diff --git a/Dockerfile b/Dockerfile index 2d6a28f..47c4fa4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.3.3-cli-alpine3.19 as final +FROM php:8.4.21-cli-alpine3.23 as final LABEL maintainer="team@appwrite.io" diff --git a/composer.json b/composer.json index 24abbd7..f8b43a5 100644 --- a/composer.json +++ b/composer.json @@ -15,17 +15,18 @@ "check": "./vendor/bin/phpstan analyse --level max src tests", "test": "./vendor/bin/phpunit --configuration phpunit.xml tests" }, - "minimum-stability": "stable", + "minimum-stability": "dev", + "prefer-stable": true, "require": { - "php": ">=8.0", + "php": ">=8.4", "utopia-php/fetch": "^1.1", "utopia-php/database": "5.*", - "utopia-php/query": "0.1.*" + "utopia-php/query": "dev-feat/clickhouse-insert-delete-settings-mv as 0.3.2" }, "require-dev": { "phpunit/phpunit": "^9.5", "utopia-php/cache": "1.*", - "phpstan/phpstan": "1.*", + "phpstan/phpstan": "^2.0", "laravel/pint": "1.*" }, "autoload": { diff --git a/composer.lock b/composer.lock index 9f751c0..dbc96e6 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": "9730d646b00f41364a5cb078f9da64c7", + "content-hash": "a77e3625222dd89388b8451062854218", "packages": [ { "name": "brick/math", @@ -145,23 +145,23 @@ }, { "name": "google/protobuf", - "version": "v4.33.5", + "version": "v4.33.6", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d" + "reference": "84b008c23915ed94536737eae46f41ba3bccfe67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d", - "reference": "ebe8010a61b2ae0cff0d246fe1c4d44e9f7dfa6d", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67", + "reference": "84b008c23915ed94536737eae46f41ba3bccfe67", "shasum": "" }, "require": { "php": ">=8.1.0" }, "require-dev": { - "phpunit/phpunit": ">=5.0.0 <8.5.27" + "phpunit/phpunit": ">=10.5.62 <11.0.0" }, "suggest": { "ext-bcmath": "Need to support JSON deserialization" @@ -183,9 +183,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.5" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6" }, - "time": "2026-01-29T20:49:00+00:00" + "time": "2026-03-18T17:32:05+00:00" }, { "name": "mongodb/mongodb", @@ -410,16 +410,16 @@ }, { "name": "open-telemetry/api", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad" + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/df5197c6fd0ddd8e9883b87de042d9341300e2ad", - "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", "shasum": "" }, "require": { @@ -476,20 +476,20 @@ "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-02-25T13:24:05+00:00" }, { "name": "open-telemetry/context", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", - "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf" + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf", - "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", "shasum": "" }, "require": { @@ -531,11 +531,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-19T00:05:49+00:00" + "time": "2025-10-19T06:44:33+00:00" }, { "name": "open-telemetry/exporter-otlp", @@ -603,16 +603,16 @@ }, { "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "673af5b06545b513466081884b47ef15a536edde" + "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/673af5b06545b513466081884b47ef15a536edde", - "reference": "673af5b06545b513466081884b47ef15a536edde", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9", + "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9", "shasum": "" }, "require": { @@ -658,30 +658,30 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-17T23:10:12+00:00" + "time": "2025-10-19T06:44:33+00:00" }, { "name": "open-telemetry/sdk", - "version": "1.13.0", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1" + "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/c76f91203bf7ef98ab3f4e0a82ca21699af185e1", - "reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410", + "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "^1.7", + "open-telemetry/api": "^1.8", "open-telemetry/context": "^1.4", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", @@ -759,7 +759,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-01-28T11:38:11+00:00" + "time": "2026-03-21T11:50:01+00:00" }, { "name": "open-telemetry/sem-conv", @@ -1316,16 +1316,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -1338,7 +1338,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -1363,7 +1363,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -1374,25 +1374,29 @@ "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": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", "shasum": "" }, "require": { @@ -1460,7 +1464,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.9" }, "funding": [ { @@ -1480,20 +1484,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-04-29T13:25:15+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -1506,7 +1510,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -1542,7 +1546,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -1553,25 +1557,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -1623,7 +1631,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -1643,20 +1651,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "5d2ed36f7734637dacc025f179698031951b1692" + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692", - "reference": "5d2ed36f7734637dacc025f179698031951b1692", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", "shasum": "" }, "require": { @@ -1703,7 +1711,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0" }, "funding": [ { @@ -1723,20 +1731,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -1783,7 +1791,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -1803,20 +1811,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -1863,7 +1871,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -1883,20 +1891,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -1914,7 +1922,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -1950,7 +1958,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -1970,7 +1978,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "tbachert/spi", @@ -2026,16 +2034,16 @@ }, { "name": "utopia-php/cache", - "version": "1.0.0", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "7068870c086a6aea16173563a26b93ef3e408439" + "reference": "ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/7068870c086a6aea16173563a26b93ef3e408439", - "reference": "7068870c086a6aea16173563a26b93ef3e408439", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa", + "reference": "ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa", "shasum": "" }, "require": { @@ -2072,79 +2080,83 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/1.0.0" + "source": "https://github.com/utopia-php/cache/tree/1.0.3" }, - "time": "2026-01-28T10:55:44+00:00" + "time": "2026-05-11T11:02:13+00:00" }, { - "name": "utopia-php/compression", - "version": "0.1.4", + "name": "utopia-php/console", + "version": "0.1.1", "source": { "type": "git", - "url": "https://github.com/utopia-php/compression.git", - "reference": "68045cb9d714c1259582d2dfd0e76bd34f83e713" + "url": "https://github.com/utopia-php/console.git", + "reference": "d298e43960780e6d76e66de1228c75dc81220e3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/compression/zipball/68045cb9d714c1259582d2dfd0e76bd34f83e713", - "reference": "68045cb9d714c1259582d2dfd0e76bd34f83e713", + "url": "https://api.github.com/repos/utopia-php/console/zipball/d298e43960780e6d76e66de1228c75dc81220e3e", + "reference": "d298e43960780e6d76e66de1228c75dc81220e3e", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.0" }, "require-dev": { "laravel/pint": "1.2.*", + "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.0.1" + "squizlabs/php_codesniffer": "^3.6", + "swoole/ide-helper": "4.8.8" }, "type": "library", "autoload": { "psr-4": { - "Utopia\\Compression\\": "src/Compression" + "Utopia\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A simple Compression library to handle file compression", + "description": "Console helpers for logging, prompting, and executing commands", "keywords": [ - "compression", - "framework", + "cli", + "console", "php", - "upf", + "terminal", "utopia" ], "support": { - "issues": "https://github.com/utopia-php/compression/issues", - "source": "https://github.com/utopia-php/compression/tree/0.1.4" + "issues": "https://github.com/utopia-php/console/issues", + "source": "https://github.com/utopia-php/console/tree/0.1.1" }, - "time": "2026-02-17T05:53:40+00:00" + "time": "2026-02-10T10:20:29+00:00" }, { "name": "utopia-php/database", - "version": "5.3.6", + "version": "5.7.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "489e3cea9da80f067fda1acc3fa03bc6ca9f69de" + "reference": "eb35e68f7f90932d5a60bd72e70158ae7a4e0511" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/489e3cea9da80f067fda1acc3fa03bc6ca9f69de", - "reference": "489e3cea9da80f067fda1acc3fa03bc6ca9f69de", + "url": "https://api.github.com/repos/utopia-php/database/zipball/eb35e68f7f90932d5a60bd72e70158ae7a4e0511", + "reference": "eb35e68f7f90932d5a60bd72e70158ae7a4e0511", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-mongodb": "*", "ext-pdo": "*", + "ext-redis": "*", "php": ">=8.4", "utopia-php/cache": "1.*", - "utopia-php/framework": "0.33.*", + "utopia-php/console": "0.1.*", "utopia-php/mongo": "1.*", - "utopia-php/pools": "1.*" + "utopia-php/pools": "1.*", + "utopia-php/validators": "0.2.*" }, "require-dev": { "fakerphp/faker": "1.23.*", @@ -2154,7 +2166,7 @@ "phpunit/phpunit": "9.*", "rregeer/phpunit-coverage-check": "0.3.*", "swoole/ide-helper": "5.1.3", - "utopia-php/cli": "0.14.*" + "utopia-php/cli": "0.22.*" }, "type": "library", "autoload": { @@ -2176,9 +2188,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.6" + "source": "https://github.com/utopia-php/database/tree/5.7.0" }, - "time": "2026-03-06T08:21:21+00:00" + "time": "2026-05-06T01:04:08+00:00" }, { "name": "utopia-php/fetch", @@ -2220,67 +2232,18 @@ }, "time": "2026-04-29T11:19:19+00:00" }, - { - "name": "utopia-php/framework", - "version": "0.33.41", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/http.git", - "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/0f3bf2377c867e547c929c3733b8224afee6ef06", - "reference": "0f3bf2377c867e547c929c3733b8224afee6ef06", - "shasum": "" - }, - "require": { - "php": ">=8.3", - "utopia-php/compression": "0.1.*", - "utopia-php/telemetry": "0.2.*", - "utopia-php/validators": "0.2.*" - }, - "require-dev": { - "laravel/pint": "1.*", - "phpbench/phpbench": "1.*", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "9.*", - "swoole/ide-helper": "^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A simple, light and advanced PHP framework", - "keywords": [ - "framework", - "php", - "upf" - ], - "support": { - "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.41" - }, - "time": "2026-02-24T12:01:28+00:00" - }, { "name": "utopia-php/mongo", - "version": "1.0.0", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d" + "reference": "73593682deee4696525a04e26524c1c1226e1530" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/45bedf36c2c946ec7a0a3e59b9f12f772de0b01d", - "reference": "45bedf36c2c946ec7a0a3e59b9f12f772de0b01d", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/73593682deee4696525a04e26524c1c1226e1530", + "reference": "73593682deee4696525a04e26524c1c1226e1530", "shasum": "" }, "require": { @@ -2326,9 +2289,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.0.0" + "source": "https://github.com/utopia-php/mongo/tree/1.1.0" }, - "time": "2026-02-12T05:54:06+00:00" + "time": "2026-04-24T06:15:10+00:00" }, { "name": "utopia-php/pools", @@ -2385,24 +2348,27 @@ }, { "name": "utopia-php/query", - "version": "0.1.1", + "version": "dev-feat/clickhouse-insert-delete-settings-mv", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27" + "reference": "c77f2280f4e899236d737dd0ea8f54398fd20710" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/964a10ed3185490505f4c0062f2eb7b89287fb27", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27", + "url": "https://api.github.com/repos/utopia-php/query/zipball/c77f2280f4e899236d737dd0ea8f54398fd20710", + "reference": "c77f2280f4e899236d737dd0ea8f54398fd20710", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { + "brianium/paratest": "*", "laravel/pint": "*", + "mongodb/mongodb": "^2.0", "phpstan/phpstan": "*", + "phpunit/phpcov": "*", "phpunit/phpunit": "^12.0" }, "type": "library", @@ -2425,22 +2391,22 @@ ], "support": { "issues": "https://github.com/utopia-php/query/issues", - "source": "https://github.com/utopia-php/query/tree/0.1.1" + "source": "https://github.com/utopia-php/query/tree/feat/clickhouse-insert-delete-settings-mv" }, - "time": "2026-03-03T09:05:14+00:00" + "time": "2026-05-17T09:28:32+00:00" }, { "name": "utopia-php/telemetry", - "version": "0.2.0", + "version": "0.3.0", "source": { "type": "git", "url": "https://github.com/utopia-php/telemetry.git", - "reference": "9997ebf59bb77920a7223ad73d834a76b09152c3" + "reference": "62bbadad03e593b071b8ca63fac2c117c1900991" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/9997ebf59bb77920a7223ad73d834a76b09152c3", - "reference": "9997ebf59bb77920a7223ad73d834a76b09152c3", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/62bbadad03e593b071b8ca63fac2c117c1900991", + "reference": "62bbadad03e593b071b8ca63fac2c117c1900991", "shasum": "" }, "require": { @@ -2480,22 +2446,22 @@ ], "support": { "issues": "https://github.com/utopia-php/telemetry/issues", - "source": "https://github.com/utopia-php/telemetry/tree/0.2.0" + "source": "https://github.com/utopia-php/telemetry/tree/0.3.0" }, - "time": "2025-12-17T07:56:38+00:00" + "time": "2026-04-01T13:52:56+00:00" }, { "name": "utopia-php/validators", - "version": "0.2.0", + "version": "0.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "30b6030a5b100fc1dff34506e5053759594b2a20" + "reference": "9770269c8ed8e6909934965fa8722103c7434c23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20", - "reference": "30b6030a5b100fc1dff34506e5053759594b2a20", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/9770269c8ed8e6909934965fa8722103c7434c23", + "reference": "9770269c8ed8e6909934965fa8722103c7434c23", "shasum": "" }, "require": { @@ -2525,9 +2491,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.0" + "source": "https://github.com/utopia-php/validators/tree/0.2.3" }, - "time": "2026-01-13T09:16:51+00:00" + "time": "2026-05-14T08:05:44+00:00" } ], "packages-dev": [ @@ -2602,16 +2568,16 @@ }, { "name": "laravel/pint", - "version": "v1.27.1", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -2622,13 +2588,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.93.1", - "illuminate/view": "^12.51.0", - "larastan/larastan": "^3.9.2", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.5" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -2665,7 +2632,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-02-10T20:00:20+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "myclabs/deep-copy", @@ -2905,15 +2872,15 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.33", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", - "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -2954,7 +2921,7 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4448,13 +4415,22 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": false, + "aliases": [ + { + "package": "utopia-php/query", + "version": "dev-feat/clickhouse-insert-delete-settings-mv", + "alias": "0.3.2", + "alias_normalized": "0.3.2.0" + } + ], + "minimum-stability": "dev", + "stability-flags": { + "utopia-php/query": 20 + }, + "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=8.0" + "php": ">=8.4" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..91c0fb6 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,199 @@ +parameters: + ignoreErrors: + - + message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset ''agg_val'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset ''agg_value'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset ''bucket'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset ''metric'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset ''ping'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset ''total'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 6 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset ''uptime'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset ''version'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot access offset 0 on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 7 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot cast mixed to float\.$#' + identifier: cast.double + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot cast mixed to int\.$#' + identifier: cast.int + count: 9 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 5 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Method Utopia\\Usage\\Adapter\\ClickHouse\:\:getTimeSeriesFromTable\(\) should return array\\}\> but returns array\\}\>\.$#' + identifier: return.type + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Parameter \#1 \$input of class Utopia\\Usage\\Metric constructor expects array\, array\ given\.$#' + identifier: argument.type + count: 2 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Part \$metricName \(mixed\) of encapsed string cannot be cast to string\.$#' + identifier: encapsedStringPart.nonString + count: 1 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Possibly invalid array key type mixed\.$#' + identifier: offsetAccess.invalidOffset + count: 10 + path: src/Usage/Adapter/ClickHouse.php + + - + message: '#^Method Utopia\\Usage\\Metric\:\:getTags\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Usage/Metric.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\ will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Usage/Adapter/ClickHouseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\\}\> will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Usage/Adapter/ClickHouseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\ will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 2 + path: tests/Usage/Adapter/ClickHouseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\ will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Usage/Adapter/ClickHouseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\{healthy\: bool, host\: string, port\: int, database\: string, secure\: bool, version\?\: string, uptime\?\: int, error\?\: string, \.\.\.\} will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Usage/Adapter/ClickHouseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\{request_count\: int, keep_alive_enabled\: bool, compression_enabled\: bool, query_logging_enabled\: bool, max_retries\: int, retry_delay\: int, async_inserts\: bool, async_insert_wait\: bool\} will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Usage/Adapter/ClickHouseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsInt\(\) with int will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 3 + path: tests/Usage/Adapter/ClickHouseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 2 + path: tests/Usage/Adapter/ClickHouseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\\}\> will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Usage/Adapter/DatabaseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\ will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 2 + path: tests/Usage/Adapter/DatabaseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\ will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Usage/Adapter/DatabaseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\{healthy\: bool, database\?\: string, collection\?\: string, error\?\: string\} will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Usage/Adapter/DatabaseTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\\> will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 4 + path: tests/Usage/MetricTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array\ will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 2 + path: tests/Usage/MetricTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 6 + path: tests/Usage/MetricTest.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3a36a39 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: max + paths: + - src + - tests diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index f0f67dc..266cd58 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -2,35 +2,36 @@ namespace Utopia\Usage\Adapter; +use ArrayObject; +use DateTime; use Exception; -use Utopia\Query\Query; +use InvalidArgumentException; use Utopia\Fetch\Client; +use Utopia\Query\Builder\ClickHouse as ClickHouseBuilder; +use Utopia\Query\Builder\ClickHouse\FormattedInsertStatement; +use Utopia\Query\Method; +use Utopia\Query\Query; +use Utopia\Query\Schema\ClickHouse as ClickHouseSchema; +use Utopia\Query\Schema\ClickHouse\Engine; +use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\Table\ClickHouse as ClickHouseTable; use Utopia\Usage\Metric; use Utopia\Usage\Usage; -use Utopia\Usage\UsageQuery; use Utopia\Validator\Hostname; /** * ClickHouse Adapter for Usage * - * This adapter stores usage metrics in ClickHouse using HTTP interface. - * Uses two separate tables: - * - Events table (MergeTree): raw request events with metadata columns - * (path, method, status, resource, resourceId) - * - Gauges table (MergeTree): simple resource snapshots (metric, value, time, tags) + * Stores usage metrics in ClickHouse via the HTTP interface. The adapter is + * a thin HTTP runtime - every SQL statement and DDL fragment is produced by + * the utopia-php/query 0.3.x builder / schema layer: * - * A SummingMergeTree materialized view pre-aggregates events by day for fast - * billing/analytics queries. - * - * Features: - * - Two-table architecture (events + gauges) - * - Event-specific columns extracted from tags - * - SUM aggregation for events, argMax for gauges - * - Safe SQL injection prevention using ClickHouse parameter binding - * - Multi-tenant support with optional shared tables - * - Namespace support for table name prefixes - * - Bloom filter indexes for efficient filtering - * - Monthly partitioning by time + * - DDL (setup, daily table, materialized view) to Utopia\Query\Schema\ClickHouse + * - INSERT (addBatch) to Utopia\Query\Builder\ClickHouse::insertFormat + * - SELECT (find, count, sum, daily, totals, time series) to Utopia\Query\Builder\ClickHouse + * with `useNamedBindings()` for `{paramN:Type}` placeholders + * - DELETE (purge) to Utopia\Query\Builder\ClickHouse::delete */ class ClickHouse extends SQL { @@ -42,32 +43,50 @@ class ClickHouse extends SQL private const INSERT_BATCH_SIZE = 1_000; - /** @var array Maps interval strings to ClickHouse time functions */ + /** @var array Maps interval strings to ClickHouse time bucket functions */ private const INTERVAL_FUNCTIONS = [ '1h' => 'toStartOfHour', '1d' => 'toStartOfDay', ]; /** - * Filter methods that must be supplied at least one value. Empty `values` - * arrays for these methods are rejected up front so they can't silently - * compile into a "no filter applied" WHERE clause. + * Filter methods that must be supplied at least one value. Empty values + * for these methods are rejected up front so they cannot silently compile + * into a vacuous WHERE clause (the upstream builder maps `IN ()` to + * `1 = 0` rather than throwing). * - * @var list + * @var list */ private const VALUE_REQUIRED_METHODS = [ - Query::TYPE_EQUAL, - Query::TYPE_NOT_EQUAL, - Query::TYPE_LESSER, - Query::TYPE_LESSER_EQUAL, - Query::TYPE_GREATER, - Query::TYPE_GREATER_EQUAL, - Query::TYPE_BETWEEN, - Query::TYPE_NOT_BETWEEN, - Query::TYPE_CONTAINS, - Query::TYPE_NOT_CONTAINS, - Query::TYPE_STARTS_WITH, - Query::TYPE_ENDS_WITH, + Method::Equal, + Method::NotEqual, + Method::LessThan, + Method::LessThanEqual, + Method::GreaterThan, + Method::GreaterThanEqual, + Method::Between, + Method::NotBetween, + Method::Contains, + Method::NotContains, + Method::StartsWith, + Method::EndsWith, + ]; + + /** @var array Shared column to ClickHouse type map for builder typed bindings */ + private const COMMON_PARAM_TYPES = [ + 'id' => 'String', + 'metric' => 'String', + 'value' => 'Int64', + 'time' => 'DateTime64(3)', + 'tenant' => 'Nullable(String)', + 'path' => 'String', + 'method' => 'String', + 'status' => 'String', + 'resource' => 'String', + 'resourceId' => 'String', + 'country' => 'Nullable(String)', + 'userAgent' => 'String', + 'tags' => 'String', ]; private string $host; @@ -82,7 +101,6 @@ class ClickHouse extends SQL private string $password; - /** @var bool Whether to use HTTPS for ClickHouse HTTP interface */ private bool $secure = false; private Client $client; @@ -93,43 +111,27 @@ class ClickHouse extends SQL protected string $namespace = ''; - /** @var bool Whether to log queries for debugging */ private bool $enableQueryLogging = false; - /** @var array, duration: float, timestamp: float, success: bool, error?: string}> Query execution log */ + /** @var array, duration: float, timestamp: float, success: bool, error?: string}> */ private array $queryLog = []; - /** @var bool Whether to enable gzip compression for HTTP requests/responses */ private bool $enableCompression = false; - /** @var bool Whether to enable HTTP keep-alive for connection pooling */ private bool $enableKeepAlive = true; - /** @var int Number of requests made using this adapter instance */ private int $requestCount = 0; - /** @var int Maximum number of retry attempts for failed requests (0 = no retries) */ private int $maxRetries = 3; - /** @var int Initial retry delay in milliseconds (doubles with each retry) */ private int $retryDelay = 100; - /** @var string|null Current operation context for better error messages */ private ?string $operationContext = null; - /** @var bool Whether to enable ClickHouse async inserts (server-side batching) */ private bool $asyncInserts = false; - /** @var bool Whether to wait for async insert confirmation before returning */ private bool $asyncInsertWait = true; - /** - * @param string $host ClickHouse host - * @param string $username ClickHouse username (default: 'default') - * @param string $password ClickHouse password (default: '') - * @param int $port ClickHouse HTTP port (default: 8123) - * @param bool $secure Whether to use HTTPS (default: false) - */ public function __construct( string $host, string $username = 'default', @@ -146,20 +148,12 @@ public function __construct( $this->password = $password; $this->secure = $secure; - // Initialize the HTTP client for connection reuse $this->client = new Client(); $this->client->addHeader('X-ClickHouse-User', $this->username); $this->client->addHeader('X-ClickHouse-Key', $this->password); - $this->client->setTimeout(30_000); // 30 seconds + $this->client->setTimeout(30_000); } - /** - * Set the HTTP request timeout in milliseconds. - * - * @param int $milliseconds Timeout in milliseconds (min: 1000ms, max: 600000ms) - * @return self - * @throws Exception If timeout is out of valid range - */ public function setTimeout(int $milliseconds): self { if ($milliseconds < 1000) { @@ -172,49 +166,24 @@ public function setTimeout(int $milliseconds): self return $this; } - /** - * Enable or disable query logging for debugging. - * - * @param bool $enable Whether to enable query logging - * @return self - */ public function enableQueryLogging(bool $enable = true): self { $this->enableQueryLogging = $enable; return $this; } - /** - * Enable or disable gzip compression for HTTP requests/responses. - * - * @param bool $enable Whether to enable compression - * @return self - */ public function setCompression(bool $enable): self { $this->enableCompression = $enable; return $this; } - /** - * Enable or disable HTTP keep-alive for connection pooling. - * - * @param bool $enable Whether to enable keep-alive (default: true) - * @return self - */ public function setKeepAlive(bool $enable): self { $this->enableKeepAlive = $enable; return $this; } - /** - * Set maximum number of retry attempts for failed requests. - * - * @param int $maxRetries Maximum retry attempts (0-10, 0 = no retries) - * @return self - * @throws Exception If maxRetries is out of valid range - */ public function setMaxRetries(int $maxRetries): self { if ($maxRetries < 0 || $maxRetries > 10) { @@ -224,14 +193,6 @@ public function setMaxRetries(int $maxRetries): self return $this; } - /** - * Set initial retry delay in milliseconds. - * Delay doubles with each retry attempt (exponential backoff). - * - * @param int $milliseconds Initial delay in milliseconds (10-5000ms) - * @return self - * @throws Exception If delay is out of valid range - */ public function setRetryDelay(int $milliseconds): self { if ($milliseconds < 10 || $milliseconds > 5000) { @@ -241,13 +202,6 @@ public function setRetryDelay(int $milliseconds): self return $this; } - /** - * Enable or disable ClickHouse async inserts (server-side batching). - * - * @param bool $enable Whether to enable async inserts - * @param bool $waitForConfirmation Whether to wait for server-side flush before returning - * @return self - */ public function setAsyncInserts(bool $enable, bool $waitForConfirmation = true): self { $this->asyncInserts = $enable; @@ -256,8 +210,6 @@ public function setAsyncInserts(bool $enable, bool $waitForConfirmation = true): } /** - * Get connection statistics for monitoring. - * * @return array{request_count: int, keep_alive_enabled: bool, compression_enabled: bool, query_logging_enabled: bool, max_retries: int, retry_delay: int, async_inserts: bool, async_insert_wait: bool} */ public function getConnectionStats(): array @@ -275,8 +227,6 @@ public function getConnectionStats(): array } /** - * Get the query execution log. - * * @return array, duration: float, timestamp: float, success: bool, error?: string}> */ public function getQueryLog(): array @@ -284,28 +234,18 @@ public function getQueryLog(): array return $this->queryLog; } - /** - * Clear the query execution log. - * - * @return self - */ public function clearQueryLog(): self { $this->queryLog = []; return $this; } - /** - * Get adapter name. - */ public function getName(): string { return 'ClickHouse'; } /** - * Check ClickHouse connection health and get server information. - * * @return array{healthy: bool, host: string, port: int, database: string, secure: bool, version?: string, uptime?: int, error?: string, response_time?: float} */ public function healthCheck(): array @@ -322,7 +262,6 @@ public function healthCheck(): array ]; try { - // Simple connectivity test $response = $this->query('SELECT 1 as ping FORMAT JSON'); $json = json_decode($response, true); @@ -331,7 +270,6 @@ public function healthCheck(): array return $result; } - // Get server version and uptime try { $versionResponse = $this->query('SELECT version() as version, uptime() as uptime FORMAT JSON'); $versionJson = json_decode($versionResponse, true); @@ -341,7 +279,6 @@ public function healthCheck(): array $result['uptime'] = (int) $versionJson['data'][0]['uptime']; } } catch (Exception $e) { - // Version info is optional, don't fail health check } $result['healthy'] = true; @@ -355,12 +292,6 @@ public function healthCheck(): array } } - /** - * Validate host parameter. - * - * @param string $host - * @throws Exception - */ private function validateHost(string $host): void { $validator = new Hostname(); @@ -369,12 +300,6 @@ private function validateHost(string $host): void } } - /** - * Validate port parameter. - * - * @param int $port - * @throws Exception - */ private function validatePort(int $port): void { if ($port < 1 || $port > 65535) { @@ -382,13 +307,6 @@ private function validatePort(int $port): void } } - /** - * Validate identifier (database, table, namespace). - * - * @param string $identifier - * @param string $type Name of the identifier type for error messages - * @throws Exception - */ private function validateIdentifier(string $identifier, string $type = 'Identifier'): void { if (empty($identifier)) { @@ -409,24 +327,11 @@ private function validateIdentifier(string $identifier, string $type = 'Identifi } } - /** - * Escape an identifier for safe use in SQL. - * - * @param string $identifier - * @return string - */ private function escapeIdentifier(string $identifier): string { return '`' . str_replace('`', '``', $identifier) . '`'; } - /** - * Set the namespace for multi-project support. - * - * @param string $namespace - * @return self - * @throws Exception - */ public function setNamespace(string $namespace): self { if (!empty($namespace)) { @@ -436,13 +341,6 @@ public function setNamespace(string $namespace): self return $this; } - /** - * Set the database name for subsequent operations. - * - * @param string $database - * @return self - * @throws Exception - */ public function setDatabase(string $database): self { $this->validateIdentifier($database, 'Database'); @@ -450,74 +348,39 @@ public function setDatabase(string $database): self return $this; } - /** - * Enable or disable HTTPS for ClickHouse HTTP interface. - */ public function setSecure(bool $secure): self { $this->secure = $secure; return $this; } - /** - * Get the namespace. - * - * @return string - */ public function getNamespace(): string { return $this->namespace; } - /** - * Set the tenant ID for multi-tenant support. - * - * @param string|null $tenant - * @return self - */ public function setTenant(?string $tenant): self { $this->tenant = $tenant; return $this; } - /** - * Get the tenant ID. - * - * @return string|null - */ public function getTenant(): ?string { return $this->tenant; } - /** - * Set whether tables are shared across tenants. - * - * @param bool $sharedTables - * @return self - */ public function setSharedTables(bool $sharedTables): self { $this->sharedTables = $sharedTables; return $this; } - /** - * Get whether tables are shared across tenants. - * - * @return bool - */ public function isSharedTables(): bool { return $this->sharedTables; } - /** - * Get the base table name with namespace prefix. - * - * @return string - */ private function getTableName(): string { $tableName = $this->table; @@ -529,68 +392,41 @@ private function getTableName(): string return $tableName; } - /** - * Get the events table name. - * - * @return string - */ private function getEventsTableName(): string { return $this->getTableName() . '_events'; } - /** - * Get the gauges table name. - * - * @return string - */ private function getGaugesTableName(): string { return $this->getTableName() . '_gauges'; } - /** - * Get the events daily table name. - * - * @return string - */ private function getEventsDailyTableName(): string { return $this->getTableName() . '_events_daily'; } - /** - * Get the appropriate table name for a given type. - * - * @param string $type 'event' or 'gauge' - * @return string - */ + private function getEventsDailyMvName(): string + { + return $this->getTableName() . '_events_daily_mv'; + } + private function getTableForType(string $type): string { return $type === Usage::TYPE_GAUGE ? $this->getGaugesTableName() : $this->getEventsTableName(); } /** - * Build a fully qualified table reference with database and escaping. - * - * @param string $tableName The table name (with namespace already applied) - * @return string Fully qualified table reference + * Build a fully-qualified `\`database\`.\`table\`` reference. */ private function buildTableReference(string $tableName): string { - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - return $escapedTable; + return $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); } /** - * Log a query execution for debugging purposes. - * - * @param string $sql SQL query executed - * @param array $params Query parameters - * @param float $duration Execution duration in seconds - * @param bool $success Whether the query succeeded - * @param string|null $error Error message if query failed - * @param int $retryAttempt Current retry attempt number + * @param array $params */ private function logQuery(string $sql, array $params, float $duration, bool $success, ?string $error = null, int $retryAttempt = 0): void { @@ -617,13 +453,6 @@ private function logQuery(string $sql, array $params, float $duration, bool $suc $this->queryLog[] = $logEntry; } - /** - * Determine if an error is retryable. - * - * @param int|null $httpCode HTTP status code if available - * @param string $errorMessage Error message - * @return bool True if the error is retryable - */ private function isRetryableError(?int $httpCode, string $errorMessage): bool { if ($httpCode !== null) { @@ -650,23 +479,15 @@ private function isRetryableError(?int $httpCode, string $errorMessage): bool return false; } - /** - * Set the current operation context for better error messages. - * - * @param string|null $context - * @return void - */ private function setOperationContext(?string $context): void { $this->operationContext = $context; } /** - * Execute an operation with automatic retry logic and exponential backoff. - * * @template T * @param callable(int): T $operation - * @param callable(Exception, int|null): bool $shouldRetry + * @param callable(Exception, ?int): bool $shouldRetry * @param callable(Exception, int): Exception $buildException * @return T * @throws Exception @@ -699,14 +520,6 @@ private function executeWithRetry(callable $operation, callable $shouldRetry, ca ); } - /** - * Build a contextual error message. - * - * @param string $baseMessage - * @param string|null $table - * @param string|null $sql - * @return string - */ private function buildErrorMessage(string $baseMessage, ?string $table = null, ?string $sql = null): string { $parts = []; @@ -730,11 +543,11 @@ private function buildErrorMessage(string $baseMessage, ?string $table = null, ? } /** - * Execute a ClickHouse query via HTTP interface. + * Execute a SELECT/DDL/DELETE against ClickHouse via HTTP. Named parameters + * follow the `{name:Type}` convention - caller supplies `name => value` + * pairs and the HTTP layer prefixes each with `param_`. * - * @param string $sql * @param array $params - * @return string * @throws Exception */ private function query(string $sql, array $params = []): string @@ -813,25 +626,23 @@ function (Exception $e, int $attempt) use ($sql): Exception { } /** - * Execute a ClickHouse INSERT using JSONEachRow format. + * Stream a list of JSON rows into a `INSERT ... FORMAT JSONEachRow` statement. * - * @param string $table Table name - * @param array $data Array of JSON strings (one per row) + * @param array $data Pre-encoded JSON rows * @throws Exception */ - private function insert(string $table, array $data): void + private function insert(string $sql, array $data): void { if (empty($data)) { return; } $this->executeWithRetry( - function (int $attempt) use ($table, $data): void { + function (int $attempt) use ($sql, $data): void { $startTime = microtime(true); $scheme = $this->secure ? 'https' : 'http'; - $escapedTable = $this->escapeIdentifier($table); - $queryParams = ['query' => "INSERT INTO {$escapedTable} FORMAT JSONEachRow"]; + $queryParams = ['query' => $sql]; if ($this->asyncInserts) { $queryParams['async_insert'] = '1'; $queryParams['wait_for_async_insert'] = $this->asyncInsertWait ? '1' : '0'; @@ -857,7 +668,6 @@ function (int $attempt) use ($table, $data): void { $body = implode("\n", $data); - $sql = "INSERT INTO {$escapedTable} FORMAT JSONEachRow"; $params = ['rows' => count($data), 'bytes' => strlen($body)]; try { @@ -875,7 +685,7 @@ function (int $attempt) use ($table, $data): void { $duration = microtime(true) - $startTime; $rowCount = count($data); $baseError = "ClickHouse insert failed with HTTP {$httpCode}: {$bodyStr}"; - $errorMsg = $this->buildErrorMessage($baseError, $table, "INSERT INTO {$table} ({$rowCount} rows)"); + $errorMsg = $this->buildErrorMessage($baseError, null, $sql . " ({$rowCount} rows)"); $this->logQuery($sql, $params, $duration, false, $errorMsg, $attempt); throw new Exception($errorMsg . '|HTTP_CODE:' . $httpCode); @@ -888,15 +698,9 @@ function (int $attempt) use ($table, $data): void { } }, function (Exception $e, ?int $httpCode): bool { - // Never retry inserts. The underlying MergeTree engine has - // no row-level deduplication, so a retried insert that hits - // the server twice (network blip + first request actually - // succeeded) leaves duplicate rows behind. Surface the - // failure to the caller instead — they can replay the - // batch from durable storage if they choose. return false; }, - function (Exception $e, int $attempt) use ($table, $data): Exception { + function (Exception $e, int $attempt) use ($sql, $data): Exception { $cleanMessage = preg_replace('/\|HTTP_CODE:\d+$/', '', $e->getMessage()); $cleanMessage = is_string($cleanMessage) ? $cleanMessage : $e->getMessage(); @@ -906,17 +710,16 @@ function (Exception $e, int $attempt) use ($table, $data): Exception { $rowCount = count($data); $baseError = "ClickHouse insert execution failed after " . ($attempt + 1) . " attempt(s): {$cleanMessage}"; - $errorMsg = $this->buildErrorMessage($baseError, $table, "INSERT INTO {$table} ({$rowCount} rows)"); + $errorMsg = $this->buildErrorMessage($baseError, null, $sql . " ({$rowCount} rows)"); return new Exception($errorMsg, 0, $e); } ); } /** - * Format a parameter value for safe transmission to ClickHouse. - * - * @param mixed $value - * @return string + * Format a parameter value for the ClickHouse HTTP `param_=...` + * transport. The builder hands us values verbatim; this layer only + * stringifies them for HTTP form encoding. */ private function formatParamValue(mixed $value): string { @@ -949,13 +752,126 @@ private function formatParamValue(mixed $value): string } /** - * Setup ClickHouse table structure. + * Format a value destined for a `DateTime64(3)` parameter slot. + * + * @throws Exception + */ + private function formatDateTime(DateTime|string|null $dateTime): string + { + if ($dateTime === null) { + return (new DateTime())->format('Y-m-d H:i:s.v'); + } + + if ($dateTime instanceof DateTime) { + return $dateTime->format('Y-m-d H:i:s.v'); + } + + try { + $dt = new DateTime($dateTime); + return $dt->format('Y-m-d H:i:s.v'); + } catch (\Exception $e) { + throw new Exception("Invalid datetime string: {$dateTime}"); + } + } + + /** + * Per-type column type map for builder typed bindings. + * + * @return array + */ + private function getParamTypeMap(string $type): array + { + $map = self::COMMON_PARAM_TYPES; + + if ($type === Usage::TYPE_GAUGE) { + foreach (['path', 'method', 'status', 'resource', 'resourceId', 'country', 'userAgent'] as $col) { + unset($map[$col]); + } + } + + return $map; + } + + private function newBuilder(string $type = Usage::TYPE_EVENT): ClickHouseBuilder + { + $builder = new ClickHouseBuilder(); + $builder->useNamedBindings()->withParamTypes($this->getParamTypeMap($type)); + + return $builder; + } + + private function newSchema(): ClickHouseSchema + { + return new ClickHouseSchema(); + } + + /** + * Walk an array of Query objects and rewrite `time` values into ClickHouse + * wire format (`Y-m-d H:i:s.v`). The builder forwards values verbatim, so + * datetime normalisation must happen up front before the values reach the + * `{paramN:DateTime64(3)}` placeholder slot. + * + * @param array $queries + * @return array + * + * @throws Exception + */ + private function normalizeTimeValues(array $queries): array + { + $normalized = []; + foreach ($queries as $query) { + if ($query->getAttribute() !== 'time' || !$query->getMethod()->isFilter()) { + $normalized[] = $query; + continue; + } + + $values = $query->getValues(); + $rewritten = []; + foreach ($values as $value) { + if ($value === null) { + $rewritten[] = null; + continue; + } + if ($value instanceof DateTime || is_string($value)) { + $rewritten[] = $this->formatDateTime($value); + continue; + } + $rewritten[] = $value; + } + + $clone = clone $query; + $clone->setValues($rewritten); + $normalized[] = $clone; + } + + return $normalized; + } + + /** + * @param array $queries * - * Creates: - * 1. Events table (MergeTree) with event-specific columns + * @throws Exception + */ + private function enforceValueRequirements(array $queries): void + { + foreach ($queries as $query) { + $method = $query->getMethod(); + if (!\in_array($method, self::VALUE_REQUIRED_METHODS, true)) { + continue; + } + + if (empty($query->getValues())) { + throw new Exception(\ucfirst($method->value) . ' queries require at least one value.'); + } + } + } + + /** + * Setup ClickHouse table structure using the schema layer: + * 1. Events table (MergeTree) with event-specific columns + bloom filter indexes * 2. Events daily table (SummingMergeTree) for pre-aggregation * 3. Events daily materialized view - * 4. Gauges table (MergeTree) with simple schema + * 4. Gauges table (MergeTree) * * @throws Exception */ @@ -963,189 +879,198 @@ public function setup(): void { $this->setOperationContext('setup()'); - // Create database if not exists $escapedDatabase = $this->escapeIdentifier($this->database); - $createDbSql = "CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"; - $this->query($createDbSql); - - // --- Events table --- - $this->createTable( - $this->getEventsTableName(), - 'event', - $this->getEventIndexes() - ); + $this->query("CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"); + + $this->createUsageTable($this->getEventsTableName(), Usage::TYPE_EVENT, $this->getEventIndexes()); - // --- Events daily table (SummingMergeTree) --- $this->createDailyTable(); - // --- Events daily materialized view --- $this->createDailyMaterializedView(); - // --- Gauges table --- - $this->createTable( - $this->getGaugesTableName(), - 'gauge', - $this->getGaugeIndexes() - ); + $this->createUsageTable($this->getGaugesTableName(), Usage::TYPE_GAUGE, $this->getGaugeIndexes()); } /** - * Create a MergeTree table for the given type. - * - * @param string $tableName - * @param string $type 'event' or 'gauge' * @param array> $indexes + * * @throws Exception */ - private function createTable(string $tableName, string $type, array $indexes): void + private function createUsageTable(string $tableName, string $type, array $indexes): void { - $columns = ['id String']; + $table = $this->newSchema()->table($tableName); + + $table->string('id')->primary(); foreach ($this->getAttributes($type) as $attribute) { /** @var string $id */ $id = $attribute['$id']; - - if ($id === 'time') { - $columns[] = 'time DateTime64(3)'; - } else { - $columns[] = $this->getColumnDefinition($id, $type); - } + $this->declareColumn($table, $id, $type); } - // Add tenant column only if tables are shared across tenants if ($this->sharedTables) { - $columns[] = 'tenant Nullable(String)'; + $table->string('tenant')->nullable(); } - // Build indexes - $indexDefs = []; foreach ($indexes as $index) { /** @var string $indexName */ $indexName = $index['$id']; /** @var array $attributes */ $attributes = $index['attributes']; - $escapedIndexName = $this->escapeIdentifier($indexName); - $escapedAttributes = array_map(fn ($attr) => $this->escapeIdentifier($attr), $attributes); - $attributeList = implode(', ', $escapedAttributes); - $indexDefs[] = "INDEX {$escapedIndexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1"; + $table->index( + $attributes, + str_replace('-', '_', $indexName), + '', + '', + [], + [], + [], + IndexAlgorithm::BloomFilter, + [], + 1, + ); + } + + $table->engine(Engine::MergeTree) + ->partitionBy('toYYYYMM(time)') + ->orderBy($this->sharedTables ? ['tenant', 'metric', 'time', 'id'] : ['metric', 'time', 'id']) + ->settings(['index_granularity' => 8192, 'allow_nullable_key' => 1]); + + $statement = $table->createIfNotExists(); + + $this->query($this->qualifyDdl($statement->query, $tableName)); + } + + /** + * Declare a column on the table via typed schema API, mapping the + * Metric attribute schema to dialect-specific column kinds. + * + * @throws Exception + */ + private function declareColumn(ClickHouseTable $table, string $id, string $type): void + { + $attribute = $this->getAttribute($id, $type); + if ($attribute === null) { + throw new Exception("Attribute {$id} not found in {$type} schema"); } - $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $attributeType = is_string($attribute['type'] ?? null) ? $attribute['type'] : 'string'; + $required = (bool) ($attribute['required'] ?? false); - $columnDefs = implode(",\n ", $columns); - $indexDefsStr = !empty($indexDefs) ? ",\n " . implode(",\n ", $indexDefs) : ''; + if ($id === 'time') { + $column = $table->datetime($id, 3); + if (!$required) { + $column->nullable(); + } + return; + } - // Primary key matches the most common filter pattern: - // tenant (multi-tenant isolation) → metric (per-metric series) → - // time (range scans). id is the tiebreaker for stable physical - // ordering. This shape lets ClickHouse skip whole granules on - // metric+time predicates instead of doing a full-table scan. - $orderByExpr = $this->sharedTables ? '(tenant, metric, time, id)' : '(metric, time, id)'; + if ($id === 'country') { + $column = $table->string($id); + if (!$required) { + $column->nullable(); + } + return; + } - $createTableSql = " - CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} ( - {$columnDefs}{$indexDefsStr} - ) - ENGINE = MergeTree() - ORDER BY {$orderByExpr} - PARTITION BY toYYYYMM(time) - SETTINGS index_granularity = 8192, allow_nullable_key = 1 - "; + $column = match ($attributeType) { + 'integer' => $table->addColumn($id, ColumnType::BigInteger), + 'float' => $table->addColumn($id, ColumnType::Float), + 'boolean' => $table->addColumn($id, ColumnType::Boolean), + 'datetime' => $table->datetime($id, 3), + default => $table->string($id), + }; - $this->query($createTableSql); + if (!$required) { + $column->nullable(); + } } /** - * Create the events daily SummingMergeTree table. - * - * Minimal schema: metric, value, time, tenant. - * Resource-level breakdown uses the raw events table. + * Create the events daily SummingMergeTree table via the schema layer. * * @throws Exception */ private function createDailyTable(): void { $dailyTableName = $this->getEventsDailyTableName(); - $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName); - $columns = [ - 'metric String', - 'value Int64', - 'time DateTime64(3)', - ]; + $table = $this->newSchema()->table($dailyTableName); + + $table->string('metric'); + $table->addColumn('value', ColumnType::BigInteger); + $table->datetime('time', 3); if ($this->sharedTables) { - $columns[] = 'tenant Nullable(String)'; + $table->string('tenant')->nullable(); } - $columnDefs = implode(",\n ", $columns); - - // metric and time are part of the ORDER BY (primary key) — no - // secondary bloom_filter indexes needed. - $dailyOrderBy = $this->sharedTables ? '(tenant, metric, time)' : '(metric, time)'; + $table->engine(Engine::SummingMergeTree, 'value') + ->partitionBy('toYYYYMM(time)') + ->orderBy($this->sharedTables ? ['tenant', 'metric', 'time'] : ['metric', 'time']) + ->settings(['index_granularity' => 8192, 'allow_nullable_key' => 1]); - $createDailyTableSql = " - CREATE TABLE IF NOT EXISTS {$escapedDailyTable} ( - {$columnDefs} - ) - ENGINE = SummingMergeTree() - ORDER BY {$dailyOrderBy} - PARTITION BY toYYYYMM(time) - SETTINGS index_granularity = 8192, allow_nullable_key = 1 - "; + $statement = $table->createIfNotExists(); - $this->query($createDailyTableSql); + $this->query($this->qualifyDdl($statement->query, $dailyTableName)); } /** - * Create the materialized view for daily event aggregation. + * Materialised view that buckets raw events into per-day SummingMergeTree + * rows. Emitted via Schema\ClickHouse::createMaterializedView. The body + * is a hand-written SELECT - the MV body needs an inner aggregation + * subquery which the builder does not yet round-trip cleanly (deferred + * upstream). * * @throws Exception */ - // NOTE: setup() uses CREATE IF NOT EXISTS for idempotency. If sharedTables - // is toggled between calls, the original MV definition is kept (DROP+CREATE - // would lose buffered data). This is acceptable for v1 since setup() is - // expected to run once per environment lifecycle. private function createDailyMaterializedView(): void { - $eventsTable = $this->getEventsTableName(); - $dailyTableName = $this->getEventsDailyTableName(); - $dailyMvName = $this->getTableName() . '_events_daily_mv'; - - $escapedEventsTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($eventsTable); - $escapedDailyTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyTableName); - $escapedDailyMv = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($dailyMvName); + $eventsTable = $this->buildTableReference($this->getEventsTableName()); if ($this->sharedTables) { - $innerSelect = "metric, tenant, sum(value) as value, toStartOfDay(time) as d"; - $innerGroupBy = "metric, tenant, d"; - $outerSelect = "metric, value, d as time, tenant"; + $body = "SELECT metric, value, d as time, tenant" + . " FROM (" + . " SELECT metric, tenant, sum(value) as value, toStartOfDay(time) as d" + . " FROM {$eventsTable}" + . " GROUP BY metric, tenant, d" + . " )"; } else { - $innerSelect = "metric, sum(value) as value, toStartOfDay(time) as d"; - $innerGroupBy = "metric, d"; - $outerSelect = "metric, value, d as time"; - } + $body = "SELECT metric, value, d as time" + . " FROM (" + . " SELECT metric, sum(value) as value, toStartOfDay(time) as d" + . " FROM {$eventsTable}" + . " GROUP BY metric, d" + . " )"; + } + + $statement = $this->newSchema()->createMaterializedView( + $this->getEventsDailyMvName(), + $this->getEventsDailyTableName(), + $body, + true, + ); + + $this->query($this->qualifyDdl($statement->query, $this->getEventsDailyMvName(), $this->getEventsDailyTableName())); + } - $createDailyMvSql = " - CREATE MATERIALIZED VIEW IF NOT EXISTS {$escapedDailyMv} - TO {$escapedDailyTable} - AS SELECT {$outerSelect} - FROM ( - SELECT {$innerSelect} - FROM {$escapedEventsTable} - GROUP BY {$innerGroupBy} - ) - "; + /** + * The schema layer emits bare table identifiers (`\`name\``). The + * runtime adapter operates against a specific database, so DDL must be + * rewritten with the qualified `\`db\`.\`name\`` form. + */ + private function qualifyDdl(string $sql, string ...$tables): string + { + foreach ($tables as $table) { + $bare = $this->escapeIdentifier($table); + $qualified = $this->buildTableReference($table); + $sql = str_replace($bare, $qualified, $sql); + } - $this->query($createDailyMvSql); + return $sql; } /** - * Validate that an attribute name exists in the schema for a given type. - * - * @param string $attributeName - * @param string $type 'event' or 'gauge' - * @return bool * @throws Exception */ private function validateAttributeName(string $attributeName, string $type = 'event'): bool @@ -1164,23 +1089,12 @@ private function validateAttributeName(string $attributeName, string $type = 'ev } } - // Reject attributes that don't exist on the target type's schema. - // Falling back to the other type's columns (e.g. allowing `path` on - // a gauge query because it exists on the event schema) compiles to - // SQL that references columns the gauge table doesn't have, which - // ClickHouse rejects with "Unknown identifier". throw new Exception("Invalid attribute name: {$attributeName}"); } - /** - * Columns available in the events daily (pre-aggregated) table. - */ private const DAILY_COLUMNS = ['metric', 'value', 'time']; /** - * Validate that a query attribute exists in the daily table schema. - * The daily table only has metric, value, time (+ tenant if shared). - * * @throws Exception */ private function validateDailyAttributeName(string $attributeName): bool @@ -1204,41 +1118,6 @@ private function validateDailyAttributeName(string $attributeName): bool } /** - * Format datetime for ClickHouse compatibility. - * - * @param \DateTime|string|null $dateTime - * @return string - * @throws Exception - */ - private function formatDateTime($dateTime): string - { - if ($dateTime === null) { - return (new \DateTime())->format('Y-m-d H:i:s.v'); - } - - if ($dateTime instanceof \DateTime) { - return $dateTime->format('Y-m-d H:i:s.v'); - } - - if (is_string($dateTime)) { - try { - $dt = new \DateTime($dateTime); - return $dt->format('Y-m-d H:i:s.v'); - } catch (\Exception $e) { - throw new Exception("Invalid datetime string: {$dateTime}"); - } - } - - /** @phpstan-ignore-next-line */ - throw new Exception("Invalid datetime value type: " . gettype($dateTime)); - } - - /** - * Get ClickHouse type for an attribute. - * - * @param string $id Attribute identifier - * @param string $type 'event' or 'gauge' - * @return string ClickHouse type * @throws Exception */ private function getColumnType(string $id, string $type = 'event'): string @@ -1248,7 +1127,6 @@ private function getColumnType(string $id, string $type = 'event'): string throw new Exception("Attribute {$id} not found in {$type} schema"); } - // Country uses LowCardinality for efficient storage of low-cardinality values if ($id === 'country') { return 'LowCardinality(Nullable(String))'; } @@ -1273,13 +1151,8 @@ protected function getColumnDefinition(string $id, string $type = 'event'): stri } /** - * Validate metric data for batch operations. + * @param array $tags * - * @param string $metric Metric name - * @param int $value Metric value - * @param string $type Metric type ('event' or 'gauge') - * @param array $tags Tags - * @param int|null $metricIndex Index for batch error messages * @throws Exception */ private function validateMetricData(string $metric, int $value, string $type, array $tags, ?int $metricIndex = null): void @@ -1299,7 +1172,7 @@ private function validateMetricData(string $metric, int $value, string $type, ar } if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) { - throw new \InvalidArgumentException($prefix . "Invalid type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); + throw new InvalidArgumentException($prefix . "Invalid type '{$type}'. Allowed: " . Usage::TYPE_EVENT . ', ' . Usage::TYPE_GAUGE); } if (!is_array($tags)) { @@ -1308,10 +1181,8 @@ private function validateMetricData(string $metric, int $value, string $type, ar } /** - * Validate all metrics in a batch before processing. - * * @param array> $metrics - * @param string $type The target table type + * * @throws Exception */ private function validateMetricsBatch(array $metrics, string $type): void @@ -1349,15 +1220,10 @@ private function validateMetricsBatch(array $metrics, string $type): void } /** - * Add metrics in batch (raw append to appropriate table). - * - * For events: extracts path/method/status/resource/resourceId from tags into - * dedicated columns; remaining tags stay in the tags JSON column. - * For gauges: simple metric/value/time/tags insert. + * Append metrics in batches via `INSERT INTO ... FORMAT JSONEachRow`. * * @param array> $metrics - * @param string $type Metric type: 'event' or 'gauge' - * @param int $batchSize Maximum number of metrics per INSERT statement + * * @throws Exception */ public function addBatch(array $metrics, string $type, int $batchSize = self::INSERT_BATCH_SIZE): bool @@ -1368,12 +1234,23 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN $this->setOperationContext('addBatch()'); - // Validate all metrics before processing $this->validateMetricsBatch($metrics, $type); $batchSize = \min(self::INSERT_BATCH_SIZE, \max(1, $batchSize)); $tableName = $this->getTableForType($type); + $columns = $this->getInsertColumns($type); + + $statement = $this->newBuilder($type) + ->into($tableName) + ->insertFormat('JSONEachRow', $columns) + ->insert(); + + if (!$statement instanceof FormattedInsertStatement) { + throw new Exception('Expected FormattedInsertStatement from builder insertFormat()'); + } + + $sql = $this->qualifyDdl($statement->query, $tableName); foreach (\array_chunk($metrics, $batchSize) as $metricsBatch) { $rows = []; @@ -1389,7 +1266,6 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN $tenant = $this->sharedTables ? $this->resolveTenantFromMetric($metricData) : null; if ($type === Usage::TYPE_EVENT) { - // Extract event-specific columns from tags into dedicated columns $eventColumns = []; foreach (Metric::EVENT_COLUMNS as $col) { if (isset($tags[$col])) { @@ -1412,7 +1288,6 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN 'tags' => $tags, ]); } else { - // Gauge: simple schema ksort($tags); $row = [ @@ -1435,16 +1310,38 @@ public function addBatch(array $metrics, string $type, int $batchSize = self::IN $rows[] = $encoded; } - $this->insert($tableName, $rows); + $this->insert($sql, $rows); } return true; } /** - * Resolve tenant for a single metric entry. + * Columns emitted in the JSONEachRow payload for the given type. * - * @param array $metricData + * @return list + */ + private function getInsertColumns(string $type): array + { + $columns = ['id', 'metric', 'value', 'time']; + + if ($type === Usage::TYPE_EVENT) { + foreach (Metric::EVENT_COLUMNS as $col) { + $columns[] = $col; + } + } + + $columns[] = 'tags'; + + if ($this->sharedTables) { + $columns[] = 'tenant'; + } + + return $columns; + } + + /** + * @param array $metricData */ private function resolveTenantFromMetric(array $metricData): ?string { @@ -1466,12 +1363,9 @@ private function resolveTenantFromMetric(array $metricData): ?string } /** - * Find metrics using Query objects. - * When $type is null, queries both tables with UNION ALL. - * - * @param array $queries - * @param string|null $type 'event', 'gauge', or null (both) + * @param array $queries * @return array + * * @throws Exception */ public function find(array $queries = [], ?string $type = null): array @@ -1482,15 +1376,13 @@ public function find(array $queries = [], ?string $type = null): array return $this->findFromTable($queries, $type); } - // Cursor pagination is per-table — paginating across both events and - // gauges has no coherent ordering, so reject this combination upfront. $userLimit = null; foreach ($queries as $query) { $method = $query->getMethod(); - if ($method === Query::TYPE_CURSOR_AFTER || $method === Query::TYPE_CURSOR_BEFORE) { + if ($method === Method::CursorAfter || $method === Method::CursorBefore) { throw new Exception('Cursor pagination requires an explicit $type (event or gauge)'); } - if ($method === Query::TYPE_LIMIT) { + if ($method === Method::Limit) { $values = $query->getValues(); if (!empty($values) && is_numeric($values[0])) { $userLimit = (int) $values[0]; @@ -1498,11 +1390,6 @@ public function find(array $queries = [], ?string $type = null): array } } - // Query both tables and merge. Each side already applied LIMIT, so - // without a final cap callers asking for `limit(N)` could receive - // up to 2N rows. Slice the merged result back down to the user's - // requested limit. Tables whose schema doesn't support every filter - // attribute (e.g. `path` on a gauge query) are skipped. $events = $this->queriesMatchType($queries, Usage::TYPE_EVENT) ? $this->findFromTable($queries, Usage::TYPE_EVENT) : []; @@ -1520,11 +1407,6 @@ public function find(array $queries = [], ?string $type = null): array } /** - * Check whether every filter attribute in $queries exists on the schema - * for the given type. Used by the null-$type code paths in find/count/sum - * so a query with event-only attributes (path/method/status/etc.) silently - * skips the gauges table instead of throwing "Invalid attribute name". - * * @param array $queries */ private function queriesMatchType(array $queries, string $type): bool @@ -1552,75 +1434,73 @@ private function queriesMatchType(array $queries, string $type): bool } /** - * Find metrics from a specific table. - * - * When a `groupByInterval` query is present, switches to aggregated mode: - * - Events: SELECT metric, SUM(value) as value, toStartOfInterval(time, INTERVAL ...) as time - * - Gauges: SELECT metric, argMax(value, time) as value, toStartOfInterval(time, INTERVAL ...) as time - * Results are grouped by metric and time bucket, ordered by time ASC. - * * @param array $queries - * @param string $type 'event' or 'gauge' * @return array + * * @throws Exception */ private function findFromTable(array $queries, string $type): array { $tableName = $this->getTableForType($type); - $fromTable = $this->buildTableReference($tableName); - $parsed = $this->parseQueries($queries, $type); + $this->enforceValueRequirements($queries); - // Cursor pagination is incompatible with time-bucketed aggregation — - // aggregated rows have no stable identity to anchor a keyset cursor on. - if (isset($parsed['cursor']) && isset($parsed['groupByInterval'])) { - throw new Exception('Cursor pagination cannot be combined with groupByInterval'); - } + $cursorQuery = $this->extractCursorQuery($queries); + $hasTimeBucket = $this->hasTimeBucket($queries); - // Check if groupByInterval is requested - if (isset($parsed['groupByInterval'])) { - return $this->findAggregatedFromTable($parsed, $fromTable, $type); + if ($cursorQuery !== null && $hasTimeBucket) { + throw new Exception('Cursor pagination cannot be combined with groupByTimeBucket'); } - $selectColumns = $this->getSelectColumns($type); + $filteredQueries = $this->stripCursorQueries($queries); - $filters = $parsed['filters']; - $params = $parsed['params']; - $orderAttributes = $parsed['orderAttributes'] ?? []; - $cursorDirection = $parsed['cursorDirection'] ?? null; + $this->validateQueryAttributes($filteredQueries, $type); - if (isset($parsed['cursor'])) { - $resolvedOrder = $this->resolveCursorOrder($orderAttributes); - $cursorWhere = $this->buildCursorWhere($resolvedOrder, $parsed['cursor'], $cursorDirection ?? 'after', $params); - $filters[] = $cursorWhere['clause']; - $params = $cursorWhere['params']; - $orderAttributes = $resolvedOrder; + if ($hasTimeBucket) { + return $this->findAggregatedFromTable($filteredQueries, $tableName, $type); } - $whereData = $this->buildWhereClause($filters, $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $builder = $this->newBuilder($type) + ->from($tableName) + ->select($this->getSelectColumns($type)); - $orderClause = ''; - if (isset($parsed['cursor'])) { - // $orderAttributes is always non-empty here — resolveCursorOrder - // appends an `id` tiebreaker when no order is specified. - $orderSql = $this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before'); - $orderClause = ' ORDER BY ' . implode(', ', $orderSql); - } elseif (!empty($parsed['orderBy'])) { - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + $orderAttributes = $this->extractOrderAttributes($filteredQueries); + $queriesWithoutOrders = $this->stripOrderQueries($filteredQueries); + + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($queriesWithoutOrders)); + $cursorDirection = $cursorQuery !== null + ? ($cursorQuery->getMethod() === Method::CursorAfter ? 'after' : 'before') + : null; + $cursorValue = null; + if ($cursorQuery !== null) { + $rawCursor = $cursorQuery->getValues()[0] ?? null; + if ($rawCursor !== null) { + $cursorValue = $this->normalizeCursorRow($rawCursor); + } + } + + if ($cursorValue !== null) { + $orderAttributes = $this->resolveCursorOrder($orderAttributes); } - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + $extraBindings = []; + if ($cursorValue !== null) { + $extraBindings = $this->applyCursorWhere( + $builder, + $orderAttributes, + $cursorValue, + $cursorDirection ?? 'after', + ); + $this->applyOrderBy($builder, $orderAttributes, $cursorDirection === 'before'); + } else { + $this->applyOrderBy($builder, $orderAttributes, false); + } - $sql = " - SELECT {$selectColumns} - FROM {$fromTable}{$whereClause}{$orderClause}{$limitClause}{$offsetClause} - FORMAT JSON - "; + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - $result = $this->query($sql, $params); + $result = $this->query($sql, array_merge($statement->namedBindings ?? [], $extraBindings)); $rows = $this->parseResults($result, $type); @@ -1632,817 +1512,724 @@ private function findFromTable(array $queries, string $type): array } /** - * Find aggregated metrics from a table using time-bucketed grouping. - * - * Produces SQL like: - * SELECT metric, SUM(value) as value, - * toStartOfInterval(time, INTERVAL 1 HOUR) as time - * FROM table WHERE ... GROUP BY metric, time ORDER BY time ASC - * - * @param array{filters: array, params: array, orderBy?: array, limit?: int, offset?: int, groupByInterval?: string} $parsed Parsed query data from parseQueries() - * @param string $fromTable Fully qualified table reference - * @param string $type 'event' or 'gauge' + * @param array $queries * @return array + * * @throws Exception */ - private function findAggregatedFromTable(array $parsed, string $fromTable, string $type): array - { - /** @var string $interval */ - $interval = $parsed['groupByInterval'] ?? '1h'; - $intervalSql = UsageQuery::VALID_INTERVALS[$interval]; - - // Choose aggregation function based on metric type - $valueExpr = $type === Usage::TYPE_GAUGE - ? 'argMax(value, time) as value' - : 'SUM(value) as value'; - - // Use 'bucket' alias to avoid collision with the raw 'time' column, - // then alias back to 'time' in outer context for consistent Metric parsing. - $timeBucketExpr = "toStartOfInterval(time, {$intervalSql})"; - - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; - $params = $whereData['params']; - - // Use custom ORDER BY if specified, otherwise default to bucket ASC. - // In aggregated mode the SELECT exposes `bucket` instead of `time`, - // so any user-supplied ORDER BY on `time` must be rewritten to - // reference the bucket alias — otherwise ClickHouse errors with - // "Unknown identifier: time". - $orderClause = ' ORDER BY bucket ASC'; - if (!empty($parsed['orderBy'])) { - $rewrittenOrderBy = array_map( - fn (string $clause): string => preg_replace( - '/^`time`(\s+(?:ASC|DESC))?$/', - '`bucket`$1', - $clause - ) ?? $clause, - $parsed['orderBy'] - ); - $orderClause = ' ORDER BY ' . implode(', ', $rewrittenOrderBy); - } + private function findAggregatedFromTable(array $queries, string $tableName, string $type): array + { + $bucketInterval = $this->extractTimeBucketInterval($queries); + $bucketAttribute = $this->extractTimeBucketAttribute($queries); + $bucketFunction = $this->bucketFunctionFor($bucketInterval); + $valueAggregate = $type === Usage::TYPE_GAUGE + ? 'argMax(`value`, `time`) AS `value`' + : 'SUM(`value`) AS `value`'; - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + $bucketExpr = $bucketFunction . '(' . $this->escapeIdentifier($bucketAttribute) . ') AS `bucket`'; - $sql = " - SELECT metric, {$valueExpr}, {$timeBucketExpr} as bucket - FROM {$fromTable}{$whereClause} - GROUP BY metric, bucket{$orderClause}{$limitClause}{$offsetClause} - FORMAT JSON - "; + $builder = $this->newBuilder($type) + ->from($tableName) + ->select(['metric']) + ->selectRaw($valueAggregate) + ->selectRaw($bucketExpr); - $result = $this->query($sql, $params); + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($this->stripOrderQueries($queries))); - return $this->parseAggregatedResults($result, $type); - } + $builder->filter([Query::groupByTimeBucket($bucketAttribute, $bucketInterval)]); + $builder->groupByRaw('`metric`'); - /** - * Parse ClickHouse JSON results from an aggregated (groupByInterval) query into Metric array. - * - * Maps the 'bucket' column back to 'time' for consistent Metric objects. - * - * @param string $result Raw JSON response from ClickHouse - * @param string $type 'event' or 'gauge' - * @return array - */ - private function parseAggregatedResults(string $result, string $type = 'event'): array - { - if (empty(trim($result))) { - return []; + $userOrderApplied = $this->applyAggregatedOrderBy($builder, $queries); + if (!$userOrderApplied) { + $builder->orderByRaw('`bucket` ASC'); } - $json = json_decode($result, true); + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - if (!is_array($json) || !isset($json['data']) || !is_array($json['data'])) { - return []; - } + $result = $this->query($sql, $statement->namedBindings ?? []); - $rows = $json['data']; - $metrics = []; + return $this->parseAggregatedResults($result, $type); + } - foreach ($rows as $row) { - if (!is_array($row)) { - continue; + /** + * @param array $queries + */ + private function hasTimeBucket(array $queries): bool + { + foreach ($queries as $query) { + if ($query->getMethod() === Method::GroupByTimeBucket) { + return true; } + } + return false; + } - $document = []; - - foreach ($row as $key => $value) { - if ($key === 'bucket') { - // Map 'bucket' back to 'time' for consistent Metric objects - $parsedTime = (string) $value; - if (strpos($parsedTime, 'T') === false) { - $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00'; - } - $document['time'] = $parsedTime; - } elseif ($key === 'value') { - // Preserve numeric precision: SUM(value) over many rows - // can exceed PHP_INT_MAX, and gauge averages are floats. - // Casting to int truncates both cases — keep numeric - // strings as int|float depending on shape. - if ($value === null) { - $document[$key] = null; - } elseif (is_int($value) || is_float($value)) { - $document[$key] = $value; - } elseif (is_numeric($value)) { - $document[$key] = (str_contains((string) $value, '.') || str_contains((string) $value, 'e') || str_contains((string) $value, 'E')) - ? (float) $value - : (int) $value; - } else { - $document[$key] = $value; - } - } else { - $document[$key] = $value; - } + /** + * @param array $queries + */ + private function extractTimeBucketInterval(array $queries): string + { + foreach ($queries as $query) { + if ($query->getMethod() === Method::GroupByTimeBucket) { + /** @var string $interval */ + $interval = $query->getValues()[0] ?? '1h'; + return $interval; } - - // Set the type based on which table we queried - $document['type'] = $type; - - $metrics[] = new Metric($document); } - - return $metrics; + return '1h'; } /** - * Count metrics using Query objects. - * - * When $max is non-null the count is bounded at the database level via - * a `LIMIT {max}` inside a subquery — ClickHouse stops scanning once - * that many rows have been matched, keeping large counts cheap. - * * @param array $queries - * @param string|null $type 'event', 'gauge', or null (both) - * @param int|null $max Optional upper bound (inclusive) for the count - * @return int - * @throws Exception */ - public function count(array $queries = [], ?string $type = null, ?int $max = null): int + private function extractTimeBucketAttribute(array $queries): string { - $this->setOperationContext('count()'); - - if ($type !== null) { - return $this->countFromTable($queries, $type, $max); + foreach ($queries as $query) { + if ($query->getMethod() === Method::GroupByTimeBucket) { + return $query->getAttribute(); + } } + return 'time'; + } - // Count from both tables. Each per-table count is independently - // capped at $max, so naively summing them could yield up to 2*$max. - // Cap the combined total at $max in PHP to honour the contract. - // Skip a table when its schema can't satisfy every filter attribute. - $events = $this->queriesMatchType($queries, Usage::TYPE_EVENT) - ? $this->countFromTable($queries, Usage::TYPE_EVENT, $max) - : 0; - $gauges = $this->queriesMatchType($queries, Usage::TYPE_GAUGE) - ? $this->countFromTable($queries, Usage::TYPE_GAUGE, $max) - : 0; - - $total = $events + $gauges; + private function bucketFunctionFor(string $interval): string + { + return match ($interval) { + '1m' => 'toStartOfMinute', + '5m' => 'toStartOfFiveMinutes', + '15m' => 'toStartOfFifteenMinutes', + '1h' => 'toStartOfHour', + '1d' => 'toStartOfDay', + '1w' => 'toStartOfWeek', + '1M' => 'toStartOfMonth', + default => throw new Exception("Invalid groupByTimeBucket interval: {$interval}"), + }; + } - if ($max !== null && $total > $max) { - $total = $max; + /** + * @param array $queries + */ + private function extractCursorQuery(array $queries): ?Query + { + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method === Method::CursorAfter || $method === Method::CursorBefore) { + return $query; + } } + return null; + } - return $total; + /** + * @param array $queries + * @return array + */ + private function stripCursorQueries(array $queries): array + { + return \array_values(\array_filter( + $queries, + static fn (Query $q): bool => $q->getMethod() !== Method::CursorAfter + && $q->getMethod() !== Method::CursorBefore, + )); } /** - * Count metrics from a specific table. - * * @param array $queries - * @param string $type - * @param int|null $max Optional upper bound (inclusive) for the count - * @return int + * * @throws Exception */ - private function countFromTable(array $queries, string $type, ?int $max = null): int + private function validateQueryAttributes(array $queries, string $type): void { - $tableName = $this->getTableForType($type); - $fromTable = $this->buildTableReference($tableName); - - $parsed = $this->parseQueries($queries, $type); - - $params = $parsed['params']; - unset($params['limit'], $params['offset']); - - $whereData = $this->buildWhereClause($parsed['filters'], $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; - - if ($max !== null) { - $params['max'] = $max; - $sql = " - SELECT COUNT(*) as total FROM ( - SELECT 1 FROM {$fromTable}{$whereClause} LIMIT {max:UInt64} - ) sub - FORMAT JSON - "; - } else { - $sql = " - SELECT COUNT(*) as total FROM {$fromTable}{$whereClause} - FORMAT JSON - "; - } - - $result = $this->query($sql, $params); - $json = json_decode($result, true); - - if (!is_array($json) || !isset($json['data'][0]['total'])) { - return 0; + foreach ($queries as $query) { + $attribute = $query->getAttribute(); + if ($attribute === '') { + continue; + } + $this->validateAttributeName($attribute, $type); } - - return (int) $json['data'][0]['total']; } /** - * Sum metric values using Query objects. + * Push standard filter/order/limit/offset queries through the builder. * - * Events-only by default — summing gauges is semantically meaningless. + * Methods the builder consumes directly: + * - All filter methods (equal, lessThan, between, contains, startsWith, ...) + * - Order (OrderAsc, OrderDesc) + * - Limit / Offset + * - GroupByTimeBucket (its GROUP BY fragment compiles via the builder; the + * SELECT projection and ORDER BY are added by `findAggregatedFromTable`) * * @param array $queries - * @param string $attribute Attribute to sum (default: 'value') - * @param string $type 'event' or 'gauge' - * @return int - * @throws Exception */ - public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int + private function applyQueries(ClickHouseBuilder $builder, array $queries): void { - $this->setOperationContext('sum()'); + $filtered = \array_filter( + $queries, + static fn (Query $q): bool => $q->getMethod() !== Method::CursorAfter + && $q->getMethod() !== Method::CursorBefore + && $q->getMethod() !== Method::GroupByTimeBucket, + ); - return $this->sumFromTable($queries, $attribute, $type); + $builder->filter(\array_values($filtered)); } /** - * Sum metric values from a specific table. - * - * @param array $queries - * @param string $attribute - * @param string $type - * @return int - * @throws Exception + * Apply a tenant equality filter using the same typed binding pipeline as + * the rest of the WHERE chain. */ - private function sumFromTable(array $queries, string $attribute, string $type): int + private function applyTenantFilter(ClickHouseBuilder $builder): void { - $tableName = $this->getTableForType($type); - $fromTable = $this->buildTableReference($tableName); - - $this->validateAttributeName($attribute, $type); - $escapedAttribute = $this->escapeIdentifier($attribute); - - $parsed = $this->parseQueries($queries, $type); - - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; - $params = $whereData['params']; - - $sql = " - SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereClause} - FORMAT JSON - "; - - $result = $this->query($sql, $params); - - $json = json_decode($result, true); - - if (!is_array($json) || !isset($json['data'][0]['total'])) { - return 0; + if (!$this->sharedTables || $this->tenant === null) { + return; } - return (int) $json['data'][0]['total']; + $builder->filter([Query::equal('tenant', [$this->tenant])]); } /** - * Find event metrics from the pre-aggregated daily table. - * * @param array $queries - * @return array - * @throws Exception + * @return array */ - public function findDaily(array $queries = []): array + private function stripOrderQueries(array $queries): array { - $this->setOperationContext('findDaily()'); - - $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); + return \array_values(\array_filter( + $queries, + static fn (Query $q): bool => $q->getMethod() !== Method::OrderAsc + && $q->getMethod() !== Method::OrderDesc, + )); + } - // Validate query attributes against daily table schema (metric, value, time, tenant only) + /** + * @param array $queries + * @return array + */ + private function extractOrderAttributes(array $queries): array + { + $order = []; foreach ($queries as $query) { - $attr = $query->getAttribute(); - if (!empty($attr)) { - $this->validateDailyAttributeName($attr); + $method = $query->getMethod(); + if ($method === Method::OrderAsc) { + $order[] = ['attribute' => $query->getAttribute(), 'direction' => 'ASC']; + } elseif ($method === Method::OrderDesc) { + $order[] = ['attribute' => $query->getAttribute(), 'direction' => 'DESC']; } } - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); + return $order; + } - $dailyColumns = ['metric', 'value', 'time']; - if ($this->sharedTables) { - $dailyColumns[] = 'tenant'; + /** + * @param array $orderAttributes + */ + private function applyOrderBy(ClickHouseBuilder $builder, array $orderAttributes, bool $flip): void + { + if (empty($orderAttributes)) { + return; } - $selectColumns = implode(', ', array_map(fn ($c) => $this->escapeIdentifier($c), $dailyColumns)); - - $orderClause = !empty($parsed['orderBy']) ? ' ORDER BY ' . implode(', ', $parsed['orderBy']) : ''; - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; - // The daily table is SummingMergeTree. Reading raw rows returns - // un-merged duplicates until background merges run. FINAL forces - // merge-on-read so callers always see fully-collapsed values. - $sql = "SELECT {$selectColumns} FROM {$fromTable} FINAL{$whereData['clause']}{$orderClause}{$limitClause}{$offsetClause} FORMAT JSON"; + foreach ($orderAttributes as $entry) { + $direction = $entry['direction']; + if ($flip) { + $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; + } - return $this->parseResults($this->query($sql, $whereData['params']), Usage::TYPE_EVENT); + if ($direction === 'DESC') { + $builder->sortDesc($entry['attribute']); + } else { + $builder->sortAsc($entry['attribute']); + } + } } /** - * Sum event metric values from the pre-aggregated daily table. + * Rewrite a user-supplied ORDER BY on `time` to reference the `bucket` + * alias when consuming aggregated reads. Returns whether any order + * fragments were applied. * * @param array $queries - * @param string $attribute Attribute to sum (default: 'value') - * @return int - * @throws Exception */ - public function sumDaily(array $queries = [], string $attribute = 'value'): int + private function applyAggregatedOrderBy(ClickHouseBuilder $builder, array $queries): bool { - $this->setOperationContext('sumDaily()'); - - $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); - $this->validateDailyAttributeName($attribute); - $escapedAttribute = $this->escapeIdentifier($attribute); - + $applied = false; foreach ($queries as $query) { - $attr = $query->getAttribute(); - if (!empty($attr)) { - $this->validateDailyAttributeName($attr); + $method = $query->getMethod(); + if ($method !== Method::OrderAsc && $method !== Method::OrderDesc) { + continue; } - } - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $sql = "SELECT sum({$escapedAttribute}) as total FROM {$fromTable}{$whereData['clause']} FORMAT JSON"; - - $result = $this->query($sql, $whereData['params']); - $json = json_decode($result, true); + $direction = $method === Method::OrderDesc ? 'DESC' : 'ASC'; + $attribute = $query->getAttribute(); - return (is_array($json) && isset($json['data'][0]['total'])) ? (int) $json['data'][0]['total'] : 0; + if ($attribute === 'time') { + $builder->orderByRaw('`bucket` ' . $direction); + } else { + $builder->orderByRaw($this->escapeIdentifier($attribute) . ' ' . $direction); + } + $applied = true; + } + return $applied; } /** - * Sum multiple event metrics from the pre-aggregated daily table in one query. + * Compile a tuple-keyset cursor WHERE fragment and register it on the + * builder via `whereRaw`. Returns the named bindings to merge into the + * Statement at execute time. + * + * @param array $orderAttributes + * @param array $cursor + * @return array * - * @param array $metrics - * @param array $queries - * @return array * @throws Exception */ - public function sumDailyBatch(array $metrics, array $queries = []): array - { - if (empty($metrics)) { - return []; - } + private function applyCursorWhere( + ClickHouseBuilder $builder, + array $orderAttributes, + array $cursor, + string $cursorDirection, + ): array { + $params = []; + $tuples = []; - $this->setOperationContext('sumDailyBatch()'); + foreach ($orderAttributes as $i => $entry) { + $attr = $entry['attribute']; + $direction = $entry['direction']; - foreach ($queries as $query) { - $attr = $query->getAttribute(); - if (!empty($attr)) { - $this->validateDailyAttributeName($attr); + if (!array_key_exists($attr, $cursor)) { + throw new Exception("Cursor is missing required attribute '{$attr}'"); } - } - $totals = \array_fill_keys($metrics, 0); + if ($cursorDirection === 'before') { + $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; + } - $fromTable = $this->buildTableReference($this->getEventsDailyTableName()); + $conditions = []; - // Build metric IN params - $metricParams = []; - $metricPlaceholders = []; - foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; - $metricParams[$paramName] = $metric; - $metricPlaceholders[] = "{{$paramName}:String}"; - } - $metricInClause = implode(', ', $metricPlaceholders); + for ($j = 0; $j < $i; $j++) { + $prev = $orderAttributes[$j]; + $prevAttr = $prev['attribute']; + if (!array_key_exists($prevAttr, $cursor)) { + throw new Exception("Cursor is missing required attribute '{$prevAttr}'"); + } + $prevValue = $cursor[$prevAttr]; + if ($prevValue === null) { + throw new Exception("Cursor value for '{$prevAttr}' cannot be null"); + } + $prevEscaped = $this->escapeIdentifier($prevAttr); + $prevType = $this->resolveColumnType($prevAttr); + $paramName = "cursor_eq_{$i}_{$j}"; - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); - $params = array_merge($metricParams, $parsed['params']); + $conditions[] = "{$prevEscaped} = {{$paramName}:{$prevType}}"; + $params[$paramName] = $this->formatBoundValue($prevType, $prevValue); + } - $whereData = $this->buildWhereClause($parsed['filters'], $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $value = $cursor[$attr]; + if ($value === null) { + throw new Exception("Cursor value for '{$attr}' cannot be null"); + } + $escaped = $this->escapeIdentifier($attr); + $chType = $this->resolveColumnType($attr); + $operator = $direction === 'DESC' ? '<' : '>'; + $paramName = "cursor_cmp_{$i}"; - $metricFilter = $this->escapeIdentifier('metric') . " IN ({$metricInClause})"; - $whereClause = !empty($whereClause) - ? $whereClause . ' AND ' . $metricFilter - : ' WHERE ' . $metricFilter; + $conditions[] = "{$escaped} {$operator} {{$paramName}:{$chType}}"; + $params[$paramName] = $this->formatBoundValue($chType, $value); - $sql = " - SELECT metric, SUM(value) as total - FROM {$fromTable}{$whereClause} - GROUP BY metric - FORMAT JSON - "; + $tuples[] = '(' . implode(' AND ', $conditions) . ')'; + } - $result = $this->query($sql, $params); - $json = json_decode($result, true); + $builder->whereRaw('(' . implode(' OR ', $tuples) . ')'); - if (is_array($json) && isset($json['data']) && is_array($json['data'])) { - foreach ($json['data'] as $row) { - $metricName = $row['metric'] ?? ''; - if (isset($totals[$metricName])) { - $totals[$metricName] = (int) ($row['total'] ?? 0); - } + return $params; + } + + /** + * @param array $orderAttributes + * @return array + */ + private function resolveCursorOrder(array $orderAttributes): array + { + foreach ($orderAttributes as $entry) { + if ($entry['attribute'] === 'id') { + return $orderAttributes; } } - return $totals; + $defaultDirection = 'ASC'; + if (!empty($orderAttributes)) { + $last = $orderAttributes[count($orderAttributes) - 1]; + $defaultDirection = $last['direction']; + } + + $orderAttributes[] = ['attribute' => 'id', 'direction' => $defaultDirection]; + + return $orderAttributes; } /** - * Get time series data for metrics with query-time aggregation. - * - * Uses SUM for event metrics and argMax for gauge metrics. - * When $type is null, queries both tables and merges results. - * - * @param array $metrics - * @param string $interval '1h' or '1d' - * @param string $startDate - * @param string $endDate - * @param array $queries - * @param bool $zeroFill - * @param string|null $type 'event', 'gauge', or null (both) - * @return array}> * @throws Exception */ - public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array + private function formatBoundValue(string $chType, mixed $value): string { - if (empty($metrics)) { - return []; + if ($chType === 'DateTime64(3)') { + if ($value === null) { + throw new Exception('DateTime parameter value cannot be null'); + } + /** @var DateTime|string $value */ + return $this->formatDateTime($value); } - if (!isset(self::INTERVAL_FUNCTIONS[$interval])) { - throw new \InvalidArgumentException("Invalid interval '{$interval}'. Allowed: " . implode(', ', array_keys(self::INTERVAL_FUNCTIONS))); - } + return $this->formatParamValue($value); + } - $this->setOperationContext('getTimeSeries()'); + private function resolveColumnType(string $attribute): string + { + return self::COMMON_PARAM_TYPES[$attribute] ?? 'String'; + } - // Initialize result structure - $output = []; - foreach ($metrics as $metric) { - $output[$metric] = ['total' => 0, 'data' => []]; + /** + * @param mixed $rawCursor + * @return array + * + * @throws Exception + */ + private function normalizeCursorRow(mixed $rawCursor): array + { + if ($rawCursor instanceof ArrayObject) { + /** @var array $row */ + $row = $rawCursor->getArrayCopy(); + } elseif (is_array($rawCursor)) { + /** @var array $rawCursor */ + $row = $rawCursor; + } else { + throw new Exception( + 'Invalid cursor value: expected ArrayObject (Metric) or associative array, got ' + . get_debug_type($rawCursor) + ); } - $typesToQuery = []; - if ($type === Usage::TYPE_EVENT || $type === null) { - $typesToQuery[] = Usage::TYPE_EVENT; - } - if ($type === Usage::TYPE_GAUGE || $type === null) { - $typesToQuery[] = Usage::TYPE_GAUGE; + if (!array_key_exists('id', $row) && array_key_exists('$id', $row)) { + $row['id'] = $row['$id']; + unset($row['$id']); } - foreach ($typesToQuery as $queryType) { - // Skip a table when its schema can't satisfy every filter attribute - // (e.g. `path` on a gauge query); avoids "Invalid attribute name" - // when the caller leaves $type null and only one side is applicable. - if (!$this->queriesMatchType($queries, $queryType)) { - continue; - } - - $typeResult = $this->getTimeSeriesFromTable($metrics, $interval, $startDate, $endDate, $queries, $queryType); + return $row; + } - // Merge results - foreach ($typeResult as $metricName => $metricData) { - if (!isset($output[$metricName])) { - continue; - } + /** + * @param array $queries + * + * @throws Exception + */ + public function count(array $queries = [], ?string $type = null, ?int $max = null): int + { + $this->setOperationContext('count()'); - $output[$metricName]['total'] += $metricData['total']; - $output[$metricName]['data'] = array_merge( - $output[$metricName]['data'], - $metricData['data'] - ); - } + if ($type !== null) { + return $this->countFromTable($queries, $type, $max); } - // Zero-fill gaps if requested - if ($zeroFill) { - foreach ($output as $metricName => &$metricData) { - $metricData['data'] = $this->zeroFillTimeSeries( - $metricData['data'], - $interval, - $startDate, - $endDate - ); - } - unset($metricData); + $events = $this->queriesMatchType($queries, Usage::TYPE_EVENT) + ? $this->countFromTable($queries, Usage::TYPE_EVENT, $max) + : 0; + $gauges = $this->queriesMatchType($queries, Usage::TYPE_GAUGE) + ? $this->countFromTable($queries, Usage::TYPE_GAUGE, $max) + : 0; + + $total = $events + $gauges; + + if ($max !== null && $total > $max) { + $total = $max; } - return $output; + return $total; } /** - * Get time series data from a specific table. + * @param array $queries * - * @param array $metrics - * @param string $interval - * @param string $startDate - * @param string $endDate - * @param array $queries - * @param string $type - * @return array}> * @throws Exception */ - private function getTimeSeriesFromTable(array $metrics, string $interval, string $startDate, string $endDate, array $queries, string $type): array + private function countFromTable(array $queries, string $type, ?int $max = null): int { - $timeFunction = self::INTERVAL_FUNCTIONS[$interval]; $tableName = $this->getTableForType($type); - $fromTable = $this->buildTableReference($tableName); - // Build metric IN params - $metricParams = []; - $metricPlaceholders = []; - foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; - $metricParams[$paramName] = $metric; - $metricPlaceholders[] = "{{$paramName}:String}"; - } + $this->enforceValueRequirements($queries); - $metricInClause = implode(', ', $metricPlaceholders); + $filtered = $this->stripCursorQueries($queries); + $this->validateQueryAttributes($filtered, $type); - // Build additional WHERE conditions from queries - $parsed = $this->parseQueries($queries, $type); - $additionalFilters = $parsed['filters']; - $params = array_merge($metricParams, $parsed['params']); + // The builder consumes limit/offset internally; both are no-ops for + // COUNT(*), so drop them before passing the query list along. + $filtered = \array_values(\array_filter( + $filtered, + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset, + )); - $params['start_date'] = $this->formatDateTime($startDate); - $params['end_date'] = $this->formatDateTime($endDate); + if ($max !== null) { + $innerBuilder = $this->newBuilder($type) + ->from($tableName) + ->selectRaw('1') + ->limit($max); - // Build tenant filter - $tenantFilter = ''; - if ($this->sharedTables && $this->tenant !== null) { - $tenantFilter = ' AND tenant = {tenant:Nullable(String)}'; - $params['tenant'] = $this->tenant; - } + $this->applyTenantFilter($innerBuilder); + $this->applyQueries($innerBuilder, $this->normalizeTimeValues($filtered)); - $additionalWhere = ''; - if (!empty($additionalFilters)) { - $additionalWhere = ' AND ' . implode(' AND ', $additionalFilters); - } + $innerStatement = $innerBuilder->build(); + $innerSql = $this->qualifyDdl($innerStatement->query, $tableName); + $sql = "SELECT COUNT(*) as total FROM ({$innerSql}) sub FORMAT JSON"; - // Use appropriate aggregation based on type - if ($type === Usage::TYPE_EVENT) { - $valueExpr = 'SUM(value) as agg_value'; + $result = $this->query($sql, $innerStatement->namedBindings ?? []); } else { - $valueExpr = 'argMax(value, time) as agg_value'; - } - - $sql = " - SELECT - metric, - {$timeFunction}(time) as bucket, - {$valueExpr} - FROM {$fromTable} - WHERE metric IN ({$metricInClause}) - AND time BETWEEN {start_date:DateTime64(3)} AND {end_date:DateTime64(3)} - {$tenantFilter}{$additionalWhere} - GROUP BY metric, bucket - ORDER BY bucket ASC - FORMAT JSON - "; - - $result = $this->query($sql, $params); - $json = json_decode($result, true); + $builder = $this->newBuilder($type) + ->from($tableName) + ->count('*', 'total'); - // Initialize result structure - $output = []; - foreach ($metrics as $metric) { - $output[$metric] = ['total' => 0, 'data' => []]; - } + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); - if (is_array($json) && isset($json['data']) && is_array($json['data'])) { - foreach ($json['data'] as $row) { - $metricName = $row['metric'] ?? ''; - $bucketTime = (string) ($row['bucket'] ?? ''); - $value = (float) ($row['agg_value'] ?? 0); + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - if (!isset($output[$metricName])) { - continue; - } + $result = $this->query($sql, $statement->namedBindings ?? []); + } - // Format bucket time - $formattedDate = $bucketTime; - if (strpos($bucketTime, 'T') === false) { - $formattedDate = str_replace(' ', 'T', $bucketTime) . '+00:00'; - } + $json = json_decode($result, true); - $output[$metricName]['total'] += $value; - $output[$metricName]['data'][] = [ - 'value' => $value, - 'date' => $formattedDate, - ]; - } + if (!is_array($json) || !isset($json['data'][0]['total'])) { + return 0; } - return $output; + return (int) $json['data'][0]['total']; } /** - * Fill gaps in time series data with zero-value entries. + * @param array $queries * - * @param array $data Existing data points - * @param string $interval '1h' or '1d' - * @param string $startDate Start datetime - * @param string $endDate End datetime - * @return array + * @throws Exception */ - private function zeroFillTimeSeries(array $data, string $interval, string $startDate, string $endDate): array + public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int { - $format = $interval === '1h' ? 'Y-m-d\TH:00:00+00:00' : 'Y-m-d\T00:00:00+00:00'; - $step = $interval === '1h' ? '+1 hour' : '+1 day'; + $this->setOperationContext('sum()'); - // Build lookup of existing data points by formatted date - $existing = []; - foreach ($data as $point) { - $dt = new \DateTime($point['date']); - $key = $dt->format($format); - // If multiple points in the same bucket, sum them - $existing[$key] = ($existing[$key] ?? 0) + $point['value']; - } + return $this->sumFromTable($queries, $attribute, $type); + } - // Generate all time buckets in range - $start = new \DateTime($startDate); - $end = new \DateTime($endDate); + /** + * @param array $queries + * + * @throws Exception + */ + private function sumFromTable(array $queries, string $attribute, string $type): int + { + $tableName = $this->getTableForType($type); - $result = []; - $current = clone $start; + $this->validateAttributeName($attribute, $type); + $this->enforceValueRequirements($queries); - while ($current <= $end) { - $key = $current->format($format); - $result[] = [ - 'value' => $existing[$key] ?? 0, - 'date' => $key, - ]; - $current->modify($step); + $filtered = $this->stripCursorQueries($queries); + $this->validateQueryAttributes($filtered, $type); + + $filtered = \array_values(\array_filter( + $filtered, + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset, + )); + + $builder = $this->newBuilder($type) + ->from($tableName) + ->sum($attribute, 'total'); + + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); + $json = json_decode($result, true); + + if (!is_array($json) || !isset($json['data'][0]['total'])) { + return 0; } - return $result; + return (int) $json['data'][0]['total']; } /** - * Get total value for a single metric. - * - * Returns sum for event metrics, latest value for gauge metrics. - * When $type is null, queries both tables. + * @param array $queries + * @return array * - * @param string $metric - * @param array $queries - * @param string|null $type 'event', 'gauge', or null (both) - * @return int * @throws Exception */ - public function getTotal(string $metric, array $queries = [], ?string $type = null): int + public function findDaily(array $queries = []): array { - $this->setOperationContext('getTotal()'); + $this->setOperationContext('findDaily()'); - if ($type === Usage::TYPE_EVENT) { - return $this->getTotalFromEvents($metric, $queries); + $tableName = $this->getEventsDailyTableName(); + + $this->enforceValueRequirements($queries); + + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (!empty($attr)) { + $this->validateDailyAttributeName($attr); + } } - if ($type === Usage::TYPE_GAUGE) { - return $this->getTotalFromGauges($metric, $queries); + $dailyColumns = ['metric', 'value', 'time']; + if ($this->sharedTables) { + $dailyColumns[] = 'tenant'; } - // Query both tables — event uses SUM, gauge uses argMax - $eventTotal = $this->getTotalFromEvents($metric, $queries); - $gaugeTotal = $this->getTotalFromGauges($metric, $queries); + $filtered = $this->stripCursorQueries($queries); - if ($eventTotal > 0 && $gaugeTotal > 0) { - throw new Exception( - "Metric '{$metric}' exists in both event and gauge tables. " - . "Specify \$type explicitly to avoid ambiguous aggregation." - ); - } + $builder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($tableName) + ->final() + ->select($dailyColumns); - return $eventTotal > 0 ? $eventTotal : $gaugeTotal; + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); + + $orderAttributes = $this->extractOrderAttributes($filtered); + $this->applyOrderBy($builder, $orderAttributes, false); + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + return $this->parseResults($this->query($sql, $statement->namedBindings ?? []), Usage::TYPE_EVENT); } /** - * Get total from events table (SUM). - * - * @param string $metric * @param array $queries - * @return int + * * @throws Exception */ - private function getTotalFromEvents(string $metric, array $queries): int + public function sumDaily(array $queries = [], string $attribute = 'value'): int { - $tableName = $this->getEventsTableName(); - $fromTable = $this->buildTableReference($tableName); - - $parsed = $this->parseQueries($queries, Usage::TYPE_EVENT); - $params = $parsed['params']; - $params['metric_name'] = $metric; + $this->setOperationContext('sumDaily()'); - $whereData = $this->buildWhereClause($parsed['filters'], $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $this->validateDailyAttributeName($attribute); + $this->enforceValueRequirements($queries); - // Add metric filter - $metricFilter = $this->escapeIdentifier('metric') . ' = {metric_name:String}'; - if (!empty($whereClause)) { - $whereClause .= ' AND ' . $metricFilter; - } else { - $whereClause = ' WHERE ' . $metricFilter; + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (!empty($attr)) { + $this->validateDailyAttributeName($attr); + } } - $sql = " - SELECT SUM(value) as total - FROM {$fromTable}{$whereClause} - FORMAT JSON - "; + $tableName = $this->getEventsDailyTableName(); + + $filtered = \array_values(\array_filter( + $this->stripCursorQueries($queries), + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset, + )); - $result = $this->query($sql, $params); + $builder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($tableName) + ->sum($attribute, 'total'); + + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); $json = json_decode($result, true); - if (!is_array($json) || !isset($json['data'][0]['total'])) { - return 0; + return (is_array($json) && isset($json['data'][0]['total'])) ? (int) $json['data'][0]['total'] : 0; + } + + /** + * @param array $metrics + * @param array $queries + * @return array + * + * @throws Exception + */ + public function sumDailyBatch(array $metrics, array $queries = []): array + { + if (empty($metrics)) { + return []; + } + + $this->setOperationContext('sumDailyBatch()'); + + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (!empty($attr)) { + $this->validateDailyAttributeName($attr); + } } - return (int) $json['data'][0]['total']; - } + $this->enforceValueRequirements($queries); - /** - * Get total from gauges table (argMax). - * - * @param string $metric - * @param array $queries - * @return int - * @throws Exception - */ - private function getTotalFromGauges(string $metric, array $queries): int - { - $tableName = $this->getGaugesTableName(); - $fromTable = $this->buildTableReference($tableName); + $totals = \array_fill_keys($metrics, 0); - $parsed = $this->parseQueries($queries, Usage::TYPE_GAUGE); - $params = $parsed['params']; - $params['metric_name'] = $metric; + $tableName = $this->getEventsDailyTableName(); - $whereData = $this->buildWhereClause($parsed['filters'], $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + $filtered = \array_values(\array_filter( + $this->stripCursorQueries($queries), + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset, + )); - // Add metric filter - $metricFilter = $this->escapeIdentifier('metric') . ' = {metric_name:String}'; - if (!empty($whereClause)) { - $whereClause .= ' AND ' . $metricFilter; - } else { - $whereClause = ' WHERE ' . $metricFilter; - } + $builder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($tableName) + ->select(['metric']) + ->selectRaw('SUM(`value`) AS `total`') + ->filter([Query::equal('metric', $metrics)]) + ->groupByRaw('`metric`'); - $sql = " - SELECT argMax(value, time) as total - FROM {$fromTable}{$whereClause} - FORMAT JSON - "; + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); - $result = $this->query($sql, $params); + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); $json = json_decode($result, true); - if (!is_array($json) || !isset($json['data'][0]['total'])) { - return 0; + if (is_array($json) && isset($json['data']) && is_array($json['data'])) { + foreach ($json['data'] as $row) { + $metricName = $row['metric'] ?? ''; + if (isset($totals[$metricName])) { + $totals[$metricName] = (int) ($row['total'] ?? 0); + } + } } - return (int) $json['data'][0]['total']; + return $totals; } /** - * Get totals for multiple metrics in a single query. - * - * When $type is null both tables are queried with their type-appropriate - * aggregator (SUM for events, argMax for gauges). If a metric appears in - * both tables the result of mixing those aggregators is meaningless, so - * the second occurrence raises an exception — callers must specify $type - * to disambiguate. - * * @param array $metrics * @param array $queries - * @param string|null $type 'event', 'gauge', or null (both) - * @return array + * @return array}> + * * @throws Exception */ - public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + public function getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries = [], bool $zeroFill = true, ?string $type = null): array { if (empty($metrics)) { return []; } - $this->setOperationContext('getTotalBatch()'); + if (!isset(self::INTERVAL_FUNCTIONS[$interval])) { + throw new InvalidArgumentException("Invalid interval '{$interval}'. Allowed: " . implode(', ', array_keys(self::INTERVAL_FUNCTIONS))); + } - // Initialize all metrics to 0 - $totals = \array_fill_keys($metrics, 0); + $this->setOperationContext('getTimeSeries()'); - // Track which type contributed a non-zero value to detect ambiguous mixing. - $contributingType = []; + $output = []; + foreach ($metrics as $metric) { + $output[$metric] = ['total' => 0, 'data' => []]; + } $typesToQuery = []; if ($type === Usage::TYPE_EVENT || $type === null) { @@ -2453,612 +2240,381 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type } foreach ($typesToQuery as $queryType) { - $tableName = $this->getTableForType($queryType); - $fromTable = $this->buildTableReference($tableName); - - // Build metric IN params - $metricParams = []; - $metricPlaceholders = []; - foreach ($metrics as $i => $metric) { - $paramName = 'metric_' . $i; - $metricParams[$paramName] = $metric; - $metricPlaceholders[] = "{{$paramName}:String}"; + if (!$this->queriesMatchType($queries, $queryType)) { + continue; } - $metricInClause = implode(', ', $metricPlaceholders); - $parsed = $this->parseQueries($queries, $queryType); - $params = array_merge($metricParams, $parsed['params']); + $typeResult = $this->getTimeSeriesFromTable($metrics, $interval, $startDate, $endDate, $queries, $queryType); - $whereData = $this->buildWhereClause($parsed['filters'], $params); - $whereClause = $whereData['clause']; - $params = $whereData['params']; + foreach ($typeResult as $metricName => $metricData) { + if (!isset($output[$metricName])) { + continue; + } - $escapedMetric = $this->escapeIdentifier('metric'); - $metricFilter = "{$escapedMetric} IN ({$metricInClause})"; - if (!empty($whereClause)) { - $whereClause .= ' AND ' . $metricFilter; - } else { - $whereClause = ' WHERE ' . $metricFilter; + $output[$metricName]['total'] += $metricData['total']; + $output[$metricName]['data'] = array_merge( + $output[$metricName]['data'], + $metricData['data'] + ); } + } - // Use appropriate aggregation - if ($queryType === Usage::TYPE_EVENT) { - $valueExpr = 'SUM(value) as agg_val'; - } else { - $valueExpr = 'argMax(value, time) as agg_val'; + if ($zeroFill) { + foreach ($output as $metricName => &$metricData) { + $metricData['data'] = $this->zeroFillTimeSeries( + $metricData['data'], + $interval, + $startDate, + $endDate + ); } + unset($metricData); + } - $sql = " - SELECT - metric, - {$valueExpr} - FROM {$fromTable}{$whereClause} - GROUP BY metric - FORMAT JSON - "; + return $output; + } - $result = $this->query($sql, $params); - $json = json_decode($result, true); + /** + * @param array $metrics + * @param array $queries + * @return array}> + * + * @throws Exception + */ + private function getTimeSeriesFromTable(array $metrics, string $interval, string $startDate, string $endDate, array $queries, string $type): array + { + $timeFunction = self::INTERVAL_FUNCTIONS[$interval]; + $tableName = $this->getTableForType($type); - if (is_array($json) && isset($json['data']) && is_array($json['data'])) { - foreach ($json['data'] as $row) { - $metricName = $row['metric'] ?? ''; + $this->enforceValueRequirements($queries); - if (!isset($totals[$metricName])) { - continue; - } + $valueExpr = $type === Usage::TYPE_EVENT + ? 'SUM(`value`) AS `agg_value`' + : 'argMax(`value`, `time`) AS `agg_value`'; - $rowValue = (int) ($row['agg_val'] ?? 0); - if ($rowValue === 0) { - continue; - } + $bucketExpr = $timeFunction . '(`time`) AS `bucket`'; - if ($type === null - && isset($contributingType[$metricName]) - && $contributingType[$metricName] !== $queryType) { - throw new Exception( - "Metric '{$metricName}' exists in both event and gauge tables. " - . "Specify \$type explicitly to avoid ambiguous aggregation." - ); - } + $filtered = \array_values(\array_filter( + $this->stripCursorQueries($queries), + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset + && $q->getMethod() !== Method::OrderAsc + && $q->getMethod() !== Method::OrderDesc + && $q->getMethod() !== Method::GroupByTimeBucket, + )); - $contributingType[$metricName] = $queryType; - $totals[$metricName] = $rowValue; - } - } - } + $this->validateQueryAttributes($filtered, $type); - return $totals; - } + $builder = $this->newBuilder($type) + ->from($tableName) + ->select(['metric']) + ->selectRaw($bucketExpr) + ->selectRaw($valueExpr) + ->filter([ + Query::equal('metric', $metrics), + Query::between('time', $this->formatDateTime($startDate), $this->formatDateTime($endDate)), + ]) + ->groupByRaw('`metric`, `bucket`') + ->orderByRaw('`bucket` ASC'); - /** - * Build WHERE clause from filters with optional tenant filtering. - * - * @param array $filters - * @param array $params - * @param bool $includeTenant - * @return array{clause: string, params: array} - */ - private function buildWhereClause(array $filters, array $params = [], bool $includeTenant = true): array - { - $conditions = $filters; - $whereParams = $params; + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); - if ($includeTenant) { - $tenantFilter = $this->getTenantFilter(); - if ($tenantFilter) { - $conditions[] = $tenantFilter; - $whereParams['tenant'] = $this->tenant; - } + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); + $json = json_decode($result, true); + + $output = []; + foreach ($metrics as $metric) { + $output[$metric] = ['total' => 0, 'data' => []]; } - $clause = !empty($conditions) ? ' WHERE ' . implode(' AND ', $conditions) : ''; + if (is_array($json) && isset($json['data']) && is_array($json['data'])) { + foreach ($json['data'] as $row) { + $metricName = $row['metric'] ?? ''; + $bucketTime = (string) ($row['bucket'] ?? ''); + $value = (float) ($row['agg_value'] ?? 0); - return [ - 'clause' => $clause, - 'params' => $whereParams - ]; - } + if (!isset($output[$metricName])) { + continue; + } - /** - * Resolve the ClickHouse parameter type for a column. - * - * Used by both filter binding and cursor keyset comparison so values are - * bound with the column's actual SQL type — binding a numeric column as - * `String` would compare values lexicographically (`"9" > "10"`) and - * silently produce incorrect filter results or page boundaries. Add a - * branch here when introducing a new typed column. - * - * @param string $attribute - * @return string ClickHouse parameter type (e.g. 'String', 'DateTime64(3)', 'Int64') - */ - private function getParamType(string $attribute): string - { - return match ($attribute) { - 'time' => 'DateTime64(3)', - 'value' => 'Int64', - default => 'String', - }; - } + $formattedDate = $bucketTime; + if (strpos($bucketTime, 'T') === false) { + $formattedDate = str_replace(' ', 'T', $bucketTime) . '+00:00'; + } - /** - * Format a value for the given ClickHouse parameter type. - * - * Routes DateTime-typed columns through formatDateTime() and everything - * else through formatParamValue(). Centralising this dispatch keeps - * parseQueries and buildCursorWhere consistent across libraries. - * - * @param string $chType ClickHouse parameter type as returned by getParamType() - * @param mixed $value - * @return string - * @throws Exception - */ - private function formatTypedValue(string $chType, mixed $value): string - { - if ($chType === 'DateTime64(3)') { - if ($value === null) { - throw new Exception('DateTime parameter value cannot be null'); + $output[$metricName]['total'] += $value; + $output[$metricName]['data'][] = [ + 'value' => $value, + 'date' => $formattedDate, + ]; } - /** @var \DateTime|string $value */ - return $this->formatDateTime($value); } - return $this->formatParamValue($value); + return $output; } /** - * Normalize a user-supplied cursor row into a column-keyed array. - * - * Accepts a `Metric` (or any `ArrayObject`) or a plain associative array. - * `Metric` stores its identifier under `$id` (Appwrite convention) while - * the underlying column is `id` — this remaps `$id` → `id` so cursor - * pagination can match the SQL column. - * - * @param mixed $rawCursor - * @return array - * @throws Exception + * @param array $data + * @return array */ - private function normalizeCursorRow(mixed $rawCursor): array + private function zeroFillTimeSeries(array $data, string $interval, string $startDate, string $endDate): array { - if ($rawCursor instanceof \ArrayObject) { - /** @var array $row */ - $row = $rawCursor->getArrayCopy(); - } elseif (is_array($rawCursor)) { - /** @var array $rawCursor */ - $row = $rawCursor; - } else { - throw new Exception( - 'Invalid cursor value: expected ArrayObject (Metric) or associative array, got ' - . get_debug_type($rawCursor) - ); - } + $format = $interval === '1h' ? 'Y-m-d\TH:00:00+00:00' : 'Y-m-d\T00:00:00+00:00'; + $step = $interval === '1h' ? '+1 hour' : '+1 day'; - if (!array_key_exists('id', $row) && array_key_exists('$id', $row)) { - $row['id'] = $row['$id']; - unset($row['$id']); + $existing = []; + foreach ($data as $point) { + $dt = new DateTime($point['date']); + $key = $dt->format($format); + $existing[$key] = ($existing[$key] ?? 0) + $point['value']; } - return $row; - } + $start = new DateTime($startDate); + $end = new DateTime($endDate); - /** - * Resolve the effective order attributes for cursor pagination. - * - * Auto-appends `id` as a tiebreaker when not already present so keyset - * pagination is deterministic on non-unique columns (e.g. time). - * - * @param array $orderAttributes - * @return array - */ - private function resolveCursorOrder(array $orderAttributes): array - { - foreach ($orderAttributes as $entry) { - if ($entry['attribute'] === 'id') { - return $orderAttributes; - } - } + $result = []; + $current = clone $start; - $defaultDirection = 'ASC'; - if (!empty($orderAttributes)) { - $last = $orderAttributes[count($orderAttributes) - 1]; - $defaultDirection = $last['direction']; + while ($current <= $end) { + $key = $current->format($format); + $result[] = [ + 'value' => $existing[$key] ?? 0, + 'date' => $key, + ]; + $current->modify($step); } - $orderAttributes[] = ['attribute' => 'id', 'direction' => $defaultDirection]; - - return $orderAttributes; + return $result; } /** - * Build keyset-pagination WHERE fragments for cursor support. - * - * Produces a tuple-compare clause across the order attributes: - * (a > A) OR (a = A AND b > B) OR ... - * - * For cursor `before`, the comparison directions are flipped relative to - * the requested ORDER BY (the caller is responsible for also flipping the - * actual ORDER BY at SQL build time so the page comes back from the right - * side, then reversing the rows post-fetch). + * @param array $queries * - * @param array $orderAttributes - * @param array $cursor - * @param string $cursorDirection 'after' or 'before' - * @param array $params Existing params (mutated by adding cursor binds) - * @return array{clause: string, params: array} * @throws Exception */ - private function buildCursorWhere(array $orderAttributes, array $cursor, string $cursorDirection, array $params): array - { - $orderAttributes = $this->resolveCursorOrder($orderAttributes); - - $tuples = []; - foreach ($orderAttributes as $i => $entry) { - $attr = $entry['attribute']; - $direction = $entry['direction']; - - if (!array_key_exists($attr, $cursor)) { - throw new \Exception("Cursor is missing required attribute '{$attr}'"); - } - - // Flip comparison direction for `before` so we paginate to the previous page. - if ($cursorDirection === 'before') { - $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; - } - - $conditions = []; - - for ($j = 0; $j < $i; $j++) { - $prev = $orderAttributes[$j]; - $prevAttr = $prev['attribute']; - if (!array_key_exists($prevAttr, $cursor)) { - throw new Exception("Cursor is missing required attribute '{$prevAttr}'"); - } - $prevValue = $cursor[$prevAttr]; - if ($prevValue === null) { - throw new Exception("Cursor value for '{$prevAttr}' cannot be null"); - } - $prevEscaped = $this->escapeIdentifier($prevAttr); - $prevType = $this->getParamType($prevAttr); - $paramName = "cursor_eq_{$i}_{$j}"; + public function getTotal(string $metric, array $queries = [], ?string $type = null): int + { + $this->setOperationContext('getTotal()'); - $conditions[] = "{$prevEscaped} = {{$paramName}:{$prevType}}"; - $params[$paramName] = $this->formatTypedValue($prevType, $prevValue); - } + if ($type === Usage::TYPE_EVENT) { + return $this->getTotalFromEvents($metric, $queries); + } - $value = $cursor[$attr]; - if ($value === null) { - throw new Exception("Cursor value for '{$attr}' cannot be null"); - } - $escaped = $this->escapeIdentifier($attr); - $chType = $this->getParamType($attr); - $operator = $direction === 'DESC' ? '<' : '>'; - $paramName = "cursor_cmp_{$i}"; + if ($type === Usage::TYPE_GAUGE) { + return $this->getTotalFromGauges($metric, $queries); + } - $conditions[] = "{$escaped} {$operator} {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); + $eventTotal = $this->getTotalFromEvents($metric, $queries); + $gaugeTotal = $this->getTotalFromGauges($metric, $queries); - $tuples[] = '(' . implode(' AND ', $conditions) . ')'; + if ($eventTotal > 0 && $gaugeTotal > 0) { + throw new Exception( + "Metric '{$metric}' exists in both event and gauge tables. " + . "Specify \$type explicitly to avoid ambiguous aggregation." + ); } - return [ - 'clause' => '(' . implode(' OR ', $tuples) . ')', - 'params' => $params, - ]; + return $eventTotal > 0 ? $eventTotal : $gaugeTotal; } /** - * Build the ORDER BY SQL fragment list, optionally flipping all directions. - * - * Used when cursor direction is `before` — we run the query in reverse to - * grab the previous-page rows, then `array_reverse` the result. + * @param array $queries * - * @param array $orderAttributes - * @param bool $flip Whether to flip ASC↔DESC - * @return array + * @throws Exception */ - private function buildOrderBySql(array $orderAttributes, bool $flip = false): array + private function getTotalFromEvents(string $metric, array $queries): int { - $sql = []; - foreach ($orderAttributes as $entry) { - $direction = $entry['direction']; - if ($flip) { - $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; - } - $sql[] = $this->escapeIdentifier($entry['attribute']) . ' ' . $direction; + $tableName = $this->getEventsTableName(); + + $this->enforceValueRequirements($queries); + + $filtered = \array_values(\array_filter( + $this->stripCursorQueries($queries), + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset, + )); + + $this->validateQueryAttributes($filtered, Usage::TYPE_EVENT); + + $builder = $this->newBuilder(Usage::TYPE_EVENT) + ->from($tableName) + ->sum('value', 'total') + ->filter([Query::equal('metric', [$metric])]); + + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); + $json = json_decode($result, true); + + if (!is_array($json) || !isset($json['data'][0]['total'])) { + return 0; } - return $sql; + + return (int) $json['data'][0]['total']; } /** - * Parse Query objects into SQL clauses. - * * @param array $queries - * @param string $type 'event' or 'gauge' — used for attribute validation - * @return array{filters: array, params: array, orderBy?: array, orderAttributes?: array, limit?: int, offset?: int, groupByInterval?: string, cursor?: array, cursorDirection?: string} + * * @throws Exception */ - private function parseQueries(array $queries, string $type = 'event'): array + private function getTotalFromGauges(string $metric, array $queries): int { - $filters = []; - $params = []; - $orderBy = []; - $orderAttributes = []; - $limit = null; - $offset = null; - $groupByInterval = null; - $cursor = null; - $cursorDirection = null; - $paramCounter = 0; + $tableName = $this->getGaugesTableName(); - foreach ($queries as $query) { - $method = $query->getMethod(); - $attribute = $query->getAttribute(); - $values = $query->getValues(); + $this->enforceValueRequirements($queries); - // Reject empty values for filter methods that take values — mirrors - // the validator in utopia-php/database (Validator/Query/Filter.php) - // and prevents silently dropping the WHERE fragment, which would - // otherwise turn `Query::contains('attr', [])` into a full-table - // match instead of an empty result. - if (\in_array($method, self::VALUE_REQUIRED_METHODS, true) && empty($values)) { - throw new \Exception(\ucfirst($method) . ' queries require at least one value.'); - } + $filtered = \array_values(\array_filter( + $this->stripCursorQueries($queries), + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset, + )); - switch ($method) { - case Query::TYPE_EQUAL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - - if (count($values) > 1) { - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } else { - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} = {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - } - break; + $this->validateQueryAttributes($filtered, Usage::TYPE_GAUGE); - case Query::TYPE_NOT_EQUAL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} != {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; + $builder = $this->newBuilder(Usage::TYPE_GAUGE) + ->from($tableName) + ->selectRaw('argMax(`value`, `time`) AS `total`') + ->filter([Query::equal('metric', [$metric])]); - case Query::TYPE_LESSER: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} < {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); - case Query::TYPE_GREATER: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} > {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - case Query::TYPE_BETWEEN: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); - break; + $result = $this->query($sql, $statement->namedBindings ?? []); + $json = json_decode($result, true); - case Query::TYPE_NOT_BETWEEN: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} NOT BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); - break; + if (!is_array($json) || !isset($json['data'][0]['total'])) { + return 0; + } - case Query::TYPE_ORDER_DESC: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $orderBy[] = "{$escapedAttr} DESC"; - $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'DESC']; - break; + return (int) $json['data'][0]['total']; + } - case Query::TYPE_ORDER_ASC: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $orderBy[] = "{$escapedAttr} ASC"; - $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'ASC']; - break; + /** + * @param array $metrics + * @param array $queries + * @return array + * + * @throws Exception + */ + public function getTotalBatch(array $metrics, array $queries = [], ?string $type = null): array + { + if (empty($metrics)) { + return []; + } - case Query::TYPE_CURSOR_AFTER: - case Query::TYPE_CURSOR_BEFORE: - if ($cursor !== null) { - // Keep the first cursor encountered (matches base groupByType semantics) - break; - } - $rawCursor = $values[0] ?? null; - if ($rawCursor === null) { - break; // no-op cursor - } - $cursor = $this->normalizeCursorRow($rawCursor); - $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? 'after' : 'before'; - break; + $this->setOperationContext('getTotalBatch()'); - case Query::TYPE_LESSER_EQUAL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} <= {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; + $this->enforceValueRequirements($queries); - case Query::TYPE_GREATER_EQUAL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} >= {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; + $totals = \array_fill_keys($metrics, 0); + $contributingType = []; - case Query::TYPE_CONTAINS: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } - break; + $typesToQuery = []; + if ($type === Usage::TYPE_EVENT || $type === null) { + $typesToQuery[] = Usage::TYPE_EVENT; + } + if ($type === Usage::TYPE_GAUGE || $type === null) { + $typesToQuery[] = Usage::TYPE_GAUGE; + } - case Query::TYPE_NOT_CONTAINS: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} NOT IN (" . implode(', ', $inParams) . ")"; - } - break; + foreach ($typesToQuery as $queryType) { + $tableName = $this->getTableForType($queryType); - case Query::TYPE_IS_NULL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $filters[] = "{$escapedAttr} IS NULL"; - break; + $filtered = \array_values(\array_filter( + $this->stripCursorQueries($queries), + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset, + )); - case Query::TYPE_IS_NOT_NULL: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $filters[] = "{$escapedAttr} IS NOT NULL"; - break; + $this->validateQueryAttributes($filtered, $queryType); - case Query::TYPE_STARTS_WITH: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("startsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "startsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; - break; + $valueExpr = $queryType === Usage::TYPE_EVENT + ? 'SUM(`value`) AS `agg_val`' + : 'argMax(`value`, `time`) AS `agg_val`'; - case Query::TYPE_ENDS_WITH: - $this->validateAttributeName($attribute, $type); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("endsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "endsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; - break; + $builder = $this->newBuilder($queryType) + ->from($tableName) + ->select(['metric']) + ->selectRaw($valueExpr) + ->filter([Query::equal('metric', $metrics)]) + ->groupByRaw('`metric`'); - case Query::TYPE_LIMIT: - $limitVal = is_array($values) && !empty($values) ? $values[0] : $values; - if (!\is_int($limitVal)) { - throw new \Exception('Invalid limit value. Expected int'); - } - $limit = $limitVal; - $params['limit'] = $limit; - break; + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); + + $statement = $builder->build(); + $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); + $json = json_decode($result, true); - case Query::TYPE_OFFSET: - $offsetVal = is_array($values) && !empty($values) ? $values[0] : $values; - if (!\is_int($offsetVal)) { - throw new \Exception('Invalid offset value. Expected int'); + if (is_array($json) && isset($json['data']) && is_array($json['data'])) { + foreach ($json['data'] as $row) { + $metricName = $row['metric'] ?? ''; + + if (!isset($totals[$metricName])) { + continue; } - $offset = $offsetVal; - $params['offset'] = $offset; - break; - case UsageQuery::TYPE_GROUP_BY_INTERVAL: - $this->validateAttributeName($attribute, $type); - $interval = $values[0] ?? '1h'; - if (!is_string($interval)) { - throw new \Exception( - 'Invalid groupByInterval interval: expected string, got ' . get_debug_type($interval) . '. Allowed: ' - . implode(', ', array_keys(UsageQuery::VALID_INTERVALS)) - ); + $rowValue = (int) ($row['agg_val'] ?? 0); + if ($rowValue === 0) { + continue; } - if (!isset(UsageQuery::VALID_INTERVALS[$interval])) { - throw new \Exception( - "Invalid groupByInterval interval '{$interval}'. Allowed: " - . implode(', ', array_keys(UsageQuery::VALID_INTERVALS)) + + if ($type === null + && isset($contributingType[$metricName]) + && $contributingType[$metricName] !== $queryType) { + throw new Exception( + "Metric '{$metricName}' exists in both event and gauge tables. " + . "Specify \$type explicitly to avoid ambiguous aggregation." ); } - $groupByInterval = $interval; - break; - } - } - - $result = [ - 'filters' => $filters, - 'params' => $params, - ]; - if (!empty($orderBy)) { - $result['orderBy'] = $orderBy; - $result['orderAttributes'] = $orderAttributes; + $contributingType[$metricName] = $queryType; + $totals[$metricName] = $rowValue; + } + } } - if ($limit !== null) { - $result['limit'] = $limit; - } + return $totals; + } - if ($offset !== null) { - $result['offset'] = $offset; - } + /** + * @return list + */ + private function getSelectColumns(string $type = 'event'): array + { + $columns = ['id']; - if ($groupByInterval !== null) { - $result['groupByInterval'] = $groupByInterval; + foreach ($this->getAttributes($type) as $attribute) { + $id = $attribute['$id']; + if (is_string($id)) { + $columns[] = $id; + } } - if ($cursor !== null && $cursorDirection !== null) { - $result['cursor'] = $cursor; - $result['cursorDirection'] = $cursorDirection; + if ($this->sharedTables) { + $columns[] = 'tenant'; } - return $result; + return $columns; } /** - * Parse ClickHouse JSON results into Metric array. + * Parse a ClickHouse JSON response into Metric objects. * - * @param string $result - * @param string $type 'event' or 'gauge' — used to set the type attribute on parsed metrics * @return array */ private function parseResults(string $result, string $type = 'event'): array @@ -3088,7 +2644,7 @@ private function parseResults(string $result, string $type = 'event'): array } elseif ($key === 'value') { $document[$key] = $value !== null ? (int) $value : null; } elseif ($key === 'time') { - $parsedTime = (string)$value; + $parsedTime = (string) $value; if (strpos($parsedTime, 'T') === false) { $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00'; } @@ -3109,7 +2665,6 @@ private function parseResults(string $result, string $type = 'event'): array unset($document['id']); } - // Set the type based on which table we queried $document['type'] = $type; $metrics[] = new Metric($document); @@ -3119,58 +2674,69 @@ private function parseResults(string $result, string $type = 'event'): array } /** - * Get the SELECT column list for queries. - * - * @param string $type 'event' or 'gauge' - * @return string + * @return array */ - private function getSelectColumns(string $type = 'event'): string + private function parseAggregatedResults(string $result, string $type = 'event'): array { - $columns = []; + if (empty(trim($result))) { + return []; + } - $columns[] = $this->escapeIdentifier('id'); + $json = json_decode($result, true); - foreach ($this->getAttributes($type) as $attribute) { - $id = $attribute['$id']; - if (is_string($id)) { - $columns[] = $this->escapeIdentifier($id); - } + if (!is_array($json) || !isset($json['data']) || !is_array($json['data'])) { + return []; } - if ($this->sharedTables) { - $columns[] = $this->escapeIdentifier('tenant'); - } + $rows = $json['data']; + $metrics = []; - return implode(', ', $columns); - } + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } - /** - * Build tenant filter clause. - * - * @return string - */ - private function getTenantFilter(): string - { - if (!$this->sharedTables || $this->tenant === null) { - return ''; + $document = []; + + foreach ($row as $key => $value) { + if ($key === 'bucket') { + $parsedTime = (string) $value; + if (strpos($parsedTime, 'T') === false) { + $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00'; + } + $document['time'] = $parsedTime; + } elseif ($key === 'value') { + if ($value === null) { + $document[$key] = null; + } elseif (is_int($value) || is_float($value)) { + $document[$key] = $value; + } elseif (is_numeric($value)) { + $document[$key] = (str_contains((string) $value, '.') || str_contains((string) $value, 'e') || str_contains((string) $value, 'E')) + ? (float) $value + : (int) $value; + } else { + $document[$key] = $value; + } + } else { + $document[$key] = $value; + } + } + + $document['type'] = $type; + + $metrics[] = new Metric($document); } - return "tenant = {tenant:Nullable(String)}"; + return $metrics; } /** - * Purge usage metrics matching the given queries. - * Deletes from the specified table(s). - * - * For event purges, also deletes matching rows from the pre-aggregated - * daily table — materialized views are forward-only triggers, so deletes - * on the source table do not propagate to the MV target. Only daily-table - * compatible filters (metric, value, time, tenant) are forwarded; queries - * with event-only attributes (path/method/status/etc.) leave existing - * daily rows in place. + * Delete usage rows matching the queries. Also propagates a compatible + * subset of queries to the daily aggregated table since the MV is + * forward-only. * * @param array $queries - * @param string|null $type 'event', 'gauge', or null (purge both) + * * @throws Exception */ public function purge(array $queries = [], ?string $type = null): bool @@ -3186,20 +2752,7 @@ public function purge(array $queries = [], ?string $type = null): bool } foreach ($typesToPurge as $purgeType) { - $tableName = $this->getTableForType($purgeType); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - - $parsed = $this->parseQueries($queries, $purgeType); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; - $params = $whereData['params']; - - if (empty($whereClause)) { - $whereClause = ' WHERE 1=1'; - } - - $sql = "DELETE FROM {$escapedTable}{$whereClause}"; - $this->query($sql, $params); + $this->purgeFromTable($queries, $purgeType); if ($purgeType === Usage::TYPE_EVENT) { $this->purgeDaily($queries); @@ -3210,20 +2763,44 @@ public function purge(array $queries = [], ?string $type = null): bool } /** - * Purge matching rows from the daily aggregated table. - * - * Only forwarded when every query attribute is daily-compatible - * (metric, value, time, tenant). If any query references an - * event-only column, the daily delete is skipped — silently - * leaving the daily rows in place is safer than throwing here - * because callers commonly purge by path/method/etc. + * @param array $queries * + * @throws Exception + */ + private function purgeFromTable(array $queries, string $type): void + { + $tableName = $this->getTableForType($type); + + $filtered = \array_values(\array_filter( + $this->stripCursorQueries($queries), + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset + && $q->getMethod() !== Method::OrderAsc + && $q->getMethod() !== Method::OrderDesc, + )); + + $this->validateQueryAttributes($filtered, $type); + + $builder = $this->newBuilder($type)->from($tableName); + + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); + + $builder->whereRaw('1 = 1'); + + $statement = $builder->delete(); + $sql = $this->qualifyDdl($statement->query, $tableName); + + $this->query($sql, $statement->namedBindings ?? []); + } + + /** * @param array $queries + * * @throws Exception */ private function purgeDaily(array $queries): void { - $dailyQueries = []; foreach ($queries as $query) { $attr = $query->getAttribute(); if (!empty($attr)) { @@ -3233,20 +2810,28 @@ private function purgeDaily(array $queries): void return; } } - $dailyQueries[] = $query; } - $dailyTable = $this->buildTableReference($this->getEventsDailyTableName()); + $tableName = $this->getEventsDailyTableName(); - $parsed = $this->parseQueries($dailyQueries, Usage::TYPE_EVENT); - $whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']); - $whereClause = $whereData['clause']; + $filtered = \array_values(\array_filter( + $this->stripCursorQueries($queries), + static fn (Query $q): bool => $q->getMethod() !== Method::Limit + && $q->getMethod() !== Method::Offset + && $q->getMethod() !== Method::OrderAsc + && $q->getMethod() !== Method::OrderDesc, + )); - if (empty($whereClause)) { - $whereClause = ' WHERE 1=1'; - } + $builder = $this->newBuilder(Usage::TYPE_EVENT)->from($tableName); + + $this->applyTenantFilter($builder); + $this->applyQueries($builder, $this->normalizeTimeValues($filtered)); + + $builder->whereRaw('1 = 1'); + + $statement = $builder->delete(); + $sql = $this->qualifyDdl($statement->query, $tableName); - $sql = "DELETE FROM {$dailyTable}{$whereClause}"; - $this->query($sql, $whereData['params']); + $this->query($sql, $statement->namedBindings ?? []); } } diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index 0f5e204..ebdd99c 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -6,10 +6,10 @@ use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Query as DatabaseQuery; +use Utopia\Query\Method; +use Utopia\Query\Query; use Utopia\Usage\Metric; use Utopia\Usage\Usage; -use Utopia\Usage\UsageQuery; -use Utopia\Query\Query; class Database extends SQL { @@ -380,25 +380,25 @@ private function convertQueriesToDatabase(array $queries): array $values = $query->getValues(); switch ($method) { - case Query::TYPE_EQUAL: + case Method::Equal: /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::equal($attribute, $values); break; - case Query::TYPE_GREATER: + case Method::GreaterThan: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::greaterThan($attribute, $value); } break; - case Query::TYPE_LESSER: + case Method::LessThan: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::lessThan($attribute, $value); } break; - case Query::TYPE_BETWEEN: + case Method::Between: if (count($values) >= 2) { /** @var bool|float|int|string $start */ $start = $values[0]; @@ -407,22 +407,22 @@ private function convertQueriesToDatabase(array $queries): array $dbQueries[] = DatabaseQuery::between($attribute, $start, $end); } break; - case Query::TYPE_CONTAINS: + case Method::Contains: /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::contains($attribute, $values); break; - case Query::TYPE_NOT_EQUAL: + case Method::NotEqual: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::notEqual($attribute, $value); } break; - case Query::TYPE_NOT_CONTAINS: + case Method::NotContains: /** @var array|bool|float|int|string> $values */ $dbQueries[] = DatabaseQuery::notContains($attribute, $values); break; - case Query::TYPE_NOT_BETWEEN: + case Method::NotBetween: if (count($values) >= 2) { /** @var bool|float|int|string $start */ $start = $values[0]; @@ -431,44 +431,44 @@ private function convertQueriesToDatabase(array $queries): array $dbQueries[] = DatabaseQuery::notBetween($attribute, $start, $end); } break; - case Query::TYPE_STARTS_WITH: + case Method::StartsWith: if (!empty($values) && is_string($values[0])) { $dbQueries[] = DatabaseQuery::startsWith($attribute, $values[0]); } break; - case Query::TYPE_ENDS_WITH: + case Method::EndsWith: if (!empty($values) && is_string($values[0])) { $dbQueries[] = DatabaseQuery::endsWith($attribute, $values[0]); } break; - case Query::TYPE_LESSER_EQUAL: + case Method::LessThanEqual: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::lessThanEqual($attribute, $value); } break; - case Query::TYPE_GREATER_EQUAL: + case Method::GreaterThanEqual: if (!empty($values)) { /** @var bool|float|int|string $value */ $value = $values[0]; $dbQueries[] = DatabaseQuery::greaterThanEqual($attribute, $value); } break; - case Query::TYPE_ORDER_DESC: + case Method::OrderDesc: $dbQueries[] = DatabaseQuery::orderDesc($attribute); break; - case Query::TYPE_ORDER_ASC: + case Method::OrderAsc: $dbQueries[] = DatabaseQuery::orderAsc($attribute); break; - case Query::TYPE_LIMIT: + case Method::Limit: if (!empty($values)) { /** @var int|string $val */ $val = $values[0] ?? 0; $dbQueries[] = DatabaseQuery::limit((int) $val); } break; - case Query::TYPE_OFFSET: + case Method::Offset: if (!empty($values)) { /** @var int|string $val */ $val = $values[0] ?? 0; @@ -476,9 +476,7 @@ private function convertQueriesToDatabase(array $queries): array } break; - case UsageQuery::TYPE_GROUP_BY_INTERVAL: - // groupByInterval is not supported by the Database adapter. - // Silently skip — callers get raw (non-aggregated) results. + case Method::GroupByTimeBucket: break; } } diff --git a/src/Usage/UsageQuery.php b/src/Usage/UsageQuery.php deleted file mode 100644 index 586f922..0000000 --- a/src/Usage/UsageQuery.php +++ /dev/null @@ -1,125 +0,0 @@ -find($queries, 'event'); - * ``` - * - * When `groupByInterval` is present in the queries array, the ClickHouse adapter - * switches from raw row returns to aggregated results grouped by time bucket: - * - Events: SUM(value) per bucket - * - Gauges: argMax(value, time) per bucket - */ -class UsageQuery extends Query -{ - public const TYPE_GROUP_BY_INTERVAL = 'groupByInterval'; - - /** - * Valid interval values and their ClickHouse INTERVAL equivalents. - */ - public const VALID_INTERVALS = [ - '1m' => 'INTERVAL 1 MINUTE', - '5m' => 'INTERVAL 5 MINUTE', - '15m' => 'INTERVAL 15 MINUTE', - '1h' => 'INTERVAL 1 HOUR', - '1d' => 'INTERVAL 1 DAY', - '1w' => 'INTERVAL 1 WEEK', - '1M' => 'INTERVAL 1 MONTH', - ]; - - /** - * Override isMethod to accept groupByInterval in addition to all base Query methods. - */ - public static function isMethod(string $value): bool - { - if ($value === self::TYPE_GROUP_BY_INTERVAL) { - return true; - } - - return parent::isMethod($value); - } - - /** - * Create a groupByInterval query. - * - * When passed to `find()`, this switches the adapter to return time-bucketed - * aggregated results instead of raw rows. - * - * @param string $attribute The time attribute to bucket (usually 'time') - * @param string $interval The bucket size: '1m', '5m', '15m', '1h', '1d', '1w', '1M' - * @return self - */ - public static function groupByInterval(string $attribute, string $interval): self - { - if (!isset(self::VALID_INTERVALS[$interval])) { - throw new \InvalidArgumentException( - "Invalid interval '{$interval}'. Allowed: " . implode(', ', array_keys(self::VALID_INTERVALS)) - ); - } - - return new self(self::TYPE_GROUP_BY_INTERVAL, $attribute, [$interval]); - } - - /** - * Check if a query is a groupByInterval query. - * - * @param Query $query - * @return bool - */ - public static function isGroupByInterval(Query $query): bool - { - return $query->getMethod() === self::TYPE_GROUP_BY_INTERVAL; - } - - /** - * Extract the groupByInterval query from an array of queries, if present. - * - * Queries parsed via `Query::parse()` are base `Query` objects rather than - * `UsageQuery` instances, so we match on the method string alone. - * - * @param array $queries - * @return Query|null The groupByInterval query, or null if not present - */ - public static function extractGroupByInterval(array $queries): ?Query - { - foreach ($queries as $query) { - if ($query->getMethod() === self::TYPE_GROUP_BY_INTERVAL) { - return $query; - } - } - - return null; - } - - /** - * Remove groupByInterval queries from an array of queries. - * - * Returns the remaining queries that should be processed normally. - * - * @param array $queries - * @return array - */ - public static function removeGroupByInterval(array $queries): array - { - return array_values(array_filter($queries, function (Query $query) { - return !self::isGroupByInterval($query); - })); - } -} diff --git a/tests/Usage/Adapter/ClickHouseSqlSnapshotTest.php b/tests/Usage/Adapter/ClickHouseSqlSnapshotTest.php new file mode 100644 index 0000000..7236584 --- /dev/null +++ b/tests/Usage/Adapter/ClickHouseSqlSnapshotTest.php @@ -0,0 +1,302 @@ + + */ + private function eventTypeMap(): array + { + return [ + 'id' => 'String', + 'metric' => 'String', + 'value' => 'Int64', + 'time' => 'DateTime64(3)', + 'tenant' => 'Nullable(String)', + 'path' => 'String', + 'method' => 'String', + 'status' => 'String', + 'resource' => 'String', + 'resourceId' => 'String', + 'country' => 'Nullable(String)', + 'userAgent' => 'String', + 'tags' => 'String', + ]; + } + + /** + * Snapshot for `setup()` events table DDL. + */ + public function testSetupEventsDdl(): void + { + $table = (new ClickHouseSchema())->table('utopia_usage_events'); + $table->string('id')->primary(); + $table->string('metric'); + $table->addColumn('value', ColumnType::BigInteger); + $table->datetime('time', 3)->nullable(); + $table->string('path')->nullable(); + $table->string('method')->nullable(); + $table->index(['path'], 'index_path', '', '', [], [], [], IndexAlgorithm::BloomFilter, [], 1); + $table->index(['method'], 'index_method', '', '', [], [], [], IndexAlgorithm::BloomFilter, [], 1); + $table->engine(Engine::MergeTree) + ->partitionBy('toYYYYMM(time)') + ->orderBy(['metric', 'time', 'id']) + ->settings(['index_granularity' => 8192, 'allow_nullable_key' => 1]); + + $statement = $table->createIfNotExists(); + + $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS `utopia_usage_events`', $statement->query); + $this->assertStringContainsString('`id` String', $statement->query); + $this->assertStringContainsString('`metric` String', $statement->query); + $this->assertStringContainsString('`value` Int64', $statement->query); + $this->assertStringContainsString('`time` Nullable(DateTime64(3))', $statement->query); + $this->assertStringContainsString('INDEX `index_path` `path` TYPE bloom_filter GRANULARITY 1', $statement->query); + $this->assertStringContainsString('INDEX `index_method` `method` TYPE bloom_filter GRANULARITY 1', $statement->query); + $this->assertStringContainsString('ENGINE = MergeTree()', $statement->query); + $this->assertStringContainsString('PARTITION BY toYYYYMM(time)', $statement->query); + $this->assertStringContainsString('ORDER BY (`metric`, `time`, `id`)', $statement->query); + $this->assertStringContainsString('SETTINGS index_granularity = 8192, allow_nullable_key = 1', $statement->query); + } + + /** + * Snapshot for `createDailyTable()` SummingMergeTree DDL. + */ + public function testDailyTableDdl(): void + { + $table = (new ClickHouseSchema())->table('utopia_usage_events_daily'); + $table->string('metric'); + $table->addColumn('value', ColumnType::BigInteger); + $table->datetime('time', 3); + $table->engine(Engine::SummingMergeTree, 'value') + ->partitionBy('toYYYYMM(time)') + ->orderBy(['metric', 'time']) + ->settings(['index_granularity' => 8192, 'allow_nullable_key' => 1]); + + $statement = $table->createIfNotExists(); + + $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS `utopia_usage_events_daily`', $statement->query); + $this->assertStringContainsString('ENGINE = SummingMergeTree(`value`)', $statement->query); + $this->assertStringContainsString('PARTITION BY toYYYYMM(time)', $statement->query); + $this->assertStringContainsString('ORDER BY (`metric`, `time`)', $statement->query); + } + + /** + * Snapshot for `createDailyMaterializedView()` MV emission. + */ + public function testDailyMaterializedViewDdl(): void + { + $body = 'SELECT metric, value, d as time' + . ' FROM (' + . ' SELECT metric, sum(value) as value, toStartOfDay(time) as d' + . ' FROM `usage_events`' + . ' GROUP BY metric, d' + . ' )'; + + $statement = (new ClickHouseSchema())->createMaterializedView( + 'usage_events_daily_mv', + 'usage_events_daily', + $body, + true, + ); + + $this->assertSame( + 'CREATE MATERIALIZED VIEW IF NOT EXISTS `usage_events_daily_mv` TO `usage_events_daily` AS ' . $body, + $statement->query + ); + } + + /** + * Snapshot for `find()` with a typical multi-filter query, exercising the + * named-typed binding pipeline end to end. + */ + public function testFindNamedTypedBindings(): void + { + $builder = new ClickHouseBuilder(); + $statement = $builder + ->useNamedBindings() + ->withParamTypes($this->eventTypeMap()) + ->from('events') + ->select(['id', 'metric', 'value', 'time']) + ->filter([ + Query::equal('metric', ['bandwidth']), + Query::greaterThanEqual('time', '2026-03-01 00:00:00'), + Query::lessThanEqual('time', '2026-04-01 00:00:00'), + ]) + ->sortDesc('time') + ->limit(10) + ->build(); + + $this->assertSame( + 'SELECT `id`, `metric`, `value`, `time` FROM `events`' + . ' WHERE `metric` IN ({param0:String})' + . ' AND `time` >= {param1:DateTime64(3)}' + . ' AND `time` <= {param2:DateTime64(3)}' + . ' ORDER BY `time` DESC' + . ' LIMIT {param3:Int64}', + $statement->query + ); + $this->assertSame( + [ + 'param0' => 'bandwidth', + 'param1' => '2026-03-01 00:00:00', + 'param2' => '2026-04-01 00:00:00', + 'param3' => 10, + ], + $statement->namedBindings + ); + } + + /** + * Snapshot for the aggregated read path: groupByTimeBucket + SUM aggregate + * + bucket SELECT/ORDER BY projection. + */ + public function testFindAggregatedWithGroupByTimeBucket(): void + { + $builder = new ClickHouseBuilder(); + $statement = $builder + ->useNamedBindings() + ->withParamTypes($this->eventTypeMap()) + ->from('events') + ->select(['metric']) + ->selectRaw('SUM(`value`) AS `value`') + ->selectRaw('toStartOfHour(`time`) AS `bucket`') + ->filter([ + Query::equal('metric', ['requests']), + Query::groupByTimeBucket('time', '1h'), + ]) + ->groupByRaw('`metric`') + ->orderByRaw('`bucket` ASC') + ->build(); + + $this->assertSame( + 'SELECT `metric`, SUM(`value`) AS `value`, toStartOfHour(`time`) AS `bucket`' + . ' FROM `events`' + . ' WHERE `metric` IN ({param0:String})' + . ' GROUP BY toStartOfHour(`time`), `metric`' + . ' ORDER BY `bucket` ASC', + $statement->query + ); + $this->assertSame(['param0' => 'requests'], $statement->namedBindings); + } + + /** + * Snapshot for `addBatch()` INSERT … FORMAT JSONEachRow. + */ + public function testAddBatchInsertFormat(): void + { + $statement = (new ClickHouseBuilder()) + ->into('events') + ->insertFormat('JSONEachRow', ['id', 'metric', 'value', 'time', 'tags']) + ->insert(); + + $this->assertInstanceOf(FormattedInsertStatement::class, $statement); + $this->assertSame( + 'INSERT INTO `events` (`id`, `metric`, `value`, `time`, `tags`) FORMAT JSONEachRow', + $statement->query + ); + $this->assertSame([], $statement->bindings); + $this->assertSame('JSONEachRow', $statement->format); + $this->assertSame(['id', 'metric', 'value', 'time', 'tags'], $statement->columns); + } + + /** + * Snapshot for `getTimeSeries()` shape: bucket projection + metric IN + * + time BETWEEN + GROUP BY + ORDER BY. + */ + public function testGetTimeSeriesShape(): void + { + $builder = new ClickHouseBuilder(); + $statement = $builder + ->useNamedBindings() + ->withParamTypes($this->eventTypeMap()) + ->from('events') + ->select(['metric']) + ->selectRaw('toStartOfHour(`time`) AS `bucket`') + ->selectRaw('SUM(`value`) AS `agg_value`') + ->filter([ + Query::equal('metric', ['requests', 'bandwidth']), + Query::between('time', '2026-03-01 00:00:00', '2026-04-01 00:00:00'), + ]) + ->groupByRaw('`metric`, `bucket`') + ->orderByRaw('`bucket` ASC') + ->build(); + + $this->assertSame( + 'SELECT `metric`, toStartOfHour(`time`) AS `bucket`, SUM(`value`) AS `agg_value`' + . ' FROM `events`' + . ' WHERE `metric` IN ({param0:String}, {param1:String})' + . ' AND `time` BETWEEN {param2:DateTime64(3)} AND {param3:DateTime64(3)}' + . ' GROUP BY `metric`, `bucket`' + . ' ORDER BY `bucket` ASC', + $statement->query + ); + $this->assertSame( + [ + 'param0' => 'requests', + 'param1' => 'bandwidth', + 'param2' => '2026-03-01 00:00:00', + 'param3' => '2026-04-01 00:00:00', + ], + $statement->namedBindings + ); + } + + /** + * Snapshot for the daily SummingMergeTree read path with FINAL. + */ + public function testFindDailyUsesFinal(): void + { + $builder = new ClickHouseBuilder(); + $statement = $builder + ->useNamedBindings() + ->withParamTypes($this->eventTypeMap()) + ->from('events_daily') + ->final() + ->select(['metric', 'value', 'time']) + ->filter([Query::equal('metric', ['bandwidth'])]) + ->build(); + + $this->assertSame( + 'SELECT `metric`, `value`, `time` FROM `events_daily` FINAL' + . ' WHERE `metric` IN ({param0:String})', + $statement->query + ); + } + + /** + * Snapshot for the DELETE path emitted by purge(). + */ + public function testDeleteLightweight(): void + { + $builder = new ClickHouseBuilder(); + $statement = $builder + ->useNamedBindings() + ->withParamTypes($this->eventTypeMap()) + ->from('events') + ->filter([Query::equal('metric', ['purge-target'])]) + ->delete(); + + $this->assertSame( + 'DELETE FROM `events` WHERE `metric` IN ({param0:String})', + $statement->query + ); + $this->assertSame(['param0' => 'purge-target'], $statement->namedBindings); + } +} diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index 81d3d14..f5b8532 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -3,11 +3,11 @@ namespace Utopia\Tests\Adapter; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; use Utopia\Tests\Usage\UsageBase; use Utopia\Usage\Adapter\ClickHouse as ClickHouseAdapter; use Utopia\Usage\Usage; -use Utopia\Usage\UsageQuery; class ClickHouseTest extends TestCase { @@ -1087,7 +1087,7 @@ public function testCursorWithGroupByIntervalThrows(): void $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); $this->usage->find([ - UsageQuery::groupByInterval('time', '1h'), + Query::groupByTimeBucket('time', '1h'), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), Query::cursorAfter(['id' => 'whatever']), @@ -1187,7 +1187,7 @@ public function testEqualRejectsEmptyValues(): void $this->expectExceptionMessage('Equal queries require at least one value.'); $this->usage->find([ - new Query(Query::TYPE_EQUAL, 'metric', []), + new Query(Method::Equal, 'metric', []), ], Usage::TYPE_EVENT); } } diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php index 6bb71d0..b54dd02 100644 --- a/tests/Usage/UsageBase.php +++ b/tests/Usage/UsageBase.php @@ -2,9 +2,9 @@ namespace Utopia\Tests\Usage; +use Utopia\Query\Exception\ValidationException; use Utopia\Query\Query; use Utopia\Usage\Usage; -use Utopia\Usage\UsageQuery; trait UsageBase { @@ -532,7 +532,7 @@ public function testGroupByIntervalHourly(): void $end = (clone $now)->modify('+1 hour')->format('Y-m-d\TH:i:s'); $results = $this->usage->find([ - UsageQuery::groupByInterval('time', '1h'), + Query::groupByTimeBucket('time', '1h'), Query::equal('metric', ['gbi-requests']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -565,7 +565,7 @@ public function testGroupByIntervalDaily(): void $end = (new \DateTime())->modify('+1 day')->format('Y-m-d\TH:i:s'); $results = $this->usage->find([ - UsageQuery::groupByInterval('time', '1d'), + Query::groupByTimeBucket('time', '1d'), Query::equal('metric', ['gbi-daily']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -596,7 +596,7 @@ public function testGroupByIntervalGauge(): void $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); $results = $this->usage->find([ - UsageQuery::groupByInterval('time', '1h'), + Query::groupByTimeBucket('time', '1h'), Query::equal('metric', ['gbi-storage']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), @@ -613,8 +613,8 @@ public function testGroupByIntervalGauge(): void public function testGroupByIntervalInvalidInterval(): void { - $this->expectException(\InvalidArgumentException::class); - UsageQuery::groupByInterval('time', '2h'); + $this->expectException(ValidationException::class); + Query::groupByTimeBucket('time', '2h'); } public function testGroupByIntervalWithLimitOffset(): void @@ -630,7 +630,7 @@ public function testGroupByIntervalWithLimitOffset(): void $end = (new \DateTime())->modify('+1 hour')->format('Y-m-d\TH:i:s'); $results = $this->usage->find([ - UsageQuery::groupByInterval('time', '1h'), + Query::groupByTimeBucket('time', '1h'), Query::equal('metric', ['gbi-limit']), Query::greaterThanEqual('time', $start), Query::lessThanEqual('time', $end), diff --git a/tests/Usage/UsageQueryTest.php b/tests/Usage/UsageQueryTest.php deleted file mode 100644 index b4b07b1..0000000 --- a/tests/Usage/UsageQueryTest.php +++ /dev/null @@ -1,134 +0,0 @@ -assertInstanceOf(UsageQuery::class, $query); - $this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $query->getMethod()); - $this->assertEquals('time', $query->getAttribute()); - $this->assertEquals(['1h'], $query->getValues()); - $this->assertEquals('1h', $query->getValue()); - } - - public function testGroupByIntervalAllValidIntervals(): void - { - $validIntervals = ['1m', '5m', '15m', '1h', '1d', '1w', '1M']; - - foreach ($validIntervals as $interval) { - $query = UsageQuery::groupByInterval('time', $interval); - $this->assertEquals($interval, $query->getValue()); - } - } - - public function testGroupByIntervalInvalidInterval(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid interval '2h'"); - UsageQuery::groupByInterval('time', '2h'); - } - - public function testGroupByIntervalInvalidIntervalEmpty(): void - { - $this->expectException(\InvalidArgumentException::class); - UsageQuery::groupByInterval('time', ''); - } - - public function testIsGroupByInterval(): void - { - $groupByQuery = UsageQuery::groupByInterval('time', '1h'); - $regularQuery = Query::equal('metric', ['bandwidth']); - - $this->assertTrue(UsageQuery::isGroupByInterval($groupByQuery)); - $this->assertFalse(UsageQuery::isGroupByInterval($regularQuery)); - } - - public function testExtractGroupByInterval(): void - { - $groupByQuery = UsageQuery::groupByInterval('time', '1h'); - $equalQuery = Query::equal('metric', ['bandwidth']); - $timeQuery = Query::greaterThanEqual('time', '2026-03-01'); - - $queries = [$equalQuery, $groupByQuery, $timeQuery]; - - $extracted = UsageQuery::extractGroupByInterval($queries); - - $this->assertNotNull($extracted); - $this->assertInstanceOf(Query::class, $extracted); - $this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $extracted->getMethod()); - $this->assertEquals('1h', $extracted->getValue()); - } - - public function testExtractGroupByIntervalFromParsedQuery(): void - { - // Queries created via Query::parse() are base Query objects, not UsageQuery. - $parsedGroupBy = new Query(UsageQuery::TYPE_GROUP_BY_INTERVAL, 'time', ['1h']); - $equalQuery = Query::equal('metric', ['bandwidth']); - - $queries = [$equalQuery, $parsedGroupBy]; - - $extracted = UsageQuery::extractGroupByInterval($queries); - - $this->assertNotNull($extracted); - $this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $extracted->getMethod()); - $this->assertEquals('1h', $extracted->getValue()); - } - - public function testExtractGroupByIntervalReturnsNullWhenMissing(): void - { - $queries = [ - Query::equal('metric', ['bandwidth']), - Query::greaterThanEqual('time', '2026-03-01'), - ]; - - $this->assertNull(UsageQuery::extractGroupByInterval($queries)); - } - - public function testRemoveGroupByInterval(): void - { - $groupByQuery = UsageQuery::groupByInterval('time', '1h'); - $equalQuery = Query::equal('metric', ['bandwidth']); - $timeQuery = Query::greaterThanEqual('time', '2026-03-01'); - - $queries = [$equalQuery, $groupByQuery, $timeQuery]; - $remaining = UsageQuery::removeGroupByInterval($queries); - - $this->assertCount(2, $remaining); - - foreach ($remaining as $query) { - $this->assertNotEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $query->getMethod()); - } - } - - public function testValidIntervalsConstant(): void - { - $this->assertIsArray(UsageQuery::VALID_INTERVALS); - $this->assertArrayHasKey('1m', UsageQuery::VALID_INTERVALS); - $this->assertArrayHasKey('5m', UsageQuery::VALID_INTERVALS); - $this->assertArrayHasKey('15m', UsageQuery::VALID_INTERVALS); - $this->assertArrayHasKey('1h', UsageQuery::VALID_INTERVALS); - $this->assertArrayHasKey('1d', UsageQuery::VALID_INTERVALS); - $this->assertArrayHasKey('1w', UsageQuery::VALID_INTERVALS); - $this->assertArrayHasKey('1M', UsageQuery::VALID_INTERVALS); - - // Verify interval SQL values - $this->assertEquals('INTERVAL 1 HOUR', UsageQuery::VALID_INTERVALS['1h']); - $this->assertEquals('INTERVAL 1 DAY', UsageQuery::VALID_INTERVALS['1d']); - $this->assertEquals('INTERVAL 1 MINUTE', UsageQuery::VALID_INTERVALS['1m']); - $this->assertEquals('INTERVAL 1 MONTH', UsageQuery::VALID_INTERVALS['1M']); - } - - public function testUsageQueryExtendsQuery(): void - { - $query = UsageQuery::groupByInterval('time', '1h'); - $this->assertInstanceOf(Query::class, $query); - } -}