diff --git a/composer.lock b/composer.lock index 1b240b0..e385ac8 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "brick/math", - "version": "0.14.1", + "version": "0.14.3", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "6af96b11de3f7d99730c118c200418c48274edb4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/6af96b11de3f7d99730c118c200418c48274edb4", + "reference": "6af96b11de3f7d99730c118c200418c48274edb4", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.14.3" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2026-02-01T15:18:05+00:00" }, { "name": "composer/semver", @@ -4397,12 +4397,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.0" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 0d598d5..c4fbe96 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -14,15 +14,100 @@ * * This adapter stores audit logs in ClickHouse using HTTP interface. * ClickHouse is optimized for analytical queries and can handle massive amounts of log data. + * + * Features: + * - HTTP compression support (gzip, lz4) for reduced bandwidth + * - Configurable connection timeouts + * - Connection health checking + * - Parameterized queries for SQL injection prevention + * - Multi-tenant support with shared tables */ class ClickHouse extends SQL { + /** + * Default HTTP port for ClickHouse + */ private const DEFAULT_PORT = 8123; + /** + * Default table name for audit logs + */ private const DEFAULT_TABLE = 'audits'; + /** + * Default database name + */ private const DEFAULT_DATABASE = 'default'; + /** + * Default connection timeout in milliseconds + */ + private const DEFAULT_TIMEOUT = 30_000; + + /** + * Minimum allowed timeout in milliseconds + */ + private const MIN_TIMEOUT = 1_000; + + /** + * Maximum allowed timeout in milliseconds (10 minutes) + */ + private const MAX_TIMEOUT = 600_000; + + /** + * Compression type: No compression + */ + public const COMPRESSION_NONE = 'none'; + + /** + * Compression type: gzip compression (best for HTTP) + */ + public const COMPRESSION_GZIP = 'gzip'; + + /** + * Compression type: lz4 compression (fastest, ClickHouse native) + */ + public const COMPRESSION_LZ4 = 'lz4'; + + /** + * Valid compression types + */ + private const VALID_COMPRESSION_TYPES = [ + self::COMPRESSION_NONE, + self::COMPRESSION_GZIP, + self::COMPRESSION_LZ4, + ]; + + /** + * Default maximum retry attempts + */ + private const DEFAULT_MAX_RETRIES = 3; + + /** + * Default retry delay in milliseconds + */ + private const DEFAULT_RETRY_DELAY = 100; + + /** + * Minimum retry delay in milliseconds + */ + private const MIN_RETRY_DELAY = 10; + + /** + * Maximum retry delay in milliseconds + */ + private const MAX_RETRY_DELAY = 5000; + + /** + * Maximum allowed retry attempts + */ + private const MAX_RETRY_ATTEMPTS = 10; + + /** + * HTTP status codes that are retryable + */ + private const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; + private string $host; private int $port; @@ -35,9 +120,51 @@ class ClickHouse extends SQL private string $password; - /** @var bool Whether to use HTTPS for ClickHouse HTTP interface */ + /** + * @var bool Whether to use HTTPS for ClickHouse HTTP interface + */ private bool $secure = false; + /** + * @var string Compression type for HTTP requests/responses + */ + private string $compression = self::COMPRESSION_NONE; + + /** + * @var int Connection timeout in milliseconds + */ + private int $timeout = self::DEFAULT_TIMEOUT; + + /** + * @var int Maximum number of retry attempts for transient failures + */ + private int $maxRetries = self::DEFAULT_MAX_RETRIES; + + /** + * @var int Base delay between retries in milliseconds (doubles with each retry) + */ + private int $retryDelay = self::DEFAULT_RETRY_DELAY; + + /** + * @var bool Whether query logging is enabled + */ + private bool $queryLoggingEnabled = false; + + /** + * @var array, duration: float, timestamp: string, success: bool, error?: string, retries?: int}> Query log entries + */ + private array $queryLog = []; + + /** + * @var int Total number of queries executed + */ + private int $queryCount = 0; + + /** + * @var int Total number of failed queries + */ + private int $failedQueryCount = 0; + private Client $client; protected string $namespace = ''; @@ -47,11 +174,15 @@ class ClickHouse extends SQL protected bool $sharedTables = false; /** - * @param string $host ClickHouse host + * Create a new ClickHouse adapter instance. + * + * @param string $host ClickHouse host (hostname or IP address) * @param string $username ClickHouse username (default: 'default') * @param string $password ClickHouse password (default: '') * @param int $port ClickHouse HTTP port (default: 8123) * @param bool $secure Whether to use HTTPS (default: false) + * @param string $compression Compression type: 'none', 'gzip', or 'lz4' (default: 'none') + * @param int $timeout Connection timeout in milliseconds (default: 30000) * @throws Exception If validation fails */ public function __construct( @@ -59,22 +190,41 @@ public function __construct( string $username = 'default', string $password = '', int $port = self::DEFAULT_PORT, - bool $secure = false + bool $secure = false, + string $compression = self::COMPRESSION_NONE, + int $timeout = self::DEFAULT_TIMEOUT ) { $this->validateHost($host); $this->validatePort($port); + $this->validateCompression($compression); + $this->validateTimeout($timeout); $this->host = $host; $this->port = $port; $this->username = $username; $this->password = $password; $this->secure = $secure; + $this->compression = $compression; + $this->timeout = $timeout; + + $this->initializeClient(); + } - // Initialize the HTTP client for connection reuse + /** + * Initialize the HTTP client with current configuration. + */ + private function initializeClient(): void + { $this->client = new Client(); $this->client->addHeader('X-ClickHouse-User', $this->username); $this->client->addHeader('X-ClickHouse-Key', $this->password); - $this->client->setTimeout(30_000); // 30 seconds + $this->client->setTimeout($this->timeout); + + // Request compressed responses from ClickHouse (safe for all requests) + if ($this->compression !== self::COMPRESSION_NONE) { + $this->client->addHeader('Accept-Encoding', $this->compression); + } + // Note: Content-Encoding is set per-request only when we actually compress the body } /** @@ -112,6 +262,35 @@ private function validatePort(int $port): void } } + /** + * Validate compression parameter. + * + * @param string $compression + * @throws Exception + */ + private function validateCompression(string $compression): void + { + if (!in_array($compression, self::VALID_COMPRESSION_TYPES, true)) { + $validTypes = implode(', ', self::VALID_COMPRESSION_TYPES); + throw new Exception("Invalid compression type '{$compression}'. Valid types are: {$validTypes}"); + } + } + + /** + * Validate timeout parameter. + * + * @param int $timeout + * @throws Exception + */ + private function validateTimeout(int $timeout): void + { + if ($timeout < self::MIN_TIMEOUT || $timeout > self::MAX_TIMEOUT) { + throw new Exception( + "Timeout must be between " . self::MIN_TIMEOUT . " and " . self::MAX_TIMEOUT . " milliseconds" + ); + } + } + /** * Validate identifier (database, table, namespace). * ClickHouse identifiers follow SQL standard rules. @@ -187,6 +366,9 @@ public function setDatabase(string $database): self /** * Enable or disable HTTPS for ClickHouse HTTP interface. + * + * @param bool $secure Whether to use HTTPS + * @return self */ public function setSecure(bool $secure): self { @@ -194,6 +376,317 @@ public function setSecure(bool $secure): self return $this; } + /** + * Set the compression type for HTTP responses. + * + * Compression can significantly reduce bandwidth for query results: + * - 'none': No compression (default) + * - 'gzip': Standard gzip compression, widely supported + * - 'lz4': ClickHouse native compression, fastest decompression + * + * Note: This configures the Accept-Encoding header to request compressed + * responses from ClickHouse. The server will compress query results before + * sending them, reducing network transfer size. + * + * @param string $compression Compression type + * @return self + * @throws Exception If compression type is invalid + */ + public function setCompression(string $compression): self + { + $this->validateCompression($compression); + $this->compression = $compression; + $this->initializeClient(); + return $this; + } + + /** + * Get the current compression type. + * + * @return string + */ + public function getCompression(): string + { + return $this->compression; + } + + /** + * Set the connection timeout. + * + * @param int $timeout Timeout in milliseconds (1000-600000) + * @return self + * @throws Exception If timeout is out of range + */ + public function setTimeout(int $timeout): self + { + $this->validateTimeout($timeout); + $this->timeout = $timeout; + $this->client->setTimeout($timeout); + return $this; + } + + /** + * Get the current timeout. + * + * @return int Timeout in milliseconds + */ + public function getTimeout(): int + { + return $this->timeout; + } + + /** + * Check if the ClickHouse server is reachable and responding. + * + * This method performs a lightweight ping query to verify: + * - Network connectivity to the server + * - Server is accepting HTTP connections + * - Authentication credentials are valid + * + * @return bool True if server is healthy, false otherwise + */ + public function ping(): bool + { + try { + $result = $this->query('SELECT 1 FORMAT TabSeparated'); + return trim($result) === '1'; + } catch (Exception $e) { + return false; + } + } + + /** + * Get server version information. + * + * @return string|null Server version string or null if unavailable + */ + public function getServerVersion(): ?string + { + try { + $result = $this->query('SELECT version() FORMAT TabSeparated'); + $version = trim($result); + return $version !== '' ? $version : null; + } catch (Exception $e) { + return null; + } + } + + /** + * Get comprehensive health check information. + * + * Returns detailed status about the ClickHouse connection including: + * - Connection health status + * - Server version and uptime + * - Response time measurement + * - Configuration details + * + * @return array{healthy: bool, host: string, port: int, database: string, secure: bool, compression: string, version: string|null, uptime: int|null, responseTime: float, error?: string} + */ + public function healthCheck(): array + { + $startTime = microtime(true); + $healthy = false; + $version = null; + $uptime = null; + $error = null; + + try { + // Query version and uptime in a single request + $result = $this->query("SELECT version() as version, uptime() as uptime FORMAT JSON"); + $decoded = json_decode($result, true); + + if (is_array($decoded) && isset($decoded['data'][0])) { + $data = $decoded['data'][0]; + $version = $data['version'] ?? null; + $uptime = isset($data['uptime']) ? (int) $data['uptime'] : null; + $healthy = true; + } + } catch (Exception $e) { + $error = $e->getMessage(); + } + + $responseTime = (microtime(true) - $startTime) * 1000; // Convert to milliseconds + + $result = [ + 'healthy' => $healthy, + 'host' => $this->host, + 'port' => $this->port, + 'database' => $this->database, + 'secure' => $this->secure, + 'compression' => $this->compression, + 'version' => $version, + 'uptime' => $uptime, + 'responseTime' => round($responseTime, 2), + ]; + + if ($error !== null) { + $result['error'] = $error; + } + + return $result; + } + + /** + * Set the maximum number of retry attempts for transient failures. + * + * When a retryable error occurs (network timeout, server overload, etc.), + * the adapter will retry the request up to this many times with exponential backoff. + * + * @param int $maxRetries Maximum retries (0-10, 0 disables retries) + * @return self + * @throws Exception If maxRetries is out of range + */ + public function setMaxRetries(int $maxRetries): self + { + if ($maxRetries < 0 || $maxRetries > self::MAX_RETRY_ATTEMPTS) { + throw new Exception("Max retries must be between 0 and " . self::MAX_RETRY_ATTEMPTS); + } + $this->maxRetries = $maxRetries; + return $this; + } + + /** + * Get the maximum number of retry attempts. + * + * @return int + */ + public function getMaxRetries(): int + { + return $this->maxRetries; + } + + /** + * Set the base delay between retry attempts. + * + * The actual delay uses exponential backoff: delay * 2^(attempt-1) + * For example, with delay=100ms: 100ms, 200ms, 400ms, 800ms, etc. + * + * @param int $delayMs Base delay in milliseconds (10-5000) + * @return self + * @throws Exception If delay is out of range + */ + public function setRetryDelay(int $delayMs): self + { + if ($delayMs < self::MIN_RETRY_DELAY || $delayMs > self::MAX_RETRY_DELAY) { + throw new Exception( + "Retry delay must be between " . self::MIN_RETRY_DELAY . " and " . self::MAX_RETRY_DELAY . " milliseconds" + ); + } + $this->retryDelay = $delayMs; + return $this; + } + + /** + * Get the base retry delay. + * + * @return int Delay in milliseconds + */ + public function getRetryDelay(): int + { + return $this->retryDelay; + } + + /** + * Enable or disable query logging. + * + * When enabled, all queries are logged with their SQL, parameters, + * execution duration, and success/failure status. Useful for debugging + * and performance monitoring. + * + * @param bool $enable Whether to enable query logging + * @return self + */ + public function enableQueryLogging(bool $enable = true): self + { + $this->queryLoggingEnabled = $enable; + return $this; + } + + /** + * Check if query logging is enabled. + * + * @return bool + */ + public function isQueryLoggingEnabled(): bool + { + return $this->queryLoggingEnabled; + } + + /** + * Get the query log. + * + * Each entry contains: + * - sql: The SQL query + * - params: Query parameters + * - duration: Execution time in milliseconds + * - timestamp: ISO 8601 timestamp + * - success: Whether the query succeeded + * - error: Error message (if failed) + * - retries: Number of retry attempts (if any) + * + * @return array, duration: float, timestamp: string, success: bool, error?: string, retries?: int}> + */ + public function getQueryLog(): array + { + return $this->queryLog; + } + + /** + * Clear the query log. + * + * @return self + */ + public function clearQueryLog(): self + { + $this->queryLog = []; + return $this; + } + + /** + * Get connection and query statistics. + * + * Returns operational metrics about the adapter's usage: + * - Total queries executed + * - Failed query count + * - Configuration settings + * + * @return array{queryCount: int, failedQueryCount: int, successRate: float, host: string, port: int, database: string, secure: bool, compression: string, timeout: int, maxRetries: int, retryDelay: int, queryLoggingEnabled: bool, queryLogSize: int} + */ + public function getStats(): array + { + $successRate = $this->queryCount > 0 + ? round(($this->queryCount - $this->failedQueryCount) / $this->queryCount * 100, 2) + : 100.0; + + return [ + 'queryCount' => $this->queryCount, + 'failedQueryCount' => $this->failedQueryCount, + 'successRate' => $successRate, + 'host' => $this->host, + 'port' => $this->port, + 'database' => $this->database, + 'secure' => $this->secure, + 'compression' => $this->compression, + 'timeout' => $this->timeout, + 'maxRetries' => $this->maxRetries, + 'retryDelay' => $this->retryDelay, + 'queryLoggingEnabled' => $this->queryLoggingEnabled, + 'queryLogSize' => count($this->queryLog), + ]; + } + + /** + * Reset query statistics. + * + * @return self + */ + public function resetStats(): self + { + $this->queryCount = 0; + $this->failedQueryCount = 0; + return $this; + } + /** * Get the namespace. * @@ -490,6 +983,10 @@ private function getTableName(): string * ClickHouse handles all parameter escaping and type conversion internally, * making both approaches fully injection-safe. * + * When compression is enabled: + * - Response decompression is handled automatically via Accept-Encoding header + * - This significantly reduces bandwidth for query results + * * @param string $sql The SQL query to execute * @param array $params Key-value pairs for query parameters (for SELECT/UPDATE/DELETE) * @param array>|null $jsonRows Array of rows for JSONEachRow INSERT operations @@ -498,59 +995,341 @@ private function getTableName(): string */ private function query(string $sql, array $params = [], ?array $jsonRows = null): string { + $startTime = microtime(true); + $retryCount = 0; + $lastException = null; + $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 ($jsonRows !== null) { - // JSON body mode for INSERT operations with JSONEachRow format - $url = "{$scheme}://{$this->host}:{$this->port}/?query=" . urlencode($sql); + // Prepare URL and body before retry loop + if ($jsonRows !== null) { + // JSON body mode for INSERT operations with JSONEachRow format + $url = "{$scheme}://{$this->host}:{$this->port}/?query=" . urlencode($sql); + + // Build JSONEachRow body - each row on a separate line + $jsonLines = []; + foreach ($jsonRows as $row) { + try { + $encoded = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logQuery($sql, $params, $startTime, false, $e->getMessage(), 0); + throw new Exception('Failed to encode row to JSON: ' . $e->getMessage()); + } + $jsonLines[] = $encoded; + } + $body = implode("\n", $jsonLines); + } 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); + } + } - // Build JSONEachRow body - each row on a separate line - $jsonLines = []; - foreach ($jsonRows as $row) { - try { - $encoded = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new Exception('Failed to encode row to JSON: ' . $e->getMessage()); + // Retry loop with exponential backoff + while (true) { + try { + $response = $this->client->fetch( + url: $url, + method: Client::METHOD_POST, + body: $body + ); + + $statusCode = $response->getStatusCode(); + $responseBody = $response->getBody(); + $responseBody = is_string($responseBody) ? $responseBody : ''; + + // Decompress response if server sent compressed data + $responseBody = $this->decompressResponse($response, $responseBody); + + if ($statusCode !== 200) { + // Check if this is a retryable error + if ($this->isRetryableError($statusCode, $responseBody) && $retryCount < $this->maxRetries) { + $retryCount++; + $this->sleepWithBackoff($retryCount); + continue; } - $jsonLines[] = $encoded; + $this->handleQueryError($statusCode, $responseBody, $sql); } - $body = implode("\n", $jsonLines); - } 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); + // Success + $this->logQuery($sql, $params, $startTime, true, null, $retryCount); + return $responseBody; + + } catch (Exception $e) { + $lastException = $e; + + // Check if this is a retryable network error + if ($this->isRetryableException($e) && $retryCount < $this->maxRetries) { + $retryCount++; + $this->sleepWithBackoff($retryCount); + continue; } + + // Log the failed query + $errorMessage = $e->getMessage(); + $this->logQuery($sql, $params, $startTime, false, $errorMessage, $retryCount); + + // Re-throw our own exceptions without wrapping + if (strpos($errorMessage, 'ClickHouse') === 0) { + throw $e; + } + throw new Exception( + "ClickHouse query execution failed: {$errorMessage}", + 0, + $e + ); } + } + } - $response = $this->client->fetch( - url: $url, - method: Client::METHOD_POST, - body: $body - ); + /** + * Check if an HTTP status code indicates a retryable error. + * + * @param int $statusCode HTTP status code + * @param string $responseBody Response body for additional checks + * @return bool + */ + private function isRetryableError(int $statusCode, string $responseBody): bool + { + // Common retryable status codes + if (in_array($statusCode, self::RETRYABLE_STATUS_CODES, true)) { + return true; + } - if ($response->getStatusCode() !== 200) { - $responseBody = $response->getBody(); - $responseBody = is_string($responseBody) ? $responseBody : ''; - throw new Exception("ClickHouse query failed with HTTP {$response->getStatusCode()}: {$responseBody}"); + // Check response body for retryable patterns + $retryablePatterns = [ + 'too many simultaneous queries', + 'memory limit exceeded', + 'timeout', + 'connection reset', + ]; + + $lowerBody = strtolower($responseBody); + foreach ($retryablePatterns as $pattern) { + if (strpos($lowerBody, $pattern) !== false) { + return true; } + } - $responseBody = $response->getBody(); - return is_string($responseBody) ? $responseBody : ''; - } catch (Exception $e) { - throw new Exception( - "ClickHouse query execution failed: {$e->getMessage()}", - 0, - $e - ); + return false; + } + + /** + * Check if an exception indicates a retryable network error. + * + * @param Exception $e The exception to check + * @return bool + */ + private function isRetryableException(Exception $e): bool + { + $message = strtolower($e->getMessage()); + $retryablePatterns = [ + 'connection', + 'timeout', + 'refused', + 'reset', + 'broken pipe', + 'network', + 'temporary', + 'unavailable', + 'could not resolve', + ]; + + foreach ($retryablePatterns as $pattern) { + if (strpos($message, $pattern) !== false) { + return true; + } + } + + return false; + } + + /** + * Sleep with exponential backoff before retry. + * + * @param int $attempt Current retry attempt (1-based) + */ + private function sleepWithBackoff(int $attempt): void + { + // Exponential backoff: delay * 2^(attempt-1) + // With jitter to avoid thundering herd + $delay = $this->retryDelay * (2 ** ($attempt - 1)); + $jitter = rand(0, (int) ($delay * 0.1)); // 10% jitter + $totalDelay = min($delay + $jitter, self::MAX_RETRY_DELAY); + + usleep((int) ($totalDelay * 1000)); // Convert ms to microseconds + } + + /** + * Log a query execution (if logging is enabled). + * + * @param string $sql The SQL query + * @param array $params Query parameters + * @param float $startTime Start time from microtime(true) + * @param bool $success Whether the query succeeded + * @param string|null $error Error message if failed + * @param int $retries Number of retry attempts + */ + private function logQuery(string $sql, array $params, float $startTime, bool $success, ?string $error, int $retries): void + { + // Always track statistics + $this->queryCount++; + if (!$success) { + $this->failedQueryCount++; + } + + // Only log details if logging is enabled + if (!$this->queryLoggingEnabled) { + return; + } + + $duration = (microtime(true) - $startTime) * 1000; // Convert to milliseconds + + $entry = [ + 'sql' => $sql, + 'params' => $params, + 'duration' => round($duration, 2), + 'timestamp' => date('c'), + 'success' => $success, + ]; + + if ($error !== null) { + $entry['error'] = $error; + } + + if ($retries > 0) { + $entry['retries'] = $retries; + } + + $this->queryLog[] = $entry; + } + + /** + * Decompress response body if the server sent compressed data. + * + * Checks the Content-Encoding header and decompresses accordingly. + * + * @param \Utopia\Fetch\Response $response The HTTP response + * @param string $body The response body + * @return string Decompressed body (or original if not compressed) + */ + private function decompressResponse(\Utopia\Fetch\Response $response, string $body): string + { + if (empty($body)) { + return $body; + } + + $headers = $response->getHeaders(); + $contentEncoding = ''; + + // Find Content-Encoding header (case-insensitive) + foreach ($headers as $name => $value) { + if (strtolower($name) === 'content-encoding') { + $contentEncoding = (string) $value; + break; + } } + + if (empty($contentEncoding)) { + return $body; + } + + $encoding = strtolower(trim($contentEncoding)); + + if ($encoding === 'gzip' || $encoding === 'x-gzip') { + $decompressed = @gzdecode($body); + if ($decompressed !== false) { + return $decompressed; + } + // If decompression fails, return original (might not actually be compressed) + return $body; + } + + if ($encoding === 'deflate') { + $decompressed = @gzinflate($body); + if ($decompressed !== false) { + return $decompressed; + } + // Try with zlib header + $decompressed = @gzuncompress($body); + if ($decompressed !== false) { + return $decompressed; + } + return $body; + } + + // LZ4 decompression requires the lz4 extension + if ($encoding === 'lz4') { + if (function_exists('lz4_uncompress')) { + /** @var string|false $decompressed */ + $decompressed = lz4_uncompress($body); + if ($decompressed !== false) { + return $decompressed; + } + } + return $body; + } + + // Unknown encoding, return as-is + return $body; + } + + /** + * Handle query error responses from ClickHouse. + * + * @param int $statusCode HTTP status code + * @param string $responseBody Response body + * @param string $sql The SQL query that failed + * @throws Exception + */ + private function handleQueryError(int $statusCode, string $responseBody, string $sql): void + { + // Extract meaningful error message from ClickHouse response + $errorMessage = $this->parseClickHouseError($responseBody); + + $context = ''; + if ($statusCode === 401) { + $context = ' (authentication failed - check username/password)'; + } elseif ($statusCode === 403) { + $context = ' (access denied - check permissions)'; + } elseif ($statusCode === 404) { + $context = ' (database or table not found)'; + } + + throw new Exception( + "ClickHouse query failed with HTTP {$statusCode}{$context}: {$errorMessage}" + ); + } + + /** + * Parse ClickHouse error response to extract a meaningful error message. + * + * @param string $responseBody The raw response body + * @return string Parsed error message + */ + private function parseClickHouseError(string $responseBody): string + { + if (empty($responseBody)) { + return 'Empty response from server'; + } + + // ClickHouse error format: Code: XXX. DB::Exception: Message + if (preg_match('/Code:\s*(\d+).*?DB::Exception:\s*(.+?)(?:\s*\(|$)/s', $responseBody, $matches)) { + return "Code {$matches[1]}: {$matches[2]}"; + } + + // Return the first line of the response as fallback + // strtok() on a non-empty string will always return a string + /** @var string $firstLine */ + $firstLine = strtok($responseBody, "\n"); + return $firstLine; } /** @@ -923,7 +1702,6 @@ private function parseQueries(array $queries): array $attribute = $query->getAttribute(); /** @var string $attribute */ - $values = $query->getValues(); $values = $query->getValues(); switch ($method) { diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index 1374c71..4e734a5 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -415,6 +415,279 @@ public function testClickHouseAdapterIndexes(): void } } + /** + * Test compression setting validation with invalid type + */ + public function testSetCompressionValidatesInvalidType(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("Invalid compression type 'invalid'"); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setCompression('invalid'); + } + + /** + * Test constructor with invalid compression type + */ + public function testConstructorValidatesInvalidCompression(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("Invalid compression type 'bzip2'"); + + new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + compression: 'bzip2' + ); + } + + /** + * Test valid compression types + */ + public function testCompressionSettings(): void + { + // Test constructor with each valid compression type + $adapterNone = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + compression: ClickHouse::COMPRESSION_NONE + ); + $this->assertEquals(ClickHouse::COMPRESSION_NONE, $adapterNone->getCompression()); + + $adapterGzip = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + compression: ClickHouse::COMPRESSION_GZIP + ); + $this->assertEquals(ClickHouse::COMPRESSION_GZIP, $adapterGzip->getCompression()); + + $adapterLz4 = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + compression: ClickHouse::COMPRESSION_LZ4 + ); + $this->assertEquals(ClickHouse::COMPRESSION_LZ4, $adapterLz4->getCompression()); + + // Test setter method + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $result = $adapter->setCompression(ClickHouse::COMPRESSION_GZIP); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertEquals(ClickHouse::COMPRESSION_GZIP, $adapter->getCompression()); + + $adapter->setCompression(ClickHouse::COMPRESSION_NONE); + $this->assertEquals(ClickHouse::COMPRESSION_NONE, $adapter->getCompression()); + } + + /** + * Test timeout validation - too low + */ + public function testTimeoutValidationTooLow(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Timeout must be between'); + + new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + timeout: 500 // Below minimum of 1000ms + ); + } + + /** + * Test timeout validation - too high + */ + public function testTimeoutValidationTooHigh(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Timeout must be between'); + + new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + timeout: 700_000 // Above maximum of 600000ms + ); + } + + /** + * Test valid timeout settings + */ + public function testTimeoutSettings(): void + { + // Test constructor with custom timeout + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + timeout: 60_000 + ); + $this->assertEquals(60_000, $adapter->getTimeout()); + + // Test setter method + $result = $adapter->setTimeout(120_000); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertEquals(120_000, $adapter->getTimeout()); + } + + /** + * Test setTimeout validates range + */ + public function testSetTimeoutValidatesRange(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Timeout must be between'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setTimeout(100); // Below minimum + } + + /** + * Test ping method returns true for healthy connection + */ + public function testPingHealthyConnection(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $result = $adapter->ping(); + $this->assertTrue($result); + } + + /** + * Test ping method returns false for bad connection + */ + public function testPingUnhealthyConnection(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'wrongpassword' + ); + + // Should return false instead of throwing exception + $result = $adapter->ping(); + $this->assertFalse($result); + } + + /** + * Test getServerVersion returns version string + */ + public function testGetServerVersion(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $version = $adapter->getServerVersion(); + $this->assertNotNull($version); + $this->assertIsString($version); + // ClickHouse version format: XX.Y.Z.W + $this->assertMatchesRegularExpression('/^\d+\.\d+/', $version); + } + + /** + * Test getServerVersion returns null for bad connection + */ + public function testGetServerVersionBadConnection(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'wrongpassword' + ); + + $version = $adapter->getServerVersion(); + $this->assertNull($version); + } + + /** + * Test that operations work with gzip compression enabled + */ + public function testOperationsWithGzipCompression(): void + { + $clickHouse = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + port: 8123, + compression: ClickHouse::COMPRESSION_GZIP + ); + + $clickHouse->setDatabase('default'); + $clickHouse->setNamespace('gzip_test'); + + $audit = new \Utopia\Audit\Audit($clickHouse); + $audit->setup(); + + // Test single log insertion with compression + $requiredAttributes = $this->getRequiredAttributes(); + $data = array_merge(['test' => 'gzip_compression'], $requiredAttributes); + + $log = $audit->log( + 'gzipuser', + 'create', + 'document/gzip1', + 'Mozilla/5.0', + '127.0.0.1', + 'US', + $data + ); + + $this->assertInstanceOf(\Utopia\Audit\Log::class, $log); + $this->assertEquals('gzipuser', $log->getAttribute('userId')); + + // Test batch insertion with compression + $batchEvents = [ + [ + 'userId' => 'gzipuser', + 'event' => 'update', + 'resource' => 'document/gzip2', + 'userAgent' => 'Mozilla/5.0', + 'ip' => '127.0.0.1', + 'location' => 'US', + 'data' => ['batch' => 'gzip'], + 'time' => \Utopia\Database\DateTime::formatTz(\Utopia\Database\DateTime::now()) ?? '' + ] + ]; + $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); + + $result = $audit->logBatch($batchEvents); + $this->assertTrue($result); + + // Verify retrieval works + $logs = $audit->getLogsByUser('gzipuser'); + $this->assertGreaterThanOrEqual(2, count($logs)); + + // Cleanup + $audit->cleanup(new \DateTime('+1 hour')); + } + /** * Test parsing of complex resource paths into resourceType/resourceId/resourceParent */ @@ -471,4 +744,270 @@ public function testParseResourceMethod(): void $this->assertEquals('table', $parsed['resourceType']); $this->assertEquals('database/6978484940ff05762e1a', $parsed['resourceParent']); } + + /** + * Test comprehensive health check method + */ + public function testHealthCheck(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $health = $adapter->healthCheck(); + + $this->assertIsArray($health); + $this->assertArrayHasKey('healthy', $health); + $this->assertArrayHasKey('host', $health); + $this->assertArrayHasKey('port', $health); + $this->assertArrayHasKey('database', $health); + $this->assertArrayHasKey('secure', $health); + $this->assertArrayHasKey('compression', $health); + $this->assertArrayHasKey('version', $health); + $this->assertArrayHasKey('uptime', $health); + $this->assertArrayHasKey('responseTime', $health); + + $this->assertTrue($health['healthy']); + $this->assertEquals('clickhouse', $health['host']); + $this->assertEquals(8123, $health['port']); + $this->assertNotNull($health['version']); + $this->assertIsInt($health['uptime']); + $this->assertGreaterThan(0, $health['uptime']); + $this->assertIsFloat($health['responseTime']); + $this->assertGreaterThan(0, $health['responseTime']); + } + + /** + * Test health check with bad credentials + */ + public function testHealthCheckUnhealthy(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'wrongpassword' + ); + + /** @var array $health */ + $health = $adapter->healthCheck(); + + $this->assertFalse($health['healthy']); + // Error key should be present when health check fails + $this->assertArrayHasKey('error', $health); + $this->assertNotEmpty($health['error']); + } + + /** + * Test retry configuration - max retries + */ + public function testSetMaxRetries(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + // Default value + $this->assertEquals(3, $adapter->getMaxRetries()); + + // Set valid value + $result = $adapter->setMaxRetries(5); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertEquals(5, $adapter->getMaxRetries()); + + // Set to 0 (disable retries) + $adapter->setMaxRetries(0); + $this->assertEquals(0, $adapter->getMaxRetries()); + } + + /** + * Test retry configuration - max retries validation + */ + public function testSetMaxRetriesValidation(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Max retries must be between 0 and 10'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setMaxRetries(15); // Above max of 10 + } + + /** + * Test retry configuration - delay + */ + public function testSetRetryDelay(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + // Default value + $this->assertEquals(100, $adapter->getRetryDelay()); + + // Set valid value + $result = $adapter->setRetryDelay(500); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertEquals(500, $adapter->getRetryDelay()); + } + + /** + * Test retry delay validation - too low + */ + public function testSetRetryDelayTooLow(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Retry delay must be between'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setRetryDelay(5); // Below min of 10 + } + + /** + * Test retry delay validation - too high + */ + public function testSetRetryDelayTooHigh(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Retry delay must be between'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setRetryDelay(10000); // Above max of 5000 + } + + /** + * Test query logging functionality + */ + public function testQueryLogging(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + // Query logging is disabled by default + $this->assertFalse($adapter->isQueryLoggingEnabled()); + $this->assertEmpty($adapter->getQueryLog()); + + // Enable query logging + $result = $adapter->enableQueryLogging(true); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertTrue($adapter->isQueryLoggingEnabled()); + + // Perform a query (ping uses query internally) + $adapter->ping(); + + // Check query log + $log = $adapter->getQueryLog(); + $this->assertNotEmpty($log); + $this->assertCount(1, $log); + + $entry = $log[0]; + $this->assertArrayHasKey('sql', $entry); + $this->assertArrayHasKey('params', $entry); + $this->assertArrayHasKey('duration', $entry); + $this->assertArrayHasKey('timestamp', $entry); + $this->assertArrayHasKey('success', $entry); + $this->assertTrue($entry['success']); + $this->assertIsFloat($entry['duration']); + $this->assertGreaterThan(0, $entry['duration']); + + // Clear log + $adapter->clearQueryLog(); + $this->assertEmpty($adapter->getQueryLog()); + + // Disable logging + $adapter->enableQueryLogging(false); + $this->assertFalse($adapter->isQueryLoggingEnabled()); + } + + /** + * Test connection statistics + */ + public function testGetStats(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + // Get initial stats + $stats = $adapter->getStats(); + + $this->assertIsArray($stats); + $this->assertArrayHasKey('queryCount', $stats); + $this->assertArrayHasKey('failedQueryCount', $stats); + $this->assertArrayHasKey('successRate', $stats); + $this->assertArrayHasKey('host', $stats); + $this->assertArrayHasKey('port', $stats); + $this->assertArrayHasKey('database', $stats); + $this->assertArrayHasKey('secure', $stats); + $this->assertArrayHasKey('compression', $stats); + $this->assertArrayHasKey('timeout', $stats); + $this->assertArrayHasKey('maxRetries', $stats); + $this->assertArrayHasKey('retryDelay', $stats); + $this->assertArrayHasKey('queryLoggingEnabled', $stats); + $this->assertArrayHasKey('queryLogSize', $stats); + + $this->assertEquals(0, $stats['queryCount']); + $this->assertEquals(0, $stats['failedQueryCount']); + $this->assertEquals(100.0, $stats['successRate']); + + // Perform some queries + $adapter->ping(); + $adapter->ping(); + + $stats = $adapter->getStats(); + $this->assertEquals(2, $stats['queryCount']); + $this->assertEquals(0, $stats['failedQueryCount']); + $this->assertEquals(100.0, $stats['successRate']); + + // Reset stats + $result = $adapter->resetStats(); + $this->assertInstanceOf(ClickHouse::class, $result); + + $stats = $adapter->getStats(); + $this->assertEquals(0, $stats['queryCount']); + } + + /** + * Test that stats track failed queries + */ + public function testStatsTrackFailedQueries(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'wrongpassword' // Bad credentials + ); + + // Attempt a query that will fail + $adapter->ping(); // Should fail but return false, not throw + + $stats = $adapter->getStats(); + $this->assertEquals(1, $stats['queryCount']); + $this->assertEquals(1, $stats['failedQueryCount']); + $this->assertEquals(0.0, $stats['successRate']); + } }