From d9845ce208062d7aa240178cc678397fa9dc7c19 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 24 Sep 2026 05:04:02 +1200 Subject: [PATCH 01/11] chore: require PHP 8.5 and build the test image on php:8.5.8-cli-alpine utopia-php/database 8 requires PHP 8.5, so usage has to run on 8.5 before it can take the 8.0 schema API. The image moves to php:8.5.8-cli-alpine and the vendor stage to composer:2.8, the same tags the database image builds from; the vendor stage already installs with --ignore-platform-reqs and the final stage keeps pdo_mysql. On its own this changes nothing else: the full suite passes on the 8.5 image against the locked utopia-php/database 7.0.0 (378 tests). Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 4 ++-- composer.json | 2 +- composer.lock | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) 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/composer.json b/composer.json index a10c23a..f83698e 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ }, "minimum-stability": "stable", "require": { - "php": ">=8.4", + "php": ">=8.5", "psr/http-client": "^1.0", "utopia-php/client": "^0.4", "utopia-php/database": "^7.0.0", diff --git a/composer.lock b/composer.lock index 067d678..542facd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4f98142bceb2cc79147f42f0e00a0db4", + "content-hash": "b5f1ddd5bd0679aeb258ea0eda072e33", "packages": [ { "name": "brick/math", @@ -4594,7 +4594,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.4" + "php": ">=8.5" }, "platform-dev": {}, "plugin-api-version": "2.9.0" From 63e24788d3abe4146af043f7e17ea6876f1bbd3c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 24 Sep 2026 05:31:47 +1200 Subject: [PATCH 02/11] feat!: port the Database adapter to utopia-php/database 8 appwrite and cloud require utopia-php/usage ^0.16.4, and every 0.16.x requires utopia-php/database ^7.0.0, so database 8.0.0 cannot be installed next to usage until a usage release requires it. Database 8 removed the createCollection(string $id, array $attributes, array $indexes) signature this adapter called, so setup() now builds the collection from Collection, Attribute and Index models. The definitions still come from Metric's schema arrays, converted with Attribute::fromArray() and Index::fromArray(), so the collection is the one 0.16.5 created: the MariaDB table is identical, and the stored metadata matches on every field 0.16.5 stored. The only difference is the ClickHouse-only indexType hint, which database 7 kept verbatim in the index metadata and the 8.0 Index model does not carry; this adapter never read it. testSetupCreatesTheCollection0165Created pins the equivalence field by field. Before the port, every DatabaseTest errors against database 8 because setup() cannot create the collection. contains() compiles through containsString(): utopia-php/query 0.6 deprecates the contains() factory, and every column in this collection is a scalar, so it is the same substring match. The database constraint is a temporary root alias of dev-feat-query-lib; the 8.* constraint and the 0.17.x-dev branch alias follow once database publishes its own branch alias. Co-Authored-By: Claude Opus 5.5 --- composer.json | 2 +- composer.lock | 570 +++++++++++++-------------- src/Usage/Adapter/Database.php | 59 +-- src/Usage/Adapter/SQL.php | 15 +- tests/Usage/Adapter/DatabaseTest.php | 178 +++++++-- 5 files changed, 459 insertions(+), 365 deletions(-) diff --git a/composer.json b/composer.json index f83698e..ea6e57a 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "php": ">=8.5", "psr/http-client": "^1.0", "utopia-php/client": "^0.4", - "utopia-php/database": "^7.0.0", + "utopia-php/database": "dev-feat-query-lib as 7.0.0", "utopia-php/query": "0.6.*" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 542facd..825dea6 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": "b5f1ddd5bd0679aeb258ea0eda072e33", + "content-hash": "fcef17a0981aef29af515967678708eb", "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", @@ -2209,27 +2182,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": { @@ -2251,22 +2218,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": "482e752a2abca179ff8de5ed9d3fa43427865ed4" }, "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/482e752a2abca179ff8de5ed9d3fa43427865ed4", + "reference": "482e752a2abca179ff8de5ed9d3fa43427865ed4", "shasum": "" }, "require": { @@ -2275,21 +2242,30 @@ "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.0 || ^5.0", + "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.0" }, "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", "autoload": { @@ -2311,27 +2287,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-22T08:53:35+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.*" }, @@ -2372,27 +2347,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.*" @@ -2428,9 +2403,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", @@ -2480,16 +2455,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": { @@ -2523,9 +2498,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", @@ -2569,20 +2544,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.*", @@ -2593,6 +2567,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" }, @@ -2614,22 +2589,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": { @@ -2654,9 +2629,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": [ @@ -4588,9 +4563,18 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/database", + "version": "dev-feat-query-lib", + "alias": "7.0.0", + "alias_normalized": "7.0.0.0" + } + ], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index b5c21de..40acea6 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 { @@ -79,48 +82,24 @@ public function setup(): void // 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'); + $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'], - ]); + $attributes[] = Attribute::string(key: 'type', size: 16); + $indexes[] = Index::key(key: 'index-type', 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: 'ordinal', size: 255); + $indexes[] = Index::key(key: 'index-ordinal', attributes: ['ordinal']); 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 +423,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/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php index 4dfcb80..3d97ebe 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,147 @@ 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' => []], + ['$id' => 'ordinal', 'type' => ColumnType::String->value, 'size' => 255, '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']], + ['$id' => 'index-ordinal', 'type' => IndexType::Key->value, 'attributes' => ['ordinal']], + ]; + } + + /** + * @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, + ]; + } } From 70a022085e093f8fa7eb08d1e2dc789c3f3358ab Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 24 Sep 2026 05:31:47 +1200 Subject: [PATCH 03/11] docs: record the 0.17.0 requirements in the changelog 0.17.0 is the first usage release that installs next to utopia-php/database 8: it requires PHP 8.5 and database 8, and the Database adapter builds the same collection through the 8.0 schema API. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd82e6..d903a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.17.0 (unreleased) — utopia-php/database 8 + +### 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. + ## Unreleased — query 0.6.x builder ### Added From f4ffaec32f0281ad42688eb4677d9f95670f49d4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 24 Sep 2026 15:15:11 +1200 Subject: [PATCH 04/11] chore: re-pin utopia-php/database to the feat-query-lib head The lock now points at d82a7ec54, the head the port's test matrix was run against. Co-Authored-By: Claude Opus 5.5 --- composer.lock | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/composer.lock b/composer.lock index 825dea6..4b65cb0 100644 --- a/composer.lock +++ b/composer.lock @@ -2228,12 +2228,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "482e752a2abca179ff8de5ed9d3fa43427865ed4" + "reference": "d82a7ec5454c8e5086d205b715ff4e9499533d80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/482e752a2abca179ff8de5ed9d3fa43427865ed4", - "reference": "482e752a2abca179ff8de5ed9d3fa43427865ed4", + "url": "https://api.github.com/repos/utopia-php/database/zipball/d82a7ec5454c8e5086d205b715ff4e9499533d80", + "reference": "d82a7ec5454c8e5086d205b715ff4e9499533d80", "shasum": "" }, "require": { @@ -2242,13 +2242,13 @@ "ext-pdo": "*", "ext-redis": "*", "php": ">=8.5", - "utopia-php/async": "^0.1", - "utopia-php/cache": "^4.0 || ^5.0", - "utopia-php/console": "^0.1 || ^0.2", + "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/query": "0.6.*", - "utopia-php/validators": "^1.0" + "utopia-php/validators": "1.*" }, "require-dev": { "brianium/paratest": "7.20.*", @@ -2268,6 +2268,12 @@ "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" @@ -2289,7 +2295,7 @@ "issues": "https://github.com/utopia-php/database/issues", "source": "https://github.com/utopia-php/database/tree/feat-query-lib" }, - "time": "2026-09-22T08:53:35+00:00" + "time": "2026-09-24T03:03:02+00:00" }, { "name": "utopia-php/mongo", From 146501c97409f3a7b014d05aaa1e03aaa958d59a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 4 Sep 2026 22:23:08 +1200 Subject: [PATCH 05/11] feat: add ordinal event dimension column Gauges already split multi-node resources on ordinal. Counters such as dedicated-database inbound, outbound and compute need the same column so a chart can tell members apart. setup() adds it to existing event tables. The daily MV stays grouped on resource identity only, so billing still sums every member. Co-authored-by: Cursor (cherry picked from commit 1dc2314ebe2768f27649521d45a64e2862a9e4a5) --- README.md | 2 +- src/Usage/Adapter/ClickHouse.php | 2 +- src/Usage/Adapter/Database.php | 9 +--- src/Usage/Metric.php | 10 ++-- .../Adapter/ClickHouseColumnTypeTest.php | 9 +++- tests/Usage/Adapter/ClickHouseTest.php | 53 +++++++++++++++++++ tests/Usage/Adapter/DatabaseTest.php | 2 - tests/Usage/MetricTest.php | 33 +++++++----- 8 files changed, 92 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index cd23928..e820e1c 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`, diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 6ef6094..735645a 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 40acea6..841ec30 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -79,9 +79,8 @@ 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. + // 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); @@ -90,10 +89,6 @@ public function setup(): void $attributes[] = Attribute::string(key: 'type', size: 16); $indexes[] = Index::key(key: 'index-type', attributes: ['type']); - // Gauge-only replica ordinal dimension. - $attributes[] = Attribute::string(key: 'ordinal', size: 255); - $indexes[] = Index::key(key: 'index-ordinal', attributes: ['ordinal']); - try { $this->db->createCollection(new Collection( id: $this->collection, 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 index b39377f..c880814 100644 --- a/tests/Usage/Adapter/ClickHouseColumnTypeTest.php +++ b/tests/Usage/Adapter/ClickHouseColumnTypeTest.php @@ -121,8 +121,8 @@ public function testLowCardinalitySdkColumns(): void } /** - * The gauge replica ordinal holds a handful of distinct values, so it - * must map to LowCardinality(Nullable(String)). + * The replica ordinal holds a handful of distinct values, so it + * must map to LowCardinality(Nullable(String)) on both tables. */ public function testLowCardinalityOrdinalColumn(): void { @@ -131,5 +131,10 @@ public function testLowCardinalityOrdinalColumn(): void $this->columnType('ordinal', 'gauge'), 'ordinal should be LowCardinality(Nullable(String))' ); + $this->assertSame( + 'LowCardinality(Nullable(String))', + $this->columnType('ordinal', 'event'), + 'event ordinal should be LowCardinality(Nullable(String))' + ); } } diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index 1a0a161..395e7ca 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -383,6 +383,59 @@ 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', [ + \Utopia\Query\Query::equal('metric', ['event-ordinal-test']), + \Utopia\Query\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', [ + \Utopia\Query\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', [ + \Utopia\Query\Query::equal('metric', ['event-ordinal-test']), + ])); + } + public function testUnknownTagKeyThrows(): void { $this->expectException(\Exception::class); diff --git a/tests/Usage/Adapter/DatabaseTest.php b/tests/Usage/Adapter/DatabaseTest.php index 3d97ebe..53007e2 100644 --- a/tests/Usage/Adapter/DatabaseTest.php +++ b/tests/Usage/Adapter/DatabaseTest.php @@ -345,7 +345,6 @@ private static function declaredAttributes(): array return [ ...Metric::getEventSchema(), ['$id' => 'type', 'type' => ColumnType::String->value, 'size' => 16, 'required' => false, 'signed' => true, 'array' => false, 'filters' => []], - ['$id' => 'ordinal', 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'signed' => true, 'array' => false, 'filters' => []], ]; } @@ -357,7 +356,6 @@ private static function declaredIndexes(): array return [ ...Metric::getEventIndexes(), ['$id' => 'index-type', 'type' => IndexType::Key->value, 'attributes' => ['type']], - ['$id' => 'index-ordinal', 'type' => IndexType::Key->value, 'attributes' => ['ordinal']], ]; } 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', From 24ace647857d5a9715bb35af923a9d0ecfbcac74 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 24 Sep 2026 21:41:43 +1200 Subject: [PATCH 06/11] chore: require database 8.* and alias the branch as 0.17.x-dev usage 0.16.x requires utopia-php/database ^7.0.0, so database 8.0.0 cannot be installed next to usage in appwrite or cloud until a usage release asks for 8. us-01 ported the code behind a temporary "dev-feat-query-lib as 7.0.0" root alias, which also claims a version below database's current 7.3.12 release. Requiring 8.* instead resolves through the branch's own 8.0.x-dev alias today and through the 8.0.0 tag once it exists, with no root alias to unwind. The branch alias for dev-feat-query-lib and dev-main lets consumers require this work before 0.17.0 is tagged. minimum-stability dev with prefer-stable true is needed only while 8.0.x-dev is untagged; stable is restored with the tag. The remaining constraint edits are wildcards for the same versions, and README's requirement line follows "php": ">=8.5". Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 2 +- composer.json | 19 +++++++++++++------ composer.lock | 27 +++++++++------------------ 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d903a7c..863ab9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 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 @@ -23,6 +32,16 @@ - 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.*`, 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. +- The `psr/http-client`, `utopia-php/client`, `utopia-php/cache` and + `phpstan/phpstan` constraints are written as wildcards (`1.*`, `0.4.*`, + `4.*` and `2.*`). The versions they resolve to are unchanged. +- `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 diff --git a/README.md b/README.md index e820e1c..cd6a141 100644 --- a/README.md +++ b/README.md @@ -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 ea6e57a..6276b11 100644 --- a/composer.json +++ b/composer.json @@ -16,18 +16,19 @@ "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.5", - "psr/http-client": "^1.0", - "utopia-php/client": "^0.4", - "utopia-php/database": "dev-feat-query-lib as 7.0.0", + "psr/http-client": "1.*", + "utopia-php/client": "0.4.*", + "utopia-php/database": "8.*", "utopia-php/query": "0.6.*" }, "require-dev": { "phpunit/phpunit": "^9.5", - "utopia-php/cache": "^4.0", - "phpstan/phpstan": "^2.0", + "utopia-php/cache": "4.*", + "phpstan/phpstan": "2.*", "laravel/pint": "1.*" }, "suggest": { @@ -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 4b65cb0..e0fdfd1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fcef17a0981aef29af515967678708eb", + "content-hash": "e07fb84b338245b0b8155080fd9075d7", "packages": [ { "name": "brick/math", @@ -2228,12 +2228,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "d82a7ec5454c8e5086d205b715ff4e9499533d80" + "reference": "8b716e3c1c21cf0a216b074ad670c30e21556cba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/d82a7ec5454c8e5086d205b715ff4e9499533d80", - "reference": "d82a7ec5454c8e5086d205b715ff4e9499533d80", + "url": "https://api.github.com/repos/utopia-php/database/zipball/8b716e3c1c21cf0a216b074ad670c30e21556cba", + "reference": "8b716e3c1c21cf0a216b074ad670c30e21556cba", "shasum": "" }, "require": { @@ -2295,7 +2295,7 @@ "issues": "https://github.com/utopia-php/database/issues", "source": "https://github.com/utopia-php/database/tree/feat-query-lib" }, - "time": "2026-09-24T03:03:02+00:00" + "time": "2026-09-24T05:48:44+00:00" }, { "name": "utopia-php/mongo", @@ -4569,19 +4569,10 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [ - { - "package": "utopia-php/database", - "version": "dev-feat-query-lib", - "alias": "7.0.0", - "alias_normalized": "7.0.0.0" - } - ], - "minimum-stability": "stable", - "stability-flags": { - "utopia-php/database": 20 - }, - "prefer-stable": false, + "aliases": [], + "minimum-stability": "dev", + "stability-flags": {}, + "prefer-stable": true, "prefer-lowest": false, "platform": { "php": ">=8.5" From 667e97a87b95dc890aa942523c845126211d5ab3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 25 Sep 2026 16:56:07 +1200 Subject: [PATCH 07/11] docs: record the utopia-php/client 0.5 requirement for 0.17.0 The merge of main brought "utopia-php/client": "^0.5" and the Utopia\Client\Client import, so the 0.17.0 entry's claim that client is written as the 0.4.* wildcard and resolves to the same version no longer held. The wildcard line now names the three constraints it still covers, and a separate line records the client 0.5 requirement. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 863ab9d..31361e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,9 +37,11 @@ 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. -- The `psr/http-client`, `utopia-php/client`, `utopia-php/cache` and - `phpstan/phpstan` constraints are written as wildcards (`1.*`, `0.4.*`, - `4.*` and `2.*`). The versions they resolve to are unchanged. +- The `psr/http-client`, `utopia-php/cache` and `phpstan/phpstan` + constraints are written as wildcards (`1.*`, `4.*` and `2.*`). The + versions they resolve to are unchanged. +- 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. From 6a39ba1ea28cb5e37b7797f19f07e4eda5733ff0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 25 Sep 2026 21:23:19 +1200 Subject: [PATCH 08/11] chore: require utopia-php/client as a 0.5.* wildcard The merge of main (da77966) took main's "utopia-php/client": "^0.5" line as main wrote it, so the branch had a caret constraint again after 24ace64 wrote every constraint as a wildcard. For a 0.x version ^0.5 and 0.5.* are the same range (>=0.5.0 <0.6.0): client stays on 0.5.1 and the lock changes only its content-hash. The 0.17.0 CHANGELOG line names the constraint, so it follows. phpunit/phpunit keeps ^9.5, the one range left, because no wildcard expresses >=9.5 <10. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- composer.json | 2 +- composer.lock | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31361e3..4d4d0a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,8 +40,8 @@ - The `psr/http-client`, `utopia-php/cache` and `phpstan/phpstan` constraints are written as wildcards (`1.*`, `4.*` and `2.*`). The versions they resolve to are unchanged. -- Requires `utopia-php/client` 0.5 (`^0.5`). The ClickHouse adapter imports - `Utopia\Client\Client`, where client 0.5 moved the class. +- 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. diff --git a/composer.json b/composer.json index 8d748e4..9567f33 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require": { "php": ">=8.5", "psr/http-client": "1.*", - "utopia-php/client": "^0.5", + "utopia-php/client": "0.5.*", "utopia-php/database": "8.*", "utopia-php/query": "0.6.*" }, diff --git a/composer.lock b/composer.lock index e1971eb..cd4aaeb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f4343e0ffe3d96cc109a5b9a7f211d2e", + "content-hash": "86b4c6a30a7bc11a98a58b0804168185", "packages": [ { "name": "brick/math", From fb9ed899d56c077141cc4883077135a24d80bc8c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 26 Sep 2026 00:10:20 +1200 Subject: [PATCH 09/11] (chore): write the train's Composer constraints as carets The train wrote the constraints it changed as wildcards under a house rule that has since been withdrawn: constraints are carets again. psr/http-client, utopia-php/client, utopia-php/cache and phpstan/phpstan take main's (and 0.16.6's) form again, ^1.0, ^0.5, ^4.0 and ^2.0. For client this undoes 6a39ba1, which had rewritten main's ^0.5 as 0.5.*. utopia-php/database, which the train moved to 8, takes the caret of that range, ^8.0. Each caret is the same range as the wildcard it replaces, and every locked version satisfies it (database through its 8.0.x-dev branch alias), so composer update --lock modifies nothing and the lock changes only its content-hash. The 0.17.0 CHANGELOG spells database and client as carets and drops the bullet that called the other three constraints wildcards: they equal 0.16.6's again. utopia-php/query keeps main's own 0.6.*. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++----- composer.json | 10 +++++----- composer.lock | 2 +- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d4d0a9..2677244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,15 +32,12 @@ - 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.*`, replacing the temporary +- `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. -- The `psr/http-client`, `utopia-php/cache` and `phpstan/phpstan` - constraints are written as wildcards (`1.*`, `4.*` and `2.*`). The - versions they resolve to are unchanged. -- Requires `utopia-php/client` 0.5 (`0.5.*`). The ClickHouse adapter +- 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. diff --git a/composer.json b/composer.json index 9567f33..f1a0f86 100644 --- a/composer.json +++ b/composer.json @@ -20,15 +20,15 @@ "prefer-stable": true, "require": { "php": ">=8.5", - "psr/http-client": "1.*", - "utopia-php/client": "0.5.*", - "utopia-php/database": "8.*", + "psr/http-client": "^1.0", + "utopia-php/client": "^0.5", + "utopia-php/database": "^8.0", "utopia-php/query": "0.6.*" }, "require-dev": { "phpunit/phpunit": "^9.5", - "utopia-php/cache": "4.*", - "phpstan/phpstan": "2.*", + "utopia-php/cache": "^4.0", + "phpstan/phpstan": "^2.0", "laravel/pint": "1.*" }, "suggest": { diff --git a/composer.lock b/composer.lock index cd4aaeb..f9dbede 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "86b4c6a30a7bc11a98a58b0804168185", + "content-hash": "d42bdfbed3a95f50622e0d02326f9971", "packages": [ { "name": "brick/math", From 19ae93cbad17337c3504c3edbc28c951d4b0b98c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 28 Sep 2026 16:05:33 +1300 Subject: [PATCH 10/11] test(clickhouse): assert ordinal behaviour instead of its storage type ClickHouseColumnTypeTest reflected into the adapter's private getColumnType() and compared the type string of 22 dimension columns, the ordinal on events and gauges among them. It never touched a table: a column that stopped storing, filtering or returning values passed, while a storage-only change such as dropping LowCardinality failed. What callers can see of those types is that a missing value is NULL and every value is kept as the string written. ClickHouseTest now checks that through the public API: - For events and gauges, ordinals '0', '1' and 10 read back as '0', '1' and '10', and a row without one reads back null. equal('10') and isNull() each match only their row, and grouping by ordinal lists each member in order, with the rows without one apart. - The daily rollup still totals every member. The event ordinal test said so but only summed the raw table. - Every event dimension, taken from Metric::EVENT_COLUMNS, filters and reads back as written and is null when omitted. The previous list held 24 of the 47 columns and read none of them back. A non-nullable or FixedString(1) ordinal fails the new tests; a plain Nullable(String) passes them, where the removed test failed. Co-Authored-By: Claude Opus 5.5 --- .../Adapter/ClickHouseColumnTypeTest.php | 140 ----------------- tests/Usage/Adapter/ClickHouseTest.php | 143 ++++++++++++++---- 2 files changed, 114 insertions(+), 169 deletions(-) delete mode 100644 tests/Usage/Adapter/ClickHouseColumnTypeTest.php diff --git a/tests/Usage/Adapter/ClickHouseColumnTypeTest.php b/tests/Usage/Adapter/ClickHouseColumnTypeTest.php deleted file mode 100644 index c880814..0000000 --- a/tests/Usage/Adapter/ClickHouseColumnTypeTest.php +++ /dev/null @@ -1,140 +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 replica ordinal holds a handful of distinct values, so it - * must map to LowCardinality(Nullable(String)) on both tables. - */ - public function testLowCardinalityOrdinalColumn(): void - { - $this->assertSame( - 'LowCardinality(Nullable(String))', - $this->columnType('ordinal', 'gauge'), - 'ordinal should be LowCardinality(Nullable(String))' - ); - $this->assertSame( - 'LowCardinality(Nullable(String))', - $this->columnType('ordinal', 'event'), - 'event ordinal should be LowCardinality(Nullable(String))' - ); - } -} diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index 395e7ca..b1471a7 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; @@ -409,8 +410,8 @@ public function testEventOrdinalSeparatesReplicaSeries(): void ], Usage::TYPE_EVENT)); $primary = $this->usage->find('1', [ - \Utopia\Query\Query::equal('metric', ['event-ordinal-test']), - \Utopia\Query\Query::equal('ordinal', ['0']), + Query::equal('metric', ['event-ordinal-test']), + Query::equal('ordinal', ['0']), ], Usage::TYPE_EVENT); $this->assertCount(1, $primary); @@ -418,7 +419,7 @@ public function testEventOrdinalSeparatesReplicaSeries(): void $this->assertEquals('0', $primary[0]->getOrdinal()); $perNode = $this->usage->find('1', [ - \Utopia\Query\Query::equal('metric', ['event-ordinal-test']), + Query::equal('metric', ['event-ordinal-test']), UsageQuery::groupBy('ordinal'), ], Usage::TYPE_EVENT); @@ -432,10 +433,97 @@ public function testEventOrdinalSeparatesReplicaSeries(): void $this->assertEquals(['0' => 512, '1' => 128], $byOrdinal); $this->assertSame(640, $this->usage->sum('1', [ - \Utopia\Query\Query::equal('metric', ['event-ordinal-test']), + 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); @@ -490,41 +578,38 @@ public function testEmptyStringCoercedToNull(): void $this->assertNull($results[0]->getOsName()); } - /** - * Round-trip a row that exercises every queryable dimension column to - * confirm the events table schema accepts and persists each one. - */ - public function testEventsSchemaPersistsAllNewColumns(): void + public function testEveryEventDimensionRoundTrips(): void { $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', - ]; + $tags = []; + foreach (Metric::EVENT_COLUMNS as $column) { + $tags[$column] = $column . '-value'; + } $this->assertTrue($this->usage->addBatch([ ['tenant' => '1', 'metric' => 'schema-roundtrip', 'value' => 1, 'tags' => $tags], + ['tenant' => '1', 'metric' => 'schema-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; + foreach ($tags as $column => $value) { $rows = $this->usage->find('1', [ - \Utopia\Query\Query::equal('metric', ['schema-roundtrip']), - \Utopia\Query\Query::equal($col, [$expected]), + Query::equal('metric', ['schema-roundtrip']), + Query::equal($column, [$value]), ], Usage::TYPE_EVENT); - $this->assertGreaterThanOrEqual(1, count($rows), "Filter on {$col} returned no rows"); + + $this->assertCount(1, $rows, 'Filter on ' . $column . ' should match only the event that carries it'); + $this->assertSame($value, $rows[0]->getAttribute($column), $column . ' should read back as written'); + } + + $untagged = $this->usage->find('1', [ + Query::equal('metric', ['schema-roundtrip']), + Query::equal('value', [2]), + ], Usage::TYPE_EVENT); + + $this->assertCount(1, $untagged); + foreach (Metric::EVENT_COLUMNS as $column) { + $this->assertNull($untagged[0]->getAttribute($column), $column . ' should read back null when the event omits it'); } } From bf9ab997fa229c6664789111d558a9b366e15141 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 28 Sep 2026 20:03:24 +1300 Subject: [PATCH 11/11] test(clickhouse): check event dimensions against explicit caller values testEveryEventDimensionRoundTrips built its tags from Metric::EVENT_COLUMNS and asserted over the same list, so a dimension dropped from that list dropped out of the test too: with weatherCode left out of EVENT_COLUMNS it still passed, checking 46 columns. Its '-value' strings, 34 bytes at most, also let a cap on stored values go unnoticed. The dimensions are now written out in the test, grouped by what callers send (request, resource, network, location, client), with values shaped like real input: a 135-byte Accept header, a route with placeholders, an IPv6 address, a non-ASCII city, a postal code with a space, a negative coordinate, uppercase codes and numeric-looking identifiers. For each one, equal() returns only the event that carries it, with the value as written, and isNull() returns only the event without it. Removing or renaming a dimension now fails the test; storage-only changes such as dropping LowCardinality still pass. Co-Authored-By: Claude Opus 5.5 --- tests/Usage/Adapter/ClickHouseTest.php | 118 ++++++++++++++++++++----- 1 file changed, 95 insertions(+), 23 deletions(-) diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index b1471a7..84e62eb 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -578,39 +578,111 @@ public function testEmptyStringCoercedToNull(): void $this->assertNull($results[0]->getOsName()); } - public function testEveryEventDimensionRoundTrips(): void + /** + * @return array}> + */ + public static function eventDimensions(): array { - $this->usage->purge('1', [], Usage::TYPE_EVENT); + 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', + ]], + ]; + } - $tags = []; - foreach (Metric::EVENT_COLUMNS as $column) { - $tags[$column] = $column . '-value'; - } + /** + * @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' => 'schema-roundtrip', 'value' => 2, 'tags' => []], + ['tenant' => '1', 'metric' => 'dimension-roundtrip', 'value' => 1, 'tags' => $dimensions], + ['tenant' => '1', 'metric' => 'dimension-roundtrip', 'value' => 2, 'tags' => []], ], Usage::TYPE_EVENT)); - foreach ($tags as $column => $value) { - $rows = $this->usage->find('1', [ - Query::equal('metric', ['schema-roundtrip']), - Query::equal($column, [$value]), - ], Usage::TYPE_EVENT); - - $this->assertCount(1, $rows, 'Filter on ' . $column . ' should match only the event that carries it'); - $this->assertSame($value, $rows[0]->getAttribute($column), $column . ' should read back as written'); + 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' + ); } + } - $untagged = $this->usage->find('1', [ - Query::equal('metric', ['schema-roundtrip']), - Query::equal('value', [2]), + /** + * @return array + */ + private function readDimension(string $dimension, Query $filter): array + { + $events = $this->usage->find('1', [ + Query::equal('metric', ['dimension-roundtrip']), + $filter, ], Usage::TYPE_EVENT); - $this->assertCount(1, $untagged); - foreach (Metric::EVENT_COLUMNS as $column) { - $this->assertNull($untagged[0]->getAttribute($column), $column . ' should read back null when the event omits it'); - } + return array_map(fn (Metric $event): array => [$event->getValue(), $event->getAttribute($dimension)], $events); } /**