diff --git a/README.md b/README.md index 8cc358640..93cac3798 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The source of truth for the [utopia-php](https://github.com/utopia-php) libraries. Each `packages/` is an independent Composer library; development happens here, and every push is mirrored to its read-only repository (e.g. `utopia-php/http`), so Composer/Packagist distribution is unchanged — mechanics in [docs/distribution.md](docs/distribution.md). -[`utopia-php/auth`](https://github.com/appwrite/appwrite/tree/main/packages/auth), [`utopia-php/cache`](https://github.com/appwrite/appwrite/tree/main/packages/cache), [`utopia-php/cdn`](https://github.com/appwrite/appwrite/tree/main/packages/cdn), [`utopia-php/circuit-breaker`](https://github.com/appwrite/appwrite/tree/main/packages/circuit-breaker), [`utopia-php/cli`](https://github.com/appwrite/appwrite/tree/main/packages/cli), [`utopia-php/client`](https://github.com/appwrite/appwrite/tree/main/packages/client), [`utopia-php/compression`](https://github.com/appwrite/appwrite/tree/main/packages/compression), [`utopia-php/console`](https://github.com/appwrite/appwrite/tree/main/packages/console), [`utopia-php/di`](https://github.com/appwrite/appwrite/tree/main/packages/di), [`utopia-php/dns`](https://github.com/appwrite/appwrite/tree/main/packages/dns), [`utopia-php/domains`](https://github.com/appwrite/appwrite/tree/main/packages/domains), [`utopia-php/http`](https://github.com/appwrite/appwrite/tree/main/packages/http), [`utopia-php/image`](https://github.com/appwrite/appwrite/tree/main/packages/image), [`utopia-php/messaging`](https://github.com/appwrite/appwrite/tree/main/packages/messaging), [`utopia-php/openapi`](https://github.com/appwrite/appwrite/tree/main/packages/openapi), [`utopia-php/platform`](https://github.com/appwrite/appwrite/tree/main/packages/platform), [`utopia-php/pools`](https://github.com/appwrite/appwrite/tree/main/packages/pools), [`utopia-php/queue`](https://github.com/appwrite/appwrite/tree/main/packages/queue), [`utopia-php/schedule`](https://github.com/appwrite/appwrite/tree/main/packages/schedule), [`utopia-php/servers`](https://github.com/appwrite/appwrite/tree/main/packages/servers), [`utopia-php/smtp`](https://github.com/appwrite/appwrite/tree/main/packages/smtp), [`utopia-php/span`](https://github.com/appwrite/appwrite/tree/main/packages/span), [`utopia-php/storage`](https://github.com/appwrite/appwrite/tree/main/packages/storage), [`utopia-php/system`](https://github.com/appwrite/appwrite/tree/main/packages/system), [`utopia-php/telemetry`](https://github.com/appwrite/appwrite/tree/main/packages/telemetry), [`utopia-php/user-agent`](https://github.com/appwrite/appwrite/tree/main/packages/user-agent), [`utopia-php/validators`](https://github.com/appwrite/appwrite/tree/main/packages/validators), [`utopia-php/vcs`](https://github.com/appwrite/appwrite/tree/main/packages/vcs) and [`utopia-php/websocket`](https://github.com/appwrite/appwrite/tree/main/packages/websocket) are maintained in Appwrite. Their distribution mirrors and Packagist packages remain available. +[`utopia-php/audit`](https://github.com/appwrite/appwrite/tree/main/packages/audit), [`utopia-php/auth`](https://github.com/appwrite/appwrite/tree/main/packages/auth), [`utopia-php/cache`](https://github.com/appwrite/appwrite/tree/main/packages/cache), [`utopia-php/cdn`](https://github.com/appwrite/appwrite/tree/main/packages/cdn), [`utopia-php/circuit-breaker`](https://github.com/appwrite/appwrite/tree/main/packages/circuit-breaker), [`utopia-php/cli`](https://github.com/appwrite/appwrite/tree/main/packages/cli), [`utopia-php/client`](https://github.com/appwrite/appwrite/tree/main/packages/client), [`utopia-php/compression`](https://github.com/appwrite/appwrite/tree/main/packages/compression), [`utopia-php/console`](https://github.com/appwrite/appwrite/tree/main/packages/console), [`utopia-php/di`](https://github.com/appwrite/appwrite/tree/main/packages/di), [`utopia-php/dns`](https://github.com/appwrite/appwrite/tree/main/packages/dns), [`utopia-php/domains`](https://github.com/appwrite/appwrite/tree/main/packages/domains), [`utopia-php/http`](https://github.com/appwrite/appwrite/tree/main/packages/http), [`utopia-php/image`](https://github.com/appwrite/appwrite/tree/main/packages/image), [`utopia-php/messaging`](https://github.com/appwrite/appwrite/tree/main/packages/messaging), [`utopia-php/openapi`](https://github.com/appwrite/appwrite/tree/main/packages/openapi), [`utopia-php/platform`](https://github.com/appwrite/appwrite/tree/main/packages/platform), [`utopia-php/pools`](https://github.com/appwrite/appwrite/tree/main/packages/pools), [`utopia-php/queue`](https://github.com/appwrite/appwrite/tree/main/packages/queue), [`utopia-php/schedule`](https://github.com/appwrite/appwrite/tree/main/packages/schedule), [`utopia-php/servers`](https://github.com/appwrite/appwrite/tree/main/packages/servers), [`utopia-php/smtp`](https://github.com/appwrite/appwrite/tree/main/packages/smtp), [`utopia-php/span`](https://github.com/appwrite/appwrite/tree/main/packages/span), [`utopia-php/storage`](https://github.com/appwrite/appwrite/tree/main/packages/storage), [`utopia-php/system`](https://github.com/appwrite/appwrite/tree/main/packages/system), [`utopia-php/telemetry`](https://github.com/appwrite/appwrite/tree/main/packages/telemetry), [`utopia-php/user-agent`](https://github.com/appwrite/appwrite/tree/main/packages/user-agent), [`utopia-php/validators`](https://github.com/appwrite/appwrite/tree/main/packages/validators), [`utopia-php/vcs`](https://github.com/appwrite/appwrite/tree/main/packages/vcs) and [`utopia-php/websocket`](https://github.com/appwrite/appwrite/tree/main/packages/websocket) are maintained in Appwrite. Their distribution mirrors and Packagist packages remain available. ## Quickstart @@ -51,7 +51,6 @@ Local siblings only: arrows point at dependencies between the packages in this c ```mermaid graph TD - audit config fastly nats diff --git a/packages/audit/.github/workflows/mirror.yml b/packages/audit/.github/workflows/mirror.yml deleted file mode 100644 index c343f55e7..000000000 --- a/packages/audit/.github/workflows/mirror.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Mirror - -on: - pull_request_target: - types: [opened] - issues: - types: [opened] - -permissions: - issues: write - pull-requests: write - -jobs: - redirect: - uses: utopia-php/monorepo/.github/workflows/mirror-redirect.yml@main - with: - package: audit diff --git a/packages/audit/.gitignore b/packages/audit/.gitignore deleted file mode 100755 index 1918e10f3..000000000 --- a/packages/audit/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/vendor/ -/.idea/ -composer.lock diff --git a/packages/audit/CHANGELOG.md b/packages/audit/CHANGELOG.md deleted file mode 100644 index 8290eba15..000000000 --- a/packages/audit/CHANGELOG.md +++ /dev/null @@ -1,130 +0,0 @@ -# Changelog - -All notable changes to `utopia-php/audit` are documented in this file. - -## Unreleased - -### ClickHouse adapter — migrated to the utopia-php/query 0.6 builder - -#### Changed - -- `utopia-php/query` bumped from `0.1.*` to `0.6.*` (locked at 0.6.0). -- `setup()` builds its DDL through `Utopia\Query\Schema\ClickHouse` instead of - hand-assembled SQL. Column types, `LowCardinality(...)` / `Nullable(...)` - wrapping, bloom-filter indexes, engine, `ORDER BY`, `PARTITION BY` and - `SETTINGS` are all emitted by the schema builder. The retention `MODIFY TTL` - / `REMOVE TTL` statements are unchanged. -- `find()`, `count()`, `getById()`, `createBatch()` and `cleanup()` build their - SQL through `Utopia\Query\Builder\ClickHouse`. Positional bindings are - rewritten to typed `{paramN:Type}` ClickHouse placeholders from a column → - type map derived from `getAttributes()`. -- `createBatch()` uses `Builder\ClickHouse::bulkInsert(Format::JSONEachRow, …)` - to emit the `INSERT … FORMAT JSONEachRow` envelope and serialize the body. -- `Query::getMethod()` now returns the `Utopia\Query\Method` enum (upstream - 0.3 change). `Utopia\Audit\Query` continues to expose the legacy `TYPE_*` - string constants, which map to the same string values. - -Filter semantics are unchanged: `contains` / `notContains` remain substring -matches (now compiled to ClickHouse `position(col, ?) > 0` / `= 0` rather than -`LIKE '%needle%'`, which also removes the need for wildcard escaping). - -## 2.9.0 - -### ClickHouse adapter — user-agent columns - -The ClickHouse adapter now stores the parsed user-agent OS / client / device -dimensions as dedicated optional columns (mirrors the usage events schema). - -#### Added - -- `Log` getters for ClickHouse-backed reads: `getOsCode()`, `getOsName()`, - `getOsVersion()`, `getClientType()`, `getClientCode()`, `getClientName()`, - `getClientVersion()`, `getClientEngine()`, `getClientEngineVersion()`, - `getDeviceName()`, `getDeviceBrand()`, `getDeviceModel()`. - -#### ClickHouse schema changes - -- `LowCardinality(Nullable(String))` — `osCode`, `osName`, `clientType`, - `clientCode`, `clientName`, `clientEngine`, `deviceName`, `deviceBrand` - (bounded name/code/type dimensions). -- `Nullable(String)` — `osVersion`, `clientVersion`, `clientEngineVersion`, - `deviceModel` (high-cardinality version/model strings, mirroring `sdkVersion`). - -All columns are optional (`required = false`) so `createBatch()` never throws when a -caller omits them. Newly created tables include the columns automatically via `setup()`. -`setup()` only issues `CREATE TABLE IF NOT EXISTS`, so **existing** tables do not gain the -columns automatically — apply them with an `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` -migration. - -## 2.7.0 - -### ClickHouse adapter — SDK columns - -The ClickHouse adapter now stores two additional optional columns capturing the -SDK that produced an audit event: - -#### Added - -- `Log::getSdk()` and `Log::getSdkVersion()` getters for ClickHouse-backed log reads. - -#### ClickHouse schema changes - -- Column `sdk` `LowCardinality(Nullable(String))` — SDK name (e.g. `web`, `flutter`, - `console`, `cli`); low-cardinality, optional. -- Column `sdkVersion` `Nullable(String)` — SDK version (e.g. `14.0.0`); high-cardinality, - optional. -- Index `_key_sdk` — bloom-filter index on the `sdk` column. - -Both columns are optional (`required = false`) so `createBatch()` never throws when a -caller omits them. Newly created tables include the columns automatically via `setup()`. -`setup()` only issues `CREATE TABLE IF NOT EXISTS`, so **existing** tables do not gain the -columns automatically — apply them with an `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` -migration. - -## 2.4.0 - -### ClickHouse adapter — actor terminology - -The ClickHouse adapter now stores its principal columns under "actor" terminology: -`actorId`, `actorType`, `actorInternalId`. The shared SQL base, the Database adapter, -and the public `Audit` API are unchanged — Database-backed audit logs continue to use -`userId`. - -This is a non-breaking change for callers of the public API. `Audit::log($userId, ...)`, -`Audit::getLogsByUser(...)`, `Audit::countLogsByUser(...)`, and the equivalent -`*ByUserAndEvents` methods all keep their original signatures. The ClickHouse adapter -translates the legacy `userId` array key and `Query::equal('userId', ...)` filter -internally to the renamed `actorId` column. - -#### Added - -- `Log::getActorId()`, `Log::getActorType()`, `Log::getActorInternalId()` getters for - ClickHouse-backed log reads. -- `Log` instances returned by the ClickHouse adapter expose both `actorId` / `actorType` - / `actorInternalId` (canonical) and `userId` / `userType` / `userInternalId` (legacy - mirror) attribute keys so existing code paths continue to work. - -#### ClickHouse schema changes - -- Column `userId` → `actorId` -- Column `userType` → `actorType` -- Column `userInternalId` → `actorInternalId` -- Index `idx_userId_event` → `idx_actorId_event` -- Index `_key_user_type` → `_key_actor_type` -- Index `_key_user_internal_id` → `_key_actor_internal_id` -- Index `_key_user_internal_and_event` → `_key_actor_internal_and_event` - -#### Migration - -ClickHouse audit tables will be recreated by `setup()` with the new column names. -Existing ClickHouse audit data is not preserved automatically — this is acceptable -because the activity-events surface backed by this schema is not yet in public use. -If preservation is needed, run `ALTER TABLE ... RENAME COLUMN` for each renamed -column before redeploying. - -No migration is required for Database-backed audit logs. The Database adapter -continues to write and read `userId` columns and indexes unchanged. - -## 2.3.2 and earlier - -See git history. diff --git a/packages/audit/LICENSE.md b/packages/audit/LICENSE.md deleted file mode 100755 index 5aa6235e7..000000000 --- a/packages/audit/LICENSE.md +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2019 Eldad Fux - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/audit/README.md b/packages/audit/README.md deleted file mode 100644 index 0b724116f..000000000 --- a/packages/audit/README.md +++ /dev/null @@ -1,464 +0,0 @@ -# Utopia Audit - -> [!IMPORTANT] -> This repository is a read-only mirror of the [utopia-php monorepo](https://github.com/utopia-php/monorepo). Development happens in [`packages/audit`](https://github.com/utopia-php/monorepo/tree/main/packages/audit) — please open issues and pull requests there. - -[![Build Status](https://travis-ci.org/utopia-php/audit.svg?branch=master)](https://travis-ci.com/utopia-php/audit) -![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/audit.svg) -[![Discord](https://img.shields.io/discord/564160730845151244)](https://appwrite.io/discord) - -Utopia framework audit library is simple and lite library for managing application user logs. This library is aiming to be as simple and easy to learn and use. This library is maintained by the [Appwrite team](https://appwrite.io). - -Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project it is dependency free, and can be used as standalone with any other PHP project or framework. - -## Features - -- **Adapter Pattern**: Support for multiple storage backends through adapters -- **Default Database Adapter**: Built-in support for utopia-php/database -- **Extensible**: Easy to create custom adapters for different storage solutions -- **Batch Operations**: Support for logging multiple events at once -- **Query Support**: Rich querying capabilities for retrieving logs - -## Getting started - -Install using Composer: -```bash -composer require utopia-php/audit -``` - -## Usage - -### Using the database adapter (default) - -The simplest way to use Utopia Audit is with the built-in Database adapter: - -```php - 3, - PDO::ATTR_PERSISTENT => true, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_EMULATE_PREPARES => true, - PDO::ATTR_STRINGIFY_FETCHES => true, -]); - -$cache = new Cache(new NoCache()); - -$database = new Database(new MySQL($pdo), $cache); -$database->setNamespace('namespace'); - -// Create audit instance with Database adapter -$audit = new Audit(new DatabaseAdapter($database)); -$audit->setup(); -``` - -### Using a custom adapter - -You can create custom adapters by extending the `Utopia\Audit\Adapter` abstract class: - -```php - 'value1','key2' => 'value2']; // Any key-value pair you need to log - -$audit->log($userId, $event, $resource, $userAgent, $ip, $data); -``` - -**Get Logs By User** - -Fetch all logs by given user ID with optional filtering parameters: - -```php -// Basic usage -$logs = $audit->getLogsByUser('userId'); - -// With pagination -$logs = $audit->getLogsByUser( - userId: 'userId', - limit: 10, - offset: 0 -); - -// With time filtering using DateTime objects -$logs = $audit->getLogsByUser( - userId: 'userId', - after: new \DateTime('2024-01-01 00:00:00'), - before: new \DateTime('2024-12-31 23:59:59'), - limit: 25, - offset: 0, - ascending: false // false = newest first (default), true = oldest first -); -``` - -**Get Logs By User and Action** - -Fetch all logs by given user ID and specific event names with optional filtering: - -```php -// Basic usage -$logs = $audit->getLogsByUserAndEvents( - userId: 'userId', - events: ['update', 'delete'] -); - -// With time filtering and pagination -$logs = $audit->getLogsByUserAndEvents( - userId: 'userId', - events: ['update', 'delete'], - after: new \DateTime('-7 days'), - before: new \DateTime('now'), - limit: 50, - offset: 0, - ascending: false -); -``` - -**Get Logs By Resource** - -Fetch all logs by a given resource name with optional filtering: - -```php -// Basic usage -$logs = $audit->getLogsByResource('database/document-1'); - -// With time filtering and pagination -$logs = $audit->getLogsByResource( - resource: 'database/document-1', - after: new \DateTime('-30 days'), - before: new \DateTime('now'), - limit: 100, - offset: 0, - ascending: true // Get oldest logs first -); -``` - -**Batch Logging** - -Log multiple events at once for better performance: - -```php -use Utopia\Database\DateTime; - -$events = [ - [ - 'userId' => 'user-1', - 'event' => 'create', - 'resource' => 'database/document/1', - 'userAgent' => 'Mozilla/5.0...', - 'ip' => '127.0.0.1', - 'location' => 'US', - 'data' => ['key' => 'value'], - 'time' => DateTime::now() - ], - [ - 'userId' => 'user-2', - 'event' => 'update', - 'resource' => 'database/document/2', - 'userAgent' => 'Mozilla/5.0...', - 'ip' => '192.168.1.1', - 'location' => 'UK', - 'data' => ['key' => 'value'], - 'time' => DateTime::now() - ] -]; - -$documents = $audit->logBatch($events); -``` - -**Counting Logs** - -All retrieval methods have corresponding count methods with the same filtering capabilities: - -```php -// Count all logs for a user -$count = $audit->countLogsByUser('userId'); - -// Count logs within a time range -$count = $audit->countLogsByUser( - userId: 'userId', - after: new \DateTime('-7 days'), - before: new \DateTime('now') -); - -// Count logs by resource -$count = $audit->countLogsByResource('database/document-1'); - -// Count logs by user and events -$count = $audit->countLogsByUserAndEvents( - userId: 'userId', - events: ['create', 'update', 'delete'], - after: new \DateTime('-30 days') -); - -// Count logs by resource and events -$count = $audit->countLogsByResourceAndEvents( - resource: 'database/document-1', - events: ['update', 'delete'] -); -``` - -**Advanced Filtering** - -Get logs by resource and specific events: - -```php -$logs = $audit->getLogsByResourceAndEvents( - resource: 'database/document-1', - events: ['create', 'update'], - after: new \DateTime('-24 hours'), - limit: 20, - offset: 0, - ascending: false -); -``` - -### Filtering parameters - -All retrieval methods support the following optional parameters: - -- **after** (`?\DateTime`): Get logs created after this datetime -- **before** (`?\DateTime`): Get logs created before this datetime -- **limit** (`int`, default: 25): Maximum number of logs to return -- **offset** (`int`, default: 0): Number of logs to skip (for pagination) -- **ascending** (`bool`, default: false): Sort order - false for newest first, true for oldest first - -## Adapters - -Utopia Audit uses an adapter pattern to support different storage backends. Currently available adapters: - -### Database adapter (default) - -The Database adapter uses [utopia-php/database](https://github.com/utopia-php/database) to store audit logs in a database. - - -### ClickHouse adapter - -The ClickHouse adapter uses [ClickHouse](https://clickhouse.com/) for high-performance analytical queries on massive amounts of log data. It communicates with ClickHouse via HTTP interface using Utopia Fetch. - -**Features:** -- Optimized for analytical queries and aggregations -- Handles billions of log entries efficiently -- Column-oriented storage for fast queries -- Automatic partitioning by month -- Bloom filter indexes for fast lookups - -**Usage:** - -```php -setDatabase('audit'); -$adapter->setTable('audit_logs'); - -$audit = new Audit($adapter); -$audit->setup(); // Creates database and table - -// Use as normal — the ClickHouse adapter requires extra attributes in data -$document = $audit->log( - userId: 'user-123', - event: 'document.create', - resource: 'database/document/1', - userAgent: 'Mozilla/5.0...', - ip: '127.0.0.1', - data: [ - 'actorType' => 'member', - 'projectId' => 'proj-1', - 'projectInternalId' => 'proj-int-1', - 'teamId' => 'team-1', - 'teamInternalId' => 'team-int-1', - 'hostname' => 'example.org', - 'key' => 'value', - ] -); -``` - -**Performance Benefits:** -- Ideal for high-volume logging (millions of events per day) -- Fast aggregation queries (counts, analytics) -- Efficient storage with compression -- Automatic data partitioning and retention policies - -### Creating custom adapters - -To create a custom adapter, extend the `Utopia\Audit\Adapter` abstract class and implement all required methods: - -```php -=8.5", - "utopia-php/database": "^7.0.0", - "utopia-php/fetch": "^1.1", - "utopia-php/query": "0.6.*", - "utopia-php/validators": "^1.0" - }, - "config": { - "allow-plugins": { - "php-http/discovery": true, - "tbachert/spi": true - } - } -} diff --git a/packages/audit/docker-compose.yml b/packages/audit/docker-compose.yml deleted file mode 100644 index 1faf80e70..000000000 --- a/packages/audit/docker-compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - mariadb: - image: mariadb:10.11 - environment: - - MYSQL_ROOT_PASSWORD=password - ports: - - "13307:3306" - healthcheck: - test: ["CMD", "sh", "-c", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 30s - - clickhouse: - image: clickhouse/clickhouse-server:25.11-alpine - environment: - - CLICKHOUSE_DB=default - - CLICKHOUSE_USER=default - - CLICKHOUSE_PASSWORD=clickhouse - - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 - ports: - - "18123:8123" - healthcheck: - test: ["CMD", "clickhouse-client", "--host=localhost", "--port=9000", "--query=SELECT 1", "--format=Null"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 15s diff --git a/packages/audit/phpstan.neon b/packages/audit/phpstan.neon deleted file mode 100644 index f3da750a9..000000000 --- a/packages/audit/phpstan.neon +++ /dev/null @@ -1,5 +0,0 @@ -includes: - - ../../phpstan.neon - -parameters: - treatPhpDocTypesAsCertain: false diff --git a/packages/audit/phpunit.xml b/packages/audit/phpunit.xml deleted file mode 100755 index aee3fdc31..000000000 --- a/packages/audit/phpunit.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - tests/Audit/QueryTest.php - - tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php - - - tests/Audit/Adapter - tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php - - - - - src - - - diff --git a/packages/audit/src/Audit/Adapter.php b/packages/audit/src/Audit/Adapter.php deleted file mode 100644 index 97047cd44..000000000 --- a/packages/audit/src/Audit/Adapter.php +++ /dev/null @@ -1,230 +0,0 @@ - - * } $log - * @return Log The created log entry - * - * @throws \Exception - */ - abstract public function create(array $log): Log; - - /** - * Create multiple audit log entries in batch. - * - * @param array - * }> $logs - * - * @throws \Exception - */ - abstract public function createBatch(array $logs): bool; - - /** - * Get logs by user ID. - * - * @return array - * @throws \Exception - */ - abstract public function getByUser( - string $userId, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array; - - /** - * Count logs by user ID. - * - * - * @throws \Exception - */ - abstract public function countByUser( - string $userId, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int; - - /** - * Get logs by resource. - * - * @return array - * @throws \Exception - */ - abstract public function getByResource( - string $resource, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array; - - /** - * Count logs by resource. - * - * - * @throws \Exception - */ - abstract public function countByResource( - string $resource, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int; - - /** - * Get logs by user and events. - * - * @param array $events - * @return array - * @throws \Exception - */ - abstract public function getByUserAndEvents( - string $userId, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array; - - /** - * Count logs by user and events. - * - * @param array $events - * - * @throws \Exception - */ - abstract public function countByUserAndEvents( - string $userId, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int; - - /** - * Get logs by resource and events. - * - * @param array $events - * @return array - * @throws \Exception - */ - abstract public function getByResourceAndEvents( - string $resource, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array; - - /** - * Count logs by resource and events. - * - * @param array $events - * - * @throws \Exception - */ - abstract public function countByResourceAndEvents( - string $resource, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int; - - /** - * Delete logs older than the specified datetime. - * - * - * @throws \Exception - */ - abstract public function cleanup(\DateTime $datetime): bool; - - /** - * Find logs using custom queries. - * - * @param array<\Utopia\Audit\Query> $queries - * @return array - * - * @throws \Exception - */ - abstract public function find(array $queries = []): array; - - /** - * Count logs using custom queries. - * - * When $max is non-null the count is bounded at the database level so - * very large tables can short-circuit once the cap is reached. Pass - * `null` for an unbounded total. - * - * @param array<\Utopia\Audit\Query> $queries - * @param int|null $max Optional upper bound (inclusive) for the count - * - * @throws \Exception - */ - abstract public function count(array $queries = [], ?int $max = null): int; - - /** - * Ping the adapter to check connectivity. - * - * Returns false on any connectivity failure rather than throwing. - * - * @return bool True when the backing store is reachable, false otherwise. - */ - abstract public function ping(): bool; -} diff --git a/packages/audit/src/Audit/Adapter/ClickHouse.php b/packages/audit/src/Audit/Adapter/ClickHouse.php deleted file mode 100644 index 7b3bdbe08..000000000 --- a/packages/audit/src/Audit/Adapter/ClickHouse.php +++ /dev/null @@ -1,2427 +0,0 @@ - - */ - private const array LOW_CARDINALITY_COLUMNS = [ - 'event', - 'actorType', - 'resourceType', - 'country', - 'sdk', - // premium geo (autonomousSystemNumber is intentionally NOT low-cardinality: - // ~100k ASNs globally can exceed ClickHouse's LowCardinality sweet spot) - 'continentCode', - 'subdivisions', - 'connectionType', - 'connectionUsageType', - // user-agent — bounded name/code/type dimensions (version/model strings - // stay high-cardinality Nullable(String), mirroring sdk / sdkVersion) - 'osCode', - 'osName', - 'clientType', - 'clientCode', - 'clientName', - 'clientEngine', - 'deviceName', - 'deviceBrand', - ]; - - /** - * Filter methods that must be supplied at least one value. Empty `values` - * arrays for these methods are rejected up front so they can't silently - * compile into a "no filter applied" WHERE clause. - * - * @var list - */ - private const array VALUE_REQUIRED_METHODS = [ - 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; - - private readonly int $port; - - private string $database = self::DEFAULT_DATABASE; - - private string $table = self::DEFAULT_TABLE; - - private readonly Client $client; - - protected string $namespace = ''; - - protected ?int $tenant = null; - - protected bool $sharedTables = false; - - protected bool $asyncCleanup = false; - - /** @var int|null Retention in days; when set, setup() applies a TTL on the table. Null disables TTL. */ - 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) - * @throws Exception If validation fails - */ - public function __construct( - string $host, - private readonly string $username = 'default', - private readonly string $password = '', - int $port = self::DEFAULT_PORT, - private bool $secure = false, - ) { - $this->validateHost($host); - $this->validatePort($port); - - $this->host = $host; - $this->port = $port; - - // Initialize the HTTP client for connection reuse - $this->client = new Client(); - $this->client->addHeader('X-ClickHouse-User', $this->username); - $this->client->addHeader('X-ClickHouse-Key', $this->password); - $this->client->setTimeout(30_000); // 30 seconds - } - - /** - * Get adapter name. - */ - public function getName(): string - { - return 'ClickHouse'; - } - - /** - * Ping ClickHouse to check connectivity. - * - * Uses ClickHouse's dedicated /ping endpoint, which bypasses the query - * pipeline, requires no database context, and is not recorded in query - * logs. Returns false on any connectivity failure rather than throwing. - * - * @return bool True when ClickHouse is reachable, false otherwise. - */ - public function ping(): bool - { - $scheme = $this->secure ? 'https' : 'http'; - $url = "{$scheme}://{$this->host}:{$this->port}/ping"; - - try { - $response = $this->client->fetch(url: $url, method: Client::METHOD_GET); - } catch (\Throwable) { - return false; - } - - return $response->getStatusCode() === 200; - } - - /** - * Validate host parameter. - * - * @throws Exception - */ - private function validateHost(string $host): void - { - $validator = new Hostname(); - if (!$validator->isValid($host)) { - throw new Exception('ClickHouse host is not a valid hostname or IP address'); - } - } - - /** - * Validate port parameter. - * - * @throws Exception - */ - private function validatePort(int $port): void - { - if ($port < 1 || $port > 65535) { - throw new Exception('ClickHouse port must be between 1 and 65535'); - } - } - - /** - * Validate identifier (database, table, namespace). - * ClickHouse identifiers follow SQL standard rules. - * - * @param string $type Name of the identifier type for error messages - * @throws Exception - */ - private function validateIdentifier(string $identifier, string $type = 'Identifier'): void - { - if ($identifier === '' || $identifier === '0') { - throw new Exception("{$type} cannot be empty"); - } - - if (\strlen($identifier) > 255) { - throw new Exception("{$type} cannot exceed 255 characters"); - } - - // ClickHouse identifiers: alphanumeric, underscores, cannot start with number - 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"); - } - - // Check against SQL keywords (common ones) - $keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TABLE', 'DATABASE']; - if (\in_array(strtoupper($identifier), $keywords, true)) { - throw new Exception("{$type} cannot be a reserved SQL keyword"); - } - } - - /** - * Escape an identifier (database name, table name, column name) for safe use in SQL. - * Uses backticks as per SQL standard for identifier quoting. - */ - private function escapeIdentifier(string $identifier): string - { - // Backtick escaping: replace any backticks in the identifier with double backticks - return '`' . str_replace('`', '``', $identifier) . '`'; - } - /** - * Set the namespace for multi-project support. - * Namespace is used as a prefix for table names. - * - * @throws Exception - */ - public function setNamespace(string $namespace): self - { - if ($namespace !== '' && $namespace !== '0') { - $this->validateIdentifier($namespace, 'Namespace'); - } - $this->namespace = $namespace; - return $this; - } - - /** - * Set the database name for subsequent operations. - * - * @throws Exception - */ - public function setDatabase(string $database): self - { - $this->validateIdentifier($database, 'Database'); - $this->database = $database; - return $this; - } - - /** - * Set the table name for subsequent operations. - * - * @throws Exception - */ - public function setTable(string $table): self - { - $this->validateIdentifier($table, 'Table'); - $this->table = $table; - return $this; - } - - /** - * Get the table name (without namespace prefix). - */ - public function getTable(): string - { - return $this->table; - } - - /** - * Enable or disable HTTPS for ClickHouse HTTP interface. - */ - public function setSecure(bool $secure): self - { - $this->secure = $secure; - return $this; - } - - /** - * Get the namespace. - */ - public function getNamespace(): string - { - return $this->namespace; - } - - /** - * Set the tenant ID for multi-tenant support. - * Tenant is used to isolate audit logs by tenant. - */ - public function setTenant(?int $tenant): self - { - $this->tenant = $tenant; - return $this; - } - - /** - * Get the tenant ID. - */ - public function getTenant(): ?int - { - return $this->tenant; - } - - /** - * Set whether tables are shared across tenants. - * When enabled, a tenant column is added to the table for data isolation. - */ - public function setSharedTables(bool $sharedTables): self - { - $this->sharedTables = $sharedTables; - return $this; - } - - /** - * Get whether tables are shared across tenants. - */ - public function isSharedTables(): bool - { - return $this->sharedTables; - } - - /** - * Set whether cleanup() should return after scheduling the DELETE mutation - * rather than waiting for it to complete. When enabled, the DELETE is sent - * with `SETTINGS lightweight_deletes_sync = 0` and the HTTP call returns - * as soon as the mutation is queued. - */ - public function setAsyncCleanup(bool $asyncCleanup): self - { - $this->asyncCleanup = $asyncCleanup; - return $this; - } - - /** - * Get whether cleanup() runs asynchronously. - */ - public function isAsyncCleanup(): bool - { - return $this->asyncCleanup; - } - - /** - * Set the retention window in days. When set, setup() applies a TTL so - * rows older than the window are dropped by background merges. Pass null - * to disable (the default). - * - * @throws Exception If $days is not positive - */ - public function setRetention(?int $days): self - { - if ($days !== null && $days < 1) { - throw new Exception('Retention must be a positive number of days'); - } - $this->retention = $days; - return $this; - } - - /** - * Get the retention window in days, or null when TTL is disabled. - */ - public function getRetention(): ?int - { - return $this->retention; - } - - /** - * Override getAttributes to provide extended attributes for ClickHouse. - * Includes existing attributes from parent and adds new missing ones. - * - * @return array> - */ - #[\Override] - public function getAttributes(): array - { - $parentAttributes = parent::getAttributes(); - - foreach ($parentAttributes as &$attribute) { - if (($attribute['$id'] ?? null) === 'userId') { - $attribute['$id'] = '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' => [], - ], - ]; - } - - /** - * Override getIndexes to provide extended indexes for ClickHouse. - * Includes existing indexes from parent and adds new missing ones. - * - * @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']; - 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' => [], - ], - ]; - } - - /** - * Get the table name with namespace prefix. - * Namespace is used to isolate tables for different projects/applications. - */ - private function getTableName(): string - { - $tableName = $this->table; - - if ($this->namespace !== '' && $this->namespace !== '0') { - return $this->namespace . '_' . $tableName; - } - - return $tableName; - } - - /** - * Build the column → ClickHouse type map registered on `Builder\ClickHouse` - * so positional `?` bindings are emitted as typed `{paramN:Type}` placeholders. - * - * Derived from `getAttributes()` so the map stays in sync with the schema — - * DateTime attributes get `DateTime64(3)`, everything else gets `String`. - * `id` is added explicitly because it lives outside `getAttributes()`, and - * `tenant` is added only when shared-tables mode is on. `limit`, `offset` - * and `max` are pseudo-columns used by the count/find SQL wrappers. - * - * @return array - */ - private function getColumnTypeMap(): array - { - $map = ['id' => 'String']; - - foreach ($this->getAttributes() as $attribute) { - /** @var string $id */ - $id = $attribute['$id']; - $map[$id] = ($attribute['type'] ?? null) === Database::VAR_DATETIME - ? 'DateTime64(3)' - : 'String'; - } - - if ($this->sharedTables) { - $map['tenant'] = 'UInt64'; - } - - $map['limit'] = 'UInt64'; - $map['offset'] = 'UInt64'; - $map['max'] = 'UInt64'; - - return $map; - } - - /** - * Build a `Builder\ClickHouse` instance with the adapter's column type map - * pre-registered. Every adapter call site that produces SQL goes through - * here so positional `?` bindings can be rewritten to typed `{paramN:Type}` - * placeholders at `Statement` time. - */ - private function newBuilder(): ClickHouseBuilder - { - return new ClickHouseBuilder() - ->useNamedBindings() - ->withParamTypes($this->getColumnTypeMap()); - } - - /** - * Execute a ClickHouse query via HTTP interface using Fetch Client. - * - * This unified method supports two modes of operation: - * - * 1. **Parameterized queries** (when $params is provided): - * Uses ClickHouse query parameters sent as POST multipart form data. - * Parameters are referenced in SQL using syntax: {paramName:Type} - * Example: SELECT * WHERE id = {id:String} - * - * 2. **Pre-serialized body queries** (when $rawBody is provided): - * Used for FORMAT-style INSERT operations (e.g. JSONEachRow). - * SQL envelope is sent via URL query string and the body is sent - * verbatim as the POST body. The caller (typically the typed - * `Builder\ClickHouse::bulkInsert()` entry point) is responsible for - * serializing rows into the format ClickHouse expects. - * - * 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 string|null $rawBody Pre-serialized request body for FORMAT INSERT operations - * @return string Response body - * @throws Exception - */ - private function query(string $sql, array $params = [], ?string $rawBody = null): string - { - $scheme = $this->secure ? 'https' : 'http'; - - // Update the database header for each query (in case setDatabase was called) - $this->client->addHeader('X-ClickHouse-Database', $this->database); - - try { - if ($rawBody !== null) { - // Pre-serialized body mode for FORMAT INSERT operations - $url = "{$scheme}://{$this->host}:{$this->port}/?query=" . urlencode($sql); - $body = $rawBody; - } else { - // Parameterized query mode using multipart form data - $url = "{$scheme}://{$this->host}:{$this->port}/"; - - // Build multipart form data body with query and parameters - $body = ['query' => $sql]; - foreach ($params as $key => $value) { - $body['param_' . $key] = $this->formatParamValue($value); - } - } - - $response = $this->client->fetch( - url: $url, - method: Client::METHOD_POST, - body: $body, - ); - - if ($response->getStatusCode() !== 200) { - $responseBody = $response->getBody(); - $responseBody = \is_string($responseBody) ? $responseBody : ''; - throw new Exception("ClickHouse query failed with HTTP {$response->getStatusCode()}: {$responseBody}"); - } - - $responseBody = $response->getBody(); - return \is_string($responseBody) ? $responseBody : ''; - } catch (Exception $e) { - throw new Exception( - "ClickHouse query execution failed: {$e->getMessage()}", - 0, - $e, - ); - } - } - - /** - * Format a parameter value for safe transmission to ClickHouse. - * - * Converts PHP values to their string representation without SQL quoting. - * ClickHouse's query parameter mechanism handles type conversion and escaping. - */ - private function formatParamValue(mixed $value): string - { - if (\is_int($value) || \is_float($value)) { - return (string) $value; - } - - if ($value === null) { - return ''; - } - - if (\is_bool($value)) { - return $value ? '1' : '0'; - } - - if (\is_array($value)) { - 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); - } - } - - if (\is_string($value)) { - return $value; - } - - // For objects or other types, attempt to convert to string - if (\is_object($value) && method_exists($value, '__toString')) { - return (string) $value; - } - - return ''; - } - - /** - * Setup ClickHouse table structure. - * - * Creates the database and table if they don't exist. - * Uses schema definitions from the base SQL adapter. - * - * @throws Exception - */ - public function setup(): void - { - // Create database if not exists - $escapedDatabase = $this->escapeIdentifier($this->database); - $this->query("CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"); - - $tableName = $this->getTableName(); - $qualifiedTable = $this->database . '.' . $tableName; - $escapedDatabaseAndTable = $escapedDatabase . '.' . $this->escapeIdentifier($tableName); - - // Build the DDL through Schema\ClickHouse so column types, LowCardinality - // wrappers, nullability, indexes, engine and settings all come from the - // typed builder rather than hand-assembled SQL fragments. - $schema = new ClickHouseSchema(); - $table = $schema->table($qualifiedTable); - $table->string('id')->primary(); - - foreach ($this->getAttributes() as $attribute) { - /** @var string $id */ - $id = $attribute['$id']; - - // Special handling for time column - must be NOT NULL for partition key - if ($id === 'time') { - $table->datetime('time', precision: 3); - - continue; - } - - $column = $table->addColumn($id, ($attribute['type'] ?? null) === Database::VAR_DATETIME - ? ColumnType::Datetime - : ColumnType::String); - if (\in_array($id, self::LOW_CARDINALITY_COLUMNS, true)) { - $column->lowCardinality(); - } - if (empty($attribute['required'])) { - $column->nullable(); - } - } - - // Add tenant column only if tables are shared across tenants - if ($this->sharedTables) { - // Supports 11-digit MySQL auto-increment IDs - $table->bigInteger('tenant')->unsigned()->nullable(); - } - - foreach ($this->getIndexes() as $index) { - /** @var string $indexName */ - $indexName = $index['$id']; - /** @var array $attributes */ - $attributes = $index['attributes']; - $table->index( - columns: $attributes, - name: $indexName, - algorithm: IndexAlgorithm::BloomFilter, - granularity: 1, - ); - } - - $table->engine(ClickHouseEngine::MergeTree); - $table->orderBy($this->sharedTables ? ['tenant', 'time', 'id'] : ['time', 'id']); - $table->partitionBy('toYYYYMM(time)'); - - $settings = ['index_granularity' => '8192']; - if ($this->sharedTables) { - $settings['allow_nullable_key'] = '1'; - } - $table->settings($settings); - - $this->query($table->createIfNotExists()->query); - - // Apply retention as a separate, idempotent ALTER. CREATE TABLE IF NOT - // EXISTS won't add a TTL to a table that already exists, and MODIFY TTL - // is a no-op when the TTL already matches, so setup() stays re-runnable. - // materialize_ttl_after_modify = 0 defers the purge to background merges - // rather than an immediate, I/O-heavy mutation. - if ($this->retention !== null) { - $this->query( - "ALTER TABLE {$escapedDatabaseAndTable} " - . "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 - // applied; otherwise rows keep being purged despite retention being - // null. ClickHouse errors (code 36) when REMOVE TTL runs on a table - // that has no TTL, so swallow that specific case to keep setup() - // idempotent. - try { - $this->query( - "ALTER TABLE {$escapedDatabaseAndTable} REMOVE TTL", - ); - } catch (Exception $e) { - if (!str_contains($e->getMessage(), "doesn't have any table TTL expression")) { - throw $e; - } - } - } - } - - - /** - * Get column names from attributes. - * Returns an array of column names excluding 'id' and 'tenant' which are handled separately. - * - * @return array Column names - */ - private function getColumnNames(): array - { - $columns = []; - foreach ($this->getAttributes() as $attribute) { - /** @var string $columnName */ - $columnName = $attribute['$id']; - // Exclude id and tenant as they're handled separately - if ($columnName !== 'id' && $columnName !== 'tenant') { - $columns[] = $columnName; - } - } - 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. - */ - private function translateAttribute(string $attribute): string - { - return match ($attribute) { - 'userId' => 'actorId', - 'userType' => 'actorType', - 'userInternalId' => 'actorInternalId', - default => $attribute, - }; - } - - private function validateAttributeName(string $attributeName): bool - { - // Special case: 'id' is always valid - if ($attributeName === 'id') { - return true; - } - - // Check if tenant is valid (only when sharedTables is enabled) - if ($attributeName === 'tenant' && $this->sharedTables) { - return true; - } - - // Check against defined attributes - foreach ($this->getAttributes() as $attribute) { - if ($attribute['$id'] === $attributeName) { - return true; - } - } - - throw new Exception("Invalid attribute name: {$attributeName}"); - } - - /** - * Format datetime for ClickHouse compatibility. - * Converts datetime to 'YYYY-MM-DD HH:MM:SS.mmm' format without timezone suffix. - * 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 - * @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 - { - if ($dateTime === null) { - return new \DateTime()->format('Y-m-d H:i:s.v'); - } - - if ($dateTime instanceof \DateTime) { - return $dateTime->format('Y-m-d H:i:s.v'); - } - - try { - // 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}"); - } - } - - /** - * Create an audit log entry - * - * @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)) { - throw new Exception('Log ID must be a string'); - } - $log['id'] = $logId; - - // Use createBatch for the actual insertion - $this->createBatch([$log]); - - // Retrieve the created log using getById to ensure consistency - $createdLog = $this->getById($logId); - if (!$createdLog instanceof \Utopia\Audit\Log) { - throw new Exception("Failed to retrieve created log with ID: {$logId}"); - } - - return $createdLog; - } - - /** - * 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(); - $qualifiedTable = $this->database . '.' . $tableName; - - $builder = $this->newBuilder() - ->from($qualifiedTable) - ->selectRaw($this->getSelectColumns()) - ->filter([Query::equal('id', $id)]) - ->limit(1); - - $this->applyTenantFilter($builder); - - $statement = $builder->build(); - $sql = $statement->query . ' FORMAT JSON'; - - $result = $this->query($sql, $statement->namedBindings ?? []); - $logs = $this->parseJsonResults($result); - - return $logs[0] ?? null; - } - - /** - * Find logs using Query objects. - * - * @param array $queries - * @return array - * @throws Exception - */ - public function find(array $queries = []): array - { - $tableName = $this->getTableName(); - $qualifiedTable = $this->database . '.' . $tableName; - - $parsed = $this->parseQueries($queries); - - // Random ordering can't combine with anything that asks for a - // specific row order: cursor pagination needs a stable anchor, and - // 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 ($parsed['randomOrder'] && isset($parsed['cursor'])) { - throw new Exception('Cursor pagination cannot be combined with orderRandom'); - } - if ($parsed['randomOrder'] && $parsed['orderAttributes'] !== []) { - throw new Exception('orderRandom cannot be combined with orderAsc/orderDesc'); - } - - // Build SELECT clause — respect Query::select if provided, otherwise - // fall back to the full column list. - $selectColumns = $this->buildProjection($parsed['select'] ?? null); - - $builder = $this->newBuilder() - ->from($qualifiedTable) - ->selectRaw($selectColumns) - ->filter($parsed['filters']); - - $this->applyTenantFilter($builder); - - $cursorDirection = $parsed['cursorDirection'] ?? null; - $orderAttributes = $parsed['orderAttributes']; - $cursorParams = []; - - if (isset($parsed['cursor'])) { - $orderAttributes = $this->resolveCursorOrder($orderAttributes); - $cursorWhere = $this->buildCursorWhere($orderAttributes, $parsed['cursor'], $cursorDirection ?? 'after', []); - $builder->whereRaw($cursorWhere['clause']); - $cursorParams = $cursorWhere['params']; - } - - // ORDER BY. orderRandom is mutually exclusive with cursor and column - // ordering (rejected above); when cursor is in play, rebuild from - // orderAttributes (always non-empty after resolveCursorOrder, which - // appends an id tiebreaker), flipping directions for `cursorBefore`. - if ($parsed['randomOrder']) { - $builder->sortRandom(); - } elseif (isset($parsed['cursor'])) { - $this->applyOrderBy($builder, $orderAttributes, flip: $cursorDirection === 'before'); - } else { - $this->applyOrderBy($builder, $orderAttributes); - } - - if (isset($parsed['limit'])) { - $builder->limit($parsed['limit']); - } - if (isset($parsed['offset'])) { - $builder->offset($parsed['offset']); - } - - $statement = $builder->build(); - $sql = $statement->query . ' FORMAT JSON'; - $params = ($statement->namedBindings ?? []) + $cursorParams; - - $result = $this->query($sql, $params); - $rows = $this->parseJsonResults($result); - - if ($cursorDirection === 'before') { - return array_reverse($rows); - } - - return $rows; - } - - /** - * Build the SELECT projection list. When `$select` is null, returns the - * full column list from `getSelectColumns()`; otherwise validates and - * escapes each requested column. - * - * `id` is always projected so `parseJsonResults` can map it back to the - * `$id` field on the `Log` model. When `sharedTables` is enabled, the - * `tenant` column is also always projected — it's metadata callers expect - * on every row and the full-projection path already includes it. Callers - * requesting a slim projection don't have to remember either. - * - * @param list|null $select - * @throws Exception - */ - private function buildProjection(?array $select): string - { - if ($select === null) { - return $this->getSelectColumns(); - } - - // Forced columns are injected here, so they're validated defensively. - // User-supplied columns in $select are already validated inside the - // TYPE_SELECT branch of parseQueries() — no need to walk - // getAttributes() a second time per column. - $forced = ['id']; - if ($this->sharedTables) { - $forced[] = 'tenant'; - } - - $columns = []; - $seen = []; - foreach ($forced as $column) { - if (isset($seen[$column])) { - continue; - } - $this->validateAttributeName($column); - $columns[] = $this->escapeIdentifier($column); - $seen[$column] = true; - } - foreach ($select as $column) { - if (isset($seen[$column])) { - continue; - } - $columns[] = $this->escapeIdentifier($column); - $seen[$column] = true; - } - - return implode(', ', $columns); - } - - /** - * Count logs using Query objects. - * - * When $max is non-null the count is bounded at the database level via a - * `LIMIT {max}` inside a subquery — ClickHouse stops scanning once 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 - * @throws Exception - */ - public function count(array $queries = [], ?int $max = null): int - { - $tableName = $this->getTableName(); - $qualifiedTable = $this->database . '.' . $tableName; - - // Parse queries - we only need filters, not ordering/limit/offset/cursor - $parsed = $this->parseQueries($queries); - - $inner = $this->newBuilder() - ->from($qualifiedTable) - ->selectRaw($max !== null ? '1' : 'COUNT(*) AS count') - ->filter($parsed['filters']); - - $this->applyTenantFilter($inner); - - if ($max !== null) { - $inner->limit($max); - } - - $statement = $inner->build(); - $params = $statement->namedBindings ?? []; - - $sql = $max !== null - ? 'SELECT COUNT(*) AS count FROM (' . $statement->query . ') sub FORMAT TabSeparated' - : $statement->query . ' FORMAT TabSeparated'; - - $result = $this->query($sql, $params); - $trimmed = trim($result); - - return $trimmed !== '' ? (int) $trimmed : 0; - } - - /** - * Parse Query objects into builder-ready filters and auxiliary metadata. - * - * Returns the input filters as a list of `Utopia\Query\Query` instances — - * the caller hands them to `Builder\ClickHouse::filter()` which compiles - * them into typed `{paramN:Type}` placeholders via the column → type map - * registered on `newBuilder()`. Filter semantics are unchanged from the - * hand-written SQL this replaces: - * - * - `Equal` with multiple values compiles to `IN (...)`. - * - `Contains` / `NotContains` stay substring matches — the ClickHouse - * builder emits `position(col, ?) > 0` / `= 0`, which is equivalent to - * the previous `LIKE '%v%'` / `NOT LIKE '%v%'` form but needs no - * wildcard escaping. - * - `Regex` compiles to ClickHouse's re2 `match(col, ?)` predicate. - * - `time`-column values arriving as `\DateTimeInterface` are pre-formatted - * to ClickHouse's `Y-m-d H:i:s.v` literal so the HTTP layer doesn't see - * raw DateTime objects in `namedBindings`. - * - * @param array $queries - * @return array{filters: array, orderAttributes: array, randomOrder: bool, limit?: int, offset?: int, cursor?: array, cursorDirection?: string, select?: list} - * @throws Exception - */ - private function parseQueries(array $queries): array - { - $filters = []; - $orderAttributes = []; - $limit = null; - $offset = null; - $cursor = null; - $cursorDirection = null; - $select = null; - $randomOrder = false; - - foreach ($queries as $query) { - if (!$query instanceof Query) { - $type = get_debug_type($query); - throw new \InvalidArgumentException("Invalid query item: expected instance of Query, got {$type}"); - } - - $method = $query->getMethod(); - $attribute = $query->getAttribute(); - $attribute = $this->translateAttribute($attribute); - $values = $query->getValues(); - - // Reject empty values for filter methods that take values — mirrors - // the validator in utopia-php/database (Validator/Query/Filter.php) - // and prevents silently dropping the WHERE fragment, which would - // otherwise turn `Query::contains('attr', [])` into a full-table - // match instead of an empty result. - if (\in_array($method, self::VALUE_REQUIRED_METHODS, true) && $values === []) { - throw new \Exception(ucfirst($method->value) . ' queries require at least one value.'); - } - - switch ($method) { - case Method::Equal: - case Method::NotEqual: - case Method::LessThan: - case Method::LessThanEqual: - case Method::GreaterThan: - case Method::GreaterThanEqual: - case Method::Between: - case Method::NotBetween: - case Method::Contains: - case Method::NotContains: - case Method::IsNull: - case Method::IsNotNull: - case Method::StartsWith: - case Method::NotStartsWith: - case Method::EndsWith: - case Method::NotEndsWith: - case Method::Regex: - $this->validateAttributeName($attribute); - $filters[] = new BaseQuery($method, $attribute, $this->normalizeFilterValues($attribute, $values)); - break; - - case Method::Select: - if ($values === []) { - // VALUE_REQUIRED_METHODS already rejects empty values - // earlier, but the explicit check keeps this branch safe - // if the guard is ever bypassed. - break; - } - // Multiple Query::select(...) calls combine into a single - // projection. Duplicates are removed; column names are - // validated and escaped at SQL build time in find(). - $select ??= []; - foreach ($values as $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)) { - $select[] = $column; - } - } - break; - - case Method::OrderDesc: - $this->validateAttributeName($attribute); - $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'DESC']; - break; - - case Method::OrderAsc: - $this->validateAttributeName($attribute); - $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'ASC']; - break; - - 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 Method::Limit: - if (!\is_int($values[0])) { - throw new \Exception('Invalid limit value. Expected int'); - } - $limit = $values[0]; - break; - - case Method::Offset: - if (!\is_int($values[0])) { - throw new \Exception('Invalid offset value. Expected int'); - } - $offset = $values[0]; - break; - - case Method::CursorAfter: - case Method::CursorBefore: - if ($cursor !== null) { - // Keep the first cursor encountered (matches base groupByType semantics) - break; - } - $rawCursor = $values[0] ?? null; - if ($rawCursor === null) { - break; // no-op cursor - } - $cursor = $this->normalizeCursorRow($rawCursor); - $cursorDirection = $method === Method::CursorAfter ? 'after' : 'before'; - break; - } - } - - $result = [ - 'filters' => $filters, - 'orderAttributes' => $orderAttributes, - 'randomOrder' => $randomOrder, - ]; - - if ($limit !== null) { - $result['limit'] = $limit; - } - - if ($offset !== null) { - $result['offset'] = $offset; - } - - if ($cursor !== null && $cursorDirection !== null) { - $result['cursor'] = $cursor; - $result['cursorDirection'] = $cursorDirection; - } - - if ($select !== null) { - $result['select'] = $select; - } - - return $result; - } - - /** - * Normalize filter values so DateTime instances on the `time` column flow - * through `namedBindings` as ClickHouse-compatible strings rather than raw - * objects (the HTTP layer would otherwise serialise them as empty). - * - * @param array $values - * @return array - * - * @throws Exception - */ - private function normalizeFilterValues(string $attribute, array $values): array - { - if ($this->getParamType($attribute) !== 'DateTime64(3)') { - return $values; - } - - $normalized = []; - foreach ($values as $value) { - if ($value === null) { - $normalized[] = null; - - continue; - } - /** @var \DateTime|string $value */ - $normalized[] = $this->formatDateTime($value); - } - - return $normalized; - } - - /** - * Apply an ordered list of column directions to the builder via the - * canonical `sortAsc` / `sortDesc` API, optionally flipping each direction - * for `cursorBefore` pagination. - * - * @param array $orderAttributes - */ - private function applyOrderBy(ClickHouseBuilder $builder, array $orderAttributes, bool $flip = false): void - { - foreach ($orderAttributes as $entry) { - $direction = $entry['direction']; - if ($flip) { - $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; - } - - if ($direction === 'DESC') { - $builder->sortDesc($entry['attribute']); - } else { - $builder->sortAsc($entry['attribute']); - } - } - } - - /** - * Normalize a user-supplied cursor row into a column-keyed array. - * - * Accepts a `Log` (or any `ArrayObject`) or a plain associative array. - * `Log` stores its identifier under `$id` (Appwrite convention) while the - * underlying column is `id` — this remaps `$id` → `id` so cursor pagination - * can match the SQL column. - * - * @return array - * @throws Exception - */ - private function normalizeCursorRow(mixed $rawCursor): array - { - if ($rawCursor instanceof \ArrayObject) { - /** @var array $row */ - $row = $rawCursor->getArrayCopy(); - } elseif (\is_array($rawCursor)) { - /** @var array $rawCursor */ - $row = $rawCursor; - } else { - throw new Exception( - 'Invalid cursor value: expected ArrayObject (Log) or associative array, got ' - . get_debug_type($rawCursor), - ); - } - - if (!\array_key_exists('id', $row) && \array_key_exists('$id', $row)) { - $row['id'] = $row['$id']; - unset($row['$id']); - } - - return $row; - } - - /** - * Resolve the ClickHouse parameter type for a column. - * - * Used by both filter binding and cursor keyset comparison so values are - * bound with the column's actual SQL type — binding a numeric column as - * `String` would compare values lexicographically (`"9" > "10"`) and - * silently produce incorrect filter results or page boundaries. Add a - * branch here when introducing a new non-String column type. - * - * @return string ClickHouse parameter type (e.g. 'String', 'DateTime64(3)', 'UInt64') - */ - private function getParamType(string $attribute): string - { - // Derived from the same map the builder is given, rather than a second - // hardcoded list. The two used to be able to disagree: this matched only - // `time`, while getColumnTypeMap() types every VAR_DATETIME attribute, so - // a second datetime column would have been bound as String here and - // declared DateTime64(3) there. - return $this->getColumnTypeMap()[$attribute] ?? 'String'; - } - - /** - * Format a value for the given ClickHouse parameter type. - * - * Routes DateTime-typed columns through formatDateTime() and everything - * else through formatParamValue(). Centralising this dispatch keeps - * parseQueries and buildCursorWhere consistent across libraries. - * - * @param string $chType ClickHouse parameter type as returned by getParamType() - * @throws Exception - */ - private function formatTypedValue(string $chType, mixed $value): string - { - if ($chType === 'DateTime64(3)') { - if ($value === null) { - throw new Exception('DateTime parameter value cannot be null'); - } - /** @var \DateTime|string $value */ - return $this->formatDateTime($value); - } - - return $this->formatParamValue($value); - } - - /** - * Resolve the effective order attributes for cursor pagination. - * - * Auto-appends `id` as a tiebreaker when not already present so keyset - * pagination is deterministic on non-unique columns (e.g. time). - * - * @param array $orderAttributes - * @return array - */ - private function resolveCursorOrder(array $orderAttributes): array - { - foreach ($orderAttributes as $entry) { - if ($entry['attribute'] === 'id') { - return $orderAttributes; - } - } - - $defaultDirection = 'DESC'; - if ($orderAttributes !== []) { - $last = $orderAttributes[\count($orderAttributes) - 1]; - $defaultDirection = $last['direction']; - } - - $orderAttributes[] = ['attribute' => 'id', 'direction' => $defaultDirection]; - - return $orderAttributes; - } - - /** - * Build keyset-pagination WHERE fragments for cursor support. - * - * Produces a tuple-compare clause across the order attributes: - * (a > A) OR (a = A AND b > B) OR ... - * - * For cursor `before`, the comparison directions are flipped relative to - * the requested ORDER BY (the caller is responsible for also flipping the - * actual ORDER BY at SQL build time so the page comes back from the right - * side, then reversing the rows post-fetch). - * - * @param array $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 - { - $tuples = []; - foreach ($orderAttributes as $i => $entry) { - $attr = $entry['attribute']; - $direction = $entry['direction']; - - if (!\array_key_exists($attr, $cursor)) { - throw new Exception("Cursor is missing required attribute '{$attr}'"); - } - - if ($cursorDirection === 'before') { - $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; - } - - $conditions = []; - - for ($j = 0; $j < $i; $j++) { - $prev = $orderAttributes[$j]; - $prevAttr = $prev['attribute']; - if (!\array_key_exists($prevAttr, $cursor)) { - throw new Exception("Cursor is missing required attribute '{$prevAttr}'"); - } - $prevValue = $cursor[$prevAttr]; - if ($prevValue === null) { - throw new Exception("Cursor value for '{$prevAttr}' cannot be null"); - } - $prevEscaped = $this->escapeIdentifier($prevAttr); - $prevType = $this->getParamType($prevAttr); - $paramName = "cursor_eq_{$i}_{$j}"; - - $conditions[] = "{$prevEscaped} = {{$paramName}:{$prevType}}"; - $params[$paramName] = $this->formatTypedValue($prevType, $prevValue); - } - - $value = $cursor[$attr]; - if ($value === null) { - throw new Exception("Cursor value for '{$attr}' cannot be null"); - } - $escaped = $this->escapeIdentifier($attr); - $chType = $this->getParamType($attr); - $operator = $direction === 'DESC' ? '<' : '>'; - $paramName = "cursor_cmp_{$i}"; - - $conditions[] = "{$escaped} {$operator} {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - - $tuples[] = '(' . implode(' AND ', $conditions) . ')'; - } - - return [ - 'clause' => '(' . implode(' OR ', $tuples) . ')', - 'params' => $params, - ]; - } - - /** - * Create multiple audit log entries in batch. - * - * @param array> $logs The logs to insert - * @throws Exception - */ - public function createBatch(array $logs): bool - { - if ($logs === []) { - return true; - } - - $tableName = $this->getTableName(); - $qualifiedTable = $this->database . '.' . $tableName; - - // Get all attribute column names - $schemaColumns = $this->getColumnNames(); - - // Build JSON rows for JSONEachRow format - $rows = []; - - foreach ($logs as $log) { - foreach (['userId' => 'actorId', 'userType' => 'actorType', 'userInternalId' => 'actorInternalId'] as $legacy => $current) { - if (isset($log[$legacy]) && !isset($log[$current])) { - $log[$current] = $log[$legacy]; - } - unset($log[$legacy]); - } - - /** @var array $logData */ - $logData = $log['data'] ?? []; - - foreach (['userId' => 'actorId', 'userType' => 'actorType', 'userInternalId' => 'actorInternalId'] as $legacy => $current) { - if (\array_key_exists($legacy, $logData) && !\array_key_exists($current, $logData)) { - $logData[$current] = $logData[$legacy]; - } - unset($logData[$legacy]); - } - $log['data'] = $logData; - - // Separate data for non-schema attributes - $nonSchemaData = $logData; - $resourceValue = $log['resource'] ?? null; - if (!\is_string($resourceValue)) { - $resourceValue = ''; - } - $resource = $this->parseResource($resourceValue); - $processedLog = $log; - - // Extract schema attributes: check main log first, then data array - foreach ($schemaColumns as $columnName) { - if ($columnName === 'data') { - continue; - } - if ($columnName === 'time') { - continue; - } - // If attribute not in main log, check data array - if (!isset($processedLog[$columnName]) && isset($logData[$columnName])) { - $processedLog[$columnName] = $logData[$columnName]; - unset($nonSchemaData[$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])) { - // If in both, main log takes precedence, remove from data - unset($nonSchemaData[$columnName]); - } - } - - // Build JSON row - use provided id or generate one - $logId = $log['id'] ?? uniqid('', true); - - /** @var string|\DateTime|null $providedTime */ - $providedTime = $processedLog['time'] ?? null; - $formattedTime = $this->formatDateTime($providedTime); - - $row = [ - 'id' => $logId, - 'time' => $formattedTime, - ]; - - // Add all other columns - foreach ($schemaColumns as $columnName) { - if ($columnName === 'time') { - continue; // Already handled - } - - // Get attribute metadata to determine if required - $attributeMetadata = $this->getAttribute($columnName); - $isRequiredAttribute = $attributeMetadata !== null && isset($attributeMetadata['required']) && $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); - } - $row['data'] = $encodedData; - } elseif (isset($processedLog[$columnName])) { - $row[$columnName] = $processedLog[$columnName]; - } elseif ($isRequiredAttribute) { - throw new \InvalidArgumentException("Required attribute '{$columnName}' is missing in batch log entry"); - } - } - - if ($this->sharedTables) { - $row['tenant'] = $log['$tenant'] ?? $this->tenant; - } - - $rows[] = $row; - } - - $columns = ['id', 'time']; - foreach ($schemaColumns as $columnName) { - if ($columnName === 'time') { - continue; - } - $columns[] = $columnName; - } - if ($this->sharedTables) { - $columns[] = 'tenant'; - } - - // Builder\ClickHouse::bulkInsert() emits the `INSERT INTO ... FORMAT - // JSONEachRow` envelope and serialises the rows into the request body. - $statement = $this->newBuilder() - ->into($qualifiedTable) - ->bulkInsert(Format::JSONEachRow, $rows, $columns); - - $this->query($statement->query, [], $statement->body); - - return true; - } - - /** - * 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 - * @return array - * @throws Exception If JSON parsing fails - */ - private function parseJsonResults(string $result): array - { - if (\in_array(trim($result), ['', '0'], true)) { - return []; - } - - /** @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()); - } - - if (!\is_array($decoded) || !isset($decoded['data']) || !\is_array($decoded['data'])) { - return []; - } - - /** @var array> $data */ - $data = $decoded['data']; - $documents = []; - - foreach ($data as $row) { - if (!\is_array($row)) { - continue; - } - - $document = []; - - foreach ($row as $columnName => $value) { - if ($columnName === 'data') { - // Decode JSON data column - $document[$columnName] = \is_string($value) ? json_decode($value, true) ?? [] : $value ?? []; - } elseif ($columnName === 'tenant') { - // Parse tenant as integer or null - if ($value === null || $value === '') { - $document[$columnName] = null; - } elseif (is_numeric($value)) { - $document[$columnName] = (int) $value; - } else { - $document[$columnName] = null; - } - } elseif ($columnName === 'time') { - // Convert ClickHouse timestamp format back to ISO 8601 - // 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'; - } - $document[$columnName] = $parsedTime; - } else { - // For other fields, handle null values - $document[$columnName] = $value; - } - } - - // Add special $id field if present - if (isset($document['id'])) { - $document['$id'] = $document['id']; - unset($document['id']); - } - - foreach (['actorId' => 'userId', 'actorType' => 'userType', 'actorInternalId' => 'userInternalId'] as $current => $legacy) { - if (\array_key_exists($current, $document) && !\array_key_exists($legacy, $document)) { - $document[$legacy] = $document[$current]; - } - } - - $documents[] = new Log($document); - } - - return $documents; - } - - /** - * Get the SELECT column list for queries. - * Dynamically builds the column list from attributes, excluding 'data' column. - * Escapes all column names to prevent SQL injection. - */ - private function getSelectColumns(): string - { - $columns = []; - - // Add id column first (not part of attributes) - $columns[] = $this->escapeIdentifier('id'); - - // Dynamically add all attribute columns except 'data' - foreach ($this->getAttributes() as $attribute) { - $id = $attribute['$id']; - /** @var string $id */ - if ($id !== 'data') { - $columns[] = $this->escapeIdentifier($id); - } - } - - // Add data column at the end - $columns[] = $this->escapeIdentifier('data'); - - // Add tenant column if shared tables are enabled - if ($this->sharedTables) { - $columns[] = $this->escapeIdentifier('tenant'); - } - - return implode(', ', $columns); - } - - /** - * Build tenant filter clause based on current tenant context. - * Escapes column name to prevent SQL injection. - */ - private function applyTenantFilter(ClickHouseBuilder $builder): void - { - if (!$this->sharedTables || $this->tenant === null) { - return; - } - - $builder->filter([Query::equal('tenant', [$this->tenant])]); - } - - /** - * Get ClickHouse-specific SQL column definition for a given attribute ID. - * - * Dynamically determines the ClickHouse type based on attribute metadata. - * DateTime attributes use DateTime64(3), all others use String. - * - * @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) { - 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) - ? 'DateTime64(3)' - : 'String'; - - $required = (bool) $attribute['required']; - - if ($type === 'String' && \in_array($id, self::LOW_CARDINALITY_COLUMNS, true)) { - $columnType = $required - ? 'LowCardinality(String)' - : 'LowCardinality(Nullable(String))'; - - return "{$id} {$columnType}"; - } - - $columnType = $required ? $type : 'Nullable(' . $type . ')'; - - return "{$id} {$columnType}"; - } - - /** - * Get logs by user ID. - * - * @throws Exception - */ - public function getByUser( - string $userId, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - $queries = [ - Query::equal('actorId', $userId), - ]; - - if ($after instanceof \DateTime && $before instanceof \DateTime) { - $queries[] = Query::between('time', $after, $before); - } elseif ($after instanceof \DateTime) { - $queries[] = Query::greaterThan('time', $after); - } elseif ($before instanceof \DateTime) { - $queries[] = Query::lessThan('time', $before); - } - - $queries[] = $ascending ? Query::orderAsc('time') : Query::orderDesc('time'); - $queries[] = Query::limit($limit); - $queries[] = Query::offset($offset); - - return $this->find($queries); - } - - /** - * Count logs by user ID. - * - * @throws Exception - */ - public function countByUser( - string $userId, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - $queries = [ - Query::equal('actorId', $userId), - ]; - - if ($after instanceof \DateTime && $before instanceof \DateTime) { - $queries[] = Query::between('time', $after, $before); - } elseif ($after instanceof \DateTime) { - $queries[] = Query::greaterThan('time', $after); - } elseif ($before instanceof \DateTime) { - $queries[] = Query::lessThan('time', $before); - } - - return $this->count($queries, $max); - } - - /** - * Get logs by resource. - * - * @throws Exception - */ - public function getByResource( - string $resource, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - $queries = [ - Query::equal('resource', $resource), - ]; - - if ($after instanceof \DateTime && $before instanceof \DateTime) { - $queries[] = Query::between('time', $after, $before); - } elseif ($after instanceof \DateTime) { - $queries[] = Query::greaterThan('time', $after); - } elseif ($before instanceof \DateTime) { - $queries[] = Query::lessThan('time', $before); - } - - $queries[] = $ascending ? Query::orderAsc('time') : Query::orderDesc('time'); - $queries[] = Query::limit($limit); - $queries[] = Query::offset($offset); - - return $this->find($queries); - } - - /** - * Count logs by resource. - * - * @throws Exception - */ - public function countByResource( - string $resource, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - $queries = [ - Query::equal('resource', $resource), - ]; - - if ($after instanceof \DateTime && $before instanceof \DateTime) { - $queries[] = Query::between('time', $after, $before); - } elseif ($after instanceof \DateTime) { - $queries[] = Query::greaterThan('time', $after); - } elseif ($before instanceof \DateTime) { - $queries[] = Query::lessThan('time', $before); - } - - return $this->count($queries, $max); - } - - /** - * Get logs by user and events. - * - * @throws Exception - */ - public function getByUserAndEvents( - string $userId, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - $queries = [ - Query::equal('actorId', $userId), - Query::equal('event', $events), - ]; - - if ($after instanceof \DateTime && $before instanceof \DateTime) { - $queries[] = Query::between('time', $after, $before); - } elseif ($after instanceof \DateTime) { - $queries[] = Query::greaterThan('time', $after); - } elseif ($before instanceof \DateTime) { - $queries[] = Query::lessThan('time', $before); - } - - $queries[] = $ascending ? Query::orderAsc('time') : Query::orderDesc('time'); - $queries[] = Query::limit($limit); - $queries[] = Query::offset($offset); - - return $this->find($queries); - } - - /** - * Count logs by user and events. - * - * @throws Exception - */ - public function countByUserAndEvents( - string $userId, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - $queries = [ - Query::equal('actorId', $userId), - Query::equal('event', $events), - ]; - - if ($after instanceof \DateTime && $before instanceof \DateTime) { - $queries[] = Query::between('time', $after, $before); - } elseif ($after instanceof \DateTime) { - $queries[] = Query::greaterThan('time', $after); - } elseif ($before instanceof \DateTime) { - $queries[] = Query::lessThan('time', $before); - } - - return $this->count($queries, $max); - } - - /** - * Get logs by resource and events. - * - * @throws Exception - */ - public function getByResourceAndEvents( - string $resource, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - $queries = [ - Query::equal('resource', $resource), - Query::equal('event', $events), - ]; - - if ($after instanceof \DateTime && $before instanceof \DateTime) { - $queries[] = Query::between('time', $after, $before); - } elseif ($after instanceof \DateTime) { - $queries[] = Query::greaterThan('time', $after); - } elseif ($before instanceof \DateTime) { - $queries[] = Query::lessThan('time', $before); - } - - $queries[] = $ascending ? Query::orderAsc('time') : Query::orderDesc('time'); - $queries[] = Query::limit($limit); - $queries[] = Query::offset($offset); - - return $this->find($queries); - } - - /** - * Count logs by resource and events. - * - * @throws Exception - */ - public function countByResourceAndEvents( - string $resource, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - $queries = [ - Query::equal('resource', $resource), - Query::equal('event', $events), - ]; - - if ($after instanceof \DateTime && $before instanceof \DateTime) { - $queries[] = Query::between('time', $after, $before); - } elseif ($after instanceof \DateTime) { - $queries[] = Query::greaterThan('time', $after); - } elseif ($before instanceof \DateTime) { - $queries[] = Query::lessThan('time', $before); - } - - return $this->count($queries, $max); - } - - /** - * Delete logs older than the specified datetime. - * - * @throws Exception - */ - public function cleanup(\DateTime $datetime): bool - { - $tableName = $this->getTableName(); - $qualifiedTable = $this->database . '.' . $tableName; - $escapedTimeColumn = $this->escapeIdentifier('time'); - $datetimeString = $datetime->format('Y-m-d H:i:s.v'); - - $builder = $this->newBuilder() - ->into($qualifiedTable) - ->whereRaw($escapedTimeColumn . ' < {datetime:DateTime64(3)}'); - - $this->applyTenantFilter($builder); - - if ($this->asyncCleanup) { - $builder->settings(['lightweight_deletes_sync' => '0']); - } - - $sql = $builder->delete()->query; - - $this->query($sql, ['datetime' => $datetimeString]); - - return true; - } -} diff --git a/packages/audit/src/Audit/Adapter/Database.php b/packages/audit/src/Audit/Adapter/Database.php deleted file mode 100644 index 796f5981e..000000000 --- a/packages/audit/src/Audit/Adapter/Database.php +++ /dev/null @@ -1,528 +0,0 @@ -db->ping(); - } catch (\Throwable) { - return false; - } - } - - /** - * Setup database structure. - * - * @throws \Exception - */ - public function setup(): void - { - if (! $this->db->exists($this->db->getDatabase())) { - throw new Exception('You need to create the database before running Audit setup'); - } - - $attributes = $this->getAttributeDocuments(); - $indexes = $this->getIndexDocuments(); - - try { - $this->db->createCollection( - $this->getCollectionName(), - $attributes, - $indexes, - ); - } catch (DuplicateException) { - // Collection already exists - } - } - - /** - * Create an audit log entry. - * - * @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))); - - return new Log($document->getArrayCopy()); - } - - /** - * Create multiple audit log entries in batch. - * - * @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(); - 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); - }); - - return true; - } - - /** - * 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)); - - if ($document->isEmpty()) { - return null; - } - - return new Log($document->getArrayCopy()); - } - - /** - * Build time-related query conditions. - * - * @return array - */ - private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array - { - $queries = []; - - $afterStr = $after instanceof \DateTime ? DateTime::format($after) : null; - $beforeStr = $before instanceof \DateTime ? DateTime::format($before) : null; - - if ($afterStr !== null && $beforeStr !== null) { - $queries[] = Query::between('time', $afterStr, $beforeStr); - return $queries; - } - - if ($afterStr !== null) { - $queries[] = Query::greaterThan('time', $afterStr); - } - - if ($beforeStr !== null) { - $queries[] = Query::lessThan('time', $beforeStr); - } - - return $queries; - } - - /** - * Get audit logs by user ID. - * - * @return array - * @throws AuthorizationException|\Exception - */ - public function getByUser( - string $userId, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - $timeQueries = $this->buildTimeQueries($after, $before); - $documents = $this->db->getAuthorization()->skip(function () use ($userId, $timeQueries, $limit, $offset, $ascending): array { - $queries = [ - Query::equal('userId', [$userId]), - ...$timeQueries, - $ascending ? Query::orderAsc() : Query::orderDesc(), - Query::limit($limit), - Query::offset($offset), - ]; - - return $this->db->find( - collection: $this->getCollectionName(), - queries: $queries, - ); - }); - - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); - } - - /** - * Count audit logs by user ID. - * - * @throws AuthorizationException|\Exception - */ - public function countByUser( - string $userId, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - $timeQueries = $this->buildTimeQueries($after, $before); - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( - collection: $this->getCollectionName(), - queries: [ - Query::equal('userId', [$userId]), - ...$timeQueries, - ], - max: $max, - )); - } - - /** - * Get logs by resource. - * - * @return array - * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query - */ - public function getByResource( - string $resource, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - $timeQueries = $this->buildTimeQueries($after, $before); - $documents = $this->db->getAuthorization()->skip(function () use ($resource, $timeQueries, $limit, $offset, $ascending): array { - $queries = [ - Query::equal('resource', [$resource]), - ...$timeQueries, - $ascending ? Query::orderAsc() : Query::orderDesc(), - Query::limit($limit), - Query::offset($offset), - ]; - - return $this->db->find( - collection: $this->getCollectionName(), - queries: $queries, - ); - }); - - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); - } - - /** - * Count logs by resource. - * - * @throws \Utopia\Database\Exception - */ - public function countByResource( - string $resource, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - $timeQueries = $this->buildTimeQueries($after, $before); - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( - collection: $this->getCollectionName(), - queries: [ - Query::equal('resource', [$resource]), - ...$timeQueries, - ], - max: $max, - )); - } - - /** - * Get logs by user and events. - * - * @param array $events - * @return array - * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query - */ - public function getByUserAndEvents( - string $userId, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - $timeQueries = $this->buildTimeQueries($after, $before); - $documents = $this->db->getAuthorization()->skip(function () use ($userId, $events, $timeQueries, $limit, $offset, $ascending): array { - $queries = [ - Query::equal('userId', [$userId]), - Query::equal('event', $events), - ...$timeQueries, - $ascending ? Query::orderAsc() : Query::orderDesc(), - Query::limit($limit), - Query::offset($offset), - ]; - - return $this->db->find( - collection: $this->getCollectionName(), - queries: $queries, - ); - }); - - 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 - * @throws \Utopia\Database\Exception - */ - public function countByUserAndEvents( - string $userId, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - $timeQueries = $this->buildTimeQueries($after, $before); - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( - collection: $this->getCollectionName(), - queries: [ - Query::equal('userId', [$userId]), - Query::equal('event', $events), - ...$timeQueries, - ], - max: $max, - )); - } - - /** - * Get logs by resource and events. - * - * @param array $events - * @return array - * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query - */ - public function getByResourceAndEvents( - string $resource, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - $timeQueries = $this->buildTimeQueries($after, $before); - $documents = $this->db->getAuthorization()->skip(function () use ($resource, $events, $timeQueries, $limit, $offset, $ascending): array { - $queries = [ - Query::equal('resource', [$resource]), - Query::equal('event', $events), - ...$timeQueries, - $ascending ? Query::orderAsc() : Query::orderDesc(), - Query::limit($limit), - Query::offset($offset), - ]; - - return $this->db->find( - collection: $this->getCollectionName(), - queries: $queries, - ); - }); - - 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 - * @throws \Utopia\Database\Exception - */ - public function countByResourceAndEvents( - string $resource, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - $timeQueries = $this->buildTimeQueries($after, $before); - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( - collection: $this->getCollectionName(), - queries: [ - Query::equal('resource', [$resource]), - Query::equal('event', $events), - ...$timeQueries, - ], - max: $max, - )); - } - - /** - * Delete logs older than the specified datetime. - * - * @param \DateTime $datetime - /** - * @throws AuthorizationException|\Exception - */ - public function cleanup(\DateTime $datetime): bool - { - $datetimeString = DateTime::format($datetime); - $this->db->getAuthorization()->skip(function () use ($datetimeString): void { - /** - * $selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt', 'time']; - * todo: Use individuals selected queries later on - */ - $queries = [ - Query::lessThan('time', $datetimeString), - Query::orderDesc('time'), - Query::orderAsc(), - ]; - - do { - $removed = $this->db->deleteDocuments($this->getCollectionName(), $queries); - } while ($removed > 0); - }); - - return true; - } - - /** - * Get database-agnostic column definition for a given attribute ID. - * - * For the Database adapter, this method is not actively used since the adapter - * delegates to utopia-php/database's native Document/Collection API which handles - * type mapping internally. However, this implementation is required to satisfy - * the abstract method declaration in the base SQL adapter. - * - * @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) { - 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; - - if ($size > 0) { - return "{$id}: {$type}({$size})"; - } - - return "{$id}: {$type}"; - } - - /** - * Find logs using custom queries. - * - * Translates Audit Query objects to Database Query objects. - * - * @param array<\Utopia\Audit\Query> $queries - * @return array<\Utopia\Audit\Log> - * @throws AuthorizationException|\Exception - */ - public function find(array $queries = []): array - { - $dbQueries = []; - - foreach ($queries as $query) { - if (!($query instanceof \Utopia\Audit\Query)) { - throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); - } - - // Convert Audit Query to Database Query - // Both use the same structure and method names - $dbQueries[] = Query::parseQuery($query->toArray()); - } - - $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); - } - - /** - * Count logs using custom queries. - * - * 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 - * @throws AuthorizationException|\Exception - */ - public function count(array $queries = [], ?int $max = null): int - { - $dbQueries = []; - - foreach ($queries as $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 === Method::Limit) { - continue; - } - if ($method === Method::Offset) { - continue; - } - if ($method === Method::CursorAfter) { - continue; - } - if ($method === Method::CursorBefore) { - continue; - } - - // Convert Audit Query to Database Query - // Both use the same structure and method names - $queryArray = $query->toArray(); - $dbQueries[] = Query::parseQuery($queryArray); - } - - return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( - collection: $this->getCollectionName(), - queries: $dbQueries, - max: $max, - )); - } -} diff --git a/packages/audit/src/Audit/Adapter/SQL.php b/packages/audit/src/Audit/Adapter/SQL.php deleted file mode 100644 index f5208a33e..000000000 --- a/packages/audit/src/Audit/Adapter/SQL.php +++ /dev/null @@ -1,245 +0,0 @@ - - * - * @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'], - ], - ]; - } - - /** - * Get attribute documents for audit logs. - * - * @return array - */ - public function getAttributeDocuments(): array - { - return array_map(static fn(array $attribute): \Utopia\Database\Document => new Document($attribute), $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> - */ - 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'], - ], - ]; - } - - /** - * Get index documents for audit logs. - * - * @return array - */ - public function getIndexDocuments(): array - { - return array_map(static fn(array $index): \Utopia\Database\Document => new Document($index), $this->getIndexes()); - } - - /** - * Get a single attribute by ID. - * - * @return array|null - */ - protected function getAttribute(string $id) - { - foreach ($this->getAttributes() as $attribute) { - if ($attribute['$id'] === $id) { - return $attribute; - } - } - - return null; - } - - /** - * 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 - * @return string Database-specific column definition - */ - abstract protected function getColumnDefinition(string $id): string; - - /** - * Get all SQL column definitions. - * Uses the concrete adapter's implementation of getColumnDefinition. - * - * @return array - */ - protected function getAllColumnDefinitions(): array - { - $definitions = []; - foreach ($this->getAttributes() as $attribute) { - /** @var string $id */ - $id = $attribute['$id']; - $definitions[] = $this->getColumnDefinition($id); - } - - return $definitions; - } - - /** - * Parses the resource string from the payload and extracts its ID, type, and parent. - * - * Supports any even number of segments shaped as alternating `/`, - * e.g. `database/`, `database//collection/`, - * `database//collection//document/`. The last segment is the - * resource id, the second-to-last is the resource type, and any preceding - * segments form the resource parent path. - * - * @return array{ resourceId: string, resourceType: string, resourceParent: string } - */ - protected function parseResource(string $resource): array - { - $parts = explode('/', $resource); - $count = \count($parts); - - $resourceId = $resource; - $resourceType = ''; - $resourceParent = ''; - - if ($count >= 2 && $count % 2 === 0) { - $resourceId = $parts[$count - 1]; - $resourceType = $parts[$count - 2]; - - if ($count > 2) { - $resourceParent = implode('/', \array_slice($parts, 0, $count - 2)); - } - } - - return [ - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'resourceParent' => $resourceParent, - ]; - } -} diff --git a/packages/audit/src/Audit/Audit.php b/packages/audit/src/Audit/Audit.php deleted file mode 100644 index 1efd9b007..000000000 --- a/packages/audit/src/Audit/Audit.php +++ /dev/null @@ -1,266 +0,0 @@ -adapter; - } - - /** - * Setup the audit log storage. - * - * @throws \Exception - */ - public function setup(): void - { - $this->adapter->setup(); - } - - /** - * Add event log. - * - * @param array $data - * - * @throws \Exception - */ - public function log(?string $userId, string $event, string $resource, string $userAgent, string $ip, array $data = []): Log - { - /** @var array{userId?: string|null, event: string, resource: string, userAgent: string, ip: string, data?: array} $log */ - $log = [ - 'userId' => $userId, - 'event' => $event, - 'resource' => $resource, - 'userAgent' => $userAgent, - 'ip' => $ip, - 'data' => $data, - ]; - - return $this->adapter->create($log); - } - - /** - * Add multiple event logs in batch. - * - * @param array}> $events - * - * @throws \Exception - */ - public function logBatch(array $events): bool - { - return $this->adapter->createBatch($events); - } - - /** - * Get a single log by its ID. - * - * @return Log|null The log entry or null if not found - * @throws \Exception - */ - public function getLogById(string $id): ?Log - { - return $this->adapter->getById($id); - } - - /** - * Get all logs by user ID. - * - * @return array - * @throws \Exception - */ - public function getLogsByUser( - string $userId, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - return $this->adapter->getByUser($userId, $after, $before, $limit, $offset, $ascending); - } - - /** - * Count logs by user ID. - * - * @throws \Exception - */ - public function countLogsByUser( - string $userId, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - return $this->adapter->countByUser($userId, $after, $before, $max); - } - - /** - * Get all logs by resource. - * - * @return array - * @throws \Exception - */ - public function getLogsByResource( - string $resource, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - return $this->adapter->getByResource($resource, $after, $before, $limit, $offset, $ascending); - } - - /** - * Count logs by resource. - * - * - * @throws \Exception - */ - public function countLogsByResource( - string $resource, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - return $this->adapter->countByResource($resource, $after, $before, $max); - } - - /** - * Get logs by user and events. - * - * @param array $events - * @return array - * @throws \Exception - */ - public function getLogsByUserAndEvents( - string $userId, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - return $this->adapter->getByUserAndEvents($userId, $events, $after, $before, $limit, $offset, $ascending); - } - - /** - * Count logs by user and events. - * - * @param array $events - * - * @throws \Exception - */ - public function countLogsByUserAndEvents( - string $userId, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - return $this->adapter->countByUserAndEvents($userId, $events, $after, $before, $max); - } - - /** - * Get logs by resource and events. - * - * @param array $events - * @return array - * @throws \Exception - */ - public function getLogsByResourceAndEvents( - string $resource, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - int $limit = 25, - int $offset = 0, - bool $ascending = false, - ): array { - return $this->adapter->getByResourceAndEvents($resource, $events, $after, $before, $limit, $offset, $ascending); - } - - /** - * Count logs by resource and events. - * - * @param array $events - * - * @throws \Exception - */ - public function countLogsByResourceAndEvents( - string $resource, - array $events, - ?\DateTime $after = null, - ?\DateTime $before = null, - ?int $max = null, - ): int { - return $this->adapter->countByResourceAndEvents($resource, $events, $after, $before, $max); - } - - /** - * Delete all logs older than the specified datetime - * - * - * @throws \Exception - */ - public function cleanup(\DateTime $datetime): bool - { - return $this->adapter->cleanup($datetime); - } - - /** - * Find logs using custom queries. - * - * @param array $queries Array of Audit Query objects - * @return array - * - * @throws \Exception - */ - public function find(array $queries = []): array - { - return $this->adapter->find($queries); - } - - /** - * Count logs using custom queries. - * - * @param array $queries Array of Audit Query objects - * - * @throws \Exception - */ - public function count(array $queries = [], ?int $max = null): int - { - return $this->adapter->count($queries, $max); - } - - /** - * Ping the adapter to check connectivity. - * - * Returns false on any connectivity failure rather than throwing. - * - * @return bool True when the backing store is reachable, false otherwise. - */ - public function ping(): bool - { - return $this->adapter->ping(); - } -} diff --git a/packages/audit/src/Audit/Log.php b/packages/audit/src/Audit/Log.php deleted file mode 100644 index aa8bfd504..000000000 --- a/packages/audit/src/Audit/Log.php +++ /dev/null @@ -1,350 +0,0 @@ - - */ -class Log extends ArrayObject -{ - /** - * Construct a new audit log object. - * - * @param array $input - */ - public function __construct(array $input = []) - { - parent::__construct($input); - } - - /** - * Get the log ID. - */ - public function getId(): string - { - $id = $this->getAttribute('$id', ''); - return \is_string($id) ? $id : ''; - } - - /** - * Get the user ID associated with this log entry. - */ - public function getUserId(): ?string - { - $userId = $this->getAttribute('userId'); - return \is_string($userId) ? $userId : null; - } - - /** - * Get the actor ID associated with this log entry. - */ - public function getActorId(): ?string - { - $actorId = $this->getAttribute('actorId'); - return \is_string($actorId) ? $actorId : null; - } - - /** - * Get the actor type associated with this log entry. - */ - public function getActorType(): ?string - { - $actorType = $this->getAttribute('actorType'); - return \is_string($actorType) ? $actorType : null; - } - - /** - * Get the actor internal ID associated with this log entry. - */ - public function getActorInternalId(): ?string - { - $actorInternalId = $this->getAttribute('actorInternalId'); - return \is_string($actorInternalId) ? $actorInternalId : null; - } - - /** - * Get the event name. - */ - public function getEvent(): string - { - $event = $this->getAttribute('event', ''); - return \is_string($event) ? $event : ''; - } - - /** - * Get the resource identifier. - */ - public function getResource(): string - { - $resource = $this->getAttribute('resource', ''); - return \is_string($resource) ? $resource : ''; - } - - /** - * Get the SDK name associated with this log entry. - * - * Optional column: returns null when the SDK was never recorded, mirroring - * the other nullable actor columns (getActorId/getActorInternalId). - */ - public function getSdk(): ?string - { - $sdk = $this->getAttribute('sdk'); - return \is_string($sdk) ? $sdk : null; - } - - /** - * Get the SDK version associated with this log entry. - * - * Optional column: returns null when the SDK version was never recorded. - */ - public function getSdkVersion(): ?string - { - $sdkVersion = $this->getAttribute('sdkVersion'); - return \is_string($sdkVersion) ? $sdkVersion : null; - } - - /** - * Get the parsed user-agent OS short code (e.g. `IOS`, `WIN`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getOsCode(): ?string - { - $value = $this->getAttribute('osCode'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent OS name (e.g. `iOS`, `Windows`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getOsName(): ?string - { - $value = $this->getAttribute('osName'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent OS version (e.g. `17.4`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getOsVersion(): ?string - { - $value = $this->getAttribute('osVersion'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent client type (e.g. `browser`, `library`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getClientType(): ?string - { - $value = $this->getAttribute('clientType'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent client short code (e.g. `MF`, `CH`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getClientCode(): ?string - { - $value = $this->getAttribute('clientCode'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent client name (e.g. `Mobile Safari`, `Chrome`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getClientName(): ?string - { - $value = $this->getAttribute('clientName'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent client version (e.g. `17.4`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getClientVersion(): ?string - { - $value = $this->getAttribute('clientVersion'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent client engine (e.g. `WebKit`, `Blink`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getClientEngine(): ?string - { - $value = $this->getAttribute('clientEngine'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent client engine version (e.g. `605.1.15`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getClientEngineVersion(): ?string - { - $value = $this->getAttribute('clientEngineVersion'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent device type (e.g. `smartphone`, `desktop`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getDeviceName(): ?string - { - $value = $this->getAttribute('deviceName'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent device brand (e.g. `Apple`, `Samsung`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getDeviceBrand(): ?string - { - $value = $this->getAttribute('deviceBrand'); - return \is_string($value) ? $value : null; - } - - /** - * Get the parsed user-agent device model (e.g. `iPhone`). - * - * ClickHouse-only optional column: returns null when it was never recorded. - */ - public function getDeviceModel(): ?string - { - $value = $this->getAttribute('deviceModel'); - return \is_string($value) ? $value : null; - } - - /** - * Get the user agent string. - */ - public function getUserAgent(): string - { - $userAgent = $this->getAttribute('userAgent', ''); - return \is_string($userAgent) ? $userAgent : ''; - } - - /** - * Get the IP address. - */ - public function getIp(): string - { - $ip = $this->getAttribute('ip', ''); - return \is_string($ip) ? $ip : ''; - } - - /** - * Get the timestamp. - */ - public function getTime(): string - { - $time = $this->getAttribute('time', ''); - return \is_string($time) ? $time : ''; - } - - /** - * Get the additional data. - * - * @return array - */ - public function getData(): array - { - $data = $this->getAttribute('data', []); - return \is_array($data) ? $data : []; - } - - /** - * Get the tenant ID (for multi-tenant setups). - */ - public function getTenant(): ?int - { - $tenant = $this->getAttribute('tenant'); - - if ($tenant === null) { - return null; - } - - if (\is_int($tenant)) { - return $tenant; - } - - if (is_numeric($tenant)) { - return (int) $tenant; - } - - return null; - } - - /** - * Get an attribute by key. - */ - public function getAttribute(string $key, mixed $default = null): mixed - { - return $this->offsetExists($key) ? $this->offsetGet($key) : $default; - } - - /** - * Set an attribute. - */ - public function setAttribute(string $key, mixed $value): self - { - $this->offsetSet($key, $value); - return $this; - } - - /** - * Remove an attribute. - */ - public function removeAttribute(string $key): self - { - if ($this->offsetExists($key)) { - $this->offsetUnset($key); - } - return $this; - } - - /** - * Check if an attribute exists. - */ - public function isSet(string $key): bool - { - return $this->offsetExists($key); - } - - /** - * Get all attributes as an array. - * - * @return array - */ - public function getArrayCopy(): array - { - return parent::getArrayCopy(); - } -} diff --git a/packages/audit/src/Audit/Query.php b/packages/audit/src/Audit/Query.php deleted file mode 100644 index dfba1825c..000000000 --- a/packages/audit/src/Audit/Query.php +++ /dev/null @@ -1,131 +0,0 @@ - $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 - */ - #[\Override] - public static function equal(string $attribute, mixed $value): static - { - /** @var array $values */ - $values = \is_array($value) ? $value : [$value]; - - return new static(Method::Equal, $attribute, $values); - } - - /** - * Filter by less than condition. - * - * Accepts mixed (including `DateTime` for the `time` column); the - * adapter handles type-specific formatting. - */ - #[\Override] - public static function lessThan(string $attribute, mixed $value): static - { - return new static(Method::LessThan, $attribute, [$value]); - } - - /** - * Filter by greater than condition. - */ - #[\Override] - public static function greaterThan(string $attribute, mixed $value): static - { - return new static(Method::GreaterThan, $attribute, [$value]); - } - - /** - * Filter by BETWEEN condition. - */ - #[\Override] - public static function between(string $attribute, mixed $start, mixed $end): static - { - return new static(Method::Between, $attribute, [$start, $end]); - } -} diff --git a/packages/audit/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php b/packages/audit/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php deleted file mode 100644 index 3a52cf9ef..000000000 --- a/packages/audit/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php +++ /dev/null @@ -1,352 +0,0 @@ - - */ - private function auditTypeMap(): array - { - return [ - 'id' => 'String', - 'actorId' => 'String', - 'actorType' => 'String', - 'actorInternalId' => 'String', - 'event' => 'String', - 'resource' => 'String', - 'userAgent' => 'String', - 'ip' => 'String', - 'time' => 'DateTime64(3)', - 'data' => 'String', - 'tenant' => 'UInt64', - ]; - } - - private function newAuditBuilder(): ClickHouseBuilder - { - return new ClickHouseBuilder() - ->useNamedBindings() - ->withParamTypes($this->auditTypeMap()); - } - - public function testSetupCreateTableSnapshot(): void - { - $schema = new ClickHouseSchema(); - $table = $schema->table('default.audits'); - $table->string('id')->primary(); - $table->string('actorId')->nullable(); - $table->string('actorType')->lowCardinality(); - $table->string('actorInternalId')->nullable(); - $table->string('event')->lowCardinality(); - $table->string('resource')->nullable(); - $table->string('userAgent'); - $table->string('ip'); - // Optional low-cardinality dimension, as emitted for sdk / country / - // the premium-geo and user-agent columns added in audit 2.7–2.9. - $table->addColumn('sdk', ColumnType::String)->lowCardinality()->nullable(); - $table->datetime('time', precision: 3); - $table->addColumn('data', ColumnType::String)->nullable(); - - $table->index( - columns: ['event'], - name: 'idx_event', - algorithm: IndexAlgorithm::BloomFilter, - granularity: 1, - ); - $table->index( - columns: ['actorId', 'event'], - name: 'idx_actorId_event', - algorithm: IndexAlgorithm::BloomFilter, - granularity: 1, - ); - $table->index( - columns: ['actorType'], - name: '_key_actor_type', - algorithm: IndexAlgorithm::BloomFilter, - granularity: 1, - ); - - $table->engine(ClickHouseEngine::MergeTree); - $table->orderBy(['time', 'id']); - $table->partitionBy('toYYYYMM(time)'); - $table->settings(['index_granularity' => '8192']); - - $sql = $table->createIfNotExists()->query; - - $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS `default`.`audits`', $sql); - $this->assertStringContainsString('`id` String', $sql); - $this->assertStringContainsString('`actorId` Nullable(String)', $sql); - $this->assertStringContainsString('`actorType` LowCardinality(String)', $sql); - $this->assertStringContainsString('`actorInternalId` Nullable(String)', $sql); - $this->assertStringContainsString('`event` LowCardinality(String)', $sql); - $this->assertStringContainsString('`sdk` LowCardinality(Nullable(String))', $sql); - $this->assertStringContainsString('`time` DateTime64(3)', $sql); - $this->assertStringNotContainsString('`location`', $sql); - $this->assertStringNotContainsString('`userId`', $sql); - $this->assertStringContainsString('INDEX `idx_event` `event` TYPE bloom_filter GRANULARITY 1', $sql); - $this->assertStringContainsString('INDEX `idx_actorId_event` (`actorId`, `event`) TYPE bloom_filter GRANULARITY 1', $sql); - $this->assertStringContainsString('INDEX `_key_actor_type` `actorType` TYPE bloom_filter GRANULARITY 1', $sql); - $this->assertStringContainsString('ENGINE = MergeTree()', $sql); - $this->assertStringContainsString('PARTITION BY toYYYYMM(time)', $sql); - $this->assertStringContainsString('ORDER BY (`time`, `id`)', $sql); - $this->assertStringContainsString('SETTINGS index_granularity = 8192', $sql); - } - - public function testBulkInsertJsonEachRowSnapshot(): void - { - $columns = ['id', 'time', 'actorId', 'actorType', 'event', 'data']; - $rows = [ - [ - 'id' => 'log-1', - 'time' => '2025-01-02 03:04:05.678', - 'actorId' => 'u1', - 'actorType' => 'users', - 'event' => 'users.create', - 'data' => '{"foo":"bar"}', - ], - [ - 'id' => 'log-2', - 'time' => '2025-01-02 03:04:06.000', - 'actorId' => 'u2', - 'actorType' => 'users', - 'event' => 'users.delete', - 'data' => '{"foo":"baz"}', - ], - ]; - - $statement = new ClickHouseBuilder() - ->into('default.audits') - ->bulkInsert(Format::JSONEachRow, $rows, $columns); - - $this->assertSame( - 'INSERT INTO `default`.`audits` (`id`, `time`, `actorId`, `actorType`, `event`, `data`) FORMAT JSONEachRow', - $statement->query, - ); - $this->assertSame('JSONEachRow', $statement->format); - $this->assertSame($columns, $statement->columns); - $this->assertSame( - '{"id":"log-1","time":"2025-01-02 03:04:05.678","actorId":"u1","actorType":"users","event":"users.create","data":"{\"foo\":\"bar\"}"}' . "\n" - . '{"id":"log-2","time":"2025-01-02 03:04:06.000","actorId":"u2","actorType":"users","event":"users.delete","data":"{\"foo\":\"baz\"}"}', - $statement->body, - ); - } - - public function testAsyncCleanupDeleteEmitsSettingsClause(): void - { - $sql = new ClickHouseBuilder() - ->into('default.audits') - ->whereRaw('`time` < {datetime:DateTime64(3)}') - ->settings(['lightweight_deletes_sync' => '0']) - ->delete() - ->query; - - $this->assertSame( - 'DELETE FROM `default`.`audits` WHERE `time` < {datetime:DateTime64(3)} SETTINGS lightweight_deletes_sync=0', - $sql, - ); - } - - public function testSyncCleanupDeleteOmitsSettingsClause(): void - { - $sql = new ClickHouseBuilder() - ->into('default.audits') - ->whereRaw('`time` < {datetime:DateTime64(3)}') - ->delete() - ->query; - - $this->assertSame( - 'DELETE FROM `default`.`audits` WHERE `time` < {datetime:DateTime64(3)}', - $sql, - ); - } - - public function testFindEmitsTypedNamedBindings(): void - { - $statement = $this->newAuditBuilder() - ->from('default.audits') - ->selectRaw('`id`, `event`, `time`') - ->filter([ - Query::equal('actorId', ['u1']), - Query::between('time', '2025-01-01 00:00:00.000', '2025-12-31 00:00:00.000'), - ]) - ->sortDesc('time') - ->limit(25) - ->build(); - - $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' - . 'WHERE `actorId` IN ({param0:String}) ' - . 'AND `time` BETWEEN {param1:DateTime64(3)} AND {param2:DateTime64(3)} ' - . 'ORDER BY `time` DESC ' - . 'LIMIT {param3:Int64}'; - - $this->assertSame($expectedSql, $statement->query); - $this->assertSame( - [ - 'param0' => 'u1', - 'param1' => '2025-01-01 00:00:00.000', - 'param2' => '2025-12-31 00:00:00.000', - 'param3' => 25, - ], - $statement->namedBindings, - ); - } - - public function testEqualMultiValueEmitsTypedIn(): void - { - $statement = $this->newAuditBuilder() - ->from('default.audits') - ->selectRaw('`id`, `event`, `time`') - ->filter([ - Query::notEqual('event', ['users.delete', 'projects.delete']), - ]) - ->limit(25) - ->build(); - - $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' - . 'WHERE `event` NOT IN ({param0:String}, {param1:String}) ' - . 'LIMIT {param2:Int64}'; - - $this->assertSame($expectedSql, $statement->query); - $this->assertSame( - [ - 'param0' => 'users.delete', - 'param1' => 'projects.delete', - 'param2' => 25, - ], - $statement->namedBindings, - ); - } - - /** - * `contains` is a substring match on ClickHouse — the builder emits - * `position(col, ?) > 0`, which replaces the adapter's previous - * hand-written `LIKE '%needle%'` (and needs no wildcard escaping). - */ - public function testContainsEmitsPositionPredicate(): void - { - $statement = $this->newAuditBuilder() - ->from('default.audits') - ->selectRaw('`id`, `event`, `time`') - ->filter([Query::containsString('event', ['dat'])]) - ->build(); - - $this->assertSame( - 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' - . 'WHERE position(`event`, {param0:String}) > 0', - $statement->query, - ); - $this->assertSame(['param0' => 'dat'], $statement->namedBindings); - } - - public function testContainsMultiValueOrsPositionPredicates(): void - { - $statement = $this->newAuditBuilder() - ->from('default.audits') - ->selectRaw('`id`') - ->filter([Query::containsString('event', ['dat', 'ins'])]) - ->build(); - - $this->assertSame( - 'SELECT `id` FROM `default`.`audits` ' - . 'WHERE (position(`event`, {param0:String}) > 0 OR position(`event`, {param1:String}) > 0)', - $statement->query, - ); - $this->assertSame(['param0' => 'dat', 'param1' => 'ins'], $statement->namedBindings); - } - - public function testNotContainsMultiValueAndsNegatedPositionPredicates(): void - { - $statement = $this->newAuditBuilder() - ->from('default.audits') - ->selectRaw('`id`') - ->filter([Query::notContains('event', ['update', 'delete'])]) - ->build(); - - $this->assertSame( - 'SELECT `id` FROM `default`.`audits` ' - . 'WHERE (position(`event`, {param0:String}) = 0 AND position(`event`, {param1:String}) = 0)', - $statement->query, - ); - $this->assertSame(['param0' => 'update', 'param1' => 'delete'], $statement->namedBindings); - } - - public function testFindCursorRawFragmentMergesWithTypedBindings(): void - { - $cursorClause = '((`time` < {cursor_cmp_0:DateTime64(3)}) ' - . 'OR (`time` = {cursor_eq_1_0:DateTime64(3)} AND `id` < {cursor_cmp_1:String}))'; - - $statement = $this->newAuditBuilder() - ->from('default.audits') - ->selectRaw('`id`, `event`, `time`') - ->filter([Query::equal('actorId', ['u1'])]) - ->whereRaw($cursorClause) - ->sortDesc('time') - ->sortDesc('id') - ->limit(25) - ->build(); - - $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' - . 'WHERE `actorId` IN ({param0:String}) ' - . 'AND ' . $cursorClause . ' ' - . 'ORDER BY `time` DESC, `id` DESC ' - . 'LIMIT {param1:Int64}'; - - $this->assertSame($expectedSql, $statement->query); - $this->assertSame( - [ - 'param0' => 'u1', - 'param1' => 25, - ], - $statement->namedBindings, - ); - } - - public function testCountWithMaxWrapsInnerSelect(): void - { - $inner = $this->newAuditBuilder() - ->from('default.audits') - ->selectRaw('1') - ->filter([Query::equal('actorId', ['u1'])]) - ->limit(5000) - ->build(); - - $sql = 'SELECT COUNT(*) AS count FROM (' . $inner->query . ') sub FORMAT TabSeparated'; - - $this->assertSame( - 'SELECT COUNT(*) AS count FROM (' - . 'SELECT 1 FROM `default`.`audits` WHERE `actorId` IN ({param0:String}) LIMIT {param1:Int64}' - . ') sub FORMAT TabSeparated', - $sql, - ); - $this->assertSame( - ['param0' => 'u1', 'param1' => 5000], - $inner->namedBindings, - ); - } -} diff --git a/packages/audit/tests/Audit/Adapter/ClickHouseTest.php b/packages/audit/tests/Audit/Adapter/ClickHouseTest.php deleted file mode 100644 index cc0b06192..000000000 --- a/packages/audit/tests/Audit/Adapter/ClickHouseTest.php +++ /dev/null @@ -1,1364 +0,0 @@ -setDatabase($database); - } else { - $clickHouse->setDatabase('default'); - } - - $this->audit = new Audit($clickHouse); - $this->audit->setup(); - } - - /** - * Provide required attributes for ClickHouse adapter tests. - * - * @return array - */ - protected function getRequiredAttributes(): array - { - return [ - 'actorType' => 'member', - 'resourceType' => 'document', - 'resourceId' => 'res-1', - 'projectId' => 'proj-1', - 'projectInternalId' => 'proj-int-1', - 'teamId' => 'team-1', - 'teamInternalId' => 'team-int-1', - 'hostname' => 'example.org', - 'country' => 'us', - ]; - } - - /** - * Test constructor validates host - */ - public function testConstructorValidatesHost(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('ClickHouse host is not a valid hostname or IP address'); - - new ClickHouse( - host: '', - username: 'default', - password: '', - ); - } - - /** - * Test constructor validates port range - */ - public function testConstructorValidatesPortTooLow(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535'); - - new ClickHouse( - host: 'localhost', - username: 'default', - password: '', - port: 0, - ); - } - - /** - * Test constructor validates port range upper bound - */ - public function testConstructorValidatesPortTooHigh(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535'); - - new ClickHouse( - host: 'localhost', - username: 'default', - password: '', - port: 65536, - ); - } - - /** - * Test constructor with valid parameters - */ - public function testConstructorWithValidParameters(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'testuser', - password: 'testpass', - port: 8443, - secure: true, - ); - - $this->assertInstanceOf(ClickHouse::class, $adapter); - $this->assertSame('ClickHouse', $adapter->getName()); - } - - /** - * Test getName returns correct adapter name - */ - public function testGetName(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $this->assertSame('ClickHouse', $adapter->getName()); - } - - /** - * Test setDatabase validates empty identifier - */ - public function testSetDatabaseValidatesEmpty(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Database cannot be empty'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setDatabase(''); - } - - /** - * Test setDatabase validates identifier length - */ - public function testSetDatabaseValidatesLength(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Database cannot exceed 255 characters'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setDatabase(str_repeat('a', 256)); - } - - /** - * Test setDatabase validates identifier format - */ - public function testSetDatabaseValidatesFormat(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Database must start with a letter or underscore'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setDatabase('123invalid'); - } - - /** - * Test setDatabase rejects SQL keywords - */ - public function testSetDatabaseRejectsKeywords(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Database cannot be a reserved SQL keyword'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setDatabase('SELECT'); - } - - /** - * Test setDatabase with valid identifier - */ - public function testSetDatabaseWithValidIdentifier(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $result = $adapter->setDatabase('my_database_123'); - $this->assertInstanceOf(ClickHouse::class, $result); - } - - /** - * Test setTable validates empty identifier - */ - public function testSetTableValidatesEmpty(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Table cannot be empty'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setTable(''); - } - - /** - * Test setTable validates identifier length - */ - public function testSetTableValidatesLength(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Table cannot exceed 255 characters'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setTable(str_repeat('a', 256)); - } - - /** - * Test setTable validates identifier format - */ - public function testSetTableValidatesFormat(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Table must start with a letter or underscore'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setTable('123invalid'); - } - - /** - * Test setTable rejects SQL keywords - */ - public function testSetTableRejectsKeywords(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Table cannot be a reserved SQL keyword'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setTable('SELECT'); - } - - /** - * Test setTable with valid identifier - */ - public function testSetTableWithValidIdentifier(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $result = $adapter->setTable('my_audit_logs'); - $this->assertInstanceOf(ClickHouse::class, $result); - $this->assertSame('my_audit_logs', $adapter->getTable()); - } - - /** - * Test setNamespace allows empty string - */ - public function testSetNamespaceAllowsEmpty(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $result = $adapter->setNamespace(''); - $this->assertInstanceOf(ClickHouse::class, $result); - $this->assertSame('', $adapter->getNamespace()); - } - - /** - * Test setNamespace validates identifier format - */ - public function testSetNamespaceValidatesFormat(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Namespace must start with a letter or underscore'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setNamespace('9invalid'); - } - - /** - * Test setNamespace with valid identifier - */ - public function testSetNamespaceWithValidIdentifier(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $result = $adapter->setNamespace('project_123'); - $this->assertInstanceOf(ClickHouse::class, $result); - $this->assertSame('project_123', $adapter->getNamespace()); - } - - /** - * Test setSecure method - */ - public function testSetSecure(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - port: 8123, - secure: false, - ); - - $result = $adapter->setSecure(true); - $this->assertInstanceOf(ClickHouse::class, $result); - } - - /** - * Test setRetention stores the value and getRetention returns it - */ - public function testSetRetention(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $this->assertNull($adapter->getRetention()); - - $result = $adapter->setRetention(30); - $this->assertInstanceOf(ClickHouse::class, $result); - $this->assertEquals(30, $adapter->getRetention()); - } - - /** - * Test setRetention accepts null to disable retention - */ - public function testSetRetentionAcceptsNull(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setRetention(30); - $adapter->setRetention(null); - $this->assertNull($adapter->getRetention()); - } - - /** - * Test setRetention rejects zero days - */ - public function testSetRetentionRejectsZero(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Retention must be a positive number of days'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setRetention(0); - } - - /** - * Test setRetention rejects negative days - */ - public function testSetRetentionRejectsNegative(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage('Retention must be a positive number of days'); - - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $adapter->setRetention(-1); - } - - /** - * Test shared tables configuration - */ - public function testSharedTablesConfiguration(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - // Test initial state - $this->assertFalse($adapter->isSharedTables()); - $this->assertNull($adapter->getTenant()); - - // Test setting shared tables - $result = $adapter->setSharedTables(true); - $this->assertInstanceOf(ClickHouse::class, $result); - $this->assertTrue($adapter->isSharedTables()); - - // Test setting tenant - $result2 = $adapter->setTenant(12345); - $this->assertInstanceOf(ClickHouse::class, $result2); - $this->assertEquals(12345, $adapter->getTenant()); - - // Test setting tenant to null - $adapter->setTenant(null); - $this->assertNull($adapter->getTenant()); - } - - /** - * Test batch operations with special characters - */ - public function testBatchOperationsWithSpecialCharacters(): void - { - // Test batch with special characters in data - $batchEvents = [ - [ - 'actorId' => 'actor`with`backticks', - 'event' => 'create', - 'resource' => 'doc/"quotes"', - 'userAgent' => "User'Agent\"With'Quotes", - 'ip' => '192.168.1.1', - 'data' => ['special' => "data with 'quotes'"], - 'time' => \Utopia\Database\DateTime::formatTz(\Utopia\Database\DateTime::now()) ?? '', - ], - ]; - - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - $result = $this->audit->logBatch($batchEvents); - $this->assertTrue($result); - - // Verify retrieval - $logs = $this->audit->getLogsByUser('actor`with`backticks'); - $this->assertGreaterThan(0, \count($logs)); - } - - /** - * Test that ClickHouse adapter has all required attributes - */ - public function testClickHouseAdapterAttributes(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $attributes = $adapter->getAttributes(); - $attributeIds = array_map(fn(array $attr): mixed => $attr['$id'], $attributes); - - // Verify all expected attributes exist - $expectedAttributes = [ - 'actorType', - 'actorId', - 'actorInternalId', - 'resourceParent', - 'resourceType', - 'resourceId', - 'resourceInternalId', - 'event', - 'resource', - 'userAgent', - 'ip', - 'country', - 'time', - 'data', - 'projectId', - 'projectInternalId', - 'teamId', - 'teamInternalId', - 'hostname', - // premium geo - 'city', - 'continentCode', - 'subdivisions', - 'isp', - 'autonomousSystemNumber', - 'autonomousSystemOrganization', - 'connectionType', - 'connectionUsageType', - 'connectionOrganization', - // sdk - 'sdk', - 'sdkVersion', - // user-agent — parsed OS / client / device - 'osCode', - 'osName', - 'osVersion', - 'clientType', - 'clientCode', - 'clientName', - 'clientVersion', - 'clientEngine', - 'clientEngineVersion', - 'deviceName', - 'deviceBrand', - 'deviceModel', - ]; - - foreach ($expectedAttributes as $expected) { - $this->assertContains($expected, $attributeIds, "Attribute '{$expected}' not found in ClickHouse adapter"); - } - } - - /** - * Test that user-agent columns get the correct ClickHouse type: bounded - * name/code/type dimensions use LowCardinality(Nullable(String)), while - * high-cardinality version/model strings stay plain Nullable(String). - */ - public function testUserAgentColumnTypes(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $method = new \ReflectionMethod($adapter, 'getColumnDefinition'); - - $lowCardinality = [ - 'osCode', - 'osName', - 'clientType', - 'clientCode', - 'clientName', - 'clientEngine', - 'deviceName', - 'deviceBrand', - ]; - foreach ($lowCardinality as $column) { - $definition = $method->invoke($adapter, $column); - $this->assertEquals("{$column} LowCardinality(Nullable(String))", $definition); - } - - $highCardinality = [ - 'osVersion', - 'clientVersion', - 'clientEngineVersion', - 'deviceModel', - ]; - foreach ($highCardinality as $column) { - $definition = $method->invoke($adapter, $column); - $this->assertEquals("{$column} Nullable(String)", $definition); - } - } - - /** - * Test that premium geo attributes are all optional String columns. - */ - public function testPremiumGeoAttributesAreOptionalStrings(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $attributes = $adapter->getAttributes(); - $byId = []; - foreach ($attributes as $attribute) { - $byId[$attribute['$id']] = $attribute; - } - - $geoColumns = [ - 'city', - 'continentCode', - 'subdivisions', - 'isp', - 'autonomousSystemNumber', - 'autonomousSystemOrganization', - 'connectionType', - 'connectionUsageType', - 'connectionOrganization', - ]; - - 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"); - } - } - - /** - * Test that premium geo columns get the correct ClickHouse type: - * low-cardinality dimensions use LowCardinality(Nullable(String)), while - * high-cardinality ones stay plain Nullable(String). - */ - public function testPremiumGeoColumnTypes(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $method = new \ReflectionMethod($adapter, 'getColumnDefinition'); - - $lowCardinality = [ - 'continentCode', - 'subdivisions', - 'connectionType', - 'connectionUsageType', - ]; - foreach ($lowCardinality as $column) { - $definition = $method->invoke($adapter, $column); - $this->assertEquals("{$column} LowCardinality(Nullable(String))", $definition); - } - - // autonomousSystemNumber is high-cardinality (~100k ASNs) so it is a - // plain Nullable(String), not LowCardinality. - $highCardinality = [ - 'city', - 'isp', - 'autonomousSystemNumber', - 'autonomousSystemOrganization', - 'connectionOrganization', - ]; - foreach ($highCardinality as $column) { - $definition = $method->invoke($adapter, $column); - $this->assertEquals("{$column} Nullable(String)", $definition); - } - } - - /** - * Premium geo values must round-trip through a real write/read cycle: - * write a log carrying all 9 geo fields and read it back unchanged. This - * proves the columns are actually created, written and selected (not just - * present in the schema definition). - */ - public function testPremiumGeoRoundTrip(): void - { - $actorId = 'geo-actor-' . uniqid('', true); - $geo = [ - 'city' => 'Mountain View', - 'continentCode' => 'NA', - 'subdivisions' => 'California', - 'isp' => 'Google', - 'autonomousSystemNumber' => '15169', - 'autonomousSystemOrganization' => 'GOOGLE', - 'connectionType' => 'cable', - 'connectionUsageType' => 'residential', - 'connectionOrganization' => 'Google LLC', - ]; - - $batchEvents = [array_merge([ - 'actorId' => $actorId, - 'event' => 'geo.roundtrip', - 'resource' => 'document/geo-1', - 'userAgent' => 'RoundTrip/1.0', - 'ip' => '8.8.8.8', - 'data' => [], - 'time' => \Utopia\Database\DateTime::formatTz(\Utopia\Database\DateTime::now()) ?? '', - ], $geo)]; - - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - $this->assertTrue($this->audit->logBatch($batchEvents)); - - $logs = $this->audit->getLogsByUser($actorId); - $this->assertGreaterThan(0, \count($logs), 'geo round-trip log was not persisted'); - - $log = $logs[0]; - foreach ($geo as $key => $expected) { - $this->assertSame($expected, $log->getAttribute($key), "premium geo '{$key}' did not round-trip"); - } - } - - /** - * 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 - { - $actorId = 'ua-actor-' . uniqid('', true); - $ua = [ - 'osCode' => 'IOS', - 'osName' => 'iOS', - 'osVersion' => '17.4', - 'clientType' => 'browser', - 'clientCode' => 'MF', - 'clientName' => 'Mobile Safari', - 'clientVersion' => '17.4', - 'clientEngine' => 'WebKit', - 'clientEngineVersion' => '605.1.15', - 'deviceName' => 'smartphone', - 'deviceBrand' => 'Apple', - 'deviceModel' => 'iPhone', - ]; - - $batchEvents = [array_merge([ - 'actorId' => $actorId, - 'event' => 'ua.roundtrip', - 'resource' => 'document/ua-1', - 'userAgent' => 'RoundTrip/1.0', - 'ip' => '8.8.8.8', - 'data' => [], - 'time' => \Utopia\Database\DateTime::formatTz(\Utopia\Database\DateTime::now()) ?? '', - ], $ua)]; - - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - $this->assertTrue($this->audit->logBatch($batchEvents)); - - $logs = $this->audit->getLogsByUser($actorId); - $this->assertGreaterThan(0, \count($logs), 'user-agent round-trip log was not persisted'); - - $log = $logs[0]; - foreach ($ua as $key => $expected) { - $this->assertSame($expected, $log->getAttribute($key), "user-agent '{$key}' did not round-trip"); - } - - // Exercise the typed getters so a wrong attribute key in any of them is - // caught (getAttribute() above can't detect that). - $this->assertSame($ua['osCode'], $log->getOsCode()); - $this->assertSame($ua['osName'], $log->getOsName()); - $this->assertSame($ua['osVersion'], $log->getOsVersion()); - $this->assertSame($ua['clientType'], $log->getClientType()); - $this->assertSame($ua['clientCode'], $log->getClientCode()); - $this->assertSame($ua['clientName'], $log->getClientName()); - $this->assertSame($ua['clientVersion'], $log->getClientVersion()); - $this->assertSame($ua['clientEngine'], $log->getClientEngine()); - $this->assertSame($ua['clientEngineVersion'], $log->getClientEngineVersion()); - $this->assertSame($ua['deviceName'], $log->getDeviceName()); - $this->assertSame($ua['deviceBrand'], $log->getDeviceBrand()); - $this->assertSame($ua['deviceModel'], $log->getDeviceModel()); - } - - /** - * Test that ClickHouse adapter has all required indexes - */ - public function testClickHouseAdapterIndexes(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $indexes = $adapter->getIndexes(); - $indexIds = array_map(fn(array $idx): mixed => $idx['$id'], $indexes); - - // Verify all ClickHouse-specific indexes exist - $expectedClickHouseIndexes = [ - '_key_actor_internal_and_event', - '_key_project_internal_id', - '_key_team_internal_id', - '_key_actor_internal_id', - '_key_actor_type', - '_key_country', - '_key_hostname', - '_key_sdk', - ]; - - foreach ($expectedClickHouseIndexes as $expected) { - $this->assertContains($expected, $indexIds, "ClickHouse index '{$expected}' not found in ClickHouse adapter"); - } - - // Verify parent indexes are also included (with parent naming convention) - $parentExpectedIndexes = ['idx_event', 'idx_actorId_event', 'idx_resource_event', 'idx_time_desc']; - foreach ($parentExpectedIndexes as $expected) { - $this->assertContains($expected, $indexIds, "Parent index '{$expected}' not found in ClickHouse adapter"); - } - } - - /** - * Test parsing of complex resource paths into resourceType/resourceId/resourceParent - */ - public function testParseResourceComplexPath(): void - { - $actorId = 'parseActor'; - $userAgent = 'UnitTestAgent/1.0'; - $ip = '127.0.0.1'; - - $resource = 'database/6978484940ff05762e1a/table/697848498066e3d2ef64'; - - // Ensure we don't provide resourceType/resourceId in data so adapter must parse it - $data = ['example' => 'value']; - - // Merge required adapter attributes so ClickHouse won't reject the log, - // but ensure we do NOT supply resourceType/resourceId/resourceParent so adapter parses them - $required = $this->getRequiredAttributes(); - unset($required['resourceType'], $required['resourceId'], $required['resourceParent']); - $dataWithAttributes = array_merge($data, $required); - - $log = $this->audit->log($actorId, 'create', $resource, $userAgent, $ip, $dataWithAttributes); - - $this->assertInstanceOf(\Utopia\Audit\Log::class, $log); - - $this->assertEquals('table', $log->getAttribute('resourceType')); - $this->assertEquals('697848498066e3d2ef64', $log->getAttribute('resourceId')); - $this->assertEquals('database/6978484940ff05762e1a', $log->getAttribute('resourceParent')); - } - - /** - * Directly test the protected parseResource method via reflection. - */ - public function testParseResourceMethod(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $method = new \ReflectionMethod($adapter, 'parseResource'); - - $resource = 'database/6978484940ff05762e1a/table/697848498066e3d2ef64'; - $parsed = $method->invoke($adapter, $resource); - - $this->assertIsArray($parsed); - $this->assertArrayHasKey('resourceId', $parsed); - $this->assertArrayHasKey('resourceType', $parsed); - $this->assertArrayHasKey('resourceParent', $parsed); - - $this->assertEquals('697848498066e3d2ef64', $parsed['resourceId']); - $this->assertEquals('table', $parsed['resourceType']); - $this->assertEquals('database/6978484940ff05762e1a', $parsed['resourceParent']); - - $sixPart = 'database/693586330029ae2f0d3f/collection/watch_history/document/6a06d9a7001c3cd05d20'; - /** @var array{resourceId: string, resourceType: string, resourceParent: string} $parsedSix */ - $parsedSix = $method->invoke($adapter, $sixPart); - - $this->assertEquals('6a06d9a7001c3cd05d20', $parsedSix['resourceId']); - $this->assertEquals('document', $parsedSix['resourceType']); - $this->assertEquals('database/693586330029ae2f0d3f/collection/watch_history', $parsedSix['resourceParent']); - - $twoPart = 'user/abc123'; - /** @var array{resourceId: string, resourceType: string, resourceParent: string} $parsedTwo */ - $parsedTwo = $method->invoke($adapter, $twoPart); - - $this->assertEquals('abc123', $parsedTwo['resourceId']); - $this->assertEquals('user', $parsedTwo['resourceType']); - $this->assertEquals('', $parsedTwo['resourceParent']); - - $oddPart = 'foo/bar/baz'; - /** @var array{resourceId: string, resourceType: string, resourceParent: string} $parsedOdd */ - $parsedOdd = $method->invoke($adapter, $oddPart); - - $this->assertEquals('foo/bar/baz', $parsedOdd['resourceId']); - $this->assertEquals('', $parsedOdd['resourceType']); - $this->assertEquals('', $parsedOdd['resourceParent']); - } - - public function testCursorAfterPaginatesLogs(): void - { - $page1 = $this->audit->find([ - Query::orderAsc('id'), - Query::limit(2), - ]); - - $this->assertCount(2, $page1); - - $page2 = $this->audit->find([ - Query::orderAsc('id'), - Query::limit(2), - Query::cursorAfter($page1[\count($page1) - 1]), - ]); - - $this->assertGreaterThanOrEqual(1, \count($page2)); - foreach ($page2 as $log) { - $this->assertNotEquals($page1[0]->getId(), $log->getId()); - $this->assertNotEquals($page1[1]->getId(), $log->getId()); - } - } - - public function testCursorBeforeReversesPagination(): void - { - $all = $this->audit->find([ - Query::orderAsc('id'), - Query::limit(50), - ]); - - $this->assertGreaterThanOrEqual(3, \count($all)); - - $before = $this->audit->find([ - Query::orderAsc('id'), - Query::limit(2), - Query::cursorBefore($all[\count($all) - 1]), - ]); - - $this->assertCount(2, $before); - $this->assertEquals($all[\count($all) - 3]->getId(), $before[0]->getId()); - $this->assertEquals($all[\count($all) - 2]->getId(), $before[1]->getId()); - } - - public function testCursorAcceptsAssociativeArray(): void - { - $all = $this->audit->find([ - Query::orderAsc('id'), - Query::limit(50), - ]); - - $this->assertGreaterThanOrEqual(2, \count($all)); - - $page = $this->audit->find([ - Query::orderAsc('id'), - Query::limit(50), - Query::cursorAfter(['id' => $all[0]->getId()]), - ]); - - $this->assertCount(\count($all) - 1, $page); - $this->assertEquals($all[1]->getId(), $page[0]->getId()); - } - - public function testCountWithMaxBound(): void - { - $unbounded = $this->audit->count(); - $this->assertGreaterThanOrEqual(4, $unbounded); - - $bounded = $this->audit->count([], max: 2); - $this->assertSame(2, $bounded); - - $boundedAboveTotal = $this->audit->count([], max: 10_000); - $this->assertSame($unbounded, $boundedAboveTotal); - } - - public function testCountByUserWithMaxBound(): void - { - $unbounded = $this->audit->countLogsByUser('userId'); - $this->assertSame(3, $unbounded); - - $bounded = $this->audit->countLogsByUser('userId', max: 1); - $this->assertSame(1, $bounded); - } - - public function testNotEqualQuery(): void - { - // Fixture: 3x event=update/delete for actor, plus 1x event=insert for null actor - $logs = $this->audit->find([ - Query::notEqual('event', 'update'), - ]); - // 1 delete + 1 insert = 2 - $this->assertCount(2, $logs); - foreach ($logs as $log) { - $this->assertNotEquals('update', $log->getEvent()); - } - } - - public function testContainsSubstringQuery(): void - { - // Contains is a substring match (like utopia-php/database), not an - // exact IN match — 'dat' matches only the 'update' logs - $logs = $this->audit->find([ - Query::containsString('event', ['dat']), - ]); - $this->assertCount(2, $logs); - foreach ($logs as $log) { - $this->assertEquals('update', $log->getEvent()); - } - - // Multiple needles OR together: 'dat' (update) + 'ins' (insert) - $logs = $this->audit->find([ - Query::containsString('event', ['dat', 'ins']), - ]); - $this->assertCount(3, $logs); - } - - public function testContainsEscapesLikeWildcards(): void - { - // '%' and '_' in needles are literals, not LIKE wildcards — - // no fixture event contains a literal '%' - $logs = $this->audit->find([ - Query::containsString('event', ['%']), - ]); - $this->assertCount(0, $logs); - - $logs = $this->audit->find([ - Query::containsString('event', ['u_date']), - ]); - $this->assertCount(0, $logs); - } - - public function testNotContainsQuery(): void - { - $logs = $this->audit->find([ - Query::notContains('event', ['update', 'delete']), - ]); - // Only the insert log - $this->assertCount(1, $logs); - $this->assertEquals('insert', $logs[0]->getEvent()); - - // Negated substring: excludes anything containing 'dat' (update), - // keeps delete + insert - $logs = $this->audit->find([ - Query::notContains('event', ['dat']), - ]); - $this->assertCount(2, $logs); - foreach ($logs as $log) { - $this->assertStringNotContainsString('dat', $log->getEvent()); - } - } - - public function testLesserEqualAndGreaterEqualQueries(): void - { - $now = new \DateTime()->modify('+1 minute'); - $past = new \DateTime()->modify('-1 hour'); - - $allLe = $this->audit->find([ - Query::lessThanEqual('time', \Utopia\Database\DateTime::format($now)), - ]); - $this->assertGreaterThanOrEqual(4, \count($allLe)); - - $noneLe = $this->audit->find([ - Query::lessThanEqual('time', \Utopia\Database\DateTime::format($past)), - ]); - $this->assertCount(0, $noneLe); - - $allGe = $this->audit->find([ - Query::greaterThanEqual('time', \Utopia\Database\DateTime::format($past)), - ]); - $this->assertGreaterThanOrEqual(4, \count($allGe)); - } - - public function testNotBetweenQuery(): void - { - $past = new \DateTime()->modify('-2 hour'); - $oldPast = new \DateTime()->modify('-3 hour'); - - $logs = $this->audit->find([ - Query::notBetween( - 'time', - \Utopia\Database\DateTime::format($oldPast), - \Utopia\Database\DateTime::format($past), - ), - ]); - // All 4 fixture logs are outside the past window - $this->assertGreaterThanOrEqual(4, \count($logs)); - } - - public function testIsNullAndIsNotNullQueries(): void - { - $nullActor = $this->audit->find([ - Query::isNull('actorId'), - ]); - // Only the insert log has null actorId - $this->assertCount(1, $nullActor); - $this->assertEquals('insert', $nullActor[0]->getEvent()); - - $notNullActor = $this->audit->find([ - Query::isNotNull('actorId'), - ]); - $this->assertCount(3, $notNullActor); - } - - public function testStartsWithAndEndsWithQueries(): void - { - $resourcePrefix = $this->audit->find([ - Query::startsWith('resource', 'database/'), - ]); - // 3 logs are on database/document/* - $this->assertCount(3, $resourcePrefix); - foreach ($resourcePrefix as $log) { - $this->assertStringStartsWith('database/', $log->getResource()); - } - - $endsWithNull = $this->audit->find([ - Query::endsWith('resource', '/null'), - ]); - // 'user/null' is the only match - $this->assertCount(1, $endsWithNull); - $this->assertEquals('user/null', $endsWithNull[0]->getResource()); - } - - public function testContainsRejectsEmptyValues(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Contains queries require at least one value.'); - - $this->audit->find([ - Query::containsString('event', []), - ]); - } - - public function testNotContainsRejectsEmptyValues(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('NotContains queries require at least one value.'); - - $this->audit->find([ - Query::notContains('event', []), - ]); - } - - public function testEqualRejectsEmptyValues(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Equal queries require at least one value.'); - - $this->audit->find([ - new Query(Query::TYPE_EQUAL, 'event', []), - ]); - } - - public function testSelectProjectsRequestedColumns(): void - { - $logs = $this->audit->find([ - Query::select(['event', 'resource']), - Query::equal('actorId', 'userId'), - Query::limit(1), - ]); - - $this->assertGreaterThanOrEqual(1, \count($logs)); - - $row = $logs[0]->getArrayCopy(); - // `id` is always projected so the Log model still has its identifier - $this->assertArrayHasKey('$id', $row); - // Requested columns present - $this->assertArrayHasKey('event', $row); - $this->assertArrayHasKey('resource', $row); - // Unrequested columns are absent - $this->assertArrayNotHasKey('userAgent', $row); - $this->assertArrayNotHasKey('ip', $row); - $this->assertArrayNotHasKey('data', $row); - } - - public function testSelectAutoIncludesTenantWhenShared(): void - { - $host = getenv('CLICKHOUSE_HOST') ?: 'localhost'; - $port = (int) (getenv('CLICKHOUSE_PORT') ?: 18123); - - $adapter = new ClickHouse( - host: $host, - username: 'default', - password: 'clickhouse', - port: $port, - ); - $adapter->setNamespace('select_tenant_test'); - $adapter->setSharedTables(true); - $adapter->setTenant(7); - $adapter->setup(); - - $audit = new Audit($adapter); - $audit->log('u1', 'create', 'doc/1', 'agent', '127.0.0.1', $this->getRequiredAttributes()); - - $logs = $audit->find([ - Query::select(['event']), - Query::limit(1), - ]); - - $this->assertCount(1, $logs); - $row = $logs[0]->getArrayCopy(); - $this->assertArrayHasKey('$id', $row); - $this->assertArrayHasKey('event', $row); - // tenant is always projected when sharedTables is on, even if the - // caller didn't list it - $this->assertArrayHasKey('tenant', $row); - } - - public function testSelectRejectsUnknownColumn(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid attribute name: bogus_column'); - - $this->audit->find([ - Query::select(['bogus_column']), - ]); - } - - public function testSelectRejectsEmptyValues(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Select queries require at least one value.'); - - $this->audit->find([ - Query::select([]), - ]); - } - - public function testNotStartsWithFilter(): void - { - $logs = $this->audit->find([ - Query::notStartsWith('resource', 'database/'), - ]); - // From fixture: only 'user/null' doesn't start with 'database/' - $this->assertCount(1, $logs); - $this->assertEquals('user/null', $logs[0]->getResource()); - } - - public function testNotEndsWithFilter(): void - { - $logs = $this->audit->find([ - Query::notEndsWith('resource', '/null'), - ]); - // From fixture: 3 logs are on database/document/{1,2,2} - $this->assertCount(3, $logs); - foreach ($logs as $log) { - $this->assertStringStartsNotWith('user/', $log->getResource()); - } - } - - public function testRegexFilter(): void - { - $logs = $this->audit->find([ - Query::regex('resource', '^database/document/\\d+$'), - ]); - // From fixture: 3 database/document/{1,2,2} rows match - $this->assertCount(3, $logs); - } - - public function testOrderRandomReturnsRows(): void - { - $logs = $this->audit->find([ - Query::orderRandom(), - Query::limit(2), - ]); - // Hard to assert randomness; just confirm the query executes and limits. - $this->assertCount(2, $logs); - } - - public function testOrderRandomRejectedWithCursor(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Cursor pagination cannot be combined with orderRandom'); - - $this->audit->find([ - Query::orderRandom(), - Query::cursorAfter(['id' => 'whatever']), - ]); - } - - public function testOrderRandomRejectedWithColumnOrder(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('orderRandom cannot be combined with orderAsc/orderDesc'); - - $this->audit->find([ - Query::orderRandom(), - Query::orderDesc('time'), - ]); - } - - public function testSharedTableSortKeyLeadsWithTenant(): void - { - $host = getenv('CLICKHOUSE_HOST') ?: 'localhost'; - $username = getenv('CLICKHOUSE_USER') ?: 'default'; - $password = getenv('CLICKHOUSE_PASSWORD') ?: 'clickhouse'; - $port = (int) (getenv('CLICKHOUSE_PORT') ?: 18123); - $secure = filter_var(getenv('CLICKHOUSE_SECURE') ?: false, FILTER_VALIDATE_BOOLEAN); - $database = getenv('CLICKHOUSE_DATABASE') ?: 'default'; - - $namespace = 'projtest_' . uniqid(); - - $adapter = new ClickHouse( - host: $host, - username: $username, - password: $password, - port: $port, - secure: $secure, - ); - $adapter->setDatabase($database); - $adapter->setNamespace($namespace); - $adapter->setSharedTables(true); - $adapter->setTenant(1); - - $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); - /** @var array $params */ - foreach ($params as $key => $value) { - $url .= '¶m_' . rawurlencode($key) . '=' . rawurlencode($value); - } - $ctx = stream_context_create(['http' => [ - 'method' => 'POST', - 'header' => "Content-Type: text/plain\r\n", - 'content' => $sql, - 'timeout' => 15, - 'ignore_errors' => true, - ]]); - $out = @file_get_contents($url, false, $ctx); - - return $out === false ? '' : trim($out); - }; - - try { - new Audit($adapter)->setup(); - - $sortingKey = $http( - 'SELECT sorting_key FROM system.tables WHERE database = {db:String} AND name = {tbl:String}', - ['db' => $database, 'tbl' => $table], - ); - - $this->assertTrue( - str_starts_with(trim($sortingKey), 'tenant'), - "Expected sorting key to lead with 'tenant', got: {$sortingKey}", - ); - } finally { - $escDb = '`' . str_replace('`', '``', $database) . '`'; - $escTbl = '`' . str_replace('`', '``', $table) . '`'; - $http("DROP TABLE IF EXISTS {$escDb}.{$escTbl}"); - } - } -} diff --git a/packages/audit/tests/Audit/Adapter/DatabaseTest.php b/packages/audit/tests/Audit/Adapter/DatabaseTest.php deleted file mode 100644 index f1a818c7e..000000000 --- a/packages/audit/tests/Audit/Adapter/DatabaseTest.php +++ /dev/null @@ -1,44 +0,0 @@ -setDatabase('utopiaTests'); - $database->setNamespace('namespace'); - - $adapter = new Adapter\Database($database); - $this->audit = new Audit($adapter); - if (! $database->exists('utopiaTests')) { - $database->create(); - $this->audit->setup(); - } - } -} diff --git a/packages/audit/tests/Audit/AuditBase.php b/packages/audit/tests/Audit/AuditBase.php deleted file mode 100644 index e0dcb10f4..000000000 --- a/packages/audit/tests/Audit/AuditBase.php +++ /dev/null @@ -1,834 +0,0 @@ -audit. - */ -trait AuditBase -{ - protected Audit $audit; - - /** - * Classes using this trait must implement this to initialize the audit instance - * with their specific adapter configuration - */ - abstract protected function initializeAudit(): void; - - /** - * Classes should override if they need custom setup - */ - public function setUp(): void - { - $this->initializeAudit(); - $cleanup = new \DateTime(); - $cleanup->modify('+10 second'); - $this->audit->cleanup(new \DateTime()); - $this->createLogs(); - } - - /** - * Classes should override if they need custom teardown - */ - public function tearDown(): void - { - $cleanup = new \DateTime(); - $cleanup->modify('+10 second'); - $this->audit->cleanup(new \DateTime()); - } - - public function createLogs(): void - { - $userId = 'userId'; - $userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'; - $ip = '127.0.0.1'; - $data = ['key1' => 'value1', 'key2' => 'value2']; - - $requiredAttributes = $this->getRequiredAttributes(); - $dataWithAttributes = array_merge($data, $requiredAttributes); - - $this->assertInstanceOf(\Utopia\Audit\Log::class, $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $dataWithAttributes)); - $this->assertInstanceOf(\Utopia\Audit\Log::class, $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $dataWithAttributes)); - $this->assertInstanceOf(\Utopia\Audit\Log::class, $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $dataWithAttributes)); - $this->assertInstanceOf(\Utopia\Audit\Log::class, $this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $dataWithAttributes)); - } - - public function testPing(): void - { - $this->assertTrue($this->audit->ping()); - } - - public function testGetLogsByUser(): void - { - $logs = $this->audit->getLogsByUser('userId'); - $this->assertEquals(3, \count($logs)); - - $logsCount = $this->audit->countLogsByUser('userId'); - $this->assertEquals(3, $logsCount); - - $logs1 = $this->audit->getLogsByUser('userId', limit: 1, offset: 1); - $this->assertEquals(1, \count($logs1)); - $this->assertEquals($logs1[0]->getId(), $logs[1]->getId()); - - $logs2 = $this->audit->getLogsByUser('userId', limit: 1, offset: 1); - $this->assertEquals(1, \count($logs2)); - $this->assertEquals($logs2[0]->getId(), $logs[1]->getId()); - } - - public function testGetLogsByUserAndEvents(): void - { - $logs1 = $this->audit->getLogsByUserAndEvents('userId', ['update']); - $logs2 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete']); - - $this->assertEquals(2, \count($logs1)); - $this->assertEquals(3, \count($logs2)); - - $logsCount1 = $this->audit->countLogsByUserAndEvents('userId', ['update']); - $logsCount2 = $this->audit->countLogsByUserAndEvents('userId', ['update', 'delete']); - - $this->assertEquals(2, $logsCount1); - $this->assertEquals(3, $logsCount2); - - $logs3 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], limit: 1, offset: 1); - - $this->assertEquals(1, \count($logs3)); - $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); - - $logs4 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], limit: 1, offset: 1); - - $this->assertEquals(1, \count($logs4)); - $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); - } - - public function testGetLogsByResourceAndEvents(): void - { - $logs1 = $this->audit->getLogsByResourceAndEvents('database/document/1', ['update']); - $logs2 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete']); - - $this->assertEquals(1, \count($logs1)); - $this->assertEquals(2, \count($logs2)); - - $logsCount1 = $this->audit->countLogsByResourceAndEvents('database/document/1', ['update']); - $logsCount2 = $this->audit->countLogsByResourceAndEvents('database/document/2', ['update', 'delete']); - - $this->assertEquals(1, $logsCount1); - $this->assertEquals(2, $logsCount2); - - $logs3 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], limit: 1, offset: 1); - - $this->assertEquals(1, \count($logs3)); - $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); - - $logs4 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], limit: 1, offset: 1); - - $this->assertEquals(1, \count($logs4)); - $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); - } - - public function testGetLogsByResource(): void - { - $logs1 = $this->audit->getLogsByResource('database/document/1'); - $logs2 = $this->audit->getLogsByResource('database/document/2'); - - $this->assertEquals(1, \count($logs1)); - $this->assertEquals(2, \count($logs2)); - - $logsCount1 = $this->audit->countLogsByResource('database/document/1'); - $logsCount2 = $this->audit->countLogsByResource('database/document/2'); - - $this->assertEquals(1, $logsCount1); - $this->assertEquals(2, $logsCount2); - - $logs3 = $this->audit->getLogsByResource('database/document/2', limit: 1, offset: 1); - $this->assertEquals(1, \count($logs3)); - $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); - - $logs4 = $this->audit->getLogsByResource('database/document/2', limit: 1, offset: 1); - $this->assertEquals(1, \count($logs4)); - $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); - - $logs5 = $this->audit->getLogsByResource('user/null'); - $this->assertEquals(1, \count($logs5)); - $this->assertNull($logs5[0]['userId']); - $this->assertEquals('127.0.0.1', $logs5[0]['ip']); - } - - public function testGetLogById(): void - { - // Create a test log - $userId = 'testGetByIdUser'; - $userAgent = 'Mozilla/5.0 Test'; - $ip = '192.168.1.100'; - $data = ['test' => 'getById']; - - $requiredAttributes = $this->getRequiredAttributes(); - $dataWithAttributes = array_merge($data, $requiredAttributes); - $log = $this->audit->log($userId, 'create', 'test/resource/123', $userAgent, $ip, $dataWithAttributes); - $logId = $log->getId(); - - // Retrieve the log by ID - $retrievedLog = $this->audit->getLogById($logId); - - $this->assertNotNull($retrievedLog); - $this->assertEquals($logId, $retrievedLog->getId()); - $this->assertEquals($userId, $retrievedLog->getAttribute('userId')); - $this->assertEquals('create', $retrievedLog->getAttribute('event')); - $this->assertEquals('test/resource/123', $retrievedLog->getAttribute('resource')); - $this->assertEquals($userAgent, $retrievedLog->getAttribute('userAgent')); - $this->assertEquals($ip, $retrievedLog->getAttribute('ip')); - $this->assertEquals($data, $retrievedLog->getAttribute('data')); - - // Test with non-existent ID - $nonExistentLog = $this->audit->getLogById('non-existent-id-12345'); - $this->assertNull($nonExistentLog); - } - - public function testLogByBatch(): void - { - // First cleanup existing logs - $this->audit->cleanup(new \DateTime()); - - $userId = 'batchUserId'; - $userAgent = 'Mozilla/5.0 (Test User Agent)'; - $ip = '192.168.1.1'; - - // Create timestamps 1 minute apart - $timestamp1 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -120)) ?? ''; - $timestamp2 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60)) ?? ''; - $timestamp3 = DateTime::formatTz(DateTime::now()) ?? ''; - - $batchEvents = [ - [ - 'userId' => $userId, - 'event' => 'create', - 'resource' => 'database/document/batch1', - 'userAgent' => $userAgent, - 'ip' => $ip, - 'data' => ['key' => 'value1'], - 'time' => $timestamp1, - ], - [ - 'userId' => $userId, - 'event' => 'update', - 'resource' => 'database/document/batch2', - 'userAgent' => $userAgent, - 'ip' => $ip, - 'data' => ['key' => 'value2'], - 'time' => $timestamp2, - ], - [ - 'userId' => $userId, - 'event' => 'delete', - 'resource' => 'database/document/batch3', - 'userAgent' => $userAgent, - 'ip' => $ip, - 'data' => ['key' => 'value3'], - 'time' => $timestamp3, - ], - [ - 'userId' => null, - 'event' => 'insert', - 'resource' => 'user1/null', - 'userAgent' => $userAgent, - 'ip' => $ip, - 'data' => ['key' => 'value4'], - 'time' => $timestamp3, - ], - ]; - - // Test batch insertion - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - - $result = $this->audit->logBatch($batchEvents); - $this->assertTrue($result); - - // Verify the number of logs inserted - $logs = $this->audit->getLogsByUser($userId); - $this->assertEquals(3, \count($logs)); - - // Verify chronological order (newest first due to orderDesc) - $this->assertEquals('delete', $logs[0]->getAttribute('event')); - $this->assertEquals('update', $logs[1]->getAttribute('event')); - $this->assertEquals('create', $logs[2]->getAttribute('event')); - - // Verify timestamps were preserved - $this->assertEquals($timestamp3, $logs[0]->getAttribute('time')); - $this->assertEquals($timestamp2, $logs[1]->getAttribute('time')); - $this->assertEquals($timestamp1, $logs[2]->getAttribute('time')); - - // Test resource-based retrieval - $resourceLogs = $this->audit->getLogsByResource('database/document/batch2'); - $this->assertEquals(1, \count($resourceLogs)); - $this->assertEquals('update', $resourceLogs[0]->getAttribute('event')); - - // Test resource with userId null - $resourceLogs = $this->audit->getLogsByResource('user1/null'); - $this->assertEquals(1, \count($resourceLogs)); - foreach ($resourceLogs as $log) { - $this->assertEquals('insert', $log->getAttribute('event')); - $this->assertNull($log['userId']); - } - - // Test event-based retrieval - $eventLogs = $this->audit->getLogsByUserAndEvents($userId, ['create', 'delete']); - $this->assertEquals(2, \count($eventLogs)); - } - - public function testGetLogsCustomFilters(): void - { - $threshold = new \DateTime(); - $threshold->modify('-10 seconds'); - $logs = $this->audit->getLogsByUser('userId', after: $threshold); - - $this->assertEquals(3, \count($logs)); - } - - public function testAscendingOrderRetrieval(): void - { - // Test ascending order retrieval - $logsDesc = $this->audit->getLogsByUser('userId', ascending: false); - $logsAsc = $this->audit->getLogsByUser('userId', ascending: true); - - // Both should have same count - $this->assertEquals(\count($logsDesc), \count($logsAsc)); - - // Events should be in opposite order - if (\count($logsDesc) > 1) { - $descEvents = array_map(fn($log) => $log->getAttribute('event'), $logsDesc); - $ascEvents = array_map(fn($log) => $log->getAttribute('event'), $logsAsc); - $this->assertEquals($descEvents, array_reverse($ascEvents)); - } - } - - public function testLargeBatchInsert(): void - { - // Create a large batch (50 events) - $batchEvents = []; - $baseTime = DateTime::now(); - for ($i = 0; $i < 50; $i++) { - $batchEvents[] = [ - 'userId' => 'largebatchuser', - 'event' => 'event_' . $i, - 'resource' => 'doc/' . $i, - 'userAgent' => 'Mozilla', - 'ip' => '127.0.0.1', - 'data' => ['index' => $i], - 'time' => DateTime::formatTz($baseTime) ?? '', - ]; - } - - // Insert batch - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - $result = $this->audit->logBatch($batchEvents); - $this->assertTrue($result); - - // Verify all were inserted - $count = $this->audit->countLogsByUser('largebatchuser'); - $this->assertEquals(50, $count); - - // Test pagination - $page1 = $this->audit->getLogsByUser('largebatchuser', limit: 10, offset: 0); - $this->assertEquals(10, \count($page1)); - - $page2 = $this->audit->getLogsByUser('largebatchuser', limit: 10, offset: 10); - $this->assertEquals(10, \count($page2)); - } - - public function testTimeRangeFilters(): void - { - // Create logs with different timestamps - $old = DateTime::format(new \DateTime('2024-01-01 10:00:00')); - $recent = DateTime::now(); - - $batchEvents = [ - [ - 'userId' => 'timerangeuser', - 'event' => 'old_event', - 'resource' => 'doc/1', - 'userAgent' => 'Mozilla', - 'ip' => '127.0.0.1', - 'data' => [], - 'time' => $old, - ], - [ - 'userId' => 'timerangeuser', - 'event' => 'recent_event', - 'resource' => 'doc/2', - 'userAgent' => 'Mozilla', - 'ip' => '127.0.0.1', - 'data' => [], - 'time' => $recent, - ], - ]; - - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - $this->audit->logBatch($batchEvents); - - // Test getting all logs - $all = $this->audit->getLogsByUser('timerangeuser'); - $this->assertGreaterThanOrEqual(2, \count($all)); - - // Test with before filter - should get both since they're both in the past relative to future - $beforeFuture = new \DateTime('2099-12-31 23:59:59'); - $beforeLogs = $this->audit->getLogsByUser('timerangeuser', before: $beforeFuture); - $this->assertGreaterThanOrEqual(2, \count($beforeLogs)); - } - - public function testCleanup(): void - { - $status = $this->audit->cleanup(new \DateTime()); - $this->assertEquals($status, true); - - // Check that all logs have been deleted - $logs = $this->audit->getLogsByUser('userId'); - $this->assertEquals(0, \count($logs)); - - // Add three sample logs - $userId = 'userId'; - $userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'; - $ip = '127.0.0.1'; - $data = ['key1' => 'value1', 'key2' => 'value2']; - - $requiredAttributes = $this->getRequiredAttributes(); - $dataWithAttributes = array_merge($data, $requiredAttributes); - - $this->assertInstanceOf(\Utopia\Audit\Log::class, $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $dataWithAttributes)); - sleep(5); - $this->assertInstanceOf(\Utopia\Audit\Log::class, $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $dataWithAttributes)); - sleep(5); - $this->assertInstanceOf(\Utopia\Audit\Log::class, $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $dataWithAttributes)); - sleep(5); - - // DELETE logs older than 11 seconds and check that status is true - $datetime = new \DateTime(); - $datetime->modify('-11 seconds'); - $status = $this->audit->cleanup($datetime); - $this->assertEquals($status, true); - - // Check if 1 log has been deleted - $logs = $this->audit->getLogsByUser('userId'); - $this->assertEquals(2, \count($logs)); - } - - /** - * Test all additional retrieval parameters: limit, offset, ascending, after, before - */ - public function testRetrievalParameters(): void - { - // Setup: Create logs with specific timestamps for testing - $this->audit->cleanup(new \DateTime()); - - $userId = 'paramtestuser'; - $userAgent = 'Mozilla/5.0'; - $ip = '192.168.1.1'; - - // Create 5 logs with different timestamps - new \DateTime('2024-06-15 12:00:00'); - $batchEvents = []; - for ($i = 0; $i < 5; $i++) { - $offset = $i * 60; - $logTime = new \DateTime('2024-06-15 12:00:00'); - $logTime->modify("+{$offset} seconds"); - $timestamp = DateTime::format($logTime); - $batchEvents[] = [ - 'userId' => $userId, - 'event' => 'event_' . $i, - 'resource' => 'doc/' . $i, - 'userAgent' => $userAgent, - 'ip' => $ip, - 'data' => ['sequence' => $i], - 'time' => $timestamp, - ]; - } - - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - - $this->audit->logBatch($batchEvents); - - // Test 1: limit parameter - $logsLimit2 = $this->audit->getLogsByUser($userId, limit: 2); - $this->assertEquals(2, \count($logsLimit2)); - - $logsLimit3 = $this->audit->getLogsByUser($userId, limit: 3); - $this->assertEquals(3, \count($logsLimit3)); - - // Test 2: offset parameter - $logsOffset0 = $this->audit->getLogsByUser($userId, limit: 10, offset: 0); - $logsOffset2 = $this->audit->getLogsByUser($userId, limit: 10, offset: 2); - $logsOffset4 = $this->audit->getLogsByUser($userId, limit: 10, offset: 4); - - $this->assertEquals(5, \count($logsOffset0)); - $this->assertEquals(3, \count($logsOffset2)); - $this->assertEquals(1, \count($logsOffset4)); - - // Verify offset returns different logs - $this->assertNotEquals($logsOffset0[0]->getId(), $logsOffset2[0]->getId()); - $this->assertNotEquals($logsOffset2[0]->getId(), $logsOffset4[0]->getId()); - - // Test 3: ascending parameter - $logsDesc = $this->audit->getLogsByUser($userId, ascending: false); - $logsAsc = $this->audit->getLogsByUser($userId, ascending: true); - - $this->assertEquals(5, \count($logsDesc)); - $this->assertEquals(5, \count($logsAsc)); - - // Verify order is reversed - if (\count($logsDesc) === \count($logsAsc)) { - $counter = \count($logsDesc); - for ($i = 0; $i < $counter; $i++) { - $this->assertEquals( - $logsDesc[$i]->getId(), - $logsAsc[\count($logsAsc) - 1 - $i]->getId(), - ); - } - } - - // Test 4: after parameter (logs after a certain timestamp) - $afterTimeObj = new \DateTime('2024-06-15 12:03:00'); // After 3rd log - $logsAfter = $this->audit->getLogsByUser($userId, after: $afterTimeObj); - // Should get logs at positions 3 and 4 (2 logs) - $this->assertGreaterThanOrEqual(1, \count($logsAfter)); - - // Test 5: before parameter (logs before a certain timestamp) - $beforeTimeObj = new \DateTime('2024-06-15 12:02:00'); // Before 3rd log - $logsBefore = $this->audit->getLogsByUser($userId, before: $beforeTimeObj); - // Should get logs at positions 0, 1, 2 (3 logs) - $this->assertGreaterThanOrEqual(1, \count($logsBefore)); - - // Test 6: Combination of limit + offset - $logsPage1 = $this->audit->getLogsByUser($userId, limit: 2, offset: 0); - $logsPage2 = $this->audit->getLogsByUser($userId, limit: 2, offset: 2); - $logsPage3 = $this->audit->getLogsByUser($userId, limit: 2, offset: 4); - - $this->assertEquals(2, \count($logsPage1)); - $this->assertEquals(2, \count($logsPage2)); - $this->assertEquals(1, \count($logsPage3)); - - // Verify pages don't overlap - $this->assertNotEquals($logsPage1[0]->getId(), $logsPage2[0]->getId()); - $this->assertNotEquals($logsPage2[0]->getId(), $logsPage3[0]->getId()); - - // Test 7: Combination of ascending + limit - $ascLimit2 = $this->audit->getLogsByUser($userId, limit: 2, ascending: true); - $this->assertEquals(2, \count($ascLimit2)); - // First log should be oldest in ascending order - $this->assertEquals('event_0', $ascLimit2[0]->getAttribute('event')); - - // Test 8: Combination of after + before (time range) - $afterTimeObj2 = new \DateTime('2024-06-15 12:01:00'); // After 1st log - $beforeTimeObj2 = new \DateTime('2024-06-15 12:04:00'); // Before 4th log - $logsRange = $this->audit->getLogsByUser($userId, after: $afterTimeObj2, before: $beforeTimeObj2); - $this->assertGreaterThanOrEqual(1, \count($logsRange)); - - // Test 9: Test with getLogsByResource using parameters - $logsRes = $this->audit->getLogsByResource('doc/0', limit: 1, offset: 0); - $this->assertEquals(1, \count($logsRes)); - - // Test 10: Test with getLogsByUserAndEvents using parameters - $logsEvt = $this->audit->getLogsByUserAndEvents( - $userId, - ['event_1', 'event_2'], - limit: 1, - offset: 0, - ascending: false, - ); - $this->assertGreaterThanOrEqual(0, \count($logsEvt)); - - // Test 11: Test count methods with after/before filters - $countAll = $this->audit->countLogsByUser($userId); - $this->assertEquals(5, $countAll); - - $countAfter = $this->audit->countLogsByUser($userId, after: $afterTimeObj); - $this->assertGreaterThanOrEqual(0, $countAfter); - - $countBefore = $this->audit->countLogsByUser($userId, before: $beforeTimeObj); - $this->assertGreaterThanOrEqual(0, $countBefore); - - // Test 12: Test countLogsByResource with filters - $countResAll = $this->audit->countLogsByResource('doc/0'); - $this->assertEquals(1, $countResAll); - - $countResAfter = $this->audit->countLogsByResource('doc/0', after: $afterTimeObj); - $this->assertGreaterThanOrEqual(0, $countResAfter); - - // Test 13: Test countLogsByUserAndEvents with filters - $countEvtAll = $this->audit->countLogsByUserAndEvents($userId, ['event_1', 'event_2']); - $this->assertGreaterThanOrEqual(0, $countEvtAll); - - $countEvtAfter = $this->audit->countLogsByUserAndEvents( - $userId, - ['event_1', 'event_2'], - after: $afterTimeObj, - ); - $this->assertGreaterThanOrEqual(0, $countEvtAfter); - - // Test 14: Test countLogsByResourceAndEvents with filters - $countResEvtAll = $this->audit->countLogsByResourceAndEvents('doc/0', ['event_0']); - $this->assertEquals(1, $countResEvtAll); - - $countResEvtAfter = $this->audit->countLogsByResourceAndEvents( - 'doc/0', - ['event_0'], - after: $afterTimeObj, - ); - $this->assertGreaterThanOrEqual(0, $countResEvtAfter); - - // Test 15: Test getLogsByResourceAndEvents with all parameters - $logsResEvt = $this->audit->getLogsByResourceAndEvents( - 'doc/1', - ['event_1'], - limit: 1, - offset: 0, - ascending: true, - ); - $this->assertGreaterThanOrEqual(0, \count($logsResEvt)); - } - - public function testFind(): void - { - // Setup: Create specific test data for find queries - $this->audit->cleanup(new \DateTime()); - - $userId = 'userId'; - $userAgent = 'Mozilla/5.0'; - $ip = '192.168.1.1'; - - // Create test logs with specific attributes - new \DateTime('2024-06-15 12:00:00'); - $batchEvents = []; - for ($i = 0; $i < 3; $i++) { - $offset = $i * 60; - $logTime = new \DateTime('2024-06-15 12:00:00'); - $logTime->modify("+{$offset} seconds"); - $timestamp = DateTime::format($logTime); - $batchEvents[] = [ - 'userId' => $userId, - 'event' => 'event_' . $i, - 'resource' => 'doc/' . $i, - 'userAgent' => $userAgent, - 'ip' => $ip, - 'data' => ['sequence' => $i], - 'time' => $timestamp, - ]; - } - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - - $this->audit->logBatch($batchEvents); - - // Test 1: Find with equal filter - $logs = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - ]); - $this->assertEquals(3, \count($logs)); - - // Test 2: Find with equal and limit - $logs = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::limit(2), - ]); - $this->assertEquals(2, \count($logs)); - - // Test 3: Find with equal, limit and offset - $logs = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::limit(2), - \Utopia\Audit\Query::offset(1), - ]); - $this->assertEquals(2, \count($logs)); - - // Test 4: Find with multiple filters - $logs = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::equal('resource', 'doc/0'), - ]); - $this->assertEquals(1, \count($logs)); - - // Test 5: Find with ordering - $logsDesc = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::orderDesc('time'), - ]); - $logsAsc = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::orderAsc('time'), - ]); - $this->assertEquals(3, \count($logsDesc)); - $this->assertEquals(3, \count($logsAsc)); - - // Verify order is reversed - if (\count($logsDesc) === \count($logsAsc)) { - $counter = \count($logsDesc); - for ($i = 0; $i < $counter; $i++) { - $this->assertEquals( - $logsDesc[$i]->getId(), - $logsAsc[\count($logsAsc) - 1 - $i]->getId(), - ); - } - } - - // Test 6: Find with contains filter (substring match, like utopia-php/database) - $logs = $this->audit->find([ - \Utopia\Audit\Query::contains('event', ['event_0', 'event_1']), - ]); - $this->assertGreaterThanOrEqual(2, \count($logs)); - - // Substring needle matches without an exact value - $logs = $this->audit->find([ - \Utopia\Audit\Query::contains('event', ['vent_0']), - ]); - $this->assertGreaterThanOrEqual(1, \count($logs)); - foreach ($logs as $log) { - $this->assertStringContainsString('vent_0', $log->getEvent()); - } - - // Test 7: Find with between query for time range - $afterTime = new \DateTime('2024-06-15 12:01:00'); - $beforeTime = new \DateTime('2024-06-15 12:04:00'); - $logs = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::between('time', DateTime::format($afterTime), DateTime::format($beforeTime)), - ]); - $this->assertGreaterThanOrEqual(0, \count($logs)); - - // Test 8: Find with greater than - $afterTime = new \DateTime('2024-06-15 12:02:00'); - $logs = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::greaterThan('time', DateTime::format($afterTime)), - ]); - $this->assertGreaterThanOrEqual(0, \count($logs)); - - // Test 9: Find with less than - $beforeTime = new \DateTime('2024-06-15 12:03:00'); - $logs = $this->audit->find([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::lessThan('time', DateTime::format($beforeTime)), - ]); - $this->assertGreaterThanOrEqual(0, \count($logs)); - } - - public function testCount(): void - { - // Setup: Create specific test data for count queries - $this->audit->cleanup(new \DateTime()); - - $userId = 'userId'; - $userAgent = 'Mozilla/5.0'; - $ip = '192.168.1.1'; - - // Create test logs with specific attributes - new \DateTime('2024-06-15 12:00:00'); - $batchEvents = []; - for ($i = 0; $i < 3; $i++) { - $offset = $i * 60; - $logTime = new \DateTime('2024-06-15 12:00:00'); - $logTime->modify("+{$offset} seconds"); - $timestamp = DateTime::format($logTime); - $batchEvents[] = [ - 'userId' => $userId, - 'event' => 'event_' . $i, - 'resource' => 'doc/' . $i, - 'userAgent' => $userAgent, - 'ip' => $ip, - 'data' => ['sequence' => $i], - 'time' => $timestamp, - ]; - } - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - $this->audit->logBatch($batchEvents); - - // Test 1: Count with simple filter - $count = $this->audit->count([ - \Utopia\Audit\Query::equal('userId', $userId), - ]); - $this->assertEquals(3, $count); - - // Test 2: Count with multiple filters - $count = $this->audit->count([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::equal('resource', 'doc/0'), - ]); - $this->assertEquals(1, $count); - - // Test 3: Count with contains filter (substring match, like utopia-php/database) - $count = $this->audit->count([ - \Utopia\Audit\Query::contains('event', ['event_0', 'event_1']), - ]); - $this->assertGreaterThanOrEqual(2, $count); - - // Test 4: Count ignores limit and offset - $count = $this->audit->count([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::limit(2), - \Utopia\Audit\Query::offset(1), - ]); - $this->assertEquals(3, $count); // Should count all 3, not affected by limit/offset - - // Test 5: Count with between query - $afterTime = new \DateTime('2024-06-15 12:01:00'); - $beforeTime = new \DateTime('2024-06-15 12:04:00'); - $count = $this->audit->count([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::between('time', DateTime::format($afterTime), DateTime::format($beforeTime)), - ]); - $this->assertGreaterThanOrEqual(0, $count); - - // Test 6: Count with greater than - $afterTime = new \DateTime('2024-06-15 12:02:00'); - $count = $this->audit->count([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::greaterThan('time', DateTime::format($afterTime)), - ]); - $this->assertGreaterThanOrEqual(0, $count); - - // Test 7: Count with less than - $beforeTime = new \DateTime('2024-06-15 12:03:00'); - $count = $this->audit->count([ - \Utopia\Audit\Query::equal('userId', $userId), - \Utopia\Audit\Query::lessThan('time', DateTime::format($beforeTime)), - ]); - $this->assertGreaterThanOrEqual(0, $count); - - // Test 8: Count returns zero for no matches - $count = $this->audit->count([ - \Utopia\Audit\Query::equal('userId', 'nonExistentUser'), - ]); - $this->assertEquals(0, $count); - } - - /** - * Apply adapter-specific required attributes to batch events. - * - * @param array> $batchEvents - * @return array}> - */ - protected function applyRequiredAttributesToBatch(array $batchEvents): array - { - $requiredAttributes = $this->getRequiredAttributes(); - if ($requiredAttributes === []) { - return $batchEvents; - } - - /** @var array}> */ - return array_map(static fn(array $event): array => array_merge($event, $requiredAttributes), $batchEvents); - } - - /** - * Override in adapter-specific tests to provide required attribute defaults. - * - * @return array - */ - protected function getRequiredAttributes(): array - { - return []; - } -} diff --git a/packages/audit/tests/Audit/QueryTest.php b/packages/audit/tests/Audit/QueryTest.php deleted file mode 100644 index 0232bf63f..000000000 --- a/packages/audit/tests/Audit/QueryTest.php +++ /dev/null @@ -1,227 +0,0 @@ -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()->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()->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()->value); - $this->assertSame('time', $query->getAttribute()); - $this->assertSame(['2023-01-01', '2024-01-01'], $query->getValues()); - - // Test contains - $query = Query::containsString('event', ['create', 'update', 'delete']); - $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()->value); - $this->assertSame('time', $query->getAttribute()); - $this->assertSame([], $query->getValues()); - - // Test orderAsc - $query = Query::orderAsc('userId'); - $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()->value); - $this->assertSame('', $query->getAttribute()); - $this->assertSame([10], $query->getValues()); - - // Test offset - $query = Query::offset(5); - $this->assertSame(Query::TYPE_OFFSET, $query->getMethod()->value); - $this->assertSame('', $query->getAttribute()); - $this->assertSame([5], $query->getValues()); - } - - /** - * Test Query parse and toString methods - */ - public function testQueryParseAndToString(): void - { - // Test parsing equal query - $json = '{"method":"equal","attribute":"userId","values":["123"]}'; - $query = Query::parse($json); - $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()->value); - $this->assertSame('userId', $query->getAttribute()); - $this->assertSame(['123'], $query->getValues()); - - // Test toString - $query = Query::equal('event', 'create'); - $json = $query->toString(); - $this->assertJson($json); - - $parsed = Query::parse($json); - $this->assertSame(Query::TYPE_EQUAL, $parsed->getMethod()->value); - $this->assertSame('event', $parsed->getAttribute()); - $this->assertSame(['create'], $parsed->getValues()); - - // Test toArray - $array = $query->toArray(); - $this->assertArrayHasKey('method', $array); - $this->assertArrayHasKey('attribute', $array); - $this->assertArrayHasKey('values', $array); - $this->assertEquals(Query::TYPE_EQUAL, $array['method']); - $this->assertEquals('event', $array['attribute']); - $this->assertEquals(['create'], $array['values']); - } - - /** - * Test Query parseQueries method - */ - public function testQueryParseQueries(): void - { - $queries = [ - '{"method":"equal","attribute":"userId","values":["123"]}', - '{"method":"greaterThan","attribute":"time","values":["2023-01-01"]}', - '{"method":"limit","values":[10]}', - ]; - - $parsed = Query::parseQueries($queries); - - $this->assertCount(3, $parsed); - $this->assertInstanceOf(Query::class, $parsed[0]); - $this->assertInstanceOf(Query::class, $parsed[1]); - $this->assertInstanceOf(Query::class, $parsed[2]); - - $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 - { - $query = Query::equal('userId', '123'); - $this->assertEquals('123', $query->getValue()); - - $query = Query::limit(10); - $this->assertEquals(10, $query->getValue()); - - // Test with default value - $query = Query::orderAsc('time'); - $this->assertNull($query->getValue()); - $this->assertEquals('default', $query->getValue('default')); - } - - /** - * Test Query with empty attribute - */ - public function testQueryWithEmptyAttribute(): void - { - $query = Query::limit(25); - $this->assertSame('', $query->getAttribute()); - $this->assertSame([25], $query->getValues()); - - $query = Query::offset(10); - $this->assertSame('', $query->getAttribute()); - $this->assertSame([10], $query->getValues()); - } - - /** - * Test Query parse with invalid JSON - */ - public function testQueryParseInvalidJson(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid query'); - - Query::parse('{"method":"equal","attribute":"userId"'); // Invalid JSON - } - - /** - * Test Query parse with non-array value - */ - public function testQueryParseNonArray(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid query. Must be an array'); - - Query::parse('"string"'); - } - - /** - * Test Query parse with invalid method type - */ - public function testQueryParseInvalidMethodType(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid query method. Must be a string'); - - Query::parse('{"method":["array"],"attribute":"test","values":[]}'); - } - - /** - * Test Query parse with invalid attribute type - */ - public function testQueryParseInvalidAttributeType(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid query attribute. Must be a string'); - - Query::parse('{"method":"equal","attribute":123,"values":[]}'); - } - - /** - * Test Query parse with invalid values type - */ - public function testQueryParseInvalidValuesType(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Invalid query values. Must be an array'); - - Query::parse('{"method":"equal","attribute":"test","values":"string"}'); - } - - /** - * Test Query toString with complex values - */ - public function testQueryToStringWithComplexValues(): 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()->value); - $this->assertSame('time', $parsed->getAttribute()); - $this->assertSame(['2023-01-01', '2024-12-31'], $parsed->getValues()); - } -}