diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 0ff5880..1524a5d 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -84,6 +84,9 @@ class ClickHouse extends SQL protected bool $asyncCleanup = false; + /** @var int|null Retention in days; when set, setup() applies a TTL on the table. Null disables TTL. */ + private ?int $retention = null; + /** * @param string $host ClickHouse host * @param string $username ClickHouse username (default: 'default') @@ -360,6 +363,34 @@ public function isAsyncCleanup(): bool return $this->asyncCleanup; } + /** + * Set the retention window in days. When set, setup() applies a TTL so + * rows older than the window are dropped by background merges. Pass null + * to disable (the default). + * + * @param int|null $days + * @return self + * @throws Exception If $days is not positive + */ + public function setRetention(?int $days): self + { + if ($days !== null && $days < 1) { + throw new Exception('Retention must be a positive number of days'); + } + $this->retention = $days; + return $this; + } + + /** + * Get the retention window in days, or null when TTL is disabled. + * + * @return int|null + */ + public function getRetention(): ?int + { + return $this->retention; + } + /** * Override getAttributes to provide extended attributes for ClickHouse. * Includes existing attributes from parent and adds new missing ones. @@ -791,6 +822,34 @@ public function setup(): void "; $this->query($createTableSql); + + // Apply retention as a separate, idempotent ALTER. CREATE TABLE IF NOT + // EXISTS won't add a TTL to a table that already exists, and MODIFY TTL + // is a no-op when the TTL already matches, so setup() stays re-runnable. + // materialize_ttl_after_modify = 0 defers the purge to background merges + // rather than an immediate, I/O-heavy mutation. + if ($this->retention !== null) { + $this->query( + "ALTER TABLE {$escapedDatabaseAndTable} " + . "MODIFY TTL toDateTime(time) + INTERVAL {$this->retention} DAY " + . 'SETTINGS materialize_ttl_after_modify = 0' + ); + } else { + // Disabling retention must actively strip any TTL a previous run + // applied; otherwise rows keep being purged despite retention being + // null. ClickHouse errors (code 36) when REMOVE TTL runs on a table + // that has no TTL, so swallow that specific case to keep setup() + // idempotent. + try { + $this->query( + "ALTER TABLE {$escapedDatabaseAndTable} REMOVE TTL" + ); + } catch (Exception $e) { + if (!str_contains($e->getMessage(), "doesn't have any table TTL expression")) { + throw $e; + } + } + } } /** diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index 8074243..c3c6a40 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -376,6 +376,74 @@ public function testSetSecure(): void $this->assertInstanceOf(ClickHouse::class, $result); } + /** + * Test setRetention stores the value and getRetention returns it + */ + public function testSetRetention(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $this->assertNull($adapter->getRetention()); + + $result = $adapter->setRetention(30); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertEquals(30, $adapter->getRetention()); + } + + /** + * Test setRetention accepts null to disable retention + */ + public function testSetRetentionAcceptsNull(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setRetention(30); + $adapter->setRetention(null); + $this->assertNull($adapter->getRetention()); + } + + /** + * Test setRetention rejects zero days + */ + public function testSetRetentionRejectsZero(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Retention must be a positive number of days'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setRetention(0); + } + + /** + * Test setRetention rejects negative days + */ + public function testSetRetentionRejectsNegative(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Retention must be a positive number of days'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setRetention(-1); + } + /** * Test shared tables configuration */