diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd82e6..2677244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## 0.17.0 (unreleased) — utopia-php/database 8 + +### Added + +- Events carry an `ordinal` dimension: the replica ordinal of a multi-node + resource, `0` for the first member. Counters such as dedicated-database + inbound, outbound and compute can now tell members apart the way gauges + already could. `setup()` adds the column to an existing event table, and the + daily materialized view still groups by resource identity, so billing sums + every member. + +### Breaking + +- Requires PHP 8.5 and utopia-php/database 8. The Docker test image builds + on `php:8.5.8-cli-alpine`. + +### Changed + +- The Database adapter creates its collection through the utopia-php/database + 8 schema API (`Collection`, `Attribute` and `Index` models). The collection + is unchanged: the same columns, in the same order, with the same types, + sizes, flags and indexes as 0.16 (on MariaDB the table is identical). + `setup()` still leaves an existing collection alone, so a collection + created by 0.16 is reused as it is. +- `SQL::getAttributeDocuments()` and `SQL::getIndexDocuments()` return + `Utopia\Database\Attribute` and `Utopia\Database\Index` models, which are + still `Document`s. The index models no longer carry the ClickHouse-only + `indexType` hint; the ClickHouse adapter reads it from + `Metric::getEventIndexes()` / `Metric::getGaugeIndexes()` as before. +- The Database adapter compiles `contains()` through `containsString()` + instead of the deprecated `contains()` factory. Matching is unchanged: a + substring match on the column. +- `utopia-php/database` is required as `^8.0`, replacing the temporary + `dev-feat-query-lib as 7.0.0` root alias. The branch resolves through its + own `8.0.x-dev` branch alias, so `minimum-stability` is `dev` with + `prefer-stable: true` until database 8.0.0 is tagged; `stable` is restored + with that tag. +- Requires `utopia-php/client` 0.5 (`^0.5`). The ClickHouse adapter + imports `Utopia\Client\Client`, where client 0.5 moved the class. +- `extra.branch-alias` maps `dev-feat-query-lib` and `dev-main` to + `0.17.x-dev`, so a consumer can require the branch before 0.17.0 is tagged. + ## Unreleased — query 0.6.x builder ### Added diff --git a/Dockerfile b/Dockerfile index c328707..2c69364 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM composer:2.0 as step0 +FROM composer:2.8 AS step0 WORKDIR /src/ @@ -8,7 +8,7 @@ COPY composer.json /src/ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist -FROM php:8.4.22-cli-alpine3.24 as final +FROM php:8.5.8-cli-alpine AS final LABEL maintainer="team@appwrite.io" diff --git a/README.md b/README.md index cd23928..cd6a141 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ $tenant->addBatch([ Events are request-level metrics like bandwidth, executions, API calls. They are summed when aggregated. Event-specific columns (see `Metric::EVENT_COLUMNS`): `path`, `method`, `status`, -`service`, `resource`, `resourceId`, `resourceInternalId`, `teamId`, +`service`, `resource`, `resourceId`, `resourceInternalId`, `ordinal`, `teamId`, `teamInternalId`, `country`, `region`, `hostname`, `osCode`, `osName`, `osVersion`, `clientType`, `clientCode`, `clientName`, `clientVersion`, `clientEngine`, `clientEngineVersion`, `deviceName`, `deviceBrand`, @@ -455,7 +455,7 @@ coroutines. ## System Requirements -Utopia Framework requires PHP 8.4 or later. We recommend using the latest PHP version whenever possible. +Utopia Framework requires PHP 8.5 or later. We recommend using the latest PHP version whenever possible. ## Copyright and license diff --git a/composer.json b/composer.json index 3202c97..f1a0f86 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,13 @@ "test": "./vendor/bin/phpunit --configuration phpunit.xml --testsuite \"Test Suite\"", "bench": "./vendor/bin/phpunit --configuration phpunit.xml --testsuite Benchmark" }, - "minimum-stability": "stable", + "minimum-stability": "dev", + "prefer-stable": true, "require": { - "php": ">=8.4", + "php": ">=8.5", "psr/http-client": "^1.0", "utopia-php/client": "^0.5", - "utopia-php/database": "^7.0.0", + "utopia-php/database": "^8.0", "utopia-php/query": "0.6.*" }, "require-dev": { @@ -48,5 +49,11 @@ "php-http/discovery": false, "tbachert/spi": false } + }, + "extra": { + "branch-alias": { + "dev-feat-query-lib": "0.17.x-dev", + "dev-main": "0.17.x-dev" + } } } diff --git a/composer.lock b/composer.lock index 6735146..f9dbede 100644 --- a/composer.lock +++ b/composer.lock @@ -4,27 +4,28 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "65d5c65440bab4e7f25b789978c71003", + "content-hash": "d42bdfbed3a95f50622e0d02326f9971", "packages": [ { "name": "brick/math", - "version": "0.18.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "phpstan/phpstan": "2.1.22", + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", "phpunit/phpunit": "^11.5" }, "type": "library", @@ -55,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/1.0.0" }, "funding": [ { @@ -63,7 +64,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-09-12T10:28:18+00:00" }, { "name": "composer/semver", @@ -144,23 +145,23 @@ }, { "name": "google/protobuf", - "version": "v5.35.1", + "version": "v5.36.2", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb" + "reference": "020bc8e831611869ac387ddd38fbbadd78d15195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/55bb4a7d6739b5af0927b96213c1371a3afb7cfb", - "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/020bc8e831611869ac387ddd38fbbadd78d15195", + "reference": "020bc8e831611869ac387ddd38fbbadd78d15195", "shasum": "" }, "require": { "php": ">=8.2.0" }, "require-dev": { - "phpunit/phpunit": ">=11.5.0 <12.0.0" + "phpunit/phpunit": ">=11.5.50 <12.0.0" }, "suggest": { "ext-bcmath": "Need to support JSON deserialization" @@ -182,86 +183,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.35.1" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.2" }, - "time": "2026-06-11T21:19:23+00:00" - }, - { - "name": "mongodb/mongodb", - "version": "2.1.2", - "source": { - "type": "git", - "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", - "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2.0", - "ext-mongodb": "^2.1", - "php": "^8.1", - "psr/log": "^1.1.4|^2|^3", - "symfony/polyfill-php85": "^1.32" - }, - "replace": { - "mongodb/builder": "*" - }, - "require-dev": { - "doctrine/coding-standard": "^12.0", - "phpunit/phpunit": "^10.5.35", - "rector/rector": "^2.1.4", - "squizlabs/php_codesniffer": "^3.7", - "vimeo/psalm": "6.5.*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "MongoDB\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Andreas Braun", - "email": "andreas.braun@mongodb.com" - }, - { - "name": "Jeremy Mikola", - "email": "jmikola@gmail.com" - }, - { - "name": "Jérôme Tamarelle", - "email": "jerome.tamarelle@mongodb.com" - } - ], - "description": "MongoDB driver library", - "homepage": "https://jira.mongodb.org/browse/PHPLIB", - "keywords": [ - "database", - "driver", - "mongodb", - "persistence" - ], - "support": { - "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" - }, - "time": "2025-10-06T12:12:40+00:00" + "time": "2026-09-17T15:56:42+00:00" }, { "name": "nyholm/psr7", @@ -765,16 +689,16 @@ }, { "name": "open-telemetry/sem-conv", - "version": "1.38.0", + "version": "1.44.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sem-conv.git", - "reference": "e613bc640a407def4991b8a936a9b27edd9a3240" + "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240", - "reference": "e613bc640a407def4991b8a936a9b27edd9a3240", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/c94bc7ebacef039cda4f4c9270807dcc58b4e3af", + "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af", "shasum": "" }, "require": { @@ -818,7 +742,72 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-01-21T04:14:03+00:00" + "time": "2026-09-09T22:06:17+00:00" + }, + { + "name": "opis/closure", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/opis/closure.git", + "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/closure/zipball/b97e42b95bb72d87507f5e2d137ceb239aea8d6b", + "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Opis\\Closure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "A library that can be used to serialize closures (anonymous functions) and arbitrary data.", + "homepage": "https://opis.io/closure", + "keywords": [ + "anonymous classes", + "anonymous functions", + "closure", + "function", + "serializable", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/opis/closure/issues", + "source": "https://github.com/opis/closure/tree/4.5.0" + }, + "time": "2026-03-05T13:32:42+00:00" }, { "name": "php-http/discovery", @@ -1240,20 +1229,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -1312,9 +1301,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1389,16 +1378,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.15", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "817bb83ef06717f67ab72a3f03e3b63fbe138de1" + "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/817bb83ef06717f67ab72a3f03e3b63fbe138de1", - "reference": "817bb83ef06717f67ab72a3f03e3b63fbe138de1", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3a523f38dc399337ec45b2ab9628e8c37ceb23c7", + "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7", "shasum": "" }, "require": { @@ -1466,7 +1455,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.15" + "source": "https://github.com/symfony/http-client/tree/v7.4.19" }, "funding": [ { @@ -1486,20 +1475,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T07:12:33+00:00" + "time": "2026-09-08T13:28:43+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -1548,7 +1537,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -1568,7 +1557,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-04T08:41:16+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -1815,98 +1804,18 @@ ], "time": "2026-07-01T12:47:55+00:00" }, - { - "name": "symfony/polyfill-php85", - "version": "v1.41.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-07-01T12:47:55+00:00" - }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -1960,7 +1869,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -1980,7 +1889,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "tbachert/spi", @@ -2034,35 +1943,96 @@ }, "time": "2025-06-29T15:42:06+00:00" }, + { + "name": "utopia-php/async", + "version": "0.1.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/async.git", + "reference": "3ee4fc3d505113d0d6050f35f5bf85e706866a22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/async/zipball/3ee4fc3d505113d0d6050f35f5bf85e706866a22", + "reference": "3ee4fc3d505113d0d6050f35f5bf85e706866a22", + "shasum": "" + }, + "require": { + "opis/closure": "4.*", + "php": ">=8.1" + }, + "require-dev": { + "amphp/amp": "3.*", + "amphp/parallel": "2.*", + "amphp/process": "^2.0", + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.5.45", + "react/child-process": "0.*", + "react/event-loop": "1.*", + "swoole/ide-helper": "*" + }, + "suggest": { + "amphp/amp": "Required for Amp promise adapter", + "amphp/parallel": "Required for Amp parallel adapter", + "ext-ev": "Required for ReactPHP event loop (recommended for best performance)", + "ext-parallel": "Required for parallel adapter (requires PHP ZTS build)", + "ext-sockets": "Required for Swoole Process adapter", + "ext-swoole": "Required for Swoole Thread and Process adapters (recommended for best performance)", + "react/child-process": "Required for ReactPHP parallel adapter", + "react/event-loop": "Required for ReactPHP promise and parallel adapters" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Async\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Appwrite Team", + "email": "team@appwrite.io" + } + ], + "description": "High-performance concurrent + parallel library with Promise and Parallel execution support for PHP.", + "support": { + "issues": "https://github.com/utopia-php/async/issues", + "source": "https://github.com/utopia-php/async/tree/0.1.1" + }, + "time": "2026-06-08T05:10:34+00:00" + }, { "name": "utopia-php/cache", - "version": "4.0.0", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "2ab50440114a7699b8a017ee2ecd8bcd9d54ac5e" + "reference": "92e02dab63606234b993b841ebf4c58845dd4620" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/2ab50440114a7699b8a017ee2ecd8bcd9d54ac5e", - "reference": "2ab50440114a7699b8a017ee2ecd8bcd9d54ac5e", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/92e02dab63606234b993b841ebf4c58845dd4620", + "reference": "92e02dab63606234b993b841ebf4c58845dd4620", "shasum": "" }, "require": { "ext-json": "*", - "ext-memcached": "*", - "ext-redis": "*", - "php": ">=8.3", - "utopia-php/circuit-breaker": "0.3.*", - "utopia-php/pools": "2.*", - "utopia-php/telemetry": "*" + "php": ">=8.4", + "utopia-php/circuit-breaker": "^0.3", + "utopia-php/pools": "^2.0", + "utopia-php/telemetry": "^0.4" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "^1.12", - "phpunit/phpunit": "^9.3", - "swoole/ide-helper": "^6.0", - "vimeo/psalm": "4.13.1" + "swoole/ide-helper": "^6.0" + }, + "suggest": { + "ext-memcached": "Required for the Memcached and Hazelcast adapters.", + "ext-redis": "Required for the Redis, RedisCluster and Sharding adapters.", + "ext-swoole": "Required for the multiplexing Redis adapter (>=6.0)." }, "type": "library", "autoload": { @@ -2074,6 +2044,12 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], "description": "A simple cache library to manage application cache storing, loading and purging", "keywords": [ "cache", @@ -2084,32 +2060,29 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/4.0.0" + "source": "https://github.com/utopia-php/cache/tree/4.0.2" }, - "time": "2026-07-31T13:04:45+00:00" + "time": "2026-08-12T07:48:59+00:00" }, { "name": "utopia-php/circuit-breaker", - "version": "0.3.1", + "version": "0.3.2", "source": { "type": "git", "url": "https://github.com/utopia-php/circuit-breaker.git", - "reference": "db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828" + "reference": "5fbc3802471b0d1b4260bd9f5544514e6929b481" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828", - "reference": "db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828", + "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/5fbc3802471b0d1b4260bd9f5544514e6929b481", + "reference": "5fbc3802471b0d1b4260bd9f5544514e6929b481", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "laravel/pint": "^1.29", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0", - "utopia-php/telemetry": "^0.4" + "utopia-php/telemetry": "^0.4.6" }, "suggest": { "ext-opentelemetry": "Required by utopia-php/telemetry when using OpenTelemetry metrics.", @@ -2121,7 +2094,7 @@ "type": "library", "autoload": { "psr-4": { - "Utopia\\CircuitBreaker\\": "src/CircuitBreaker" + "Utopia\\CircuitBreaker\\": "src/CircuitBreaker/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2146,9 +2119,9 @@ ], "support": { "issues": "https://github.com/utopia-php/circuit-breaker/issues", - "source": "https://github.com/utopia-php/circuit-breaker/tree/0.3.1" + "source": "https://github.com/utopia-php/circuit-breaker/tree/0.3.2" }, - "time": "2026-05-29T12:12:23+00:00" + "time": "2026-08-05T18:07:20+00:00" }, { "name": "utopia-php/client", @@ -2212,27 +2185,21 @@ }, { "name": "utopia-php/console", - "version": "0.1.1", + "version": "0.2.9", "source": { "type": "git", "url": "https://github.com/utopia-php/console.git", - "reference": "d298e43960780e6d76e66de1228c75dc81220e3e" + "reference": "7bd794582ad7a1964e1d0d762bdf8f42e6e834ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/console/zipball/d298e43960780e6d76e66de1228c75dc81220e3e", - "reference": "d298e43960780e6d76e66de1228c75dc81220e3e", + "url": "https://api.github.com/repos/utopia-php/console/zipball/7bd794582ad7a1964e1d0d762bdf8f42e6e834ad", + "reference": "7bd794582ad7a1964e1d0d762bdf8f42e6e834ad", "shasum": "" }, "require": { - "php": ">=8.0" - }, - "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.6", - "swoole/ide-helper": "4.8.8" + "php": ">=8.5", + "utopia-php/validators": "^1.0" }, "type": "library", "autoload": { @@ -2254,22 +2221,22 @@ ], "support": { "issues": "https://github.com/utopia-php/console/issues", - "source": "https://github.com/utopia-php/console/tree/0.1.1" + "source": "https://github.com/utopia-php/console/tree/0.2.9" }, - "time": "2026-02-10T10:20:29+00:00" + "time": "2026-09-02T15:57:19+00:00" }, { "name": "utopia-php/database", - "version": "7.0.0", + "version": "dev-feat-query-lib", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "40185b100f92c402a189d6f4f0962c63bbe5726b" + "reference": "8b716e3c1c21cf0a216b074ad670c30e21556cba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/40185b100f92c402a189d6f4f0962c63bbe5726b", - "reference": "40185b100f92c402a189d6f4f0962c63bbe5726b", + "url": "https://api.github.com/repos/utopia-php/database/zipball/8b716e3c1c21cf0a216b074ad670c30e21556cba", + "reference": "8b716e3c1c21cf0a216b074ad670c30e21556cba", "shasum": "" }, "require": { @@ -2278,23 +2245,38 @@ "ext-pdo": "*", "ext-redis": "*", "php": ">=8.5", - "utopia-php/cache": "^4.0.0", - "utopia-php/console": "0.1.*", + "utopia-php/async": "0.1.*", + "utopia-php/cache": "4.* || 5.*", + "utopia-php/console": "0.1.* || 0.2.*", "utopia-php/mongo": "1.*", "utopia-php/pools": "2.*", - "utopia-php/validators": "0.3.*" + "utopia-php/query": "0.6.*", + "utopia-php/validators": "1.*" }, "require-dev": { + "brianium/paratest": "7.20.*", "fakerphp/faker": "1.23.*", "laravel/pint": "*", - "pcov/clobber": "2.*", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "9.*", + "phpstan/phpstan": "2.1.*", + "phpstan/phpstan-phpunit": "2.0.*", + "phpunit/phpunit": "12.5.*", "rregeer/phpunit-coverage-check": "0.3.*", "swoole/ide-helper": "5.1.3", - "utopia-php/cli": "0.22.*" + "utopia-php/cli": "^0.24.4" + }, + "suggest": { + "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", + "ext-redis": "Needed to support Redis Cache Adapter", + "ext-swoole": "Needed to scope lifecycle hook silencing per coroutine and to detect lost connections", + "mongodb/mongodb": "Needed to support MongoDB Database Adapter" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0.x-dev", + "dev-feat-query-lib": "8.0.x-dev" + } + }, "autoload": { "psr-4": { "Utopia\\Database\\": "src/Database" @@ -2314,27 +2296,26 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/7.0.0" + "source": "https://github.com/utopia-php/database/tree/feat-query-lib" }, - "time": "2026-07-31T13:33:10+00:00" + "time": "2026-09-24T05:48:44+00:00" }, { "name": "utopia-php/mongo", - "version": "1.4.0", + "version": "1.5.4", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "78818fd295f2829aaad5b74c9094e8c6f603550d" + "reference": "34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/78818fd295f2829aaad5b74c9094e8c6f603550d", - "reference": "78818fd295f2829aaad5b74c9094e8c6f603550d", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88", + "reference": "34ccfd6e81d76fd6ee9f26313bfad42c95b9ce88", "shasum": "" }, "require": { - "ext-mongodb": "2.1.*", - "mongodb/mongodb": "2.1.*", + "ext-mongodb": "^2.1.0", "php": ">=8.0", "ramsey/uuid": "4.9.*" }, @@ -2375,27 +2356,27 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.4.0" + "source": "https://github.com/utopia-php/mongo/tree/1.5.4" }, - "time": "2026-07-30T05:24:58+00:00" + "time": "2026-09-09T05:42:37+00:00" }, { "name": "utopia-php/pools", - "version": "2.0.1", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/utopia-php/pools.git", - "reference": "48905dac1b8f8050c2e07bdda32a018ca33982fc" + "reference": "a45d867e56de82a2eb6e1eb3ea78d4c2f6059f4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/pools/zipball/48905dac1b8f8050c2e07bdda32a018ca33982fc", - "reference": "48905dac1b8f8050c2e07bdda32a018ca33982fc", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/a45d867e56de82a2eb6e1eb3ea78d4c2f6059f4a", + "reference": "a45d867e56de82a2eb6e1eb3ea78d4c2f6059f4a", "shasum": "" }, "require": { "php": ">=8.4", - "utopia-php/telemetry": "^0.4" + "utopia-php/telemetry": "^0.4.6" }, "require-dev": { "swoole/ide-helper": "6.*" @@ -2431,9 +2412,9 @@ ], "support": { "issues": "https://github.com/utopia-php/pools/issues", - "source": "https://github.com/utopia-php/pools/tree/2.0.1" + "source": "https://github.com/utopia-php/pools/tree/2.1.2" }, - "time": "2026-07-31T12:19:18+00:00" + "time": "2026-09-07T07:02:57+00:00" }, { "name": "utopia-php/psr7", @@ -2483,16 +2464,16 @@ }, { "name": "utopia-php/query", - "version": "0.6.0", + "version": "0.6.1", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "abaebb2f3426bdbc6f44bab04254a668de937148" + "reference": "67f89d99593900fe9e60ba22575e65ce8c684093" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/abaebb2f3426bdbc6f44bab04254a668de937148", - "reference": "abaebb2f3426bdbc6f44bab04254a668de937148", + "url": "https://api.github.com/repos/utopia-php/query/zipball/67f89d99593900fe9e60ba22575e65ce8c684093", + "reference": "67f89d99593900fe9e60ba22575e65ce8c684093", "shasum": "" }, "require": { @@ -2526,9 +2507,9 @@ ], "support": { "issues": "https://github.com/utopia-php/query/issues", - "source": "https://github.com/utopia-php/query/tree/0.6.0" + "source": "https://github.com/utopia-php/query/tree/0.6.1" }, - "time": "2026-08-21T11:03:36+00:00" + "time": "2026-09-21T05:06:19+00:00" }, { "name": "utopia-php/span", @@ -2572,20 +2553,19 @@ }, { "name": "utopia-php/telemetry", - "version": "0.4.5", + "version": "0.4.11", "source": { "type": "git", "url": "https://github.com/utopia-php/telemetry.git", - "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a" + "reference": "852e76c89d0c20fc6560ac9687b15457a97b3704" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/139943bffcd4f6dd8fb9ed247f946a1d151b006a", - "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/852e76c89d0c20fc6560ac9687b15457a97b3704", + "reference": "852e76c89d0c20fc6560ac9687b15457a97b3704", "shasum": "" }, "require": { - "ext-protobuf": "*", "nyholm/psr7": "1.*", "open-telemetry/exporter-otlp": "1.*", "open-telemetry/sdk": "1.*", @@ -2596,6 +2576,7 @@ "swoole/ide-helper": "6.*" }, "suggest": { + "ext-protobuf": "For better performance, when dealing with the protobuf format", "ext-sockets": "Required for the Swoole transport implementation", "ext-swoole": "Required for the Swoole transport implementation" }, @@ -2617,22 +2598,22 @@ ], "support": { "issues": "https://github.com/utopia-php/telemetry/issues", - "source": "https://github.com/utopia-php/telemetry/tree/0.4.5" + "source": "https://github.com/utopia-php/telemetry/tree/0.4.11" }, - "time": "2026-07-08T11:07:25+00:00" + "time": "2026-09-16T10:12:28+00:00" }, { "name": "utopia-php/validators", - "version": "0.3.1", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba" + "reference": "f0a08bd888d035b68c463d22b47ef385984da73d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba", - "reference": "d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/f0a08bd888d035b68c463d22b47ef385984da73d", + "reference": "f0a08bd888d035b68c463d22b47ef385984da73d", "shasum": "" }, "require": { @@ -2657,9 +2638,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.3.1" + "source": "https://github.com/utopia-php/validators/tree/1.0.1" }, - "time": "2026-07-14T11:55:48+00:00" + "time": "2026-09-02T15:57:19+00:00" } ], "packages-dev": [ @@ -4592,12 +4573,12 @@ } ], "aliases": [], - "minimum-stability": "stable", + "minimum-stability": "dev", "stability-flags": {}, - "prefer-stable": false, + "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=8.4" + "php": ">=8.5" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index d295d5b..9a3b4ce 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -1936,7 +1936,7 @@ private function getColumnType(string $id, string $type = 'event'): string 'timeZone', 'weatherCode', // sdk identity 'sdk', 'sdkVersion', - // gauge replica ordinal + // replica ordinal (events and gauges) 'ordinal', ]; diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index b5c21de..841ec30 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -2,15 +2,18 @@ namespace Utopia\Usage\Adapter; +use Exception; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\Database as UtopiaDatabase; use Utopia\Database\Document; -use Exception; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Index; use Utopia\Database\Query as DatabaseQuery; -use Utopia\Usage\Metric; -use Utopia\Usage\Usage; use Utopia\Query\Method; use Utopia\Query\Query; +use Utopia\Usage\Metric; +use Utopia\Usage\Usage; class Database extends SQL { @@ -76,51 +79,22 @@ public function setup(): void // Event schema is a superset of the gauge schema for the dimensions // that exist in both (resourceId, resourceInternalId, teamId, - // teamInternalId), so a single Database collection backed by the - // event schema works for both types. Gauge-only columns (ordinal) - // are appended below. - $attributes = $this->getAttributeDocuments('event'); - $indexDocs = $this->getIndexDocuments('event'); + // teamInternalId, ordinal), so a single Database collection backed + // by the event schema works for both types. + $attributes = $this->getAttributeDocuments(Usage::TYPE_EVENT); + $indexes = $this->getIndexDocuments(Usage::TYPE_EVENT); // Append a `type` column so a single collection can disambiguate event vs gauge rows. // ClickHouse uses separate tables instead, so this lives in the Database adapter only. - $attributes[] = new Document([ - '$id' => 'type', - 'type' => 'string', - 'size' => 16, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - $indexDocs[] = new Document([ - '$id' => 'index-type', - 'type' => 'key', - 'attributes' => ['type'], - ]); - - // Gauge-only replica ordinal dimension. - $attributes[] = new Document([ - '$id' => 'ordinal', - 'type' => 'string', - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - $indexDocs[] = new Document([ - '$id' => 'index-ordinal', - 'type' => 'key', - 'attributes' => ['ordinal'], - ]); + $attributes[] = Attribute::string(key: 'type', size: 16); + $indexes[] = Index::key(key: 'index-type', attributes: ['type']); try { - $this->db->createCollection( - $this->collection, - $attributes, - $indexDocs - ); + $this->db->createCollection(new Collection( + id: $this->collection, + attributes: $attributes, + indexes: $indexes, + )); } catch (DuplicateException) { // Collection already exists } @@ -444,11 +418,9 @@ private function convertQueriesToDatabase(array $queries): array } break; case Method::Contains: - /** @var array|bool|float|int|string> $values */ - $dbQueries[] = DatabaseQuery::contains($attribute, $values); + $dbQueries[] = DatabaseQuery::containsString($attribute, $values); break; case Method::ContainsAny: - /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::containsAny($attribute, $values); break; case Method::IsNull: diff --git a/src/Usage/Adapter/SQL.php b/src/Usage/Adapter/SQL.php index f7f2d6b..4c065b6 100644 --- a/src/Usage/Adapter/SQL.php +++ b/src/Usage/Adapter/SQL.php @@ -2,9 +2,10 @@ namespace Utopia\Usage\Adapter; +use Utopia\Database\Attribute; +use Utopia\Database\Index; use Utopia\Usage\Adapter; use Utopia\Usage\Metric; -use Utopia\Database\Document; /** * Base SQL Adapter for Usage @@ -58,14 +59,14 @@ public function getAttributes(string $type = 'event'): array } /** - * Get attribute documents for a specific type. + * Get attribute models for a specific type. * * @param string $type 'event' or 'gauge' - * @return array + * @return array */ public function getAttributeDocuments(string $type = 'event'): array { - return array_map(static fn (array $attribute) => new Document($attribute), $this->getAttributes($type)); + return array_map(static fn (array $attribute): Attribute => Attribute::fromArray($attribute), $this->getAttributes($type)); } /** @@ -100,14 +101,14 @@ public function getIndexes(string $type = 'event'): array } /** - * Get index documents for a specific type. + * Get index models for a specific type. * * @param string $type 'event' or 'gauge' - * @return array + * @return array */ public function getIndexDocuments(string $type = 'event'): array { - return array_map(static fn (array $index) => new Document($index), $this->getIndexes($type)); + return array_map(static fn (array $index): Index => Index::fromArray($index), $this->getIndexes($type)); } /** diff --git a/src/Usage/Metric.php b/src/Usage/Metric.php index d993fe8..d6992b2 100644 --- a/src/Usage/Metric.php +++ b/src/Usage/Metric.php @@ -52,6 +52,7 @@ class Metric extends ArrayObject public const EVENT_COLUMNS = [ 'path', 'method', 'status', 'service', 'resourceType', 'resourceId', 'resourceInternalId', + 'ordinal', 'teamId', 'teamInternalId', 'country', 'region', 'hostname', 'ip', // request attributes (firewall rule matching) @@ -92,6 +93,7 @@ class Metric extends ArrayObject * - path / method / status: HTTP shape * - service: API service segment (storage, databases, …) * - resourceType / resourceId / resourceInternalId: resource identity + * - ordinal: replica ordinal for multi-node resources (0 is the first member) * - teamId / teamInternalId: owning team identity * - country / region / hostname / ip: geographic + caller origin * - protocol / accept / acceptLanguage / queryKeys: request attributes (firewall rule matching) @@ -108,7 +110,7 @@ class Metric extends ArrayObject * * Gauge-only dimension columns (see GAUGE_COLUMNS): * - teamId / teamInternalId / resourceId / resourceInternalId - * - ordinal: replica ordinal for multi-node resources (0 is the primary) + * - ordinal: replica ordinal for multi-node resources (0 is the first member) * * @param array $input Metric data */ @@ -290,7 +292,7 @@ public function getResourceInternalId(): ?string } /** - * Get replica ordinal (gauge metrics). 0 is the primary; 1+ are replicas. + * Get replica ordinal (event and gauge metrics). 0 is the first member; 1+ are further members. */ public function getOrdinal(): ?string { @@ -651,6 +653,7 @@ public static function getEventSchema(): array $stringColumn('resourceType', 256), $stringColumn('resourceId', 255), $stringColumn('resourceInternalId', 255), + $stringColumn('ordinal', 255), $stringColumn('teamId', 255), $stringColumn('teamInternalId', 255), $stringColumn('country', 2), @@ -779,6 +782,7 @@ public static function getEventIndexes(): array $indexed = [ 'path', 'method', 'status', 'service', 'resourceType', 'resourceId', 'resourceInternalId', + 'ordinal', 'teamId', 'teamInternalId', 'country', 'region', 'hostname', 'ip', 'osName', 'clientType', 'clientName', 'deviceName', @@ -792,7 +796,7 @@ public static function getEventIndexes(): array ]; $setIndexed = [ - 'status', 'method', 'country', 'service', 'clientType', 'osName', + 'status', 'method', 'country', 'service', 'clientType', 'osName', 'ordinal', // low-cardinality request/geo dims filtered by equality. accept/ // acceptLanguage/queryKeys are unbounded caller input, so they use // bloom_filter (below) instead of an unlimited set(0) index. diff --git a/tests/Usage/Adapter/ClickHouseColumnTypeTest.php b/tests/Usage/Adapter/ClickHouseColumnTypeTest.php deleted file mode 100644 index b39377f..0000000 --- a/tests/Usage/Adapter/ClickHouseColumnTypeTest.php +++ /dev/null @@ -1,135 +0,0 @@ -adapter = new ClickHouseAdapter( - 'clickhouse', - 'default', - '', - 8123, - false, - namespace: 'utopia_usage_coltype', - ); - - $this->getColumnType = new ReflectionMethod(ClickHouseAdapter::class, 'getColumnType'); - $this->getColumnType->setAccessible(true); - } - - private function columnType(string $id, string $type = 'event'): string - { - /** @var string $columnType */ - $columnType = $this->getColumnType->invoke($this->adapter, $id, $type); - return $columnType; - } - - /** - * Lower-cardinality premium geo dims must map to LowCardinality(Nullable(String)). - */ - public function testLowCardinalityPremiumGeoColumns(): void - { - foreach ([ - 'continentCode', 'subdivisions', 'connectionType', - 'connectionUsageType', 'autonomousSystemNumber', - ] as $col) { - $this->assertSame( - 'LowCardinality(Nullable(String))', - $this->columnType($col), - "{$col} should be LowCardinality(Nullable(String))" - ); - } - } - - /** - * High-cardinality premium geo dims must fall through to Nullable(String). - */ - public function testHighCardinalityPremiumGeoColumns(): void - { - foreach ([ - 'city', 'isp', 'autonomousSystemOrganization', 'connectionOrganization', - ] as $col) { - $this->assertSame( - 'Nullable(String)', - $this->columnType($col), - "{$col} should be plain Nullable(String)" - ); - } - } - - /** - * Low-cardinality request-attribute and premium-geo dims added in #36/#37 - * must map to LowCardinality(Nullable(String)). - */ - public function testLowCardinalityRequestAndGeoColumns(): void - { - foreach (['protocol', 'ipReputation', 'timeZone', 'weatherCode'] as $col) { - $this->assertSame( - 'LowCardinality(Nullable(String))', - $this->columnType($col), - "{$col} should be LowCardinality(Nullable(String))" - ); - } - } - - /** - * High-cardinality dims — un-normalized request text (accept/acceptLanguage/ - * queryKeys) and premium-geo — must fall through to plain Nullable(String). - */ - public function testHighCardinalityRequestAndGeoColumns(): void - { - foreach ([ - 'accept', 'acceptLanguage', 'queryKeys', - 'postalCode', 'latitude', 'longitude', - ] as $col) { - $this->assertSame( - 'Nullable(String)', - $this->columnType($col), - "{$col} should be plain Nullable(String)" - ); - } - } - - /** - * SDK dims must map to LowCardinality(Nullable(String)). - */ - public function testLowCardinalitySdkColumns(): void - { - foreach (['sdk', 'sdkVersion'] as $col) { - $this->assertSame( - 'LowCardinality(Nullable(String))', - $this->columnType($col), - "{$col} should be LowCardinality(Nullable(String))" - ); - } - } - - /** - * The gauge replica ordinal holds a handful of distinct values, so it - * must map to LowCardinality(Nullable(String)). - */ - public function testLowCardinalityOrdinalColumn(): void - { - $this->assertSame( - 'LowCardinality(Nullable(String))', - $this->columnType('ordinal', 'gauge'), - 'ordinal should be LowCardinality(Nullable(String))' - ); - } -} diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index 1a0a161..84e62eb 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -7,6 +7,7 @@ use Utopia\Query\Query; use Utopia\Tests\Usage\UsageBase; use Utopia\Usage\Adapter\ClickHouse as ClickHouseAdapter; +use Utopia\Usage\Metric; use Utopia\Usage\Usage; use Utopia\Usage\UsageQuery; @@ -383,6 +384,146 @@ public function testGaugeOrdinalSeparatesReplicaSeries(): void $this->assertEquals(['0' => 10, '1' => 20], $byOrdinal); } + /** + * Per-replica event rows for one resource stay distinct series via the + * ordinal dimension. Filtering isolates one node; grouping returns a + * sum per node. The daily MV is intentionally unsplit — billing still + * sums every member. + */ + public function testEventOrdinalSeparatesReplicaSeries(): void + { + $this->usage->purge('1', [], Usage::TYPE_EVENT); + + $this->assertTrue($this->usage->addBatch([ + [ + 'tenant' => '1', + 'metric' => 'event-ordinal-test', + 'value' => 512, + 'tags' => ['resourceType' => 'dedicatedDatabases', 'resourceId' => 'db1', 'ordinal' => '0'], + ], + [ + 'tenant' => '1', + 'metric' => 'event-ordinal-test', + 'value' => 128, + 'tags' => ['resourceType' => 'dedicatedDatabases', 'resourceId' => 'db1', 'ordinal' => '1'], + ], + ], Usage::TYPE_EVENT)); + + $primary = $this->usage->find('1', [ + Query::equal('metric', ['event-ordinal-test']), + Query::equal('ordinal', ['0']), + ], Usage::TYPE_EVENT); + + $this->assertCount(1, $primary); + $this->assertEquals(512, $primary[0]->getValue()); + $this->assertEquals('0', $primary[0]->getOrdinal()); + + $perNode = $this->usage->find('1', [ + Query::equal('metric', ['event-ordinal-test']), + UsageQuery::groupBy('ordinal'), + ], Usage::TYPE_EVENT); + + $this->assertCount(2, $perNode); + $byOrdinal = []; + foreach ($perNode as $row) { + $ordinal = $row->getOrdinal(); + $this->assertNotNull($ordinal); + $byOrdinal[$ordinal] = (int) $row->getValue(); + } + $this->assertEquals(['0' => 512, '1' => 128], $byOrdinal); + + $this->assertSame(640, $this->usage->sum('1', [ + Query::equal('metric', ['event-ordinal-test']), + ])); + $this->assertSame(640, $this->usage->sumDaily('1', [ + Query::equal('metric', ['event-ordinal-test']), + ])); + } + + /** + * @return array + */ + public static function metricTypes(): array + { + return [ + 'events' => [Usage::TYPE_EVENT], + 'gauges' => [Usage::TYPE_GAUGE], + ]; + } + + /** + * @dataProvider metricTypes + */ + public function testOrdinalReadsBackExactly(string $type): void + { + $this->addReplicaRows($type); + + $ordinals = []; + foreach ($this->usage->find('1', [Query::equal('metric', ['replica-ordinals'])], $type) as $row) { + $ordinals[(int) $row->getValue()] = $row->getOrdinal(); + } + ksort($ordinals); + + $this->assertSame([1 => '0', 2 => '1', 3 => '10', 4 => null], $ordinals); + } + + /** + * @dataProvider metricTypes + */ + public function testOrdinalFiltersMatchExactly(string $type): void + { + $this->addReplicaRows($type); + + $member = $this->usage->find('1', [ + Query::equal('metric', ['replica-ordinals']), + Query::equal('ordinal', ['10']), + ], $type); + + $this->assertCount(1, $member); + $this->assertSame(3, $member[0]->getValue()); + + $unassigned = $this->usage->find('1', [ + Query::equal('metric', ['replica-ordinals']), + Query::isNull('ordinal'), + ], $type); + + $this->assertCount(1, $unassigned); + $this->assertSame(4, $unassigned[0]->getValue()); + } + + /** + * @dataProvider metricTypes + */ + public function testGroupByOrdinalListsEachMemberInOrder(string $type): void + { + $this->addReplicaRows($type); + + $members = $this->usage->find('1', [ + Query::equal('metric', ['replica-ordinals']), + UsageQuery::groupBy('ordinal'), + Query::orderAsc('ordinal'), + ], $type); + + $this->assertSame( + [['0', 1], ['1', 2], ['10', 3], [null, 4]], + array_map(fn (Metric $member): array => [$member->getOrdinal(), $member->getValue()], $members) + ); + } + + private function addReplicaRows(string $type): void + { + $this->usage->purge('1', [], $type); + + $resource = ['resourceType' => 'dedicatedDatabases', 'resourceId' => 'db1']; + + $this->assertTrue($this->usage->addBatch([ + ['tenant' => '1', 'metric' => 'replica-ordinals', 'value' => 1, 'tags' => [...$resource, 'ordinal' => '0']], + ['tenant' => '1', 'metric' => 'replica-ordinals', 'value' => 2, 'tags' => [...$resource, 'ordinal' => '1']], + ['tenant' => '1', 'metric' => 'replica-ordinals', 'value' => 3, 'tags' => [...$resource, 'ordinal' => 10]], + ['tenant' => '1', 'metric' => 'replica-ordinals', 'value' => 4, 'tags' => $resource], + ], $type)); + } + public function testUnknownTagKeyThrows(): void { $this->expectException(\Exception::class); @@ -438,43 +579,112 @@ public function testEmptyStringCoercedToNull(): void } /** - * Round-trip a row that exercises every queryable dimension column to - * confirm the events table schema accepts and persists each one. + * @return array}> */ - public function testEventsSchemaPersistsAllNewColumns(): void + public static function eventDimensions(): array { - $this->usage->purge('1', [], Usage::TYPE_EVENT); - - $tags = [ - 'path' => '/v1/x', 'method' => 'GET', 'status' => '200', - 'service' => 'storage', 'resourceType' => 'bucket', - 'resourceId' => 'r1', 'resourceInternalId' => '42', - 'teamId' => 't1', 'teamInternalId' => '7', - 'country' => 'us', 'region' => 'fra', 'hostname' => 'h.example.com', - 'osCode' => 'IOS', 'osName' => 'iOS', 'osVersion' => '17.4', - 'clientType' => 'browser', 'clientCode' => 'CH', - 'clientName' => 'Chrome', 'clientVersion' => '125', - 'clientEngine' => 'Blink', 'clientEngineVersion' => '125', - 'deviceName' => 'desktop', 'deviceBrand' => 'Apple', - 'deviceModel' => 'MacBook', + return [ + 'request' => [[ + 'path' => '/v1/databases/:databaseId/collections/:collectionId/documents/:documentId', + 'method' => 'PATCH', + 'status' => '200', + 'protocol' => 'https', + 'hostname' => 'api.example.com', + 'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'acceptLanguage' => 'fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7', + 'queryKeys' => 'queries,search', + ]], + 'resource' => [[ + 'service' => 'storage', + 'resourceType' => 'bucket', + 'resourceId' => 'photos', + 'resourceInternalId' => '1042', + 'ordinal' => '2', + 'teamId' => 'team-alpha', + 'teamInternalId' => '77', + ]], + 'network' => [[ + 'ip' => '2001:db8::7334', + 'ipReputation' => 'clean', + 'isp' => 'Example Fibre', + 'autonomousSystemNumber' => '64500', + 'autonomousSystemOrganization' => 'EXAMPLE-FIBRE-AS', + 'connectionType' => 'Cable/DSL', + 'connectionUsageType' => 'residential', + 'connectionOrganization' => 'Example Fibre Ltd', + ]], + 'location' => [[ + 'country' => 'ca', + 'region' => 'fra', + 'continentCode' => 'NA', + 'subdivisions' => 'Quebec', + 'city' => 'Montréal', + 'postalCode' => 'H2X 1Y4', + 'latitude' => '45.5019', + 'longitude' => '-73.5674', + 'timeZone' => 'America/Toronto', + 'weatherCode' => 'CAXX0301', + ]], + 'client' => [[ + 'osCode' => 'IOS', + 'osName' => 'iOS', + 'osVersion' => '17.4.1', + 'clientType' => 'browser', + 'clientCode' => 'MF', + 'clientName' => 'Mobile Safari', + 'clientVersion' => '17.4', + 'clientEngine' => 'WebKit', + 'clientEngineVersion' => '605.1.15', + 'deviceName' => 'smartphone', + 'deviceBrand' => 'Apple', + 'deviceModel' => 'iPhone 15 Pro', + 'sdk' => 'Web', + 'sdkVersion' => '16.0.2', + ]], ]; + } + + /** + * @dataProvider eventDimensions + * + * @param array $dimensions + */ + public function testEventDimensionsRoundTrip(array $dimensions): void + { + $this->usage->purge('1', [], Usage::TYPE_EVENT); $this->assertTrue($this->usage->addBatch([ - ['tenant' => '1', 'metric' => 'schema-roundtrip', 'value' => 1, 'tags' => $tags], + ['tenant' => '1', 'metric' => 'dimension-roundtrip', 'value' => 1, 'tags' => $dimensions], + ['tenant' => '1', 'metric' => 'dimension-roundtrip', 'value' => 2, 'tags' => []], ], Usage::TYPE_EVENT)); - // Filtering on each indexed dimension should be schema-valid. - foreach (['service', 'resourceInternalId', 'teamId', 'teamInternalId', 'region', 'hostname', 'osName', 'clientName', 'deviceName'] as $col) { - $value = $tags[$col]; - $expected = $col === 'region' ? strtolower($value) : $value; - $rows = $this->usage->find('1', [ - \Utopia\Query\Query::equal('metric', ['schema-roundtrip']), - \Utopia\Query\Query::equal($col, [$expected]), - ], Usage::TYPE_EVENT); - $this->assertGreaterThanOrEqual(1, count($rows), "Filter on {$col} returned no rows"); + foreach ($dimensions as $dimension => $value) { + $this->assertSame( + [[1, $value]], + $this->readDimension($dimension, Query::equal($dimension, [$value])), + 'Filtering on ' . $dimension . ' should return only the event that carries it, with the value as written' + ); + $this->assertSame( + [[2, null]], + $this->readDimension($dimension, Query::isNull($dimension)), + $dimension . ' should be null only on the event that omits it' + ); } } + /** + * @return array + */ + private function readDimension(string $dimension, Query $filter): array + { + $events = $this->usage->find('1', [ + Query::equal('metric', ['dimension-roundtrip']), + $filter, + ], Usage::TYPE_EVENT); + + return array_map(fn (Metric $event): array => [$event->getValue(), $event->getAttribute($dimension)], $events); + } + /** * Test querying events by event-specific columns */ diff --git a/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php index 4dfcb80..53007e2 100644 --- a/tests/Usage/Adapter/DatabaseTest.php +++ b/tests/Usage/Adapter/DatabaseTest.php @@ -7,17 +7,27 @@ use Utopia\Cache\Adapter\None as NoCache; use Utopia\Cache\Cache; use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Attribute; use Utopia\Database\Database; use Utopia\Database\Exception\Duplicate; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; use Utopia\Query\Query; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; use Utopia\Tests\Usage\UsageBase; use Utopia\Usage\Adapter\Database as AdapterDatabase; +use Utopia\Usage\Adapter\SQL; +use Utopia\Usage\Metric; use Utopia\Usage\Usage; class DatabaseTest extends TestCase { use UsageBase; + private const string SCHEMA_NAMESPACE = 'utopia_usage_schema'; + protected Database $database; protected function initializeUsage(): void @@ -26,16 +36,7 @@ protected function initializeUsage(): void $this->markTestSkipped('pdo_mysql extension is not installed in this environment'); } - $dbHost = getenv('MARIADB_HOST') ?: 'mariadb'; - $dbPort = getenv('MARIADB_PORT') ?: '3306'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPdoAttributes()); - $cache = new Cache(new NoCache()); - $this->database = new Database(new MariaDB($pdo), $cache); - $this->database->setDatabase('utopiaTests'); - $this->database->setNamespace('utopia_usage'); + $this->database = $this->connect('utopiaTests', 'utopia_usage'); $this->usage = new Usage(new AdapterDatabase($this->database)); @@ -231,19 +232,7 @@ public function testHealthCheckWithNonExistentDatabase(): void $this->markTestSkipped('pdo_mysql extension is not installed'); } - // Create a new database instance pointing to a non-existent database - $dbHost = getenv('MARIADB_HOST') ?: 'mariadb'; - $dbPort = getenv('MARIADB_PORT') ?: '3306'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPdoAttributes()); - $cache = new Cache(new NoCache()); - $database = new Database(new MariaDB($pdo), $cache); - $database->setDatabase('nonexistent_database_xyz'); - $database->setNamespace('test'); - - $adapter = new AdapterDatabase($database); + $adapter = new AdapterDatabase($this->connect('nonexistent_database_xyz', 'test')); $health = $adapter->healthCheck(); @@ -296,4 +285,145 @@ public function testNegativeValuePersistsWhenOptedIn(): void 'the opted-in negative must be stored, not silently dropped', ); } + + public function testSetupCreatesTheCollection0165Created(): void + { + $database = $this->connect('utopiaTests', self::SCHEMA_NAMESPACE); + $database->create(); + self::dropCollection($database); + + try { + (new AdapterDatabase($database))->setup(); + + $collection = $database->getCollection(SQL::COLLECTION); + + $this->assertSame( + \array_map(self::describeDeclaredAttribute(...), self::declaredAttributes()), + \array_map(self::describeAttribute(...), $collection->attributes), + 'every column 0.16.5 declared must come back with the same type, size and flags, in the same order', + ); + $this->assertSame( + \array_map(self::describeDeclaredIndex(...), self::declaredIndexes()), + \array_map(self::describeIndex(...), $collection->indexes), + 'every index 0.16.5 declared must come back with the same type, columns and prefix lengths, in the same order', + ); + $this->assertTrue($collection->documentSecurity); + $this->assertSame([Permission::create(Role::any())], $collection->getPermissions()); + } finally { + self::dropCollection($database); + } + } + + private static function dropCollection(Database $database): void + { + if (!$database->getCollection(SQL::COLLECTION)->isEmpty()) { + $database->deleteCollection(SQL::COLLECTION); + } + } + + private function connect(string $database, string $namespace): Database + { + $host = getenv('MARIADB_HOST') ?: 'mariadb'; + $port = getenv('MARIADB_PORT') ?: '3306'; + + $pdo = new PDO("mysql:host={$host};port={$port};charset=utf8mb4", 'root', 'password', MariaDB::getPDOAttributes()); + + $connection = new Database(new MariaDB($pdo), new Cache(new NoCache())); + $connection->setDatabase($database); + $connection->setNamespace($namespace); + + return $connection; + } + + /** + * The declarations in the array form 0.16.5 passed to utopia-php/database 7's createCollection(), which stored them as given. + * + * @return array> + */ + private static function declaredAttributes(): array + { + return [ + ...Metric::getEventSchema(), + ['$id' => 'type', 'type' => ColumnType::String->value, 'size' => 16, 'required' => false, 'signed' => true, 'array' => false, 'filters' => []], + ]; + } + + /** + * @return array> + */ + private static function declaredIndexes(): array + { + return [ + ...Metric::getEventIndexes(), + ['$id' => 'index-type', 'type' => IndexType::Key->value, 'attributes' => ['type']], + ]; + } + + /** + * @param array $declared + * @return array + */ + private static function describeDeclaredAttribute(array $declared): array + { + /** @var array{'$id': string, type: string, size: int, required: bool, signed: bool, array: bool, filters: array, format?: string} $declared */ + return [ + 'key' => $declared['$id'], + 'type' => ColumnType::from($declared['type']), + 'size' => $declared['size'], + 'required' => $declared['required'], + 'default' => null, + 'signed' => $declared['signed'], + 'array' => $declared['array'], + 'format' => ($declared['format'] ?? '') ?: null, + 'filters' => $declared['filters'], + ]; + } + + /** + * @return array + */ + private static function describeAttribute(Attribute $attribute): array + { + return [ + 'key' => $attribute->key, + 'type' => $attribute->type, + 'size' => $attribute->size, + 'required' => $attribute->required, + 'default' => $attribute->default, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'format' => $attribute->format ?: null, + 'filters' => $attribute->filters, + ]; + } + + /** + * @param array $declared + * @return array + */ + private static function describeDeclaredIndex(array $declared): array + { + /** @var array{'$id': string, type: string, attributes: array, lengths?: array} $declared */ + return [ + 'key' => $declared['$id'], + 'type' => IndexType::from($declared['type']), + 'attributes' => $declared['attributes'], + 'lengths' => $declared['lengths'] ?? [], + 'orders' => [], + ]; + } + + /** + * @return array + */ + private static function describeIndex(Index $index): array + { + return [ + 'key' => $index->key, + 'type' => $index->type, + 'attributes' => $index->attributes, + 'lengths' => $index->lengths, + 'orders' => $index->orders, + ]; + } } diff --git a/tests/Usage/MetricTest.php b/tests/Usage/MetricTest.php index 2146319..abb6d27 100644 --- a/tests/Usage/MetricTest.php +++ b/tests/Usage/MetricTest.php @@ -614,6 +614,7 @@ public function testEventColumnsConstant(): void $expected = [ 'path', 'method', 'status', 'service', 'resourceType', 'resourceId', 'resourceInternalId', + 'ordinal', 'teamId', 'teamInternalId', 'country', 'region', 'hostname', 'ip', 'protocol', 'accept', 'acceptLanguage', 'queryKeys', @@ -641,27 +642,35 @@ public function testGaugeColumnsConstant(): void } /** - * The replica ordinal is a gauge-only dimension: present in GAUGE_COLUMNS, - * the gauge schema and gauge indexes, extracted from tags into its column, - * and readable via the typed accessor. Events must not carry it. + * The replica ordinal is a shared dimension: present in both EVENT_COLUMNS + * and GAUGE_COLUMNS, extracted from tags into its column, and readable via + * the typed accessor. Daily event rollups stay unsplit — they still group + * on resource identity only. */ - public function testOrdinalIsGaugeOnly(): void + public function testOrdinalIsASharedDimension(): void { $this->assertContains('ordinal', Metric::GAUGE_COLUMNS); - $this->assertNotContains('ordinal', Metric::EVENT_COLUMNS); + $this->assertContains('ordinal', Metric::EVENT_COLUMNS); $gaugeIds = array_column(Metric::getGaugeSchema(), '$id'); $this->assertContains('ordinal', $gaugeIds); $eventIds = array_column(Metric::getEventSchema(), '$id'); - $this->assertNotContains('ordinal', $eventIds); + $this->assertContains('ordinal', $eventIds); - $indexIds = array_column(Metric::getGaugeIndexes(), '$id'); - $this->assertContains('index-ordinal', $indexIds); + $gaugeIndexIds = array_column(Metric::getGaugeIndexes(), '$id'); + $this->assertContains('index-ordinal', $gaugeIndexIds); - $columns = Metric::extractColumns(['resourceId' => 'db_a', 'ordinal' => 1], 'gauge'); - $this->assertSame('1', $columns['ordinal']); - $this->assertSame('db_a', $columns['resourceId']); + $eventIndexIds = array_column(Metric::getEventIndexes(), '$id'); + $this->assertContains('index-ordinal', $eventIndexIds); + + $gauge = Metric::extractColumns(['resourceId' => 'db_a', 'ordinal' => 1], 'gauge'); + $this->assertSame('1', $gauge['ordinal']); + $this->assertSame('db_a', $gauge['resourceId']); + + $event = Metric::extractColumns(['resourceId' => 'db_a', 'ordinal' => 0], 'event'); + $this->assertSame('0', $event['ordinal']); + $this->assertSame('db_a', $event['resourceId']); $metric = new Metric(['ordinal' => '2']); $this->assertSame('2', $metric->getOrdinal()); @@ -675,7 +684,7 @@ public function testEventSchemaHasAllNewColumns(): void { $ids = array_column(Metric::getEventSchema(), '$id'); foreach ([ - 'service', 'resourceInternalId', 'teamId', 'teamInternalId', + 'service', 'resourceInternalId', 'ordinal', 'teamId', 'teamInternalId', 'region', 'hostname', 'osCode', 'osName', 'osVersion', 'clientType', 'clientCode', 'clientName', 'clientVersion', 'clientEngine', 'clientEngineVersion',