From 85406faa5b722fc6b279c872c3e55cb463a5b060 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 14 Aug 2026 16:32:09 +1200 Subject: [PATCH 1/8] feat: adapt audit schema to query-lib Attribute and Index VOs --- composer.json | 14 +- composer.lock | 478 ++++++++++---- src/Audit/Adapter/ClickHouse.php | 879 ++++++++----------------- src/Audit/Adapter/Database.php | 90 +-- src/Audit/Adapter/SQL.php | 144 +--- src/Audit/Query.php | 78 ++- tests/Audit/Adapter/ClickHouseTest.php | 154 ++--- tests/Audit/QueryTest.php | 52 +- 8 files changed, 888 insertions(+), 1001 deletions(-) diff --git a/composer.json b/composer.json index 4c5813b..df86d63 100644 --- a/composer.json +++ b/composer.json @@ -27,11 +27,21 @@ }, "require": { "php": ">=8.5", - "utopia-php/database": "^7.0.0", + "utopia-php/database": "dev-feat-query-lib as 7.0.0", "utopia-php/fetch": "^1.1", - "utopia-php/query": "0.1.*", + "utopia-php/query": "0.4.*", "utopia-php/validators": "^0.4" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/database.git" + }, + { + "type": "vcs", + "url": "https://github.com/utopia-php/async.git" + } + ], "config": { "allow-plugins": { "php-http/discovery": true, diff --git a/composer.lock b/composer.lock index eb4d0be..8526266 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": "a5c1fc3c6c6349d3c24daa9b49eb219e", + "content-hash": "1b07c21b6ff8067d80771495b7b2b373", "packages": [ { "name": "brick/math", @@ -144,23 +144,23 @@ }, { "name": "google/protobuf", - "version": "v4.33.6", + "version": "v5.35.1", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "84b008c23915ed94536737eae46f41ba3bccfe67" + "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67", - "reference": "84b008c23915ed94536737eae46f41ba3bccfe67", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/55bb4a7d6739b5af0927b96213c1371a3afb7cfb", + "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb", "shasum": "" }, "require": { - "php": ">=8.1.0" + "php": ">=8.2.0" }, "require-dev": { - "phpunit/phpunit": ">=10.5.62 <11.0.0" + "phpunit/phpunit": ">=11.5.0 <12.0.0" }, "suggest": { "ext-bcmath": "Need to support JSON deserialization" @@ -182,9 +182,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.35.1" }, - "time": "2026-03-18T17:32:05+00:00" + "time": "2026-06-11T21:19:23+00:00" }, { "name": "mongodb/mongodb", @@ -409,16 +409,16 @@ }, { "name": "open-telemetry/api", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", "shasum": "" }, "require": { @@ -475,7 +475,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-02-25T13:24:05+00:00" + "time": "2026-07-06T12:28:04+00:00" }, { "name": "open-telemetry/context", @@ -602,20 +602,20 @@ }, { "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9" + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9", - "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/66f04d0e448ad333033bfc7baae1aa56330be088", + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088", "shasum": "" }, "require": { - "google/protobuf": "^3.22 || ^4.0", + "google/protobuf": "^3.22 || ^4.0 || ^5.0", "php": "^8.0" }, "suggest": { @@ -661,20 +661,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-19T06:44:33+00:00" + "time": "2026-06-17T12:06:32+00:00" }, { "name": "open-telemetry/sdk", - "version": "1.14.0", + "version": "1.15.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410" + "reference": "77e1aa73850154abb86937d52a70883edc3b4547" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410", - "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/77e1aa73850154abb86937d52a70883edc3b4547", + "reference": "77e1aa73850154abb86937d52a70883edc3b4547", "shasum": "" }, "require": { @@ -682,7 +682,7 @@ "nyholm/psr7-server": "^1.1", "open-telemetry/api": "^1.8", "open-telemetry/context": "^1.4", - "open-telemetry/sem-conv": "^1.0", + "open-telemetry/sem-conv": "^1.38.0", "php": "^8.1", "php-http/discovery": "^1.14", "psr/http-client": "^1.0", @@ -705,7 +705,10 @@ "spi": { "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", - "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig" + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySemConv", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySpanKind", + "OpenTelemetry\\SDK\\ConfigEnv\\Distribution\\DistributionConfigurationSdk" ], "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" @@ -715,7 +718,7 @@ ] }, "branch-alias": { - "dev-main": "1.12.x-dev" + "dev-main": "1.14.x-dev" } }, "autoload": { @@ -758,7 +761,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-03-21T11:50:01+00:00" + "time": "2026-07-14T13:09:54+00:00" }, { "name": "open-telemetry/sem-conv", @@ -817,6 +820,71 @@ }, "time": "2026-01-21T04:14:03+00:00" }, + { + "name": "opis/closure", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/opis/closure.git", + "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/closure/zipball/b97e42b95bb72d87507f5e2d137ceb239aea8d6b", + "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Opis\\Closure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "A library that can be used to serialize closures (anonymous functions) and arbitrary data.", + "homepage": "https://opis.io/closure", + "keywords": [ + "anonymous classes", + "anonymous functions", + "closure", + "function", + "serializable", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/opis/closure/issues", + "source": "https://github.com/opis/closure/tree/4.5.0" + }, + "time": "2026-03-05T13:32:42+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -1386,16 +1454,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.14", + "version": "v7.4.16", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" + "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", - "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "url": "https://api.github.com/repos/symfony/http-client/zipball/c513ed0ba5d1784a6b55fc84190dbe4451b12f41", + "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41", "shasum": "" }, "require": { @@ -1463,7 +1531,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.14" + "source": "https://github.com/symfony/http-client/tree/v7.4.16" }, "funding": [ { @@ -1483,7 +1551,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T11:50:14+00:00" + "time": "2026-07-29T16:20:51+00:00" }, { "name": "symfony/http-client-contracts", @@ -1734,16 +1802,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { @@ -1790,7 +1858,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -1810,20 +1878,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -1870,7 +1938,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -1890,7 +1958,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/service-contracts", @@ -2031,35 +2099,152 @@ }, "time": "2025-06-29T15:42:06+00:00" }, + { + "name": "utopia-php/async", + "version": "0.1.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/async.git", + "reference": "3ee4fc3d505113d0d6050f35f5bf85e706866a22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/async/zipball/3ee4fc3d505113d0d6050f35f5bf85e706866a22", + "reference": "3ee4fc3d505113d0d6050f35f5bf85e706866a22", + "shasum": "" + }, + "require": { + "opis/closure": "4.*", + "php": ">=8.1" + }, + "require-dev": { + "amphp/amp": "3.*", + "amphp/parallel": "2.*", + "amphp/process": "^2.0", + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.5.45", + "react/child-process": "0.*", + "react/event-loop": "1.*", + "swoole/ide-helper": "*" + }, + "suggest": { + "amphp/amp": "Required for Amp promise adapter", + "amphp/parallel": "Required for Amp parallel adapter", + "ext-ev": "Required for ReactPHP event loop (recommended for best performance)", + "ext-parallel": "Required for parallel adapter (requires PHP ZTS build)", + "ext-sockets": "Required for Swoole Process adapter", + "ext-swoole": "Required for Swoole Thread and Process adapters (recommended for best performance)", + "react/child-process": "Required for ReactPHP parallel adapter", + "react/event-loop": "Required for ReactPHP promise and parallel adapters" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Async\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Utopia\\Tests\\": "tests/" + } + }, + "scripts": { + "test-unit": [ + "vendor/bin/phpunit tests/Unit --exclude-group no-swoole" + ], + "test-promise-sync": [ + "vendor/bin/phpunit tests/E2e/Promise/SyncTest.php" + ], + "test-promise-swoole": [ + "vendor/bin/phpunit tests/E2e/Promise/Swoole" + ], + "test-promise-amp": [ + "vendor/bin/phpunit tests/E2e/Promise/Amp" + ], + "test-promise-react": [ + "vendor/bin/phpunit tests/E2e/Promise/React" + ], + "test-parallel-sync": [ + "vendor/bin/phpunit tests/E2e/Parallel/Sync" + ], + "test-parallel-swoole-thread": [ + "vendor/bin/phpunit tests/E2e/Parallel/Swoole/ThreadTest.php" + ], + "test-parallel-swoole-process": [ + "vendor/bin/phpunit tests/E2e/Parallel/Swoole/ProcessTest.php" + ], + "test-parallel-amp": [ + "vendor/bin/phpunit tests/E2e/Parallel/Amp" + ], + "test-parallel-react": [ + "vendor/bin/phpunit tests/E2e/Parallel/React" + ], + "test-parallel-ext": [ + "php -n -d extension=parallel.so -d extension=sockets.so vendor/bin/phpunit tests/E2e/Parallel/Parallel" + ], + "test-e2e": [ + "vendor/bin/phpunit tests/E2e --exclude-group ext-parallel" + ], + "test": [ + "@test-unit", + "@test-e2e", + "@test-parallel-ext" + ], + "lint": [ + "vendor/bin/pint" + ], + "format": [ + "php -d memory_limit=4G vendor/bin/pint" + ], + "check": [ + "vendor/bin/phpstan analyse src tests --level=max --memory-limit=4G" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Appwrite Team", + "email": "team@appwrite.io" + } + ], + "description": "High-performance concurrent + parallel library with Promise and Parallel execution support for PHP.", + "support": { + "source": "https://github.com/utopia-php/async/tree/0.1.1", + "issues": "https://github.com/utopia-php/async/issues" + }, + "time": "2026-06-08T05:10:34+00:00" + }, { "name": "utopia-php/cache", - "version": "4.0.0", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "2ab50440114a7699b8a017ee2ecd8bcd9d54ac5e" + "reference": "92e02dab63606234b993b841ebf4c58845dd4620" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/2ab50440114a7699b8a017ee2ecd8bcd9d54ac5e", - "reference": "2ab50440114a7699b8a017ee2ecd8bcd9d54ac5e", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/92e02dab63606234b993b841ebf4c58845dd4620", + "reference": "92e02dab63606234b993b841ebf4c58845dd4620", "shasum": "" }, "require": { "ext-json": "*", - "ext-memcached": "*", - "ext-redis": "*", - "php": ">=8.3", - "utopia-php/circuit-breaker": "0.3.*", - "utopia-php/pools": "2.*", - "utopia-php/telemetry": "*" + "php": ">=8.4", + "utopia-php/circuit-breaker": "^0.3", + "utopia-php/pools": "^2.0", + "utopia-php/telemetry": "^0.4" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "^1.12", - "phpunit/phpunit": "^9.3", - "swoole/ide-helper": "^6.0", - "vimeo/psalm": "4.13.1" + "swoole/ide-helper": "^6.0" + }, + "suggest": { + "ext-memcached": "Required for the Memcached and Hazelcast adapters.", + "ext-redis": "Required for the Redis, RedisCluster and Sharding adapters.", + "ext-swoole": "Required for the multiplexing Redis adapter (>=6.0)." }, "type": "library", "autoload": { @@ -2071,6 +2256,12 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], "description": "A simple cache library to manage application cache storing, loading and purging", "keywords": [ "cache", @@ -2081,32 +2272,29 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/4.0.0" + "source": "https://github.com/utopia-php/cache/tree/4.0.2" }, - "time": "2026-07-31T13:04:45+00:00" + "time": "2026-08-12T07:48:59+00:00" }, { "name": "utopia-php/circuit-breaker", - "version": "0.3.1", + "version": "0.3.2", "source": { "type": "git", "url": "https://github.com/utopia-php/circuit-breaker.git", - "reference": "db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828" + "reference": "5fbc3802471b0d1b4260bd9f5544514e6929b481" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828", - "reference": "db5d77f6c99ebce2ee81bd8ed4ae8f41bd2b0828", + "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/5fbc3802471b0d1b4260bd9f5544514e6929b481", + "reference": "5fbc3802471b0d1b4260bd9f5544514e6929b481", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "laravel/pint": "^1.29", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0", - "utopia-php/telemetry": "^0.4" + "utopia-php/telemetry": "^0.4.6" }, "suggest": { "ext-opentelemetry": "Required by utopia-php/telemetry when using OpenTelemetry metrics.", @@ -2118,7 +2306,7 @@ "type": "library", "autoload": { "psr-4": { - "Utopia\\CircuitBreaker\\": "src/CircuitBreaker" + "Utopia\\CircuitBreaker\\": "src/CircuitBreaker/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2143,9 +2331,9 @@ ], "support": { "issues": "https://github.com/utopia-php/circuit-breaker/issues", - "source": "https://github.com/utopia-php/circuit-breaker/tree/0.3.1" + "source": "https://github.com/utopia-php/circuit-breaker/tree/0.3.2" }, - "time": "2026-05-29T12:12:23+00:00" + "time": "2026-08-05T18:07:20+00:00" }, { "name": "utopia-php/console", @@ -2197,16 +2385,16 @@ }, { "name": "utopia-php/database", - "version": "7.0.0", + "version": "dev-feat-query-lib", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "40185b100f92c402a189d6f4f0962c63bbe5726b" + "reference": "e593b78c587e1f1d66b644979f5c4ff11dffcd31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/40185b100f92c402a189d6f4f0962c63bbe5726b", - "reference": "40185b100f92c402a189d6f4f0962c63bbe5726b", + "url": "https://api.github.com/repos/utopia-php/database/zipball/e593b78c587e1f1d66b644979f5c4ff11dffcd31", + "reference": "e593b78c587e1f1d66b644979f5c4ff11dffcd31", "shasum": "" }, "require": { @@ -2215,29 +2403,67 @@ "ext-pdo": "*", "ext-redis": "*", "php": ">=8.5", - "utopia-php/cache": "^4.0.0", + "utopia-php/async": "0.1.*", + "utopia-php/cache": "4.*", "utopia-php/console": "0.1.*", "utopia-php/mongo": "1.*", "utopia-php/pools": "2.*", - "utopia-php/validators": "0.3.*" + "utopia-php/query": "0.4.*", + "utopia-php/validators": "0.4.*" }, "require-dev": { + "brianium/paratest": "7.20.*", "fakerphp/faker": "1.23.*", "laravel/pint": "*", - "pcov/clobber": "2.*", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "9.*", + "phpstan/phpstan": "2.1.*", + "phpunit/phpunit": "12.5.*", "rregeer/phpunit-coverage-check": "0.3.*", "swoole/ide-helper": "5.1.3", "utopia-php/cli": "0.22.*" }, + "suggest": { + "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", + "ext-redis": "Needed to support Redis Cache Adapter", + "mongodb/mongodb": "Needed to support MongoDB Database Adapter" + }, "type": "library", "autoload": { "psr-4": { "Utopia\\Database\\": "src/Database" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Tests\\E2E\\": "tests/e2e", + "Tests\\Unit\\": "tests/unit" + } + }, + "scripts": { + "build": [ + "Composer\\Config::disableProcessTimeout", + "docker compose build" + ], + "start": [ + "Composer\\Config::disableProcessTimeout", + "docker compose up -d" + ], + "test": [ + "Composer\\Config::disableProcessTimeout", + "docker compose exec tests vendor/bin/paratest --configuration phpunit.xml --functional --processes 4" + ], + "lint": [ + "php -d memory_limit=2G ./vendor/bin/pint --test" + ], + "format": [ + "php -d memory_limit=2G ./vendor/bin/pint" + ], + "check": [ + "./vendor/bin/phpstan analyse --memory-limit 2G" + ], + "coverage": [ + "./vendor/bin/coverage-check ./tmp/clover.xml 90" + ] + }, "license": [ "MIT" ], @@ -2250,10 +2476,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/7.0.0" + "source": "https://github.com/utopia-php/database/tree/feat-query-lib", + "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-07-31T13:33:10+00:00" + "time": "2026-08-14T02:54:13+00:00" }, { "name": "utopia-php/fetch", @@ -2297,16 +2523,16 @@ }, { "name": "utopia-php/mongo", - "version": "1.4.0", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "78818fd295f2829aaad5b74c9094e8c6f603550d" + "reference": "be29ee2d84b9f7efdfc6fea5b4b2d4bf95ff5ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/78818fd295f2829aaad5b74c9094e8c6f603550d", - "reference": "78818fd295f2829aaad5b74c9094e8c6f603550d", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/be29ee2d84b9f7efdfc6fea5b4b2d4bf95ff5ec4", + "reference": "be29ee2d84b9f7efdfc6fea5b4b2d4bf95ff5ec4", "shasum": "" }, "require": { @@ -2352,27 +2578,27 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.4.0" + "source": "https://github.com/utopia-php/mongo/tree/1.5.3" }, - "time": "2026-07-30T05:24:58+00:00" + "time": "2026-08-13T01:55:11+00:00" }, { "name": "utopia-php/pools", - "version": "2.0.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/pools.git", - "reference": "48905dac1b8f8050c2e07bdda32a018ca33982fc" + "reference": "86d43fcacd125232c0743e8c22695faf97285cf3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/pools/zipball/48905dac1b8f8050c2e07bdda32a018ca33982fc", - "reference": "48905dac1b8f8050c2e07bdda32a018ca33982fc", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/86d43fcacd125232c0743e8c22695faf97285cf3", + "reference": "86d43fcacd125232c0743e8c22695faf97285cf3", "shasum": "" }, "require": { "php": ">=8.4", - "utopia-php/telemetry": "^0.4" + "utopia-php/telemetry": "^0.4.6" }, "require-dev": { "swoole/ide-helper": "6.*" @@ -2408,30 +2634,33 @@ ], "support": { "issues": "https://github.com/utopia-php/pools/issues", - "source": "https://github.com/utopia-php/pools/tree/2.0.1" + "source": "https://github.com/utopia-php/pools/tree/2.0.2" }, - "time": "2026-07-31T12:19:18+00:00" + "time": "2026-08-05T18:07:20+00:00" }, { "name": "utopia-php/query", - "version": "0.1.1", + "version": "0.4.0", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27" + "reference": "c334515035a2ab0aa49176eeca96de3e1c096e22" }, "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/c334515035a2ab0aa49176eeca96de3e1c096e22", + "reference": "c334515035a2ab0aa49176eeca96de3e1c096e22", "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", @@ -2454,22 +2683,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/0.4.0" }, - "time": "2026-03-03T09:05:14+00:00" + "time": "2026-08-14T01:42:36+00:00" }, { "name": "utopia-php/telemetry", - "version": "0.4.5", + "version": "0.4.6", "source": { "type": "git", "url": "https://github.com/utopia-php/telemetry.git", - "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a" + "reference": "f96778a01792c32df0876fe1f38a79b9588445d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/139943bffcd4f6dd8fb9ed247f946a1d151b006a", - "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/f96778a01792c32df0876fe1f38a79b9588445d8", + "reference": "f96778a01792c32df0876fe1f38a79b9588445d8", "shasum": "" }, "require": { @@ -2505,22 +2734,22 @@ ], "support": { "issues": "https://github.com/utopia-php/telemetry/issues", - "source": "https://github.com/utopia-php/telemetry/tree/0.4.5" + "source": "https://github.com/utopia-php/telemetry/tree/0.4.6" }, - "time": "2026-07-08T11:07:25+00:00" + "time": "2026-08-05T17:56:48+00:00" }, { "name": "utopia-php/validators", - "version": "0.3.1", + "version": "0.4.2", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba" + "reference": "a5b7b78b789e5af0a30f96da8355bbf48fb56699" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba", - "reference": "d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/a5b7b78b789e5af0a30f96da8355bbf48fb56699", + "reference": "a5b7b78b789e5af0a30f96da8355bbf48fb56699", "shasum": "" }, "require": { @@ -2545,15 +2774,24 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.3.1" + "source": "https://github.com/utopia-php/validators/tree/0.4.2" }, - "time": "2026-07-14T11:55:48+00:00" + "time": "2026-08-11T07:43:49+00:00" } ], "packages-dev": [], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/database", + "version": "dev-feat-query-lib", + "alias": "7.0.0", + "alias_normalized": "7.0.0.0" + } + ], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index d7c7e81..fdb6955 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -5,8 +5,13 @@ use Exception; use Utopia\Audit\Log; use Utopia\Audit\Query; +use Utopia\Database\Attribute; use Utopia\Database\Database; +use Utopia\Database\Index; use Utopia\Fetch\Client; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; use Utopia\Validator\Hostname; /** @@ -55,25 +60,25 @@ class ClickHouse extends SQL * arrays for these methods are rejected up front so they can't silently * compile into a "no filter applied" WHERE clause. * - * @var list + * @var list */ private const array 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_NOT_STARTS_WITH, - Query::TYPE_ENDS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_REGEX, - Query::TYPE_SELECT, + Method::Equal, + Method::NotEqual, + Method::LessThan, + Method::LessThanEqual, + Method::GreaterThan, + Method::GreaterThanEqual, + Method::Between, + Method::NotBetween, + Method::Contains, + Method::NotContains, + Method::StartsWith, + Method::NotStartsWith, + Method::EndsWith, + Method::NotEndsWith, + Method::Regex, + Method::Select, ]; private readonly string $host; @@ -98,11 +103,12 @@ class ClickHouse extends SQL private ?int $retention = null; /** - * @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) + * @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) + * * @throws Exception If validation fails */ public function __construct( @@ -119,7 +125,7 @@ public function __construct( $this->port = $port; // Initialize the HTTP client for connection reuse - $this->client = new Client(); + $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 @@ -163,8 +169,8 @@ public function ping(): bool */ private function validateHost(string $host): void { - $validator = new Hostname(); - if (!$validator->isValid($host)) { + $validator = new Hostname; + if (! $validator->isValid($host)) { throw new Exception('ClickHouse host is not a valid hostname or IP address'); } } @@ -185,7 +191,8 @@ private function validatePort(int $port): void * Validate identifier (database, table, namespace). * ClickHouse identifiers follow SQL standard rules. * - * @param string $type Name of the identifier type for error messages + * @param string $type Name of the identifier type for error messages + * * @throws Exception */ private function validateIdentifier(string $identifier, string $type = 'Identifier'): void @@ -199,7 +206,7 @@ private function validateIdentifier(string $identifier, string $type = 'Identifi } // ClickHouse identifiers: alphanumeric, underscores, cannot start with number - if (!preg_match('/^[a-zA-Z_]\w*$/', $identifier)) { + if (! preg_match('/^[a-zA-Z_]\w*$/', $identifier)) { throw new Exception("{$type} must start with a letter or underscore and contain only alphanumeric characters and underscores"); } @@ -217,8 +224,9 @@ private function validateIdentifier(string $identifier, string $type = 'Identifi private function escapeIdentifier(string $identifier): string { // Backtick escaping: replace any backticks in the identifier with double backticks - return '`' . str_replace('`', '``', $identifier) . '`'; + return '`'.str_replace('`', '``', $identifier).'`'; } + /** * Set the namespace for multi-project support. * Namespace is used as a prefix for table names. @@ -231,6 +239,7 @@ public function setNamespace(string $namespace): self $this->validateIdentifier($namespace, 'Namespace'); } $this->namespace = $namespace; + return $this; } @@ -243,6 +252,7 @@ public function setDatabase(string $database): self { $this->validateIdentifier($database, 'Database'); $this->database = $database; + return $this; } @@ -255,6 +265,7 @@ public function setTable(string $table): self { $this->validateIdentifier($table, 'Table'); $this->table = $table; + return $this; } @@ -272,6 +283,7 @@ public function getTable(): string public function setSecure(bool $secure): self { $this->secure = $secure; + return $this; } @@ -290,6 +302,7 @@ public function getNamespace(): string public function setTenant(?int $tenant): self { $this->tenant = $tenant; + return $this; } @@ -308,6 +321,7 @@ public function getTenant(): ?int public function setSharedTables(bool $sharedTables): self { $this->sharedTables = $sharedTables; + return $this; } @@ -328,6 +342,7 @@ public function isSharedTables(): bool public function setAsyncCleanup(bool $asyncCleanup): self { $this->asyncCleanup = $asyncCleanup; + return $this; } @@ -352,6 +367,7 @@ public function setRetention(?int $days): self throw new Exception('Retention must be a positive number of days'); } $this->retention = $days; + return $this; } @@ -367,394 +383,57 @@ public function getRetention(): ?int * Override getAttributes to provide extended attributes for ClickHouse. * Includes existing attributes from parent and adds new missing ones. * - * @return array> + * @return array */ #[\Override] public function getAttributes(): array { $parentAttributes = parent::getAttributes(); - foreach ($parentAttributes as &$attribute) { - if (($attribute['$id'] ?? null) === 'userId') { - $attribute['$id'] = 'actorId'; + foreach ($parentAttributes as $attribute) { + if ($attribute->key === 'userId') { + $attribute->key = 'actorId'; break; } } - unset($attribute); return [ ...$parentAttributes, - [ - '$id' => 'actorType', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'actorInternalId', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'resourceParent', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'resourceType', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'resourceId', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'resourceInternalId', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'country', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - // premium geo - [ - '$id' => 'city', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'continentCode', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'subdivisions', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'isp', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'autonomousSystemNumber', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'autonomousSystemOrganization', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'connectionType', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'connectionUsageType', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'connectionOrganization', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'projectInternalId', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'teamId', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'teamInternalId', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'hostname', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'sdk', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'sdkVersion', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - // user-agent — parsed OS / client / device dimensions - [ - '$id' => 'osCode', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'osName', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'osVersion', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientType', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientCode', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientName', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientVersion', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientEngine', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientEngineVersion', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'deviceName', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'deviceBrand', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'deviceModel', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], + new Attribute(key: 'actorType', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'actorInternalId', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'resourceParent', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'resourceType', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'resourceId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'resourceInternalId', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'country', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'city', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'continentCode', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'subdivisions', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'isp', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'autonomousSystemNumber', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'autonomousSystemOrganization', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'connectionType', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'connectionUsageType', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'connectionOrganization', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'projectId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'projectInternalId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'teamId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'teamInternalId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'hostname', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'sdk', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'sdkVersion', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'osCode', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'osName', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'osVersion', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'clientType', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'clientCode', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'clientName', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'clientVersion', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'clientEngine', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'clientEngineVersion', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'deviceName', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'deviceBrand', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'deviceModel', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), ]; } @@ -762,80 +441,31 @@ public function getAttributes(): array * Override getIndexes to provide extended indexes for ClickHouse. * Includes existing indexes from parent and adds new missing ones. * - * @return array> + * @return array */ #[\Override] public function getIndexes(): array { $parentIndexes = parent::getIndexes(); - foreach ($parentIndexes as &$index) { - if (($index['$id'] ?? null) === 'idx_userId_event') { - $index['$id'] = 'idx_actorId_event'; - $index['attributes'] = ['actorId', 'event']; + foreach ($parentIndexes as $index) { + if ($index->key === 'idx_userId_event') { + $index->key = 'idx_actorId_event'; + $index->attributes = ['actorId', 'event']; break; } } - unset($index); return [ ...$parentIndexes, - [ - '$id' => '_key_actor_internal_and_event', - 'type' => Database::INDEX_KEY, - 'attributes' => ['actorInternalId', 'event'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_project_internal_id', - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectInternalId'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_team_internal_id', - 'type' => Database::INDEX_KEY, - 'attributes' => ['teamInternalId'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_actor_internal_id', - 'type' => Database::INDEX_KEY, - 'attributes' => ['actorInternalId'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_actor_type', - 'type' => Database::INDEX_KEY, - 'attributes' => ['actorType'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_country', - 'type' => Database::INDEX_KEY, - 'attributes' => ['country'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_hostname', - 'type' => Database::INDEX_KEY, - 'attributes' => ['hostname'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_sdk', - 'type' => Database::INDEX_KEY, - 'attributes' => ['sdk'], - 'lengths' => [], - 'orders' => [], - ], + new Index(key: '_key_actor_internal_and_event', type: IndexType::Key, attributes: ['actorInternalId', 'event']), + new Index(key: '_key_project_internal_id', type: IndexType::Key, attributes: ['projectInternalId']), + new Index(key: '_key_team_internal_id', type: IndexType::Key, attributes: ['teamInternalId']), + new Index(key: '_key_actor_internal_id', type: IndexType::Key, attributes: ['actorInternalId']), + new Index(key: '_key_actor_type', type: IndexType::Key, attributes: ['actorType']), + new Index(key: '_key_country', type: IndexType::Key, attributes: ['country']), + new Index(key: '_key_hostname', type: IndexType::Key, attributes: ['hostname']), + new Index(key: '_key_sdk', type: IndexType::Key, attributes: ['sdk']), ]; } @@ -848,7 +478,7 @@ private function getTableName(): string $tableName = $this->table; if ($this->namespace !== '' && $this->namespace !== '0') { - return $this->namespace . '_' . $tableName; + return $this->namespace.'_'.$tableName; } return $tableName; @@ -872,10 +502,11 @@ private function getTableName(): string * ClickHouse handles all parameter escaping and type conversion internally, * making both approaches fully injection-safe. * - * @param string $sql The SQL query to execute - * @param array $params Key-value pairs for query parameters (for SELECT/UPDATE/DELETE) - * @param array>|null $jsonRows Array of rows for JSONEachRow INSERT operations + * @param string $sql The SQL query to execute + * @param array $params Key-value pairs for query parameters (for SELECT/UPDATE/DELETE) + * @param array>|null $jsonRows Array of rows for JSONEachRow INSERT operations * @return string Response body + * * @throws Exception */ private function query(string $sql, array $params = [], ?array $jsonRows = null): string @@ -888,7 +519,7 @@ private function query(string $sql, array $params = [], ?array $jsonRows = null) try { if ($jsonRows !== null) { // JSON body mode for INSERT operations with JSONEachRow format - $url = "{$scheme}://{$this->host}:{$this->port}/?query=" . urlencode($sql); + $url = "{$scheme}://{$this->host}:{$this->port}/?query=".urlencode($sql); // Build JSONEachRow body - each row on a separate line $jsonLines = []; @@ -896,7 +527,7 @@ private function query(string $sql, array $params = [], ?array $jsonRows = null) try { $encoded = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new Exception('Failed to encode row to JSON: ' . $e->getMessage(), $e->getCode(), $e); + throw new Exception('Failed to encode row to JSON: '.$e->getMessage(), $e->getCode(), $e); } $jsonLines[] = $encoded; } @@ -908,7 +539,7 @@ private function query(string $sql, array $params = [], ?array $jsonRows = null) // Build multipart form data body with query and parameters $body = ['query' => $sql]; foreach ($params as $key => $value) { - $body['param_' . $key] = $this->formatParamValue($value); + $body['param_'.$key] = $this->formatParamValue($value); } } @@ -925,6 +556,7 @@ private function query(string $sql, array $params = [], ?array $jsonRows = null) } $responseBody = $response->getBody(); + return \is_string($responseBody) ? $responseBody : ''; } catch (Exception $e) { throw new Exception( @@ -959,7 +591,7 @@ private function formatParamValue(mixed $value): string try { return json_encode($value, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new Exception('Failed to encode array parameter to JSON: ' . $e->getMessage(), $e->getCode(), $e); + throw new Exception('Failed to encode array parameter to JSON: '.$e->getMessage(), $e->getCode(), $e); } } @@ -997,8 +629,7 @@ public function setup(): void ]; foreach ($this->getAttributes() as $attribute) { - /** @var string $id */ - $id = $attribute['$id']; + $id = $attribute->key; // Special handling for time column - must be NOT NULL for partition key $columns[] = $id === 'time' ? 'time DateTime64(3)' : $this->getColumnDefinition($id); @@ -1012,10 +643,8 @@ public function setup(): void // Build indexes from base adapter schema $indexes = []; foreach ($this->getIndexes() as $index) { - /** @var string $indexName */ - $indexName = $index['$id']; - /** @var array $attributes */ - $attributes = $index['attributes']; + $indexName = $index->key; + $attributes = $index->attributes; // Escape each attribute name to prevent SQL injection $escapedAttributes = array_map($this->escapeIdentifier(...), $attributes); $attributeList = implode(', ', $escapedAttributes); @@ -1023,20 +652,20 @@ public function setup(): void } $tableName = $this->getTableName(); - $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedDatabaseAndTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); $orderByExpr = $this->sharedTables ? '(tenant, time, id)' : '(time, id)'; // Create table with MergeTree engine for optimal performance $createTableSql = " CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} ( - " . implode(",\n ", $columns) . ', - ' . implode(",\n ", $indexes) . " + ".implode(",\n ", $columns).', + '.implode(",\n ", $indexes)." ) ENGINE = MergeTree() ORDER BY {$orderByExpr} PARTITION BY toYYYYMM(time) - SETTINGS index_granularity = 8192" . ($this->sharedTables ? ', allow_nullable_key = 1' : '') . ' + SETTINGS index_granularity = 8192".($this->sharedTables ? ', allow_nullable_key = 1' : '').' '; $this->query($createTableSql); @@ -1049,8 +678,8 @@ public function setup(): void if ($this->retention !== null) { $this->query( "ALTER TABLE {$escapedDatabaseAndTable} " - . "MODIFY TTL toDateTime(time) + INTERVAL {$this->retention} DAY " - . 'SETTINGS materialize_ttl_after_modify = 0', + ."MODIFY TTL toDateTime(time) + INTERVAL {$this->retention} DAY " + .'SETTINGS materialize_ttl_after_modify = 0', ); } else { // Disabling retention must actively strip any TTL a previous run @@ -1063,7 +692,7 @@ public function setup(): void "ALTER TABLE {$escapedDatabaseAndTable} REMOVE TTL", ); } catch (Exception $e) { - if (!str_contains($e->getMessage(), "doesn't have any table TTL expression")) { + if (! str_contains($e->getMessage(), "doesn't have any table TTL expression")) { throw $e; } } @@ -1080,13 +709,13 @@ private function getColumnNames(): array { $columns = []; foreach ($this->getAttributes() as $attribute) { - /** @var string $columnName */ - $columnName = $attribute['$id']; + $columnName = $attribute->key; // Exclude id and tenant as they're handled separately if ($columnName !== 'id' && $columnName !== 'tenant') { $columns[] = $columnName; } } + return $columns; } @@ -1094,8 +723,9 @@ private function getColumnNames(): array * Validate that an attribute name exists in the schema. * Prevents SQL injection by ensuring only valid column names are used. * - * @param string $attributeName The attribute name to validate + * @param string $attributeName The attribute name to validate * @return bool True if valid + * * @throws Exception If attribute name is invalid */ /** @@ -1125,7 +755,7 @@ private function validateAttributeName(string $attributeName): bool // Check against defined attributes foreach ($this->getAttributes() as $attribute) { - if ($attribute['$id'] === $attributeName) { + if ($attribute->key === $attributeName) { return true; } } @@ -1139,8 +769,9 @@ private function validateAttributeName(string $attributeName): bool * ClickHouse DateTime64(3) type expects this format as timezone is handled by column metadata. * Works with DateTime objects, strings, and other datetime representations. * - * @param \DateTime|string|null $dateTime The datetime value to format + * @param \DateTime|string|null $dateTime The datetime value to format * @return string The formatted datetime string in ClickHouse compatible format + * * @throws Exception If the datetime string cannot be parsed */ private function formatDateTime(\DateTime|string|null $dateTime): string @@ -1156,6 +787,7 @@ private function formatDateTime(\DateTime|string|null $dateTime): string try { // Parse the datetime string, handling ISO 8601 format with timezone $dt = new \DateTime($dateTime); + return $dt->format('Y-m-d H:i:s.v'); } catch (\Exception) { throw new Exception("Invalid datetime string: {$dateTime}"); @@ -1165,14 +797,15 @@ private function formatDateTime(\DateTime|string|null $dateTime): string /** * Create an audit log entry * - * @param array $log The log data + * @param array $log The log data + * * @throws Exception */ public function create(array $log): Log { // Generate ID if not provided $logId = $log['id'] ?? uniqid('', true); - if (!\is_string($logId)) { + if (! \is_string($logId)) { throw new Exception('Log ID must be a string'); } $log['id'] = $logId; @@ -1182,7 +815,7 @@ public function create(array $log): Log // Retrieve the created log using getById to ensure consistency $createdLog = $this->getById($logId); - if (!$createdLog instanceof \Utopia\Audit\Log) { + if (! $createdLog instanceof \Utopia\Audit\Log) { throw new Exception("Failed to retrieve created log with ID: {$logId}"); } @@ -1193,17 +826,18 @@ public function create(array $log): Log * Get a single log by its ID using JSON format for reliable parsing. * * @return Log|null The log entry or null if not found + * * @throws Exception */ public function getById(string $id): ?Log { $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); $escapedId = $this->escapeIdentifier('id'); $sql = ' - SELECT ' . $this->getSelectColumns() . " + SELECT '.$this->getSelectColumns()." FROM {$escapedTable} WHERE {$escapedId} = {id:String}{$tenantFilter} LIMIT 1 @@ -1219,14 +853,15 @@ public function getById(string $id): ?Log /** * Find logs using Query objects. * - * @param array $queries + * @param array $queries * @return array + * * @throws Exception */ public function find(array $queries = []): array { $tableName = $this->getTableName(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); // Parse queries $parsed = $this->parseQueries($queries); @@ -1236,10 +871,10 @@ public function find(array $queries = []): array // mixing column-based ORDER BY with rand() would silently drop the // column order. Reject loudly in both cases so the caller fixes the // query rather than getting unexpected results. - if (!empty($parsed['randomOrder']) && isset($parsed['cursor'])) { + if (! empty($parsed['randomOrder']) && isset($parsed['cursor'])) { throw new Exception('Cursor pagination cannot be combined with orderRandom'); } - if (!empty($parsed['randomOrder']) && !empty($parsed['orderBy'])) { + if (! empty($parsed['randomOrder']) && ! empty($parsed['orderBy'])) { throw new Exception('orderRandom cannot be combined with orderAsc/orderDesc'); } @@ -1262,12 +897,12 @@ public function find(array $queries = []): array // Build WHERE clause $whereClause = ''; $tenantFilter = $this->getTenantFilter(); - if (!empty($filters) || $tenantFilter) { + if (! empty($filters) || $tenantFilter) { $conditions = $filters; if ($tenantFilter !== '' && $tenantFilter !== '0') { $conditions[] = ltrim($tenantFilter, ' AND'); } - $whereClause = ' WHERE ' . implode(' AND ', $conditions); + $whereClause = ' WHERE '.implode(' AND ', $conditions); } // Build ORDER BY clause. orderRandom is mutually exclusive with @@ -1276,13 +911,13 @@ public function find(array $queries = []): array // after resolveCursorOrder, which appends an id tiebreaker), // flipping directions for `cursorBefore`. $orderClause = ''; - if (!empty($parsed['randomOrder'])) { + if (! empty($parsed['randomOrder'])) { $orderClause = ' ORDER BY rand()'; } elseif (isset($parsed['cursor'])) { $orderSql = $this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before'); - $orderClause = ' ORDER BY ' . implode(', ', $orderSql); - } elseif (!empty($parsed['orderBy'])) { - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + $orderClause = ' ORDER BY '.implode(', ', $orderSql); + } elseif (! empty($parsed['orderBy'])) { + $orderClause = ' ORDER BY '.implode(', ', $parsed['orderBy']); } // Build LIMIT and OFFSET @@ -1317,6 +952,7 @@ public function find(array $queries = []): array * requesting a slim projection don't have to remember either. * * @param list|null $select + * * @throws Exception */ private function buildProjection(?array $select): string @@ -1362,14 +998,15 @@ private function buildProjection(?array $select): string * `LIMIT {max}` inside a subquery — ClickHouse stops scanning once the cap * is reached, keeping large counts cheap (e.g. for "5000+" UI badges). * - * @param array $queries - * @param int|null $max Optional upper bound (inclusive) for the count + * @param array $queries + * @param int|null $max Optional upper bound (inclusive) for the count + * * @throws Exception */ public function count(array $queries = [], ?int $max = null): int { $tableName = $this->getTableName(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); // Parse queries - we only need filters and params, not ordering/limit/offset/cursor $parsed = $this->parseQueries($queries); @@ -1377,12 +1014,12 @@ public function count(array $queries = [], ?int $max = null): int // Build WHERE clause $whereClause = ''; $tenantFilter = $this->getTenantFilter(); - if (!empty($parsed['filters']) || $tenantFilter) { + if (! empty($parsed['filters']) || $tenantFilter) { $conditions = $parsed['filters']; if ($tenantFilter !== '' && $tenantFilter !== '0') { $conditions[] = ltrim($tenantFilter, ' AND'); } - $whereClause = ' WHERE ' . implode(' AND ', $conditions); + $whereClause = ' WHERE '.implode(' AND ', $conditions); } // Remove limit and offset from params as they don't apply to count @@ -1415,8 +1052,9 @@ public function count(array $queries = [], ?int $max = null): int /** * Parse Query objects into SQL components. * - * @param array $queries + * @param array $queries * @return array{filters: array, params: array, orderBy?: array, orderAttributes?: array, randomOrder?: bool, limit?: int, offset?: int, cursor?: array, cursorDirection?: string, select?: list} + * * @throws Exception */ private function parseQueries(array $queries): array @@ -1434,7 +1072,7 @@ private function parseQueries(array $queries): array $paramCounter = 0; foreach ($queries as $query) { - if (!$query instanceof Query) { + if (! $query instanceof Query) { $type = get_debug_type($query); throw new \InvalidArgumentException("Invalid query item: expected instance of Query, got {$type}"); } @@ -1450,11 +1088,11 @@ private function parseQueries(array $queries): array // otherwise turn `Query::contains('attr', [])` into a full-table // match instead of an empty result. if (\in_array($method, self::VALUE_REQUIRED_METHODS, true) && $values === []) { - throw new \Exception(ucfirst($method) . ' queries require at least one value.'); + throw new \Exception(ucfirst($method->value).' queries require at least one value.'); } switch ($method) { - case Query::TYPE_EQUAL: + case Method::Equal: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); @@ -1462,187 +1100,187 @@ private function parseQueries(array $queries): array if (\count($values) > 1) { $inParams = []; foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $inParams[] = "{{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $value); } - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ')'; + $filters[] = "{$escapedAttr} IN (".implode(', ', $inParams).')'; } else { - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} = {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); } break; - case Query::TYPE_NOT_EQUAL: + case Method::NotEqual: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} != {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; - case Query::TYPE_LESSER: + case Method::LessThan: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} < {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; - case Query::TYPE_LESSER_EQUAL: + case Method::LessThanEqual: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} <= {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; - case Query::TYPE_GREATER: + case Method::GreaterThan: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} > {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; - case Query::TYPE_GREATER_EQUAL: + case Method::GreaterThanEqual: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "{$escapedAttr} >= {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); break; - case Query::TYPE_BETWEEN: + case Method::Between: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; + $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; - case Query::TYPE_NOT_BETWEEN: + case Method::NotBetween: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; + $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; - case Query::TYPE_CONTAINS: + case Method::Contains: // Substring match, mirroring utopia-php/database: each // value becomes `LIKE '%value%'`, OR'd together. $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $conditions = []; foreach ($values as $value) { - if (!\is_string($value)) { + if (! \is_string($value)) { throw new Exception("contains value must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $conditions[] = "{$escapedAttr} LIKE {{$paramName}:String}"; - $params[$paramName] = '%' . $this->escapeLikeWildcards($value) . '%'; + $params[$paramName] = '%'.$this->escapeLikeWildcards($value).'%'; } - $filters[] = '(' . implode(' OR ', $conditions) . ')'; + $filters[] = '('.implode(' OR ', $conditions).')'; break; - case Query::TYPE_NOT_CONTAINS: + case Method::NotContains: // Negated substring match, mirroring utopia-php/database: // each value becomes `NOT LIKE '%value%'`, AND'd together. $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $conditions = []; foreach ($values as $value) { - if (!\is_string($value)) { + if (! \is_string($value)) { throw new Exception("notContains value must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $conditions[] = "{$escapedAttr} NOT LIKE {{$paramName}:String}"; - $params[$paramName] = '%' . $this->escapeLikeWildcards($value) . '%'; + $params[$paramName] = '%'.$this->escapeLikeWildcards($value).'%'; } - $filters[] = '(' . implode(' AND ', $conditions) . ')'; + $filters[] = '('.implode(' AND ', $conditions).')'; break; - case Query::TYPE_IS_NULL: + case Method::IsNull: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $filters[] = "{$escapedAttr} IS NULL"; break; - case Query::TYPE_IS_NOT_NULL: + case Method::IsNotNull: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $filters[] = "{$escapedAttr} IS NOT NULL"; break; - case Query::TYPE_STARTS_WITH: + case Method::StartsWith: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $needle = $values[0] ?? null; - if (!\is_string($needle)) { + if (! \is_string($needle)) { throw new Exception("startsWith needle must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "startsWith({$escapedAttr}, {{$paramName}:String})"; $params[$paramName] = $needle; break; - case Query::TYPE_NOT_STARTS_WITH: + case Method::NotStartsWith: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $needle = $values[0] ?? null; - if (!\is_string($needle)) { + if (! \is_string($needle)) { throw new Exception("notStartsWith needle must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "NOT startsWith({$escapedAttr}, {{$paramName}:String})"; $params[$paramName] = $needle; break; - case Query::TYPE_ENDS_WITH: + case Method::EndsWith: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $needle = $values[0] ?? null; - if (!\is_string($needle)) { + if (! \is_string($needle)) { throw new Exception("endsWith needle must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "endsWith({$escapedAttr}, {{$paramName}:String})"; $params[$paramName] = $needle; break; - case Query::TYPE_NOT_ENDS_WITH: + case Method::NotEndsWith: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $needle = $values[0] ?? null; - if (!\is_string($needle)) { + if (! \is_string($needle)) { throw new Exception("notEndsWith needle must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; $filters[] = "NOT endsWith({$escapedAttr}, {{$paramName}:String})"; $params[$paramName] = $needle; break; - case Query::TYPE_REGEX: + case Method::Regex: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $pattern = $values[0] ?? null; - if (!\is_string($pattern)) { + if (! \is_string($pattern)) { throw new Exception("regex pattern must be a string for attribute '{$attribute}'"); } - $paramName = 'param_' . $paramCounter++; + $paramName = 'param_'.$paramCounter++; // ClickHouse's `match(haystack, pattern)` is the re2-style // regex predicate. Pattern is bound as a parameter, never // interpolated, so it can't escape into the SQL. @@ -1650,7 +1288,7 @@ private function parseQueries(array $queries): array $params[$paramName] = $pattern; break; - case Query::TYPE_SELECT: + case Method::Select: if (empty($values)) { // VALUE_REQUIRED_METHODS already rejects empty values // earlier, but the explicit check keeps this branch safe @@ -1662,55 +1300,55 @@ private function parseQueries(array $queries): array // validated and escaped at SQL build time in find(). $select ??= []; foreach ($values as $column) { - if (!\is_string($column) || $column === '') { + if (! \is_string($column) || $column === '') { throw new Exception('select columns must be non-empty strings'); } $this->validateAttributeName($column); - if (!\in_array($column, $select, true)) { + if (! \in_array($column, $select, true)) { $select[] = $column; } } break; - case Query::TYPE_ORDER_DESC: + case Method::OrderDesc: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $orderBy[] = "{$escapedAttr} DESC"; $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'DESC']; break; - case Query::TYPE_ORDER_ASC: + case Method::OrderAsc: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); $orderBy[] = "{$escapedAttr} ASC"; $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'ASC']; break; - case Query::TYPE_ORDER_RANDOM: + case Method::OrderRandom: // ClickHouse's rand() is the per-row PRNG used for random // sampling. Single emission across the result set — repeated // Query::orderRandom() calls collapse into one ORDER BY rand(). $randomOrder = true; break; - case Query::TYPE_LIMIT: - if (!\is_int($values[0])) { + case Method::Limit: + if (! \is_int($values[0])) { throw new \Exception('Invalid limit value. Expected int'); } $limit = $values[0]; $params['limit'] = $limit; break; - case Query::TYPE_OFFSET: - if (!\is_int($values[0])) { + case Method::Offset: + if (! \is_int($values[0])) { throw new \Exception('Invalid offset value. Expected int'); } $offset = $values[0]; $params['offset'] = $offset; break; - case Query::TYPE_CURSOR_AFTER: - case Query::TYPE_CURSOR_BEFORE: + case Method::CursorAfter: + case Method::CursorBefore: if ($cursor !== null) { // Keep the first cursor encountered (matches base groupByType semantics) break; @@ -1720,7 +1358,7 @@ private function parseQueries(array $queries): array break; // no-op cursor } $cursor = $this->normalizeCursorRow($rawCursor); - $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? 'after' : 'before'; + $cursorDirection = $method === Method::CursorAfter ? 'after' : 'before'; break; } } @@ -1768,6 +1406,7 @@ private function parseQueries(array $queries): array * can match the SQL column. * * @return array + * * @throws Exception */ private function normalizeCursorRow(mixed $rawCursor): array @@ -1781,11 +1420,11 @@ private function normalizeCursorRow(mixed $rawCursor): array } else { throw new Exception( 'Invalid cursor value: expected ArrayObject (Log) or associative array, got ' - . get_debug_type($rawCursor), + .get_debug_type($rawCursor), ); } - if (!\array_key_exists('id', $row) && \array_key_exists('$id', $row)) { + if (! \array_key_exists('id', $row) && \array_key_exists('$id', $row)) { $row['id'] = $row['$id']; unset($row['$id']); } @@ -1820,7 +1459,8 @@ private function getParamType(string $attribute): string * else through formatParamValue(). Centralising this dispatch keeps * parseQueries and buildCursorWhere consistent across libraries. * - * @param string $chType ClickHouse parameter type as returned by getParamType() + * @param string $chType ClickHouse parameter type as returned by getParamType() + * * @throws Exception */ private function formatTypedValue(string $chType, mixed $value): string @@ -1829,6 +1469,7 @@ private function formatTypedValue(string $chType, mixed $value): string if ($value === null) { throw new Exception('DateTime parameter value cannot be null'); } + /** @var \DateTime|string $value */ return $this->formatDateTime($value); } @@ -1854,7 +1495,7 @@ private function escapeLikeWildcards(string $value): string * 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 + * @param array $orderAttributes * @return array */ private function resolveCursorOrder(array $orderAttributes): array @@ -1887,11 +1528,12 @@ private function resolveCursorOrder(array $orderAttributes): array * actual ORDER BY at SQL build time so the page comes back from the right * side, then reversing the rows post-fetch). * - * @param array $orderAttributes - * @param array $cursor - * @param string $cursorDirection 'after' or 'before' - * @param array $params Existing params (mutated by adding cursor binds) + * @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 @@ -1901,7 +1543,7 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string $attr = $entry['attribute']; $direction = $entry['direction']; - if (!\array_key_exists($attr, $cursor)) { + if (! \array_key_exists($attr, $cursor)) { throw new Exception("Cursor is missing required attribute '{$attr}'"); } @@ -1914,7 +1556,7 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string for ($j = 0; $j < $i; $j++) { $prev = $orderAttributes[$j]; $prevAttr = $prev['attribute']; - if (!\array_key_exists($prevAttr, $cursor)) { + if (! \array_key_exists($prevAttr, $cursor)) { throw new Exception("Cursor is missing required attribute '{$prevAttr}'"); } $prevValue = $cursor[$prevAttr]; @@ -1941,11 +1583,11 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string $conditions[] = "{$escaped} {$operator} {{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $value); - $tuples[] = '(' . implode(' AND ', $conditions) . ')'; + $tuples[] = '('.implode(' AND ', $conditions).')'; } return [ - 'clause' => '(' . implode(' OR ', $tuples) . ')', + 'clause' => '('.implode(' OR ', $tuples).')', 'params' => $params, ]; } @@ -1956,8 +1598,8 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string * 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 $orderAttributes - * @param bool $flip Whether to flip ASC↔DESC + * @param array $orderAttributes + * @param bool $flip Whether to flip ASC↔DESC * @return array */ private function buildOrderBySql(array $orderAttributes, bool $flip = false): array @@ -1968,15 +1610,17 @@ private function buildOrderBySql(array $orderAttributes, bool $flip = false): ar if ($flip) { $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; } - $sql[] = $this->escapeIdentifier($entry['attribute']) . ' ' . $direction; + $sql[] = $this->escapeIdentifier($entry['attribute']).' '.$direction; } + return $sql; } /** * Create multiple audit log entries in batch. * - * @param array> $logs The logs to insert + * @param array> $logs The logs to insert + * * @throws Exception */ public function createBatch(array $logs): bool @@ -1986,7 +1630,7 @@ public function createBatch(array $logs): bool } $tableName = $this->getTableName(); - $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedDatabaseAndTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); // Get all attribute column names $schemaColumns = $this->getColumnNames(); @@ -1996,7 +1640,7 @@ public function createBatch(array $logs): bool foreach ($logs as $log) { foreach (['userId' => 'actorId', 'userType' => 'actorType', 'userInternalId' => 'actorInternalId'] as $legacy => $current) { - if (isset($log[$legacy]) && !isset($log[$current])) { + if (isset($log[$legacy]) && ! isset($log[$current])) { $log[$current] = $log[$legacy]; } unset($log[$legacy]); @@ -2006,7 +1650,7 @@ public function createBatch(array $logs): bool $logData = $log['data'] ?? []; foreach (['userId' => 'actorId', 'userType' => 'actorType', 'userInternalId' => 'actorInternalId'] as $legacy => $current) { - if (\array_key_exists($legacy, $logData) && !\array_key_exists($current, $logData)) { + if (\array_key_exists($legacy, $logData) && ! \array_key_exists($current, $logData)) { $logData[$current] = $logData[$legacy]; } unset($logData[$legacy]); @@ -2016,7 +1660,7 @@ public function createBatch(array $logs): bool // Separate data for non-schema attributes $nonSchemaData = $logData; $resourceValue = $log['resource'] ?? null; - if (!\is_string($resourceValue)) { + if (! \is_string($resourceValue)) { $resourceValue = ''; } $resource = $this->parseResource($resourceValue); @@ -2031,10 +1675,10 @@ public function createBatch(array $logs): bool continue; } // If attribute not in main log, check data array - if (!isset($processedLog[$columnName]) && isset($logData[$columnName])) { + if (! isset($processedLog[$columnName]) && isset($logData[$columnName])) { $processedLog[$columnName] = $logData[$columnName]; unset($nonSchemaData[$columnName]); - } elseif (!isset($processedLog[$columnName]) && isset($resource[$columnName])) { + } elseif (! isset($processedLog[$columnName]) && isset($resource[$columnName])) { // Check parsed resource for resourceType, resourceId, resourceParent $processedLog[$columnName] = $resource[$columnName]; } elseif (isset($processedLog[$columnName]) && isset($logData[$columnName])) { @@ -2063,14 +1707,14 @@ public function createBatch(array $logs): bool // Get attribute metadata to determine if required $attributeMetadata = $this->getAttribute($columnName); - $isRequiredAttribute = $attributeMetadata !== null && isset($attributeMetadata['required']) && $attributeMetadata['required']; + $isRequiredAttribute = $attributeMetadata !== null && $attributeMetadata->required; if ($columnName === 'data') { // Data column - encode remaining non-schema data as JSON try { $encodedData = json_encode($nonSchemaData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new Exception('Failed to encode data column to JSON: ' . $e->getMessage(), $e->getCode(), $e); + throw new Exception('Failed to encode data column to JSON: '.$e->getMessage(), $e->getCode(), $e); } $row['data'] = $encodedData; } elseif (isset($processedLog[$columnName])) { @@ -2090,6 +1734,7 @@ public function createBatch(array $logs): bool $insertSql = "INSERT INTO {$escapedDatabaseAndTable} FORMAT JSONEachRow"; $this->query($insertSql, [], $rows); + return true; } @@ -2097,8 +1742,9 @@ public function createBatch(array $logs): bool * Parse ClickHouse JSON format results into Log objects. * JSON format provides structured data with automatic type handling. * - * @param string $result The JSON response from ClickHouse + * @param string $result The JSON response from ClickHouse * @return array + * * @throws Exception If JSON parsing fails */ private function parseJsonResults(string $result): array @@ -2110,10 +1756,10 @@ private function parseJsonResults(string $result): array /** @var array|null $decoded */ $decoded = json_decode($result, true); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { - throw new Exception('Failed to parse ClickHouse JSON response: ' . json_last_error_msg()); + throw new Exception('Failed to parse ClickHouse JSON response: '.json_last_error_msg()); } - if (!\is_array($decoded) || !isset($decoded['data']) || !\is_array($decoded['data'])) { + if (! \is_array($decoded) || ! isset($decoded['data']) || ! \is_array($decoded['data'])) { return []; } @@ -2122,7 +1768,7 @@ private function parseJsonResults(string $result): array $documents = []; foreach ($data as $row) { - if (!\is_array($row)) { + if (! \is_array($row)) { continue; } @@ -2146,8 +1792,8 @@ private function parseJsonResults(string $result): array // ClickHouse JSON: "2025-12-07 23:33:54.493" // ISO 8601: "2025-12-07T23:33:54.493+00:00" $parsedTime = \is_string($value) ? $value : (\is_scalar($value) ? (string) $value : ''); - if (!str_contains($parsedTime, 'T') && $parsedTime !== '') { - $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00'; + if (! str_contains($parsedTime, 'T') && $parsedTime !== '') { + $parsedTime = str_replace(' ', 'T', $parsedTime).'+00:00'; } $document[$columnName] = $parsedTime; } else { @@ -2163,7 +1809,7 @@ private function parseJsonResults(string $result): array } foreach (['actorId' => 'userId', 'actorType' => 'userType', 'actorInternalId' => 'userInternalId'] as $current => $legacy) { - if (\array_key_exists($current, $document) && !\array_key_exists($legacy, $document)) { + if (\array_key_exists($current, $document) && ! \array_key_exists($legacy, $document)) { $document[$legacy] = $document[$current]; } } @@ -2188,8 +1834,7 @@ private function getSelectColumns(): string // Dynamically add all attribute columns except 'data' foreach ($this->getAttributes() as $attribute) { - $id = $attribute['$id']; - /** @var string $id */ + $id = $attribute->key; if ($id !== 'data') { $columns[] = $this->escapeIdentifier($id); } @@ -2212,11 +1857,12 @@ private function getSelectColumns(): string */ private function getTenantFilter(): string { - if (!$this->sharedTables || $this->tenant === null) { + if (! $this->sharedTables || $this->tenant === null) { return ''; } $escapedTenant = $this->escapeIdentifier('tenant'); + return " AND {$escapedTenant} = {$this->tenant}"; } @@ -2226,25 +1872,24 @@ private function getTenantFilter(): string * Dynamically determines the ClickHouse type based on attribute metadata. * DateTime attributes use DateTime64(3), all others use String. * - * @param string $id Attribute identifier + * @param string $id Attribute identifier * @return string ClickHouse column definition with appropriate types and nullability + * * @throws Exception */ protected function getColumnDefinition(string $id): string { $attribute = $this->getAttribute($id); - if (!$attribute) { + if (! $attribute) { throw new Exception("Attribute {$id} not found"); } - // Dynamically determine type based on attribute metadata - // DateTime attributes use DateTime64(3), all others use String - $type = (isset($attribute['type']) && $attribute['type'] === Database::VAR_DATETIME) + $type = $attribute->type === ColumnType::Datetime ? 'DateTime64(3)' : 'String'; - $required = (bool) $attribute['required']; + $required = $attribute->required; if ($type === 'String' && \in_array($id, self::LOW_CARDINALITY_COLUMNS, true)) { $columnType = $required @@ -2254,7 +1899,7 @@ protected function getColumnDefinition(string $id): string return "{$id} {$columnType}"; } - $columnType = $required ? $type : 'Nullable(' . $type . ')'; + $columnType = $required ? $type : 'Nullable('.$type.')'; return "{$id} {$columnType}"; } @@ -2508,7 +2153,7 @@ public function cleanup(\DateTime $datetime): bool { $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedTable = $this->escapeIdentifier($this->database).'.'.$this->escapeIdentifier($tableName); $datetimeString = $datetime->format('Y-m-d H:i:s.v'); diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 2f22765..53613a1 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -10,6 +10,7 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Timeout; use Utopia\Database\Query; +use Utopia\Query\Method; /** * Database Adapter for Audit @@ -72,13 +73,14 @@ public function setup(): void /** * Create an audit log entry. * - * @param array $log + * @param array $log + * * @throws AuthorizationException|\Exception */ public function create(array $log): Log { $log['time'] ??= DateTime::now(); - $document = $this->db->getAuthorization()->skip(fn(): \Utopia\Database\Document => $this->db->createDocument($this->getCollectionName(), new Document($log))); + $document = $this->db->getAuthorization()->skip(fn (): \Utopia\Database\Document => $this->db->createDocument($this->getCollectionName(), new Document($log))); return new Log($document->getArrayCopy()); } @@ -86,19 +88,21 @@ public function create(array $log): Log /** * Create multiple audit log entries in batch. * - * @param array> $logs + * @param array> $logs + * * @throws AuthorizationException|\Exception */ public function createBatch(array $logs): bool { $this->db->getAuthorization()->skip(function () use ($logs): void { $documents = array_map(function (array $log): \Utopia\Database\Document { - $time = $log['time'] ?? new \DateTime(); + $time = $log['time'] ?? new \DateTime; if (\is_string($time)) { $time = new \DateTime($time); } \assert($time instanceof \DateTime); $log['time'] = DateTime::format($time); + return new Document($log); }, $logs); $this->db->createDocuments($this->getCollectionName(), $documents); @@ -111,11 +115,12 @@ public function createBatch(array $logs): bool * Get a single log by its ID. * * @return Log|null The log entry or null if not found + * * @throws AuthorizationException|\Exception */ public function getById(string $id): ?Log { - $document = $this->db->getAuthorization()->skip(fn(): \Utopia\Database\Document => $this->db->getDocument($this->getCollectionName(), $id)); + $document = $this->db->getAuthorization()->skip(fn (): \Utopia\Database\Document => $this->db->getDocument($this->getCollectionName(), $id)); if ($document->isEmpty()) { return null; @@ -138,6 +143,7 @@ private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array if ($afterStr !== null && $beforeStr !== null) { $queries[] = Query::between('time', $afterStr, $beforeStr); + return $queries; } @@ -156,6 +162,7 @@ private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array * Get audit logs by user ID. * * @return array + * * @throws AuthorizationException|\Exception */ public function getByUser( @@ -182,7 +189,7 @@ public function getByUser( ); }); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -197,7 +204,8 @@ public function countByUser( ?int $max = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( + + return $this->db->getAuthorization()->skip(fn (): int => $this->db->count( collection: $this->getCollectionName(), queries: [ Query::equal('userId', [$userId]), @@ -211,6 +219,7 @@ public function countByUser( * Get logs by resource. * * @return array + * * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByResource( @@ -237,7 +246,7 @@ public function getByResource( ); }); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -252,7 +261,8 @@ public function countByResource( ?int $max = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( + + return $this->db->getAuthorization()->skip(fn (): int => $this->db->count( collection: $this->getCollectionName(), queries: [ Query::equal('resource', [$resource]), @@ -265,8 +275,9 @@ public function countByResource( /** * Get logs by user and events. * - * @param array $events + * @param array $events * @return array + * * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByUserAndEvents( @@ -295,13 +306,14 @@ public function getByUserAndEvents( ); }); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); } /** * Count logs by user and events. * - * @param array $events + * @param array $events + * * @throws \Utopia\Database\Exception */ public function countByUserAndEvents( @@ -312,7 +324,8 @@ public function countByUserAndEvents( ?int $max = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( + + return $this->db->getAuthorization()->skip(fn (): int => $this->db->count( collection: $this->getCollectionName(), queries: [ Query::equal('userId', [$userId]), @@ -326,8 +339,9 @@ public function countByUserAndEvents( /** * Get logs by resource and events. * - * @param array $events + * @param array $events * @return array + * * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByResourceAndEvents( @@ -356,13 +370,14 @@ public function getByResourceAndEvents( ); }); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); } /** * Count logs by resource and events. * - * @param array $events + * @param array $events + * * @throws \Utopia\Database\Exception */ public function countByResourceAndEvents( @@ -373,7 +388,8 @@ public function countByResourceAndEvents( ?int $max = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( + + return $this->db->getAuthorization()->skip(fn (): int => $this->db->count( collection: $this->getCollectionName(), queries: [ Query::equal('resource', [$resource]), @@ -387,7 +403,6 @@ public function countByResourceAndEvents( /** * Delete logs older than the specified datetime. * - * @param \DateTime $datetime /** * @throws AuthorizationException|\Exception */ @@ -421,24 +436,21 @@ public function cleanup(\DateTime $datetime): bool * type mapping internally. However, this implementation is required to satisfy * the abstract method declaration in the base SQL adapter. * - * @param string $id Attribute identifier + * @param string $id Attribute identifier * @return string Database-agnostic column description + * * @throws Exception */ protected function getColumnDefinition(string $id): string { $attribute = $this->getAttribute($id); - if (!$attribute) { + if (! $attribute) { throw new Exception("Attribute {$id} not found"); } - // For the Database adapter, we use Utopia's VAR_* type constants internally - // This method provides a description for reference purposes - /** @var string $type */ - $type = $attribute['type']; - /** @var int $size */ - $size = $attribute['size'] ?? 0; + $type = $attribute->type->value; + $size = $attribute->size; if ($size > 0) { return "{$id}: {$type}({$size})"; @@ -452,8 +464,9 @@ protected function getColumnDefinition(string $id): string * * Translates Audit Query objects to Database Query objects. * - * @param array<\Utopia\Audit\Query> $queries + * @param array<\Utopia\Audit\Query> $queries * @return array<\Utopia\Audit\Log> + * * @throws AuthorizationException|\Exception */ public function find(array $queries = []): array @@ -461,7 +474,7 @@ public function find(array $queries = []): array $dbQueries = []; foreach ($queries as $query) { - if (!($query instanceof \Utopia\Audit\Query)) { + if (! ($query instanceof \Utopia\Audit\Query)) { throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } @@ -470,12 +483,12 @@ public function find(array $queries = []): array $dbQueries[] = Query::parseQuery($query->toArray()); } - $documents = $this->db->getAuthorization()->skip(fn(): array => $this->db->find( + $documents = $this->db->getAuthorization()->skip(fn (): array => $this->db->find( collection: $this->getCollectionName(), queries: $dbQueries, )); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -484,8 +497,9 @@ public function find(array $queries = []): array * Translates Audit Query objects to Database Query objects. * Ignores limit, offset, and cursor queries as they don't apply to count. * - * @param array<\Utopia\Audit\Query> $queries - * @param int|null $max Optional upper bound (inclusive) for the count + * @param array<\Utopia\Audit\Query> $queries + * @param int|null $max Optional upper bound (inclusive) for the count + * * @throws AuthorizationException|\Exception */ public function count(array $queries = [], ?int $max = null): int @@ -493,22 +507,22 @@ public function count(array $queries = [], ?int $max = null): int $dbQueries = []; foreach ($queries as $query) { - if (!($query instanceof \Utopia\Audit\Query)) { + if (! ($query instanceof \Utopia\Audit\Query)) { throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } // Skip limit, offset, and cursor queries — they don't apply to count $method = $query->getMethod(); - if ($method === \Utopia\Audit\Query::TYPE_LIMIT) { + if ($method === Method::Limit) { continue; } - if ($method === \Utopia\Audit\Query::TYPE_OFFSET) { + if ($method === Method::Offset) { continue; } - if ($method === \Utopia\Audit\Query::TYPE_CURSOR_AFTER) { + if ($method === Method::CursorAfter) { continue; } - if ($method === \Utopia\Audit\Query::TYPE_CURSOR_BEFORE) { + if ($method === Method::CursorBefore) { continue; } @@ -518,7 +532,7 @@ public function count(array $queries = [], ?int $max = null): int $dbQueries[] = Query::parseQuery($queryArray); } - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( + return $this->db->getAuthorization()->skip(fn (): int => $this->db->count( collection: $this->getCollectionName(), queries: $dbQueries, max: $max, diff --git a/src/Audit/Adapter/SQL.php b/src/Audit/Adapter/SQL.php index f5208a3..b43b9fc 100644 --- a/src/Audit/Adapter/SQL.php +++ b/src/Audit/Adapter/SQL.php @@ -3,8 +3,11 @@ namespace Utopia\Audit\Adapter; use Utopia\Audit\Adapter; +use Utopia\Database\Attribute; use Utopia\Database\Database; -use Utopia\Database\Document; +use Utopia\Database\Index; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; /** * Base SQL Adapter for Audit @@ -27,152 +30,63 @@ public function getCollectionName(): string /** * Get attribute definitions for audit logs. * - * Each attribute is an array with the following string keys: - * - $id: string (attribute identifier) - * - type: string - * - size: int - * - required: bool - * - signed: bool - * - array: bool - * - filters: array - * - * @return array> + * @return array */ public function getAttributes(): array { return [ - [ - '$id' => 'userId', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'event', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'resource', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'userAgent', - 'type' => Database::VAR_STRING, - 'size' => 65534, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'ip', - 'type' => Database::VAR_STRING, - 'size' => 45, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'time', - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => 'data', - 'type' => Database::VAR_STRING, - 'size' => 16777216, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => ['json'], - ], + new Attribute(key: 'userId', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'event', type: ColumnType::String, size: 255, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'resource', type: ColumnType::String, size: 255, required: false, signed: true, array: false, filters: []), + new Attribute(key: 'userAgent', type: ColumnType::String, size: 65534, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'ip', type: ColumnType::String, size: 45, required: true, signed: true, array: false, filters: []), + new Attribute(key: 'time', type: ColumnType::Datetime, size: 0, required: false, signed: true, array: false, filters: ['datetime']), + new Attribute(key: 'data', type: ColumnType::String, size: 16777216, required: false, signed: true, array: false, filters: ['json']), ]; } /** - * Get attribute documents for audit logs. + * Get attribute value objects for createCollection. * - * @return array + * @return array */ public function getAttributeDocuments(): array { - return array_map(static fn(array $attribute): \Utopia\Database\Document => new Document($attribute), $this->getAttributes()); + return $this->getAttributes(); } /** * Get index definitions for audit logs. * - * Each index is an array with the following string keys: - * - $id: string (index identifier) - * - type: string - * - attributes: array - * - * @return array> + * @return array */ public function getIndexes(): array { return [ - [ - '$id' => 'idx_event', - 'type' => 'key', - 'attributes' => ['event'], - ], - [ - '$id' => 'idx_userId_event', - 'type' => 'key', - 'attributes' => ['userId', 'event'], - ], - [ - '$id' => 'idx_resource_event', - 'type' => 'key', - 'attributes' => ['resource', 'event'], - ], - [ - '$id' => 'idx_time_desc', - 'type' => 'key', - 'attributes' => ['time'], - ], + new Index(key: 'idx_event', type: IndexType::Key, attributes: ['event']), + new Index(key: 'idx_userId_event', type: IndexType::Key, attributes: ['userId', 'event']), + new Index(key: 'idx_resource_event', type: IndexType::Key, attributes: ['resource', 'event']), + new Index(key: 'idx_time_desc', type: IndexType::Key, attributes: ['time']), ]; } /** - * Get index documents for audit logs. + * Get index value objects for createCollection. * - * @return array + * @return array */ public function getIndexDocuments(): array { - return array_map(static fn(array $index): \Utopia\Database\Document => new Document($index), $this->getIndexes()); + return $this->getIndexes(); } /** - * Get a single attribute by ID. - * - * @return array|null + * Get a single attribute by key. */ - protected function getAttribute(string $id) + protected function getAttribute(string $id): ?Attribute { foreach ($this->getAttributes() as $attribute) { - if ($attribute['$id'] === $id) { + if ($attribute->key === $id) { return $attribute; } } @@ -184,7 +98,7 @@ protected function getAttribute(string $id) * Get SQL column definition for a given attribute ID. * This method is database-specific and must be implemented by each concrete adapter. * - * @param string $id Attribute identifier + * @param string $id Attribute identifier * @return string Database-specific column definition */ abstract protected function getColumnDefinition(string $id): string; @@ -199,9 +113,7 @@ protected function getAllColumnDefinitions(): array { $definitions = []; foreach ($this->getAttributes() as $attribute) { - /** @var string $id */ - $id = $attribute['$id']; - $definitions[] = $this->getColumnDefinition($id); + $definitions[] = $this->getColumnDefinition($attribute->key); } return $definitions; diff --git a/src/Audit/Query.php b/src/Audit/Query.php index 4ae063b..850a2d4 100644 --- a/src/Audit/Query.php +++ b/src/Audit/Query.php @@ -2,6 +2,7 @@ namespace Utopia\Audit; +use Utopia\Query\Method; use Utopia\Query\Query as BaseQuery; /** @@ -12,23 +13,90 @@ * `parse()` validation — while keeping audit's lenient single-value factory * signatures (the base requires arrays / scalars only; audit accepts mixed * including `DateTime` for the `time` column). + * + * Also re-exposes the legacy `TYPE_*` string constants the audit adapter and + * its tests have always used. The base library moved to a `Method` enum in + * 0.3.x; the constants here map to the same string values (`equal`, + * `lessThan`, etc.) so existing call sites keep working. */ class Query extends BaseQuery { + public const TYPE_EQUAL = 'equal'; + + public const TYPE_NOT_EQUAL = 'notEqual'; + + public const TYPE_LESSER = 'lessThan'; + + public const TYPE_LESSER_EQUAL = 'lessThanEqual'; + + public const TYPE_GREATER = 'greaterThan'; + + public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; + + public const TYPE_BETWEEN = 'between'; + + public const TYPE_NOT_BETWEEN = 'notBetween'; + + public const TYPE_CONTAINS = 'contains'; + + public const TYPE_NOT_CONTAINS = 'notContains'; + + public const TYPE_IS_NULL = 'isNull'; + + public const TYPE_IS_NOT_NULL = 'isNotNull'; + + public const TYPE_STARTS_WITH = 'startsWith'; + + public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; + + public const TYPE_ENDS_WITH = 'endsWith'; + + public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + + public const TYPE_REGEX = 'regex'; + + public const TYPE_SELECT = 'select'; + + public const TYPE_ORDER_DESC = 'orderDesc'; + + public const TYPE_ORDER_ASC = 'orderAsc'; + + public const TYPE_ORDER_RANDOM = 'orderRandom'; + + public const TYPE_LIMIT = 'limit'; + + public const TYPE_OFFSET = 'offset'; + + public const TYPE_CURSOR_AFTER = 'cursorAfter'; + + public const TYPE_CURSOR_BEFORE = 'cursorBefore'; + + /** + * Construct a query with a string method name (legacy `TYPE_*` constants) + * or a `Method` enum case (new query-lib API). + * + * @param array $values + */ + public function __construct(Method|string $method, string $attribute = '', array $values = []) + { + parent::__construct($method, $attribute, $values); + } + /** * Filter by equal condition. * * Accepts a single scalar/object/array value and stores it as the values * array. Matches the legacy audit signature. * - * @param mixed $value Single value or array of values + * @param mixed $value Single value or array of values */ #[\Override] public static function equal(string $attribute, mixed $value): static { /** @var array $values */ $values = \is_array($value) ? $value : [$value]; - return new static(self::TYPE_EQUAL, $attribute, $values); + + return new static(Method::Equal, $attribute, $values); } /** @@ -40,7 +108,7 @@ public static function equal(string $attribute, mixed $value): static #[\Override] public static function lessThan(string $attribute, mixed $value): static { - return new static(self::TYPE_LESSER, $attribute, [$value]); + return new static(Method::LessThan, $attribute, [$value]); } /** @@ -49,7 +117,7 @@ public static function lessThan(string $attribute, mixed $value): static #[\Override] public static function greaterThan(string $attribute, mixed $value): static { - return new static(self::TYPE_GREATER, $attribute, [$value]); + return new static(Method::GreaterThan, $attribute, [$value]); } /** @@ -58,6 +126,6 @@ public static function greaterThan(string $attribute, mixed $value): static #[\Override] public static function between(string $attribute, mixed $start, mixed $end): static { - return new static(self::TYPE_BETWEEN, $attribute, [$start, $end]); + return new static(Method::Between, $attribute, [$start, $end]); } } diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index b95a8cd..b01a59f 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -70,7 +70,7 @@ protected function getRequiredAttributes(): array /** * Test constructor validates host */ - public function testConstructorValidatesHost(): void + public function test_constructor_validates_host(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('ClickHouse host is not a valid hostname or IP address'); @@ -85,7 +85,7 @@ public function testConstructorValidatesHost(): void /** * Test constructor validates port range */ - public function testConstructorValidatesPortTooLow(): void + public function test_constructor_validates_port_too_low(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535'); @@ -101,7 +101,7 @@ public function testConstructorValidatesPortTooLow(): void /** * Test constructor validates port range upper bound */ - public function testConstructorValidatesPortTooHigh(): void + public function test_constructor_validates_port_too_high(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535'); @@ -117,7 +117,7 @@ public function testConstructorValidatesPortTooHigh(): void /** * Test constructor with valid parameters */ - public function testConstructorWithValidParameters(): void + public function test_constructor_with_valid_parameters(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -134,7 +134,7 @@ public function testConstructorWithValidParameters(): void /** * Test getName returns correct adapter name */ - public function testGetName(): void + public function test_get_name(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -148,7 +148,7 @@ public function testGetName(): void /** * Test setDatabase validates empty identifier */ - public function testSetDatabaseValidatesEmpty(): void + public function test_set_database_validates_empty(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Database cannot be empty'); @@ -165,7 +165,7 @@ public function testSetDatabaseValidatesEmpty(): void /** * Test setDatabase validates identifier length */ - public function testSetDatabaseValidatesLength(): void + public function test_set_database_validates_length(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Database cannot exceed 255 characters'); @@ -182,7 +182,7 @@ public function testSetDatabaseValidatesLength(): void /** * Test setDatabase validates identifier format */ - public function testSetDatabaseValidatesFormat(): void + public function test_set_database_validates_format(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Database must start with a letter or underscore'); @@ -199,7 +199,7 @@ public function testSetDatabaseValidatesFormat(): void /** * Test setDatabase rejects SQL keywords */ - public function testSetDatabaseRejectsKeywords(): void + public function test_set_database_rejects_keywords(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Database cannot be a reserved SQL keyword'); @@ -216,7 +216,7 @@ public function testSetDatabaseRejectsKeywords(): void /** * Test setDatabase with valid identifier */ - public function testSetDatabaseWithValidIdentifier(): void + public function test_set_database_with_valid_identifier(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -231,7 +231,7 @@ public function testSetDatabaseWithValidIdentifier(): void /** * Test setTable validates empty identifier */ - public function testSetTableValidatesEmpty(): void + public function test_set_table_validates_empty(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Table cannot be empty'); @@ -248,7 +248,7 @@ public function testSetTableValidatesEmpty(): void /** * Test setTable validates identifier length */ - public function testSetTableValidatesLength(): void + public function test_set_table_validates_length(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Table cannot exceed 255 characters'); @@ -265,7 +265,7 @@ public function testSetTableValidatesLength(): void /** * Test setTable validates identifier format */ - public function testSetTableValidatesFormat(): void + public function test_set_table_validates_format(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Table must start with a letter or underscore'); @@ -282,7 +282,7 @@ public function testSetTableValidatesFormat(): void /** * Test setTable rejects SQL keywords */ - public function testSetTableRejectsKeywords(): void + public function test_set_table_rejects_keywords(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Table cannot be a reserved SQL keyword'); @@ -299,7 +299,7 @@ public function testSetTableRejectsKeywords(): void /** * Test setTable with valid identifier */ - public function testSetTableWithValidIdentifier(): void + public function test_set_table_with_valid_identifier(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -315,7 +315,7 @@ public function testSetTableWithValidIdentifier(): void /** * Test setNamespace allows empty string */ - public function testSetNamespaceAllowsEmpty(): void + public function test_set_namespace_allows_empty(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -331,7 +331,7 @@ public function testSetNamespaceAllowsEmpty(): void /** * Test setNamespace validates identifier format */ - public function testSetNamespaceValidatesFormat(): void + public function test_set_namespace_validates_format(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Namespace must start with a letter or underscore'); @@ -348,7 +348,7 @@ public function testSetNamespaceValidatesFormat(): void /** * Test setNamespace with valid identifier */ - public function testSetNamespaceWithValidIdentifier(): void + public function test_set_namespace_with_valid_identifier(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -364,7 +364,7 @@ public function testSetNamespaceWithValidIdentifier(): void /** * Test setSecure method */ - public function testSetSecure(): void + public function test_set_secure(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -381,7 +381,7 @@ public function testSetSecure(): void /** * Test setRetention stores the value and getRetention returns it */ - public function testSetRetention(): void + public function test_set_retention(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -399,7 +399,7 @@ public function testSetRetention(): void /** * Test setRetention accepts null to disable retention */ - public function testSetRetentionAcceptsNull(): void + public function test_set_retention_accepts_null(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -415,7 +415,7 @@ public function testSetRetentionAcceptsNull(): void /** * Test setRetention rejects zero days */ - public function testSetRetentionRejectsZero(): void + public function test_set_retention_rejects_zero(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Retention must be a positive number of days'); @@ -432,7 +432,7 @@ public function testSetRetentionRejectsZero(): void /** * Test setRetention rejects negative days */ - public function testSetRetentionRejectsNegative(): void + public function test_set_retention_rejects_negative(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Retention must be a positive number of days'); @@ -449,7 +449,7 @@ public function testSetRetentionRejectsNegative(): void /** * Test shared tables configuration */ - public function testSharedTablesConfiguration(): void + public function test_shared_tables_configuration(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -479,7 +479,7 @@ public function testSharedTablesConfiguration(): void /** * Test batch operations with special characters */ - public function testBatchOperationsWithSpecialCharacters(): void + public function test_batch_operations_with_special_characters(): void { // Test batch with special characters in data $batchEvents = [ @@ -506,7 +506,7 @@ public function testBatchOperationsWithSpecialCharacters(): void /** * Test that ClickHouse adapter has all required attributes */ - public function testClickHouseAdapterAttributes(): void + public function test_click_house_adapter_attributes(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -515,7 +515,7 @@ public function testClickHouseAdapterAttributes(): void ); $attributes = $adapter->getAttributes(); - $attributeIds = array_map(fn(array $attr): mixed => $attr['$id'], $attributes); + $attributeIds = array_map(fn ($attr): string => $attr->key, $attributes); // Verify all expected attributes exist $expectedAttributes = [ @@ -576,7 +576,7 @@ public function testClickHouseAdapterAttributes(): void * name/code/type dimensions use LowCardinality(Nullable(String)), while * high-cardinality version/model strings stay plain Nullable(String). */ - public function testUserAgentColumnTypes(): void + public function test_user_agent_column_types(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -616,7 +616,7 @@ public function testUserAgentColumnTypes(): void /** * Test that premium geo attributes are all optional String columns. */ - public function testPremiumGeoAttributesAreOptionalStrings(): void + public function test_premium_geo_attributes_are_optional_strings(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -627,7 +627,7 @@ public function testPremiumGeoAttributesAreOptionalStrings(): void $attributes = $adapter->getAttributes(); $byId = []; foreach ($attributes as $attribute) { - $byId[$attribute['$id']] = $attribute; + $byId[$attribute->key] = $attribute; } $geoColumns = [ @@ -644,9 +644,9 @@ public function testPremiumGeoAttributesAreOptionalStrings(): void foreach ($geoColumns as $column) { $this->assertArrayHasKey($column, $byId, "Premium geo attribute '{$column}' not found"); - $this->assertEquals(\Utopia\Database\Database::VAR_STRING, $byId[$column]['type'], "'{$column}' should be a string"); - $this->assertFalse($byId[$column]['required'], "'{$column}' should be optional"); - $this->assertFalse($byId[$column]['array'], "'{$column}' should not be an array"); + $this->assertSame(\Utopia\Query\Schema\ColumnType::String, $byId[$column]->type, "'{$column}' should be a string"); + $this->assertFalse($byId[$column]->required, "'{$column}' should be optional"); + $this->assertFalse($byId[$column]->array, "'{$column}' should not be an array"); } } @@ -655,7 +655,7 @@ public function testPremiumGeoAttributesAreOptionalStrings(): void * low-cardinality dimensions use LowCardinality(Nullable(String)), while * high-cardinality ones stay plain Nullable(String). */ - public function testPremiumGeoColumnTypes(): void + public function test_premium_geo_column_types(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -697,9 +697,9 @@ public function testPremiumGeoColumnTypes(): void * proves the columns are actually created, written and selected (not just * present in the schema definition). */ - public function testPremiumGeoRoundTrip(): void + public function test_premium_geo_round_trip(): void { - $actorId = 'geo-actor-' . uniqid('', true); + $actorId = 'geo-actor-'.uniqid('', true); $geo = [ 'city' => 'Mountain View', 'continentCode' => 'NA', @@ -738,9 +738,9 @@ public function testPremiumGeoRoundTrip(): void * User-agent OS / client / device values must round-trip through a real * write/read cycle, proving the columns are created, written and selected. */ - public function testUserAgentRoundTrip(): void + public function test_user_agent_round_trip(): void { - $actorId = 'ua-actor-' . uniqid('', true); + $actorId = 'ua-actor-'.uniqid('', true); $ua = [ 'osCode' => 'IOS', 'osName' => 'iOS', @@ -796,7 +796,7 @@ public function testUserAgentRoundTrip(): void /** * Test that ClickHouse adapter has all required indexes */ - public function testClickHouseAdapterIndexes(): void + public function test_click_house_adapter_indexes(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -805,7 +805,7 @@ public function testClickHouseAdapterIndexes(): void ); $indexes = $adapter->getIndexes(); - $indexIds = array_map(fn(array $idx): mixed => $idx['$id'], $indexes); + $indexIds = array_map(fn ($idx): string => $idx->key, $indexes); // Verify all ClickHouse-specific indexes exist $expectedClickHouseIndexes = [ @@ -833,7 +833,7 @@ public function testClickHouseAdapterIndexes(): void /** * Test parsing of complex resource paths into resourceType/resourceId/resourceParent */ - public function testParseResourceComplexPath(): void + public function test_parse_resource_complex_path(): void { $actorId = 'parseActor'; $userAgent = 'UnitTestAgent/1.0'; @@ -862,7 +862,7 @@ public function testParseResourceComplexPath(): void /** * Directly test the protected parseResource method via reflection. */ - public function testParseResourceMethod(): void + public function test_parse_resource_method(): void { $adapter = new ClickHouse( host: 'clickhouse', @@ -909,7 +909,7 @@ public function testParseResourceMethod(): void $this->assertEquals('', $parsedOdd['resourceParent']); } - public function testCursorAfterPaginatesLogs(): void + public function test_cursor_after_paginates_logs(): void { $page1 = $this->audit->find([ Query::orderAsc('id'), @@ -931,7 +931,7 @@ public function testCursorAfterPaginatesLogs(): void } } - public function testCursorBeforeReversesPagination(): void + public function test_cursor_before_reverses_pagination(): void { $all = $this->audit->find([ Query::orderAsc('id'), @@ -951,7 +951,7 @@ public function testCursorBeforeReversesPagination(): void $this->assertEquals($all[\count($all) - 2]->getId(), $before[1]->getId()); } - public function testCursorAcceptsAssociativeArray(): void + public function test_cursor_accepts_associative_array(): void { $all = $this->audit->find([ Query::orderAsc('id'), @@ -970,7 +970,7 @@ public function testCursorAcceptsAssociativeArray(): void $this->assertEquals($all[1]->getId(), $page[0]->getId()); } - public function testCountWithMaxBound(): void + public function test_count_with_max_bound(): void { $unbounded = $this->audit->count(); $this->assertGreaterThanOrEqual(4, $unbounded); @@ -982,7 +982,7 @@ public function testCountWithMaxBound(): void $this->assertSame($unbounded, $boundedAboveTotal); } - public function testCountByUserWithMaxBound(): void + public function test_count_by_user_with_max_bound(): void { $unbounded = $this->audit->countLogsByUser('userId'); $this->assertSame(3, $unbounded); @@ -991,7 +991,7 @@ public function testCountByUserWithMaxBound(): void $this->assertSame(1, $bounded); } - public function testNotEqualQuery(): void + public function test_not_equal_query(): void { // Fixture: 3x event=update/delete for actor, plus 1x event=insert for null actor $logs = $this->audit->find([ @@ -1004,7 +1004,7 @@ public function testNotEqualQuery(): void } } - public function testContainsSubstringQuery(): void + public function test_contains_substring_query(): void { // Contains is a substring match (like utopia-php/database), not an // exact IN match — 'dat' matches only the 'update' logs @@ -1023,7 +1023,7 @@ public function testContainsSubstringQuery(): void $this->assertCount(3, $logs); } - public function testContainsEscapesLikeWildcards(): void + public function test_contains_escapes_like_wildcards(): void { // '%' and '_' in needles are literals, not LIKE wildcards — // no fixture event contains a literal '%' @@ -1038,7 +1038,7 @@ public function testContainsEscapesLikeWildcards(): void $this->assertCount(0, $logs); } - public function testNotContainsQuery(): void + public function test_not_contains_query(): void { $logs = $this->audit->find([ Query::notContains('event', ['update', 'delete']), @@ -1058,7 +1058,7 @@ public function testNotContainsQuery(): void } } - public function testLesserEqualAndGreaterEqualQueries(): void + public function test_lesser_equal_and_greater_equal_queries(): void { $now = new \DateTime()->modify('+1 minute'); $past = new \DateTime()->modify('-1 hour'); @@ -1079,7 +1079,7 @@ public function testLesserEqualAndGreaterEqualQueries(): void $this->assertGreaterThanOrEqual(4, \count($allGe)); } - public function testNotBetweenQuery(): void + public function test_not_between_query(): void { $past = new \DateTime()->modify('-2 hour'); $oldPast = new \DateTime()->modify('-3 hour'); @@ -1095,7 +1095,7 @@ public function testNotBetweenQuery(): void $this->assertGreaterThanOrEqual(4, \count($logs)); } - public function testIsNullAndIsNotNullQueries(): void + public function test_is_null_and_is_not_null_queries(): void { $nullActor = $this->audit->find([ Query::isNull('actorId'), @@ -1110,7 +1110,7 @@ public function testIsNullAndIsNotNullQueries(): void $this->assertCount(3, $notNullActor); } - public function testStartsWithAndEndsWithQueries(): void + public function test_starts_with_and_ends_with_queries(): void { $resourcePrefix = $this->audit->find([ Query::startsWith('resource', 'database/'), @@ -1129,7 +1129,7 @@ public function testStartsWithAndEndsWithQueries(): void $this->assertEquals('user/null', $endsWithNull[0]->getResource()); } - public function testContainsRejectsEmptyValues(): void + public function test_contains_rejects_empty_values(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Contains queries require at least one value.'); @@ -1139,7 +1139,7 @@ public function testContainsRejectsEmptyValues(): void ]); } - public function testNotContainsRejectsEmptyValues(): void + public function test_not_contains_rejects_empty_values(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('NotContains queries require at least one value.'); @@ -1149,7 +1149,7 @@ public function testNotContainsRejectsEmptyValues(): void ]); } - public function testEqualRejectsEmptyValues(): void + public function test_equal_rejects_empty_values(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Equal queries require at least one value.'); @@ -1159,7 +1159,7 @@ public function testEqualRejectsEmptyValues(): void ]); } - public function testSelectProjectsRequestedColumns(): void + public function test_select_projects_requested_columns(): void { $logs = $this->audit->find([ Query::select(['event', 'resource']), @@ -1181,7 +1181,7 @@ public function testSelectProjectsRequestedColumns(): void $this->assertArrayNotHasKey('data', $row); } - public function testSelectAutoIncludesTenantWhenShared(): void + public function test_select_auto_includes_tenant_when_shared(): void { $host = getenv('CLICKHOUSE_HOST') ?: 'localhost'; $port = (int) (getenv('CLICKHOUSE_PORT') ?: 18123); @@ -1214,7 +1214,7 @@ public function testSelectAutoIncludesTenantWhenShared(): void $this->assertArrayHasKey('tenant', $row); } - public function testSelectRejectsUnknownColumn(): void + public function test_select_rejects_unknown_column(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Invalid attribute name: bogus_column'); @@ -1224,7 +1224,7 @@ public function testSelectRejectsUnknownColumn(): void ]); } - public function testSelectRejectsEmptyValues(): void + public function test_select_rejects_empty_values(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Select queries require at least one value.'); @@ -1234,7 +1234,7 @@ public function testSelectRejectsEmptyValues(): void ]); } - public function testNotStartsWithFilter(): void + public function test_not_starts_with_filter(): void { $logs = $this->audit->find([ Query::notStartsWith('resource', 'database/'), @@ -1244,7 +1244,7 @@ public function testNotStartsWithFilter(): void $this->assertEquals('user/null', $logs[0]->getResource()); } - public function testNotEndsWithFilter(): void + public function test_not_ends_with_filter(): void { $logs = $this->audit->find([ Query::notEndsWith('resource', '/null'), @@ -1256,7 +1256,7 @@ public function testNotEndsWithFilter(): void } } - public function testRegexFilter(): void + public function test_regex_filter(): void { $logs = $this->audit->find([ Query::regex('resource', '^database/document/\\d+$'), @@ -1265,7 +1265,7 @@ public function testRegexFilter(): void $this->assertCount(3, $logs); } - public function testOrderRandomReturnsRows(): void + public function test_order_random_returns_rows(): void { $logs = $this->audit->find([ Query::orderRandom(), @@ -1275,7 +1275,7 @@ public function testOrderRandomReturnsRows(): void $this->assertCount(2, $logs); } - public function testOrderRandomRejectedWithCursor(): void + public function test_order_random_rejected_with_cursor(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Cursor pagination cannot be combined with orderRandom'); @@ -1286,7 +1286,7 @@ public function testOrderRandomRejectedWithCursor(): void ]); } - public function testOrderRandomRejectedWithColumnOrder(): void + public function test_order_random_rejected_with_column_order(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('orderRandom cannot be combined with orderAsc/orderDesc'); @@ -1297,7 +1297,7 @@ public function testOrderRandomRejectedWithColumnOrder(): void ]); } - public function testSharedTableSortKeyLeadsWithTenant(): void + public function test_shared_table_sort_key_leads_with_tenant(): void { $host = getenv('CLICKHOUSE_HOST') ?: 'localhost'; $username = getenv('CLICKHOUSE_USER') ?: 'default'; @@ -1306,7 +1306,7 @@ public function testSharedTableSortKeyLeadsWithTenant(): void $secure = filter_var(getenv('CLICKHOUSE_SECURE') ?: false, FILTER_VALIDATE_BOOLEAN); $database = getenv('CLICKHOUSE_DATABASE') ?: 'default'; - $namespace = 'projtest_' . uniqid(); + $namespace = 'projtest_'.uniqid(); $adapter = new ClickHouse( host: $host, @@ -1320,15 +1320,15 @@ public function testSharedTableSortKeyLeadsWithTenant(): void $adapter->setSharedTables(true); $adapter->setTenant(1); - $table = $namespace . '_audits'; + $table = $namespace.'_audits'; $http = function (string $sql, array $params = []) use ($host, $port, $username, $password, $secure, $database): string { $scheme = $secure ? 'https' : 'http'; - $url = "{$scheme}://{$host}:{$port}/?database=" . rawurlencode($database) - . '&user=' . rawurlencode($username) - . '&password=' . rawurlencode($password); + $url = "{$scheme}://{$host}:{$port}/?database=".rawurlencode($database) + .'&user='.rawurlencode($username) + .'&password='.rawurlencode($password); foreach ($params as $key => $value) { - $url .= '¶m_' . rawurlencode((string) $key) . '=' . rawurlencode((string) $value); + $url .= '¶m_'.rawurlencode((string) $key).'='.rawurlencode((string) $value); } $ctx = stream_context_create(['http' => [ 'method' => 'POST', @@ -1355,8 +1355,8 @@ public function testSharedTableSortKeyLeadsWithTenant(): void "Expected sorting key to lead with 'tenant', got: {$sortingKey}", ); } finally { - $escDb = '`' . str_replace('`', '``', $database) . '`'; - $escTbl = '`' . str_replace('`', '``', $table) . '`'; + $escDb = '`'.str_replace('`', '``', $database).'`'; + $escTbl = '`'.str_replace('`', '``', $table).'`'; $http("DROP TABLE IF EXISTS {$escDb}.{$escTbl}"); } } diff --git a/tests/Audit/QueryTest.php b/tests/Audit/QueryTest.php index 7c29712..c044373 100644 --- a/tests/Audit/QueryTest.php +++ b/tests/Audit/QueryTest.php @@ -12,59 +12,59 @@ final class QueryTest extends TestCase /** * Test Query class static factory methods */ - public function testQueryStaticFactoryMethods(): void + public function test_query_static_factory_methods(): void { // Test equal $query = Query::equal('userId', '123'); - $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()->value); $this->assertSame('userId', $query->getAttribute()); $this->assertSame(['123'], $query->getValues()); // Test lessThan $query = Query::lessThan('time', '2024-01-01'); - $this->assertSame(Query::TYPE_LESSER, $query->getMethod()); + $this->assertSame(Query::TYPE_LESSER, $query->getMethod()->value); $this->assertSame('time', $query->getAttribute()); $this->assertSame(['2024-01-01'], $query->getValues()); // Test greaterThan $query = Query::greaterThan('time', '2023-01-01'); - $this->assertSame(Query::TYPE_GREATER, $query->getMethod()); + $this->assertSame(Query::TYPE_GREATER, $query->getMethod()->value); $this->assertSame('time', $query->getAttribute()); $this->assertSame(['2023-01-01'], $query->getValues()); // Test between $query = Query::between('time', '2023-01-01', '2024-01-01'); - $this->assertSame(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertSame(Query::TYPE_BETWEEN, $query->getMethod()->value); $this->assertSame('time', $query->getAttribute()); $this->assertSame(['2023-01-01', '2024-01-01'], $query->getValues()); // Test contains $query = Query::contains('event', ['create', 'update', 'delete']); - $this->assertSame(Query::TYPE_CONTAINS, $query->getMethod()); + $this->assertSame(Query::TYPE_CONTAINS, $query->getMethod()->value); $this->assertSame('event', $query->getAttribute()); $this->assertSame(['create', 'update', 'delete'], $query->getValues()); // Test orderDesc $query = Query::orderDesc('time'); - $this->assertSame(Query::TYPE_ORDER_DESC, $query->getMethod()); + $this->assertSame(Query::TYPE_ORDER_DESC, $query->getMethod()->value); $this->assertSame('time', $query->getAttribute()); $this->assertSame([], $query->getValues()); // Test orderAsc $query = Query::orderAsc('userId'); - $this->assertSame(Query::TYPE_ORDER_ASC, $query->getMethod()); + $this->assertSame(Query::TYPE_ORDER_ASC, $query->getMethod()->value); $this->assertSame('userId', $query->getAttribute()); $this->assertSame([], $query->getValues()); // Test limit $query = Query::limit(10); - $this->assertSame(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertSame(Query::TYPE_LIMIT, $query->getMethod()->value); $this->assertSame('', $query->getAttribute()); $this->assertSame([10], $query->getValues()); // Test offset $query = Query::offset(5); - $this->assertSame(Query::TYPE_OFFSET, $query->getMethod()); + $this->assertSame(Query::TYPE_OFFSET, $query->getMethod()->value); $this->assertSame('', $query->getAttribute()); $this->assertSame([5], $query->getValues()); } @@ -72,12 +72,12 @@ public function testQueryStaticFactoryMethods(): void /** * Test Query parse and toString methods */ - public function testQueryParseAndToString(): void + public function test_query_parse_and_to_string(): void { // Test parsing equal query $json = '{"method":"equal","attribute":"userId","values":["123"]}'; $query = Query::parse($json); - $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()->value); $this->assertSame('userId', $query->getAttribute()); $this->assertSame(['123'], $query->getValues()); @@ -87,7 +87,7 @@ public function testQueryParseAndToString(): void $this->assertJson($json); $parsed = Query::parse($json); - $this->assertSame(Query::TYPE_EQUAL, $parsed->getMethod()); + $this->assertSame(Query::TYPE_EQUAL, $parsed->getMethod()->value); $this->assertSame('event', $parsed->getAttribute()); $this->assertSame(['create'], $parsed->getValues()); @@ -104,7 +104,7 @@ public function testQueryParseAndToString(): void /** * Test Query parseQueries method */ - public function testQueryParseQueries(): void + public function test_query_parse_queries(): void { $queries = [ '{"method":"equal","attribute":"userId","values":["123"]}', @@ -119,15 +119,15 @@ public function testQueryParseQueries(): void $this->assertInstanceOf(Query::class, $parsed[1]); $this->assertInstanceOf(Query::class, $parsed[2]); - $this->assertSame(Query::TYPE_EQUAL, $parsed[0]->getMethod()); - $this->assertSame(Query::TYPE_GREATER, $parsed[1]->getMethod()); - $this->assertSame(Query::TYPE_LIMIT, $parsed[2]->getMethod()); + $this->assertSame(Query::TYPE_EQUAL, $parsed[0]->getMethod()->value); + $this->assertSame(Query::TYPE_GREATER, $parsed[1]->getMethod()->value); + $this->assertSame(Query::TYPE_LIMIT, $parsed[2]->getMethod()->value); } /** * Test Query getValue method */ - public function testGetValue(): void + public function test_get_value(): void { $query = Query::equal('userId', '123'); $this->assertEquals('123', $query->getValue()); @@ -144,7 +144,7 @@ public function testGetValue(): void /** * Test Query with empty attribute */ - public function testQueryWithEmptyAttribute(): void + public function test_query_with_empty_attribute(): void { $query = Query::limit(25); $this->assertSame('', $query->getAttribute()); @@ -158,7 +158,7 @@ public function testQueryWithEmptyAttribute(): void /** * Test Query parse with invalid JSON */ - public function testQueryParseInvalidJson(): void + public function test_query_parse_invalid_json(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Invalid query'); @@ -169,7 +169,7 @@ public function testQueryParseInvalidJson(): void /** * Test Query parse with non-array value */ - public function testQueryParseNonArray(): void + public function test_query_parse_non_array(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Invalid query. Must be an array'); @@ -180,7 +180,7 @@ public function testQueryParseNonArray(): void /** * Test Query parse with invalid method type */ - public function testQueryParseInvalidMethodType(): void + public function test_query_parse_invalid_method_type(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Invalid query method. Must be a string'); @@ -191,7 +191,7 @@ public function testQueryParseInvalidMethodType(): void /** * Test Query parse with invalid attribute type */ - public function testQueryParseInvalidAttributeType(): void + public function test_query_parse_invalid_attribute_type(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Invalid query attribute. Must be a string'); @@ -202,7 +202,7 @@ public function testQueryParseInvalidAttributeType(): void /** * Test Query parse with invalid values type */ - public function testQueryParseInvalidValuesType(): void + public function test_query_parse_invalid_values_type(): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Invalid query values. Must be an array'); @@ -213,14 +213,14 @@ public function testQueryParseInvalidValuesType(): void /** * Test Query toString with complex values */ - public function testQueryToStringWithComplexValues(): void + public function test_query_to_string_with_complex_values(): void { $query = Query::between('time', '2023-01-01', '2024-12-31'); $json = $query->toString(); $this->assertJson($json); $parsed = Query::parse($json); - $this->assertSame(Query::TYPE_BETWEEN, $parsed->getMethod()); + $this->assertSame(Query::TYPE_BETWEEN, $parsed->getMethod()->value); $this->assertSame('time', $parsed->getAttribute()); $this->assertSame(['2023-01-01', '2024-12-31'], $parsed->getValues()); } From 9c7f6db7757333454260df79c15d7b269829ee86 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 19 Aug 2026 04:16:23 +1200 Subject: [PATCH 2/8] (chore): allow validators 0.5 alongside 0.4 Needed so Appwrite can take main's queue 1.3 pin. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index df86d63..b3ec7de 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "utopia-php/database": "dev-feat-query-lib as 7.0.0", "utopia-php/fetch": "^1.1", "utopia-php/query": "0.4.*", - "utopia-php/validators": "^0.4" + "utopia-php/validators": "^0.4 || ^0.5" }, "repositories": [ { From 9688d08e8ffee9633159ad12e5a196f71b0aaec7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 21 Aug 2026 16:24:24 +1200 Subject: [PATCH 3/8] (fix): reconstruct Attribute VOs and disable persistent PDO in tests ClickHouse was mutating parent Attribute/Index instances in place. MariaDB tests were also losing event=delete rows because persistent PDO plus a new adapter per PHPUnit setUp rolled back uncommitted writes. --- src/Audit/Adapter/ClickHouse.php | 38 +++++++++++++++++----------- src/Audit/Adapter/Database.php | 1 - src/Audit/Query.php | 14 ++++++++++ tests/Audit/Adapter/DatabaseTest.php | 4 ++- tests/Audit/QueryTest.php | 1 - 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index fdb6955..c53f038 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -390,9 +390,17 @@ public function getAttributes(): array { $parentAttributes = parent::getAttributes(); - foreach ($parentAttributes as $attribute) { + foreach ($parentAttributes as $index => $attribute) { if ($attribute->key === 'userId') { - $attribute->key = 'actorId'; + $parentAttributes[$index] = new Attribute( + key: 'actorId', + type: $attribute->type, + size: $attribute->size, + required: $attribute->required, + signed: $attribute->signed, + array: $attribute->array, + filters: $attribute->filters, + ); break; } } @@ -448,10 +456,16 @@ public function getIndexes(): array { $parentIndexes = parent::getIndexes(); - foreach ($parentIndexes as $index) { - if ($index->key === 'idx_userId_event') { - $index->key = 'idx_actorId_event'; - $index->attributes = ['actorId', 'event']; + foreach ($parentIndexes as $index => $definition) { + if ($definition->key === 'idx_userId_event') { + $parentIndexes[$index] = new Index( + key: 'idx_actorId_event', + type: $definition->type, + attributes: ['actorId', 'event'], + lengths: $definition->lengths, + orders: $definition->orders, + ttl: $definition->ttl, + ); break; } } @@ -719,15 +733,6 @@ private function getColumnNames(): array return $columns; } - /** - * Validate that an attribute name exists in the schema. - * Prevents SQL injection by ensuring only valid column names are used. - * - * @param string $attributeName The attribute name to validate - * @return bool True if valid - * - * @throws Exception If attribute name is invalid - */ /** * Translate legacy user* attribute names to actor* column names. */ @@ -741,6 +746,9 @@ private function translateAttribute(string $attribute): string }; } + /** + * @throws Exception If the name is not a known column + */ private function validateAttributeName(string $attributeName): bool { // Special case: 'id' is always valid diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 53613a1..47815cf 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -403,7 +403,6 @@ public function countByResourceAndEvents( /** * Delete logs older than the specified datetime. * - /** * @throws AuthorizationException|\Exception */ public function cleanup(\DateTime $datetime): bool diff --git a/src/Audit/Query.php b/src/Audit/Query.php index 850a2d4..0350c13 100644 --- a/src/Audit/Query.php +++ b/src/Audit/Query.php @@ -128,4 +128,18 @@ public static function between(string $attribute, mixed $start, mixed $end): sta { return new static(Method::Between, $attribute, [$start, $end]); } + + /** + * Filter by contains condition. + * + * @param mixed $value Single value or array of values + */ + #[\Override] + public static function contains(string $attribute, mixed $value): static + { + /** @var array $values */ + $values = \is_array($value) ? $value : [$value]; + + return new static(Method::Contains, $attribute, $values); + } } diff --git a/tests/Audit/Adapter/DatabaseTest.php b/tests/Audit/Adapter/DatabaseTest.php index f1a818c..3a9ed86 100644 --- a/tests/Audit/Adapter/DatabaseTest.php +++ b/tests/Audit/Adapter/DatabaseTest.php @@ -28,7 +28,9 @@ protected function initializeAudit(): void $dbUser = 'root'; $dbPass = 'password'; - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPdoAttributes()); + $attributes = MariaDB::getPdoAttributes(); + $attributes[PDO::ATTR_PERSISTENT] = false; + $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, $attributes); $cache = new Cache(new NoCache()); $database = new Database(new MariaDB($pdo), $cache); $database->setDatabase('utopiaTests'); diff --git a/tests/Audit/QueryTest.php b/tests/Audit/QueryTest.php index c044373..86cf652 100644 --- a/tests/Audit/QueryTest.php +++ b/tests/Audit/QueryTest.php @@ -38,7 +38,6 @@ public function test_query_static_factory_methods(): void $this->assertSame('time', $query->getAttribute()); $this->assertSame(['2023-01-01', '2024-01-01'], $query->getValues()); - // Test contains $query = Query::contains('event', ['create', 'update', 'delete']); $this->assertSame(Query::TYPE_CONTAINS, $query->getMethod()->value); $this->assertSame('event', $query->getAttribute()); From e1700e518253e8abadd1d325250ebbe80a433820 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 21 Aug 2026 16:48:43 +1200 Subject: [PATCH 4/8] (refactor): prefer Attribute and Index typed factories Schema definitions with a known column or index type now use Attribute::string() / Index::key() instead of the generic constructors plus ColumnType / IndexType. --- src/Audit/Adapter/ClickHouse.php | 87 ++++++++++++++++---------------- src/Audit/Adapter/SQL.php | 24 ++++----- 2 files changed, 54 insertions(+), 57 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index c53f038..52765bb 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -11,7 +11,6 @@ use Utopia\Fetch\Client; use Utopia\Query\Method; use Utopia\Query\Schema\ColumnType; -use Utopia\Query\Schema\IndexType; use Utopia\Validator\Hostname; /** @@ -407,41 +406,41 @@ public function getAttributes(): array return [ ...$parentAttributes, - new Attribute(key: 'actorType', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'actorInternalId', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'resourceParent', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'resourceType', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'resourceId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'resourceInternalId', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'country', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'city', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'continentCode', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'subdivisions', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'isp', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'autonomousSystemNumber', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'autonomousSystemOrganization', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'connectionType', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'connectionUsageType', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'connectionOrganization', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'projectId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'projectInternalId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'teamId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'teamInternalId', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'hostname', type: ColumnType::String, size: Database::LENGTH_KEY, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'sdk', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'sdkVersion', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'osCode', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'osName', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'osVersion', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'clientType', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'clientCode', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'clientName', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'clientVersion', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'clientEngine', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'clientEngineVersion', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'deviceName', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'deviceBrand', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'deviceModel', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), + Attribute::string(key: 'actorType', required: true), + Attribute::string(key: 'actorInternalId'), + Attribute::string(key: 'resourceParent'), + Attribute::string(key: 'resourceType', required: true), + Attribute::string(key: 'resourceId', required: true), + Attribute::string(key: 'resourceInternalId'), + Attribute::string(key: 'country'), + Attribute::string(key: 'city'), + Attribute::string(key: 'continentCode'), + Attribute::string(key: 'subdivisions'), + Attribute::string(key: 'isp'), + Attribute::string(key: 'autonomousSystemNumber'), + Attribute::string(key: 'autonomousSystemOrganization'), + Attribute::string(key: 'connectionType'), + Attribute::string(key: 'connectionUsageType'), + Attribute::string(key: 'connectionOrganization'), + Attribute::string(key: 'projectId', required: true), + Attribute::string(key: 'projectInternalId', required: true), + Attribute::string(key: 'teamId', required: true), + Attribute::string(key: 'teamInternalId', required: true), + Attribute::string(key: 'hostname', required: true), + Attribute::string(key: 'sdk'), + Attribute::string(key: 'sdkVersion'), + Attribute::string(key: 'osCode'), + Attribute::string(key: 'osName'), + Attribute::string(key: 'osVersion'), + Attribute::string(key: 'clientType'), + Attribute::string(key: 'clientCode'), + Attribute::string(key: 'clientName'), + Attribute::string(key: 'clientVersion'), + Attribute::string(key: 'clientEngine'), + Attribute::string(key: 'clientEngineVersion'), + Attribute::string(key: 'deviceName'), + Attribute::string(key: 'deviceBrand'), + Attribute::string(key: 'deviceModel'), ]; } @@ -472,14 +471,14 @@ public function getIndexes(): array return [ ...$parentIndexes, - new Index(key: '_key_actor_internal_and_event', type: IndexType::Key, attributes: ['actorInternalId', 'event']), - new Index(key: '_key_project_internal_id', type: IndexType::Key, attributes: ['projectInternalId']), - new Index(key: '_key_team_internal_id', type: IndexType::Key, attributes: ['teamInternalId']), - new Index(key: '_key_actor_internal_id', type: IndexType::Key, attributes: ['actorInternalId']), - new Index(key: '_key_actor_type', type: IndexType::Key, attributes: ['actorType']), - new Index(key: '_key_country', type: IndexType::Key, attributes: ['country']), - new Index(key: '_key_hostname', type: IndexType::Key, attributes: ['hostname']), - new Index(key: '_key_sdk', type: IndexType::Key, attributes: ['sdk']), + Index::key(key: '_key_actor_internal_and_event', attributes: ['actorInternalId', 'event']), + Index::key(key: '_key_project_internal_id', attributes: ['projectInternalId']), + Index::key(key: '_key_team_internal_id', attributes: ['teamInternalId']), + Index::key(key: '_key_actor_internal_id', attributes: ['actorInternalId']), + Index::key(key: '_key_actor_type', attributes: ['actorType']), + Index::key(key: '_key_country', attributes: ['country']), + Index::key(key: '_key_hostname', attributes: ['hostname']), + Index::key(key: '_key_sdk', attributes: ['sdk']), ]; } diff --git a/src/Audit/Adapter/SQL.php b/src/Audit/Adapter/SQL.php index b43b9fc..10afbb8 100644 --- a/src/Audit/Adapter/SQL.php +++ b/src/Audit/Adapter/SQL.php @@ -6,8 +6,6 @@ use Utopia\Database\Attribute; use Utopia\Database\Database; use Utopia\Database\Index; -use Utopia\Query\Schema\ColumnType; -use Utopia\Query\Schema\IndexType; /** * Base SQL Adapter for Audit @@ -35,13 +33,13 @@ public function getCollectionName(): string public function getAttributes(): array { return [ - new Attribute(key: 'userId', type: ColumnType::String, size: Database::LENGTH_KEY, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'event', type: ColumnType::String, size: 255, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'resource', type: ColumnType::String, size: 255, required: false, signed: true, array: false, filters: []), - new Attribute(key: 'userAgent', type: ColumnType::String, size: 65534, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'ip', type: ColumnType::String, size: 45, required: true, signed: true, array: false, filters: []), - new Attribute(key: 'time', type: ColumnType::Datetime, size: 0, required: false, signed: true, array: false, filters: ['datetime']), - new Attribute(key: 'data', type: ColumnType::String, size: 16777216, required: false, signed: true, array: false, filters: ['json']), + Attribute::string(key: 'userId'), + Attribute::string(key: 'event', required: true), + Attribute::string(key: 'resource'), + Attribute::string(key: 'userAgent', size: 65534, required: true), + Attribute::string(key: 'ip', size: 45, required: true), + Attribute::datetime(key: 'time', filters: ['datetime']), + Attribute::string(key: 'data', size: 16777216, filters: ['json']), ]; } @@ -63,10 +61,10 @@ public function getAttributeDocuments(): array public function getIndexes(): array { return [ - new Index(key: 'idx_event', type: IndexType::Key, attributes: ['event']), - new Index(key: 'idx_userId_event', type: IndexType::Key, attributes: ['userId', 'event']), - new Index(key: 'idx_resource_event', type: IndexType::Key, attributes: ['resource', 'event']), - new Index(key: 'idx_time_desc', type: IndexType::Key, attributes: ['time']), + Index::key(key: 'idx_event', attributes: ['event']), + Index::key(key: 'idx_userId_event', attributes: ['userId', 'event']), + Index::key(key: 'idx_resource_event', attributes: ['resource', 'event']), + Index::key(key: 'idx_time_desc', attributes: ['time']), ]; } From 8750f1e144536c03aa7890b7f6b25e81cb03ea8c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 21 Aug 2026 17:11:35 +1200 Subject: [PATCH 5/8] (chore): use caret ranges for Utopia dependencies Asterisk wildcards on utopia-php packages are replaced with equivalent caret constraints so Composer ranges stay consistent. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4389601..23c9026 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "php": ">=8.5", "utopia-php/database": "dev-feat-query-lib as 7.0.0", "utopia-php/fetch": "^1.1", - "utopia-php/query": "0.4.*", + "utopia-php/query": "^0.4", "utopia-php/validators": "^0.5" }, "repositories": [ From 348a4f8c410f1241fb7f11f63d140c0f3efc683a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 21 Aug 2026 17:23:29 +1200 Subject: [PATCH 6/8] (chore): refresh lockfile after caret Utopia constraints Keep composer.json and composer.lock in sync so `composer validate` passes, and pin utopia-php/database to the current query-lib HEAD. --- composer.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/composer.lock b/composer.lock index 65d9323..5bead6b 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": "d10e9abe643e05feec61c278cb74c1a5", + "content-hash": "33a559a229f589d6517c8afaf75fa9bb", "packages": [ { "name": "brick/math", @@ -2389,12 +2389,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "5719eddbd77bb6b9a9916aec64ee3a8b0bc45c0f" + "reference": "798f294c1615102c92f8e8f7f8b3d18f36ad083b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/5719eddbd77bb6b9a9916aec64ee3a8b0bc45c0f", - "reference": "5719eddbd77bb6b9a9916aec64ee3a8b0bc45c0f", + "url": "https://api.github.com/repos/utopia-php/database/zipball/798f294c1615102c92f8e8f7f8b3d18f36ad083b", + "reference": "798f294c1615102c92f8e8f7f8b3d18f36ad083b", "shasum": "" }, "require": { @@ -2403,13 +2403,13 @@ "ext-pdo": "*", "ext-redis": "*", "php": ">=8.5", - "utopia-php/async": "0.1.*", - "utopia-php/cache": "4.*", - "utopia-php/console": "0.1.*", - "utopia-php/mongo": "1.*", - "utopia-php/pools": "2.*", - "utopia-php/query": "0.4.*", - "utopia-php/validators": "0.4.* || 0.5.*" + "utopia-php/async": "^0.1", + "utopia-php/cache": "^4.0", + "utopia-php/console": "^0.1", + "utopia-php/mongo": "^1.0", + "utopia-php/pools": "^2.0", + "utopia-php/query": "^0.4", + "utopia-php/validators": "^0.4 || ^0.5" }, "require-dev": { "brianium/paratest": "7.20.*", @@ -2419,7 +2419,7 @@ "phpunit/phpunit": "12.5.*", "rregeer/phpunit-coverage-check": "0.3.*", "swoole/ide-helper": "5.1.3", - "utopia-php/cli": "0.22.*" + "utopia-php/cli": "^0.22" }, "suggest": { "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", @@ -2479,7 +2479,7 @@ "source": "https://github.com/utopia-php/database/tree/feat-query-lib", "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-08-20T11:46:41+00:00" + "time": "2026-08-21T05:22:01+00:00" }, { "name": "utopia-php/fetch", From d55241398d1e4798289ccf7253a7f8eb542af230 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 21 Aug 2026 18:52:50 +1200 Subject: [PATCH 7/8] (feat): pass Collection to createCollection Database::createCollection no longer accepts a string id. --- composer.lock | 8 +++--- src/Audit/Adapter/Database.php | 47 ++++++++++++++++------------------ 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/composer.lock b/composer.lock index 5bead6b..43c3ffb 100644 --- a/composer.lock +++ b/composer.lock @@ -2389,12 +2389,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "798f294c1615102c92f8e8f7f8b3d18f36ad083b" + "reference": "808f90bff1a7ccec78cc2eabebd30c2454a9c896" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/798f294c1615102c92f8e8f7f8b3d18f36ad083b", - "reference": "798f294c1615102c92f8e8f7f8b3d18f36ad083b", + "url": "https://api.github.com/repos/utopia-php/database/zipball/808f90bff1a7ccec78cc2eabebd30c2454a9c896", + "reference": "808f90bff1a7ccec78cc2eabebd30c2454a9c896", "shasum": "" }, "require": { @@ -2479,7 +2479,7 @@ "source": "https://github.com/utopia-php/database/tree/feat-query-lib", "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-08-21T05:22:01+00:00" + "time": "2026-08-21T06:50:33+00:00" }, { "name": "utopia-php/fetch", diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 47815cf..cc2a5de 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -4,6 +4,7 @@ use Exception; use Utopia\Audit\Log; +use Utopia\Database\Collection; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -48,7 +49,7 @@ public function ping(): bool /** * Setup database structure. * - * @throws \Exception + * @throws Exception */ public function setup(): void { @@ -60,11 +61,7 @@ public function setup(): void $indexes = $this->getIndexDocuments(); try { - $this->db->createCollection( - $this->getCollectionName(), - $attributes, - $indexes, - ); + $this->db->createCollection(new Collection(id: $this->getCollectionName(), attributes: $attributes, indexes: $indexes)); } catch (DuplicateException) { // Collection already exists } @@ -75,12 +72,12 @@ public function setup(): void * * @param array $log * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function create(array $log): Log { $log['time'] ??= DateTime::now(); - $document = $this->db->getAuthorization()->skip(fn (): \Utopia\Database\Document => $this->db->createDocument($this->getCollectionName(), new Document($log))); + $document = $this->db->getAuthorization()->skip(fn (): Document => $this->db->createDocument($this->getCollectionName(), new Document($log))); return new Log($document->getArrayCopy()); } @@ -90,12 +87,12 @@ public function create(array $log): Log * * @param array> $logs * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function createBatch(array $logs): bool { $this->db->getAuthorization()->skip(function () use ($logs): void { - $documents = array_map(function (array $log): \Utopia\Database\Document { + $documents = array_map(function (array $log): Document { $time = $log['time'] ?? new \DateTime; if (\is_string($time)) { $time = new \DateTime($time); @@ -116,11 +113,11 @@ public function createBatch(array $logs): bool * * @return Log|null The log entry or null if not found * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function getById(string $id): ?Log { - $document = $this->db->getAuthorization()->skip(fn (): \Utopia\Database\Document => $this->db->getDocument($this->getCollectionName(), $id)); + $document = $this->db->getAuthorization()->skip(fn (): Document => $this->db->getDocument($this->getCollectionName(), $id)); if ($document->isEmpty()) { return null; @@ -163,7 +160,7 @@ private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array * * @return array * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function getByUser( string $userId, @@ -189,13 +186,13 @@ public function getByUser( ); }); - return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** * Count audit logs by user ID. * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function countByUser( string $userId, @@ -246,7 +243,7 @@ public function getByResource( ); }); - return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -306,7 +303,7 @@ public function getByUserAndEvents( ); }); - return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -370,7 +367,7 @@ public function getByResourceAndEvents( ); }); - return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -403,7 +400,7 @@ public function countByResourceAndEvents( /** * Delete logs older than the specified datetime. * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function cleanup(\DateTime $datetime): bool { @@ -464,9 +461,9 @@ protected function getColumnDefinition(string $id): string * Translates Audit Query objects to Database Query objects. * * @param array<\Utopia\Audit\Query> $queries - * @return array<\Utopia\Audit\Log> + * @return array * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function find(array $queries = []): array { @@ -474,7 +471,7 @@ public function find(array $queries = []): array foreach ($queries as $query) { if (! ($query instanceof \Utopia\Audit\Query)) { - throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); + throw new Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } // Convert Audit Query to Database Query @@ -487,7 +484,7 @@ public function find(array $queries = []): array queries: $dbQueries, )); - return array_map(fn (\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn (Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -499,7 +496,7 @@ public function find(array $queries = []): array * @param array<\Utopia\Audit\Query> $queries * @param int|null $max Optional upper bound (inclusive) for the count * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function count(array $queries = [], ?int $max = null): int { @@ -507,7 +504,7 @@ public function count(array $queries = [], ?int $max = null): int foreach ($queries as $query) { if (! ($query instanceof \Utopia\Audit\Query)) { - throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); + throw new Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } // Skip limit, offset, and cursor queries — they don't apply to count From e6e289239369ef96db0c5c7c414d71a54f6d4e9d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 21 Aug 2026 19:18:02 +1200 Subject: [PATCH 8/8] (chore): allow utopia-php/query 0.5 Database feat-query-lib now requires query ^0.5 after nested joins. --- composer.json | 2 +- composer.lock | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index 23c9026..7b4b17a 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "php": ">=8.5", "utopia-php/database": "dev-feat-query-lib as 7.0.0", "utopia-php/fetch": "^1.1", - "utopia-php/query": "^0.4", + "utopia-php/query": "^0.4 || ^0.5", "utopia-php/validators": "^0.5" }, "repositories": [ diff --git a/composer.lock b/composer.lock index 43c3ffb..ba9cd80 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": "33a559a229f589d6517c8afaf75fa9bb", + "content-hash": "32f1708fd2094eea841257bc767439cf", "packages": [ { "name": "brick/math", @@ -2389,12 +2389,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "808f90bff1a7ccec78cc2eabebd30c2454a9c896" + "reference": "63bd77d0735a81280d99a9e6960bb36ecd1c902e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/808f90bff1a7ccec78cc2eabebd30c2454a9c896", - "reference": "808f90bff1a7ccec78cc2eabebd30c2454a9c896", + "url": "https://api.github.com/repos/utopia-php/database/zipball/63bd77d0735a81280d99a9e6960bb36ecd1c902e", + "reference": "63bd77d0735a81280d99a9e6960bb36ecd1c902e", "shasum": "" }, "require": { @@ -2408,7 +2408,7 @@ "utopia-php/console": "^0.1", "utopia-php/mongo": "^1.0", "utopia-php/pools": "^2.0", - "utopia-php/query": "^0.4", + "utopia-php/query": "^0.5", "utopia-php/validators": "^0.4 || ^0.5" }, "require-dev": { @@ -2479,7 +2479,7 @@ "source": "https://github.com/utopia-php/database/tree/feat-query-lib", "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-08-21T06:50:33+00:00" + "time": "2026-08-21T07:15:17+00:00" }, { "name": "utopia-php/fetch", @@ -2640,16 +2640,16 @@ }, { "name": "utopia-php/query", - "version": "0.4.0", + "version": "0.5.0", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "c334515035a2ab0aa49176eeca96de3e1c096e22" + "reference": "802821c6fb0470410e1f0e65561cc16224c343c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/c334515035a2ab0aa49176eeca96de3e1c096e22", - "reference": "c334515035a2ab0aa49176eeca96de3e1c096e22", + "url": "https://api.github.com/repos/utopia-php/query/zipball/802821c6fb0470410e1f0e65561cc16224c343c0", + "reference": "802821c6fb0470410e1f0e65561cc16224c343c0", "shasum": "" }, "require": { @@ -2683,9 +2683,9 @@ ], "support": { "issues": "https://github.com/utopia-php/query/issues", - "source": "https://github.com/utopia-php/query/tree/0.4.0" + "source": "https://github.com/utopia-php/query/tree/0.5.0" }, - "time": "2026-08-14T01:42:36+00:00" + "time": "2026-08-21T06:16:10+00:00" }, { "name": "utopia-php/telemetry",