From 7bd27cded0ac6a4728945fceb4058c9f8475ed26 Mon Sep 17 00:00:00 2001 From: shimon Date: Wed, 25 Jun 2025 20:06:38 +0300 Subject: [PATCH 01/34] re-adding mongodb adapter --- Dockerfile | 12 +- composer.json | 7 +- composer.lock | 454 +++- docker-compose.yml | 27 + phpunit.xml | 2 +- src/Database/Adapter.php | 4 + src/Database/Adapter/Mongo.php | 2081 +++++++++++++++++ src/Database/Database.php | 13 +- tests/e2e/Adapter/MongoDBTest.php | 108 + tests/e2e/Adapter/Scopes/CollectionTests.php | 10 +- .../e2e/Adapter/SharedTables/MongoDBTest.php | 111 + 11 files changed, 2716 insertions(+), 113 deletions(-) create mode 100644 src/Database/Adapter/Mongo.php create mode 100644 tests/e2e/Adapter/MongoDBTest.php create mode 100644 tests/e2e/Adapter/SharedTables/MongoDBTest.php diff --git a/Dockerfile b/Dockerfile index 381e801f7..8f1621c47 100755 --- a/Dockerfile +++ b/Dockerfile @@ -20,9 +20,7 @@ ENV PHP_REDIS_VERSION="6.0.2" \ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -RUN \ - apk update \ - && apk add --no-cache \ +RUN apk update && apk add --no-cache \ postgresql-libs \ postgresql-dev \ make \ @@ -35,9 +33,11 @@ RUN \ linux-headers \ docker-cli \ docker-cli-compose \ - && docker-php-ext-install opcache pgsql pdo_mysql pdo_pgsql \ - && apk del postgresql-dev \ - && rm -rf /var/cache/apk/* + && pecl install mongodb-1.17.0 \ + && docker-php-ext-enable mongodb \ + && docker-php-ext-install opcache pgsql pdo_mysql pdo_pgsql \ + && apk del postgresql-dev \ + && rm -rf /var/cache/apk/* # Redis Extension FROM compile AS redis diff --git a/composer.json b/composer.json index 4a0fecbd2..a868fb153 100755 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "ext-mbstring": "*", "utopia-php/framework": "0.33.*", "utopia-php/cache": "0.13.*", - "utopia-php/pools": "0.8.*" + "utopia-php/pools": "0.8.*", + "utopia-php/mongo": "0.3.*" }, "require-dev": { "fakerphp/faker": "1.23.*", @@ -52,7 +53,9 @@ }, "suggests": { "ext-redis": "Needed to support Redis Cache Adapter", - "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter" + "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", + "mongodb/mongodb": "Needed to support MongoDB Database Adapter" + }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 774cd790d..75c91f705 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5a68454fa54e1d31deef8571953a3da3", + "content-hash": "ce968cc79ace7935a265cdfddd0abffc", "packages": [ { "name": "brick/math", - "version": "0.12.3", + "version": "0.13.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.13.1" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2025-03-29T13:50:30+00:00" }, { "name": "composer/semver", @@ -149,16 +149,16 @@ }, { "name": "google/protobuf", - "version": "v4.30.2", + "version": "v4.31.1", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "a4c4d8565b40b9f76debc9dfeb221412eacb8ced" + "reference": "2b028ce8876254e2acbeceea7d9b573faad41864" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/a4c4d8565b40b9f76debc9dfeb221412eacb8ced", - "reference": "a4c4d8565b40b9f76debc9dfeb221412eacb8ced", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/2b028ce8876254e2acbeceea7d9b573faad41864", + "reference": "2b028ce8876254e2acbeceea7d9b573faad41864", "shasum": "" }, "require": { @@ -187,9 +187,138 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.30.2" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.31.1" }, - "time": "2025-03-26T18:01:50+00:00" + "time": "2025-05-28T18:52:35+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "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": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "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.1" + }, + "time": "2025-03-19T14:43:43+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" }, { "name": "nyholm/psr7", @@ -466,16 +595,16 @@ }, { "name": "open-telemetry/exporter-otlp", - "version": "1.3.0", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "19adf03d2b0f91f9e9b1c7f93db6c755c737cf6c" + "reference": "8b3ca1f86d01429c73b407bf1a2075d9c187001e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/19adf03d2b0f91f9e9b1c7f93db6c755c737cf6c", - "reference": "19adf03d2b0f91f9e9b1c7f93db6c755c737cf6c", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/8b3ca1f86d01429c73b407bf1a2075d9c187001e", + "reference": "8b3ca1f86d01429c73b407bf1a2075d9c187001e", "shasum": "" }, "require": { @@ -526,7 +655,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-05-12T00:36:35+00:00" + "time": "2025-05-21T12:02:20+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", @@ -593,16 +722,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "939d3a28395c249a763676458140dad44b3a8011" + "reference": "cd0d7367599717fc29e04eb8838ec061e6c2c657" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/939d3a28395c249a763676458140dad44b3a8011", - "reference": "939d3a28395c249a763676458140dad44b3a8011", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/cd0d7367599717fc29e04eb8838ec061e6c2c657", + "reference": "cd0d7367599717fc29e04eb8838ec061e6c2c657", "shasum": "" }, "require": { @@ -679,7 +808,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-05-07T12:32:21+00:00" + "time": "2025-05-22T02:33:34+00:00" }, { "name": "open-telemetry/sem-conv", @@ -1158,20 +1287,20 @@ }, { "name": "ramsey/uuid", - "version": "4.7.6", + "version": "4.8.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088" + "reference": "fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28", + "reference": "fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", "ext-json": "*", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" @@ -1180,26 +1309,23 @@ "rhumsaa/uuid": "self.version" }, "require-dev": { - "captainhook/captainhook": "^5.10", + "captainhook/captainhook": "^5.25", "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "doctrine/annotations": "^1.8", - "ergebnis/composer-normalize": "^2.15", - "mockery/mockery": "^1.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.2", - "php-mock/php-mock-mockery": "^1.3", - "php-parallel-lint/php-parallel-lint": "^1.1", - "phpbench/phpbench": "^1.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^8.5 || ^9", - "ramsey/composer-repl": "^1.4", - "slevomat/coding-standard": "^8.4", - "squizlabs/php_codesniffer": "^3.5", - "vimeo/psalm": "^4.9" + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" }, "suggest": { "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", @@ -1234,32 +1360,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.7.6" + "source": "https://github.com/ramsey/uuid/tree/4.8.1" }, - "funding": [ - { - "url": "https://github.com/ramsey", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", - "type": "tidelift" - } - ], - "time": "2024-04-27T21:32:50+00:00" + "time": "2025-06-01T06:28:46+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -1272,7 +1388,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1297,7 +1413,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -1313,20 +1429,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/http-client", - "version": "v7.2.4", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6" + "reference": "57e4fb86314015a695a750ace358d07a7e37b8a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", - "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", + "url": "https://api.github.com/repos/symfony/http-client/zipball/57e4fb86314015a695a750ace358d07a7e37b8a9", + "reference": "57e4fb86314015a695a750ace358d07a7e37b8a9", "shasum": "" }, "require": { @@ -1392,7 +1508,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.2.4" + "source": "https://github.com/symfony/http-client/tree/v7.3.0" }, "funding": [ { @@ -1408,20 +1524,20 @@ "type": "tidelift" } ], - "time": "2025-02-13T10:27:23+00:00" + "time": "2025-05-02T08:23:16+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.5.2", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645" + "reference": "75d7043853a42837e68111812f4d964b01e5101c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645", - "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", "shasum": "" }, "require": { @@ -1434,7 +1550,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1470,7 +1586,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" }, "funding": [ { @@ -1486,7 +1602,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T08:49:48+00:00" + "time": "2025-04-29T11:18:49+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -1569,6 +1685,86 @@ ], "time": "2024-12-23T08:48:59+00:00" }, + { + "name": "symfony/polyfill-php80", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "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.32.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": "2025-01-02T08:10:11+00:00" + }, { "name": "symfony/polyfill-php82", "version": "v1.32.0", @@ -1647,16 +1843,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -1674,7 +1870,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1710,7 +1906,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -1726,7 +1922,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "tbachert/spi", @@ -1880,16 +2076,16 @@ }, { "name": "utopia-php/framework", - "version": "0.33.19", + "version": "0.33.20", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "64c7b7bb8a8595ffe875fa8d4b7705684dbf46c0" + "reference": "e1c7ab4e0b5b0a9a70256b1e00912e101e76a131" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/64c7b7bb8a8595ffe875fa8d4b7705684dbf46c0", - "reference": "64c7b7bb8a8595ffe875fa8d4b7705684dbf46c0", + "url": "https://api.github.com/repos/utopia-php/http/zipball/e1c7ab4e0b5b0a9a70256b1e00912e101e76a131", + "reference": "e1c7ab4e0b5b0a9a70256b1e00912e101e76a131", "shasum": "" }, "require": { @@ -1921,9 +2117,69 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.19" + "source": "https://github.com/utopia-php/http/tree/0.33.20" + }, + "time": "2025-05-18T23:51:21+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": "2025-03-06T11:37:49+00:00" + "time": "2023-09-01T17:25:28+00:00" }, { "name": "utopia-php/pools", @@ -2498,16 +2754,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.25", + "version": "1.12.27", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "e310849a19e02b8bfcbb63147f495d8f872dd96f" + "reference": "3a6e423c076ab39dfedc307e2ac627ef579db162" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e310849a19e02b8bfcbb63147f495d8f872dd96f", - "reference": "e310849a19e02b8bfcbb63147f495d8f872dd96f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/3a6e423c076ab39dfedc307e2ac627ef579db162", + "reference": "3a6e423c076ab39dfedc307e2ac627ef579db162", "shasum": "" }, "require": { @@ -2552,7 +2808,7 @@ "type": "github" } ], - "time": "2025-04-27T12:20:45+00:00" + "time": "2025-05-21T20:51:45+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4131,7 +4387,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4139,6 +4395,6 @@ "ext-pdo": "*", "ext-mbstring": "*" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.2.0" } diff --git a/docker-compose.yml b/docker-compose.yml index e7861f69e..1af22637e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: - ./dev/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini - /var/run/docker.sock:/var/run/docker.sock - ./docker-compose.yml:/usr/src/code/docker-compose.yml + - ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php adminer: image: adminer @@ -71,6 +72,32 @@ services: environment: - MYSQL_ROOT_PASSWORD=password + mongo: + image: mongo:latest + container_name: utopia-mongo + networks: + - database + ports: + - "9706:27017" + environment: + MONGO_INITDB_DATABASE: utopia_testing + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: password + + mongo-express: + image: mongo-express + container_name: mongo-express + networks: + - database + ports: + - "8081:8081" + environment: + ME_CONFIG_MONGODB_SERVER: mongo + ME_CONFIG_MONGODB_ADMINUSERNAME: root + ME_CONFIG_MONGODB_ADMINPASSWORD: password + ME_CONFIG_BASICAUTH_USERNAME: admin + ME_CONFIG_BASICAUTH_PASSWORD: admin + mysql: image: mysql:8.0.41 container_name: utopia-mysql diff --git a/phpunit.xml b/phpunit.xml index 2a0531cfd..34365d48d 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,7 +7,7 @@ convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" - stopOnFailure="false" + stopOnFailure="true" > diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 88fd7d64f..3d59e3744 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -374,8 +374,12 @@ public function withTransaction(callable $callback): mixed for ($attempts = 0; $attempts < 3; $attempts++) { try { $this->startTransaction(); + //var_dump($attempts); $result = $callback(); + //var_dump($result); + $this->commitTransaction(); + return $result; } catch (\Throwable $action) { try { diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php new file mode 100644 index 000000000..b205d60f2 --- /dev/null +++ b/src/Database/Adapter/Mongo.php @@ -0,0 +1,2081 @@ + + */ + 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; + } + + /** + * Create Attributes + * + * @param string $collection + * @param array> $attributes + * @return bool + * @throws DatabaseException + */ + public function createAttributes(string $collection, array $attributes): 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->getSequence() . '_' . $relatedCollection->getSequence()); + + 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) && + $type !== Database::INDEX_FULLTEXT) { + //$options['collation'] = $collation; + $indexes['collation'] = [ + 'locale' => 'en', + 'strength' => 1, + ]; + } + + 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; + //var_dump($result); + if (empty($result)) { + return new Document([]); + } + + $result = $this->replaceChars('_', '$', (array)$result[0]); + $result = $this->timeToDocument($result); + + return new Document($result); + } +public static $count = 0; + /** + * 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); + + if($collection === "_metadata" && $document->getId() === "actors"){ + //$backtrace = debug_backtrace(); + //var_dump($backtrace[2]['function']); + //var_dump(self::$count); + //var_dump($document); + self::$count++; + } + + $sequence = $document->getSequence(); + + $document->removeAttribute('$sequence'); + + 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($sequence)) { + $record['_id'] = $sequence; + } + + $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 = []; + $hasSequence = null; + $documents = array_map(fn ($doc) => clone $doc, $documents); + + foreach ($documents as $document) { + $sequence = $document->getSequence(); + + if ($hasSequence === null) { + $hasSequence = !empty($sequence); + } elseif ($hasSequence == empty($sequence)) { + throw new DatabaseException('All documents must have an sequence if one is set'); + } + + $document->removeAttribute('$sequence'); + + if ($this->sharedTables) { + $document->setAttribute('$tenant', (string)$this->getTenant()); + } + + $record = $this->replaceChars('$', '_', (array)$document); + $record = $this->timeToMongo($record); + + if (!empty($sequence)) { + $record['_id'] = $sequence; + } + + $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 { + $bla = $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]; + //var_dump($name); + //var_dump($filters); + //var_dump($result); + 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 $sequences, array $permissionIds): int + { + $name = $this->getNamespace() . '_' . $this->filter($collection); + + $filters = $this->buildFilters([new Query(Query::TYPE_EQUAL, '_id', $sequences)]); + + 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 == 'sequence' ? '_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['$sequence']) + ] + ]); + } + // 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"); + } + + $orderOperatorSequence = 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; + $orderOperatorSequence = $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($orderOperatorSequence) => new ObjectId($cursor['$sequence']) + ] + ], + ]; + + $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['$sequence'] = (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'] = (string)$array['_tenant']; + unset($result['_tenant']); + } + } elseif ($from === '$') { + if (array_key_exists('$id', $array)) { + $result['_uid'] = $array['$id']; + unset($result['$id']); + } + if (array_key_exists('$sequence', $array)) { + $result['_id'] = new ObjectId($array['$sequence']); + unset($result['$sequence']); + } + if (array_key_exists('$tenant', $array)) { + $result['_tenant'] = (string)$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() === '$sequence') { + $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 hostname supported? + * + * @return bool + */ + public function getSupportForHostname(): bool + { + return true; + } + + /** + * 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; + } + + public function getSupportForBatchCreateAttributes(): bool + { + return true; + } + + /** + * Get current attribute count from collection document + * + * @param Document $collection + * @return int + */ + public function getCountOfAttributes(Document $collection): int + { + $attributes = \count($collection->getAttribute('attributes') ?? []); + + return $attributes + 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 function getCountOfDefaultAttributes(): int + { + return \count(Database::INTERNAL_ATTRIBUTES); + } + + /** + * Returns number of indexes used by default. + * + * @return int + */ + public function getCountOfDefaultIndexes(): int + { + return \count(Database::INTERNAL_INDEXES); + } + + /** + * Get maximum width, in bytes, allowed for a SQL row + * Return 0 when no restrictions apply + * + * @return int + */ + public function getDocumentSizeLimit(): int + { + return 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; + } + + protected function quote(string $string): string + { + return ""; + } + + /** + * @param mixed $stmt + * @return bool + */ + protected function execute(mixed $stmt): bool + { + return true; + } + + /** + * @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/src/Database/Database.php b/src/Database/Database.php index 6d55e5f17..28c7b6778 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1217,7 +1217,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $collection = $this->silent(fn () => $this->getCollection($id)); if (!$collection->isEmpty() && $id !== self::METADATA) { - throw new DuplicateException('Collection ' . $id . ' already exists'); + DuplicateException('Collection ' . $id . ' already exists'); } /** @@ -1299,9 +1299,12 @@ public function createCollection(string $id, array $attributes = [], array $inde throw new LimitException('Document size limit of ' . $this->adapter->getDocumentSizeLimit() . ' exceeded. Cannot create collection.'); } } - try { + $this->adapter->createCollection($id, $attributes, $indexes); + + + } catch (DuplicateException $e) { // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { @@ -1313,6 +1316,7 @@ public function createCollection(string $id, array $attributes = [], array $inde return new Document(self::COLLECTION); } + $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); @@ -3612,6 +3616,9 @@ public function createDocument(string $collection, Document $document): Document $time = DateTime::now(); + + + $createdAt = $document->getCreatedAt(); $updatedAt = $document->getUpdatedAt(); @@ -3786,6 +3793,7 @@ private function createDocumentRelationships(Document $collection, Document $doc $stackCount = count($this->relationshipWriteStack); + foreach ($relationships as $relationship) { $key = $relationship['key']; $value = $document->getAttribute($key); @@ -3802,7 +3810,6 @@ private function createDocumentRelationships(Document $collection, Document $doc } $this->relationshipWriteStack[] = $collection->getId(); - try { switch (\gettype($value)) { case 'array': diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php new file mode 100644 index 000000000..c4d33e5e7 --- /dev/null +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -0,0 +1,108 @@ +connect('redis', 6379); + $redis->flushAll(); + $cache = new Cache(new RedisAdapter($redis)); + + $schema = 'utopiaTests'; // same as $this->testDatabase + $client = new Client( + $schema, + 'mongo', + 27017, + 'root', + 'password', + 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; + } +} \ No newline at end of file diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 731525f81..6f4b0e010 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -41,6 +41,8 @@ public function testCreateExistsDelete(): void */ public function testCreateListExistsDeleteCollection(): void { + + /** @var Database $database */ $database = static::getDatabase(); @@ -48,7 +50,11 @@ public function testCreateListExistsDeleteCollection(): void Permission::create(Role::any()), Permission::read(Role::any()), ])); + $this->assertCount(1, $database->listCollections()); + + + $this->assertEquals(true, $database->exists($this->testDatabase, 'actors')); // Collection names should not be unique @@ -667,8 +673,8 @@ public function testCreateCollectionWithSchemaIndexes(): void new Document([ '$id' => ID::custom('idx_username_created_at'), 'type' => Database::INDEX_KEY, - 'attributes' => ['username'], - 'lengths' => [99], // Length not equal to attributes length + 'attributes' => ['username', 'cards'], + 'lengths' => [99,200], // Length not equal to attributes length 'orders' => [Database::ORDER_DESC], ]), ]; diff --git a/tests/e2e/Adapter/SharedTables/MongoDBTest.php b/tests/e2e/Adapter/SharedTables/MongoDBTest.php new file mode 100644 index 000000000..ffe4bcce0 --- /dev/null +++ b/tests/e2e/Adapter/SharedTables/MongoDBTest.php @@ -0,0 +1,111 @@ +connect('redis', 6379); + $redis->flushAll(); + $cache = new Cache(new RedisAdapter($redis)); + + $schema = 'utopiaTests'; // same as $this->testDatabase + $client = new Client( + $schema, + 'mongo', + 27017, + 'root', + 'password', + 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; + } +} \ No newline at end of file From 865b5085a0f10cadd090e98847d97daec8f7a8b0 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 29 Jun 2025 13:25:02 +0300 Subject: [PATCH 02/34] re-adding mongodb adapter --- src/Database/Adapter/Mongo.php | 67 ++++++++++++++++---- src/Database/Database.php | 13 +--- tests/e2e/Adapter/Scopes/CollectionTests.php | 13 ++-- tests/e2e/Adapter/Scopes/DocumentTests.php | 13 +++- 4 files changed, 74 insertions(+), 32 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index b205d60f2..a7bc1c6a7 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -11,6 +11,7 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Exception\Timeout; use Utopia\Database\Query; @@ -618,6 +619,13 @@ public function createIndex(string $collection, string $id, string $type, array } } + /** + * Collation + * .1 Moved under $indexes. + * .2 Updated format. + * .3 Avoid adding collation to fulltext index + */ + if (!empty($collation) && $type !== Database::INDEX_FULLTEXT) { //$options['collation'] = $collation; @@ -728,7 +736,7 @@ public function getDocument(string $collection, string $id, array $queries = [], return new Document($result); } -public static $count = 0; +//public static $count = 0; /** * Create Document * @@ -748,7 +756,7 @@ public function createDocument(string $collection, Document $document): Document //var_dump($backtrace[2]['function']); //var_dump(self::$count); //var_dump($document); - self::$count++; + //self::$count++; } $sequence = $document->getSequence(); @@ -1078,6 +1086,26 @@ public function updateAttribute(string $collection, string $id, string $type, in return true; } + /** + * TODO Consider moving this to adapter.php + * @param string $attribute + * @return string + */ + protected function getInternalKeyForAttribute(string $attribute): string + { + return match ($attribute) { + '$id' => '_uid', + '$sequence' => '_id', + '$collection' => '_collection', + '$tenant' => '_tenant', + '$createdAt' => '_createdAt', + '$updatedAt' => '_updatedAt', + '$permissions' => '_permissions', + default => $attribute + }; + } + + /** * Find Documents * @@ -1099,6 +1127,7 @@ public function updateAttribute(string $collection, string $id, string $type, in */ 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); @@ -1132,24 +1161,31 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $options['projection'] = $this->getAttributeProjection($selections); } + $hasIdAttribute = false; + // orders foreach ($orderAttributes as $i => $attribute) { + $originalAttribute = $attribute; + $attribute = $this->getInternalKeyForAttribute($attribute); $attribute = $this->filter($attribute); + $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); + if (\in_array($attribute, ['_uid', '_id'])) { + $hasIdAttribute = true; + } + if ($cursorDirection === Database::CURSOR_BEFORE) { $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; } - $attribute = $attribute == 'id' ? '_uid' : $attribute; - $attribute = $attribute == 'sequence' ? '_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); + if(!$hasIdAttribute) { + $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); + } // queries @@ -1167,6 +1203,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, ] ]); } + // Allow order type without any order attribute, fallback to the natural order (_id) if (!empty($orderTypes)) { $orderType = $this->filter($orderTypes[0] ?? Database::ORDER_ASC); @@ -1175,16 +1212,20 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } $options['sort']['_id'] = $this->getOrder($orderType); + } } - if (!empty($cursor) && !empty($orderAttributes) && array_key_exists(0, $orderAttributes)) { + if (!empty($cursor) && !empty($orderAttributes)) { $attribute = $orderAttributes[0]; if (is_null($cursor[$attribute] ?? null)) { throw new DatabaseException("Order attribute '{$attribute}' is empty"); } + $attribute = $this->getInternalKeyForAttribute($attribute); + $attribute = $this->filter($attribute); + $orderOperatorSequence = Query::TYPE_GREATER; $orderType = $this->filter($orderTypes[0] ?? Database::ORDER_ASC); $orderOperator = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; @@ -1198,11 +1239,11 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $cursorFilters = [ [ $attribute => [ - $this->getQueryOperator($orderOperator) => $cursor[$attribute] + $this->getQueryOperator($orderOperator) => $cursor[$originalAttribute] ] ], [ - $attribute => $cursor[$attribute], + $attribute => $cursor[$originalAttribute], '_id' => [ $this->getQueryOperator($orderOperatorSequence) => new ObjectId($cursor['$sequence']) ] @@ -1222,7 +1263,9 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $found = []; try { + $results = $this->client->find($name, $filters, $options)->cursor->firstBatch ?? []; + } catch (MongoException $e) { throw $this->processException($e); } @@ -2056,7 +2099,7 @@ protected function execute(mixed $stmt): bool */ public function getMaxIndexLength(): int { - return 0; + return 1024; } public function getConnectionId(): string diff --git a/src/Database/Database.php b/src/Database/Database.php index 28c7b6778..6d55e5f17 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1217,7 +1217,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $collection = $this->silent(fn () => $this->getCollection($id)); if (!$collection->isEmpty() && $id !== self::METADATA) { - DuplicateException('Collection ' . $id . ' already exists'); + throw new DuplicateException('Collection ' . $id . ' already exists'); } /** @@ -1299,12 +1299,9 @@ public function createCollection(string $id, array $attributes = [], array $inde throw new LimitException('Document size limit of ' . $this->adapter->getDocumentSizeLimit() . ' exceeded. Cannot create collection.'); } } - try { + try { $this->adapter->createCollection($id, $attributes, $indexes); - - - } catch (DuplicateException $e) { // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { @@ -1316,7 +1313,6 @@ public function createCollection(string $id, array $attributes = [], array $inde return new Document(self::COLLECTION); } - $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); @@ -3616,9 +3612,6 @@ public function createDocument(string $collection, Document $document): Document $time = DateTime::now(); - - - $createdAt = $document->getCreatedAt(); $updatedAt = $document->getUpdatedAt(); @@ -3793,7 +3786,6 @@ private function createDocumentRelationships(Document $collection, Document $doc $stackCount = count($this->relationshipWriteStack); - foreach ($relationships as $relationship) { $key = $relationship['key']; $value = $document->getAttribute($key); @@ -3810,6 +3802,7 @@ private function createDocumentRelationships(Document $collection, Document $doc } $this->relationshipWriteStack[] = $collection->getId(); + try { switch (\gettype($value)) { case 'array': diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 6f4b0e010..3f3755de0 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -41,8 +41,6 @@ public function testCreateExistsDelete(): void */ public function testCreateListExistsDeleteCollection(): void { - - /** @var Database $database */ $database = static::getDatabase(); @@ -50,11 +48,7 @@ public function testCreateListExistsDeleteCollection(): void Permission::create(Role::any()), Permission::read(Role::any()), ])); - $this->assertCount(1, $database->listCollections()); - - - $this->assertEquals(true, $database->exists($this->testDatabase, 'actors')); // Collection names should not be unique @@ -655,12 +649,15 @@ public function testCreateCollectionWithSchemaIndexes(): void ]), ]; + /** + * Update array length check to 255 + */ $indexes = [ new Document([ '$id' => ID::custom('idx_cards'), 'type' => Database::INDEX_KEY, 'attributes' => ['cards'], - 'lengths' => [500], // Will be changed to Database::ARRAY_INDEX_LENGTH (255) + 'lengths' => [500], 'orders' => [Database::ORDER_DESC], ]), new Document([ @@ -674,7 +671,7 @@ public function testCreateCollectionWithSchemaIndexes(): void '$id' => ID::custom('idx_username_created_at'), 'type' => Database::INDEX_KEY, 'attributes' => ['username', 'cards'], - 'lengths' => [99,200], // Length not equal to attributes length + 'lengths' => [99, 255], // Length not equal to attributes length 'orders' => [Database::ORDER_DESC], ]), ]; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 3fdbb87d7..39c897c42 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1405,7 +1405,7 @@ public function testFindBasicChecks(): void ]); $this->assertEquals($firstDocumentId, $documents[0]->getId()); - /** + /** * Check internal numeric ID sorting */ $documents = $database->find('movies', [ @@ -1413,6 +1413,13 @@ public function testFindBasicChecks(): void Query::offset(0), Query::orderDesc(''), ]); + +// foreach ($documents as $document) { +// var_dump($document->getAttribute('name')); +// } +// +// exit; + //var_dump($movieDocuments); $this->assertEquals($movieDocuments[\count($movieDocuments) - 1]->getId(), $documents[0]->getId()); $documents = $database->find('movies', [ Query::limit(25), @@ -1978,10 +1985,12 @@ public function testFindOrderByAfterNaturalOrder(): void Query::orderDesc(''), Query::cursorAfter($movies[1]) ]); + //var_dump($movieDocuments); + $this->assertEquals(2, count($documents)); $this->assertEquals($movies[2]['name'], $documents[0]['name']); $this->assertEquals($movies[3]['name'], $documents[1]['name']); - + exit; $documents = $database->find('movies', [ Query::limit(2), Query::offset(0), From 95e0ea62ce2985d7600fb9208ca57e7872c2401f Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 30 Jun 2025 11:20:19 +0300 Subject: [PATCH 03/34] re-adding mongodb adapter --- src/Database/Adapter/Mongo.php | 90 +++++++--------------- tests/e2e/Adapter/Scopes/DocumentTests.php | 4 +- 2 files changed, 29 insertions(+), 65 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index a7bc1c6a7..59f083687 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1127,7 +1127,6 @@ protected function getInternalKeyForAttribute(string $attribute): string */ 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); @@ -1140,7 +1139,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, // permissions if (Authorization::$status) { $roles = \implode('|', Authorization::getRoles()); - $filters['_permissions']['$in'] = [new Regex("{$forPermission}\(\".*(?:{$roles}).*\"\)", 'i')]; + $filters['_permissions']['$in'] = [new Regex("{$forPermission}\\(\".*(?:{$roles}).*\"\\)", 'i')]; } $options = []; @@ -1156,19 +1155,16 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } $selections = $this->getAttributeSelections($queries); - if (!empty($selections) && !\in_array('*', $selections)) { $options['projection'] = $this->getAttributeProjection($selections); } $hasIdAttribute = false; - // orders foreach ($orderAttributes as $i => $attribute) { $originalAttribute = $attribute; $attribute = $this->getInternalKeyForAttribute($attribute); $attribute = $this->filter($attribute); - $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); if (\in_array($attribute, ['_uid', '_id'])) { @@ -1180,92 +1176,58 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } $options['sort'][$attribute] = $this->getOrder($orderType); - } - if(!$hasIdAttribute) { + if (!$hasIdAttribute) { $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['$sequence']) - ] - ]); - } - - // 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); - - } - } - + // Compound cursor logic if (!empty($cursor) && !empty($orderAttributes)) { - $attribute = $orderAttributes[0]; - - if (is_null($cursor[$attribute] ?? null)) { - throw new DatabaseException("Order attribute '{$attribute}' is empty"); - } - - $attribute = $this->getInternalKeyForAttribute($attribute); + $attribute = $this->getInternalKeyForAttribute($orderAttributes[0]); $attribute = $this->filter($attribute); + $orderType = $orderTypes[0] ?? Database::ORDER_ASC; - $orderOperatorSequence = Query::TYPE_GREATER; - $orderType = $this->filter($orderTypes[0] ?? Database::ORDER_ASC); - $orderOperator = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; + $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); - if ($cursorDirection === Database::CURSOR_BEFORE) { - $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; - $orderOperatorSequence = $orderType === Database::ORDER_ASC ? Query::TYPE_LESSER : Query::TYPE_GREATER; - $orderOperator = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; - } + $sequenceOperator = $cursorDirection === Database::CURSOR_AFTER + ? ($orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER) + : ($orderType === Database::ORDER_DESC ? Query::TYPE_GREATER : Query::TYPE_LESSER); - $cursorFilters = [ + $filters['$or'] = [ [ $attribute => [ - $this->getQueryOperator($orderOperator) => $cursor[$originalAttribute] + $this->getQueryOperator($orderOperator) => $cursor[$orderAttributes[0]] ] ], [ - $attribute => $cursor[$originalAttribute], + $attribute => $cursor[$orderAttributes[0]], '_id' => [ - $this->getQueryOperator($orderOperatorSequence) => new ObjectId($cursor['$sequence']) + $this->getQueryOperator($sequenceOperator) => new ObjectId($cursor['$sequence']) ] - ], + ] ]; - - $filters = [ - '$and' => [$filters, ['$or' => $cursorFilters]] + } elseif (!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['_id'] = [ + $this->getQueryOperator($orderOperator) => new ObjectId($cursor['$sequence']) ]; } + // Translate operators and handle time filters $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); } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 39c897c42..767f95ead 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1990,7 +1990,7 @@ public function testFindOrderByAfterNaturalOrder(): void $this->assertEquals(2, count($documents)); $this->assertEquals($movies[2]['name'], $documents[0]['name']); $this->assertEquals($movies[3]['name'], $documents[1]['name']); - exit; + $documents = $database->find('movies', [ Query::limit(2), Query::offset(0), @@ -2016,6 +2016,8 @@ public function testFindOrderByAfterNaturalOrder(): void Query::orderDesc(''), Query::cursorAfter($movies[5]) ]); + var_dump(count($documents)); + exit; $this->assertEmpty(count($documents)); } public function testFindOrderByBeforeNaturalOrder(): void From 95220c1b58d00063d369446d8383661d7b467921 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 30 Jun 2025 12:27:01 +0300 Subject: [PATCH 04/34] pull cursor --- composer.lock | 91 +++++++++++++++++--------------- src/Database/Adapter/MariaDB.php | 90 +++++++++++-------------------- src/Database/Database.php | 26 ++++++++- 3 files changed, 102 insertions(+), 105 deletions(-) diff --git a/composer.lock b/composer.lock index 75c91f705..989c60a80 100644 --- a/composer.lock +++ b/composer.lock @@ -466,16 +466,16 @@ }, { "name": "open-telemetry/api", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "4e3bb38e069876fb73c2ce85c89583bf2b28cd86" + "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/4e3bb38e069876fb73c2ce85c89583bf2b28cd86", - "reference": "4e3bb38e069876fb73c2ce85c89583bf2b28cd86", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", + "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", "shasum": "" }, "require": { @@ -495,7 +495,7 @@ ] }, "branch-alias": { - "dev-main": "1.1.x-dev" + "dev-main": "1.4.x-dev" } }, "autoload": { @@ -532,7 +532,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-05-07T12:32:21+00:00" + "time": "2025-06-19T23:36:51+00:00" }, { "name": "open-telemetry/context", @@ -595,16 +595,16 @@ }, { "name": "open-telemetry/exporter-otlp", - "version": "1.3.1", + "version": "1.3.2", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "8b3ca1f86d01429c73b407bf1a2075d9c187001e" + "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/8b3ca1f86d01429c73b407bf1a2075d9c187001e", - "reference": "8b3ca1f86d01429c73b407bf1a2075d9c187001e", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", + "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", "shasum": "" }, "require": { @@ -655,7 +655,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-05-21T12:02:20+00:00" + "time": "2025-06-16T00:24:51+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", @@ -722,22 +722,22 @@ }, { "name": "open-telemetry/sdk", - "version": "1.5.0", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "cd0d7367599717fc29e04eb8838ec061e6c2c657" + "reference": "1c0371794e4c0700afd4a9d4d8511cb5e3f78e6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/cd0d7367599717fc29e04eb8838ec061e6c2c657", - "reference": "cd0d7367599717fc29e04eb8838ec061e6c2c657", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/1c0371794e4c0700afd4a9d4d8511cb5e3f78e6a", + "reference": "1c0371794e4c0700afd4a9d4d8511cb5e3f78e6a", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "~1.0 || ~1.1", + "open-telemetry/api": "~1.4.0", "open-telemetry/context": "^1.0", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", @@ -760,6 +760,10 @@ "type": "library", "extra": { "spi": { + "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig" + ], "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" ] @@ -808,20 +812,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-05-22T02:33:34+00:00" + "time": "2025-06-19T23:36:51+00:00" }, { "name": "open-telemetry/sem-conv", - "version": "1.32.0", + "version": "1.32.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sem-conv.git", - "reference": "16585cc0dbc3032a318e274043454679430d2ebf" + "reference": "94daa85ea61a8e2b7e1b0af6be0e875bedda7c22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/16585cc0dbc3032a318e274043454679430d2ebf", - "reference": "16585cc0dbc3032a318e274043454679430d2ebf", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/94daa85ea61a8e2b7e1b0af6be0e875bedda7c22", + "reference": "94daa85ea61a8e2b7e1b0af6be0e875bedda7c22", "shasum": "" }, "require": { @@ -865,7 +869,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-05-05T03:58:53+00:00" + "time": "2025-06-24T02:32:27+00:00" }, { "name": "php-http/discovery", @@ -1287,21 +1291,20 @@ }, { "name": "ramsey/uuid", - "version": "4.8.1", + "version": "4.9.0", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28" + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28", - "reference": "fdf4dd4e2ff1813111bd0ad58d7a1ddbb5b56c28", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", "shasum": "" }, "require": { "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", - "ext-json": "*", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -1360,9 +1363,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.8.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.0" }, - "time": "2025-06-01T06:28:46+00:00" + "time": "2025-06-25T14:20:11+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1433,16 +1436,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "57e4fb86314015a695a750ace358d07a7e37b8a9" + "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/57e4fb86314015a695a750ace358d07a7e37b8a9", - "reference": "57e4fb86314015a695a750ace358d07a7e37b8a9", + "url": "https://api.github.com/repos/symfony/http-client/zipball/4403d87a2c16f33345dca93407a8714ee8c05a64", + "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64", "shasum": "" }, "require": { @@ -1454,6 +1457,7 @@ }, "conflict": { "amphp/amp": "<2.5", + "amphp/socket": "<1.1", "php-http/discovery": "<1.15", "symfony/http-foundation": "<6.4" }, @@ -1466,7 +1470,6 @@ "require-dev": { "amphp/http-client": "^4.2.1|^5.0", "amphp/http-tunnel": "^1.0|^2.0", - "amphp/socket": "^1.1", "guzzlehttp/promises": "^1.4|^2.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", @@ -1508,7 +1511,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.0" + "source": "https://github.com/symfony/http-client/tree/v7.3.1" }, "funding": [ { @@ -1524,7 +1527,7 @@ "type": "tidelift" } ], - "time": "2025-05-02T08:23:16+00:00" + "time": "2025-06-28T07:58:39+00:00" }, { "name": "symfony/http-client-contracts", @@ -1926,16 +1929,16 @@ }, { "name": "tbachert/spi", - "version": "v1.0.3", + "version": "v1.0.4", "source": { "type": "git", "url": "https://github.com/Nevay/spi.git", - "reference": "506a79c98e1a51522e76ee921ccb6c62d52faf3a" + "reference": "86e355edfdd57f9cb720bd2ac3af7dde521ca0e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Nevay/spi/zipball/506a79c98e1a51522e76ee921ccb6c62d52faf3a", - "reference": "506a79c98e1a51522e76ee921ccb6c62d52faf3a", + "url": "https://api.github.com/repos/Nevay/spi/zipball/86e355edfdd57f9cb720bd2ac3af7dde521ca0e7", + "reference": "86e355edfdd57f9cb720bd2ac3af7dde521ca0e7", "shasum": "" }, "require": { @@ -1953,7 +1956,7 @@ "extra": { "class": "Nevay\\SPI\\Composer\\Plugin", "branch-alias": { - "dev-main": "0.2.x-dev" + "dev-main": "1.0.x-dev" }, "plugin-optional": true }, @@ -1972,9 +1975,9 @@ ], "support": { "issues": "https://github.com/Nevay/spi/issues", - "source": "https://github.com/Nevay/spi/tree/v1.0.3" + "source": "https://github.com/Nevay/spi/tree/v1.0.4" }, - "time": "2025-04-02T19:38:14+00:00" + "time": "2025-06-28T20:18:22+00:00" }, { "name": "utopia-php/cache", @@ -4396,5 +4399,5 @@ "ext-mbstring": "*" }, "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index bab2eb267..f30afc864 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1517,84 +1517,56 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $queries = array_map(fn ($query) => clone $query, $queries); - $hasIdAttribute = false; - foreach ($orderAttributes as $i => $attribute) { - $originalAttribute = $attribute; + $cursorWhere = []; - $attribute = $this->getInternalKeyForAttribute($attribute); + foreach ($orderAttributes as $i => $originalAttribute) { + $attribute = $this->getInternalKeyForAttribute($originalAttribute); $attribute = $this->filter($attribute); - if (\in_array($attribute, ['_uid', '_id'])) { - $hasIdAttribute = true; - } $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); + $direction = $orderType; - // Get most dominant/first order attribute - if ($i === 0 && !empty($cursor)) { - $orderMethodSequence = Query::TYPE_GREATER; // To preserve natural order - $orderMethod = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; + if ($cursorDirection === Database::CURSOR_BEFORE) { + $direction = ($direction === Database::ORDER_ASC) + ? Database::ORDER_DESC + : Database::ORDER_ASC; + } - if ($cursorDirection === Database::CURSOR_BEFORE) { - $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; - $orderMethodSequence = $orderType === Database::ORDER_ASC ? Query::TYPE_LESSER : Query::TYPE_GREATER; - $orderMethod = $orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER; - } + $orders[] = "{$this->quote($attribute)} {$direction}"; - if (\is_null($cursor[$originalAttribute] ?? null)) { - throw new OrderException( - message: "Order attribute '{$originalAttribute}' is empty", - attribute: $originalAttribute - ); - } + // Build pagination WHERE clause only if we have a cursor + if (!empty($cursor)) { + $conditions = []; - $binds[':cursor'] = $cursor[$originalAttribute]; - - $where[] = "( - {$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($orderMethod)} :cursor - OR ( - {$this->quote($alias)}.{$this->quote($attribute)} = :cursor - AND - {$this->quote($alias)}._id {$this->getSQLOperator($orderMethodSequence)} {$cursor['$sequence']} - ) - )"; - } elseif ($cursorDirection === Database::CURSOR_BEFORE) { - $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; - } + // Add equality conditions for previous attributes + for ($j = 0; $j < $i; $j++) { + $prevOriginal = $orderAttributes[$j]; + $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal)); - $orders[] = "{$this->quote($attribute)} {$orderType}"; - } + $bindName = ":cursor_{$j}"; + $binds[$bindName] = $cursor[$prevOriginal]; - // Allow after pagination without any order - if (empty($orderAttributes) && !empty($cursor)) { - $orderType = $orderTypes[0] ?? Database::ORDER_ASC; + $conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}"; + } - if ($cursorDirection === Database::CURSOR_AFTER) { - $orderMethod = $orderType === Database::ORDER_DESC + // Add comparison for current attribute + $operator = ($direction === Database::ORDER_DESC) ? Query::TYPE_LESSER : Query::TYPE_GREATER; - } else { - $orderMethod = $orderType === Database::ORDER_DESC - ? Query::TYPE_GREATER - : Query::TYPE_LESSER; - } - $where[] = "({$this->quote($alias)}._id {$this->getSQLOperator($orderMethod)} {$cursor['$sequence']})"; - } + $bindName = ":cursor_{$i}"; + $binds[$bindName] = $cursor[$originalAttribute]; - // Allow order type without any order attribute, fallback to the natural order (_id) - if (!$hasIdAttribute) { - if (empty($orderAttributes) && !empty($orderTypes)) { - $order = $orderTypes[0] ?? Database::ORDER_ASC; - if ($cursorDirection === Database::CURSOR_BEFORE) { - $order = $order === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; - } + $conditions[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; - $orders[] = "{$this->quote($alias)}._id ".$this->filter($order); - } else { - $orders[] = "{$this->quote($alias)}._id " . ($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); // Enforce last ORDER by '_id' + $cursorWhere[] = '(' . implode(' AND ', $conditions) . ')'; } } + if (!empty($cursorWhere)) { + $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; + } + $conditions = $this->getSQLConditions($queries, $binds); if (!empty($conditions)) { $where[] = $conditions; diff --git a/src/Database/Database.php b/src/Database/Database.php index 6d55e5f17..67b163b4b 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6014,7 +6014,29 @@ public function find(string $collection, array $queries = [], string $forPermiss $orderAttributes = $grouped['orderAttributes']; $orderTypes = $grouped['orderTypes']; $cursor = $grouped['cursor']; - $cursorDirection = $grouped['cursorDirection']; + $cursorDirection = $grouped['cursorDirection'] ?? Database::CURSOR_AFTER; + + $uniqueOrderBy = false; + foreach ($orderAttributes as $order) { + if ($order === '$id' || $order === '$sequence') { + $uniqueOrderBy = true; + } + } + + if ($uniqueOrderBy === false) { + $orderAttributes[] = '$sequence'; + } + + if (!empty($cursor)) { + foreach ($orderAttributes as $order) { + if ($cursor->getAttribute($order) === null) { + throw new OrderException( + message: "Order attribute '{$order}' is empty", + attribute: $order + ); + } + } + } if (!empty($cursor) && $cursor->getCollection() !== $collection->getId()) { throw new DatabaseException("cursor Document must be from the same Collection."); @@ -6082,7 +6104,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $orderAttributes, $orderTypes, $cursor, - $cursorDirection ?? Database::CURSOR_AFTER, + $cursorDirection, $forPermission ); From 1e9202d5931f2b53a5b338764c3f80e122d0dbd7 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 30 Jun 2025 14:23:23 +0300 Subject: [PATCH 05/34] Fix tests --- src/Database/Adapter/Mongo.php | 152 ++++++++++++++------ tests/e2e/Adapter/Scopes/AttributeTests.php | 1 + tests/e2e/Adapter/Scopes/DocumentTests.php | 2 +- 3 files changed, 109 insertions(+), 46 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 59f083687..9f0ab6296 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1159,67 +1159,129 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $options['projection'] = $this->getAttributeProjection($selections); } - $hasIdAttribute = false; + $orFilters = []; - foreach ($orderAttributes as $i => $attribute) { - $originalAttribute = $attribute; - $attribute = $this->getInternalKeyForAttribute($attribute); + foreach ($orderAttributes as $i => $originalAttribute) { + $attribute = $this->getInternalKeyForAttribute($originalAttribute); $attribute = $this->filter($attribute); - $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); - if (\in_array($attribute, ['_uid', '_id'])) { - $hasIdAttribute = true; - } + $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); + $direction = $orderType; if ($cursorDirection === Database::CURSOR_BEFORE) { - $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; + $direction = ($direction === Database::ORDER_ASC) + ? Database::ORDER_DESC + : Database::ORDER_ASC; } - $options['sort'][$attribute] = $this->getOrder($orderType); - } + $options['sort'][$attribute] = $this->getOrder($direction); - if (!$hasIdAttribute) { - $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); - } + if (!empty($cursor)) { + /** + * todo: make special case If we have a single order by $sequnce no need for $or + */ + $andConditions = []; - // Compound cursor logic - if (!empty($cursor) && !empty($orderAttributes)) { - $attribute = $this->getInternalKeyForAttribute($orderAttributes[0]); - $attribute = $this->filter($attribute); - $orderType = $orderTypes[0] ?? Database::ORDER_ASC; + // Equality conditions for previous fields + for ($j = 0; $j < $i; $j++) { + $originalPrev = $orderAttributes[$j]; + $prevAttr = $this->filter($this->getInternalKeyForAttribute($originalPrev)); + + $kaka = $cursor[$originalPrev]; + if($originalPrev === '$sequence'){ + $kaka = new ObjectId($kaka); + } - $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); + $andConditions[] = [ + $prevAttr => $kaka + ]; + } - $sequenceOperator = $cursorDirection === Database::CURSOR_AFTER - ? ($orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER) - : ($orderType === Database::ORDER_DESC ? Query::TYPE_GREATER : Query::TYPE_LESSER); + // Comparison for current attribute + $operator = ($direction === Database::ORDER_DESC) ? '$lt' : '$gt'; - $filters['$or'] = [ - [ + $kaka = $cursor[$originalAttribute]; + if($originalAttribute === '$sequence'){ + $kaka = new ObjectId($kaka); + } + + $andConditions[] = [ $attribute => [ - $this->getQueryOperator($orderOperator) => $cursor[$orderAttributes[0]] + $operator => $kaka ] - ], - [ - $attribute => $cursor[$orderAttributes[0]], - '_id' => [ - $this->getQueryOperator($sequenceOperator) => new ObjectId($cursor['$sequence']) - ] - ] - ]; - } elseif (!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['_id'] = [ - $this->getQueryOperator($orderOperator) => new ObjectId($cursor['$sequence']) - ]; + ]; + + $orFilters[] = [ + '$and' => $andConditions + ]; + } } + if (!empty($orFilters)) { + $filters['$or'] = $orFilters; + } + +// $hasIdAttribute = false; +// +// foreach ($orderAttributes as $i => $attribute) { +// $originalAttribute = $attribute; +// $attribute = $this->getInternalKeyForAttribute($attribute); +// $attribute = $this->filter($attribute); +// $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); +// +// if (\in_array($attribute, ['_uid', '_id'])) { +// $hasIdAttribute = true; +// } +// +// if ($cursorDirection === Database::CURSOR_BEFORE) { +// $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; +// } +// +// $options['sort'][$attribute] = $this->getOrder($orderType); +// } +// +// if (!$hasIdAttribute) { +// $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); +// } +// +// // Compound cursor logic +// if (!empty($cursor) && !empty($orderAttributes)) { +// $attribute = $this->getInternalKeyForAttribute($orderAttributes[0]); +// $attribute = $this->filter($attribute); +// $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); +// +// $sequenceOperator = $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['$or'] = [ +// [ +// $attribute => [ +// $this->getQueryOperator($orderOperator) => $cursor[$orderAttributes[0]] +// ] +// ], +// [ +// $attribute => $cursor[$orderAttributes[0]], +// '_id' => [ +// $this->getQueryOperator($sequenceOperator) => new ObjectId($cursor['$sequence']) +// ] +// ] +// ]; +// } elseif (!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['_id'] = [ +// $this->getQueryOperator($orderOperator) => new ObjectId($cursor['$sequence']) +// ]; +// } + // Translate operators and handle time filters $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); $filters = $this->timeFilter($filters); diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index fa401db2a..5c81c7a05 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1473,6 +1473,7 @@ public function testArrayAttribute(): void if ($database->getAdapter()->getMaxIndexLength() > 0) { // If getMaxIndexLength() > 0 We clear length for array attributes $database->createIndex($collection, 'indx1', Database::INDEX_KEY, ['long_size'], [], []); + $database->deleteIndex($collection, 'indx1'); $database->createIndex($collection, 'indx2', Database::INDEX_KEY, ['long_size'], [1000], []); try { diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 53ce5acf4..e276b9e42 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -2017,7 +2017,7 @@ public function testFindOrderByAfterNaturalOrder(): void Query::cursorAfter($movies[5]) ]); var_dump(count($documents)); - exit; + //exit; $this->assertEmpty(count($documents)); } public function testFindOrderByBeforeNaturalOrder(): void From 7b54377155ca0cc6cbfccae98bdc77899d0574c2 Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 30 Jun 2025 21:20:24 +0300 Subject: [PATCH 06/34] re-adding mongodb adapter --- src/Database/Adapter/Mongo.php | 39 ++++++++++++++-------- tests/e2e/Adapter/Scopes/DocumentTests.php | 3 +- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 9f0ab6296..9793800bc 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1168,46 +1168,57 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); $direction = $orderType; + /** Get sort direction ASC || DESC**/ if ($cursorDirection === Database::CURSOR_BEFORE) { $direction = ($direction === Database::ORDER_ASC) ? Database::ORDER_DESC : Database::ORDER_ASC; } + $options['sort'][$attribute] = $this->getOrder($direction); + /** Get operator sign '$lt' ? '$gt' **/ + $operator = $cursorDirection === Database::CURSOR_AFTER + ? ($orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER) + : ($orderType === Database::ORDER_DESC ? Query::TYPE_GREATER : Query::TYPE_LESSER); + + $operator = $this->getQueryOperator($operator); + if (!empty($cursor)) { - /** - * todo: make special case If we have a single order by $sequnce no need for $or - */ - $andConditions = []; - // Equality conditions for previous fields + $andConditions = []; for ($j = 0; $j < $i; $j++) { $originalPrev = $orderAttributes[$j]; $prevAttr = $this->filter($this->getInternalKeyForAttribute($originalPrev)); - $kaka = $cursor[$originalPrev]; + $tmp = $cursor[$originalPrev]; if($originalPrev === '$sequence'){ - $kaka = new ObjectId($kaka); + $tmp = new ObjectId($tmp); } $andConditions[] = [ - $prevAttr => $kaka + $prevAttr => $tmp ]; } - // Comparison for current attribute - $operator = ($direction === Database::ORDER_DESC) ? '$lt' : '$gt'; + $tmp = $cursor[$originalAttribute]; - $kaka = $cursor[$originalAttribute]; if($originalAttribute === '$sequence'){ - $kaka = new ObjectId($kaka); + $tmp = new ObjectId($tmp); + + /** If there is only $sequence attribute in $orderAttributes skip Or And operators **/ + if(count($orderAttributes) === 1){ + $filters[$attribute] = [ + $operator => $tmp + ]; + break; + } } $andConditions[] = [ $attribute => [ - $operator => $kaka + $operator => $tmp ] ]; @@ -1587,10 +1598,12 @@ protected function buildFilters(array $queries, string $separator = '$and'): arr { $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); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index e276b9e42..adb5316a8 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -2016,8 +2016,7 @@ public function testFindOrderByAfterNaturalOrder(): void Query::orderDesc(''), Query::cursorAfter($movies[5]) ]); - var_dump(count($documents)); - //exit; + $this->assertEmpty(count($documents)); } public function testFindOrderByBeforeNaturalOrder(): void From 9d5206865cf556104a8c903e00c73f302cc626fc Mon Sep 17 00:00:00 2001 From: shimon Date: Thu, 3 Jul 2025 21:57:52 +0300 Subject: [PATCH 07/34] re-adding mongodb adapter --- src/Database/Adapter/Mongo.php | 102 ++++--------------- tests/e2e/Adapter/Base.php | 2 +- tests/e2e/Adapter/Scopes/AttributeTests.php | 4 +- tests/e2e/Adapter/Scopes/CollectionTests.php | 4 + tests/e2e/Adapter/Scopes/GeneralTests.php | 11 +- 5 files changed, 33 insertions(+), 90 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 9793800bc..5d2822257 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -71,7 +71,7 @@ public function clearTimeout(string $event): void { parent::clearTimeout($event); - $this->timeout = null; + $this->timeout = 0; } public function startTransaction(): bool @@ -232,6 +232,7 @@ public function createCollection(string $name, array $attributes = [], array $in // using $i and $j as counters to distinguish from $key foreach ($indexes as $i => $index) { + $key = []; $unique = false; $attributes = $index->getAttribute('attributes'); @@ -714,7 +715,7 @@ public function getDocument(string $collection, string $id, array $queries = [], $filters = ['_uid' => $id]; if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); + $filters['_tenant'] = $this->getTenant(); } $options = []; @@ -751,20 +752,12 @@ public function createDocument(string $collection, Document $document): Document $name = $this->getNamespace() . '_' . $this->filter($collection); - if($collection === "_metadata" && $document->getId() === "actors"){ - //$backtrace = debug_backtrace(); - //var_dump($backtrace[2]['function']); - //var_dump(self::$count); - //var_dump($document); - //self::$count++; - } - $sequence = $document->getSequence(); $document->removeAttribute('$sequence'); if ($this->sharedTables) { - $document->setAttribute('$tenant', (string)$this->getTenant()); + $document->setAttribute('$tenant', $this->getTenant()); } $record = $this->replaceChars('$', '_', (array)$document); @@ -813,7 +806,7 @@ public function createDocuments(string $collection, array $documents): array $document->removeAttribute('$sequence'); if ($this->sharedTables) { - $document->setAttribute('$tenant', (string)$this->getTenant()); + $document->setAttribute('$tenant', $this->getTenant()); } $record = $this->replaceChars('$', '_', (array)$document); @@ -856,7 +849,7 @@ private function insertDocument(string $name, array $document): array $filters['_uid'] = $document['_uid']; if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); + $filters['_tenant'] = $this->getTenant(); } $result = $this->client->find( @@ -894,7 +887,7 @@ public function updateDocument(string $collection, string $id, Document $documen $filters = []; $filters['_uid'] = $id; if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); + $filters['_tenant'] = $this->getTenant(); } try { @@ -924,12 +917,13 @@ public function updateDocuments(string $collection, Document $updates, array $do $name = $this->getNamespace() . '_' . $this->filter($collection); $queries = [ - Query::equal('$id', array_map(fn ($document) => $document->getId(), $documents)) + Query::equal('$sequence', \array_map(fn ($document) => $document->getSequence(), $documents)) ]; $filters = $this->buildFilters($queries); + if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); + $filters['_tenant'] = $this->getTenant(); } $record = $updates->getArrayCopy(); @@ -981,7 +975,7 @@ public function increaseDocumentAttribute(string $collection, string $id, string $filters = ['_uid' => $id]; if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); + $filters['_tenant'] = $this->getTenant(); } if ($max) { @@ -1020,7 +1014,7 @@ public function deleteDocument(string $collection, string $id): bool $filters = []; $filters['_uid'] = $id; if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); + $filters['_tenant'] = $this->getTenant(); } $result = $this->client->delete($name, $filters); @@ -1043,7 +1037,7 @@ public function deleteDocuments(string $collection, array $sequences, array $per $filters = $this->buildFilters([new Query(Query::TYPE_EQUAL, '_id', $sequences)]); if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); + $filters['_tenant'] = $this->getTenant(); } $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); @@ -1133,7 +1127,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $filters = $this->buildFilters($queries); if ($this->sharedTables) { - $filters['_tenant'] = (string)$this->getTenant(); + $filters['_tenant'] = $this->getTenant(); } // permissions @@ -1175,7 +1169,6 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, : Database::ORDER_ASC; } - $options['sort'][$attribute] = $this->getOrder($direction); /** Get operator sign '$lt' ? '$gt' **/ @@ -1232,67 +1225,6 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $filters['$or'] = $orFilters; } -// $hasIdAttribute = false; -// -// foreach ($orderAttributes as $i => $attribute) { -// $originalAttribute = $attribute; -// $attribute = $this->getInternalKeyForAttribute($attribute); -// $attribute = $this->filter($attribute); -// $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); -// -// if (\in_array($attribute, ['_uid', '_id'])) { -// $hasIdAttribute = true; -// } -// -// if ($cursorDirection === Database::CURSOR_BEFORE) { -// $orderType = $orderType === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC; -// } -// -// $options['sort'][$attribute] = $this->getOrder($orderType); -// } -// -// if (!$hasIdAttribute) { -// $options['sort']['_id'] = $this->getOrder($cursorDirection === Database::CURSOR_AFTER ? Database::ORDER_ASC : Database::ORDER_DESC); -// } -// -// // Compound cursor logic -// if (!empty($cursor) && !empty($orderAttributes)) { -// $attribute = $this->getInternalKeyForAttribute($orderAttributes[0]); -// $attribute = $this->filter($attribute); -// $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); -// -// $sequenceOperator = $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['$or'] = [ -// [ -// $attribute => [ -// $this->getQueryOperator($orderOperator) => $cursor[$orderAttributes[0]] -// ] -// ], -// [ -// $attribute => $cursor[$orderAttributes[0]], -// '_id' => [ -// $this->getQueryOperator($sequenceOperator) => new ObjectId($cursor['$sequence']) -// ] -// ] -// ]; -// } elseif (!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['_id'] = [ -// $this->getQueryOperator($orderOperator) => new ObjectId($cursor['$sequence']) -// ]; -// } - // Translate operators and handle time filters $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); $filters = $this->timeFilter($filters); @@ -1567,7 +1499,7 @@ protected function replaceChars(string $from, string $to, array $array): array unset($result['_uid']); } if (array_key_exists('_tenant', $array)) { - $result['$tenant'] = (string)$array['_tenant']; + $result['$tenant'] = $array['_tenant']; unset($result['_tenant']); } } elseif ($from === '$') { @@ -1580,7 +1512,7 @@ protected function replaceChars(string $from, string $to, array $array): array unset($result['$sequence']); } if (array_key_exists('$tenant', $array)) { - $result['_tenant'] = (string)$array['$tenant']; + $result['_tenant'] = $array['$tenant']; unset($result['$tenant']); } } @@ -2156,6 +2088,6 @@ public function getSchemaAttributes(string $collection): array public function getTenantQuery(string $collection, string $parentAlias = ''): string { - return (string)$this->getTenant(); + return $this->getTenant(); } } diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index a57fe2748..ff38b0155 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -22,7 +22,7 @@ abstract class Base extends TestCase use AttributeTests; use IndexTests; use PermissionTests; - use RelationshipTests; + //use RelationshipTests; use GeneralTests; protected static string $namespace; diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 5c81c7a05..a28f33788 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1293,12 +1293,12 @@ public function testArrayAttribute(): void required: false, signed: false )); - + /** Is this hack valid? */ $this->assertEquals(true, $database->createAttribute( $collection, 'tv_show', Database::VAR_STRING, - size: 700, + size: $database->getAdapter()->getMaxIndexLength() - 68, /** Verify with Jake if this solution is valid? */ required: false, signed: false, )); diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 3f3755de0..20cd78c45 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -686,6 +686,10 @@ public function testCreateCollectionWithSchemaIndexes(): void ); $this->assertEquals($collection->getAttribute('indexes')[0]['attributes'][0], 'cards'); + /** + * If we set getMaxIndexLength to 1024 then this tests pass but other tests that depend on index length fail + */ + $this->assertEquals($collection->getAttribute('indexes')[0]['lengths'][0], Database::ARRAY_INDEX_LENGTH); $this->assertEquals($collection->getAttribute('indexes')[0]['orders'][0], null); diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index a5fc8f200..b138dba76 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -120,6 +120,9 @@ public function testPreserveDatesUpdate(): void 'attr1' => 'value3', ])); + + + $newDate = '2000-01-01T10:00:00.000+00:00'; $doc1->setAttribute('$updatedAt', $newDate); @@ -128,12 +131,16 @@ public function testPreserveDatesUpdate(): void $doc1 = $database->getDocument('preserve_update_dates', 'doc1'); $this->assertEquals($newDate, $doc1->getAttribute('$updatedAt')); +// var_dump([ +// '$doc2' => $doc2->getAttribute('$updatedAt'), +// '$doc3' => $doc3->getAttribute('$updatedAt'), +// ]); + $this->getDatabase()->updateDocuments( 'preserve_update_dates', new Document([ '$updatedAt' => $newDate - ]), - [ + ]), [ Query::equal('$id', [ $doc2->getId(), $doc3->getId() From f2bf9dcbc42b4d1f218321927b0943fdd9a91a97 Mon Sep 17 00:00:00 2001 From: shimon Date: Wed, 9 Jul 2025 13:34:17 +0300 Subject: [PATCH 08/34] clean up --- composer.json | 2 +- composer.lock | 255 +++++------------- src/Database/Adapter/Mongo.php | 145 +++++++++- tests/e2e/Adapter/MongoDBTest.php | 2 +- tests/e2e/Adapter/Scopes/DocumentTests.php | 29 +- tests/e2e/Adapter/Scopes/GeneralTests.php | 9 +- .../e2e/Adapter/SharedTables/MongoDBTest.php | 4 +- 7 files changed, 222 insertions(+), 224 deletions(-) diff --git a/composer.json b/composer.json index a868fb153..7300239c4 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": "0.3.*" + "utopia-php/mongo": "dev-feat-bulk-writes as 0.3.1" }, "require-dev": { "fakerphp/faker": "1.23.*", diff --git a/composer.lock b/composer.lock index 989c60a80..59db2f26b 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": "ce968cc79ace7935a265cdfddd0abffc", + "content-hash": "cd1babfd7f7750ad399c915edd6209ad", "packages": [ { "name": "brick/math", @@ -191,97 +191,40 @@ }, "time": "2025-05-28T18:52:35+00:00" }, - { - "name": "jean85/pretty-package-versions", - "version": "2.1.1", - "source": { - "type": "git", - "url": "https://github.com/Jean85/pretty-package-versions.git", - "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", - "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", - "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": "^2.0", - "phpunit/phpunit": "^7.5|^8.5|^9.6", - "rector/rector": "^2.0", - "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.1" - }, - "time": "2025-03-19T14:43:43+00:00" - }, { "name": "mongodb/mongodb", - "version": "1.10.0", + "version": "1.21.1", "source": { "type": "git", "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "b0bbd657f84219212487d01a8ffe93a789e1e488" + "reference": "37bc8df3a67ddf8380704a5ba5dbd00e92ec1f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/b0bbd657f84219212487d01a8ffe93a789e1e488", - "reference": "b0bbd657f84219212487d01a8ffe93a789e1e488", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/37bc8df3a67ddf8380704a5ba5dbd00e92ec1f6a", + "reference": "37bc8df3a67ddf8380704a5ba5dbd00e92ec1f6a", "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" + "composer-runtime-api": "^2.0", + "ext-mongodb": "^1.21.0", + "php": "^8.1", + "psr/log": "^1.1.4|^2|^3" + }, + "replace": { + "mongodb/builder": "*" }, "require-dev": { - "doctrine/coding-standard": "^9.0", - "squizlabs/php_codesniffer": "^3.6", - "symfony/phpunit-bridge": "^5.2" + "doctrine/coding-standard": "^12.0", + "phpunit/phpunit": "^10.5.35", + "rector/rector": "^1.2", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "6.5.*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10.x-dev" + "dev-master": "1.x-dev" } }, "autoload": { @@ -304,6 +247,10 @@ { "name": "Jeremy Mikola", "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" } ], "description": "MongoDB driver library", @@ -316,9 +263,9 @@ ], "support": { "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/1.10.0" + "source": "https://github.com/mongodb/mongo-php-library/tree/1.21.1" }, - "time": "2021-10-20T22:22:37+00:00" + "time": "2025-02-28T17:24:20+00:00" }, { "name": "nyholm/psr7", @@ -1688,86 +1635,6 @@ ], "time": "2024-12-23T08:48:59+00:00" }, - { - "name": "symfony/polyfill-php80", - "version": "v1.32.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "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.32.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": "2025-01-02T08:10:11+00:00" - }, { "name": "symfony/polyfill-php82", "version": "v1.32.0", @@ -1929,16 +1796,16 @@ }, { "name": "tbachert/spi", - "version": "v1.0.4", + "version": "v1.0.5", "source": { "type": "git", "url": "https://github.com/Nevay/spi.git", - "reference": "86e355edfdd57f9cb720bd2ac3af7dde521ca0e7" + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Nevay/spi/zipball/86e355edfdd57f9cb720bd2ac3af7dde521ca0e7", - "reference": "86e355edfdd57f9cb720bd2ac3af7dde521ca0e7", + "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002", "shasum": "" }, "require": { @@ -1975,9 +1842,9 @@ ], "support": { "issues": "https://github.com/Nevay/spi/issues", - "source": "https://github.com/Nevay/spi/tree/v1.0.4" + "source": "https://github.com/Nevay/spi/tree/v1.0.5" }, - "time": "2025-06-28T20:18:22+00:00" + "time": "2025-06-29T15:42:06+00:00" }, { "name": "utopia-php/cache", @@ -2126,21 +1993,21 @@ }, { "name": "utopia-php/mongo", - "version": "0.3.1", + "version": "dev-feat-bulk-writes", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "52326a9a43e2d27ff0c15c48ba746dacbe9a7aee" + "reference": "414d4d099386ba742d1620fe2a75afd6105ad611" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/52326a9a43e2d27ff0c15c48ba746dacbe9a7aee", - "reference": "52326a9a43e2d27ff0c15c48ba746dacbe9a7aee", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/414d4d099386ba742d1620fe2a75afd6105ad611", + "reference": "414d4d099386ba742d1620fe2a75afd6105ad611", "shasum": "" }, "require": { "ext-mongodb": "*", - "mongodb/mongodb": "1.10.0", + "mongodb/mongodb": "^1.21", "php": ">=8.0" }, "require-dev": { @@ -2180,9 +2047,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/0.3.1" + "source": "https://github.com/utopia-php/mongo/tree/feat-bulk-writes" }, - "time": "2023-09-01T17:25:28+00:00" + "time": "2025-07-08T17:47:22+00:00" }, { "name": "utopia-php/pools", @@ -2423,16 +2290,16 @@ }, { "name": "laravel/pint", - "version": "v1.22.1", + "version": "v1.23.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "941d1927c5ca420c22710e98420287169c7bcaf7" + "reference": "9ab851dba4faa51a3c3223dd3d07044129021024" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/941d1927c5ca420c22710e98420287169c7bcaf7", - "reference": "941d1927c5ca420c22710e98420287169c7bcaf7", + "url": "https://api.github.com/repos/laravel/pint/zipball/9ab851dba4faa51a3c3223dd3d07044129021024", + "reference": "9ab851dba4faa51a3c3223dd3d07044129021024", "shasum": "" }, "require": { @@ -2443,10 +2310,10 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.75.0", - "illuminate/view": "^11.44.7", - "larastan/larastan": "^3.4.0", - "laravel-zero/framework": "^11.36.1", + "friendsofphp/php-cs-fixer": "^3.76.0", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" @@ -2456,6 +2323,9 @@ ], "type": "project", "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -2485,20 +2355,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-05-08T08:38:12+00:00" + "time": "2025-07-03T10:37:47+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.13.1", + "version": "1.13.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", "shasum": "" }, "require": { @@ -2537,7 +2407,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" }, "funding": [ { @@ -2545,7 +2415,7 @@ "type": "tidelift" } ], - "time": "2025-04-29T12:36:36+00:00" + "time": "2025-07-05T12:25:42+00:00" }, { "name": "nikic/php-parser", @@ -4388,9 +4258,18 @@ "time": "2022-10-09T10:19:07+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/mongo", + "version": "dev-feat-bulk-writes", + "alias": "0.3.1", + "alias_normalized": "0.3.1.0" + } + ], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "utopia-php/mongo": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4399,5 +4278,5 @@ "ext-mbstring": "*" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.2.0" } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 5d2822257..5fade391a 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -727,7 +727,7 @@ public function getDocument(string $collection, string $id, array $queries = [], } $result = $this->client->find($name, $filters, $options)->cursor->firstBatch; - //var_dump($result); + if (empty($result)) { return new Document([]); } @@ -737,7 +737,7 @@ public function getDocument(string $collection, string $id, array $queries = [], return new Document($result); } -//public static $count = 0; + /** * Create Document * @@ -857,9 +857,7 @@ private function insertDocument(string $name, array $document): array $filters, ['limit' => 1] )->cursor->firstBatch[0]; - //var_dump($name); - //var_dump($filters); - //var_dump($result); + return $this->client->toArray($result); } catch (MongoException $e) { throw new Duplicate($e->getMessage()); @@ -889,8 +887,9 @@ public function updateDocument(string $collection, string $id, Document $documen if ($this->sharedTables) { $filters['_tenant'] = $this->getTenant(); } - try { + unset($record['_id']); // Don't update _id + $this->client->update($name, $filters, $record); } catch (MongoException $e) { throw new Duplicate($e->getMessage()); @@ -946,12 +945,133 @@ public function updateDocuments(string $collection, Document $updates, array $do /** * @param string $collection * @param string $attribute - * @param array $documents + * @param array $changes * @return array */ - public function createOrUpdateDocuments(string $collection, string $attribute, array $documents): array + public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array { - return $documents; + if (empty($changes)) { + return $changes; + } + + try { + $name = $this->getNamespace() . '_' . $this->filter($collection); + $attribute = $this->filter($attribute); + + $documentIds = []; + $documentTenants = []; + + $operations = []; + foreach ($changes as $change) { + $document = $change->getNew(); + $attributes = $document->getAttributes(); + + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = $document->getPermissions(); + + if (!empty($document->getSequence())) { + $attributes['_id'] = new ObjectId($document->getSequence()); + } else { + $documentIds[] = $document->getId(); + } + + if ($this->sharedTables) { + $attributes['_tenant'] = $document->getTenant(); + $documentTenants[] = $document->getTenant(); + } + + $record = $this->replaceChars('$', '_', $attributes); + $record = $this->timeToMongo($record); + $record = $this->removeNullKeys($record); + + + // Build filter for upsert + $filter = ['_uid' => $document->getId()]; + if ($this->sharedTables) { + $filter['_tenant'] = $document->getTenant(); + } + + if (!empty($attribute)) { + // Increment specific attribute + $update = [ + '$inc' => [$attribute => $record[$attribute] ?? 0], + '$set' => ['_updatedAt' => $record['_updatedAt']] + ]; + } else { + // Update all fields + unset($record['_id']); // Don't update _id + $update = ['$set' => $record]; + } + + $operations[] = [ + 'filter' => $filter, + 'update' => $update, + ]; + } + + // Use the new bulkUpsert method + $this->client->bulkUpsert( + $name, + $operations, + ["ordered" => false] // TODO Do we want to continue if an error is thrown? + ); + + // Get sequences for documents that were created + if (!empty($documentIds)) { + $sequences = $this->getSequences($collection, $documentIds, $documentTenants); + + foreach ($changes as $change) { + if (isset($sequences[$change->getNew()->getId()])) { + $change->getNew()->setAttribute('$sequence', $sequences[$change->getNew()->getId()]); + } + } + } + + } catch (MongoException $e) { + throw $this->processException($e); + } + + return \array_map(fn ($change) => $change->getNew(), $changes); + } + + /** + * Get sequences for documents that were created + * + * @param string $collection + * @param array $documentIds + * @param array $documentTenants + * @return array + */ + protected function getSequences(string $collection, array $documentIds, array $documentTenants = []): array + { + $sequences = []; + $name = $this->getNamespace() . '_' . $this->filter($collection); + + // Process in chunks to avoid large queries + foreach (\array_chunk($documentIds, 1000) as $documentIdsChunk) { + $filters = ['_uid' => ['$in' => $documentIdsChunk]]; + + if ($this->sharedTables) { + $tenantChunk = \array_slice($documentTenants, 0, \count($documentIdsChunk)); + $filters['_tenant'] = ['$in' => $tenantChunk]; + $documentTenants = \array_slice($documentTenants, \count($documentIdsChunk)); + } + + try { + $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); + + foreach ($results->cursor->firstBatch as $result) { + $sequences[$result->_uid] = (string)$result->_id; + } + } catch (MongoException $e) { + // If query fails, continue with empty sequences + continue; + } + } + + return $sequences; } /** @@ -1129,7 +1249,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, if ($this->sharedTables) { $filters['_tenant'] = $this->getTenant(); } - + // permissions if (Authorization::$status) { $roles = \implode('|', Authorization::getRoles()); @@ -1236,13 +1356,16 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } 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); @@ -1898,7 +2021,7 @@ public function getSupportForCastIndexArray(): bool public function getSupportForUpserts(): bool { - return false; + return true; } public function getSupportForReconnection(): bool diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php index c4d33e5e7..55b21f8e4 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -105,4 +105,4 @@ protected static function deleteIndex(string $collection, string $index): bool { return true; } -} \ No newline at end of file +} diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index adb5316a8..33260a6fc 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -431,6 +431,7 @@ public function testUpsertDocuments(): void ]; $results = []; + $count = $database->createOrUpdateDocuments( __FUNCTION__, $documents, @@ -438,9 +439,10 @@ public function testUpsertDocuments(): void $results[] = $doc; } ); + $this->assertEquals(2, $count); - + $createdAt = []; foreach ($results as $index => $document) { $createdAt[$index] = $document->getCreatedAt(); @@ -453,8 +455,8 @@ public function testUpsertDocuments(): void $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); } + $documents = $database->find(__FUNCTION__); - $this->assertEquals(2, count($documents)); foreach ($documents as $document) { @@ -563,7 +565,7 @@ public function testUpsertDocumentsInc(): void $documents[0]->setAttribute('integer', -1); $documents[1]->setAttribute('integer', -1); - + $database->createOrUpdateDocumentsWithIncrease( collection: __FUNCTION__, attribute: 'integer', @@ -571,7 +573,7 @@ public function testUpsertDocumentsInc(): void ); $documents = $database->find(__FUNCTION__); - + foreach ($documents as $document) { $this->assertEquals(5, $document->getAttribute('integer')); } @@ -1405,21 +1407,16 @@ public function testFindBasicChecks(): void ]); $this->assertEquals($firstDocumentId, $documents[0]->getId()); - /** - * Check internal numeric ID sorting - */ + /** + * Check internal numeric ID sorting + */ $documents = $database->find('movies', [ Query::limit(25), Query::offset(0), Query::orderDesc(''), ]); -// foreach ($documents as $document) { -// var_dump($document->getAttribute('name')); -// } -// -// exit; - //var_dump($movieDocuments); + $this->assertEquals($movieDocuments[\count($movieDocuments) - 1]->getId(), $documents[0]->getId()); $documents = $database->find('movies', [ Query::limit(25), @@ -4238,7 +4235,9 @@ public function testExceptionCaseInsensitiveDuplicate(Document $document): Docum $database = static::getDatabase(); $document->setAttribute('$id', 'caseSensitive'); - $document->setAttribute('$sequence', '200'); + // Todo 200 van not be ObjectId + //$document->setAttribute('$sequence', '200'); + $document->setAttribute('$sequence', '507f1f77bcf86cd799439011'); $database->createDocument($document->getCollection(), $document); $document->setAttribute('$id', 'CaseSensitive'); @@ -4278,7 +4277,7 @@ public function testEmptyTenant(): void $document = $database->getDocument('documents', $document->getId()); $this->assertArrayHasKey('$id', $document); $this->assertArrayNotHasKey('$tenant', $document); - + $document = $database->updateDocument('documents', $document->getId(), $document); $this->assertArrayHasKey('$id', $document); $this->assertArrayNotHasKey('$tenant', $document); diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index b138dba76..7ef44b7d5 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -131,16 +131,12 @@ public function testPreserveDatesUpdate(): void $doc1 = $database->getDocument('preserve_update_dates', 'doc1'); $this->assertEquals($newDate, $doc1->getAttribute('$updatedAt')); -// var_dump([ -// '$doc2' => $doc2->getAttribute('$updatedAt'), -// '$doc3' => $doc3->getAttribute('$updatedAt'), -// ]); - $this->getDatabase()->updateDocuments( 'preserve_update_dates', new Document([ '$updatedAt' => $newDate - ]), [ + ]), + [ Query::equal('$id', [ $doc2->getId(), $doc3->getId() @@ -544,6 +540,7 @@ public function testSharedTablesTenantPerDocument(): void public function testCacheFallback(): void { + /** @var Database $database */ $database = static::getDatabase(); diff --git a/tests/e2e/Adapter/SharedTables/MongoDBTest.php b/tests/e2e/Adapter/SharedTables/MongoDBTest.php index ffe4bcce0..9a9f2e749 100644 --- a/tests/e2e/Adapter/SharedTables/MongoDBTest.php +++ b/tests/e2e/Adapter/SharedTables/MongoDBTest.php @@ -56,7 +56,7 @@ public static function getDatabase(): Database ->setDatabase($schema) ->setSharedTables(true) ->setTenant(999) - ->setNamespace(static::$namespace = ''); + ->setNamespace(static::$namespace = 'my_shared_tables'); if ($database->exists()) { $database->delete(); @@ -108,4 +108,4 @@ protected static function deleteIndex(string $collection, string $index): bool { return true; } -} \ No newline at end of file +} From 3800e8eb8eb92b377656bd03617d5cd23daac97a Mon Sep 17 00:00:00 2001 From: shimon Date: Wed, 9 Jul 2025 13:58:35 +0300 Subject: [PATCH 09/34] clean up --- tests/e2e/Adapter/Scopes/CollectionTests.php | 9 +-------- tests/e2e/Adapter/Scopes/DocumentTests.php | 12 +----------- tests/e2e/Adapter/Scopes/GeneralTests.php | 11 ++--------- 3 files changed, 4 insertions(+), 28 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 20cd78c45..6a039fee7 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -649,15 +649,12 @@ public function testCreateCollectionWithSchemaIndexes(): void ]), ]; - /** - * Update array length check to 255 - */ $indexes = [ new Document([ '$id' => ID::custom('idx_cards'), 'type' => Database::INDEX_KEY, 'attributes' => ['cards'], - 'lengths' => [500], + 'lengths' => [500], // Will be changed to Database::ARRAY_INDEX_LENGTH (255) 'orders' => [Database::ORDER_DESC], ]), new Document([ @@ -686,10 +683,6 @@ public function testCreateCollectionWithSchemaIndexes(): void ); $this->assertEquals($collection->getAttribute('indexes')[0]['attributes'][0], 'cards'); - /** - * If we set getMaxIndexLength to 1024 then this tests pass but other tests that depend on index length fail - */ - $this->assertEquals($collection->getAttribute('indexes')[0]['lengths'][0], Database::ARRAY_INDEX_LENGTH); $this->assertEquals($collection->getAttribute('indexes')[0]['orders'][0], null); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index adb5316a8..b4969242a 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1405,7 +1405,7 @@ public function testFindBasicChecks(): void ]); $this->assertEquals($firstDocumentId, $documents[0]->getId()); - /** + /** * Check internal numeric ID sorting */ $documents = $database->find('movies', [ @@ -1413,13 +1413,6 @@ public function testFindBasicChecks(): void Query::offset(0), Query::orderDesc(''), ]); - -// foreach ($documents as $document) { -// var_dump($document->getAttribute('name')); -// } -// -// exit; - //var_dump($movieDocuments); $this->assertEquals($movieDocuments[\count($movieDocuments) - 1]->getId(), $documents[0]->getId()); $documents = $database->find('movies', [ Query::limit(25), @@ -1985,8 +1978,6 @@ public function testFindOrderByAfterNaturalOrder(): void Query::orderDesc(''), Query::cursorAfter($movies[1]) ]); - //var_dump($movieDocuments); - $this->assertEquals(2, count($documents)); $this->assertEquals($movies[2]['name'], $documents[0]['name']); $this->assertEquals($movies[3]['name'], $documents[1]['name']); @@ -2016,7 +2007,6 @@ public function testFindOrderByAfterNaturalOrder(): void Query::orderDesc(''), Query::cursorAfter($movies[5]) ]); - $this->assertEmpty(count($documents)); } public function testFindOrderByBeforeNaturalOrder(): void diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index b138dba76..a5fc8f200 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -120,9 +120,6 @@ public function testPreserveDatesUpdate(): void 'attr1' => 'value3', ])); - - - $newDate = '2000-01-01T10:00:00.000+00:00'; $doc1->setAttribute('$updatedAt', $newDate); @@ -131,16 +128,12 @@ public function testPreserveDatesUpdate(): void $doc1 = $database->getDocument('preserve_update_dates', 'doc1'); $this->assertEquals($newDate, $doc1->getAttribute('$updatedAt')); -// var_dump([ -// '$doc2' => $doc2->getAttribute('$updatedAt'), -// '$doc3' => $doc3->getAttribute('$updatedAt'), -// ]); - $this->getDatabase()->updateDocuments( 'preserve_update_dates', new Document([ '$updatedAt' => $newDate - ]), [ + ]), + [ Query::equal('$id', [ $doc2->getId(), $doc3->getId() From 4ecc0041040e634c465111f3beae175b1c217865 Mon Sep 17 00:00:00 2001 From: shimon Date: Wed, 9 Jul 2025 14:05:03 +0300 Subject: [PATCH 10/34] clean up --- src/Database/Adapter.php | 4 ---- tests/e2e/Adapter/Scopes/GeneralTests.php | 1 - 2 files changed, 5 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 3d59e3744..88fd7d64f 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -374,12 +374,8 @@ public function withTransaction(callable $callback): mixed for ($attempts = 0; $attempts < 3; $attempts++) { try { $this->startTransaction(); - //var_dump($attempts); $result = $callback(); - //var_dump($result); - $this->commitTransaction(); - return $result; } catch (\Throwable $action) { try { diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 56b976e79..a5fc8f200 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -537,7 +537,6 @@ public function testSharedTablesTenantPerDocument(): void public function testCacheFallback(): void { - /** @var Database $database */ $database = static::getDatabase(); From f0a9d0560a3f90c9ac518319c8d123ef3005547d Mon Sep 17 00:00:00 2001 From: shimon Date: Thu, 10 Jul 2025 09:29:24 +0300 Subject: [PATCH 11/34] linter --- src/Database/Adapter/Mongo.php | 37 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 5fade391a..9cd54616a 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -11,7 +11,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Exception\Timeout; use Utopia\Database\Query; @@ -727,7 +726,7 @@ public function getDocument(string $collection, string $id, array $queries = [], } $result = $this->client->find($name, $filters, $options)->cursor->firstBatch; - + if (empty($result)) { return new Document([]); } @@ -857,7 +856,7 @@ private function insertDocument(string $name, array $document): array $filters, ['limit' => 1] )->cursor->firstBatch[0]; - + return $this->client->toArray($result); } catch (MongoException $e) { throw new Duplicate($e->getMessage()); @@ -953,7 +952,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a if (empty($changes)) { return $changes; } - + try { $name = $this->getNamespace() . '_' . $this->filter($collection); $attribute = $this->filter($attribute); @@ -970,7 +969,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = $document->getPermissions(); - + if (!empty($document->getSequence())) { $attributes['_id'] = new ObjectId($document->getSequence()); } else { @@ -985,7 +984,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $record = $this->replaceChars('$', '_', $attributes); $record = $this->timeToMongo($record); $record = $this->removeNullKeys($record); - + // Build filter for upsert $filter = ['_uid' => $document->getId()]; @@ -1009,19 +1008,19 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a 'filter' => $filter, 'update' => $update, ]; - } - + } + // Use the new bulkUpsert method $this->client->bulkUpsert( $name, $operations, - ["ordered" => false] // TODO Do we want to continue if an error is thrown? + ["ordered" => false] // TODO Do we want to continue if an error is thrown? ); // Get sequences for documents that were created if (!empty($documentIds)) { $sequences = $this->getSequences($collection, $documentIds, $documentTenants); - + foreach ($changes as $change) { if (isset($sequences[$change->getNew()->getId()])) { $change->getNew()->setAttribute('$sequence', $sequences[$change->getNew()->getId()]); @@ -1032,7 +1031,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a } catch (MongoException $e) { throw $this->processException($e); } - + return \array_map(fn ($change) => $change->getNew(), $changes); } @@ -1061,7 +1060,7 @@ protected function getSequences(string $collection, array $documentIds, array $d try { $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); - + foreach ($results->cursor->firstBatch as $result) { $sequences[$result->_uid] = (string)$result->_id; } @@ -1249,7 +1248,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, if ($this->sharedTables) { $filters['_tenant'] = $this->getTenant(); } - + // permissions if (Authorization::$status) { $roles = \implode('|', Authorization::getRoles()); @@ -1306,7 +1305,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $prevAttr = $this->filter($this->getInternalKeyForAttribute($originalPrev)); $tmp = $cursor[$originalPrev]; - if($originalPrev === '$sequence'){ + if ($originalPrev === '$sequence') { $tmp = new ObjectId($tmp); } @@ -1317,11 +1316,11 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $tmp = $cursor[$originalAttribute]; - if($originalAttribute === '$sequence'){ + if ($originalAttribute === '$sequence') { $tmp = new ObjectId($tmp); /** If there is only $sequence attribute in $orderAttributes skip Or And operators **/ - if(count($orderAttributes) === 1){ + if (count($orderAttributes) === 1) { $filters[$attribute] = [ $operator => $tmp ]; @@ -1356,16 +1355,16 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } 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); From c6298f00d86e4e802111b81b9f17366a6023ebcc Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 21 Jul 2025 09:52:01 +0300 Subject: [PATCH 12/34] replaced bulkUpsert() with upsert() call --- Dockerfile | 6 +- composer.json | 2 +- composer.lock | 133 ++++++++++++++++++++++++++------- src/Database/Adapter/Mongo.php | 6 +- tests/e2e/Adapter/Base.php | 2 +- 5 files changed, 112 insertions(+), 37 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8f1621c47..e33f09e71 100755 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,8 @@ FROM php:8.3.19-cli-alpine3.21 AS compile ENV PHP_REDIS_VERSION="6.0.2" \ PHP_SWOOLE_VERSION="v5.1.7" \ - PHP_XDEBUG_VERSION="3.4.2" - + PHP_XDEBUG_VERSION="3.4.2" \ + PHP_MONGODB_VERSION="2.1.1" RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN apk update && apk add --no-cache \ @@ -33,7 +33,7 @@ RUN apk update && apk add --no-cache \ linux-headers \ docker-cli \ docker-cli-compose \ - && pecl install mongodb-1.17.0 \ + && pecl install mongodb-$PHP_MONGODB_VERSION \ && docker-php-ext-enable mongodb \ && docker-php-ext-install opcache pgsql pdo_mysql pdo_pgsql \ && apk del postgresql-dev \ diff --git a/composer.json b/composer.json index 7300239c4..1231c2144 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-bulk-writes as 0.3.1" + "utopia-php/mongo": "dev-feat-bulk-writes-2 as 0.3.1" }, "require-dev": { "fakerphp/faker": "1.23.*", diff --git a/composer.lock b/composer.lock index 59db2f26b..c9d652738 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": "cd1babfd7f7750ad399c915edd6209ad", + "content-hash": "d0116653391026bc9593e93a53760ab7", "packages": [ { "name": "brick/math", @@ -193,23 +193,24 @@ }, { "name": "mongodb/mongodb", - "version": "1.21.1", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "37bc8df3a67ddf8380704a5ba5dbd00e92ec1f6a" + "reference": "3bbe7ba9578724c7e1f47fcd17c881c0995baaad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/37bc8df3a67ddf8380704a5ba5dbd00e92ec1f6a", - "reference": "37bc8df3a67ddf8380704a5ba5dbd00e92ec1f6a", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/3bbe7ba9578724c7e1f47fcd17c881c0995baaad", + "reference": "3bbe7ba9578724c7e1f47fcd17c881c0995baaad", "shasum": "" }, "require": { "composer-runtime-api": "^2.0", - "ext-mongodb": "^1.21.0", + "ext-mongodb": "^2.1", "php": "^8.1", - "psr/log": "^1.1.4|^2|^3" + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php85": "^1.32" }, "replace": { "mongodb/builder": "*" @@ -263,9 +264,9 @@ ], "support": { "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/1.21.1" + "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.0" }, - "time": "2025-02-28T17:24:20+00:00" + "time": "2025-05-23T10:48:05+00:00" }, { "name": "nyholm/psr7", @@ -1711,6 +1712,82 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-php85", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", + "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", + "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\\Php85\\": "" + }, + "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.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.32.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": "2025-05-02T08:40:52+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.6.0", @@ -1993,21 +2070,21 @@ }, { "name": "utopia-php/mongo", - "version": "dev-feat-bulk-writes", + "version": "dev-feat-bulk-writes-2", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "414d4d099386ba742d1620fe2a75afd6105ad611" + "reference": "088d890e1646a143ee95eb17e983e7944b3bcecd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/414d4d099386ba742d1620fe2a75afd6105ad611", - "reference": "414d4d099386ba742d1620fe2a75afd6105ad611", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/088d890e1646a143ee95eb17e983e7944b3bcecd", + "reference": "088d890e1646a143ee95eb17e983e7944b3bcecd", "shasum": "" }, "require": { "ext-mongodb": "*", - "mongodb/mongodb": "^1.21", + "mongodb/mongodb": "2.1.0", "php": ">=8.0" }, "require-dev": { @@ -2047,9 +2124,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/feat-bulk-writes" + "source": "https://github.com/utopia-php/mongo/tree/feat-bulk-writes-2" }, - "time": "2025-07-08T17:47:22+00:00" + "time": "2025-07-20T10:59:11+00:00" }, { "name": "utopia-php/pools", @@ -2290,16 +2367,16 @@ }, { "name": "laravel/pint", - "version": "v1.23.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "9ab851dba4faa51a3c3223dd3d07044129021024" + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/9ab851dba4faa51a3c3223dd3d07044129021024", - "reference": "9ab851dba4faa51a3c3223dd3d07044129021024", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", "shasum": "" }, "require": { @@ -2310,7 +2387,7 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.76.0", + "friendsofphp/php-cs-fixer": "^3.82.2", "illuminate/view": "^11.45.1", "larastan/larastan": "^3.5.0", "laravel-zero/framework": "^11.45.0", @@ -2355,7 +2432,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-07-03T10:37:47+00:00" + "time": "2025-07-10T18:09:32+00:00" }, { "name": "myclabs/deep-copy", @@ -2627,16 +2704,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.27", + "version": "1.12.28", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "3a6e423c076ab39dfedc307e2ac627ef579db162" + "reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/3a6e423c076ab39dfedc307e2ac627ef579db162", - "reference": "3a6e423c076ab39dfedc307e2ac627ef579db162", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fcf8b71aeab4e1a1131d1783cef97b23a51b87a9", + "reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9", "shasum": "" }, "require": { @@ -2681,7 +2758,7 @@ "type": "github" } ], - "time": "2025-05-21T20:51:45+00:00" + "time": "2025-07-17T17:15:39+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4261,7 +4338,7 @@ "aliases": [ { "package": "utopia-php/mongo", - "version": "dev-feat-bulk-writes", + "version": "dev-feat-bulk-writes-2", "alias": "0.3.1", "alias_normalized": "0.3.1.0" } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 5fade391a..8b2ba4131 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -11,7 +11,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Exception\Timeout; use Utopia\Database\Query; @@ -843,7 +842,7 @@ private function insertDocument(string $name, array $document): array { try { - $bla = $this->client->insert($name, $document); + $this->client->insert($name, $document); $filters = []; $filters['_uid'] = $document['_uid']; @@ -1011,8 +1010,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a ]; } - // Use the new bulkUpsert method - $this->client->bulkUpsert( + $this->client->upsert( $name, $operations, ["ordered" => false] // TODO Do we want to continue if an error is thrown? diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index ff38b0155..a57fe2748 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -22,7 +22,7 @@ abstract class Base extends TestCase use AttributeTests; use IndexTests; use PermissionTests; - //use RelationshipTests; + use RelationshipTests; use GeneralTests; protected static string $namespace; From 9a8faadd2b64b9879a418be40cb33dac83d2050b Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 21 Jul 2025 13:05:25 +0300 Subject: [PATCH 13/34] rollback PHP_MONGODB_VERSION --- Dockerfile | 2 +- composer.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index e33f09e71..4fbf2a03d 100755 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ FROM php:8.3.19-cli-alpine3.21 AS compile ENV PHP_REDIS_VERSION="6.0.2" \ PHP_SWOOLE_VERSION="v5.1.7" \ PHP_XDEBUG_VERSION="3.4.2" \ - PHP_MONGODB_VERSION="2.1.1" + PHP_MONGODB_VERSION="1.21.1" RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN apk update && apk add --no-cache \ diff --git a/composer.lock b/composer.lock index c9d652738..aa3a8d351 100644 --- a/composer.lock +++ b/composer.lock @@ -2074,12 +2074,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "088d890e1646a143ee95eb17e983e7944b3bcecd" + "reference": "9c67b64f90f9737c2d10279554c7a856ca586f10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/088d890e1646a143ee95eb17e983e7944b3bcecd", - "reference": "088d890e1646a143ee95eb17e983e7944b3bcecd", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/9c67b64f90f9737c2d10279554c7a856ca586f10", + "reference": "9c67b64f90f9737c2d10279554c7a856ca586f10", "shasum": "" }, "require": { @@ -2126,7 +2126,7 @@ "issues": "https://github.com/utopia-php/mongo/issues", "source": "https://github.com/utopia-php/mongo/tree/feat-bulk-writes-2" }, - "time": "2025-07-20T10:59:11+00:00" + "time": "2025-07-21T07:00:47+00:00" }, { "name": "utopia-php/pools", From ecc831fb22288476fe67c522e4a9b82781c5631a Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 21 Jul 2025 13:21:00 +0300 Subject: [PATCH 14/34] remove scrap --- src/Database/Adapter/Mongo.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 9cd54616a..726948138 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -842,7 +842,7 @@ private function insertDocument(string $name, array $document): array { try { - $bla = $this->client->insert($name, $document); + $this->client->insert($name, $document); $filters = []; $filters['_uid'] = $document['_uid']; From 86bf4ffc16d8bf03b20a2508fa95d9919ac07f84 Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 21 Jul 2025 17:25:59 +0300 Subject: [PATCH 15/34] updates --- src/Database/Adapter/Mongo.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 726948138..0151fce26 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -842,7 +842,7 @@ private function insertDocument(string $name, array $document): array { try { - $this->client->insert($name, $document); + $result = $this->client->insert($name, $document); $filters = []; $filters['_uid'] = $document['_uid']; From 645f5b58ec3644ea2c107e8633ab5a9b15b3ee17 Mon Sep 17 00:00:00 2001 From: shimon Date: Thu, 24 Jul 2025 13:17:53 +0300 Subject: [PATCH 16/34] Implement internal casting methods in Mongo and SQL adapters; update dependencies in composer.lock --- composer.lock | 12 +- src/Database/Adapter.php | 38 +++ src/Database/Adapter/Mongo.php | 248 +++++++++++++++--- src/Database/Adapter/SQL.php | 30 +++ src/Database/Database.php | 96 +++++-- tests/e2e/Adapter/Scopes/CollectionTests.php | 261 ++++++++++--------- tests/e2e/Adapter/Scopes/DocumentTests.php | 13 +- 7 files changed, 492 insertions(+), 206 deletions(-) diff --git a/composer.lock b/composer.lock index aa3a8d351..4561b2fc2 100644 --- a/composer.lock +++ b/composer.lock @@ -2074,23 +2074,23 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "9c67b64f90f9737c2d10279554c7a856ca586f10" + "reference": "0516d0d325ea54c093f18435e0b11795c1293328" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/9c67b64f90f9737c2d10279554c7a856ca586f10", - "reference": "9c67b64f90f9737c2d10279554c7a856ca586f10", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/0516d0d325ea54c093f18435e0b11795c1293328", + "reference": "0516d0d325ea54c093f18435e0b11795c1293328", "shasum": "" }, "require": { - "ext-mongodb": "*", + "ext-mongodb": "2.1.1", "mongodb/mongodb": "2.1.0", "php": ">=8.0" }, "require-dev": { "fakerphp/faker": "^1.14", "laravel/pint": "1.2.*", - "phpstan/phpstan": "1.8.*", + "phpstan/phpstan": "2.1.*", "phpunit/phpunit": "^9.4", "swoole/ide-helper": "4.8.0" }, @@ -2126,7 +2126,7 @@ "issues": "https://github.com/utopia-php/mongo/issues", "source": "https://github.com/utopia-php/mongo/tree/feat-bulk-writes-2" }, - "time": "2025-07-21T07:00:47+00:00" + "time": "2025-07-22T13:34:49+00:00" }, { "name": "utopia-php/pools", diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 88fd7d64f..2001cb058 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -3,6 +3,7 @@ namespace Utopia\Database; use Exception; +use PhpParser\Node\Scalar\MagicConst\Dir; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Timeout as TimeoutException; @@ -1200,4 +1201,41 @@ abstract public function getTenantQuery(string $collection, string $alias = ''): * @return bool */ abstract protected function execute(mixed $stmt): bool; + + /** + * Returns the document after casting from + * @param Document $collection + * @param Document $document + * @return Document + */ + abstract public function internalCastingFrom(Document $collection, Document $document): Document; + + /** + * Returns the document after casting to + * @param Document $collection + * @param Document $document + * @return Document + */ + abstract public function internalCastingTo(Document $collection, Document $document): Document; + /** + * @return bool + */ + abstract public function isMongo(): bool; + + /** + * Is internal casting supported? + * + * @return bool + */ + abstract public function getSupportForInternalCasting(): bool; + + /** + * Set UTC Datetime + * + * @param string $value + * @return mixed + */ + abstract function setUTCDatetime(string $value): mixed; + } + diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index caaf0d3b0..ba8df4270 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -6,6 +6,8 @@ use MongoDB\BSON\ObjectId; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\BSON\Int32; +use MongoDB\BSON\Int64; use Utopia\Database\Adapter; use Utopia\Database\Database; use Utopia\Database\DateTime; @@ -191,10 +193,11 @@ public function delete(string $name): bool 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); @@ -203,18 +206,38 @@ public function createCollection(string $name, array $attributes = [], array $in 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, + $internalIndex = [ + [ + 'key' => ['_uid' => $this->getOrder(Database::ORDER_ASC)], + 'name' => '_uid', + 'unique' => true, + 'collation' => [ + 'locale' => 'en', + 'strength' => 1, + ] + ], + [ + 'key' => ['_createdAt' => $this->getOrder(Database::ORDER_ASC)], + 'name' => '_createdAt', + ], + [ + 'key' => ['_updatedAt' => $this->getOrder(Database::ORDER_ASC)], + 'name' => '_updatedAt', + ], + [ + 'key' => ['_permissions' => $this->getOrder(Database::ORDER_ASC)], + 'name' => '_permissions', ] - ], [ - 'key' => ['_permissions' => $this->getOrder(Database::ORDER_DESC)], - 'name' => '_permissions', - ]]); + ]; + + if ($this->sharedTables) { + foreach ($internalIndex as &$index) { + $index['key'] = array_merge(['_tenant' => $this->getOrder(Database::ORDER_ASC)], $index['key']); + } + unset($index); + } + + $indexesCreated = $this->client->createIndexes($id, $internalIndex); if (!$indexesCreated) { return false; @@ -222,7 +245,7 @@ public function createCollection(string $name, array $attributes = [], array $in // 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] @@ -237,6 +260,11 @@ public function createCollection(string $name, array $attributes = [], array $in $attributes = $index->getAttribute('attributes'); $orders = $index->getAttribute('orders'); + // If sharedTables, always add _tenant as the first key + if ($this->sharedTables) { + $key['_tenant'] = $this->getOrder(Database::ORDER_ASC); + } + foreach ($attributes as $attribute) { $attribute = $this->filter($attribute); @@ -268,6 +296,7 @@ public function createCollection(string $name, array $attributes = [], array $in return false; } } + return true; } @@ -596,9 +625,13 @@ public function createIndex(string $collection, string $id, string $type, array $indexes = []; $options = []; - // pass in custom index name $indexes['name'] = $id; + // If sharedTables, always add _tenant as the first key + if ($this->sharedTables) { + $indexes['key']['_tenant'] = $this->getOrder(Database::ORDER_ASC); + } + foreach ($attributes as $i => $attribute) { $attribute = $this->filter($attribute); @@ -726,13 +759,13 @@ public function getDocument(string $collection, string $id, array $queries = [], } $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); + //$result = $this->timeToDocument($result); return new Document($result); } @@ -760,7 +793,7 @@ public function createDocument(string $collection, Document $document): Document } $record = $this->replaceChars('$', '_', (array)$document); - $record = $this->timeToMongo($record); + //$record = $this->timeToMongo($record); // Insert manual id if set if (!empty($sequence)) { @@ -770,11 +803,130 @@ public function createDocument(string $collection, Document $document): Document $result = $this->insertDocument($name, $this->removeNullKeys($record)); $result = $this->replaceChars('_', '$', $result); - $result = $this->timeToDocument($result); + //$result = $this->timeToDocument($result); return new Document($result); } + + /** + * Returns the document after casting from + *@param Document $collection + * @param Document $document + + * @return Document + */ +public function internalCastingFrom($collection, $document): Document +{ + + if (!$this->getSupportForInternalCasting()) { + return $document; + } + + if($document->isEmpty()){ + return $document; + } + + $attributes = $collection->getAttribute('attributes', []); + + $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES); + + foreach ($attributes as $attribute) { + $key = $attribute['$id'] ?? ''; + $type = $attribute['type'] ?? ''; + $array = $attribute['array'] ?? false; + $value = $document->getAttribute($key, null); + if (is_null($value)) { + continue; + } + + if ($array) { + $value = !is_string($value) + ? $value + : json_decode($value, true); + } else { + $value = [$value]; + } + + foreach ($value as &$node) { + //var_dump([$type, $key, $node]); + switch ($type) { + case Database::VAR_INTEGER: + $node = (int)$node; + break; + case Database::VAR_DATETIME : + $node = DateTime::format($node->toDateTime()); + break; + default: + break; + } + } + unset($node); + $document->setAttribute($key, ($array) ? $value : $value[0]); + } + + return $document; +} + + + + /** + * Returns the document after casting to + *@param Document $collection + * @param Document $document + + * @return Document + */ +public function internalCastingTo($collection, $document): Document +{ + + if (!$this->getSupportForInternalCasting()) { + return $document; + } + + if($document->isEmpty()){ + return $document; + } + + $attributes = $collection->getAttribute('attributes', []); + + $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES); + + foreach ($attributes as $attribute) { + + $key = $attribute['$id'] ?? ''; + $type = $attribute['type'] ?? ''; + $array = $attribute['array'] ?? false; + + $value = $document->getAttribute($key, null); + if (is_null($value)) { + continue; + } + + if ($array) { + $value = !is_string($value) + ? $value + : json_decode($value, true); + } else { + $value = [$value]; + } + + foreach ($value as &$node) { + switch ($type) { + case Database::VAR_DATETIME : + $node = new UTCDateTime(new \DateTime($node)); + break; + default: + break; + } + } + unset($node); + $document->setAttribute($key, ($array) ? $value : $value[0]); + } + + return $document; +} + /** * Create Documents in batches * @@ -809,7 +961,7 @@ public function createDocuments(string $collection, array $documents): array } $record = $this->replaceChars('$', '_', (array)$document); - $record = $this->timeToMongo($record); + //$record = $this->timeToMongo($record); if (!empty($sequence)) { $record['_id'] = $sequence; @@ -822,7 +974,7 @@ public function createDocuments(string $collection, array $documents): array foreach ($documents as $index => $document) { $documents[$index] = $this->replaceChars('_', '$', $this->client->toArray($document)); - $documents[$index] = $this->timeToDocument($documents[$index]); + //$documents[$index] = $this->timeToDocument($documents[$index]); $documents[$index] = new Document($documents[$index]); } @@ -863,6 +1015,8 @@ private function insertDocument(string $name, array $document): array } } + + /** * Update Document * @@ -879,7 +1033,7 @@ public function updateDocument(string $collection, string $id, Document $documen $record = $document->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); - $record = $this->timeToMongo($record); + //$record = $this->timeToMongo($record); $filters = []; $filters['_uid'] = $id; @@ -926,7 +1080,7 @@ public function updateDocuments(string $collection, Document $updates, array $do $record = $updates->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); - $record = $this->timeToMongo($record); + //$record = $this->timeToMongo($record); $updateQuery = [ '$set' => $record, @@ -964,10 +1118,9 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a foreach ($changes as $change) { $document = $change->getNew(); $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_createdAt'] = $document['$createdAt']; + $attributes['_updatedAt'] = $document['$updatedAt']; $attributes['_permissions'] = $document->getPermissions(); if (!empty($document->getSequence())) { @@ -982,10 +1135,9 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a } $record = $this->replaceChars('$', '_', $attributes); - $record = $this->timeToMongo($record); + //$record = $this->timeToMongo($record); $record = $this->removeNullKeys($record); - - + // Build filter for upsert $filter = ['_uid' => $document->getId()]; if ($this->sharedTables) { @@ -1159,8 +1311,7 @@ public function deleteDocuments(string $collection, array $sequences, array $per } $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - $filters = $this->timeFilter($filters); - + //$filters = $this->timeFilter($filters); $options = []; try { @@ -1241,7 +1392,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, { $name = $this->getNamespace() . '_' . $this->filter($collection); $queries = array_map(fn ($query) => clone $query, $queries); - + $filters = $this->buildFilters($queries); if ($this->sharedTables) { @@ -1314,7 +1465,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } $tmp = $cursor[$originalAttribute]; - + if ($originalAttribute === '$sequence') { $tmp = new ObjectId($tmp); @@ -1342,10 +1493,10 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, if (!empty($orFilters)) { $filters['$or'] = $orFilters; } - + // Translate operators and handle time filters $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - $filters = $this->timeFilter($filters); + //$filters = $this->timeFilter($filters); $found = []; @@ -1355,17 +1506,13 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, 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); - + //$record = $this->timeToDocument($record); $found[] = new Document($record); } @@ -1433,6 +1580,7 @@ private function timeToDocument(array $record): array */ private function timeToMongo(array $record): array { + if (isset($record['_createdAt'])) { $record['_createdAt'] = $this->toMongoDatetime($record['_createdAt']); } @@ -1877,6 +2025,28 @@ public function getSupportForSchemas(): bool return false; } + /** + * Is internal casting supported? + * + * @return bool + */ + public function getSupportForInternalCasting(): bool + { + return true; + } + + + public function isMongo(): bool + { + return true; + } + + public function setUTCDatetime(string $value): mixed + { + return new UTCDateTime(new \DateTime($value)); + } + + /** * Are attributes supported? * diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 31bc7e6a3..65d472389 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -808,6 +808,36 @@ public function getSupportForSchemas(): bool return true; } + /** + * Is internal casting supported? + * + * @return bool + */ + public function getSupportForInternalCasting(): bool + { + return false; + } + + public function internalCastingFrom(Document $collection, Document $document): Document + { + return $document; + } + + public function internalCastingTo(Document $collection, Document $document): Document + { + return $document; + } + + public function isMongo(): bool + { + return false; + } + + public function setUTCDatetime(string $value): mixed + { + return $value; + } + /** * Is index supported? * diff --git a/src/Database/Database.php b/src/Database/Database.php index 0658065cc..daddb52db 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1204,6 +1204,7 @@ public function delete(?string $database = null): bool */ public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document { + $permissions ??= [ Permission::create(Role::any()), ]; @@ -1376,7 +1377,7 @@ public function updateCollection(string $id, array $permissions, bool $documentS public function getCollection(string $id): Document { $collection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); - + if ( $id !== self::METADATA && $this->adapter->getSharedTables() @@ -3295,11 +3296,13 @@ public function getDocument(string $collection, string $id, array $queries = [], $queries, $forUpdate ); - + if ($document->isEmpty()) { return $document; } - + + $document = $this->adapter->internalCastingFrom($collection, $document); + $document->setAttribute('$collection', $collection->getId()); if ($collection->getId() !== self::METADATA) { @@ -3311,7 +3314,6 @@ public function getDocument(string $collection, string $id, array $queries = [], } } - $document = $this->casting($collection, $document); $document = $this->decode($collection, $document, $selections); $this->map = []; @@ -3612,7 +3614,7 @@ public function createDocument(string $collection, Document $document): Document } $time = DateTime::now(); - + $createdAt = $document->getCreatedAt(); $updatedAt = $document->getUpdatedAt(); @@ -3636,7 +3638,7 @@ public function createDocument(string $collection, Document $document): Document } $document = $this->encode($collection, $document); - + if ($this->validate) { $validator = new Permissions(); if (!$validator->isValid($document->getPermissions())) { @@ -3653,17 +3655,26 @@ public function createDocument(string $collection, Document $document): Document throw new StructureException($structure->getDescription()); } + var_dump($document); + $document = $this->withTransaction(function () use ($collection, $document) { + if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); } + + $document = $this->adapter->internalCastingTo($collection, $document); + return $this->adapter->createDocument($collection->getId(), $document); }); - + + $document = $this->adapter->internalCastingFrom($collection, $document); + if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); } + $document = $this->decode($collection, $document); $this->trigger(self::EVENT_DOCUMENT_CREATE, $document); @@ -3744,6 +3755,9 @@ public function createDocuments( if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); } + + $document = $this->adapter->internalCastingTo($collection, $document); + } foreach (\array_chunk($documents, $batchSize) as $chunk) { @@ -3752,6 +3766,7 @@ public function createDocuments( }); foreach ($batch as $document) { + $document = $this->adapter->internalCastingFrom($collection, $document); if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); } @@ -4107,12 +4122,13 @@ private function relateDocumentsById( */ public function updateDocument(string $collection, string $id, Document $document): Document { + if (!$id) { throw new DatabaseException('Must define $id attribute'); } $collection = $this->silent(fn () => $this->getCollection($collection)); - + $document = $this->withTransaction(function () use ($collection, $id, $document) { $time = DateTime::now(); $old = Authorization::skip(fn () => $this->silent( @@ -4274,8 +4290,12 @@ public function updateDocument(string $collection, string $id, Document $documen if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->updateDocumentRelationships($collection, $old, $document)); } - + $document = $this->adapter->internalCastingTo($collection, $document); + $this->adapter->updateDocument($collection->getId(), $id, $document); + + $document = $this->adapter->internalCastingFrom($collection, $document); + $this->purgeCachedDocument($collection->getId(), $id); return $document; @@ -4437,8 +4457,8 @@ public function updateDocuments( if (!is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { throw new ConflictException('Document was updated after the request timestamp'); } - $document = $this->encode($collection, $document); + $document = $this->adapter->internalCastingTo($collection, $document); } $this->withTransaction(function () use ($collection, $updates, $batch) { @@ -4450,6 +4470,7 @@ public function updateDocuments( }); foreach ($batch as $doc) { + $doc = $this->adapter->internalCastingFrom($collection, $doc); $this->purgeCachedDocument($collection->getId(), $doc->getId()); $doc = $this->decode($collection, $doc); $onNext && $onNext($doc); @@ -5045,6 +5066,9 @@ public function createOrUpdateDocumentsWithIncrease( $seenIds[] = $document->getId(); + $old = $this->adapter->internalCastingTo($collection, $old); + $document = $this->adapter->internalCastingTo($collection, $document); + $documents[$key] = new Change( old: $old, new: $document @@ -5075,6 +5099,9 @@ public function createOrUpdateDocumentsWithIncrease( } foreach ($batch as $doc) { + + $doc = $this->adapter->internalCastingFrom($collection, $doc); + if ($this->resolveRelationships) { $doc = $this->silent(fn () => $this->populateDocumentRelationships($collection, $doc)); } @@ -5088,7 +5115,7 @@ public function createOrUpdateDocumentsWithIncrease( } else { $this->purgeCachedDocument($collection->getId(), $doc->getId()); } - + $onNext && $onNext($doc); } } @@ -5197,7 +5224,7 @@ public function increaseDocumentAttribute( $this->purgeCachedDocument($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_INCREASE, $document); - + return $document; } @@ -5316,16 +5343,16 @@ public function decreaseDocumentAttribute( public function deleteDocument(string $collection, string $id): bool { $collection = $this->silent(fn () => $this->getCollection($collection)); - + $deleted = $this->withTransaction(function () use ($collection, $id, &$document) { $document = Authorization::skip(fn () => $this->silent( fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) )); - + if ($document->isEmpty()) { return false; } - + $validator = new Authorization(self::PERMISSION_DELETE); if ($collection->getId() !== self::METADATA) { @@ -5352,9 +5379,9 @@ public function deleteDocument(string $collection, string $id): bool if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document)); } - + $result = $this->adapter->deleteDocument($collection->getId(), $id); - + $this->purgeCachedDocument($collection->getId(), $id); return $result; @@ -6043,12 +6070,17 @@ public function find(string $collection, array $queries = [], string $forPermiss throw new DatabaseException("cursor Document must be from the same Collection."); } + if (!empty($cursor)) { + $cursor = $this->adapter->internalCastingTo($collection, $cursor); + } + $cursor = empty($cursor) ? [] : $this->encode($collection, $cursor)->getArrayCopy(); + /** @var array $queries */ $queries = \array_merge( $selects, - self::convertQueries($collection, $filters) + $this->convertQueries($collection, $filters) ); $selections = $this->validateSelections($collection, $selects); @@ -6095,8 +6127,8 @@ public function find(string $collection, array $queries = [], string $forPermiss } } - $queries = \array_values($queries); - + $queries = \array_values($queries); + $getResults = fn () => $this->adapter->find( $collection->getId(), $queries, @@ -6112,6 +6144,10 @@ public function find(string $collection, array $queries = [], string $forPermiss $results = $skipAuth ? Authorization::skip($getResults) : $getResults(); foreach ($results as &$node) { + //var_dump($node); + $node = $this->adapter->internalCastingFrom($collection, $node); + + if ($this->resolveRelationships && (empty($selects) || !empty($nestedSelections))) { $node = $this->silent(fn () => $this->populateDocumentRelationships($collection, $node, $nestedSelections)); } @@ -6123,7 +6159,8 @@ public function find(string $collection, array $queries = [], string $forPermiss $node->setAttribute('$collection', $collection->getId()); } } - + + unset($node); unset($query); $this->trigger(self::EVENT_DOCUMENT_FIND, $results); @@ -6253,7 +6290,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) } $queries = Query::groupByType($queries)['filters']; - $queries = self::convertQueries($collection, $queries); + $queries = $this->convertQueries($collection, $queries); $getCount = fn () => $this->adapter->count($collection->getId(), $queries, $max); $count = $skipAuth ?? false ? Authorization::skip($getCount) : $getCount(); @@ -6297,7 +6334,7 @@ public function sum(string $collection, string $attribute, array $queries = [], } } - $queries = self::convertQueries($collection, $queries); + $queries = $this->convertQueries($collection, $queries); $sum = $this->adapter->sum($collection->getId(), $attribute, $queries, $max); @@ -6664,7 +6701,7 @@ public function getLimitForIndexes(): int * @throws QueryException * @throws Exception */ - public static function convertQueries(Document $collection, array $queries): array + public function convertQueries(Document $collection, array $queries): array { $attributes = $collection->getAttribute('attributes', []); @@ -6678,14 +6715,21 @@ public static function convertQueries(Document $collection, array $queries): arr $query->setOnArray($attribute->getAttribute('array', false)); } } - + if ($attribute->getAttribute('type') == Database::VAR_DATETIME) { + foreach ($queries as $index => $query) { + //var_dump($query->getAttribute() ); if ($query->getAttribute() === $attribute->getId()) { $values = $query->getValues(); + foreach ($values as $valueIndex => $value) { try { - $values[$valueIndex] = DateTime::setTimezone($value); + if ($this->adapter->isMongo()) { + $values[$valueIndex] = $this->adapter->setUTCDatetime($value); + } else { + $values[$valueIndex] = DateTime::setTimezone($value); + } } catch (\Throwable $e) { throw new QueryException($e->getMessage(), $e->getCode(), $e); } diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 6a039fee7..7ea6020f9 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -59,7 +59,9 @@ public function testCreateListExistsDeleteCollection(): void $this->assertCount(2, $database->listCollections()); $this->assertEquals(true, $database->exists($this->testDatabase, 'actors2')); $collection = $database->getCollection('actors2'); + $collection->setAttribute('name', 'actors'); // change name to one that exists + $this->assertInstanceOf('Utopia\Database\Document', $database->updateDocument( $collection->getCollection(), $collection->getId(), @@ -1298,135 +1300,136 @@ public function testSharedTablesDuplicates(): void ->setDatabase($schema); } - public function testEvents(): void - { - Authorization::skip(function () { - $database = static::getDatabase(); - - $events = [ - Database::EVENT_DATABASE_CREATE, - Database::EVENT_DATABASE_LIST, - Database::EVENT_COLLECTION_CREATE, - Database::EVENT_COLLECTION_LIST, - Database::EVENT_COLLECTION_READ, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_ATTRIBUTE_CREATE, - Database::EVENT_ATTRIBUTE_UPDATE, - Database::EVENT_INDEX_CREATE, - Database::EVENT_DOCUMENT_CREATE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_UPDATE, - Database::EVENT_DOCUMENT_READ, - Database::EVENT_DOCUMENT_FIND, - Database::EVENT_DOCUMENT_FIND, - Database::EVENT_DOCUMENT_COUNT, - Database::EVENT_DOCUMENT_SUM, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_INCREASE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_DECREASE, - Database::EVENT_DOCUMENTS_CREATE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENTS_UPDATE, - Database::EVENT_INDEX_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENTS_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_ATTRIBUTE_DELETE, - Database::EVENT_COLLECTION_DELETE, - Database::EVENT_DATABASE_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_DOCUMENTS_DELETE, - Database::EVENT_DOCUMENT_PURGE, - Database::EVENT_ATTRIBUTE_DELETE, - Database::EVENT_COLLECTION_DELETE, - Database::EVENT_DATABASE_DELETE - ]; - - $database->on(Database::EVENT_ALL, 'test', function ($event, $data) use (&$events) { - $shifted = array_shift($events); - $this->assertEquals($shifted, $event); - }); - - if ($this->getDatabase()->getAdapter()->getSupportForSchemas()) { - $database->setDatabase('hellodb'); - $database->create(); - } else { - \array_shift($events); - } - - $database->list(); - - $database->setDatabase($this->testDatabase); - - $collectionId = ID::unique(); - $database->createCollection($collectionId); - $database->listCollections(); - $database->getCollection($collectionId); - $database->createAttribute($collectionId, 'attr1', Database::VAR_INTEGER, 2, false); - $database->updateAttributeRequired($collectionId, 'attr1', true); - $indexId1 = 'index2_' . uniqid(); - $database->createIndex($collectionId, $indexId1, Database::INDEX_KEY, ['attr1']); - - $document = $database->createDocument($collectionId, new Document([ - '$id' => 'doc1', - 'attr1' => 10, - '$permissions' => [ - Permission::delete(Role::any()), - Permission::update(Role::any()), - Permission::read(Role::any()), - ], - ])); - - $executed = false; - $database->on(Database::EVENT_ALL, 'should-not-execute', function ($event, $data) use (&$executed) { - $executed = true; - }); - - $database->silent(function () use ($database, $collectionId, $document) { - $database->updateDocument($collectionId, 'doc1', $document->setAttribute('attr1', 15)); - $database->getDocument($collectionId, 'doc1'); - $database->find($collectionId); - $database->findOne($collectionId); - $database->count($collectionId); - $database->sum($collectionId, 'attr1'); - $database->increaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); - $database->decreaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); - }, ['should-not-execute']); - - $this->assertFalse($executed); - - $database->createDocuments($collectionId, [ - new Document([ - 'attr1' => 10, - ]), - new Document([ - 'attr1' => 20, - ]), - ]); - - $database->updateDocuments($collectionId, new Document([ - 'attr1' => 15, - ])); - - $database->deleteIndex($collectionId, $indexId1); - $database->deleteDocument($collectionId, 'doc1'); - - $database->deleteDocuments($collectionId); - $database->deleteAttribute($collectionId, 'attr1'); - $database->deleteCollection($collectionId); - $database->delete('hellodb'); - - // Remove all listeners - $database->on(Database::EVENT_ALL, 'test', null); - $database->on(Database::EVENT_ALL, 'should-not-execute', null); - }); - } + // public function testEvents(): void + // { + // Authorization::skip(function () { + // $database = static::getDatabase(); + + // $events = [ + // Database::EVENT_DATABASE_CREATE, + // Database::EVENT_DATABASE_LIST, + // Database::EVENT_COLLECTION_CREATE, + // Database::EVENT_COLLECTION_LIST, + // Database::EVENT_COLLECTION_READ, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_ATTRIBUTE_CREATE, + // Database::EVENT_ATTRIBUTE_UPDATE, + // Database::EVENT_INDEX_CREATE, + // Database::EVENT_DOCUMENT_CREATE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENT_UPDATE, + // Database::EVENT_DOCUMENT_READ, + // Database::EVENT_DOCUMENT_FIND, + // Database::EVENT_DOCUMENT_FIND, + // Database::EVENT_DOCUMENT_COUNT, + // Database::EVENT_DOCUMENT_SUM, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENT_INCREASE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENT_DECREASE, + // Database::EVENT_DOCUMENTS_CREATE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENTS_UPDATE, + // Database::EVENT_INDEX_DELETE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENT_DELETE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENTS_DELETE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_ATTRIBUTE_DELETE, + // Database::EVENT_COLLECTION_DELETE, + // Database::EVENT_DATABASE_DELETE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_DOCUMENTS_DELETE, + // Database::EVENT_DOCUMENT_PURGE, + // Database::EVENT_ATTRIBUTE_DELETE, + // Database::EVENT_COLLECTION_DELETE, + // Database::EVENT_DATABASE_DELETE + // ]; + + // $database->on(Database::EVENT_ALL, 'test', function ($event, $data) use (&$events) { + // $shifted = array_shift($events); + // $this->assertEquals($shifted, $event); + // }); + + // if ($this->getDatabase()->getAdapter()->getSupportForSchemas()) { + // $database->setDatabase('hellodb'); + // $database->create(); + // } else { + // \array_shift($events); + // } + + // $database->list(); + + // $database->setDatabase($this->testDatabase); + + // $collectionId = ID::unique(); + // $database->createCollection($collectionId); + // $database->listCollections(); + // $database->getCollection($collectionId); + // $database->createAttribute($collectionId, 'attr1', Database::VAR_INTEGER, 2, false); + // $database->updateAttributeRequired($collectionId, 'attr1', true); + // $indexId1 = 'index2_' . uniqid(); + // $database->createIndex($collectionId, $indexId1, Database::INDEX_KEY, ['attr1']); + + // $document = $database->createDocument($collectionId, new Document([ + // '$id' => 'doc1', + // 'attr1' => 10, + // '$permissions' => [ + // Permission::delete(Role::any()), + // Permission::update(Role::any()), + // Permission::read(Role::any()), + // ], + // ])); + + // $executed = false; + // $database->on(Database::EVENT_ALL, 'should-not-execute', function ($event, $data) use (&$executed) { + // $executed = true; + // }); + + // $database->silent(function () use ($database, $collectionId, $document) { + // $database->updateDocument($collectionId, 'doc1', $document->setAttribute('attr1', 15)); + // $database->getDocument($collectionId, 'doc1'); + // $database->find($collectionId); + // $database->findOne($collectionId); + // $database->count($collectionId); + // $database->sum($collectionId, 'attr1'); + // $database->increaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); + // $database->decreaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); + // }, ['should-not-execute']); + + // $this->assertFalse($executed); + + // $database->createDocuments($collectionId, [ + // new Document([ + // 'attr1' => 10, + // ]), + // new Document([ + // 'attr1' => 20, + // ]), + // ]); + + // $database->updateDocuments($collectionId, new Document([ + // 'attr1' => 15, + // ])); + + // $database->deleteIndex($collectionId, $indexId1); + + // $database->deleteDocument($collectionId, 'doc1'); + + // $database->deleteDocuments($collectionId); + // $database->deleteAttribute($collectionId, 'attr1'); + // $database->deleteCollection($collectionId); + // $database->delete('hellodb'); + + // // Remove all listeners + // $database->on(Database::EVENT_ALL, 'test', null); + // $database->on(Database::EVENT_ALL, 'should-not-execute', null); + // }); + // } public function testCreatedAtUpdatedAt(): void { diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 3bdfa64f8..716c4846b 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -67,7 +67,7 @@ public function testCreateDocument(): Document 'empty' => [], 'with-dash' => 'Works', ])); - + //var_dump($document); $this->assertNotEmpty(true, $document->getId()); $this->assertIsString($document->getAttribute('string')); $this->assertEquals('text📝', $document->getAttribute('string')); // Also makes sure an emoji is working @@ -120,7 +120,7 @@ public function testCreateDocument(): Document 'empty' => [], 'with-dash' => 'Works', ])); - + $this->assertEquals('56000', $manualIdDocument->getSequence()); $this->assertNotEmpty(true, $manualIdDocument->getId()); $this->assertIsString($manualIdDocument->getAttribute('string')); @@ -246,9 +246,9 @@ public function testCreateDocuments(): void $count = $database->createDocuments($collection, $documents, 3, onNext: function ($doc) use (&$results) { $results[] = $doc; }); - + $this->assertEquals($count, \count($results)); - + foreach ($results as $document) { $this->assertNotEmpty(true, $document->getId()); $this->assertIsString($document->getAttribute('string')); @@ -451,10 +451,11 @@ public function testUpsertDocuments(): void $this->assertEquals(5, $document->getAttribute('integer')); $this->assertIsInt($document->getAttribute('bigint')); $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + } - + $documents = $database->find(__FUNCTION__); - + $this->assertEquals(2, count($documents)); foreach ($documents as $document) { From 0975fe59624b85b4145ba123fd9633b64769983f Mon Sep 17 00:00:00 2001 From: shimon Date: Thu, 24 Jul 2025 18:41:48 +0300 Subject: [PATCH 17/34] Refactor casting methods in Database adapter; rename internalCastingFrom/to to castingBefore/After for clarity --- src/Database/Adapter.php | 35 +-- src/Database/Adapter/Mongo.php | 219 ++++++++-------- src/Database/Adapter/SQL.php | 29 +- src/Database/Database.php | 106 ++++---- tests/e2e/Adapter/Scopes/CollectionTests.php | 262 +++++++++---------- tests/e2e/Adapter/Scopes/DocumentTests.php | 14 +- 6 files changed, 338 insertions(+), 327 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 2001cb058..b6ca45491 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -3,7 +3,6 @@ namespace Utopia\Database; use Exception; -use PhpParser\Node\Scalar\MagicConst\Dir; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Timeout as TimeoutException; @@ -1203,21 +1202,24 @@ abstract public function getTenantQuery(string $collection, string $alias = ''): abstract protected function execute(mixed $stmt): bool; /** - * Returns the document after casting from - * @param Document $collection - * @param Document $document - * @return Document - */ - abstract public function internalCastingFrom(Document $collection, Document $document): Document; + * Returns the document after casting + * @param Document $collection + * @param Document $document + * @return Document + */ + abstract public function castingBefore(Document $collection, Document $document): Document; /** - * Returns the document after casting to + * Returns the document after casting * @param Document $collection * @param Document $document * @return Document */ - abstract public function internalCastingTo(Document $collection, Document $document): Document; + abstract public function castingAfter(Document $collection, Document $document): Document; + /** + * Is Mongo? + * * @return bool */ abstract public function isMongo(): bool; @@ -1229,13 +1231,12 @@ abstract public function isMongo(): bool; */ abstract public function getSupportForInternalCasting(): bool; - /** - * Set UTC Datetime - * - * @param string $value - * @return mixed - */ - abstract function setUTCDatetime(string $value): mixed; + /** + * Set UTC Datetime + * + * @param string $value + * @return mixed + */ + abstract public function setUTCDatetime(string $value): mixed; } - diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index ba8df4270..91373ca0d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -6,8 +6,6 @@ use MongoDB\BSON\ObjectId; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; -use MongoDB\BSON\Int32; -use MongoDB\BSON\Int64; use Utopia\Database\Adapter; use Utopia\Database\Database; use Utopia\Database\DateTime; @@ -197,7 +195,7 @@ public function createCollection(string $name, array $attributes = [], array $in if ($name === Database::METADATA && $this->exists($this->getNamespace(), $name)) { return true; } - + // Returns an array/object with the result document try { $this->getClient()->createCollection($id); @@ -245,7 +243,7 @@ public function createCollection(string $name, array $attributes = [], array $in // 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] @@ -296,7 +294,7 @@ public function createCollection(string $name, array $attributes = [], array $in return false; } } - + return true; } @@ -661,7 +659,6 @@ public function createIndex(string $collection, string $id, string $type, array if (!empty($collation) && $type !== Database::INDEX_FULLTEXT) { - //$options['collation'] = $collation; $indexes['collation'] = [ 'locale' => 'en', 'strength' => 1, @@ -759,13 +756,12 @@ public function getDocument(string $collection, string $id, array $queries = [], } $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); } @@ -793,7 +789,6 @@ public function createDocument(string $collection, Document $document): Document } $record = $this->replaceChars('$', '_', (array)$document); - //$record = $this->timeToMongo($record); // Insert manual id if set if (!empty($sequence)) { @@ -803,129 +798,125 @@ public function createDocument(string $collection, Document $document): Document $result = $this->insertDocument($name, $this->removeNullKeys($record)); $result = $this->replaceChars('_', '$', $result); - //$result = $this->timeToDocument($result); return new Document($result); } - /** + /** * Returns the document after casting from *@param Document $collection * @param Document $document * @return Document */ -public function internalCastingFrom($collection, $document): Document -{ + public function castingAfter($collection, $document): Document + { - if (!$this->getSupportForInternalCasting()) { - return $document; - } + if (!$this->getSupportForInternalCasting()) { + return $document; + } - if($document->isEmpty()){ - return $document; - } + if ($document->isEmpty()) { + return $document; + } - $attributes = $collection->getAttribute('attributes', []); + $attributes = $collection->getAttribute('attributes', []); - $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES); - - foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - $array = $attribute['array'] ?? false; - $value = $document->getAttribute($key, null); - if (is_null($value)) { - continue; - } - - if ($array) { - $value = !is_string($value) - ? $value - : json_decode($value, true); - } else { - $value = [$value]; - } - - foreach ($value as &$node) { - //var_dump([$type, $key, $node]); - switch ($type) { - case Database::VAR_INTEGER: - $node = (int)$node; - break; - case Database::VAR_DATETIME : - $node = DateTime::format($node->toDateTime()); - break; - default: - break; + $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES); + + foreach ($attributes as $attribute) { + $key = $attribute['$id'] ?? ''; + $type = $attribute['type'] ?? ''; + $array = $attribute['array'] ?? false; + $value = $document->getAttribute($key, null); + if (is_null($value)) { + continue; } - } - unset($node); - $document->setAttribute($key, ($array) ? $value : $value[0]); - } - return $document; -} + if ($array) { + $value = !is_string($value) + ? $value + : json_decode($value, true); + } else { + $value = [$value]; + } + foreach ($value as &$node) { + switch ($type) { + case Database::VAR_INTEGER: + $node = (int)$node; + break; + case Database::VAR_DATETIME : + $node = DateTime::format($node->toDateTime()); + break; + default: + break; + } + } + unset($node); + $document->setAttribute($key, ($array) ? $value : $value[0]); + } + return $document; + } - /** + /** * Returns the document after casting to *@param Document $collection * @param Document $document * @return Document */ -public function internalCastingTo($collection, $document): Document -{ + public function castingBefore($collection, $document): Document + { - if (!$this->getSupportForInternalCasting()) { - return $document; - } + if (!$this->getSupportForInternalCasting()) { + return $document; + } - if($document->isEmpty()){ - return $document; - } + if ($document->isEmpty()) { + return $document; + } - $attributes = $collection->getAttribute('attributes', []); + $attributes = $collection->getAttribute('attributes', []); - $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES); + $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES); - foreach ($attributes as $attribute) { - - $key = $attribute['$id'] ?? ''; - $type = $attribute['type'] ?? ''; - $array = $attribute['array'] ?? false; - - $value = $document->getAttribute($key, null); - if (is_null($value)) { - continue; - } - - if ($array) { - $value = !is_string($value) - ? $value - : json_decode($value, true); - } else { - $value = [$value]; - } - - foreach ($value as &$node) { - switch ($type) { - case Database::VAR_DATETIME : - $node = new UTCDateTime(new \DateTime($node)); - break; - default: - break; + foreach ($attributes as $attribute) { + + $key = $attribute['$id'] ?? ''; + $type = $attribute['type'] ?? ''; + $array = $attribute['array'] ?? false; + + $value = $document->getAttribute($key, null); + if (is_null($value)) { + continue; } + + if ($array) { + $value = !is_string($value) + ? $value + : json_decode($value, true); + } else { + $value = [$value]; + } + + foreach ($value as &$node) { + switch ($type) { + case Database::VAR_DATETIME : + $node = new UTCDateTime(new \DateTime($node)); + break; + default: + break; + } + } + unset($node); + $document->setAttribute($key, ($array) ? $value : $value[0]); } - unset($node); - $document->setAttribute($key, ($array) ? $value : $value[0]); - } - return $document; -} + return $document; + } /** * Create Documents in batches @@ -961,7 +952,6 @@ public function createDocuments(string $collection, array $documents): array } $record = $this->replaceChars('$', '_', (array)$document); - //$record = $this->timeToMongo($record); if (!empty($sequence)) { $record['_id'] = $sequence; @@ -974,8 +964,6 @@ public function createDocuments(string $collection, array $documents): array 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]); } @@ -994,7 +982,7 @@ private function insertDocument(string $name, array $document): array { try { - $this->client->insert($name, $document); + $this->client->insert($name, $document); $filters = []; $filters['_uid'] = $document['_uid']; @@ -1033,7 +1021,6 @@ public function updateDocument(string $collection, string $id, Document $documen $record = $document->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); - //$record = $this->timeToMongo($record); $filters = []; $filters['_uid'] = $id; @@ -1066,6 +1053,7 @@ public function updateDocument(string $collection, string $id, Document $documen */ public function updateDocuments(string $collection, Document $updates, array $documents): int { + ; $name = $this->getNamespace() . '_' . $this->filter($collection); $queries = [ @@ -1080,7 +1068,7 @@ public function updateDocuments(string $collection, Document $updates, array $do $record = $updates->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); - //$record = $this->timeToMongo($record); + $updateQuery = [ '$set' => $record, @@ -1135,9 +1123,8 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a } $record = $this->replaceChars('$', '_', $attributes); - //$record = $this->timeToMongo($record); $record = $this->removeNullKeys($record); - + // Build filter for upsert $filter = ['_uid' => $document->getId()]; if ($this->sharedTables) { @@ -1160,8 +1147,8 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a 'filter' => $filter, 'update' => $update, ]; - } - + } + $this->client->upsert( $name, $operations, @@ -1311,7 +1298,7 @@ public function deleteDocuments(string $collection, array $sequences, array $per } $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - //$filters = $this->timeFilter($filters); + $options = []; try { @@ -1390,9 +1377,10 @@ protected function getInternalKeyForAttribute(string $attribute): string */ 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) { @@ -1465,7 +1453,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, } $tmp = $cursor[$originalAttribute]; - + if ($originalAttribute === '$sequence') { $tmp = new ObjectId($tmp); @@ -1493,10 +1481,9 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, if (!empty($orFilters)) { $filters['$or'] = $orFilters; } - + // Translate operators and handle time filters $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - //$filters = $this->timeFilter($filters); $found = []; @@ -1512,7 +1499,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, foreach ($this->client->toArray($results) as $result) { $record = $this->replaceChars('_', '$', (array)$result); - //$record = $this->timeToDocument($record); + $found[] = new Document($record); } @@ -2030,12 +2017,12 @@ public function getSupportForSchemas(): bool * * @return bool */ - public function getSupportForInternalCasting(): bool + public function getSupportForInternalCasting(): bool { return true; } - + public function isMongo(): bool { return true; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 65d472389..7505b0665 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -813,26 +813,49 @@ public function getSupportForSchemas(): bool * * @return bool */ - public function getSupportForInternalCasting(): bool + public function getSupportForInternalCasting(): bool { return false; } - public function internalCastingFrom(Document $collection, Document $document): Document + /** + * Returns the document after casting + * @param Document $collection + * @param Document $document + * @return Document + */ + public function castingBefore(Document $collection, Document $document): Document { return $document; } - public function internalCastingTo(Document $collection, Document $document): Document + /** + * Returns the document after casting + * @param Document $collection + * @param Document $document + * @return Document + */ + public function castingAfter(Document $collection, Document $document): Document { return $document; } + /** + * Is Mongo? + * + * @return bool + */ public function isMongo(): bool { return false; } + /** + * Set UTC Datetime + * + * @param string $value + * @return mixed + */ public function setUTCDatetime(string $value): mixed { return $value; diff --git a/src/Database/Database.php b/src/Database/Database.php index daddb52db..0c56a6468 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1204,7 +1204,7 @@ public function delete(?string $database = null): bool */ public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document { - + $permissions ??= [ Permission::create(Role::any()), ]; @@ -1377,7 +1377,7 @@ public function updateCollection(string $id, array $permissions, bool $documentS public function getCollection(string $id): Document { $collection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); - + if ( $id !== self::METADATA && $this->adapter->getSharedTables() @@ -3296,13 +3296,13 @@ public function getDocument(string $collection, string $id, array $queries = [], $queries, $forUpdate ); - + if ($document->isEmpty()) { return $document; } - - $document = $this->adapter->internalCastingFrom($collection, $document); - + + $document = $this->adapter->castingAfter($collection, $document); + $document->setAttribute('$collection', $collection->getId()); if ($collection->getId() !== self::METADATA) { @@ -3614,7 +3614,7 @@ public function createDocument(string $collection, Document $document): Document } $time = DateTime::now(); - + $createdAt = $document->getCreatedAt(); $updatedAt = $document->getUpdatedAt(); @@ -3638,7 +3638,7 @@ public function createDocument(string $collection, Document $document): Document } $document = $this->encode($collection, $document); - + if ($this->validate) { $validator = new Permissions(); if (!$validator->isValid($document->getPermissions())) { @@ -3655,26 +3655,22 @@ public function createDocument(string $collection, Document $document): Document throw new StructureException($structure->getDescription()); } - var_dump($document); - + $document = $this->adapter->castingBefore($collection, $document); + $document = $this->withTransaction(function () use ($collection, $document) { - if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); } - - $document = $this->adapter->internalCastingTo($collection, $document); - return $this->adapter->createDocument($collection->getId(), $document); }); - - $document = $this->adapter->internalCastingFrom($collection, $document); - + + $document = $this->adapter->castingAfter($collection, $document); + if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); } - + $document = $this->decode($collection, $document); $this->trigger(self::EVENT_DOCUMENT_CREATE, $document); @@ -3755,9 +3751,9 @@ public function createDocuments( if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); } - - $document = $this->adapter->internalCastingTo($collection, $document); - + + $document = $this->adapter->castingBefore($collection, $document); + } foreach (\array_chunk($documents, $batchSize) as $chunk) { @@ -3766,7 +3762,7 @@ public function createDocuments( }); foreach ($batch as $document) { - $document = $this->adapter->internalCastingFrom($collection, $document); + $document = $this->adapter->castingAfter($collection, $document); if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); } @@ -4122,13 +4118,13 @@ private function relateDocumentsById( */ public function updateDocument(string $collection, string $id, Document $document): Document { - + if (!$id) { throw new DatabaseException('Must define $id attribute'); } $collection = $this->silent(fn () => $this->getCollection($collection)); - + $document = $this->withTransaction(function () use ($collection, $id, $document) { $time = DateTime::now(); $old = Authorization::skip(fn () => $this->silent( @@ -4290,11 +4286,12 @@ public function updateDocument(string $collection, string $id, Document $documen if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->updateDocumentRelationships($collection, $old, $document)); } - $document = $this->adapter->internalCastingTo($collection, $document); - + + $document = $this->adapter->castingBefore($collection, $document); + $this->adapter->updateDocument($collection->getId(), $id, $document); - - $document = $this->adapter->internalCastingFrom($collection, $document); + + $document = $this->adapter->castingAfter($collection, $document); $this->purgeCachedDocument($collection->getId(), $id); @@ -4458,10 +4455,14 @@ public function updateDocuments( throw new ConflictException('Document was updated after the request timestamp'); } $document = $this->encode($collection, $document); - $document = $this->adapter->internalCastingTo($collection, $document); + $document = $this->adapter->castingBefore($collection, $document); } + unset($document); + + $updates = $this->adapter->castingBefore($collection, $updates); $this->withTransaction(function () use ($collection, $updates, $batch) { + $this->adapter->updateDocuments( $collection->getId(), $updates, @@ -4470,7 +4471,7 @@ public function updateDocuments( }); foreach ($batch as $doc) { - $doc = $this->adapter->internalCastingFrom($collection, $doc); + $doc = $this->adapter->castingAfter($collection, $doc); $this->purgeCachedDocument($collection->getId(), $doc->getId()); $doc = $this->decode($collection, $doc); $onNext && $onNext($doc); @@ -5066,9 +5067,9 @@ public function createOrUpdateDocumentsWithIncrease( $seenIds[] = $document->getId(); - $old = $this->adapter->internalCastingTo($collection, $old); - $document = $this->adapter->internalCastingTo($collection, $document); - + $old = $this->adapter->castingBefore($collection, $old); + $document = $this->adapter->castingBefore($collection, $document); + $documents[$key] = new Change( old: $old, new: $document @@ -5100,8 +5101,8 @@ public function createOrUpdateDocumentsWithIncrease( foreach ($batch as $doc) { - $doc = $this->adapter->internalCastingFrom($collection, $doc); - + $doc = $this->adapter->castingAfter($collection, $doc); + if ($this->resolveRelationships) { $doc = $this->silent(fn () => $this->populateDocumentRelationships($collection, $doc)); } @@ -5115,7 +5116,7 @@ public function createOrUpdateDocumentsWithIncrease( } else { $this->purgeCachedDocument($collection->getId(), $doc->getId()); } - + $onNext && $onNext($doc); } } @@ -5224,7 +5225,7 @@ public function increaseDocumentAttribute( $this->purgeCachedDocument($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_INCREASE, $document); - + return $document; } @@ -5343,16 +5344,16 @@ public function decreaseDocumentAttribute( public function deleteDocument(string $collection, string $id): bool { $collection = $this->silent(fn () => $this->getCollection($collection)); - + $deleted = $this->withTransaction(function () use ($collection, $id, &$document) { $document = Authorization::skip(fn () => $this->silent( fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) )); - + if ($document->isEmpty()) { return false; } - + $validator = new Authorization(self::PERMISSION_DELETE); if ($collection->getId() !== self::METADATA) { @@ -5379,9 +5380,9 @@ public function deleteDocument(string $collection, string $id): bool if ($this->resolveRelationships) { $document = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document)); } - + $result = $this->adapter->deleteDocument($collection->getId(), $id); - + $this->purgeCachedDocument($collection->getId(), $id); return $result; @@ -6071,12 +6072,12 @@ public function find(string $collection, array $queries = [], string $forPermiss } if (!empty($cursor)) { - $cursor = $this->adapter->internalCastingTo($collection, $cursor); + $cursor = $this->adapter->castingBefore($collection, $cursor); } $cursor = empty($cursor) ? [] : $this->encode($collection, $cursor)->getArrayCopy(); - + /** @var array $queries */ $queries = \array_merge( $selects, @@ -6127,8 +6128,8 @@ public function find(string $collection, array $queries = [], string $forPermiss } } - $queries = \array_values($queries); - + $queries = \array_values($queries); + $getResults = fn () => $this->adapter->find( $collection->getId(), $queries, @@ -6145,7 +6146,7 @@ public function find(string $collection, array $queries = [], string $forPermiss foreach ($results as &$node) { //var_dump($node); - $node = $this->adapter->internalCastingFrom($collection, $node); + $node = $this->adapter->castingAfter($collection, $node); if ($this->resolveRelationships && (empty($selects) || !empty($nestedSelections))) { @@ -6159,7 +6160,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $node->setAttribute('$collection', $collection->getId()); } } - + unset($node); unset($query); @@ -6701,7 +6702,7 @@ public function getLimitForIndexes(): int * @throws QueryException * @throws Exception */ - public function convertQueries(Document $collection, array $queries): array + public function convertQueries(Document $collection, array $queries): array { $attributes = $collection->getAttribute('attributes', []); @@ -6715,14 +6716,13 @@ public function convertQueries(Document $collection, array $queries): array $query->setOnArray($attribute->getAttribute('array', false)); } } - + if ($attribute->getAttribute('type') == Database::VAR_DATETIME) { - + foreach ($queries as $index => $query) { - //var_dump($query->getAttribute() ); if ($query->getAttribute() === $attribute->getId()) { $values = $query->getValues(); - + foreach ($values as $valueIndex => $value) { try { if ($this->adapter->isMongo()) { diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 7ea6020f9..065050b1f 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -59,7 +59,7 @@ public function testCreateListExistsDeleteCollection(): void $this->assertCount(2, $database->listCollections()); $this->assertEquals(true, $database->exists($this->testDatabase, 'actors2')); $collection = $database->getCollection('actors2'); - + $collection->setAttribute('name', 'actors'); // change name to one that exists $this->assertInstanceOf('Utopia\Database\Document', $database->updateDocument( @@ -1300,136 +1300,136 @@ public function testSharedTablesDuplicates(): void ->setDatabase($schema); } - // public function testEvents(): void - // { - // Authorization::skip(function () { - // $database = static::getDatabase(); - - // $events = [ - // Database::EVENT_DATABASE_CREATE, - // Database::EVENT_DATABASE_LIST, - // Database::EVENT_COLLECTION_CREATE, - // Database::EVENT_COLLECTION_LIST, - // Database::EVENT_COLLECTION_READ, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_ATTRIBUTE_CREATE, - // Database::EVENT_ATTRIBUTE_UPDATE, - // Database::EVENT_INDEX_CREATE, - // Database::EVENT_DOCUMENT_CREATE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENT_UPDATE, - // Database::EVENT_DOCUMENT_READ, - // Database::EVENT_DOCUMENT_FIND, - // Database::EVENT_DOCUMENT_FIND, - // Database::EVENT_DOCUMENT_COUNT, - // Database::EVENT_DOCUMENT_SUM, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENT_INCREASE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENT_DECREASE, - // Database::EVENT_DOCUMENTS_CREATE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENTS_UPDATE, - // Database::EVENT_INDEX_DELETE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENT_DELETE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENTS_DELETE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_ATTRIBUTE_DELETE, - // Database::EVENT_COLLECTION_DELETE, - // Database::EVENT_DATABASE_DELETE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_DOCUMENTS_DELETE, - // Database::EVENT_DOCUMENT_PURGE, - // Database::EVENT_ATTRIBUTE_DELETE, - // Database::EVENT_COLLECTION_DELETE, - // Database::EVENT_DATABASE_DELETE - // ]; - - // $database->on(Database::EVENT_ALL, 'test', function ($event, $data) use (&$events) { - // $shifted = array_shift($events); - // $this->assertEquals($shifted, $event); - // }); - - // if ($this->getDatabase()->getAdapter()->getSupportForSchemas()) { - // $database->setDatabase('hellodb'); - // $database->create(); - // } else { - // \array_shift($events); - // } - - // $database->list(); - - // $database->setDatabase($this->testDatabase); - - // $collectionId = ID::unique(); - // $database->createCollection($collectionId); - // $database->listCollections(); - // $database->getCollection($collectionId); - // $database->createAttribute($collectionId, 'attr1', Database::VAR_INTEGER, 2, false); - // $database->updateAttributeRequired($collectionId, 'attr1', true); - // $indexId1 = 'index2_' . uniqid(); - // $database->createIndex($collectionId, $indexId1, Database::INDEX_KEY, ['attr1']); - - // $document = $database->createDocument($collectionId, new Document([ - // '$id' => 'doc1', - // 'attr1' => 10, - // '$permissions' => [ - // Permission::delete(Role::any()), - // Permission::update(Role::any()), - // Permission::read(Role::any()), - // ], - // ])); - - // $executed = false; - // $database->on(Database::EVENT_ALL, 'should-not-execute', function ($event, $data) use (&$executed) { - // $executed = true; - // }); - - // $database->silent(function () use ($database, $collectionId, $document) { - // $database->updateDocument($collectionId, 'doc1', $document->setAttribute('attr1', 15)); - // $database->getDocument($collectionId, 'doc1'); - // $database->find($collectionId); - // $database->findOne($collectionId); - // $database->count($collectionId); - // $database->sum($collectionId, 'attr1'); - // $database->increaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); - // $database->decreaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); - // }, ['should-not-execute']); - - // $this->assertFalse($executed); - - // $database->createDocuments($collectionId, [ - // new Document([ - // 'attr1' => 10, - // ]), - // new Document([ - // 'attr1' => 20, - // ]), - // ]); - - // $database->updateDocuments($collectionId, new Document([ - // 'attr1' => 15, - // ])); - - // $database->deleteIndex($collectionId, $indexId1); - - // $database->deleteDocument($collectionId, 'doc1'); - - // $database->deleteDocuments($collectionId); - // $database->deleteAttribute($collectionId, 'attr1'); - // $database->deleteCollection($collectionId); - // $database->delete('hellodb'); - - // // Remove all listeners - // $database->on(Database::EVENT_ALL, 'test', null); - // $database->on(Database::EVENT_ALL, 'should-not-execute', null); - // }); - // } + public function testEvents(): void + { + Authorization::skip(function () { + $database = static::getDatabase(); + + $events = [ + Database::EVENT_DATABASE_CREATE, + Database::EVENT_DATABASE_LIST, + Database::EVENT_COLLECTION_CREATE, + Database::EVENT_COLLECTION_LIST, + Database::EVENT_COLLECTION_READ, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_ATTRIBUTE_CREATE, + Database::EVENT_ATTRIBUTE_UPDATE, + Database::EVENT_INDEX_CREATE, + Database::EVENT_DOCUMENT_CREATE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENT_UPDATE, + Database::EVENT_DOCUMENT_READ, + Database::EVENT_DOCUMENT_FIND, + Database::EVENT_DOCUMENT_FIND, + Database::EVENT_DOCUMENT_COUNT, + Database::EVENT_DOCUMENT_SUM, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENT_INCREASE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENT_DECREASE, + Database::EVENT_DOCUMENTS_CREATE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENTS_UPDATE, + Database::EVENT_INDEX_DELETE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENT_DELETE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENTS_DELETE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_ATTRIBUTE_DELETE, + Database::EVENT_COLLECTION_DELETE, + Database::EVENT_DATABASE_DELETE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_DOCUMENTS_DELETE, + Database::EVENT_DOCUMENT_PURGE, + Database::EVENT_ATTRIBUTE_DELETE, + Database::EVENT_COLLECTION_DELETE, + Database::EVENT_DATABASE_DELETE + ]; + + $database->on(Database::EVENT_ALL, 'test', function ($event, $data) use (&$events) { + $shifted = array_shift($events); + $this->assertEquals($shifted, $event); + }); + + if ($this->getDatabase()->getAdapter()->getSupportForSchemas()) { + $database->setDatabase('hellodb'); + $database->create(); + } else { + \array_shift($events); + } + + $database->list(); + + $database->setDatabase($this->testDatabase); + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + $database->listCollections(); + $database->getCollection($collectionId); + $database->createAttribute($collectionId, 'attr1', Database::VAR_INTEGER, 2, false); + $database->updateAttributeRequired($collectionId, 'attr1', true); + $indexId1 = 'index2_' . uniqid(); + $database->createIndex($collectionId, $indexId1, Database::INDEX_KEY, ['attr1']); + + $document = $database->createDocument($collectionId, new Document([ + '$id' => 'doc1', + 'attr1' => 10, + '$permissions' => [ + Permission::delete(Role::any()), + Permission::update(Role::any()), + Permission::read(Role::any()), + ], + ])); + + $executed = false; + $database->on(Database::EVENT_ALL, 'should-not-execute', function ($event, $data) use (&$executed) { + $executed = true; + }); + + $database->silent(function () use ($database, $collectionId, $document) { + $database->updateDocument($collectionId, 'doc1', $document->setAttribute('attr1', 15)); + $database->getDocument($collectionId, 'doc1'); + $database->find($collectionId); + $database->findOne($collectionId); + $database->count($collectionId); + $database->sum($collectionId, 'attr1'); + $database->increaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); + $database->decreaseDocumentAttribute($collectionId, $document->getId(), 'attr1'); + }, ['should-not-execute']); + + $this->assertFalse($executed); + + $database->createDocuments($collectionId, [ + new Document([ + 'attr1' => 10, + ]), + new Document([ + 'attr1' => 20, + ]), + ]); + + $database->updateDocuments($collectionId, new Document([ + 'attr1' => 15, + ])); + + $database->deleteIndex($collectionId, $indexId1); + + $database->deleteDocument($collectionId, 'doc1'); + + $database->deleteDocuments($collectionId); + $database->deleteAttribute($collectionId, 'attr1'); + $database->deleteCollection($collectionId); + $database->delete('hellodb'); + + // Remove all listeners + $database->on(Database::EVENT_ALL, 'test', null); + $database->on(Database::EVENT_ALL, 'should-not-execute', null); + }); + } public function testCreatedAtUpdatedAt(): void { diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 716c4846b..f040f8767 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -67,7 +67,7 @@ public function testCreateDocument(): Document 'empty' => [], 'with-dash' => 'Works', ])); - //var_dump($document); + //var_dump($document); $this->assertNotEmpty(true, $document->getId()); $this->assertIsString($document->getAttribute('string')); $this->assertEquals('text📝', $document->getAttribute('string')); // Also makes sure an emoji is working @@ -120,7 +120,7 @@ public function testCreateDocument(): Document 'empty' => [], 'with-dash' => 'Works', ])); - + $this->assertEquals('56000', $manualIdDocument->getSequence()); $this->assertNotEmpty(true, $manualIdDocument->getId()); $this->assertIsString($manualIdDocument->getAttribute('string')); @@ -246,9 +246,9 @@ public function testCreateDocuments(): void $count = $database->createDocuments($collection, $documents, 3, onNext: function ($doc) use (&$results) { $results[] = $doc; }); - + $this->assertEquals($count, \count($results)); - + foreach ($results as $document) { $this->assertNotEmpty(true, $document->getId()); $this->assertIsString($document->getAttribute('string')); @@ -451,11 +451,11 @@ public function testUpsertDocuments(): void $this->assertEquals(5, $document->getAttribute('integer')); $this->assertIsInt($document->getAttribute('bigint')); $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); - + } - + $documents = $database->find(__FUNCTION__); - + $this->assertEquals(2, count($documents)); foreach ($documents as $document) { From 7d976e62639cf0e765544101c7fd540de5a90663 Mon Sep 17 00:00:00 2001 From: shimon Date: Thu, 24 Jul 2025 18:49:26 +0300 Subject: [PATCH 18/34] updates --- src/Database/Database.php | 7 +------ tests/e2e/Adapter/Scopes/CollectionTests.php | 3 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 3 +-- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 0c56a6468..bb39b2425 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1204,7 +1204,6 @@ public function delete(?string $database = null): bool */ public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document { - $permissions ??= [ Permission::create(Role::any()), ]; @@ -3314,6 +3313,7 @@ public function getDocument(string $collection, string $id, array $queries = [], } } + $document = $this->casting($collection, $document); $document = $this->decode($collection, $document, $selections); $this->map = []; @@ -3670,7 +3670,6 @@ public function createDocument(string $collection, Document $document): Document $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); } - $document = $this->decode($collection, $document); $this->trigger(self::EVENT_DOCUMENT_CREATE, $document); @@ -4118,7 +4117,6 @@ private function relateDocumentsById( */ public function updateDocument(string $collection, string $id, Document $document): Document { - if (!$id) { throw new DatabaseException('Must define $id attribute'); } @@ -6077,7 +6075,6 @@ public function find(string $collection, array $queries = [], string $forPermiss $cursor = empty($cursor) ? [] : $this->encode($collection, $cursor)->getArrayCopy(); - /** @var array $queries */ $queries = \array_merge( $selects, @@ -6718,11 +6715,9 @@ public function convertQueries(Document $collection, array $queries): array } if ($attribute->getAttribute('type') == Database::VAR_DATETIME) { - foreach ($queries as $index => $query) { if ($query->getAttribute() === $attribute->getId()) { $values = $query->getValues(); - foreach ($values as $valueIndex => $value) { try { if ($this->adapter->isMongo()) { diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 065050b1f..6a039fee7 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -59,9 +59,7 @@ public function testCreateListExistsDeleteCollection(): void $this->assertCount(2, $database->listCollections()); $this->assertEquals(true, $database->exists($this->testDatabase, 'actors2')); $collection = $database->getCollection('actors2'); - $collection->setAttribute('name', 'actors'); // change name to one that exists - $this->assertInstanceOf('Utopia\Database\Document', $database->updateDocument( $collection->getCollection(), $collection->getId(), @@ -1417,7 +1415,6 @@ public function testEvents(): void ])); $database->deleteIndex($collectionId, $indexId1); - $database->deleteDocument($collectionId, 'doc1'); $database->deleteDocuments($collectionId); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index f040f8767..3bdfa64f8 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -67,7 +67,7 @@ public function testCreateDocument(): Document 'empty' => [], 'with-dash' => 'Works', ])); - //var_dump($document); + $this->assertNotEmpty(true, $document->getId()); $this->assertIsString($document->getAttribute('string')); $this->assertEquals('text📝', $document->getAttribute('string')); // Also makes sure an emoji is working @@ -451,7 +451,6 @@ public function testUpsertDocuments(): void $this->assertEquals(5, $document->getAttribute('integer')); $this->assertIsInt($document->getAttribute('bigint')); $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); - } $documents = $database->find(__FUNCTION__); From 7b7c4f236630da59cc3fddb4d25f0fe45aee530c Mon Sep 17 00:00:00 2001 From: shimon Date: Thu, 24 Jul 2025 19:11:50 +0300 Subject: [PATCH 19/34] Update utopia-php/mongo dependency to version 0.4.* in composer.json and composer.lock --- composer.json | 2 +- composer.lock | 27 +++++++++------------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 1231c2144..7c840cd7e 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-bulk-writes-2 as 0.3.1" + "utopia-php/mongo": "0.4.*" }, "require-dev": { "fakerphp/faker": "1.23.*", diff --git a/composer.lock b/composer.lock index 4561b2fc2..349571189 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": "d0116653391026bc9593e93a53760ab7", + "content-hash": "d671739cd5467316e960b1879d08ee6a", "packages": [ { "name": "brick/math", @@ -2070,16 +2070,16 @@ }, { "name": "utopia-php/mongo", - "version": "dev-feat-bulk-writes-2", + "version": "0.4.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "0516d0d325ea54c093f18435e0b11795c1293328" + "reference": "6b62e8daa51edfb648984c2c57cf977e87cbc444" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/0516d0d325ea54c093f18435e0b11795c1293328", - "reference": "0516d0d325ea54c093f18435e0b11795c1293328", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/6b62e8daa51edfb648984c2c57cf977e87cbc444", + "reference": "6b62e8daa51edfb648984c2c57cf977e87cbc444", "shasum": "" }, "require": { @@ -2124,9 +2124,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/feat-bulk-writes-2" + "source": "https://github.com/utopia-php/mongo/tree/0.4.0" }, - "time": "2025-07-22T13:34:49+00:00" + "time": "2025-07-23T14:55:58+00:00" }, { "name": "utopia-php/pools", @@ -4335,18 +4335,9 @@ "time": "2022-10-09T10:19:07+00:00" } ], - "aliases": [ - { - "package": "utopia-php/mongo", - "version": "dev-feat-bulk-writes-2", - "alias": "0.3.1", - "alias_normalized": "0.3.1.0" - } - ], + "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "utopia-php/mongo": 20 - }, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { From fd0b29b750376d403b4e030ce626403652326a9f Mon Sep 17 00:00:00 2001 From: shimon Date: Sat, 26 Jul 2025 12:07:04 +0300 Subject: [PATCH 20/34] Update utopia-php/mongo dependency to version 0.5.* in composer.json and composer.lock --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 7c840cd7e..27d390922 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": "0.4.*" + "utopia-php/mongo": "0.5.*" }, "require-dev": { "fakerphp/faker": "1.23.*", diff --git a/composer.lock b/composer.lock index 349571189..8598421d1 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": "d671739cd5467316e960b1879d08ee6a", + "content-hash": "f6dc7d44d9bb06432e3a2d2bf026022a", "packages": [ { "name": "brick/math", @@ -2070,16 +2070,16 @@ }, { "name": "utopia-php/mongo", - "version": "0.4.0", + "version": "0.5.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "6b62e8daa51edfb648984c2c57cf977e87cbc444" + "reference": "b7a4901f552f6383b274d5a6c84feba6357afa95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/6b62e8daa51edfb648984c2c57cf977e87cbc444", - "reference": "6b62e8daa51edfb648984c2c57cf977e87cbc444", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/b7a4901f552f6383b274d5a6c84feba6357afa95", + "reference": "b7a4901f552f6383b274d5a6c84feba6357afa95", "shasum": "" }, "require": { @@ -2124,9 +2124,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/0.4.0" + "source": "https://github.com/utopia-php/mongo/tree/0.5.0" }, - "time": "2025-07-23T14:55:58+00:00" + "time": "2025-07-25T04:02:37+00:00" }, { "name": "utopia-php/pools", From 10df727fd332a768b1ea590e7bc611a4313dd454 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 27 Jul 2025 12:10:29 +0300 Subject: [PATCH 21/34] sync with main --- src/Database/Adapter/Mongo.php | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 91373ca0d..466f23729 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1181,7 +1181,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a * @param array $documentTenants * @return array */ - protected function getSequences(string $collection, array $documentIds, array $documentTenants = []): array + public function getSequences(string $collection, array $documentIds, array $documentTenants = []): array { $sequences = []; $name = $this->getNamespace() . '_' . $this->filter($collection); @@ -2012,6 +2012,21 @@ public function getSupportForSchemas(): bool return false; } + /** + * Is index supported? + * + * @return bool + */ + public function getSupportForIndex(): bool + { + return true; + } + + public function getSupportForIndexArray(): bool + { + return true; + } + /** * Is internal casting supported? * @@ -2044,16 +2059,6 @@ public function getSupportForAttributes(): bool return false; } - /** - * Is index supported? - * - * @return bool - */ - public function getSupportForIndex(): bool - { - return true; - } - /** * Is unique index supported? * @@ -2368,4 +2373,5 @@ public function getTenantQuery(string $collection, string $parentAlias = ''): st { return $this->getTenant(); } + } From 57e323d527cb7523e9b2a4d13a1a64785fef8f2c Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 27 Jul 2025 12:22:02 +0300 Subject: [PATCH 22/34] Refactor getSequences method in Mongo adapter to accept an array of documents, improving sequence retrieval logic and preserving document structure. --- src/Database/Adapter/Mongo.php | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 466f23729..7da6baa3b 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1181,19 +1181,34 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a * @param array $documentTenants * @return array */ - public function getSequences(string $collection, array $documentIds, array $documentTenants = []): array + public function getSequences(string $collection, array $documents): array { + $documentIds = []; + $documentTenants = []; + + foreach ($documents as $document) { + if (empty($document->getSequence())) { + $documentIds[] = $document->getId(); + + if ($this->sharedTables) { + $documentTenants[] = $document->getTenant(); + } + } + } + + if (empty($documentIds)) { + return $documents; + } + $sequences = []; $name = $this->getNamespace() . '_' . $this->filter($collection); - // Process in chunks to avoid large queries - foreach (\array_chunk($documentIds, 1000) as $documentIdsChunk) { + foreach (\array_chunk($documentIds, 1000) as $index => $documentIdsChunk) { $filters = ['_uid' => ['$in' => $documentIdsChunk]]; if ($this->sharedTables) { - $tenantChunk = \array_slice($documentTenants, 0, \count($documentIdsChunk)); + $tenantChunk = \array_slice($documentTenants, $index * 1000, \count($documentIdsChunk)); $filters['_tenant'] = ['$in' => $tenantChunk]; - $documentTenants = \array_slice($documentTenants, \count($documentIdsChunk)); } try { @@ -1207,8 +1222,13 @@ public function getSequences(string $collection, array $documentIds, array $docu continue; } } + foreach ($documents as $document) { + if (isset($sequences[$document->getId()])) { + $document['$sequence'] = $sequences[$document->getId()]; + } + } - return $sequences; + return $documents; } /** From fd5e24ce6a05b1c1fee9e695f967ad0ff4e577a8 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 27 Jul 2025 16:09:47 +0300 Subject: [PATCH 23/34] Enhance sequence retrieval in Mongo adapter by creating temporary documents for sequences without existing IDs, and update index length checks in tests to align with maximum index length constraints. --- src/Database/Adapter/Mongo.php | 12 +++++++++--- src/Database/Database.php | 4 ++-- tests/e2e/Adapter/Scopes/IndexTests.php | 6 +++--- tests/e2e/Adapter/Scopes/PermissionTests.php | 10 ++++++++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 7da6baa3b..6d202ff21 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1155,9 +1155,16 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a ["ordered" => false] // TODO Do we want to continue if an error is thrown? ); - // Get sequences for documents that were created if (!empty($documentIds)) { - $sequences = $this->getSequences($collection, $documentIds, $documentTenants); + // Create temporary documents for getSequences + $tempDocuments = []; + foreach ($changes as $change) { + if (empty($change->getNew()->getSequence())) { + $tempDocuments[] = $change->getNew(); + } + } + + $sequences = $this->getSequences($collection, $tempDocuments); foreach ($changes as $change) { if (isset($sequences[$change->getNew()->getId()])) { @@ -1185,7 +1192,6 @@ public function getSequences(string $collection, array $documents): array { $documentIds = []; $documentTenants = []; - foreach ($documents as $document) { if (empty($document->getSequence())) { $documentIds[] = $document->getId(); diff --git a/src/Database/Database.php b/src/Database/Database.php index 306395709..631fb83cd 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5079,14 +5079,14 @@ public function createOrUpdateDocumentsWithIncrease( /** * @var array $chunk */ + $batch = $this->withTransaction(fn () => Authorization::skip(fn () => $this->adapter->createOrUpdateDocuments( $collection->getId(), $attribute, $chunk ))); - $batch = $this->adapter->getSequences($collection->getId(), $batch); - + foreach ($chunk as $change) { if ($change->getOld()->isEmpty()) { $created++; diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index ac8b11da7..1d40c553e 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -304,8 +304,8 @@ public function testIndexLengthZero(): void $database = static::getDatabase(); $database->createCollection(__FUNCTION__); - - $database->createAttribute(__FUNCTION__, 'title1', Database::VAR_STRING, 1000, true); + + $database->createAttribute(__FUNCTION__, 'title1', Database::VAR_STRING, $database->getAdapter()->getMaxIndexLength() + 300, true); try { $database->createIndex(__FUNCTION__, 'index_title1', Database::INDEX_KEY, ['title1'], [0]); @@ -319,7 +319,7 @@ public function testIndexLengthZero(): void $database->createIndex(__FUNCTION__, 'index_title2', Database::INDEX_KEY, ['title2'], [0]); try { - $database->updateAttribute(__FUNCTION__, 'title2', Database::VAR_STRING, 1000, true); + $database->updateAttribute(__FUNCTION__, 'title2', Database::VAR_STRING, $database->getAdapter()->getMaxIndexLength() + 300, true); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->getMaxIndexLength(), $e->getMessage()); diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index e2c82600f..97ebc8de1 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -755,6 +755,11 @@ public function testCollectionPermissionsRelationshipsFindWorks(array $data): vo /** @var Database $database */ $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + $documents = $database->find( $collection->getId() ); @@ -832,6 +837,11 @@ public function testCollectionPermissionsRelationshipsGetWorks(array $data): arr /** @var Database $database */ $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return []; + } + $document = $database->getDocument( $collection->getId(), $document->getId() From b373d1d1425b7b33080dd4e42e19b5a717a431ef Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 27 Jul 2025 16:18:06 +0300 Subject: [PATCH 24/34] Clean up whitespace in Database and Mongo adapter files, and in test files to improve code readability and maintainability. --- src/Database/Adapter/Mongo.php | 4 ++-- src/Database/Database.php | 4 ++-- tests/e2e/Adapter/Scopes/IndexTests.php | 2 +- tests/e2e/Adapter/Scopes/PermissionTests.php | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 6d202ff21..433af6c2d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1163,7 +1163,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $tempDocuments[] = $change->getNew(); } } - + $sequences = $this->getSequences($collection, $tempDocuments); foreach ($changes as $change) { @@ -1195,7 +1195,7 @@ public function getSequences(string $collection, array $documents): array foreach ($documents as $document) { if (empty($document->getSequence())) { $documentIds[] = $document->getId(); - + if ($this->sharedTables) { $documentTenants[] = $document->getTenant(); } diff --git a/src/Database/Database.php b/src/Database/Database.php index 631fb83cd..dce84737a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5079,14 +5079,14 @@ public function createOrUpdateDocumentsWithIncrease( /** * @var array $chunk */ - + $batch = $this->withTransaction(fn () => Authorization::skip(fn () => $this->adapter->createOrUpdateDocuments( $collection->getId(), $attribute, $chunk ))); $batch = $this->adapter->getSequences($collection->getId(), $batch); - + foreach ($chunk as $change) { if ($change->getOld()->isEmpty()) { $created++; diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index 1d40c553e..df3207f35 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -304,7 +304,7 @@ public function testIndexLengthZero(): void $database = static::getDatabase(); $database->createCollection(__FUNCTION__); - + $database->createAttribute(__FUNCTION__, 'title1', Database::VAR_STRING, $database->getAdapter()->getMaxIndexLength() + 300, true); try { diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 97ebc8de1..285ff2e4c 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -759,7 +759,7 @@ public function testCollectionPermissionsRelationshipsFindWorks(array $data): vo $this->expectNotToPerformAssertions(); return; } - + $documents = $database->find( $collection->getId() ); From 1e27b342585248bcc2f225c47ac77436d685f581 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 27 Jul 2025 17:07:31 +0300 Subject: [PATCH 25/34] Add casting methods and support checks in Pool adapter; update Mongo adapter for tenant compatibility --- src/Database/Adapter/Mongo.php | 13 +++++++------ src/Database/Adapter/Pool.php | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 433af6c2d..1801dd3a8 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -7,6 +7,7 @@ use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use Utopia\Database\Adapter; +use Utopia\Database\Change; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -1184,9 +1185,8 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a * Get sequences for documents that were created * * @param string $collection - * @param array $documentIds - * @param array $documentTenants - * @return array + * @param array $documents + * @return array */ public function getSequences(string $collection, array $documents): array { @@ -1309,8 +1309,8 @@ public function deleteDocument(string $collection, string $id): bool * Delete Documents * * @param string $collection - * @param array $ids - * + * @param array $sequences + * @param array $permissionIds * @return int */ public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int @@ -2397,7 +2397,8 @@ public function getSchemaAttributes(string $collection): array public function getTenantQuery(string $collection, string $parentAlias = ''): string { - return $this->getTenant(); + // ** tenant in mongodb is an int but we need to return a string in order to be compatible with the rest of the code + return (string)$this->getTenant(); } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index e64db87ec..37a2a4b6d 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -499,4 +499,29 @@ public function getSequences(string $collection, array $documents): array { return $this->delegate(__FUNCTION__, \func_get_args()); } + + public function castingBefore(Document $collection, Document $document): Document + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function castingAfter(Document $collection, Document $document): Document + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSupportForInternalCasting(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function isMongo(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function setUTCDatetime(string $value): mixed + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } } From bdb38b3d8ba74aaa771eaa142bf8a6b3b250e046 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 27 Jul 2025 17:30:23 +0300 Subject: [PATCH 26/34] Refactor Mongo adapter to enhance datetime handling by adding type checks for UTCDateTime instances and removing unused time conversion methods, improving code clarity and performance. --- src/Database/Adapter/Mongo.php | 75 +++------------------------------- src/Database/Database.php | 1 - 2 files changed, 6 insertions(+), 70 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 1801dd3a8..8988c2996 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -849,7 +849,9 @@ public function castingAfter($collection, $document): Document $node = (int)$node; break; case Database::VAR_DATETIME : - $node = DateTime::format($node->toDateTime()); + if ($node instanceof UTCDateTime) { + $node = DateTime::format($node->toDateTime()); + } break; default: break; @@ -906,7 +908,9 @@ public function castingBefore($collection, $document): Document foreach ($value as &$node) { switch ($type) { case Database::VAR_DATETIME : - $node = new UTCDateTime(new \DateTime($node)); + if (!($node instanceof UTCDateTime)) { + $node = new UTCDateTime(new \DateTime($node)); + } break; default: break; @@ -1536,74 +1540,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, 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. diff --git a/src/Database/Database.php b/src/Database/Database.php index dce84737a..58708d080 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3738,7 +3738,6 @@ public function createDocuments( } $document = $this->adapter->castingBefore($collection, $document); - } foreach (\array_chunk($documents, $batchSize) as $chunk) { From 53a458f7f4d1afa428ecc05f21ccbdda68899944 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 27 Jul 2025 17:35:29 +0300 Subject: [PATCH 27/34] Comment out the Mongo Client path in docker-compose.yml to prevent potential conflicts during development. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1cbfe65b5..ebdfb1af1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - ./dev/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini - /var/run/docker.sock:/var/run/docker.sock - ./docker-compose.yml:/usr/src/code/docker-compose.yml - - ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php + #- ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php environment: PHP_IDE_CONFIG: serverName=tests From 0e2f54c9572f1a7b1690d2345f6d83c4f6955089 Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 28 Jul 2025 15:08:05 +0300 Subject: [PATCH 28/34] sync with main --- src/Database/Adapter/Mongo.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 7da6baa3b..15b98a7d3 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1189,7 +1189,7 @@ public function getSequences(string $collection, array $documents): array foreach ($documents as $document) { if (empty($document->getSequence())) { $documentIds[] = $document->getId(); - + if ($this->sharedTables) { $documentTenants[] = $document->getTenant(); } From 5429a8820203b93a9b719008975873cd8e369ae7 Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 28 Jul 2025 15:40:51 +0300 Subject: [PATCH 29/34] Update MongoDB adapter to support skipping permissions during document updates; change Redis cache to Memory cache as fallback in tests; update Docker port mapping. --- docker-compose.yml | 2 +- src/Database/Adapter/Mongo.php | 7 ++++++- src/Database/Database.php | 2 +- tests/e2e/Adapter/MongoDBTest.php | 10 ++++------ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ebdfb1af1..8b079b891 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,7 +92,7 @@ services: networks: - database ports: - - "8081:8081" + - "8083:8081" environment: ME_CONFIG_MONGODB_SERVER: mongo ME_CONFIG_MONGODB_ADMINUSERNAME: root diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 8988c2996..86c5c5f36 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1020,13 +1020,18 @@ private function insertDocument(string $name, array $document): array * @return Document * @throws Exception */ - public function updateDocument(string $collection, string $id, Document $document): Document + public function updateDocument(string $collection, string $id, Document $document, bool $skipPermissions): Document { $name = $this->getNamespace() . '_' . $this->filter($collection); $record = $document->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); + // If skipPermissions is true, remove the _permissions field from the update + if ($skipPermissions) { + unset($record['_permissions']); + } + $filters = []; $filters['_uid'] = $id; if ($this->sharedTables) { diff --git a/src/Database/Database.php b/src/Database/Database.php index ed7c40044..0d8078e1c 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4286,7 +4286,7 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->adapter->castingBefore($collection, $document); - $this->adapter->updateDocument($collection->getId(), $id, $document); + $this->adapter->updateDocument($collection->getId(), $id, $document, $skipPermissionsUpdate); $document = $this->adapter->castingAfter($collection, $document); diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php index 55b21f8e4..99b8a3adc 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -3,8 +3,8 @@ namespace Tests\E2E\Adapter; use Exception; -use Redis; -use Utopia\Cache\Adapter\Redis as RedisAdapter; +use Utopia\Cache\Adapter\Memory; +use Utopia\Cache\Adapter\None as NoCache; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; use Utopia\Database\Database; @@ -35,10 +35,8 @@ public static function getDatabase(): Database return self::$database; } - $redis = new Redis(); - $redis->connect('redis', 6379); - $redis->flushAll(); - $cache = new Cache(new RedisAdapter($redis)); + // Use Memory cache adapter as fallback when Redis is not available + $cache = new Cache(new Memory()); $schema = 'utopiaTests'; // same as $this->testDatabase $client = new Client( From 796584f266d68d5ede31f62d2b2992a81b05ed84 Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 29 Jul 2025 15:26:16 +0300 Subject: [PATCH 30/34] Refactor MongoDB adapter to improve document processing and permissions handling; update Docker configuration for client mapping; enhance tests with Redis cache integration. --- docker-compose.yml | 2 +- src/Database/Adapter/Mongo.php | 42 ++++++-------------- src/Database/Database.php | 18 ++++++--- tests/e2e/Adapter/MongoDBTest.php | 9 +++-- tests/e2e/Adapter/Scopes/CollectionTests.php | 6 +-- tests/e2e/Adapter/Scopes/DocumentTests.php | 2 +- 6 files changed, 37 insertions(+), 42 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8b079b891..9d7f5431d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - ./dev/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini - /var/run/docker.sock:/var/run/docker.sock - ./docker-compose.yml:/usr/src/code/docker-compose.yml - #- ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php + - ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php environment: PHP_IDE_CONFIG: serverName=tests diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 86c5c5f36..4eb69b60c 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -761,9 +761,13 @@ public function getDocument(string $collection, string $id, array $queries = [], if (empty($result)) { return new Document([]); } - + $result = $this->replaceChars('_', '$', (array)$result[0]); + // if (array_key_exists('$permissions', $result) && empty($result['$permissions'])) { + // $result['$permissions'] = []; + // } + return new Document($result); } @@ -778,7 +782,7 @@ public function getDocument(string $collection, string $id, array $queries = [], */ public function createDocument(string $collection, Document $document): Document { - + $name = $this->getNamespace() . '_' . $this->filter($collection); $sequence = $document->getSequence(); @@ -795,9 +799,9 @@ public function createDocument(string $collection, Document $document): Document if (!empty($sequence)) { $record['_id'] = $sequence; } - + $result = $this->insertDocument($name, $this->removeNullKeys($record)); - + $result = $this->replaceChars('_', '$', $result); return new Document($result); @@ -1001,7 +1005,7 @@ private function insertDocument(string $name, array $document): array $filters, ['limit' => 1] )->cursor->firstBatch[0]; - + return $this->client->toArray($result); } catch (MongoException $e) { throw new Duplicate($e->getMessage()); @@ -1027,11 +1031,7 @@ public function updateDocument(string $collection, string $id, Document $documen $record = $document->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); - // If skipPermissions is true, remove the _permissions field from the update - if ($skipPermissions) { - unset($record['_permissions']); - } - + $filters = []; $filters['_uid'] = $id; if ($this->sharedTables) { @@ -1111,6 +1111,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $documentIds = []; $documentTenants = []; + $tempDocuments = []; // Collect documents that need sequences $operations = []; foreach ($changes as $change) { @@ -1125,6 +1126,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $attributes['_id'] = new ObjectId($document->getSequence()); } else { $documentIds[] = $document->getId(); + $tempDocuments[] = $document; // Collect for sequence retrieval } if ($this->sharedTables) { @@ -1165,28 +1167,10 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a ["ordered" => false] // TODO Do we want to continue if an error is thrown? ); - if (!empty($documentIds)) { - // Create temporary documents for getSequences - $tempDocuments = []; - foreach ($changes as $change) { - if (empty($change->getNew()->getSequence())) { - $tempDocuments[] = $change->getNew(); - } - } - - $sequences = $this->getSequences($collection, $tempDocuments); - - foreach ($changes as $change) { - if (isset($sequences[$change->getNew()->getId()])) { - $change->getNew()->setAttribute('$sequence', $sequences[$change->getNew()->getId()]); - } - } - } - } catch (MongoException $e) { throw $this->processException($e); } - + return \array_map(fn ($change) => $change->getNew(), $changes); } diff --git a/src/Database/Database.php b/src/Database/Database.php index 58d58d4ee..da0efa9f8 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4117,7 +4117,7 @@ public function updateDocument(string $collection, string $id, Document $documen fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) )); - $skipPermissionsUpdate = false; + $skipPermissionsUpdate = true; if ($document->offsetExists('$permissions')) { $originalPermissions = $old->getPermissions(); @@ -4950,6 +4950,7 @@ public function createOrUpdateDocumentsWithIncrease( $created = 0; $updated = 0; $seenIds = []; + $processedDocuments = []; // Track which documents were actually processed foreach ($documents as $key => $document) { if ($this->getSharedTables() && $this->getTenantPerDocument()) { $old = Authorization::skip(fn () => $this->withTenant($document->getTenant(), fn () => $this->silent(fn () => $this->getDocument( @@ -4962,7 +4963,7 @@ public function createOrUpdateDocumentsWithIncrease( $document->getId(), ))); } - + $skipPermissionsUpdate = true; if ($document->offsetExists('$permissions')) { @@ -4986,6 +4987,9 @@ public function createOrUpdateDocumentsWithIncrease( continue; } + // Track that this document was processed + $processedDocuments[$document->getId()] = true; + // If old is empty, check if user has create permission on the collection // If old is not empty, check if user has update permission on the collection // If old is not empty AND documentSecurity is enabled, check if user has update permission on the collection or document @@ -5105,8 +5109,9 @@ public function createOrUpdateDocumentsWithIncrease( $attribute, $chunk ))); + $batch = $this->adapter->getSequences($collection->getId(), $batch); - + foreach ($chunk as $change) { if ($change->getOld()->isEmpty()) { $created++; @@ -5133,7 +5138,10 @@ public function createOrUpdateDocumentsWithIncrease( $this->purgeCachedDocument($collection->getId(), $doc->getId()); } - $onNext && $onNext($doc); + // Only call onNext for documents that were actually processed + if (isset($processedDocuments[$doc->getId()])) { + $onNext && $onNext($doc); + } } } @@ -5142,7 +5150,7 @@ public function createOrUpdateDocumentsWithIncrease( 'created' => $created, 'updated' => $updated, ])); - + return $created + $updated; } diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php index 99b8a3adc..39033c61a 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -3,7 +3,8 @@ namespace Tests\E2E\Adapter; use Exception; -use Utopia\Cache\Adapter\Memory; +use Redis; +use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Adapter\None as NoCache; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; @@ -35,8 +36,10 @@ public static function getDatabase(): Database return self::$database; } - // Use Memory cache adapter as fallback when Redis is not available - $cache = new Cache(new Memory()); + $redis = new Redis(); + $redis->connect('redis', 6379); + $redis->flushAll(); + $cache = new Cache(new RedisAdapter($redis)); $schema = 'utopiaTests'; // same as $this->testDatabase $client = new Client( diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 1fd836594..e93a73764 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -659,10 +659,10 @@ public function testCreateCollectionWithSchemaIndexes(): void 'orders' => [], ]), new Document([ - '$id' => ID::custom('idx_username_created_at'), + '$id' => ID::custom('idx_username_uid'), 'type' => Database::INDEX_KEY, - 'attributes' => ['username', 'cards'], - 'lengths' => [99, 255], // Length not equal to attributes length + 'attributes' => ['username', '$id'], // to solve the same attribute mongo issue + 'lengths' => [99, 200], // Length not equal to attributes length 'orders' => [Database::ORDER_DESC], ]), ]; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index db762bc45..76661a35d 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -433,7 +433,7 @@ public function testSkipPermissions(): void ]; $documents = array_map(fn ($d) => new Document($d), $data); - + Authorization::disable(); $results = []; From bf9f705d16498d4e75762e4864f9de91893cbf35 Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 29 Jul 2025 15:44:28 +0300 Subject: [PATCH 31/34] Refactor MongoDB adapter by removing unused permission handling code and optimizing document ID retrieval; streamline sequence fetching logic for improved performance. --- src/Database/Adapter/Mongo.php | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 4eb69b60c..18ae2780b 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -764,10 +764,6 @@ public function getDocument(string $collection, string $id, array $queries = [], $result = $this->replaceChars('_', '$', (array)$result[0]); - // if (array_key_exists('$permissions', $result) && empty($result['$permissions'])) { - // $result['$permissions'] = []; - // } - return new Document($result); } @@ -1079,7 +1075,6 @@ public function updateDocuments(string $collection, Document $updates, array $do $record = $updates->getArrayCopy(); $record = $this->replaceChars('$', '_', $record); - $updateQuery = [ '$set' => $record, ]; @@ -1111,8 +1106,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $documentIds = []; $documentTenants = []; - $tempDocuments = []; // Collect documents that need sequences - + $operations = []; foreach ($changes as $change) { $document = $change->getNew(); @@ -1126,7 +1120,6 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $attributes['_id'] = new ObjectId($document->getSequence()); } else { $documentIds[] = $document->getId(); - $tempDocuments[] = $document; // Collect for sequence retrieval } if ($this->sharedTables) { @@ -1202,25 +1195,18 @@ public function getSequences(string $collection, array $documents): array $sequences = []; $name = $this->getNamespace() . '_' . $this->filter($collection); - foreach (\array_chunk($documentIds, 1000) as $index => $documentIdsChunk) { - $filters = ['_uid' => ['$in' => $documentIdsChunk]]; + $filters = ['_uid' => ['$in' => $documentIds]]; - if ($this->sharedTables) { - $tenantChunk = \array_slice($documentTenants, $index * 1000, \count($documentIdsChunk)); - $filters['_tenant'] = ['$in' => $tenantChunk]; - } + if ($this->sharedTables) { + $filters['_tenant'] = ['$in' => $documentTenants]; + } - try { - $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); + $results = $this->client->find($name, $filters, ['projection' => ['_uid' => 1, '_id' => 1]]); - foreach ($results->cursor->firstBatch as $result) { - $sequences[$result->_uid] = (string)$result->_id; - } - } catch (MongoException $e) { - // If query fails, continue with empty sequences - continue; + foreach ($results->cursor->firstBatch as $result) { + $sequences[$result->_uid] = (string)$result->_id; } - } + foreach ($documents as $document) { if (isset($sequences[$document->getId()])) { $document['$sequence'] = $sequences[$document->getId()]; From b7f74afe864e985ae018f4af4e457cb353a2a96b Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 29 Jul 2025 16:01:06 +0300 Subject: [PATCH 32/34] Refactor MongoDB adapter by removing unused arrays for document IDs and tenants; streamline upsert filter construction for improved clarity and performance. --- src/Database/Adapter/Mongo.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 18ae2780b..1ea236bdb 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1104,9 +1104,6 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a $name = $this->getNamespace() . '_' . $this->filter($collection); $attribute = $this->filter($attribute); - $documentIds = []; - $documentTenants = []; - $operations = []; foreach ($changes as $change) { $document = $change->getNew(); @@ -1118,13 +1115,10 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a if (!empty($document->getSequence())) { $attributes['_id'] = new ObjectId($document->getSequence()); - } else { - $documentIds[] = $document->getId(); - } + } if ($this->sharedTables) { $attributes['_tenant'] = $document->getTenant(); - $documentTenants[] = $document->getTenant(); } $record = $this->replaceChars('$', '_', $attributes); @@ -1132,6 +1126,7 @@ public function createOrUpdateDocuments(string $collection, string $attribute, a // Build filter for upsert $filter = ['_uid' => $document->getId()]; + if ($this->sharedTables) { $filter['_tenant'] = $document->getTenant(); } From 5ce323be230501961a3174babd93af8865304083 Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 29 Jul 2025 16:07:52 +0300 Subject: [PATCH 33/34] Update Docker configuration by commenting out the Client.php mapping and adding MongoDB initialization credentials for username and password. --- docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9d7f5431d..09b3aa026 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - ./dev/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini - /var/run/docker.sock:/var/run/docker.sock - ./docker-compose.yml:/usr/src/code/docker-compose.yml - - ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php + #- ./vendor/utopia-php/mongo/src/Client.php:/usr/src/code/vendor/utopia-php/mongo/src/Client.php environment: PHP_IDE_CONFIG: serverName=tests @@ -85,6 +85,8 @@ services: MONGO_INITDB_DATABASE: utopia_testing MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: password + MONGO_INITDB_USERNAME: user + MONGO_INITDB_PASSWORD: paswword mongo-express: image: mongo-express From 5a5be7061b6520c6ae2ba3013a174bea5ceef9c7 Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 5 Aug 2025 11:19:45 +0300 Subject: [PATCH 34/34] Refactor createOrUpdateDocuments method in Mongo adapter to improve attribute handling during upserts; ensure correct incrementing of specified attributes while maintaining other fields. --- src/Database/Adapter/Mongo.php | 156 ++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 72 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 1ea236bdb..d5bc791d6 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1089,78 +1089,90 @@ public function updateDocuments(string $collection, Document $updates, array $do } /** - * @param string $collection - * @param string $attribute - * @param array $changes - * @return array - */ - public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array - { - if (empty($changes)) { - return $changes; - } - - try { - $name = $this->getNamespace() . '_' . $this->filter($collection); - $attribute = $this->filter($attribute); - - $operations = []; - foreach ($changes as $change) { - $document = $change->getNew(); - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document['$createdAt']; - $attributes['_updatedAt'] = $document['$updatedAt']; - $attributes['_permissions'] = $document->getPermissions(); - - if (!empty($document->getSequence())) { - $attributes['_id'] = new ObjectId($document->getSequence()); - } - - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); - } - - $record = $this->replaceChars('$', '_', $attributes); - $record = $this->removeNullKeys($record); - - // Build filter for upsert - $filter = ['_uid' => $document->getId()]; - - if ($this->sharedTables) { - $filter['_tenant'] = $document->getTenant(); - } - - if (!empty($attribute)) { - // Increment specific attribute - $update = [ - '$inc' => [$attribute => $record[$attribute] ?? 0], - '$set' => ['_updatedAt' => $record['_updatedAt']] - ]; - } else { - // Update all fields - unset($record['_id']); // Don't update _id - $update = ['$set' => $record]; - } - - $operations[] = [ - 'filter' => $filter, - 'update' => $update, - ]; - } - - $this->client->upsert( - $name, - $operations, - ["ordered" => false] // TODO Do we want to continue if an error is thrown? - ); - - } catch (MongoException $e) { - throw $this->processException($e); - } - - return \array_map(fn ($change) => $change->getNew(), $changes); - } + * @param string $collection + * @param string $attribute + * @param array $changes + * @return array + */ + public function createOrUpdateDocuments(string $collection, string $attribute, array $changes): array + { + if (empty($changes)) { + return $changes; + } + + try { + $name = $this->getNamespace() . '_' . $this->filter($collection); + $attribute = $this->filter($attribute); + + $operations = []; + foreach ($changes as $change) { + $document = $change->getNew(); + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document['$createdAt']; + $attributes['_updatedAt'] = $document['$updatedAt']; + $attributes['_permissions'] = $document->getPermissions(); + + if (!empty($document->getSequence())) { + $attributes['_id'] = new ObjectId($document->getSequence()); + } else { + $documentIds[] = $document->getId(); + } + + if ($this->sharedTables) { + $attributes['_tenant'] = $document->getTenant(); + } + + $record = $this->replaceChars('$', '_', $attributes); + $record = $this->removeNullKeys($record); + + // Build filter for upsert + $filter = ['_uid' => $document->getId()]; + + if ($this->sharedTables) { + $filter['_tenant'] = $document->getTenant(); + } + + unset($record['_id']); // Don't update _id + + if (!empty($attribute)) { + // Get the attribute value before removing it from $set + $attributeValue = $record[$attribute] ?? 0; + + // Remove the attribute from $set since we're incrementing it + // it is requierd to mimic the behaver of SQL on duplicate key update + unset($record[$attribute]); + + // Increment the specific attribute and update all other fields + $update = [ + '$inc' => [$attribute => $attributeValue], + '$set' => $record + ]; + } else { + // Update all fields + $update = [ + '$set' => $record + ]; + } + + $operations[] = [ + 'filter' => $filter, + 'update' => $update, + ]; + } + + $this->client->upsert( + $name, + $operations, + ["ordered" => false] // TODO Do we want to continue if an error is thrown? + ); + + } catch (MongoException $e) { + throw $this->processException($e); + } + + return \array_map(fn ($change) => $change->getNew(), $changes); + } /** * Get sequences for documents that were created