diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 3d9134a..539d0d4 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -32,6 +32,12 @@ class ClickHouse extends SQL 'resourceType', 'country', 'sdk', + // premium geo (autonomousSystemNumber is intentionally NOT low-cardinality: + // ~100k ASNs globally can exceed ClickHouse's LowCardinality sweet spot) + 'continentCode', + 'subdivisions', + 'connectionType', + 'connectionUsageType', ]; /** @@ -482,6 +488,97 @@ public function getAttributes(): array 'array' => false, 'filters' => [], ], + // premium geo + [ + '$id' => 'city', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'continentCode', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'subdivisions', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'isp', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'autonomousSystemNumber', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'autonomousSystemOrganization', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'connectionType', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'connectionUsageType', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'connectionOrganization', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], [ '$id' => 'projectId', 'type' => Database::VAR_STRING, diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index 5b56173..a2202c8 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -536,8 +536,19 @@ public function testClickHouseAdapterAttributes(): void 'teamId', 'teamInternalId', 'hostname', + // premium geo + 'city', + 'continentCode', + 'subdivisions', + 'isp', + 'autonomousSystemNumber', + 'autonomousSystemOrganization', + 'connectionType', + 'connectionUsageType', + 'connectionOrganization', + // sdk 'sdk', - 'sdkVersion' + 'sdkVersion', ]; foreach ($expectedAttributes as $expected) { @@ -545,6 +556,128 @@ public function testClickHouseAdapterAttributes(): void } } + /** + * Test that premium geo attributes are all optional String columns. + */ + public function testPremiumGeoAttributesAreOptionalStrings(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $attributes = $adapter->getAttributes(); + $byId = []; + foreach ($attributes as $attribute) { + $byId[$attribute['$id']] = $attribute; + } + + $geoColumns = [ + 'city', + 'continentCode', + 'subdivisions', + 'isp', + 'autonomousSystemNumber', + 'autonomousSystemOrganization', + 'connectionType', + 'connectionUsageType', + 'connectionOrganization', + ]; + + foreach ($geoColumns as $column) { + $this->assertArrayHasKey($column, $byId, "Premium geo attribute '{$column}' not found"); + $this->assertEquals(\Utopia\Database\Database::VAR_STRING, $byId[$column]['type'], "'{$column}' should be a string"); + $this->assertFalse($byId[$column]['required'], "'{$column}' should be optional"); + $this->assertFalse($byId[$column]['array'], "'{$column}' should not be an array"); + } + } + + /** + * Test that premium geo columns get the correct ClickHouse type: + * low-cardinality dimensions use LowCardinality(Nullable(String)), while + * high-cardinality ones stay plain Nullable(String). + */ + public function testPremiumGeoColumnTypes(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $method = new \ReflectionMethod($adapter, 'getColumnDefinition'); + $method->setAccessible(true); + + $lowCardinality = [ + 'continentCode', + 'subdivisions', + 'connectionType', + 'connectionUsageType', + ]; + foreach ($lowCardinality as $column) { + $definition = $method->invoke($adapter, $column); + $this->assertEquals("{$column} LowCardinality(Nullable(String))", $definition); + } + + // autonomousSystemNumber is high-cardinality (~100k ASNs) so it is a + // plain Nullable(String), not LowCardinality. + $highCardinality = [ + 'city', + 'isp', + 'autonomousSystemNumber', + 'autonomousSystemOrganization', + 'connectionOrganization', + ]; + foreach ($highCardinality as $column) { + $definition = $method->invoke($adapter, $column); + $this->assertEquals("{$column} Nullable(String)", $definition); + } + } + + /** + * Premium geo values must round-trip through a real write/read cycle: + * write a log carrying all 9 geo fields and read it back unchanged. This + * proves the columns are actually created, written and selected (not just + * present in the schema definition). + */ + public function testPremiumGeoRoundTrip(): void + { + $actorId = 'geo-actor-' . uniqid('', true); + $geo = [ + 'city' => 'Mountain View', + 'continentCode' => 'NA', + 'subdivisions' => 'California', + 'isp' => 'Google', + 'autonomousSystemNumber' => '15169', + 'autonomousSystemOrganization' => 'GOOGLE', + 'connectionType' => 'cable', + 'connectionUsageType' => 'residential', + 'connectionOrganization' => 'Google LLC', + ]; + + $batchEvents = [array_merge([ + 'actorId' => $actorId, + 'event' => 'geo.roundtrip', + 'resource' => 'document/geo-1', + 'userAgent' => 'RoundTrip/1.0', + 'ip' => '8.8.8.8', + 'data' => [], + 'time' => \Utopia\Database\DateTime::formatTz(\Utopia\Database\DateTime::now()) ?? '', + ], $geo)]; + + $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); + $this->assertTrue($this->audit->logBatch($batchEvents)); + + $logs = $this->audit->getLogsByUser($actorId); + $this->assertGreaterThan(0, count($logs), 'geo round-trip log was not persisted'); + + $log = $logs[0]; + foreach ($geo as $key => $expected) { + $this->assertSame($expected, $log->getAttribute($key), "premium geo '{$key}' did not round-trip"); + } + } + /** * Test that ClickHouse adapter has all required indexes */