diff --git a/composer.lock b/composer.lock index 19178c1..7424c17 100644 --- a/composer.lock +++ b/composer.lock @@ -539,16 +539,16 @@ }, { "name": "open-telemetry/exporter-otlp", - "version": "1.3.3", + "version": "1.3.4", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d" + "reference": "62e680d587beb42e5247aa6ecd89ad1ca406e8ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/07b02bc71838463f6edcc78d3485c04b48fb263d", - "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/62e680d587beb42e5247aa6ecd89ad1ca406e8ca", + "reference": "62e680d587beb42e5247aa6ecd89ad1ca406e8ca", "shasum": "" }, "require": { @@ -599,7 +599,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-11-13T08:04:37+00:00" + "time": "2026-01-15T09:31:34+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", @@ -666,16 +666,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.10.0", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99" + "reference": "d91f21addcdb42da9a451c002777f8318432461a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", - "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/d91f21addcdb42da9a451c002777f8318432461a", + "reference": "d91f21addcdb42da9a451c002777f8318432461a", "shasum": "" }, "require": { @@ -759,7 +759,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-11-25T10:59:15+00:00" + "time": "2026-01-15T11:21:03+00:00" }, { "name": "open-telemetry/sem-conv", @@ -2026,16 +2026,16 @@ }, { "name": "utopia-php/cache", - "version": "0.13.2", + "version": "0.13.3", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "5768498c9f451482f0bf3eede4d6452ddcd4a0f6" + "reference": "355707ab2c0090435059216165db86976b68a126" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/5768498c9f451482f0bf3eede4d6452ddcd4a0f6", - "reference": "5768498c9f451482f0bf3eede4d6452ddcd4a0f6", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/355707ab2c0090435059216165db86976b68a126", + "reference": "355707ab2c0090435059216165db86976b68a126", "shasum": "" }, "require": { @@ -2043,7 +2043,7 @@ "ext-memcached": "*", "ext-redis": "*", "php": ">=8.0", - "utopia-php/pools": "0.8.*", + "utopia-php/pools": "1.*", "utopia-php/telemetry": "*" }, "require-dev": { @@ -2072,9 +2072,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/0.13.2" + "source": "https://github.com/utopia-php/cache/tree/0.13.3" }, - "time": "2025-12-17T08:55:43+00:00" + "time": "2026-01-16T07:54:34+00:00" }, { "name": "utopia-php/compression", @@ -2124,16 +2124,16 @@ }, { "name": "utopia-php/database", - "version": "4.4.0", + "version": "4.6.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "783193d5cdc723b3784e8fb399068b17d4228d53" + "reference": "b5c16caf4f6b12fa2c04d5a48f6e5785c99da8df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/783193d5cdc723b3784e8fb399068b17d4228d53", - "reference": "783193d5cdc723b3784e8fb399068b17d4228d53", + "url": "https://api.github.com/repos/utopia-php/database/zipball/b5c16caf4f6b12fa2c04d5a48f6e5785c99da8df", + "reference": "b5c16caf4f6b12fa2c04d5a48f6e5785c99da8df", "shasum": "" }, "require": { @@ -2144,7 +2144,7 @@ "utopia-php/cache": "0.13.*", "utopia-php/framework": "0.33.*", "utopia-php/mongo": "0.11.*", - "utopia-php/pools": "0.8.*" + "utopia-php/pools": "1.*" }, "require-dev": { "fakerphp/faker": "1.23.*", @@ -2176,9 +2176,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/4.4.0" + "source": "https://github.com/utopia-php/database/tree/4.6.0" }, - "time": "2026-01-08T04:54:39+00:00" + "time": "2026-01-16T12:35:16+00:00" }, { "name": "utopia-php/fetch", @@ -2221,28 +2221,29 @@ }, { "name": "utopia-php/framework", - "version": "0.33.30", + "version": "0.33.37", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "07cf699a7c47bd1a03b4da1812f1719a66b3c924" + "reference": "30a119d76531d89da9240496940c84fcd9e1758b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/07cf699a7c47bd1a03b4da1812f1719a66b3c924", - "reference": "07cf699a7c47bd1a03b4da1812f1719a66b3c924", + "url": "https://api.github.com/repos/utopia-php/http/zipball/30a119d76531d89da9240496940c84fcd9e1758b", + "reference": "30a119d76531d89da9240496940c84fcd9e1758b", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.3", "utopia-php/compression": "0.1.*", - "utopia-php/telemetry": "0.1.*" + "utopia-php/telemetry": "0.1.*", + "utopia-php/validators": "0.2.*" }, "require-dev": { - "laravel/pint": "^1.2", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5.25" + "laravel/pint": "1.*", + "phpbench/phpbench": "1.*", + "phpstan/phpstan": "1.*", + "phpunit/phpunit": "9.*" }, "type": "library", "autoload": { @@ -2262,9 +2263,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.30" + "source": "https://github.com/utopia-php/http/tree/0.33.37" }, - "time": "2025-11-18T12:18:00+00:00" + "time": "2026-01-13T10:10:21+00:00" }, { "name": "utopia-php/mongo", @@ -2329,16 +2330,16 @@ }, { "name": "utopia-php/pools", - "version": "0.8.3", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/utopia-php/pools.git", - "reference": "ad7d6ba946376e81c603204285ce9a674b6502b8" + "reference": "74ba7dc985c2f629df8cf08ed95507955e3bcf86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/pools/zipball/ad7d6ba946376e81c603204285ce9a674b6502b8", - "reference": "ad7d6ba946376e81c603204285ce9a674b6502b8", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/74ba7dc985c2f629df8cf08ed95507955e3bcf86", + "reference": "74ba7dc985c2f629df8cf08ed95507955e3bcf86", "shasum": "" }, "require": { @@ -2348,7 +2349,8 @@ "require-dev": { "laravel/pint": "1.*", "phpstan/phpstan": "1.*", - "phpunit/phpunit": "11.*" + "phpunit/phpunit": "11.*", + "swoole/ide-helper": "5.1.2" }, "type": "library", "autoload": { @@ -2375,9 +2377,9 @@ ], "support": { "issues": "https://github.com/utopia-php/pools/issues", - "source": "https://github.com/utopia-php/pools/tree/0.8.3" + "source": "https://github.com/utopia-php/pools/tree/1.0.0" }, - "time": "2025-12-17T09:35:18+00:00" + "time": "2026-01-15T12:34:17+00:00" }, { "name": "utopia-php/telemetry", diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php index 1a39d9c..0b7b484 100644 --- a/src/Audit/Adapter.php +++ b/src/Audit/Adapter.php @@ -24,6 +24,16 @@ abstract public function getName(): string; */ abstract public function setup(): void; + /** + * Get a single log by its ID. + * + * @param string $id + * @return Log|null The log entry or null if not found + * + * @throws \Exception + */ + abstract public function getById(string $id): ?Log; + /** * Create an audit log entry. * @@ -202,4 +212,24 @@ abstract public function countByResourceAndEvents( * @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. + * + * @param array<\Utopia\Audit\Query> $queries + * @return int + * + * @throws \Exception + */ + abstract public function count(array $queries = []): int; } diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index c475edb..06a763d 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -4,6 +4,8 @@ use Exception; use Utopia\Audit\Log; +use Utopia\Audit\Query; +use Utopia\Database\Database; use Utopia\Fetch\Client; use Utopia\Validator\Hostname; @@ -248,6 +250,211 @@ public function isSharedTables(): bool return $this->sharedTables; } + /** + * Override getAttributes to provide extended attributes for ClickHouse. + * Includes existing attributes from parent and adds new missing ones. + * + * @return array> + */ + public function getAttributes(): array + { + $parentAttributes = parent::getAttributes(); + + return [ + ...$parentAttributes, + [ + '$id' => 'userType', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => true, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'userInternalId', + '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' => [], + ], + [ + '$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' => [], + ], + ]; + } + + /** + * Override getIndexes to provide extended indexes for ClickHouse. + * Includes existing indexes from parent and adds new missing ones. + * + * @return array> + */ + public function getIndexes(): array + { + $parentIndexes = parent::getIndexes(); + + // New indexes to add + return [ + ...$parentIndexes, + [ + '$id' => '_key_user_internal_and_event', + 'type' => Database::INDEX_KEY, + 'attributes' => ['userInternalId', '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_user_internal_id', + 'type' => Database::INDEX_KEY, + 'attributes' => ['userInternalId'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => '_key_user_type', + 'type' => Database::INDEX_KEY, + 'attributes' => ['userType'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => '_key_country', + 'type' => Database::INDEX_KEY, + 'attributes' => ['country'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => '_key_hostname', + 'type' => Database::INDEX_KEY, + 'attributes' => ['hostname'], + 'lengths' => [], + 'orders' => [], + ], + ]; + } + /** * Get the table name with namespace prefix. * Namespace is used to isolate tables for different projects/applications. @@ -409,7 +616,9 @@ public function setup(): void $indexName = $index['$id']; /** @var array $attributes */ $attributes = $index['attributes']; - $attributeList = implode(', ', $attributes); + // Escape each attribute name to prevent SQL injection + $escapedAttributes = array_map(fn ($attr) => $this->escapeIdentifier($attr), $attributes); + $attributeList = implode(', ', $escapedAttributes); $indexes[] = "INDEX {$indexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1"; } @@ -431,73 +640,473 @@ public function setup(): void $this->query($createTableSql); } + /** + * 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 + */ + 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 + { + if ($dateTime === null) { + return (new \DateTime())->format('Y-m-d H:i:s.v'); + } + + if ($dateTime instanceof \DateTime) { + return $dateTime->format('Y-m-d H:i:s.v'); + } + + if (is_string($dateTime)) { + try { + // 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 $e) { + throw new Exception("Invalid datetime string: {$dateTime}"); + } + } + + // This is unreachable code but kept for completeness - all valid types are handled above + // @phpstan-ignore-next-line + throw new Exception('DateTime must be a DateTime object or string'); + } + /** * Create an audit log entry. * + * @param array $log The log data * @throws Exception */ public function create(array $log): Log { - $id = uniqid('', true); - $time = (new \DateTime())->format('Y-m-d H:i:s.v'); + $logId = uniqid('', true); + + // Format time - use provided time or current time + /** @var string|\DateTime|null $providedTime */ + $providedTime = $log['time'] ?? null; + $formattedTime = $this->formatDateTime($providedTime); $tableName = $this->getTableName(); - // Build column list and values based on sharedTables setting - $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data']; - $placeholders = ['{id:String}', '{userId:Nullable(String)}', '{event:String}', '{resource:String}', '{userAgent:String}', '{ip:String}', '{location:Nullable(String)}', '{time:String}', '{data:String}']; - - $params = [ - 'id' => $id, - 'userId' => $log['userId'] ?? null, - 'event' => $log['event'], - 'resource' => $log['resource'], - 'userAgent' => $log['userAgent'], - 'ip' => $log['ip'], - 'location' => $log['location'] ?? null, - 'time' => $time, - 'data' => json_encode($log['data'] ?? []), + // Extract additional attributes from the data array + /** @var array $logData */ + $logData = $log['data'] ?? []; + + // Build column list and placeholders dynamically from attributes + $insertColumns = ['id', 'time']; + $valuePlaceholders = ['{id:String}', '{time:String}']; + $queryParams = [ + 'id' => $logId, + 'time' => $formattedTime, ]; + // Get all column names from attributes + $schemaColumns = $this->getColumnNames(); + + // Separate data for the data column (non-schema attributes) + $nonSchemaData = $logData; + + foreach ($schemaColumns as $columnName) { + if ($columnName === 'time') { + // Skip time - already handled above + continue; + } + + // Get attribute metadata to determine if required and nullable + $attributeMetadata = $this->getAttribute($columnName); + $isRequiredAttribute = $attributeMetadata !== null && isset($attributeMetadata['required']) && $attributeMetadata['required']; + $isNullableAttribute = $attributeMetadata !== null && (!isset($attributeMetadata['required']) || !$attributeMetadata['required']); + + // For 'data' column, we'll handle it separately at the end + if ($columnName === 'data') { + continue; + } + + // Check if value exists in main log first, then in data array + $attributeValue = null; + $hasAttributeValue = false; + + if (isset($log[$columnName])) { + // Value is in main log (e.g., userId, event, resource, etc.) + $attributeValue = $log[$columnName]; + $hasAttributeValue = true; + } elseif (isset($logData[$columnName])) { + // Value is in data array (additional attributes) + $attributeValue = $logData[$columnName]; + $hasAttributeValue = true; + // Remove from non-schema data as it's now a dedicated column + unset($nonSchemaData[$columnName]); + } + + // Validate required attributes + if ($isRequiredAttribute && !$hasAttributeValue) { + throw new \InvalidArgumentException("Required attribute '{$columnName}' is missing in log entry"); + } + + if ($hasAttributeValue) { + $insertColumns[] = $columnName; + $queryParams[$columnName] = $attributeValue; + + // Determine placeholder type based on attribute metadata + if ($isNullableAttribute) { + $valuePlaceholders[] = '{' . $columnName . ':Nullable(String)}'; + } else { + $valuePlaceholders[] = '{' . $columnName . ':String}'; + } + } + } + + // Add the data column with remaining non-schema attributes + $insertColumns[] = 'data'; + $queryParams['data'] = json_encode($nonSchemaData); + $valuePlaceholders[] = '{data:Nullable(String)}'; + if ($this->sharedTables) { - $columns[] = 'tenant'; - $placeholders[] = '{tenant:Nullable(UInt64)}'; - $params['tenant'] = $this->tenant; + $insertColumns[] = 'tenant'; + $valuePlaceholders[] = '{tenant:Nullable(UInt64)}'; + $queryParams['tenant'] = $this->tenant; } $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $insertSql = " INSERT INTO {$escapedDatabaseAndTable} - (" . implode(', ', $columns) . ") + (" . implode(', ', $insertColumns) . ") VALUES ( - " . implode(", ", $placeholders) . " + " . implode(", ", $valuePlaceholders) . " ) "; - $this->query($insertSql, $params); + $this->query($insertSql, $queryParams); + + // Retrieve the created log using getById to ensure consistency + $createdLog = $this->getById($logId); + if ($createdLog === null) { + throw new Exception("Failed to retrieve created log with ID: {$logId}"); + } + + return $createdLog; + } + + /** + * Get a single log by its ID. + * + * @param string $id + * @return Log|null The log entry or null if not found + * @throws Exception + */ + public function getById(string $id): ?Log + { + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $escapedId = $this->escapeIdentifier('id'); + + $sql = " + SELECT " . $this->getSelectColumns() . " + FROM {$escapedTable} + WHERE {$escapedId} = {id:String}{$tenantFilter} + LIMIT 1 + FORMAT TabSeparated + "; + + $result = $this->query($sql, ['id' => $id]); + $logs = $this->parseResults($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(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + // Parse queries + $parsed = $this->parseQueries($queries); + + // Build SELECT clause + $selectColumns = $this->getSelectColumns(); + + // Build WHERE clause + $whereClause = ''; + $tenantFilter = $this->getTenantFilter(); + if (!empty($parsed['filters']) || $tenantFilter) { + $conditions = $parsed['filters']; + if ($tenantFilter) { + $conditions[] = ltrim($tenantFilter, ' AND'); + } + $whereClause = ' WHERE ' . implode(' AND ', $conditions); + } + + // Build ORDER BY clause + $orderClause = ''; + if (!empty($parsed['orderBy'])) { + $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + } + + // Build LIMIT and OFFSET + $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; + $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + + $sql = " + SELECT {$selectColumns} + FROM {$escapedTable}{$whereClause}{$orderClause}{$limitClause}{$offsetClause} + FORMAT TabSeparated + "; + + $result = $this->query($sql, $parsed['params']); + return $this->parseResults($result); + } + + /** + * Count logs using Query objects. + * + * @param array $queries + * @return int + * @throws Exception + */ + public function count(array $queries = []): int + { + $tableName = $this->getTableName(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + // Parse queries - we only need filters and params, not ordering/limit/offset + $parsed = $this->parseQueries($queries); + + // Build WHERE clause + $whereClause = ''; + $tenantFilter = $this->getTenantFilter(); + if (!empty($parsed['filters']) || $tenantFilter) { + $conditions = $parsed['filters']; + if ($tenantFilter) { + $conditions[] = ltrim($tenantFilter, ' AND'); + } + $whereClause = ' WHERE ' . implode(' AND ', $conditions); + } + + // Remove limit and offset from params as they don't apply to count + $params = $parsed['params']; + unset($params['limit'], $params['offset']); + + $sql = " + SELECT COUNT(*) as count + FROM {$escapedTable}{$whereClause} + FORMAT TabSeparated + "; + + $result = $this->query($sql, $params); + $trimmed = trim($result); + + return $trimmed !== '' ? (int) $trimmed : 0; + } + + /** + * Parse Query objects into SQL components. + * + * @param array $queries + * @return array{filters: array, params: array, orderBy?: array, limit?: int, offset?: int} + * @throws Exception + */ + private function parseQueries(array $queries): array + { + $filters = []; + $params = []; + $orderBy = []; + $limit = null; + $offset = null; + $paramCounter = 0; + + foreach ($queries as $query) { + if (!$query instanceof Query) { + /** @phpstan-ignore-next-line ternary.alwaysTrue - runtime validation despite type hint */ + $type = is_object($query) ? get_class($query) : gettype($query); + throw new \InvalidArgumentException("Invalid query item: expected instance of Query, got {$type}"); + } + + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + $values = $query->getValues(); + + switch ($method) { + case Query::TYPE_EQUAL: + $this->validateAttributeName($attribute); + $escapedAttr = $this->escapeIdentifier($attribute); + $paramName = 'param_' . $paramCounter++; + $filters[] = "{$escapedAttr} = {{$paramName}:String}"; + $params[$paramName] = $this->formatParamValue($values[0]); + break; + + case Query::TYPE_LESSER: + $this->validateAttributeName($attribute); + $escapedAttr = $this->escapeIdentifier($attribute); + $paramName = 'param_' . $paramCounter++; + if ($attribute === 'time') { + $filters[] = "{$escapedAttr} < {{$paramName}:DateTime64(3)}"; + $params[$paramName] = $this->formatDateTime($values[0]); + } else { + $filters[] = "{$escapedAttr} < {{$paramName}:String}"; + $params[$paramName] = $this->formatParamValue($values[0]); + } + break; + + case Query::TYPE_GREATER: + $this->validateAttributeName($attribute); + $escapedAttr = $this->escapeIdentifier($attribute); + $paramName = 'param_' . $paramCounter++; + if ($attribute === 'time') { + $filters[] = "{$escapedAttr} > {{$paramName}:DateTime64(3)}"; + $params[$paramName] = $this->formatDateTime($values[0]); + } else { + $filters[] = "{$escapedAttr} > {{$paramName}:String}"; + $params[$paramName] = $this->formatParamValue($values[0]); + } + break; + + case Query::TYPE_BETWEEN: + $this->validateAttributeName($attribute); + $escapedAttr = $this->escapeIdentifier($attribute); + $paramName1 = 'param_' . $paramCounter++; + $paramName2 = 'param_' . $paramCounter++; + // Use DateTime64 type for time column, String for others + // This prevents type mismatch when comparing DateTime64 with timezone-suffixed strings + if ($attribute === 'time') { + $paramType = 'DateTime64(3)'; + $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:{$paramType}} AND {{$paramName2}:{$paramType}}"; + $params[$paramName1] = $this->formatDateTime($values[0]); + $params[$paramName2] = $this->formatDateTime($values[1]); + } else { + $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:String} AND {{$paramName2}:String}"; + $params[$paramName1] = $this->formatParamValue($values[0]); + $params[$paramName2] = $this->formatParamValue($values[1]); + } + break; + + case Query::TYPE_IN: + $this->validateAttributeName($attribute); + $escapedAttr = $this->escapeIdentifier($attribute); + $inParams = []; + foreach ($values as $value) { + $paramName = 'param_' . $paramCounter++; + $inParams[] = "{{$paramName}:String}"; + $params[$paramName] = $this->formatParamValue($value); + } + $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; + break; + + case Query::TYPE_ORDER_DESC: + $this->validateAttributeName($attribute); + $escapedAttr = $this->escapeIdentifier($attribute); + $orderBy[] = "{$escapedAttr} DESC"; + break; + + case Query::TYPE_ORDER_ASC: + $this->validateAttributeName($attribute); + $escapedAttr = $this->escapeIdentifier($attribute); + $orderBy[] = "{$escapedAttr} ASC"; + break; + + case Query::TYPE_LIMIT: + if (!\is_int($values[0])) { + throw new \Exception('Invalid limit value. Expected int'); + } + $limit = $values[0]; + $params['limit'] = $limit; + break; + + case Query::TYPE_OFFSET: + if (!\is_int($values[0])) { + throw new \Exception('Invalid offset value. Expected int'); + } + $offset = $values[0]; + $params['offset'] = $offset; + break; + } + } $result = [ - '$id' => $id, - 'userId' => $log['userId'] ?? null, - 'event' => $log['event'], - 'resource' => $log['resource'], - 'userAgent' => $log['userAgent'], - 'ip' => $log['ip'], - 'location' => $log['location'] ?? null, - 'time' => $time, - 'data' => $log['data'] ?? [], + 'filters' => $filters, + 'params' => $params, ]; - if ($this->sharedTables) { - $result['tenant'] = $this->tenant; + if (!empty($orderBy)) { + $result['orderBy'] = $orderBy; + } + + if ($limit !== null) { + $result['limit'] = $limit; + } + + if ($offset !== null) { + $result['offset'] = $offset; } - return new Log($result); + return $result; } /** * Create multiple audit log entries in batch. * + * @param array> $logs The logs to insert * @throws Exception */ public function createBatch(array $logs): bool @@ -509,81 +1118,155 @@ public function createBatch(array $logs): bool $tableName = $this->getTableName(); $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - // Build column list based on sharedTables setting - $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data']; + // Get all attribute column names + $schemaColumns = $this->getColumnNames(); + + // Process each log to extract additional attributes from data + $processedLogs = []; + foreach ($logs as $log) { + /** @var array $logData */ + $logData = $log['data'] ?? []; + + // Separate data for non-schema attributes + $nonSchemaData = $logData; + $processedLog = $log; + + // Extract schema attributes: check main log first, then data array + foreach ($schemaColumns as $columnName) { + if ($columnName === 'data' || $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($logData[$columnName])) { + // If in both, main log takes precedence, remove from data + unset($nonSchemaData[$columnName]); + } + } + + // Update data with remaining non-schema attributes + $processedLog['data'] = $nonSchemaData; + $processedLogs[] = $processedLog; + } + + // Build column list starting with id and time + $insertColumns = ['id', 'time']; + + // Determine which attribute columns are present in any log + $presentColumns = []; + foreach ($processedLogs as $processedLog) { + foreach ($schemaColumns as $columnName) { + if ($columnName === 'time') { + continue; // Already in insertColumns + } + if (isset($processedLog[$columnName]) && !in_array($columnName, $presentColumns, true)) { + $presentColumns[] = $columnName; + } + } + } + + // Add present columns in the order they're defined in attributes + foreach ($schemaColumns as $columnName) { + if ($columnName === 'time') { + continue; // Already added + } + if (in_array($columnName, $presentColumns, true)) { + $insertColumns[] = $columnName; + } + } + if ($this->sharedTables) { - $columns[] = 'tenant'; + $insertColumns[] = 'tenant'; } - $ids = []; $paramCounter = 0; - $params = []; + $queryParams = []; $valueClauses = []; - foreach ($logs as $log) { - $id = uniqid('', true); - $ids[] = $id; - - // Create parameter placeholders for this row - $paramKeys = []; - $paramKeys[] = 'id_' . $paramCounter; - $paramKeys[] = 'userId_' . $paramCounter; - $paramKeys[] = 'event_' . $paramCounter; - $paramKeys[] = 'resource_' . $paramCounter; - $paramKeys[] = 'userAgent_' . $paramCounter; - $paramKeys[] = 'ip_' . $paramCounter; - $paramKeys[] = 'location_' . $paramCounter; - $paramKeys[] = 'time_' . $paramCounter; - $paramKeys[] = 'data_' . $paramCounter; - - // Set parameter values - $params[$paramKeys[0]] = $id; - $params[$paramKeys[1]] = $log['userId'] ?? null; - $params[$paramKeys[2]] = $log['event']; - $params[$paramKeys[3]] = $log['resource']; - $params[$paramKeys[4]] = $log['userAgent']; - $params[$paramKeys[5]] = $log['ip']; - $params[$paramKeys[6]] = $log['location'] ?? null; - - $time = $log['time'] ?? new \DateTime(); - if (is_string($time)) { - $time = new \DateTime($time); - } - $params[$paramKeys[7]] = $time->format('Y-m-d H:i:s.v'); - $params[$paramKeys[8]] = json_encode($log['data'] ?? []); + foreach ($processedLogs as $processedLog) { + $logId = uniqid('', true); + $valuePlaceholders = []; + + // Add id + $paramKey = 'id_' . $paramCounter; + $queryParams[$paramKey] = $logId; + $valuePlaceholders[] = '{' . $paramKey . ':String}'; + + // Add time + /** @var string|\DateTime|null $providedTime */ + $providedTime = $processedLog['time'] ?? null; + $formattedTime = $this->formatDateTime($providedTime); + $paramKey = 'time_' . $paramCounter; + $queryParams[$paramKey] = $formattedTime; + $valuePlaceholders[] = '{' . $paramKey . ':String}'; + + // Add all other present columns + foreach ($insertColumns as $columnName) { + if ($columnName === 'id' || $columnName === 'time' || $columnName === 'tenant') { + continue; // Already handled + } - if ($this->sharedTables) { - $paramKeys[] = 'tenant_' . $paramCounter; - $params[$paramKeys[9]] = $this->tenant; - } + $paramKey = $columnName . '_' . $paramCounter; + + // Get attribute metadata to determine if required and nullable + $attributeMetadata = $this->getAttribute($columnName); + $isRequiredAttribute = $attributeMetadata !== null && isset($attributeMetadata['required']) && $attributeMetadata['required']; + $isNullableAttribute = $attributeMetadata !== null && (!isset($attributeMetadata['required']) || !$attributeMetadata['required']); + + $attributeValue = null; + $hasAttributeValue = false; + + if ($columnName === 'data') { + // Data column - encode as JSON + /** @var array $dataValue */ + $dataValue = $processedLog['data']; + $attributeValue = json_encode($dataValue); + $hasAttributeValue = true; + } elseif (isset($processedLog[$columnName])) { + $attributeValue = $processedLog[$columnName]; + $hasAttributeValue = true; + } + + // Validate required attributes + if ($isRequiredAttribute && !$hasAttributeValue) { + throw new \InvalidArgumentException("Required attribute '{$columnName}' is missing in batch log entry"); + } + + $queryParams[$paramKey] = $attributeValue; - // Build placeholder string for this row - $placeholders = []; - for ($i = 0; $i < count($paramKeys); $i++) { - if ($i === 1 || $i === 6) { // userId and location are nullable - $placeholders[] = '{' . $paramKeys[$i] . ':Nullable(String)}'; - } elseif ($this->sharedTables && $i === 9) { // tenant is nullable UInt64 - $placeholders[] = '{' . $paramKeys[$i] . ':Nullable(UInt64)}'; + // Determine placeholder type based on attribute metadata + if ($isNullableAttribute) { + $valuePlaceholders[] = '{' . $paramKey . ':Nullable(String)}'; } else { - $placeholders[] = '{' . $paramKeys[$i] . ':String}'; + $valuePlaceholders[] = '{' . $paramKey . ':String}'; } } - $valueClauses[] = '(' . implode(', ', $placeholders) . ')'; + if ($this->sharedTables) { + $paramKey = 'tenant_' . $paramCounter; + $queryParams[$paramKey] = $this->tenant; + $valuePlaceholders[] = '{' . $paramKey . ':Nullable(UInt64)}'; + } + + $valueClauses[] = '(' . implode(', ', $valuePlaceholders) . ')'; $paramCounter++; } $insertSql = " INSERT INTO {$escapedDatabaseAndTable} - (" . implode(', ', $columns) . ") + (" . implode(', ', $insertColumns) . ") VALUES " . implode(', ', $valueClauses); - $this->query($insertSql, $params); + $this->query($insertSql, $queryParams); return true; } /** * Parse ClickHouse query result into Log objects. + * Dynamically maps columns based on current attribute definitions. * * @return array */ @@ -596,28 +1279,32 @@ private function parseResults(string $result): array $lines = explode("\n", trim($result)); $documents = []; + // Build the expected column order dynamically (matching getSelectColumns order) + $selectColumns = ['id']; + foreach ($this->getAttributes() as $attribute) { + $id = $attribute['$id']; + if ($id !== 'data') { + $selectColumns[] = $id; + } + } + $selectColumns[] = 'data'; + + if ($this->sharedTables) { + $selectColumns[] = 'tenant'; + } + + $expectedColumns = count($selectColumns); + foreach ($lines as $line) { if (empty(trim($line))) { continue; } $columns = explode("\t", $line); - // Expect 9 columns without sharedTables, 10 with sharedTables - $expectedColumns = $this->sharedTables ? 10 : 9; if (count($columns) < $expectedColumns) { continue; } - $data = json_decode($columns[8], true) ?? []; - - // Convert ClickHouse timestamp format back to ISO 8601 - // ClickHouse: 2025-12-07 23:33:54.493 - // ISO 8601: 2025-12-07T23:33:54.493+00:00 - $time = $columns[7]; - if (strpos($time, 'T') === false) { - $time = str_replace(' ', 'T', $time) . '+00:00'; - } - // Helper function to parse nullable string fields // ClickHouse TabSeparated format uses \N for NULL, but empty strings are also treated as null for nullable fields $parseNullableString = static function ($value): ?string { @@ -627,21 +1314,47 @@ private function parseResults(string $result): array return $value; }; - $document = [ - '$id' => $columns[0], - 'userId' => $parseNullableString($columns[1]), - 'event' => $columns[2], - 'resource' => $columns[3], - 'userAgent' => $columns[4], - 'ip' => $columns[5], - 'location' => $parseNullableString($columns[6]), - 'time' => $time, - 'data' => $data, - ]; - - // Add tenant only if sharedTables is enabled - if ($this->sharedTables && isset($columns[9])) { - $document['tenant'] = $columns[9] === '\\N' || $columns[9] === '' ? null : (int) $columns[9]; + // Build document dynamically by mapping columns to values + $document = []; + foreach ($selectColumns as $index => $columnName) { + if (!isset($columns[$index])) { + continue; + } + + $value = $columns[$index]; + + if ($columnName === 'data') { + // Decode JSON data column + $document[$columnName] = json_decode($value, true) ?? []; + } elseif ($columnName === 'tenant') { + // Parse tenant as integer or null + $document[$columnName] = ($value === '\\N' || $value === '') ? null : (int) $value; + } elseif ($columnName === 'time') { + // Convert ClickHouse timestamp format back to ISO 8601 + // ClickHouse: 2025-12-07 23:33:54.493 + // ISO 8601: 2025-12-07T23:33:54.493+00:00 + $parsedTime = $value; + if (strpos($parsedTime, 'T') === false) { + $parsedTime = str_replace(' ', 'T', $parsedTime) . '+00:00'; + } + $document[$columnName] = $parsedTime; + } else { + // Get attribute metadata to check if nullable + $attribute = $this->getAttribute($columnName); + if ($attribute && !$attribute['required']) { + // Nullable field - parse null values + $document[$columnName] = $parseNullableString($value); + } else { + // Required field - use value as-is + $document[$columnName] = $value; + } + } + } + + // Add special $id field if present + if (isset($document['id'])) { + $document['$id'] = $document['id']; + unset($document['id']); } $documents[] = new Log($document); @@ -652,20 +1365,40 @@ private function parseResults(string $result): array /** * Get the SELECT column list for queries. - * Returns 9 columns if not using shared tables, 10 if using shared tables. + * Dynamically builds the column list from attributes, excluding 'data' column. + * Escapes all column names to prevent SQL injection. * * @return string */ 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']; + 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) { - return 'id, userId, event, resource, userAgent, ip, location, time, data, tenant'; + $columns[] = $this->escapeIdentifier('tenant'); } - return 'id, userId, event, resource, userAgent, ip, location, time, data'; + + return implode(', ', $columns); } /** * Build tenant filter clause based on current tenant context. + * Escapes column name to prevent SQL injection. * * @return string */ @@ -675,11 +1408,13 @@ private function getTenantFilter(): string return ''; } - return " AND tenant = {$this->tenant}"; + $escapedTenant = $this->escapeIdentifier('tenant'); + return " AND {$escapedTenant} = {$this->tenant}"; } /** * Build time WHERE clause and parameters with safe parameter placeholders. + * Escapes column name to prevent SQL injection. * * @param \DateTime|null $after * @param \DateTime|null $before @@ -703,8 +1438,10 @@ private function buildTimeClause(?\DateTime $after, ?\DateTime $before): array $beforeStr = \Utopia\Database\DateTime::format($before); } + $escapedTime = $this->escapeIdentifier('time'); + if ($afterStr !== null && $beforeStr !== null) { - $conditions[] = 'time BETWEEN {after:String} AND {before:String}'; + $conditions[] = "{$escapedTime} BETWEEN {after:String} AND {before:String}"; $params['after'] = $afterStr; $params['before'] = $beforeStr; @@ -712,12 +1449,12 @@ private function buildTimeClause(?\DateTime $after, ?\DateTime $before): array } if ($afterStr !== null) { - $conditions[] = 'time > {after:String}'; + $conditions[] = "{$escapedTime} > {after:String}"; $params['after'] = $afterStr; } if ($beforeStr !== null) { - $conditions[] = 'time < {before:String}'; + $conditions[] = "{$escapedTime} < {before:String}"; $params['before'] = $beforeStr; } @@ -759,6 +1496,9 @@ private function buildEventsList(array $events, int $paramOffset = 0): array /** * 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 @@ -771,12 +1511,11 @@ protected function getColumnDefinition(string $id): string throw new Exception("Attribute {$id} not found"); } - // ClickHouse-specific type mapping - $type = match ($id) { - 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'data' => 'String', - 'time' => 'DateTime64(3)', - default => 'String', - }; + // 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'; $nullable = !$attribute['required'] ? 'Nullable(' . $type . ')' : $type; @@ -796,29 +1535,23 @@ public function getByUser( int $offset = 0, bool $ascending = false, ): array { - $time = $this->buildTimeClause($after, $before); - $order = $ascending ? 'ASC' : 'DESC'; - - $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $queries = [ + Query::equal('userId', $userId), + ]; - $sql = " - SELECT " . $this->getSelectColumns() . " - FROM {$escapedTable} - WHERE userId = {userId:String}{$tenantFilter}{$time['clause']} - ORDER BY time {$order} - LIMIT {limit:UInt64} OFFSET {offset:UInt64} - FORMAT TabSeparated - "; + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + } elseif ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } elseif ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } - $result = $this->query($sql, array_merge([ - 'userId' => $userId, - 'limit' => $limit, - 'offset' => $offset, - ], $time['params'])); + $queries[] = $ascending ? Query::orderAsc('time') : Query::orderDesc('time'); + $queries[] = Query::limit($limit); + $queries[] = Query::offset($offset); - return $this->parseResults($result); + return $this->find($queries); } /** @@ -831,24 +1564,19 @@ public function countByUser( ?\DateTime $after = null, ?\DateTime $before = null, ): int { - $time = $this->buildTimeClause($after, $before); - - $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - - $sql = " - SELECT count() - FROM {$escapedTable} - WHERE userId = {userId:String}{$tenantFilter}{$time['clause']} - FORMAT TabSeparated - "; + $queries = [ + Query::equal('userId', $userId), + ]; - $result = $this->query($sql, array_merge([ - 'userId' => $userId, - ], $time['params'])); + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + } elseif ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } elseif ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } - return (int) trim($result); + return count($this->find($queries)); } /** @@ -864,29 +1592,23 @@ public function getByResource( int $offset = 0, bool $ascending = false, ): array { - $time = $this->buildTimeClause($after, $before); - $order = $ascending ? 'ASC' : 'DESC'; - - $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $queries = [ + Query::equal('resource', $resource), + ]; - $sql = " - SELECT " . $this->getSelectColumns() . " - FROM {$escapedTable} - WHERE resource = {resource:String}{$tenantFilter}{$time['clause']} - ORDER BY time {$order} - LIMIT {limit:UInt64} OFFSET {offset:UInt64} - FORMAT TabSeparated - "; + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + } elseif ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } elseif ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } - $result = $this->query($sql, array_merge([ - 'resource' => $resource, - 'limit' => $limit, - 'offset' => $offset, - ], $time['params'])); + $queries[] = $ascending ? Query::orderAsc('time') : Query::orderDesc('time'); + $queries[] = Query::limit($limit); + $queries[] = Query::offset($offset); - return $this->parseResults($result); + return $this->find($queries); } /** @@ -899,24 +1621,19 @@ public function countByResource( ?\DateTime $after = null, ?\DateTime $before = null, ): int { - $time = $this->buildTimeClause($after, $before); - - $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - - $sql = " - SELECT count() - FROM {$escapedTable} - WHERE resource = {resource:String}{$tenantFilter}{$time['clause']} - FORMAT TabSeparated - "; + $queries = [ + Query::equal('resource', $resource), + ]; - $result = $this->query($sql, array_merge([ - 'resource' => $resource, - ], $time['params'])); + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + } elseif ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } elseif ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } - return (int) trim($result); + return count($this->find($queries)); } /** @@ -933,29 +1650,24 @@ public function getByUserAndEvents( int $offset = 0, bool $ascending = false, ): array { - $time = $this->buildTimeClause($after, $before); - $order = $ascending ? 'ASC' : 'DESC'; - $eventList = $this->buildEventsList($events, 0); - $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $queries = [ + Query::equal('userId', $userId), + Query::in('event', $events), + ]; - $sql = " - SELECT " . $this->getSelectColumns() . " - FROM {$escapedTable} - WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} - ORDER BY time {$order} - LIMIT {limit:UInt64} OFFSET {offset:UInt64} - FORMAT TabSeparated - "; + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + } elseif ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } elseif ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } - $result = $this->query($sql, array_merge([ - 'userId' => $userId, - 'limit' => $limit, - 'offset' => $offset, - ], $eventList['params'], $time['params'])); + $queries[] = $ascending ? Query::orderAsc('time') : Query::orderDesc('time'); + $queries[] = Query::limit($limit); + $queries[] = Query::offset($offset); - return $this->parseResults($result); + return $this->find($queries); } /** @@ -969,24 +1681,20 @@ public function countByUserAndEvents( ?\DateTime $after = null, ?\DateTime $before = null, ): int { - $time = $this->buildTimeClause($after, $before); - $eventList = $this->buildEventsList($events, 0); - $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - - $sql = " - SELECT count() - FROM {$escapedTable} - WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} - FORMAT TabSeparated - "; + $queries = [ + Query::equal('userId', $userId), + Query::in('event', $events), + ]; - $result = $this->query($sql, array_merge([ - 'userId' => $userId, - ], $eventList['params'], $time['params'])); + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + } elseif ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } elseif ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } - return (int) trim($result); + return count($this->find($queries)); } /** @@ -1003,29 +1711,24 @@ public function getByResourceAndEvents( int $offset = 0, bool $ascending = false, ): array { - $time = $this->buildTimeClause($after, $before); - $order = $ascending ? 'ASC' : 'DESC'; - $eventList = $this->buildEventsList($events, 0); - $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $queries = [ + Query::equal('resource', $resource), + Query::in('event', $events), + ]; - $sql = " - SELECT " . $this->getSelectColumns() . " - FROM {$escapedTable} - WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} - ORDER BY time {$order} - LIMIT {limit:UInt64} OFFSET {offset:UInt64} - FORMAT TabSeparated - "; + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + } elseif ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } elseif ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } - $result = $this->query($sql, array_merge([ - 'resource' => $resource, - 'limit' => $limit, - 'offset' => $offset, - ], $eventList['params'], $time['params'])); + $queries[] = $ascending ? Query::orderAsc('time') : Query::orderDesc('time'); + $queries[] = Query::limit($limit); + $queries[] = Query::offset($offset); - return $this->parseResults($result); + return $this->find($queries); } /** @@ -1039,24 +1742,20 @@ public function countByResourceAndEvents( ?\DateTime $after = null, ?\DateTime $before = null, ): int { - $time = $this->buildTimeClause($after, $before); - $eventList = $this->buildEventsList($events, 0); - $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - - $sql = " - SELECT count() - FROM {$escapedTable} - WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} - FORMAT TabSeparated - "; + $queries = [ + Query::equal('resource', $resource), + Query::in('event', $events), + ]; - $result = $this->query($sql, array_merge([ - 'resource' => $resource, - ], $eventList['params'], $time['params'])); + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + } elseif ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } elseif ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } - return (int) trim($result); + return count($this->find($queries)); } /** diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 5ed8d89..e9310ee 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -102,6 +102,26 @@ public function createBatch(array $logs): bool return true; } + /** + * Get a single log by its ID. + * + * @param string $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(function () use ($id) { + return $this->db->getDocument($this->getCollectionName(), $id); + }); + + if ($document->isEmpty()) { + return null; + } + + return new Log($document->getArrayCopy()); + } + /** * Build time-related query conditions. * @@ -435,4 +455,76 @@ protected function getColumnDefinition(string $id): string 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(function () use ($dbQueries) { + return $this->db->find( + collection: $this->getCollectionName(), + queries: $dbQueries, + ); + }); + + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + } + + /** + * Count logs using custom queries. + * + * Translates Audit Query objects to Database Query objects. + * Ignores limit and offset queries as they don't apply to count. + * + * @param array<\Utopia\Audit\Query> $queries + * @return int + * @throws AuthorizationException|\Exception + */ + public function count(array $queries = []): int + { + $dbQueries = []; + + foreach ($queries as $query) { + if (!($query instanceof \Utopia\Audit\Query)) { + throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); + } + + // Skip limit and offset for count queries + $method = $query->getMethod(); + if ($method === \Utopia\Audit\Query::TYPE_LIMIT || $method === \Utopia\Audit\Query::TYPE_OFFSET) { + 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(function () use ($dbQueries) { + return $this->db->count( + collection: $this->getCollectionName(), + queries: $dbQueries, + ); + }); + } } diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index f4c0f33..bb17f83 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -60,7 +60,8 @@ public function setup(): void */ public function log(?string $userId, string $event, string $resource, string $userAgent, string $ip, string $location, array $data = []): Log { - return $this->adapter->create([ + /** @var array{userId?: string|null, event: string, resource: string, userAgent: string, ip: string, location?: string, data?: array} $log */ + $log = [ 'userId' => $userId, 'event' => $event, 'resource' => $resource, @@ -68,7 +69,9 @@ public function log(?string $userId, string $event, string $resource, string $us 'ip' => $ip, 'location' => $location, 'data' => $data, - ]); + ]; + + return $this->adapter->create($log); } /** @@ -84,6 +87,19 @@ public function logBatch(array $events): bool return $this->adapter->createBatch($events); } + /** + * Get a single log by its ID. + * + * @param string $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. * @@ -243,4 +259,30 @@ 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 + * @return int + * + * @throws \Exception + */ + public function count(array $queries = []): int + { + return $this->adapter->count($queries); + } } diff --git a/src/Audit/Query.php b/src/Audit/Query.php new file mode 100644 index 0000000..ebc7d7a --- /dev/null +++ b/src/Audit/Query.php @@ -0,0 +1,288 @@ + + */ + protected array $values = []; + + /** + * Construct a new query object + * + * @param string $method + * @param string $attribute + * @param array $values + */ + public function __construct(string $method, string $attribute = '', array $values = []) + { + $this->method = $method; + $this->attribute = $attribute; + $this->values = $values; + } + + /** + * @return string + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * @return string + */ + public function getAttribute(): string + { + return $this->attribute; + } + + /** + * @return array + */ + public function getValues(): array + { + return $this->values; + } + + /** + * @param mixed $default + * @return mixed + */ + public function getValue(mixed $default = null): mixed + { + return $this->values[0] ?? $default; + } + + /** + * Filter by equal condition + * + * @param string $attribute + * @param mixed $value + * @return self + */ + public static function equal(string $attribute, mixed $value): self + { + return new self(self::TYPE_EQUAL, $attribute, [$value]); + } + + /** + * Filter by less than condition + * + * @param string $attribute + * @param mixed $value + * @return self + */ + public static function lessThan(string $attribute, mixed $value): self + { + return new self(self::TYPE_LESSER, $attribute, [$value]); + } + + /** + * Filter by greater than condition + * + * @param string $attribute + * @param mixed $value + * @return self + */ + public static function greaterThan(string $attribute, mixed $value): self + { + return new self(self::TYPE_GREATER, $attribute, [$value]); + } + + /** + * Filter by BETWEEN condition + * + * @param string $attribute + * @param mixed $start + * @param mixed $end + * @return self + */ + public static function between(string $attribute, mixed $start, mixed $end): self + { + return new self(self::TYPE_BETWEEN, $attribute, [$start, $end]); + } + + /** + * Filter by IN condition + * + * @param string $attribute + * @param array $values + * @return self + */ + public static function in(string $attribute, array $values): self + { + return new self(self::TYPE_IN, $attribute, $values); + } + + /** + * Order by descending + * + * @param string $attribute + * @return self + */ + public static function orderDesc(string $attribute = 'time'): self + { + return new self(self::TYPE_ORDER_DESC, $attribute); + } + + /** + * Order by ascending + * + * @param string $attribute + * @return self + */ + public static function orderAsc(string $attribute = 'time'): self + { + return new self(self::TYPE_ORDER_ASC, $attribute); + } + + /** + * Limit number of results + * + * @param int $limit + * @return self + */ + public static function limit(int $limit): self + { + return new self(self::TYPE_LIMIT, '', [$limit]); + } + + /** + * Offset results + * + * @param int $offset + * @return self + */ + public static function offset(int $offset): self + { + return new self(self::TYPE_OFFSET, '', [$offset]); + } + + /** + * Parse query from JSON string + * + * @param string $query + * @return self + * @throws \Exception + */ + public static function parse(string $query): self + { + try { + $query = \json_decode($query, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \Exception('Invalid query: ' . $e->getMessage()); + } + + if (!\is_array($query)) { + throw new \Exception('Invalid query. Must be an array, got ' . \gettype($query)); + } + + return self::parseQuery($query); + } + + /** + * Parse an array of queries + * + * @param array $queries + * @return array + * @throws \Exception + */ + public static function parseQueries(array $queries): array + { + $parsed = []; + + foreach ($queries as $query) { + $parsed[] = self::parse($query); + } + + return $parsed; + } + + /** + * Parse query from array + * + * @param array $query + * @return self + * @throws \Exception + */ + protected static function parseQuery(array $query): self + { + $method = $query['method'] ?? ''; + $attribute = $query['attribute'] ?? ''; + $values = $query['values'] ?? []; + + if (!\is_string($method)) { + throw new \Exception('Invalid query method. Must be a string, got ' . \gettype($method)); + } + + if (!\is_string($attribute)) { + throw new \Exception('Invalid query attribute. Must be a string, got ' . \gettype($attribute)); + } + + if (!\is_array($values)) { + throw new \Exception('Invalid query values. Must be an array, got ' . \gettype($values)); + } + + return new self($method, $attribute, $values); + } + + /** + * Convert query to array + * + * @return array + */ + public function toArray(): array + { + $array = ['method' => $this->method]; + + if (!empty($this->attribute)) { + $array['attribute'] = $this->attribute; + } + + $array['values'] = $this->values; + + return $array; + } + + /** + * Convert query to JSON string + * + * @return string + * @throws \Exception + */ + public function toString(): string + { + try { + return \json_encode($this->toArray(), flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \Exception('Invalid Json: ' . $e->getMessage()); + } + } +} diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index 2872fbd..96c40d4 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -33,6 +33,25 @@ protected function initializeAudit(): void $this->audit->setup(); } + /** + * Provide required attributes for ClickHouse adapter tests. + * + * @return array + */ + protected function getRequiredAttributes(): array + { + return [ + 'userType' => 'member', + 'resourceType' => 'document', + 'resourceId' => 'res-1', + 'projectId' => 'proj-1', + 'projectInternalId' => 'proj-int-1', + 'teamId' => 'team-1', + 'teamInternalId' => 'team-int-1', + 'hostname' => 'example.org', + ]; + } + /** * Test constructor validates host */ @@ -316,4 +335,82 @@ public function testBatchOperationsWithSpecialCharacters(): void $logs = $this->audit->getLogsByUser('user`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 ($attr) => $attr['$id'], $attributes); + + // Verify all expected attributes exist + $expectedAttributes = [ + 'userType', + 'userId', + 'userInternalId', + 'resourceParent', + 'resourceType', + 'resourceId', + 'resourceInternalId', + 'event', + 'resource', + 'userAgent', + 'ip', + 'country', + 'time', + 'data', + 'projectId', + 'projectInternalId', + 'teamId', + 'teamInternalId', + 'hostname' + ]; + + foreach ($expectedAttributes as $expected) { + $this->assertContains($expected, $attributeIds, "Attribute '{$expected}' not found in ClickHouse adapter"); + } + } + + /** + * 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 ($idx) => $idx['$id'], $indexes); + + // Verify all ClickHouse-specific indexes exist + $expectedClickHouseIndexes = [ + '_key_user_internal_and_event', + '_key_project_internal_id', + '_key_team_internal_id', + '_key_user_internal_id', + '_key_user_type', + '_key_country', + '_key_hostname' + ]; + + 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_userId_event', 'idx_resource_event', 'idx_time_desc']; + foreach ($parentExpectedIndexes as $expected) { + $this->assertContains($expected, $indexIds, "Parent index '{$expected}' not found in ClickHouse adapter"); + } + } } diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 25b90cb..cf6b42b 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -54,10 +54,13 @@ public function createLogs(): void $location = 'US'; $data = ['key1' => 'value1', 'key2' => 'value2']; - $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); - $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); - $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); - $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $location, $data)); + $requiredAttributes = $this->getRequiredAttributes(); + $dataWithAttributes = array_merge($data, $requiredAttributes); + + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $dataWithAttributes)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $dataWithAttributes)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $dataWithAttributes)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $location, $dataWithAttributes)); } public function testGetLogsByUser(): void @@ -155,6 +158,38 @@ public function testGetLogsByResource(): void $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'; + $location = 'US'; + $data = ['test' => 'getById']; + + $requiredAttributes = $this->getRequiredAttributes(); + $dataWithAttributes = array_merge($data, $requiredAttributes); + $log = $this->audit->log($userId, 'create', 'test/resource/123', $userAgent, $ip, $location, $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($location, $retrievedLog->getAttribute('location')); + $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 @@ -213,7 +248,11 @@ public function testLogByBatch(): void ] ]; + $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); + // Test batch insertion + $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); + $result = $this->audit->logBatch($batchEvents); $this->assertTrue($result); @@ -294,6 +333,7 @@ public function testLargeBatchInsert(): void } // Insert batch + $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); $result = $this->audit->logBatch($batchEvents); $this->assertTrue($result); @@ -338,6 +378,7 @@ public function testTimeRangeFilters(): void ] ]; + $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); $this->audit->logBatch($batchEvents); // Test getting all logs @@ -366,11 +407,14 @@ public function testCleanup(): void $location = 'US'; $data = ['key1' => 'value1', 'key2' => 'value2']; - $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); + $requiredAttributes = $this->getRequiredAttributes(); + $dataWithAttributes = array_merge($data, $requiredAttributes); + + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $dataWithAttributes)); sleep(5); - $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $dataWithAttributes)); sleep(5); - $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $dataWithAttributes)); sleep(5); // DELETE logs older than 11 seconds and check that status is true @@ -417,6 +461,8 @@ public function testRetrievalParameters(): void ]; } + $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); + $this->audit->logBatch($batchEvents); // Test 1: limit parameter @@ -556,4 +602,237 @@ public function testRetrievalParameters(): void ); $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'; + $location = 'US'; + + // Create test logs with specific attributes + $baseTime = 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, + 'location' => $location, + '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)) { + for ($i = 0; $i < \count($logsDesc); $i++) { + $this->assertEquals( + $logsDesc[$i]->getId(), + $logsAsc[\count($logsAsc) - 1 - $i]->getId() + ); + } + } + + // Test 6: Find with IN filter + $logs = $this->audit->find([ + \Utopia\Audit\Query::in('event', ['event_0', 'event_1']), + ]); + $this->assertGreaterThanOrEqual(2, \count($logs)); + + // 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'; + $location = 'US'; + + // Create test logs with specific attributes + $baseTime = 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, + 'location' => $location, + 'data' => ['sequence' => $i], + 'time' => $timestamp + ]; + } + $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 IN filter + $count = $this->audit->count([ + \Utopia\Audit\Query::in('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 === []) { + /** @var array}> */ + return $batchEvents; + } + + /** @var array}> */ + return array_map(static fn (array $event) => 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/tests/Audit/QueryTest.php b/tests/Audit/QueryTest.php new file mode 100644 index 0000000..2dc3fa1 --- /dev/null +++ b/tests/Audit/QueryTest.php @@ -0,0 +1,225 @@ +assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertEquals('userId', $query->getAttribute()); + $this->assertEquals(['123'], $query->getValues()); + + // Test lessThan + $query = Query::lessThan('time', '2024-01-01'); + $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertEquals('time', $query->getAttribute()); + $this->assertEquals(['2024-01-01'], $query->getValues()); + + // Test greaterThan + $query = Query::greaterThan('time', '2023-01-01'); + $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertEquals('time', $query->getAttribute()); + $this->assertEquals(['2023-01-01'], $query->getValues()); + + // Test between + $query = Query::between('time', '2023-01-01', '2024-01-01'); + $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertEquals('time', $query->getAttribute()); + $this->assertEquals(['2023-01-01', '2024-01-01'], $query->getValues()); + + // Test in + $query = Query::in('event', ['create', 'update', 'delete']); + $this->assertEquals(Query::TYPE_IN, $query->getMethod()); + $this->assertEquals('event', $query->getAttribute()); + $this->assertEquals(['create', 'update', 'delete'], $query->getValues()); + + // Test orderDesc + $query = Query::orderDesc('time'); + $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()); + $this->assertEquals('time', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + + // Test orderAsc + $query = Query::orderAsc('userId'); + $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()); + $this->assertEquals('userId', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + + // Test limit + $query = Query::limit(10); + $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([10], $query->getValues()); + + // Test offset + $query = Query::offset(5); + $this->assertEquals(Query::TYPE_OFFSET, $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([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->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertEquals('userId', $query->getAttribute()); + $this->assertEquals(['123'], $query->getValues()); + + // Test toString + $query = Query::equal('event', 'create'); + $json = $query->toString(); + $this->assertJson($json); + + $parsed = Query::parse($json); + $this->assertEquals(Query::TYPE_EQUAL, $parsed->getMethod()); + $this->assertEquals('event', $parsed->getAttribute()); + $this->assertEquals(['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->assertEquals(Query::TYPE_EQUAL, $parsed[0]->getMethod()); + $this->assertEquals(Query::TYPE_GREATER, $parsed[1]->getMethod()); + $this->assertEquals(Query::TYPE_LIMIT, $parsed[2]->getMethod()); + } + + /** + * 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->assertEquals('', $query->getAttribute()); + $this->assertEquals([25], $query->getValues()); + + $query = Query::offset(10); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([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->assertEquals(Query::TYPE_BETWEEN, $parsed->getMethod()); + $this->assertEquals('time', $parsed->getAttribute()); + $this->assertEquals(['2023-01-01', '2024-12-31'], $parsed->getValues()); + } +}