diff --git a/composer.lock b/composer.lock index fdbacb754..40a9f66c2 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "brick/math", - "version": "0.14.0", + "version": "0.14.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.0" + "source": "https://github.com/brick/math/tree/0.14.1" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-08-29T12:40:03+00:00" + "time": "2025-11-24T14:40:29+00:00" }, { "name": "composer/semver", @@ -145,16 +145,16 @@ }, { "name": "google/protobuf", - "version": "v4.33.0", + "version": "v4.33.4", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "b50269e23204e5ae859a326ec3d90f09efe3047d" + "reference": "22d28025cda0d223a2e48c2e16c5284ecc9f5402" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/b50269e23204e5ae859a326ec3d90f09efe3047d", - "reference": "b50269e23204e5ae859a326ec3d90f09efe3047d", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/22d28025cda0d223a2e48c2e16c5284ecc9f5402", + "reference": "22d28025cda0d223a2e48c2e16c5284ecc9f5402", "shasum": "" }, "require": { @@ -183,22 +183,22 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.0" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.4" }, - "time": "2025-10-15T20:10:28+00:00" + "time": "2026-01-12T17:58:43+00:00" }, { "name": "mongodb/mongodb", - "version": "2.1.1", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "f399d24905dd42f97dfe0af9706129743ef247ac" + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/f399d24905dd42f97dfe0af9706129743ef247ac", - "reference": "f399d24905dd42f97dfe0af9706129743ef247ac", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", "shasum": "" }, "require": { @@ -214,7 +214,7 @@ "require-dev": { "doctrine/coding-standard": "^12.0", "phpunit/phpunit": "^10.5.35", - "rector/rector": "^1.2", + "rector/rector": "^2.1.4", "squizlabs/php_codesniffer": "^3.7", "vimeo/psalm": "6.5.*" }, @@ -260,9 +260,9 @@ ], "support": { "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.1" + "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" }, - "time": "2025-08-13T20:50:05+00:00" + "time": "2025-10-06T12:12:40+00:00" }, { "name": "nyholm/psr7", @@ -410,16 +410,16 @@ }, { "name": "open-telemetry/api", - "version": "1.7.0", + "version": "1.7.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522" + "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/610b79ad9d6d97e8368bcb6c4d42394fbb87b522", - "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", + "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", "shasum": "" }, "require": { @@ -439,7 +439,7 @@ ] }, "branch-alias": { - "dev-main": "1.7.x-dev" + "dev-main": "1.8.x-dev" } }, "autoload": { @@ -472,11 +472,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-02T23:44:28+00:00" + "time": "2025-10-19T10:49:48+00:00" }, { "name": "open-telemetry/context", @@ -539,16 +539,16 @@ }, { "name": "open-telemetry/exporter-otlp", - "version": "1.3.2", + "version": "1.3.3", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2" + "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", - "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/07b02bc71838463f6edcc78d3485c04b48fb263d", + "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d", "shasum": "" }, "require": { @@ -595,11 +595,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-06-16T00:24:51+00:00" + "time": "2025-11-13T08:04:37+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", @@ -666,16 +666,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e" + "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", - "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", + "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", "shasum": "" }, "require": { @@ -755,11 +755,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-02T23:44:28+00:00" + "time": "2025-11-25T10:59:15+00:00" }, { "name": "open-telemetry/sem-conv", @@ -1238,20 +1238,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -1310,9 +1310,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1383,16 +1383,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.4", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" + "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616", + "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616", "shasum": "" }, "require": { @@ -1423,12 +1423,13 @@ "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -1459,7 +1460,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.4" + "source": "https://github.com/symfony/http-client/tree/v7.4.3" }, "funding": [ { @@ -1479,7 +1480,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -1886,16 +1887,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -1949,7 +1950,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -1960,12 +1961,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "tbachert/spi", @@ -2021,16 +2026,16 @@ }, { "name": "utopia-php/cache", - "version": "0.13.1", + "version": "0.13.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "97220cb3b3822b166ee016d1646e2ae2815dc540" + "reference": "5768498c9f451482f0bf3eede4d6452ddcd4a0f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/97220cb3b3822b166ee016d1646e2ae2815dc540", - "reference": "97220cb3b3822b166ee016d1646e2ae2815dc540", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/5768498c9f451482f0bf3eede4d6452ddcd4a0f6", + "reference": "5768498c9f451482f0bf3eede4d6452ddcd4a0f6", "shasum": "" }, "require": { @@ -2039,7 +2044,7 @@ "ext-redis": "*", "php": ">=8.0", "utopia-php/pools": "0.8.*", - "utopia-php/telemetry": "0.1.*" + "utopia-php/telemetry": "*" }, "require-dev": { "laravel/pint": "1.2.*", @@ -2067,9 +2072,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/0.13.1" + "source": "https://github.com/utopia-php/cache/tree/0.13.2" }, - "time": "2025-05-09T14:43:52+00:00" + "time": "2025-12-17T08:55:43+00:00" }, { "name": "utopia-php/compression", @@ -2119,28 +2124,29 @@ }, { "name": "utopia-php/framework", - "version": "0.33.28", + "version": "0.33.37", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "5aaa94d406577b0059ad28c78022606890dc6de0" + "reference": "30a119d76531d89da9240496940c84fcd9e1758b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/5aaa94d406577b0059ad28c78022606890dc6de0", - "reference": "5aaa94d406577b0059ad28c78022606890dc6de0", + "url": "https://api.github.com/repos/utopia-php/http/zipball/30a119d76531d89da9240496940c84fcd9e1758b", + "reference": "30a119d76531d89da9240496940c84fcd9e1758b", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.3", "utopia-php/compression": "0.1.*", - "utopia-php/telemetry": "0.1.*" + "utopia-php/telemetry": "0.1.*", + "utopia-php/validators": "0.2.*" }, "require-dev": { - "laravel/pint": "^1.2", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5.25" + "laravel/pint": "1.*", + "phpbench/phpbench": "1.*", + "phpstan/phpstan": "1.*", + "phpunit/phpunit": "9.*" }, "type": "library", "autoload": { @@ -2160,9 +2166,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.28" + "source": "https://github.com/utopia-php/http/tree/0.33.37" }, - "time": "2025-09-25T10:44:24+00:00" + "time": "2026-01-13T10:10:21+00:00" }, { "name": "utopia-php/mongo", @@ -2227,21 +2233,21 @@ }, { "name": "utopia-php/pools", - "version": "0.8.2", + "version": "0.8.3", "source": { "type": "git", "url": "https://github.com/utopia-php/pools.git", - "reference": "05c67aba42eb68ac65489cc1e7fc5db83db2dd4d" + "reference": "ad7d6ba946376e81c603204285ce9a674b6502b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/pools/zipball/05c67aba42eb68ac65489cc1e7fc5db83db2dd4d", - "reference": "05c67aba42eb68ac65489cc1e7fc5db83db2dd4d", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/ad7d6ba946376e81c603204285ce9a674b6502b8", + "reference": "ad7d6ba946376e81c603204285ce9a674b6502b8", "shasum": "" }, "require": { - "php": ">=8.3", - "utopia-php/telemetry": "0.1.*" + "php": ">=8.4", + "utopia-php/telemetry": "*" }, "require-dev": { "laravel/pint": "1.*", @@ -2273,9 +2279,9 @@ ], "support": { "issues": "https://github.com/utopia-php/pools/issues", - "source": "https://github.com/utopia-php/pools/tree/0.8.2" + "source": "https://github.com/utopia-php/pools/tree/0.8.3" }, - "time": "2025-04-17T02:04:54+00:00" + "time": "2025-12-17T09:35:18+00:00" }, { "name": "utopia-php/telemetry", @@ -2326,35 +2332,79 @@ "source": "https://github.com/utopia-php/telemetry/tree/0.1.1" }, "time": "2025-03-17T11:57:52+00:00" + }, + { + "name": "utopia-php/validators", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "30b6030a5b100fc1dff34506e5053759594b2a20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20", + "reference": "30b6030a5b100fc1dff34506e5053759594b2a20", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.2.0" + }, + "time": "2026-01-13T09:16:51+00:00" } ], "packages-dev": [ { "name": "doctrine/instantiator", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.4" }, "require-dev": { - "doctrine/coding-standard": "^11", + "doctrine/coding-standard": "^14", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -2381,7 +2431,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" }, "funding": [ { @@ -2397,7 +2447,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:23:10+00:00" + "time": "2026-01-05T06:47:08+00:00" }, { "name": "fakerphp/faker", @@ -2464,16 +2514,16 @@ }, { "name": "laravel/pint", - "version": "v1.25.1", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", + "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", "shasum": "" }, "require": { @@ -2484,13 +2534,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.87.2", - "illuminate/view": "^11.46.0", - "larastan/larastan": "^3.7.1", - "laravel-zero/framework": "^11.45.0", + "friendsofphp/php-cs-fixer": "^3.92.4", + "illuminate/view": "^12.44.0", + "larastan/larastan": "^3.8.1", + "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.1", - "pestphp/pest": "^2.36.0" + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" }, "bin": [ "builds/pint" @@ -2516,6 +2566,7 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ + "dev", "format", "formatter", "lint", @@ -2526,7 +2577,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-09-19T02:57:12+00:00" + "time": "2026-01-05T16:49:17+00:00" }, { "name": "myclabs/deep-copy", @@ -2590,16 +2641,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.19.4", + "version": "v4.19.5", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2" + "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/715f4d25e225bc47b293a8b997fe6ce99bf987d2", - "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", + "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", "shasum": "" }, "require": { @@ -2614,11 +2665,6 @@ "bin/php-parse" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } - }, "autoload": { "psr-4": { "PhpParser\\": "lib/PhpParser" @@ -2640,9 +2686,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.4" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.5" }, - "time": "2024-09-29T15:01:53+00:00" + "time": "2025-12-06T11:45:25+00:00" }, { "name": "pcov/clobber", @@ -3170,16 +3216,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.29", + "version": "9.6.31", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3" + "reference": "945d0b7f346a084ce5549e95289962972c4272e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", - "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5", + "reference": "945d0b7f346a084ce5549e95289962972c4272e5", "shasum": "" }, "require": { @@ -3253,7 +3299,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.31" }, "funding": [ { @@ -3277,7 +3323,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:29:11+00:00" + "time": "2025-12-06T07:45:52+00:00" }, { "name": "rregeer/phpunit-coverage-check", @@ -4370,16 +4416,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -4408,7 +4454,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -4416,7 +4462,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "utopia-php/cli", @@ -4484,5 +4530,5 @@ "ext-mbstring": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 62a8eb7fe..49a33e403 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1079,6 +1079,13 @@ abstract public function getSupportForSpatialAttributes(): bool; */ abstract public function getSupportForObject(): bool; + /** + * Are object (JSON) indexes supported? + * + * @return bool + */ + abstract public function getSupportForObjectIndexes(): bool; + /** * Does the adapter support null values in spatial indexes? * @@ -1442,4 +1449,38 @@ public function enableAlterLocks(bool $enable): self return $this; } + + /** + * Does the adapter support trigram index? + * + * @return bool + */ + abstract public function getSupportForTrigramIndex(): bool; + + /** + * Is PCRE regex supported? + * PCRE (Perl Compatible Regular Expressions) supports \b for word boundaries + * + * @return bool + */ + abstract public function getSupportForPCRERegex(): bool; + + /** + * Is POSIX regex supported? + * POSIX regex uses \y for word boundaries instead of \b + * + * @return bool + */ + abstract public function getSupportForPOSIXRegex(): bool; + + /** + * Is regex supported at all? + * Returns true if either PCRE or POSIX regex is supported + * + * @return bool + */ + public function getSupportForRegex(): bool + { + return $this->getSupportForPCRERegex() || $this->getSupportForPOSIXRegex(); + } } diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 2876139f7..b8110b039 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2140,6 +2140,16 @@ public function getSupportForObject(): bool return false; } + /** + * Are object (JSON) indexes supported? + * + * @return bool + */ + public function getSupportForObjectIndexes(): bool + { + return false; + } + /** * Get Support for Null Values in Spatial Indexes * @@ -2230,4 +2240,19 @@ public function getSupportForAlterLocks(): bool { return true; } + + public function getSupportForTrigramIndex(): bool + { + return false; + } + + public function getSupportForPCRERegex(): bool + { + return true; + } + + public function getSupportForPOSIXRegex(): bool + { + return false; + } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 18554f87c..fdcdd80c2 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -5,6 +5,7 @@ use Exception; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use stdClass; use Utopia\Database\Adapter; use Utopia\Database\Change; use Utopia\Database\Database; @@ -43,6 +44,8 @@ class Mongo extends Adapter '$not', '$nor', '$exists', + '$elemMatch', + '$exists' ]; protected Client $client; @@ -415,7 +418,6 @@ public function createCollection(string $name, array $attributes = [], array $in try { $options = $this->getTransactionOptions(); $this->getClient()->createCollection($id, $options); - } catch (MongoException $e) { $e = $this->processException($e); if ($e instanceof DuplicateException) { @@ -1232,7 +1234,7 @@ public function castingAfter(Document $collection, Document $document): Document case Database::VAR_INTEGER: $node = (int)$node; break; - case Database::VAR_DATETIME : + case Database::VAR_DATETIME: if ($node instanceof UTCDateTime) { // Handle UTCDateTime objects $node = DateTime::format($node->toDateTime()); @@ -1258,6 +1260,12 @@ public function castingAfter(Document $collection, Document $document): Document } } break; + case Database::VAR_OBJECT: + // Convert stdClass objects to arrays for object attributes + if (is_object($node) && get_class($node) === stdClass::class) { + $node = $this->convertStdClassToArray($node); + } + break; default: break; } @@ -1266,9 +1274,33 @@ public function castingAfter(Document $collection, Document $document): Document $document->setAttribute($key, ($array) ? $value : $value[0]); } + if (!$this->getSupportForAttributes()) { + foreach ($document->getArrayCopy() as $key => $value) { + // mongodb results out a stdclass for objects + if (is_object($value) && get_class($value) === stdClass::class) { + $document->setAttribute($key, $this->convertStdClassToArray($value)); + } + } + } return $document; } + private function convertStdClassToArray(mixed $value): mixed + { + if (is_object($value) && get_class($value) === stdClass::class) { + return array_map($this->convertStdClassToArray(...), get_object_vars($value)); + } + + if (is_array($value)) { + return array_map( + fn ($v) => $this->convertStdClassToArray($v), + $value + ); + } + + return $value; + } + /** * Returns the document after casting to * @param Document $collection @@ -1319,6 +1351,9 @@ public function castingBefore(Document $collection, Document $document): Documen $node = new UTCDateTime(new \DateTime($node)); } break; + case Database::VAR_OBJECT: + $node = json_decode($node); + break; default: break; } @@ -1592,7 +1627,6 @@ public function upsertDocuments(Document $collection, string $attribute, array $ $operations, options: $options ); - } catch (MongoException $e) { throw $this->processException($e); } @@ -1977,7 +2011,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 // Process first batch foreach ($results as $result) { $record = $this->replaceChars('_', '$', (array)$result); - $found[] = new Document($record); + $found[] = new Document($this->convertStdClassToArray($record)); } // Get cursor ID for subsequent batches @@ -1999,7 +2033,6 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $cursorId = (int)($moreResponse->cursor->id ?? 0); } - } catch (MongoException $e) { throw $this->processException($e); } finally { @@ -2335,6 +2368,15 @@ protected function buildFilters(array $queries, string $separator = '$and'): arr foreach ($queries as $query) { /* @var $query Query */ if ($query->isNested()) { + if ($query->getMethod() === Query::TYPE_ELEM_MATCH) { + $filters[$separator][] = [ + $query->getAttribute() => [ + '$elemMatch' => $this->buildFilters($query->getValues(), $separator) + ] + ]; + continue; + } + $operator = $this->getQueryOperator($query->getMethod()); $filters[$separator][] = $this->buildFilters($query->getValues(), $operator); @@ -2385,6 +2427,10 @@ protected function buildFilter(Query $query): array }; $filter = []; + if ($query->isObjectAttribute() && in_array($query->getMethod(), [Query::TYPE_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_NOT_EQUAL])) { + $this->handleObjectFilters($query, $filter); + return $filter; + } if ($operator == '$eq' && \is_array($value)) { $filter[$attribute]['$in'] = $value; @@ -2448,6 +2494,88 @@ protected function buildFilter(Query $query): array return $filter; } + /** + * @param Query $query + * @param array $filter + * @return void + */ + private function handleObjectFilters(Query $query, array &$filter): void + { + $conditions = []; + $isNot = in_array($query->getMethod(), [Query::TYPE_NOT_CONTAINS,Query::TYPE_NOT_EQUAL]); + $values = $query->getValues(); + foreach ($values as $attribute => $value) { + $flattendQuery = $this->flattenWithDotNotation(is_string($attribute) ? $attribute : '', $value); + $flattenedObjectKey = array_key_first($flattendQuery); + $queryValue = $flattendQuery[$flattenedObjectKey]; + $flattenedObjectKey = $query->getAttribute() . '.' . array_key_first($flattendQuery); + switch ($query->getMethod()) { + + case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_CONTAINS: { + $arrayValue = \is_array($queryValue) ? $queryValue : [$queryValue]; + $operator = $isNot ? '$nin' : '$in'; + $conditions[] = [ $flattenedObjectKey => [ $operator => $arrayValue] ]; + break; + } + + case Query::TYPE_EQUAL: + case Query::TYPE_NOT_EQUAL: { + if (\is_array($queryValue)) { + $operator = $isNot ? '$nin' : '$in'; + $conditions[] = [ $flattenedObjectKey => [ $operator => $queryValue] ]; + } else { + $operator = $isNot ? '$ne' : '$eq'; + $conditions[] = [ $flattenedObjectKey => [ $operator => $queryValue] ]; + } + + break; + } + } + } + + $logicalOperator = $isNot ? '$and' : '$or'; + if (count($conditions) && isset($filter[$logicalOperator])) { + $filter[$logicalOperator] = array_merge($filter[$logicalOperator], $conditions); + } else { + $filter[$logicalOperator] = $conditions; + } + } + + /** + * Flatten a nested associative array into Mongo-style dot notation. + * + * @param string $key + * @param mixed $value + * @param string $prefix + * @return array + */ + private function flattenWithDotNotation(string $key, mixed $value, string $prefix = ''): array + { + /** @var array $result */ + $result = []; + + $stack = []; + + $initialKey = $prefix === '' ? $key : $prefix . '.' . $key; + $stack[] = [$initialKey, $value]; + while (!empty($stack)) { + [$currentPath, $currentValue] = array_pop($stack); + if (is_array($currentValue) && !array_is_list($currentValue)) { + foreach ($currentValue as $nextKey => $nextValue) { + $nextKey = (string)$nextKey; + $nextPath = $currentPath === '' ? $nextKey : $currentPath . '.' . $nextKey; + $stack[] = [$nextPath, $nextValue]; + } + } else { + // leaf node + $result[$currentPath] = $currentValue; + } + } + + return $result; + } + /** * Get Query Operator * @@ -2476,11 +2604,13 @@ protected function getQueryOperator(string $operator): string Query::TYPE_STARTS_WITH, Query::TYPE_NOT_STARTS_WITH, Query::TYPE_ENDS_WITH, - Query::TYPE_NOT_ENDS_WITH => '$regex', + Query::TYPE_NOT_ENDS_WITH, + Query::TYPE_REGEX => '$regex', Query::TYPE_OR => '$or', Query::TYPE_AND => '$and', Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS => '$exists', + Query::TYPE_ELEM_MATCH => '$elemMatch', 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_NOT_BETWEEN . ', ' . Query::TYPE_STARTS_WITH . ', ' . Query::TYPE_NOT_STARTS_WITH . ', ' . Query::TYPE_ENDS_WITH . ', ' . Query::TYPE_NOT_ENDS_WITH . ', ' . Query::TYPE_CONTAINS . ', ' . Query::TYPE_NOT_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_NOT_SEARCH . ', ' . Query::TYPE_SELECT), }; } @@ -2749,6 +2879,26 @@ public function getSupportForGetConnectionId(): bool return false; } + /** + * Is PCRE regex supported? + * + * @return bool + */ + public function getSupportForPCRERegex(): bool + { + return true; + } + + /** + * Is POSIX regex supported? + * + * @return bool + */ + public function getSupportForPOSIXRegex(): bool + { + return false; + } + /** * Is cache fallback supported? * @@ -2800,6 +2950,16 @@ public function getSupportForBatchCreateAttributes(): bool } public function getSupportForObject(): bool + { + return true; + } + + /** + * Are object (JSON) indexes supported? + * + * @return bool + */ + public function getSupportForObjectIndexes(): bool { return false; } @@ -3230,4 +3390,9 @@ public function getSupportForAlterLocks(): bool { return false; } + + public function getSupportForTrigramIndex(): bool + { + return false; + } } diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 2ff77e9a0..308013738 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -31,6 +31,9 @@ public function setTimeout(int $milliseconds, string $event = Database::EVENT_AL $this->timeout = $milliseconds; + $pdo = $this->getPDO(); + $pdo->exec("SET GLOBAL regexp_time_limit = {$milliseconds}"); + $this->before($event, 'timeout', function ($sql) use ($milliseconds) { return \preg_replace( pattern: '/SELECT/', @@ -152,6 +155,11 @@ protected function processException(PDOException $e): \Exception return new TimeoutException('Query timed out', $e->getCode(), $e); } + // Regex timeout + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 3699) { + return new TimeoutException('Query timed out', $e->getCode(), $e); + } + // Functional index dependency if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 3837) { return new DependencyException('Attribute cannot be deleted because it is used in an index', $e->getCode(), $e); @@ -245,6 +253,11 @@ public function getSupportForSpatialAxisOrder(): bool return true; } + public function getSupportForObjectIndexes(): bool + { + return false; + } + /** * Get the spatial axis order specification string for MySQL * MySQL with SRID 4326 expects lat-long by default, but our data is in long-lat format diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 76c98e8b2..d70a836ea 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -365,6 +365,21 @@ public function getSupportForFulltextWildcardIndex(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForPCRERegex(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSupportForPOSIXRegex(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSupportForTrigramIndex(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getSupportForCasting(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); @@ -590,6 +605,11 @@ public function getSupportForObject(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForObjectIndexes(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function castingBefore(Document $collection, Document $document): Document { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 86da09a58..050180a0a 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -154,6 +154,7 @@ public function create(string $name): bool // Enable extensions $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS postgis')->execute(); $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS vector')->execute(); + $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS pg_trgm')->execute(); $collation = " CREATE COLLATION IF NOT EXISTS utf8_ci_ai ( @@ -899,9 +900,10 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_SPATIAL, Database::INDEX_HNSW_EUCLIDEAN, Database::INDEX_HNSW_COSINE, - Database::INDEX_HNSW_DOT => 'INDEX', + Database::INDEX_HNSW_DOT, + Database::INDEX_OBJECT, + Database::INDEX_TRIGRAM => 'INDEX', Database::INDEX_UNIQUE => 'UNIQUE INDEX', - Database::INDEX_OBJECT => 'INDEX', default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT), }; @@ -922,6 +924,11 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_HNSW_COSINE => " USING HNSW ({$attributes} vector_cosine_ops)", Database::INDEX_HNSW_DOT => " USING HNSW ({$attributes} vector_ip_ops)", Database::INDEX_OBJECT => " USING GIN ({$attributes})", + Database::INDEX_TRIGRAM => + " USING GIN (" . implode(', ', array_map( + fn ($attr) => "$attr gin_trgm_ops", + array_map(fn ($attr) => trim($attr), explode(',', $attributes)) + )) . ")", default => " ({$attributes})", }; @@ -2112,6 +2119,21 @@ public function getSupportForVectors(): bool return true; } + public function getSupportForPCRERegex(): bool + { + return false; + } + + public function getSupportForPOSIXRegex(): bool + { + return true; + } + + public function getSupportForTrigramIndex(): bool + { + return true; + } + /** * @return string */ @@ -2120,6 +2142,14 @@ public function getLikeOperator(): string return 'ILIKE'; } + /** + * @return string + */ + public function getRegexOperator(): string + { + return '~'; + } + protected function processException(PDOException $e): \Exception { // Timeout @@ -2194,6 +2224,16 @@ public function getSupportForObject(): bool return true; } + /** + * Are object (JSONB) indexes supported? + * + * @return bool + */ + public function getSupportForObjectIndexes(): bool + { + return true; + } + /** * Does the adapter support null values in spatial indexes? * diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index dfd1565ba..26cee75ed 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1794,6 +1794,8 @@ protected function getSQLOperator(string $method): string case Query::TYPE_NOT_ENDS_WITH: case Query::TYPE_NOT_CONTAINS: return $this->getLikeOperator(); + case Query::TYPE_REGEX: + return $this->getRegexOperator(); case Query::TYPE_VECTOR_DOT: case Query::TYPE_VECTOR_COSINE: case Query::TYPE_VECTOR_EUCLIDEAN: @@ -2287,6 +2289,14 @@ public function getLikeOperator(): string return 'LIKE'; } + /** + * @return string + */ + public function getRegexOperator(): string + { + return 'REGEXP'; + } + public function getInternalIndexesKeys(): array { return []; diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index a3d31db68..948070654 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1013,6 +1013,16 @@ public function getSupportForObject(): bool return false; } + /** + * Are object (JSON) indexes supported? + * + * @return bool + */ + public function getSupportForObjectIndexes(): bool + { + return false; + } + public function getSupportForSpatialIndexNull(): bool { return false; // SQLite doesn't have native spatial support @@ -1876,4 +1886,26 @@ public function getSupportForAlterLocks(): bool { return false; } + + /** + * Is PCRE regex supported? + * SQLite does not have native REGEXP support - it requires compile-time option or user-defined function + * + * @return bool + */ + public function getSupportForPCRERegex(): bool + { + return false; + } + + /** + * Is POSIX regex supported? + * SQLite does not have native REGEXP support - it requires compile-time option or user-defined function + * + * @return bool + */ + public function getSupportForPOSIXRegex(): bool + { + return false; + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index d5595df38..a2bc2da55 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -85,6 +85,7 @@ class Database public const INDEX_HNSW_EUCLIDEAN = 'hnsw_euclidean'; public const INDEX_HNSW_COSINE = 'hnsw_cosine'; public const INDEX_HNSW_DOT = 'hnsw_dot'; + public const INDEX_TRIGRAM = 'trigram'; // Max limits public const MAX_INT = 2147483647; @@ -650,13 +651,14 @@ function (mixed $value) { return \json_encode($value); }, /** - * @param string|null $value + * @param mixed $value * @return array|null */ - function (?string $value) { + function (mixed $value) { if (is_null($value)) { - return null; + return; } + // can be non string in case of mongodb as it stores the value as object if (!is_string($value)) { return $value; } @@ -1640,7 +1642,12 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObject(), + $this->adapter->getSupportForObjectIndexes(), + $this->adapter->getSupportForTrigramIndex(), + $this->adapter->getSupportForSpatialAttributes(), + $this->adapter->getSupportForIndex(), + $this->adapter->getSupportForUniqueIndex(), + $this->adapter->getSupportForFulltextIndex(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -2785,7 +2792,12 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObject() + $this->adapter->getSupportForObjectIndexes(), + $this->adapter->getSupportForTrigramIndex(), + $this->adapter->getSupportForSpatialAttributes(), + $this->adapter->getSupportForIndex(), + $this->adapter->getSupportForUniqueIndex(), + $this->adapter->getSupportForFulltextIndex(), ); foreach ($indexes as $index) { @@ -3623,52 +3635,6 @@ public function createIndex(string $collection, string $id, string $type, array throw new LimitException('Index limit reached. Cannot create new index.'); } - switch ($type) { - case self::INDEX_KEY: - if (!$this->adapter->getSupportForIndex()) { - throw new DatabaseException('Key index is not supported'); - } - break; - - case self::INDEX_UNIQUE: - if (!$this->adapter->getSupportForUniqueIndex()) { - throw new DatabaseException('Unique index is not supported'); - } - break; - - case self::INDEX_FULLTEXT: - if (!$this->adapter->getSupportForFulltextIndex()) { - throw new DatabaseException('Fulltext index is not supported'); - } - break; - - case self::INDEX_SPATIAL: - if (!$this->adapter->getSupportForSpatialAttributes()) { - throw new DatabaseException('Spatial indexes are not supported'); - } - if (!empty($orders) && !$this->adapter->getSupportForSpatialIndexOrder()) { - throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); - } - break; - - case Database::INDEX_HNSW_EUCLIDEAN: - case Database::INDEX_HNSW_COSINE: - case Database::INDEX_HNSW_DOT: - if (!$this->adapter->getSupportForVectors()) { - throw new DatabaseException('Vector indexes are not supported'); - } - break; - - case self::INDEX_OBJECT: - if (!$this->adapter->getSupportForObject()) { - throw new DatabaseException('Object indexes are not supported'); - } - break; - - default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT); - } - /** @var array $collectionAttributes */ $collectionAttributes = $collection->getAttribute('attributes', []); $indexAttributesWithTypes = []; @@ -3721,7 +3687,12 @@ public function createIndex(string $collection, string $id, string $type, array $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObject(), + $this->adapter->getSupportForObjectIndexes(), + $this->adapter->getSupportForTrigramIndex(), + $this->adapter->getSupportForSpatialAttributes(), + $this->adapter->getSupportForIndex(), + $this->adapter->getSupportForUniqueIndex(), + $this->adapter->getSupportForFulltextIndex(), ); if (!$validator->isValid($index)) { throw new IndexException($validator->getDescription()); @@ -8116,6 +8087,43 @@ public function convertQueries(Document $collection, array $queries): array * @throws QueryException * @throws \Utopia\Database\Exception */ + /** + * Check if values are compatible with object attribute type (hashmap/multi-dimensional array) + * + * @param array $values + * @return bool + */ + private function isCompatibleObjectValue(array $values): bool + { + if (empty($values)) { + return false; + } + + foreach ($values as $value) { + if (!\is_array($value)) { + return false; + } + + // Check associative array (hashmap) or nested structure + if (empty($value)) { + continue; + } + + // simple indexed array => not an object + if (\array_keys($value) === \range(0, \count($value) - 1)) { + return false; + } + + foreach ($value as $nestedValue) { + if (\is_array($nestedValue)) { + continue; + } + } + } + + return true; + } + public function convertQuery(Document $collection, Query $query): Query { /** @@ -8152,6 +8160,12 @@ public function convertQuery(Document $collection, Query $query): Query } $query->setValues($values); } + } elseif (!$this->adapter->getSupportForAttributes()) { + $values = $query->getValues(); + // setting attribute type to properly apply filters in the adapter level + if ($this->adapter->getSupportForObject() && $this->isCompatibleObjectValue($values)) { + $query->setAttributeType(Database::VAR_OBJECT); + } } return $query; diff --git a/src/Database/Query.php b/src/Database/Query.php index e8ccdcaa3..cfa6a5934 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -26,6 +26,7 @@ class Query public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; public const TYPE_ENDS_WITH = 'endsWith'; public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + public const TYPE_REGEX = 'regex'; public const TYPE_EXISTS = 'exists'; public const TYPE_NOT_EXISTS = 'notExists'; @@ -64,7 +65,7 @@ class Query // Logical methods public const TYPE_AND = 'and'; public const TYPE_OR = 'or'; - + public const TYPE_ELEM_MATCH = 'elemMatch'; public const DEFAULT_ALIAS = 'main'; public const TYPES = [ @@ -113,6 +114,8 @@ class Query self::TYPE_CURSOR_BEFORE, self::TYPE_AND, self::TYPE_OR, + self::TYPE_ELEM_MATCH, + self::TYPE_REGEX ]; public const VECTOR_TYPES = [ @@ -124,12 +127,14 @@ class Query protected const LOGICAL_TYPES = [ self::TYPE_AND, self::TYPE_OR, + self::TYPE_ELEM_MATCH, ]; protected string $method = ''; protected string $attribute = ''; protected string $attributeType = ''; protected bool $onArray = false; + protected bool $isObjectAttribute = false; /** * @var array @@ -295,6 +300,7 @@ public static function isMethod(string $value): bool self::TYPE_NOT_TOUCHES, self::TYPE_OR, self::TYPE_AND, + self::TYPE_ELEM_MATCH, self::TYPE_SELECT, self::TYPE_VECTOR_DOT, self::TYPE_VECTOR_COSINE, @@ -1185,6 +1191,18 @@ public static function vectorEuclidean(string $attribute, array $vector): self return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); } + /** + * Helper method to create Query with regex method + * + * @param string $attribute + * @param string $pattern + * @return Query + */ + public static function regex(string $attribute, string $pattern): self + { + return new self(self::TYPE_REGEX, $attribute, [$pattern]); + } + /** * Helper method to create Query with exists method * @@ -1206,4 +1224,14 @@ public static function notExists(string|int|float|bool|array $attribute): self { return new self(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]); } + + /** + * @param string $attribute + * @param array $queries + * @return Query + */ + public static function elemMatch(string $attribute, array $queries): self + { + return new self(self::TYPE_ELEM_MATCH, $attribute, $queries); + } } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 33648feeb..e2fc70a0b 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -29,6 +29,11 @@ class Index extends Validator * @param bool $supportForMultipleFulltextIndexes * @param bool $supportForIdenticalIndexes * @param bool $supportForObjectIndexes + * @param bool $supportForTrigramIndexes + * @param bool $supportForSpatialIndexes + * @param bool $supportForKeyIndexes + * @param bool $supportForUniqueIndexes + * @param bool $supportForFulltextIndexes * @throws DatabaseException */ public function __construct( @@ -43,7 +48,12 @@ public function __construct( protected bool $supportForAttributes = true, protected bool $supportForMultipleFulltextIndexes = true, protected bool $supportForIdenticalIndexes = true, - protected bool $supportForObjectIndexes = false + protected bool $supportForObjectIndexes = false, + protected bool $supportForTrigramIndexes = false, + protected bool $supportForSpatialIndexes = false, + protected bool $supportForKeyIndexes = true, + protected bool $supportForUniqueIndexes = true, + protected bool $supportForFulltextIndexes = true, ) { foreach ($attributes as $attribute) { $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); @@ -98,6 +108,9 @@ public function isArray(): bool */ public function isValid($value): bool { + if (!$this->checkValidIndex($value)) { + return false; + } if (!$this->checkValidAttributes($value)) { return false; } @@ -137,6 +150,82 @@ public function isValid($value): bool if (!$this->checkObjectIndexes($value)) { return false; } + if (!$this->checkTrigramIndexes($value)) { + return false; + } + if (!$this->checkKeyUniqueFulltextSupport($value)) { + return false; + } + return true; + } + + /** + * @param Document $index + * @return bool + */ + public function checkValidIndex(Document $index): bool + { + $type = $index->getAttribute('type'); + switch ($type) { + case Database::INDEX_KEY: + if (!$this->supportForKeyIndexes) { + $this->message = 'Key index is not supported'; + return false; + } + break; + + case Database::INDEX_UNIQUE: + if (!$this->supportForUniqueIndexes) { + $this->message = 'Unique index is not supported'; + return false; + } + break; + + case Database::INDEX_FULLTEXT: + if (!$this->supportForFulltextIndexes) { + $this->message = 'Fulltext index is not supported'; + return false; + } + break; + + case Database::INDEX_SPATIAL: + if (!$this->supportForSpatialIndexes) { + $this->message = 'Spatial indexes are not supported'; + return false; + } + if (!empty($index->getAttribute('orders')) && !$this->supportForSpatialIndexOrder) { + $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; + return false; + } + break; + + case Database::INDEX_HNSW_EUCLIDEAN: + case Database::INDEX_HNSW_COSINE: + case Database::INDEX_HNSW_DOT: + if (!$this->supportForVectorIndexes) { + $this->message = 'Vector indexes are not supported'; + return false; + } + break; + + case Database::INDEX_OBJECT: + if (!$this->supportForObjectIndexes) { + $this->message = 'Object indexes are not supported'; + return false; + } + break; + + case Database::INDEX_TRIGRAM: + if (!$this->supportForTrigramIndexes) { + $this->message = 'Trigram indexes are not supported'; + return false; + } + break; + + default: + $this->message = 'Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT . ', '.Database::INDEX_TRIGRAM; + return false; + } return true; } @@ -357,6 +446,11 @@ public function checkSpatialIndexes(Document $index): bool return true; } + if ($this->supportForSpatialIndexes === false) { + $this->message = 'Spatial indexes are not supported'; + return false; + } + $attributes = $index->getAttribute('attributes', []); $orders = $index->getAttribute('orders', []); @@ -462,6 +556,65 @@ public function checkVectorIndexes(Document $index): bool return true; } + /** + * @param Document $index + * @return bool + * @throws DatabaseException + */ + public function checkTrigramIndexes(Document $index): bool + { + $type = $index->getAttribute('type'); + + if ($type !== Database::INDEX_TRIGRAM) { + return true; + } + + if ($this->supportForTrigramIndexes === false) { + $this->message = 'Trigram indexes are not supported'; + return false; + } + + $attributes = $index->getAttribute('attributes', []); + + foreach ($attributes as $attributeName) { + $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); + if ($attribute->getAttribute('type', '') !== Database::VAR_STRING) { + $this->message = 'Trigram index can only be created on string type attributes'; + return false; + } + } + + $orders = $index->getAttribute('orders', []); + $lengths = $index->getAttribute('lengths', []); + if (!empty($orders) || \count(\array_filter($lengths)) > 0) { + $this->message = 'Trigram indexes do not support orders or lengths'; + return false; + } + + return true; + } + + /** + * @param Document $index + * @return bool + */ + public function checkKeyUniqueFulltextSupport(Document $index): bool + { + $type = $index->getAttribute('type'); + + if ($type === Database::INDEX_KEY && $this->supportForKeyIndexes === false) { + $this->message = 'Key index is not supported'; + return false; + } + + if ($type === Database::INDEX_UNIQUE && $this->supportForUniqueIndexes === false) { + $this->message = 'Unique index is not supported'; + return false; + } + + return true; + } + /** * @param Document $index * @return bool diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 22017692a..26f2b09d9 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -107,6 +107,7 @@ public function isValid($value): bool Query::TYPE_NOT_CONTAINS, Query::TYPE_AND, Query::TYPE_OR, + Query::TYPE_ELEM_MATCH, Query::TYPE_CROSSES, Query::TYPE_NOT_CROSSES, Query::TYPE_DISTANCE_EQUAL, @@ -122,6 +123,7 @@ public function isValid($value): bool Query::TYPE_VECTOR_DOT, Query::TYPE_VECTOR_COSINE, Query::TYPE_VECTOR_EUCLIDEAN, + Query::TYPE_REGEX, Query::TYPE_EXISTS, Query::TYPE_NOT_EXISTS => Base::METHOD_TYPE_FILTER, default => '', diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index e62fc3913..d68ccbf8b 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -146,7 +146,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s break; case Database::VAR_INTEGER: - $validator = new Integer(); + $size = $attributeSchema['size'] ?? 4; + $signed = $attributeSchema['signed'] ?? true; + $bits = $size >= 8 ? 64 : 32; + // For 64-bit unsigned, use signed since PHP doesn't support true 64-bit unsigned + $unsigned = !$signed && $bits < 64; + $validator = new Integer(false, $bits, $unsigned); break; case Database::VAR_FLOAT: @@ -169,8 +174,11 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s break; case Database::VAR_OBJECT: - // value for object can be of any type as its a hashmap - // eg; ['key'=>value'] + if (\in_array($method, [Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS], true) + && !$this->isValidObjectQueryValues($value)) { + $this->message = 'Invalid object query structure for attribute "' . $attribute . '"'; + return false; + } continue 2; case Database::VAR_POINT: @@ -294,6 +302,50 @@ protected function isEmpty(array $values): bool return false; } + /** + * Validate object attribute query values. + * + * Disallows ambiguous nested structures like: + * ['a' => [1, 'b' => [212]]] // mixed list + * + * but allows: + * ['a' => [1, 2], 'b' => [212]] // multiple top-level paths + * ['projects' => [[...]]] // list of objects + * ['role' => ['name' => [...], 'ex' => [...]]] // multiple nested paths + * + * @param mixed $values + * @return bool + */ + private function isValidObjectQueryValues(mixed $values): bool + { + if (!is_array($values)) { + return true; + } + + $hasInt = false; + $hasString = false; + + foreach (array_keys($values) as $key) { + if (is_int($key)) { + $hasInt = true; + } else { + $hasString = true; + } + } + + if ($hasInt && $hasString) { + return false; + } + + foreach ($values as $value) { + if (!$this->isValidObjectQueryValues($value)) { + return false; + } + } + + return true; + } + /** * Is valid. * @@ -342,6 +394,7 @@ public function isValid($value): bool case Query::TYPE_NOT_STARTS_WITH: case Query::TYPE_ENDS_WITH: case Query::TYPE_NOT_ENDS_WITH: + case Query::TYPE_REGEX: if (count($value->getValues()) != 1) { $this->message = \ucfirst($method) . ' queries require exactly one value.'; return false; @@ -404,6 +457,32 @@ public function isValid($value): bool return true; + case Query::TYPE_ELEM_MATCH: + // elemMatch is not supported when adapter supports attributes (schema mode) + if ($this->supportForAttributes) { + $this->message = 'elemMatch is not supported by the database'; + return false; + } + + // Validate that the attribute (array field) exists + if (!$this->isValidAttribute($attribute)) { + return false; + } + + // For schemaless mode, allow elemMatch on any attribute + // Validate nested queries are filter queries + $filters = Query::groupByType($value->getValues())['filters']; + if (count($value->getValues()) !== count($filters)) { + $this->message = 'elemMatch queries can only contain filter queries'; + return false; + } + + if (count($filters) < 1) { + $this->message = 'elemMatch queries require at least one query'; + return false; + } + return true; + default: // Handle spatial query types and any other query types if ($value->isSpatialQuery()) { diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index 74eadcf96..ac1739089 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -348,8 +348,12 @@ protected function checkForInvalidAttributeValues(array $structure, array $keys) break; case Database::VAR_INTEGER: - // We need both Integer and Range because Range implicitly casts non-numeric values - $validators[] = new Integer(); + // Determine bit size based on attribute size in bytes + $bits = $size >= 8 ? 64 : 32; + // For 64-bit unsigned, use signed since PHP doesn't support true 64-bit unsigned + // The Range validator will restrict to positive values only + $unsigned = !$signed && $bits < 64; + $validators[] = new Integer(false, $bits, $unsigned); $max = $size >= 8 ? Database::MAX_BIG_INT : Database::MAX_INT; $min = $signed ? -$max : 0; $validators[] = new Range($min, $max, Database::VAR_INTEGER); diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index c428f3f01..cab5a8b9e 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1408,7 +1408,7 @@ public function testArrayAttribute(): void $this->fail('Failed to throw exception'); } catch (Throwable $e) { if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertEquals('Invalid document structure: Attribute "age" has invalid type. Value must be a valid integer', $e->getMessage()); + $this->assertEquals('Invalid document structure: Attribute "age" has invalid type. Value must be a valid unsigned 32-bit integer between 0 and 4,294,967,295', $e->getMessage()); } } @@ -1419,7 +1419,7 @@ public function testArrayAttribute(): void $this->fail('Failed to throw exception'); } catch (Throwable $e) { if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertEquals('Invalid document structure: Attribute "age" has invalid type. Value must be a valid range between 0 and 2,147,483,647', $e->getMessage()); + $this->assertEquals('Invalid document structure: Attribute "age" has invalid type. Value must be a valid unsigned 32-bit integer between 0 and 4,294,967,295', $e->getMessage()); } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 151a5ae26..df31b9595 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use PDOException; use Throwable; use Utopia\Database\Adapter\SQL; use Utopia\Database\Database; @@ -14,6 +15,7 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Structure as StructureException; +use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; @@ -6549,4 +6551,880 @@ public function testUpsertWithJSONFilters(): void // Cleanup $database->deleteCollection($collection); } + + public function testFindRegex(): void + { + Authorization::setRole(Role::any()->toString()); + + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip test if regex is not supported + if (!$database->getAdapter()->getSupportForRegex()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Determine regex support type + $supportsPCRE = $database->getAdapter()->getSupportForPCRERegex(); + $supportsPOSIX = $database->getAdapter()->getSupportForPOSIXRegex(); + + // Determine word boundary pattern based on support + $wordBoundaryPattern = null; + $wordBoundaryPatternPHP = null; + if ($supportsPCRE) { + $wordBoundaryPattern = '\\b'; // PCRE uses \b + $wordBoundaryPatternPHP = '\\b'; // PHP preg_match uses \b + } elseif ($supportsPOSIX) { + $wordBoundaryPattern = '\\y'; // POSIX uses \y + $wordBoundaryPatternPHP = '\\b'; // PHP preg_match still uses \b for verification + } + + $database->createCollection('moviesRegex', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->assertEquals(true, $database->createAttribute('moviesRegex', 'name', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, $database->createAttribute('moviesRegex', 'director', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, $database->createAttribute('moviesRegex', 'year', Database::VAR_INTEGER, 0, true)); + } + + if ($database->getAdapter()->getSupportForTrigramIndex()) { + $database->createIndex('moviesRegex', 'trigram_name', Database::INDEX_TRIGRAM, ['name']); + $database->createIndex('moviesRegex', 'trigram_director', Database::INDEX_TRIGRAM, ['director']); + } + + // Create test documents + $database->createDocuments('moviesRegex', [ + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Frozen', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2013, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Frozen II', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2019, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Captain America: The First Avenger', + 'director' => 'Joe Johnston', + 'year' => 2011, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Captain Marvel', + 'director' => 'Anna Boden & Ryan Fleck', + 'year' => 2019, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Work in Progress', + 'director' => 'TBD', + 'year' => 2025, + ]), + new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Work in Progress 2', + 'director' => 'TBD', + 'year' => 2026, + ]), + ]); + + // Helper function to verify regex query completeness + $verifyRegexQuery = function (string $attribute, string $regexPattern, array $queryResults) use ($database) { + // Convert database regex pattern to PHP regex format. + // POSIX-style word boundary (\y) is not supported by PHP PCRE, so map it to \b. + $normalizedPattern = str_replace('\y', '\b', $regexPattern); + $phpPattern = '/' . str_replace('/', '\/', $normalizedPattern) . '/'; + + // Get all documents to manually verify + $allDocuments = $database->find('moviesRegex'); + + // Manually filter documents that match the pattern + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $value = $doc->getAttribute($attribute); + if (preg_match($phpPattern, $value)) { + $expectedMatches[] = $doc->getId(); + } + } + + // Get IDs from query results + $actualMatches = array_map(fn ($doc) => $doc->getId(), $queryResults); + + // Verify no extra documents are returned + foreach ($queryResults as $doc) { + $value = $doc->getAttribute($attribute); + $this->assertTrue( + (bool) preg_match($phpPattern, $value), + "Document '{$doc->getId()}' with {$attribute}='{$value}' should match pattern '{$regexPattern}'" + ); + } + + // Verify all expected documents are returned (no missing) + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '{$regexPattern}' on attribute '{$attribute}'" + ); + }; + + // Test basic regex pattern - match movies starting with 'Captain' + // Note: Pattern format may vary by adapter (MongoDB uses regex strings, SQL uses REGEXP) + $pattern = '/^Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^Captain'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '^Captain', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + $this->assertTrue(in_array('Captain Marvel', $names)); + + // Test regex pattern - match movies containing 'Frozen' + $pattern = '/Frozen/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Frozen'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Frozen', $documents); + + // Test regex pattern - match exact title 'Frozen' + $exactFrozenDocuments = $database->find('moviesRegex', [ + Query::regex('name', '^Frozen$'), + ]); + $verifyRegexQuery('name', '^Frozen$', $exactFrozenDocuments); + $this->assertCount(1, $exactFrozenDocuments, 'Exact ^Frozen$ regex should return only one document'); + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Frozen', $names)); + $this->assertTrue(in_array('Frozen II', $names)); + + // Test regex pattern - match movies ending with 'Marvel' + $pattern = '/Marvel$/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Marvel$'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Marvel$', $documents); + + $this->assertEquals(1, count($documents)); // Only Captain Marvel + $this->assertEquals('Captain Marvel', $documents[0]->getAttribute('name')); + + // Test regex pattern - match movies with 'Work' in the name + $pattern = '/.*Work.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*Work.*'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*Work.*', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Work in Progress', $names)); + $this->assertTrue(in_array('Work in Progress 2', $names)); + + // Test regex pattern - match movies with 'Buck' in director + $pattern = '/.*Buck.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('director', '.*Buck.*'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('director', '.*Buck.*', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Frozen', $names)); + $this->assertTrue(in_array('Frozen II', $names)); + + // Test regex with case pattern - adapters may be case-sensitive or case-insensitive + // MySQL/MariaDB REGEXP is case-insensitive by default, MongoDB is case-sensitive + $patternCaseSensitive = '/captain/'; + $patternCaseInsensitive = '/captain/i'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'captain'), // lowercase + ]); + + // Verify all returned documents match the pattern (case-insensitive check for verification) + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + // Verify that returned documents contain 'captain' (case-insensitive check) + $this->assertTrue( + (bool) preg_match($patternCaseInsensitive, $name), + "Document '{$name}' should match pattern 'captain' (case-insensitive check)" + ); + } + + // Verify completeness: Check what the database actually returns + // Some adapters (MongoDB) are case-sensitive, others (MySQL/MariaDB) are case-insensitive + // We'll determine expected matches based on case-sensitive matching (pure regex behavior) + // If the adapter is case-insensitive, it will return more documents, which is fine + $allDocuments = $database->find('moviesRegex'); + $expectedMatchesCaseSensitive = []; + $expectedMatchesCaseInsensitive = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($patternCaseSensitive, $name)) { + $expectedMatchesCaseSensitive[] = $doc->getId(); + } + if (preg_match($patternCaseInsensitive, $name)) { + $expectedMatchesCaseInsensitive[] = $doc->getId(); + } + } + + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($actualMatches); + + // The database might be case-sensitive (MongoDB) or case-insensitive (MySQL/MariaDB) + // Check which one matches the actual results + sort($expectedMatchesCaseSensitive); + sort($expectedMatchesCaseInsensitive); + + // Verify that actual results match either case-sensitive or case-insensitive expectations + $matchesCaseSensitive = ($expectedMatchesCaseSensitive === $actualMatches); + $matchesCaseInsensitive = ($expectedMatchesCaseInsensitive === $actualMatches); + + $this->assertTrue( + $matchesCaseSensitive || $matchesCaseInsensitive, + "Query results should match either case-sensitive (" . count($expectedMatchesCaseSensitive) . " docs) or case-insensitive (" . count($expectedMatchesCaseInsensitive) . " docs) expectations. Got " . count($actualMatches) . " documents." + ); + + // Test regex with case-insensitive pattern (if adapter supports it via flags) + // Test with uppercase to verify case sensitivity + $pattern = '/Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Captain'), // uppercase + ]); + + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern 'Captain'" + ); + } + + // Verify completeness + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'Captain'" + ); + + // Test regex combined with other queries + $pattern = '/^Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^Captain'), + Query::greaterThan('year', 2010), + ]); + + // Verify all returned documents match both conditions + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $year = $doc->getAttribute('year'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + $this->assertGreaterThan(2010, $year, "Document '{$name}' should have year > 2010"); + } + + // Verify completeness: manually check all documents that match both conditions + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $year = $doc->getAttribute('year'); + if (preg_match($pattern, $name) && $year > 2010) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching both regex '^Captain' and year > 2010" + ); + + // Test regex with limit + $pattern = '/.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*'), // Match all + Query::limit(3), + ]); + + $this->assertEquals(3, count($documents)); + + // Verify all returned documents match the pattern (should match all) + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + } + + // Note: With limit, we can't verify completeness, but we can verify all returned match + + // Test regex with non-matching pattern + $pattern = '/^NonExistentPattern$/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^NonExistentPattern$'), + ]); + + $this->assertEquals(0, count($documents)); + + // Verify no documents match (double-check by getting all and filtering) + $allDocuments = $database->find('moviesRegex'); + $matchingCount = 0; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern, $name)) { + $matchingCount++; + } + } + $this->assertEquals(0, $matchingCount, "No documents should match pattern '{$pattern}'"); + + // Verify completeness: no documents should be returned + $this->assertEquals([], array_map(fn ($doc) => $doc->getId(), $documents)); + + // Test regex with special characters (should be escaped or handled properly) + $pattern = '/.*:.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*:.*'), // Match movies with colon + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*:.*', $documents); + + // Verify expected document is included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + + // ReDOS safety: ensure pathological patterns respond quickly and do not hang + $catastrophicPattern = '(a+)+$'; + $start = microtime(true); + $redosDocs = $database->find('moviesRegex', [ + Query::regex('name', $catastrophicPattern), + ]); + $elapsed = microtime(true) - $start; + $this->assertLessThan(1.0, $elapsed, 'Regex evaluation should not be slow or vulnerable to ReDOS'); + $verifyRegexQuery('name', $catastrophicPattern, $redosDocs); + $this->assertCount(0, $redosDocs, 'Pathological regex should not match any movie titles'); + + // Test regex search pattern - match movies with word boundaries + // Only test if word boundaries are supported (PCRE or POSIX) + if ($wordBoundaryPattern !== null) { + $dbPattern = $wordBoundaryPattern . 'Work' . $wordBoundaryPattern; + $phpPattern = '/' . $wordBoundaryPatternPHP . 'Work' . $wordBoundaryPatternPHP . '/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', $dbPattern), + ]); + + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($phpPattern, $name), + "Document '{$name}' should match pattern '{$dbPattern}'" + ); + } + + // Verify completeness: manually check all documents + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($phpPattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '{$dbPattern}'" + ); + } + + // Test regex search with multiple patterns - match movies containing 'Captain' or 'Frozen' + $pattern1 = '/Captain/'; + $pattern2 = '/Frozen/'; + $documents = $database->find('moviesRegex', [ + Query::or([ + Query::regex('name', 'Captain'), + Query::regex('name', 'Frozen'), + ]), + ]); + + // Verify all returned documents match at least one pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $matchesPattern1 = (bool) preg_match($pattern1, $name); + $matchesPattern2 = (bool) preg_match($pattern2, $name); + $this->assertTrue( + $matchesPattern1 || $matchesPattern2, + "Document '{$name}' should match either pattern 'Captain' or 'Frozen'" + ); + } + + // Verify completeness: manually check all documents + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern1, $name) || preg_match($pattern2, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'Captain' OR 'Frozen'" + ); + $database->deleteCollection('moviesRegex'); + } + public function testRegexInjection(): void + { + Authorization::setRole(Role::any()->toString()); + + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip test if regex is not supported + if (!$database->getAdapter()->getSupportForRegex()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionName = 'injectionTest'; + $database->createCollection($collectionName, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->assertEquals(true, $database->createAttribute($collectionName, 'text', Database::VAR_STRING, 1000, true)); + } + + // Create test documents - one that should match, one that shouldn't + $database->createDocument($collectionName, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'text' => 'target', + ])); + + $database->createDocument($collectionName, new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'text' => 'other', + ])); + + // SQL injection attempts - these should NOT return the "other" document + $sqlInjectionPatterns = [ + "target') OR '1'='1", // SQL injection attempt + "target' OR 1=1--", // SQL injection with comment + "target' OR 'x'='x", // SQL injection attempt + "target' UNION SELECT *--", // SQL UNION injection + ]; + + // MongoDB injection attempts - these should NOT return the "other" document + $mongoInjectionPatterns = [ + 'target" || "1"=="1', // MongoDB injection attempt + 'target" || true', // MongoDB boolean injection + 'target"} || {"text": "other"}', // MongoDB operator injection + ]; + + $allInjectionPatterns = array_merge($sqlInjectionPatterns, $mongoInjectionPatterns); + + foreach ($allInjectionPatterns as $pattern) { + try { + $results = $database->find($collectionName, [ + Query::regex('text', $pattern), + ]); + + // Critical check: if injection succeeded, we might get the "other" document + // which should NOT match a pattern starting with "target" + $foundOther = false; + foreach ($results as $doc) { + $text = $doc->getAttribute('text'); + if ($text === 'other') { + $foundOther = true; + + // Verify that "other" doesn't actually match the pattern as a regex + $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); + if ($matches === 0 || $matches === false) { + // "other" doesn't match the pattern but was returned + // This indicates potential injection vulnerability + $this->fail( + "Potential injection detected: Pattern '{$pattern}' returned document 'other' " . + "which doesn't match the pattern. This suggests SQL/MongoDB injection may have succeeded." + ); + } + } + } + + // Additional verification: check that all returned documents actually match the pattern + foreach ($results as $doc) { + $text = $doc->getAttribute('text'); + $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); + + // If pattern is invalid, skip validation + if ($matches === false) { + continue; + } + + // If document doesn't match but was returned, it's suspicious + if ($matches === 0) { + $this->fail( + "Potential injection: Document '{$text}' was returned for pattern '{$pattern}' " . + "but doesn't match the regex pattern." + ); + } + } + + } catch (\Exception $e) { + // Exceptions are acceptable - they indicate the injection was blocked or caused an error + // This is actually good - it means the system rejected the malicious pattern + $this->assertInstanceOf(\Exception::class, $e); + } + } + + // Test that legitimate regex patterns still work correctly + $legitimatePatterns = [ + 'target', // Should match "target" + '^target', // Should match "target" (anchored) + 'other', // Should match "other" + ]; + + foreach ($legitimatePatterns as $pattern) { + try { + $results = $database->find($collectionName, [ + Query::regex('text', $pattern), + ]); + + $this->assertIsArray($results); + + // Verify each result actually matches + foreach ($results as $doc) { + $text = $doc->getAttribute('text'); + $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); + if ($matches !== false) { + $this->assertEquals( + 1, + $matches, + "Document '{$text}' should match pattern '{$pattern}'" + ); + } + } + } catch (\Exception $e) { + $this->fail("Legitimate pattern '{$pattern}' should not throw exception: " . $e->getMessage()); + } + } + + // Cleanup + $database->deleteCollection($collectionName); + } + + /** + * Test ReDoS (Regular Expression Denial of Service) with timeout protection + * This test verifies that ReDoS patterns either timeout properly or complete quickly, + * preventing denial of service attacks. + */ + public function testRegexRedos(): void + { + Authorization::setRole(Role::any()->toString()); + + /** @var Database $database */ + $database = static::getDatabase(); + + // Skip test if regex is not supported + if (!$database->getAdapter()->getSupportForRegex()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionName = 'redosTimeoutTest'; + $database->createCollection($collectionName, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->assertEquals(true, $database->createAttribute($collectionName, 'text', Database::VAR_STRING, 1000, true)); + } + + // Create documents with strings designed to trigger ReDoS + // These strings have many 'a's but end with 'c' instead of 'b' + // This causes catastrophic backtracking with patterns like (a+)+b + $redosStrings = []; + for ($i = 15; $i <= 35; $i += 5) { + $redosStrings[] = str_repeat('a', $i) . 'c'; + } + + // Also add some normal strings + $normalStrings = [ + 'normal text', + 'another string', + 'test123', + 'valid data', + ]; + + $documents = []; + foreach ($redosStrings as $text) { + $documents[] = new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'text' => $text, + ]); + } + + foreach ($normalStrings as $text) { + $documents[] = new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'text' => $text, + ]); + } + + $database->createDocuments($collectionName, $documents); + + // ReDoS patterns that cause exponential backtracking + $redosPatterns = [ + '(a+)+b', // Classic ReDoS: nested quantifiers + '(a|a)*b', // Alternation with quantifier + '(a+)+$', // Anchored pattern + '(a*)*b', // Nested star quantifiers + '(a+)+b+', // Multiple nested quantifiers + '(.+)+b', // Generic nested quantifiers + '(.*)+b', // Generic nested quantifiers + ]; + + $supportsTimeout = $database->getAdapter()->getSupportForTimeouts(); + + if ($supportsTimeout) { + $database->setTimeout(2000); + } + + foreach ($redosPatterns as $pattern) { + $startTime = microtime(true); + + try { + $results = $database->find($collectionName, [ + Query::regex('text', $pattern), + ]); + $elapsed = microtime(true) - $startTime; + // If timeout is supported, the query should either: + // 1. Complete quickly (< 3 seconds) if ReDoS is mitigated + // 2. Throw TimeoutException if it takes too long + if ($supportsTimeout) { + // If we got here without timeout, it should have completed quickly + $this->assertLessThan( + 3.0, + $elapsed, + "Regex pattern '{$pattern}' should complete quickly or timeout. Took {$elapsed}s" + ); + } else { + // Without timeout support, we just check it doesn't hang forever + // Set a reasonable upper bound (15 seconds) for systems without timeout + $this->assertLessThan( + 15.0, + $elapsed, + "Regex pattern '{$pattern}' should not cause excessive delay. Took {$elapsed}s" + ); + } + + // Verify results: none of our ReDoS strings should match these patterns + // (they all end with 'c', not 'b') + foreach ($results as $doc) { + $text = $doc->getAttribute('text'); + // If it matched, verify it's actually a valid match + $matches = @preg_match('/' . str_replace('/', '\/', $pattern) . '/', $text); + if ($matches !== false) { + $this->assertEquals( + 1, + $matches, + "Document with text '{$text}' should actually match pattern '{$pattern}'" + ); + } + } + + } catch (TimeoutException $e) { + // Timeout is expected for ReDoS patterns if not properly mitigated + $elapsed = microtime(true) - $startTime; + $this->assertInstanceOf( + TimeoutException::class, + $e, + "Regex pattern '{$pattern}' should timeout if it causes ReDoS. Elapsed: {$elapsed}s" + ); + + // Timeout should happen within reasonable time (not immediately, but not too late) + // Fast timeouts are actually good - they mean the system is protecting itself quickly + $this->assertGreaterThan( + 0.05, + $elapsed, + "Timeout should occur after some minimal processing time" + ); + + // Timeout should happen before the timeout limit (with some buffer) + if ($supportsTimeout) { + $this->assertLessThan( + 5.0, + $elapsed, + "Timeout should occur within reasonable time (before 5 seconds)" + ); + } + + } catch (\Exception $e) { + // Check if this is a query interruption/timeout from MySQL (error 1317) + // MySQL sometimes throws "Query execution was interrupted" instead of TimeoutException + $message = $e->getMessage(); + $isQueryInterrupted = false; + + // Check message for interruption keywords + if (strpos($message, 'Query execution was interrupted') !== false || + strpos($message, 'interrupted') !== false) { + $isQueryInterrupted = true; + } + + // Check if it's a PDOException with error code 1317 + if ($e instanceof PDOException) { + $errorInfo = $e->errorInfo ?? []; + // Error 1317 is "Query execution was interrupted" + if (isset($errorInfo[1]) && $errorInfo[1] === 1317) { + $isQueryInterrupted = true; + } + // Also check SQLSTATE 70100 + if ($e->getCode() === '70100') { + $isQueryInterrupted = true; + } + } + + if ($isQueryInterrupted) { + // This is effectively a timeout - MySQL interrupted the query + $elapsed = microtime(true) - $startTime; + $this->assertGreaterThan( + 0.05, + $elapsed, + "Query interruption should occur after some minimal processing time" + ); + // This is acceptable - the query was interrupted due to timeout + continue; + } + + // Other exceptions are unexpected + $this->fail("Unexpected exception for pattern '{$pattern}': " . get_class($e) . " - " . $e->getMessage()); + } + } + + // Test with a pattern that should match quickly (not ReDoS) + $safePattern = 'normal'; + $startTime = microtime(true); + $results = $database->find($collectionName, [ + Query::regex('text', $safePattern), + ]); + $elapsed = microtime(true) - $startTime; + + // Safe patterns should complete very quickly + $this->assertLessThan(1.0, $elapsed, 'Safe regex pattern should complete quickly'); + $this->assertGreaterThan(0, count($results), 'Safe pattern should match some documents'); + + // Verify safe pattern results are correct + foreach ($results as $doc) { + $text = $doc->getAttribute('text'); + $this->assertStringContainsString('normal', $text, "Document '{$text}' should contain 'normal'"); + } + + // Cleanup + if ($supportsTimeout) { + $database->clearTimeout(); + } + $database->deleteCollection($collectionName); + } } diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index 77f276cd6..e5eda16d0 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -173,7 +173,13 @@ public function testIndexValidation(): void $database->getAdapter()->getSupportForVectors(), $database->getAdapter()->getSupportForAttributes(), $database->getAdapter()->getSupportForMultipleFulltextIndexes(), - $database->getAdapter()->getSupportForIdenticalIndexes() + $database->getAdapter()->getSupportForIdenticalIndexes(), + $database->getAdapter()->getSupportForObject(), + $database->getAdapter()->getSupportForTrigramIndex(), + $database->getAdapter()->getSupportForSpatialAttributes(), + $database->getAdapter()->getSupportForIndex(), + $database->getAdapter()->getSupportForUniqueIndex(), + $database->getAdapter()->getSupportForFulltextIndex() ); if ($database->getAdapter()->getSupportForIdenticalIndexes()) { $errorMessage = 'Index length 701 is larger than the size for title1: 700"'; @@ -264,12 +270,20 @@ public function testIndexValidation(): void $database->getAdapter()->getSupportForVectors(), $database->getAdapter()->getSupportForAttributes(), $database->getAdapter()->getSupportForMultipleFulltextIndexes(), - $database->getAdapter()->getSupportForIdenticalIndexes() + $database->getAdapter()->getSupportForIdenticalIndexes(), + $database->getAdapter()->getSupportForObject(), + $database->getAdapter()->getSupportForTrigramIndex(), + $database->getAdapter()->getSupportForSpatialAttributes(), + $database->getAdapter()->getSupportForIndex(), + $database->getAdapter()->getSupportForUniqueIndex(), + $database->getAdapter()->getSupportForFulltextIndex() ); $this->assertFalse($validator->isValid($newIndex)); - if (!$database->getAdapter()->getSupportForMultipleFulltextIndexes()) { + if (!$database->getAdapter()->getSupportForFulltextIndex()) { + $this->assertEquals('Fulltext index is not supported', $validator->getDescription()); + } elseif (!$database->getAdapter()->getSupportForMultipleFulltextIndexes()) { $this->assertEquals('There is already a fulltext index in the collection', $validator->getDescription()); } elseif ($database->getAdapter()->getSupportForAttributes()) { $this->assertEquals('Attribute "integer" cannot be part of a fulltext index, must be of type string', $validator->getDescription()); @@ -281,7 +295,11 @@ public function testIndexValidation(): void $this->fail('Failed to throw exception'); } } catch (Exception $e) { - $this->assertEquals('Attribute "integer" cannot be part of a fulltext index, must be of type string', $e->getMessage()); + if (!$database->getAdapter()->getSupportForFulltextIndex()) { + $this->assertEquals('Fulltext index is not supported', $e->getMessage()); + } else { + $this->assertEquals('Attribute "integer" cannot be part of a fulltext index, must be of type string', $e->getMessage()); + } } @@ -644,4 +662,126 @@ public function testIdenticalIndexValidation(): void $database->deleteCollection($collectionId); } } + + public function testTrigramIndex(): void + { + $trigramSupport = $this->getDatabase()->getAdapter()->getSupportForTrigramIndex(); + if (!$trigramSupport) { + $this->expectNotToPerformAssertions(); + return; + } + + /** @var Database $database */ + $database = static::getDatabase(); + + $collectionId = 'trigram_test'; + try { + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 256, false); + $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 512, false); + + // Create trigram index on name attribute + $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_name', Database::INDEX_TRIGRAM, ['name'])); + + $collection = $database->getCollection($collectionId); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(1, $indexes); + $this->assertEquals('trigram_name', $indexes[0]['$id']); + $this->assertEquals(Database::INDEX_TRIGRAM, $indexes[0]['type']); + $this->assertEquals(['name'], $indexes[0]['attributes']); + + // Create another trigram index on description + $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_description', Database::INDEX_TRIGRAM, ['description'])); + + $collection = $database->getCollection($collectionId); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + // Test that trigram index can be deleted + $this->assertEquals(true, $database->deleteIndex($collectionId, 'trigram_name')); + $this->assertEquals(true, $database->deleteIndex($collectionId, 'trigram_description')); + + $collection = $database->getCollection($collectionId); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(0, $indexes); + + } finally { + // Clean up + $database->deleteCollection($collectionId); + } + } + + public function testTrigramIndexValidation(): void + { + $trigramSupport = $this->getDatabase()->getAdapter()->getSupportForTrigramIndex(); + if (!$trigramSupport) { + $this->expectNotToPerformAssertions(); + return; + } + + /** @var Database $database */ + $database = static::getDatabase(); + + $collectionId = 'trigram_validation_test'; + try { + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 256, false); + $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 412, false); + $database->createAttribute($collectionId, 'age', Database::VAR_INTEGER, 8, false); + + // Test: Trigram index on non-string attribute should fail + try { + $database->createIndex($collectionId, 'trigram_invalid', Database::INDEX_TRIGRAM, ['age']); + $this->fail('Expected exception when creating trigram index on non-string attribute'); + } catch (Exception $e) { + $this->assertStringContainsString('Trigram index can only be created on string type attributes', $e->getMessage()); + } + + // Test: Trigram index with multiple string attributes should succeed + $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_multi', Database::INDEX_TRIGRAM, ['name', 'description'])); + + $collection = $database->getCollection($collectionId); + $indexes = $collection->getAttribute('indexes'); + $trigramMultiIndex = null; + foreach ($indexes as $idx) { + if ($idx['$id'] === 'trigram_multi') { + $trigramMultiIndex = $idx; + break; + } + } + $this->assertNotNull($trigramMultiIndex); + $this->assertEquals(Database::INDEX_TRIGRAM, $trigramMultiIndex['type']); + $this->assertEquals(['name', 'description'], $trigramMultiIndex['attributes']); + + // Test: Trigram index with mixed string and non-string attributes should fail + try { + $database->createIndex($collectionId, 'trigram_mixed', Database::INDEX_TRIGRAM, ['name', 'age']); + $this->fail('Expected exception when creating trigram index with mixed attribute types'); + } catch (Exception $e) { + $this->assertStringContainsString('Trigram index can only be created on string type attributes', $e->getMessage()); + } + + // Test: Trigram index with orders should fail + try { + $database->createIndex($collectionId, 'trigram_order', Database::INDEX_TRIGRAM, ['name'], [], [Database::ORDER_ASC]); + $this->fail('Expected exception when creating trigram index with orders'); + } catch (Exception $e) { + $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $e->getMessage()); + } + + // Test: Trigram index with lengths should fail + try { + $database->createIndex($collectionId, 'trigram_length', Database::INDEX_TRIGRAM, ['name'], [128]); + $this->fail('Expected exception when creating trigram index with lengths'); + } catch (Exception $e) { + $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $e->getMessage()); + } + + } finally { + // Clean up + $database->deleteCollection($collectionId); + } + } } diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 2e9dc78f7..a37cfb451 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -15,6 +15,30 @@ trait ObjectAttributeTests { + /** + * Helper function to create an attribute if adapter supports attributes, + * otherwise returns true to allow tests to continue + * + * @param Database $database + * @param string $collectionId + * @param string $attributeId + * @param string $type + * @param int $size + * @param bool $required + * @param mixed $default + * @return bool + */ + private function createAttribute(Database $database, string $collectionId, string $attributeId, string $type, int $size, bool $required, $default = null): bool + { + if (!$database->getAdapter()->getSupportForAttributes()) { + return true; + } + + $result = $database->createAttribute($collectionId, $attributeId, $type, $size, $required, $default); + $this->assertEquals(true, $result); + return $result; + } + public function testObjectAttribute(): void { /** @var Database $database */ @@ -29,12 +53,12 @@ public function testObjectAttribute(): void $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::VAR_OBJECT, 0, false)); + $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); // Test 1: Create and read document with object attribute $doc1 = $database->createDocument($collectionId, new Document([ '$id' => 'doc1', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'meta' => [ 'age' => 25, 'skills' => ['react', 'node'], @@ -81,7 +105,7 @@ public function testObjectAttribute(): void // Test 5: Create another document with different values $doc2 = $database->createDocument($collectionId, new Document([ '$id' => 'doc2', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'meta' => [ 'age' => 30, 'skills' => ['python', 'java'], @@ -163,7 +187,7 @@ public function testObjectAttribute(): void try { // test -> not equal allows one value only $results = $database->find($collectionId, [ - Query::notEqual('meta', [['age' => 26],['age' => 27]]) + Query::notEqual('meta', [['age' => 26], ['age' => 27]]) ]); $this->fail('No query thrown'); } catch (Exception $e) { @@ -441,7 +465,7 @@ public function testObjectAttribute(): void // Test 28: Test equal query with complete object match $doc11 = $database->createDocument($collectionId, new Document([ '$id' => 'doc11', - '$permissions' => [Permission::read(Role::any()),Permission::update(Role::any())], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'meta' => [ 'config' => [ 'theme' => 'dark', @@ -556,16 +580,15 @@ public function testObjectAttributeGinIndex(): void /** @var Database $database */ $database = static::getDatabase(); - // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { - $this->markTestSkipped('Adapter does not support object attributes'); + if (!$database->getAdapter()->getSupportForObjectIndexes()) { + $this->markTestSkipped('Adapter does not support object indexes'); } $collectionId = ID::unique(); $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'data', Database::VAR_OBJECT, 0, false)); + $this->createAttribute($database, $collectionId, 'data', Database::VAR_OBJECT, 0, false); // Test 1: Create Object index on object attribute $ginIndex = $database->createIndex($collectionId, 'idx_data_gin', Database::INDEX_OBJECT, ['data']); @@ -620,7 +643,7 @@ public function testObjectAttributeGinIndex(): void $this->assertEquals('gin2', $results[0]->getId()); // Test 6: Try to create Object index on non-object attribute (should fail) - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, false); + $this->createAttribute($database, $collectionId, 'name', Database::VAR_STRING, 255, false); $exceptionThrown = false; try { @@ -633,7 +656,7 @@ public function testObjectAttributeGinIndex(): void $this->assertTrue($exceptionThrown, 'Expected Index exception for Object index on non-object attribute'); // Test 7: Try to create Object index on multiple attributes (should fail) - $database->createAttribute($collectionId, 'metadata', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'metadata', Database::VAR_OBJECT, 0, false); $exceptionThrown = false; try { @@ -666,7 +689,7 @@ public function testObjectAttributeInvalidCases(): void $database = static::getDatabase(); // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { + if (!$database->getAdapter()->getSupportForObject() || !$database->getAdapter()->getSupportForAttributes()) { $this->markTestSkipped('Adapter does not support object attributes'); } @@ -674,7 +697,7 @@ public function testObjectAttributeInvalidCases(): void $database->createCollection($collectionId); // Create object attribute - $this->assertEquals(true, $database->createAttribute($collectionId, 'meta', Database::VAR_OBJECT, 0, false)); + $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); // Test 1: Try to create document with string instead of object (should fail) $exceptionThrown = false; @@ -841,11 +864,11 @@ public function testObjectAttributeInvalidCases(): void // Test 16: with multiple json $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; - $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings)); + $this->createAttribute($database, $collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings); $database->createDocument($collectionId, new Document(['$permissions' => [Permission::read(Role::any())]])); - $database->createDocument($collectionId, new Document(['settings' => ['config' => ['theme' => 'dark', 'lang' => 'en']],'$permissions' => [Permission::read(Role::any())]])); + $database->createDocument($collectionId, new Document(['settings' => ['config' => ['theme' => 'dark', 'lang' => 'en']], '$permissions' => [Permission::read(Role::any())]])); $results = $database->find($collectionId, [ - Query::equal('settings', [['config' => ['theme' => 'light']],['config' => ['theme' => 'dark']]]) + Query::equal('settings', [['config' => ['theme' => 'light']], ['config' => ['theme' => 'dark']]]) ]); $this->assertCount(2, $results); @@ -865,7 +888,7 @@ public function testObjectAttributeDefaults(): void $database = static::getDatabase(); // Skip test if adapter doesn't support JSONB - if (!$database->getAdapter()->getSupportForObject()) { + if (!$database->getAdapter()->getSupportForObject() || !$database->getAdapter()->getSupportForAttributes()) { $this->markTestSkipped('Adapter does not support object attributes'); } @@ -873,20 +896,20 @@ public function testObjectAttributeDefaults(): void $database->createCollection($collectionId); // 1) Default empty object - $this->assertEquals(true, $database->createAttribute($collectionId, 'metaDefaultEmpty', Database::VAR_OBJECT, 0, false, [])); + $this->createAttribute($database, $collectionId, 'metaDefaultEmpty', Database::VAR_OBJECT, 0, false, []); // 2) Default nested object $defaultSettings = ['config' => ['theme' => 'light', 'lang' => 'en']]; - $this->assertEquals(true, $database->createAttribute($collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings)); + $this->createAttribute($database, $collectionId, 'settings', Database::VAR_OBJECT, 0, false, $defaultSettings); // 3) Required without default (should fail when missing) - $this->assertEquals(true, $database->createAttribute($collectionId, 'profile', Database::VAR_OBJECT, 0, true, null)); + $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, true, null); // 4) Required with default (should auto-populate) - $this->assertEquals(true, $database->createAttribute($collectionId, 'profile2', Database::VAR_OBJECT, 0, false, ['name' => 'anon'])); + $this->createAttribute($database, $collectionId, 'profile2', Database::VAR_OBJECT, 0, false, ['name' => 'anon']); // 5) Explicit null default - $this->assertEquals(true, $database->createAttribute($collectionId, 'misc', Database::VAR_OBJECT, 0, false, null)); + $this->createAttribute($database, $collectionId, 'misc', Database::VAR_OBJECT, 0, false, null); // Create document missing all above attributes $exceptionThrown = false; @@ -954,8 +977,8 @@ public function testMetadataWithVector(): void $database->createCollection($collectionId); // Attributes: 3D vector and nested metadata object - $database->createAttribute($collectionId, 'embedding', Database::VAR_VECTOR, 3, true); - $database->createAttribute($collectionId, 'metadata', Database::VAR_OBJECT, 0, false); + $this->createAttribute($database, $collectionId, 'embedding', Database::VAR_VECTOR, 3, true); + $this->createAttribute($database, $collectionId, 'metadata', Database::VAR_OBJECT, 0, false); // Seed documents $docA = $database->createDocument($collectionId, new Document([ diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 87c35af0e..856d08263 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -10,6 +10,7 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Structure as StructureException; +use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; @@ -751,6 +752,62 @@ public function testSchemalessIndexDuplicatePrevention(): void $database->deleteCollection($col); } + public function testSchemalessObjectIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Only run for schemaless adapters that support object attributes + if ($database->getAdapter()->getSupportForAttributes() || !$database->getAdapter()->getSupportForObject()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_obj_idx'); + $database->createCollection($col); + + // Define object attributes in metadata + $database->createAttribute($col, 'meta', Database::VAR_OBJECT, 0, false); + $database->createAttribute($col, 'meta2', Database::VAR_OBJECT, 0, false); + + // Create regular key index on first object attribute + $this->assertTrue( + $database->createIndex( + $col, + 'idx_meta_key', + Database::INDEX_KEY, + ['meta'], + [0], + [Database::ORDER_ASC] + ) + ); + + // Create unique index on second object attribute + $this->assertTrue( + $database->createIndex( + $col, + 'idx_meta_unique', + Database::INDEX_UNIQUE, + ['meta2'], + [0], + [Database::ORDER_ASC] + ) + ); + + // Verify index metadata is stored on the collection + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + $ids = array_map(fn ($i) => $i['$id'], $indexes); + $this->assertContains('idx_meta_key', $ids); + $this->assertContains('idx_meta_unique', $ids); + + // Clean up indexes and collection + $this->assertTrue($database->deleteIndex($col, 'idx_meta_key')); + $this->assertTrue($database->deleteIndex($col, 'idx_meta_unique')); + $database->deleteCollection($col); + } + public function testSchemalessPermissions(): void { /** @var Database $database */ @@ -1378,4 +1435,434 @@ public function testSchemalessNotExists(): void $database->deleteCollection($colName); } + + /** + * Test elemMatch query functionality + * + * @throws Exception + */ + public function testElemMatch(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create documents with array of objects + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'order1', + '$permissions' => [Permission::read(Role::any())], + 'items' => [ + ['sku' => 'ABC', 'qty' => 5, 'price' => 10.50], + ['sku' => 'XYZ', 'qty' => 2, 'price' => 20.00], + ] + ])); + + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'order2', + '$permissions' => [Permission::read(Role::any())], + 'items' => [ + ['sku' => 'ABC', 'qty' => 1, 'price' => 10.50], + ['sku' => 'DEF', 'qty' => 10, 'price' => 15.00], + ] + ])); + + $doc3 = $database->createDocument($collectionId, new Document([ + '$id' => 'order3', + '$permissions' => [Permission::read(Role::any())], + 'items' => [ + ['sku' => 'XYZ', 'qty' => 3, 'price' => 20.00], + ] + ])); + + // Test 1: elemMatch with equal and greaterThan - should match doc1 + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + Query::greaterThan('qty', 1), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('order1', $results[0]->getId()); + + // Test 2: elemMatch with equal and greaterThan - should not match doc2 (qty is 1, not > 1) + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + Query::greaterThan('qty', 1), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('order1', $results[0]->getId()); + + // Test 3: elemMatch with equal only - should match doc1 and doc2 + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + ]) + ]); + $this->assertCount(2, $results); + $ids = array_map(fn ($doc) => $doc->getId(), $results); + $this->assertContains('order1', $ids); + $this->assertContains('order2', $ids); + + // Elematch means at least ONE element in the array matches this condition. + // that means array having two docs qty -> 1 and qty -> 10 , it will be returned as it has atleast one doc with qty > 10 + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::greaterThan('qty', 1), + ]) + ]); + $this->assertCount(3, $results); + $ids = array_map(fn ($doc) => $doc->getId(), $results); + $this->assertContains('order1', $ids); + $this->assertContains('order2', $ids); + $this->assertContains('order3', $ids); + + // Test 5: elemMatch with multiple conditions - should match doc2 + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['DEF']), + Query::greaterThan('qty', 5), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('order2', $results[0]->getId()); + + // Test 6: elemMatch with lessThan + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + Query::lessThan('qty', 3), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('order2', $results[0]->getId()); + + // Test 7: elemMatch with equal and greaterThanEqual + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['ABC']), + Query::greaterThanEqual('qty', 1), + ]) + ]); + $this->assertCount(2, $results); + + // Test 8: elemMatch with no matching conditions + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['NONEXISTENT']), + ]) + ]); + $this->assertCount(0, $results); + + // Test 9: elemMatch with price condition + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::equal('sku', ['XYZ']), + Query::equal('price', [20.00]), + ]) + ]); + $this->assertCount(2, $results); + $ids = array_map(fn ($doc) => $doc->getId(), $results); + $this->assertContains('order1', $ids); + $this->assertContains('order3', $ids); + + // Test 10: elemMatch with notEqual + $results = $database->find($collectionId, [ + Query::elemMatch('items', [ + Query::notEqual('sku', ['ABC']), + Query::greaterThan('qty', 2), + ]) + ]); + // order 1 has elements where sku == "ABC", qty: 5 => !=ABC fails and sku = XYZ ,qty: 2 => >2 fails + $this->assertCount(2, $results); + $ids = array_map(fn ($doc) => $doc->getId(), $results); + $this->assertContains('order2', $ids); + $this->assertContains('order3', $ids); + + // Clean up + $database->deleteCollection($collectionId); + } + + /** + * Test elemMatch with complex nested conditions + * + * @throws Exception + */ + public function testElemMatchComplex(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + $collectionId = ID::unique(); + $database->createCollection($collectionId); + + // Create documents with complex nested structures + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'store1', + '$permissions' => [Permission::read(Role::any())], + 'products' => [ + ['name' => 'Widget', 'stock' => 100, 'category' => 'A', 'active' => true], + ['name' => 'Gadget', 'stock' => 50, 'category' => 'B', 'active' => false], + ] + ])); + + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'store2', + '$permissions' => [Permission::read(Role::any())], + 'products' => [ + ['name' => 'Widget', 'stock' => 200, 'category' => 'A', 'active' => true], + ['name' => 'Thing', 'stock' => 25, 'category' => 'C', 'active' => true], + ] + ])); + + // Test: elemMatch with multiple conditions including boolean + $results = $database->find($collectionId, [ + Query::elemMatch('products', [ + Query::equal('name', ['Widget']), + Query::greaterThan('stock', 50), + Query::equal('category', ['A']), + Query::equal('active', [true]), + ]) + ]); + $this->assertCount(2, $results); + + // Test: elemMatch with between + $results = $database->find($collectionId, [ + Query::elemMatch('products', [ + Query::equal('category', ['A']), + Query::between('stock', 75, 150), + ]) + ]); + $this->assertCount(1, $results); + $this->assertEquals('store1', $results[0]->getId()); + + // Test: elemMatch with OR grouping on name and stock threshold + $results = $database->find($collectionId, [ + Query::elemMatch('products', [ + Query::or([ + Query::equal('name', ['Widget']), + Query::equal('name', ['Thing']), + ]), + Query::greaterThanEqual('stock', 25), + ]) + ]); + // Both stores have at least one matching product: + // - store1: Widget (stock 100) + // - store2: Widget (stock 200) and Thing (stock 25) + $this->assertCount(2, $results); + + // Test: elemMatch with nested AND/OR conditions + $results = $database->find($collectionId, [ + Query::elemMatch('products', [ + Query::or([ + Query::and([ + Query::equal('name', ['Widget']), + Query::greaterThan('stock', 150), + ]), + Query::and([ + Query::equal('name', ['Thing']), + Query::greaterThan('stock', 20), + ]), + ]), + Query::equal('active', [true]), + ]) + ]); + // Only store2 matches: + // - Widget with stock 200 (>150) and active true + // - Thing with stock 25 (>20) and active true + $this->assertCount(1, $results); + $this->assertEquals('store2', $results[0]->getId()); + + // Clean up + $database->deleteCollection($collectionId); + } + + public function testSchemalessNestedObjectAttributeQueries(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + /** @var Database $database */ + $database = static::getDatabase(); + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_nested_obj'); + $database->createCollection($col); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Documents with nested objects + $database->createDocument($col, new Document([ + '$id' => 'u1', + '$permissions' => $permissions, + 'profile' => [ + 'name' => 'Alice', + 'location' => [ + 'country' => 'US', + 'city' => 'New York', + 'coordinates' => [ + 'lat' => 40.7128, + 'lng' => -74.0060, + ], + ], + ], + 'sessions' => [ + [ + 'device' => [ + 'os' => 'ios', + 'version' => '17', + ], + 'active' => true, + ], + [ + 'device' => [ + 'os' => 'android', + 'version' => '14', + ], + 'active' => false, + ], + ], + ])); + + $database->createDocument($col, new Document([ + '$id' => 'u2', + '$permissions' => $permissions, + 'profile' => [ + 'name' => 'Bob', + 'location' => [ + 'country' => 'UK', + 'city' => 'London', + 'coordinates' => [ + 'lat' => 51.5074, + 'lng' => -0.1278, + ], + ], + ], + 'sessions' => [ + [ + 'device' => [ + 'os' => 'android', + 'version' => '15', + ], + 'active' => true, + ], + ], + ])); + + // Document without full nesting + $database->createDocument($col, new Document([ + '$id' => 'u3', + '$permissions' => $permissions, + 'profile' => [ + 'name' => 'Charlie', + 'location' => [ + 'country' => 'US', + ], + ], + 'sessions' => [], + ])); + + // Query using Mongo-style dotted paths: attribute.key.key + $nycDocs = $database->find($col, [ + Query::equal('profile.location.city', ['New York']), + ]); + $this->assertCount(1, $nycDocs); + $this->assertEquals('u1', $nycDocs[0]->getId()); + + // Query on deeper nested numeric field + $northOf50 = $database->find($col, [ + Query::greaterThan('profile.location.coordinates.lat', 50), + ]); + $this->assertCount(1, $northOf50); + $this->assertEquals('u2', $northOf50[0]->getId()); + + // exists on nested key should match docs where the full path exists + $withCoordinates = $database->find($col, [ + Query::exists(['profile.location.coordinates.lng']), + ]); + $this->assertCount(2, $withCoordinates); + $ids = array_map(fn (Document $doc) => $doc->getId(), $withCoordinates); + $this->assertContains('u1', $ids); + $this->assertContains('u2', $ids); + $this->assertNotContains('u3', $ids); + + // Combination of filters on nested paths + $usWithCoords = $database->find($col, [ + Query::equal('profile.location.country', ['US']), + Query::exists(['profile.location.coordinates.lat']), + ]); + $this->assertCount(1, $usWithCoords); + $this->assertEquals('u1', $usWithCoords[0]->getId()); + + // contains on object attribute using nested structure: parent.key and [key => [key => 'value']] + $matchedByNestedContains = $database->find($col, [ + Query::contains('profile', [[ + 'location' => [ + 'city' => 'London', + ], + ]]), + ]); + $this->assertCount(1, $matchedByNestedContains); + $this->assertEquals('u2', $matchedByNestedContains[0]->getId()); + + // equal on object attribute using nested structure should behave similarly + $matchedByNestedEqual = $database->find($col, [ + Query::equal('profile', [[ + 'location' => [ + 'country' => 'US', + ], + ]]), + ]); + $this->assertCount(2, $matchedByNestedEqual); + $idsEqual = array_map(fn (Document $doc) => $doc->getId(), $matchedByNestedEqual); + $this->assertContains('u1', $idsEqual); + $this->assertContains('u3', $idsEqual); + + // elemMatch on array of nested objects (sessions.device.os, sessions.active) + $iosActive = $database->find($col, [ + Query::elemMatch('sessions', [ + Query::equal('device.os', ['ios']), + Query::equal('active', [true]), + ]), + ]); + $this->assertCount(1, $iosActive); + $this->assertEquals('u1', $iosActive[0]->getId()); + + // elemMatch where nested condition only matches u2 (android & active) + $androidActive = $database->find($col, [ + Query::elemMatch('sessions', [ + Query::equal('device.os', ['android']), + Query::equal('active', [true]), + ]), + ]); + $this->assertCount(1, $androidActive); + $this->assertEquals('u2', $androidActive[0]->getId()); + + // elemMatch with condition that should not match any document + $windowsSessions = $database->find($col, [ + Query::elemMatch('sessions', [ + Query::equal('device.os', ['windows']), + ]), + ]); + $this->assertCount(0, $windowsSessions); + + $database->deleteCollection($col); + } } diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 608a65d2b..5dfe80e4e 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -477,4 +477,123 @@ public function testIndexWithNoAttributeSupport(): void $index = $collection->getAttribute('indexes')[0]; $this->assertTrue($validator->isValid($index)); } + + /** + * @throws Exception + */ + public function testTrigramIndexValidation(): void + { + $collection = new Document([ + '$id' => ID::custom('test'), + 'name' => 'test', + 'attributes' => [ + new Document([ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 255, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('description'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 512, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('age'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + ], + 'indexes' => [] + ]); + + // Validator with supportForTrigramIndexes enabled + $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false, false, false, false, supportForTrigramIndexes: true); + + // Valid: Trigram index on single VAR_STRING attribute + $validIndex = new Document([ + '$id' => ID::custom('idx_trigram_valid'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validIndex)); + + // Valid: Trigram index on multiple string attributes + $validIndexMulti = new Document([ + '$id' => ID::custom('idx_trigram_multi_valid'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name', 'description'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validIndexMulti)); + + // Invalid: Trigram index on non-string attribute + $invalidIndexType = new Document([ + '$id' => ID::custom('idx_trigram_invalid_type'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['age'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexType)); + $this->assertStringContainsString('Trigram index can only be created on string type attributes', $validator->getDescription()); + + // Invalid: Trigram index with mixed string and non-string attributes + $invalidIndexMixed = new Document([ + '$id' => ID::custom('idx_trigram_mixed'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name', 'age'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexMixed)); + $this->assertStringContainsString('Trigram index can only be created on string type attributes', $validator->getDescription()); + + // Invalid: Trigram index with orders + $invalidIndexOrder = new Document([ + '$id' => ID::custom('idx_trigram_order'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name'], + 'lengths' => [], + 'orders' => ['asc'], + ]); + $this->assertFalse($validator->isValid($invalidIndexOrder)); + $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $validator->getDescription()); + + // Invalid: Trigram index with lengths + $invalidIndexLength = new Document([ + '$id' => ID::custom('idx_trigram_length'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name'], + 'lengths' => [128], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexLength)); + $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $validator->getDescription()); + + // Validator with supportForTrigramIndexes disabled should reject trigram + $validatorNoSupport = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false, false, false, false, false); + $this->assertFalse($validatorNoSupport->isValid($validIndex)); + $this->assertEquals('Trigram indexes are not supported', $validatorNoSupport->getDescription()); + } } diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 265e9cbd0..40e8d7671 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -57,7 +57,13 @@ public function testValid(): void 'key' => 'name', 'type' => Database::VAR_STRING, 'array' => false, - ]) + ]), + new Document([ + '$id' => 'meta', + 'key' => 'meta', + 'type' => Database::VAR_OBJECT, + 'array' => false, + ]), ]; $validator = new Queries( @@ -75,5 +81,39 @@ public function testValid(): void $this->assertEquals(true, $validator->isValid([Query::limit(10)]), $validator->getDescription()); $this->assertEquals(true, $validator->isValid([Query::offset(10)]), $validator->getDescription()); $this->assertEquals(true, $validator->isValid([Query::orderAsc('name')]), $validator->getDescription()); + + // Object attribute query: allowed shape + $this->assertTrue( + $validator->isValid([ + Query::equal('meta', [ + ['a' => [1, 2]], + ['b' => [212]], + ]), + ]), + $validator->getDescription() + ); + + // Object attribute query: disallowed nested multiple keys in same level + $this->assertFalse( + $validator->isValid([ + Query::equal('meta', [ + ['a' => [1, 'b' => [212]]], + ]), + ]) + ); + + // Object attribute query: disallowed complex multi-key nested structure + $this->assertTrue( + $validator->isValid([ + Query::contains('meta', [ + [ + 'role' => [ + 'name' => ['test1', 'test2'], + 'ex' => ['new' => 'test1'], + ], + ], + ]), + ]) + ); } } diff --git a/tests/unit/Validator/StructureTest.php b/tests/unit/Validator/StructureTest.php index e4749d342..7176527ae 100644 --- a/tests/unit/Validator/StructureTest.php +++ b/tests/unit/Validator/StructureTest.php @@ -281,7 +281,7 @@ public function testIntegerAsString(): void '$updatedAt' => '2000-04-01T12:00:00.000+00:00' ]))); - $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid integer', $validator->getDescription()); + $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); } public function testValidDocument(): void @@ -459,7 +459,7 @@ public function testIntegerValidation(): void '$updatedAt' => '2000-04-01T12:00:00.000+00:00' ]))); - $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid integer', $validator->getDescription()); + $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); $this->assertEquals(false, $validator->isValid(new Document([ '$collection' => ID::custom('posts'), @@ -474,7 +474,7 @@ public function testIntegerValidation(): void '$updatedAt' => '2000-04-01T12:00:00.000+00:00' ]))); - $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid integer', $validator->getDescription()); + $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); } public function testArrayOfIntegersValidation(): void @@ -540,7 +540,7 @@ public function testArrayOfIntegersValidation(): void '$updatedAt' => '2000-04-01T12:00:00.000+00:00' ]))); - $this->assertEquals('Invalid document structure: Attribute "reviews[\'0\']" has invalid type. Value must be a valid integer', $validator->getDescription()); + $this->assertEquals('Invalid document structure: Attribute "reviews[\'0\']" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); } public function testFloatValidation(): void @@ -662,7 +662,7 @@ public function testIntegerMaxRange(): void '$updatedAt' => '2000-04-01T12:00:00.000+00:00' ]))); - $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid range between -2,147,483,647 and 2,147,483,647', $validator->getDescription()); + $this->assertEquals('Invalid document structure: Attribute "rating" has invalid type. Value must be a valid signed 32-bit integer between -2,147,483,648 and 2,147,483,647', $validator->getDescription()); } public function testDoubleUnsigned(): void