Skip to content

Conversation

@lohanidamodar
Copy link
Contributor

@lohanidamodar lohanidamodar commented Jan 11, 2026

Summary by CodeRabbit

  • New Features

    • Expanded audit logging schema with additional attributes and composite indexes for richer, faster queries.
    • Added single-log retrieval, enhanced searching (filters, time ranges, ordering, pagination) and counting, plus a fluent query builder for composing queries.
  • Adapter/API

    • Adapter and Audit interfaces extended to expose get-by-ID, find, and count methods across adapters.
  • Tests

    • New and expanded tests covering attributes/indexes, query builder, find/count behavior, ranges, ordering, and pagination.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 11, 2026

Walkthrough

Adds a fluent Query builder (Utopia\Audit\Query) and extends audit adapters and API to support indexed, attribute-rich ClickHouse queries. New adapter methods: getById(string): ?Log, find(array): array, count(array): int are declared in Adapter and implemented in Database and ClickHouse (with SQL/HTTP query execution, parameter formatting, and query parsing). Audit exposes getLogById, find, and count delegating to the adapter. Tests added for Query, ClickHouse adapter attributes/indexes, and extended Audit find/count/getById behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Title check ⚠️ Warning The pull request title claims to add extended attributes and indexes for ClickHouse adapter, but the actual changeset includes far more substantial additions: new Query class, find/count/getById methods across multiple adapter classes, and comprehensive test coverage. Update the title to accurately reflect the main scope, such as: 'Add query support and extended attributes to audit adapters' or 'Implement flexible querying for audit logs with find, count, and getById methods'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 91.94% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In @tests/Audit/Adapter/ClickHouseTest.php:
- Around line 323-360: The test testClickHouseAdapterAttributes is missing the
'location' attribute in its $expectedAttributes list and the adapter's creation
path doesn't accept/populate it; add 'location' to the $expectedAttributes array
in testClickHouseAdapterAttributes to assert its presence, and update the
ClickHouse create() (or the parent SQL adapter create() method that handles
audit fields) to accept a 'location' input and populate the corresponding
schema/attribute so the attribute can actually be stored and retrieved via
ClickHouse::getAttributes().
- Around line 365-393: The test testClickHouseAdapterIndexes expects parent
index IDs with `_key_*` names but the parent SQL adapter (class SQL) actually
uses `idx_*` names; update the expectedIndexes array in the test to match the
real parent IDs (replace `_key_event` -> `idx_event`,
`_key_user_internal_and_event` -> `idx_userId_event`, `_key_resource_and_event`
-> `idx_resource_event`, `_key_time` -> `idx_time_desc`) so the assertions
compare against the actual index IDs returned by ClickHouse adapter.
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 27d6663 and aa4f0d4.

📒 Files selected for processing (2)
  • src/Audit/Adapter/ClickHouse.php
  • tests/Audit/Adapter/ClickHouseTest.php
🧰 Additional context used
🧬 Code graph analysis (2)
src/Audit/Adapter/ClickHouse.php (1)
src/Audit/Adapter/SQL.php (2)
  • getAttributes (43-120)
  • getIndexes (142-166)
tests/Audit/Adapter/ClickHouseTest.php (1)
src/Audit/Adapter/ClickHouse.php (1)
  • ClickHouse (17-1295)
🪛 GitHub Actions: Tests
tests/Audit/Adapter/ClickHouseTest.php

[error] 391-391: Command failed: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests. PHPUnit failure: 'Index '_key_event' not found in ClickHouse adapter' at tests/Audit/Adapter/ClickHouseTest.php:391.

🔇 Additional comments (2)
src/Audit/Adapter/ClickHouse.php (2)

7-7: LGTM - Required import for attribute and index constants.

The Database class import is correctly added to support the new attribute and index definitions.


398-455: Verify indexing strategy for resourceType, resourceId, resourceParent, and resourceInternalId attributes.

These four attributes are defined but not indexed, while other new attributes have dedicated indexes. If these attributes are frequently used in WHERE clauses or JOIN conditions, they may benefit from indexes. Consider reviewing query patterns to confirm the current indexing strategy is complete.

Comment on lines +258 to +390
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' => [],
],
];
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

wc -l src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 97


🏁 Script executed:

# First, let's find the file and examine its structure
git ls-files src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 92


🏁 Script executed:

# Get the create() method to see what it populates
sed -n '645,702p' src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 2128


🏁 Script executed:

# Get the createBatch() method
sed -n '709,789p' src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 3190


🏁 Script executed:

# Also check the full getAttributes() method to see all attribute definitions
sed -n '258,390p' src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 4443


🏁 Script executed:

# Verify if these new attributes are referenced elsewhere in the file
rg -n 'userType|resourceType|resourceId|projectId|teamId|hostname' src/Audit/Adapter/ClickHouse.php | head -20

Repository: utopia-php/audit

Length of output: 549


🏁 Script executed:

# Check if there's a parent getAttributes() method that might define these
rg -B5 -A20 'public function getAttributes' src/Audit/Adapter/ClickHouse.php | head -50

Repository: utopia-php/audit

Length of output: 952


🏁 Script executed:

# Check the context around lines 436 and 448-450 to see how these attributes are used
sed -n '430,460p' src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 987


🏁 Script executed:

# Search for any INSERT or UPDATE statements that might use these new attributes
rg -n "INSERT|UPDATE" src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 283


🏁 Script executed:

# Check if there's a schema creation method that uses these attributes
rg -B2 -A10 'CREATE TABLE|getSchema' src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 572


🏁 Script executed:

# Check if the parent class method has any of these attributes
rg -n "extends\|class" src/Audit/Adapter/ClickHouse.php | head -5

Repository: utopia-php/audit

Length of output: 42


🏁 Script executed:

# Check parent class and look for Log constructor to understand data flow
rg -n "new Log" src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 147


🏁 Script executed:

# Look at the full file structure to find parent class
head -30 src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 702


🏁 Script executed:

# Find the parent SQL class to understand the parent::getAttributes()
find . -name "SQL.php" -type f | head -5

Repository: utopia-php/audit

Length of output: 87


🏁 Script executed:

# Look at the Log class to see what it does with attributes
fd -e php "Log.php" | grep -v node_modules

Repository: utopia-php/audit

Length of output: 77


🏁 Script executed:

# Check if there are any comments or TODOs about these new attributes
rg -n "TODO|FIXME|NOTE|XXX" src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 42


🏁 Script executed:

# Check the parent SQL class getAttributes method
rg -B5 -A50 "public function getAttributes" src/Audit/Adapter/SQL.php

Repository: utopia-php/audit

Length of output: 1760


🏁 Script executed:

# Look at the Log class to understand its structure
cat src/Audit/Log.php

Repository: utopia-php/audit

Length of output: 4329


🏁 Script executed:

# Check if the new attributes are ever populated anywhere in the SQL adapter
rg -n "userType|resourceType|resourceId|projectId|teamId" src/Audit/Adapter/SQL.php

Repository: utopia-php/audit

Length of output: 42


🏁 Script executed:

# Check if there are any tests or examples showing how these new attributes should be populated
fd -e php "test\|example\|spec" | head -10

Repository: utopia-php/audit

Length of output: 42


🏁 Script executed:

# Search across the entire repo for usage of these new attributes
rg -n "userType|resourceType|resourceId|projectId|teamId" --type=php | head -20

Repository: utopia-php/audit

Length of output: 857


🏁 Script executed:

# Check the create signature and surrounding context to see if there are any clues
sed -n '640,650p' src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 328


🏁 Script executed:

# Check the test file to understand how these attributes are expected to be used
sed -n '330,360p' tests/Audit/Adapter/ClickHouseTest.php

Repository: utopia-php/audit

Length of output: 945


🏁 Script executed:

# Look more broadly at the test to see the full context
sed -n '320,380p' tests/Audit/Adapter/ClickHouseTest.php

Repository: utopia-php/audit

Length of output: 1819


🏁 Script executed:

# Check if there are any other create methods or overrides we might have missed
rg -n "function create|function insert" src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 162


🏁 Script executed:

# Look for any comments or documentation about when these attributes are supposed to be populated
sed -n '250,265p' src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 494


🏁 Script executed:

# Check the entire indexes definition to understand if these are truly required
sed -n '408,460p' src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 1783


Critical: New attributes are never populated in log creation methods.

The new attributes (userType, userInternalId, resourceParent, resourceType, resourceId, resourceInternalId, country, projectId, projectInternalId, teamId, teamInternalId, hostname) are defined in the schema with indexes, but are never populated in the create() (lines 645-702) or createBatch() (lines 709-789) methods. These fields will always remain NULL/empty when audit logs are created, making the indexes on these columns ineffective.

Additionally, 8 attributes have conflicting configuration: 'required' => true combined with 'default' => null. If a field is required, it cannot default to null. This applies to: userType, resourceType, resourceId, projectId, projectInternalId, teamId, teamInternalId, and hostname.

The create() method must be updated to accept and populate these new attributes, and the 'required' flags should be corrected to match the actual schema constraints.

Comment on lines +323 to +360
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");
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing 'location' attribute in expected list.

The test validates attribute presence but is missing 'location' from the expected attributes list (line 335-355). The parent SQL adapter defines a 'location' attribute that should be included in the validation.

Additionally, note that this test only validates schema presence, not that these attributes can actually be populated. The underlying issue is that the create() method doesn't accept or populate these new attributes.

✅ Add missing 'location' attribute to expected list
         $expectedAttributes = [
             'userType',
             'userId',
             'userInternalId',
             'resourceParent',
             'resourceType',
             'resourceId',
             'resourceInternalId',
             'event',
             'resource',
             'userAgent',
             'ip',
+            'location',
             'country',
             'time',
             'data',
             'projectId',
             'projectInternalId',
             'teamId',
             'teamInternalId',
             'hostname'
         ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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");
}
}
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',
'location',
'country',
'time',
'data',
'projectId',
'projectInternalId',
'teamId',
'teamInternalId',
'hostname'
];
foreach ($expectedAttributes as $expected) {
$this->assertContains($expected, $attributeIds, "Attribute '{$expected}' not found in ClickHouse adapter");
}
}
🤖 Prompt for AI Agents
In @tests/Audit/Adapter/ClickHouseTest.php around lines 323 - 360, The test
testClickHouseAdapterAttributes is missing the 'location' attribute in its
$expectedAttributes list and the adapter's creation path doesn't accept/populate
it; add 'location' to the $expectedAttributes array in
testClickHouseAdapterAttributes to assert its presence, and update the
ClickHouse create() (or the parent SQL adapter create() method that handles
audit fields) to accept a 'location' input and populate the corresponding
schema/attribute so the attribute can actually be stored and retrieved via
ClickHouse::getAttributes().

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
tests/Audit/Adapter/ClickHouseTest.php (2)

323-360: Consider asserting “missing attributes” in one shot (clearer failures, less noise).

Current loop works, but a single array_diff-based assertion typically produces cleaner failure output and fewer assertions; also array_column($attributes, '$id') is simpler than array_map.

Proposed tweak
-        $attributes = $adapter->getAttributes();
-        $attributeIds = array_map(fn ($attr) => $attr['$id'], $attributes);
+        $attributes = $adapter->getAttributes();
+        $attributeIds = array_column($attributes, '$id');

         // 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");
-        }
+        $missing = array_diff($expectedAttributes, $attributeIds);
+        $this->assertEmpty($missing, 'Missing attributes: ' . implode(', ', $missing));

365-396: Index coverage looks good; same “single missing set” assertion could simplify (and note: parent index IDs are hard-coded).

The ClickHouse-specific index assertions align with the PR objective. If the parent idx_* IDs aren’t a stable contract, this portion may be the first to become brittle on upstream schema changes.

Proposed tweak (also uses array_column)
-        $indexes = $adapter->getIndexes();
-        $indexIds = array_map(fn ($idx) => $idx['$id'], $indexes);
+        $indexes = $adapter->getIndexes();
+        $indexIds = array_column($indexes, '$id');

         // 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");
-        }
+        $missingClickHouse = array_diff($expectedClickHouseIndexes, $indexIds);
+        $this->assertEmpty($missingClickHouse, 'Missing ClickHouse indexes: ' . implode(', ', $missingClickHouse));

         // 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");
-        }
+        $missingParent = array_diff($parentExpectedIndexes, $indexIds);
+        $this->assertEmpty($missingParent, 'Missing parent indexes: ' . implode(', ', $missingParent));
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between aa4f0d4 and fc4a15d.

📒 Files selected for processing (1)
  • tests/Audit/Adapter/ClickHouseTest.php
🧰 Additional context used
🧬 Code graph analysis (1)
tests/Audit/Adapter/ClickHouseTest.php (1)
src/Audit/Adapter/ClickHouse.php (1)
  • ClickHouse (17-1295)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fc4a15d and 207d1dd.

📒 Files selected for processing (5)
  • src/Audit/Adapter.php
  • src/Audit/Adapter/ClickHouse.php
  • src/Audit/Adapter/Database.php
  • src/Audit/Audit.php
  • tests/Audit/AuditBase.php
🧰 Additional context used
🧬 Code graph analysis (5)
src/Audit/Audit.php (4)
src/Audit/Log.php (1)
  • Log (15-209)
src/Audit/Adapter.php (1)
  • getById (35-35)
src/Audit/Adapter/ClickHouse.php (1)
  • getById (711-729)
src/Audit/Adapter/Database.php (1)
  • getById (112-127)
tests/Audit/AuditBase.php (2)
src/Audit/Audit.php (2)
  • log (61-72)
  • getLogById (95-98)
src/Audit/Log.php (1)
  • getId (32-36)
src/Audit/Adapter.php (3)
src/Audit/Adapter/ClickHouse.php (1)
  • getById (711-729)
src/Audit/Adapter/Database.php (1)
  • getById (112-127)
src/Audit/Log.php (1)
  • Log (15-209)
src/Audit/Adapter/Database.php (3)
src/Audit/Adapter.php (1)
  • getById (35-35)
src/Audit/Adapter/ClickHouse.php (1)
  • getById (711-729)
src/Audit/Log.php (2)
  • Log (15-209)
  • getArrayCopy (205-208)
src/Audit/Adapter/ClickHouse.php (4)
src/Audit/Adapter/Database.php (2)
  • Database (20-454)
  • getById (112-127)
src/Audit/Adapter/SQL.php (2)
  • getAttributes (43-120)
  • getIndexes (142-166)
src/Audit/Adapter.php (1)
  • getById (35-35)
src/Audit/Log.php (1)
  • Log (15-209)
🔇 Additional comments (7)
src/Audit/Audit.php (1)

87-98: LGTM!

The delegation to the adapter is correct and the method signature aligns with the adapter contract.

src/Audit/Adapter.php (1)

27-35: LGTM!

The abstract method declaration establishes a clear contract for retrieving a single log by ID across all adapter implementations.

src/Audit/Adapter/Database.php (1)

105-127: LGTM!

The implementation follows the established patterns in this adapter: authorization skip, empty document handling, and consistent exception handling that returns null.

tests/Audit/AuditBase.php (1)

158-186: LGTM!

The test provides comprehensive coverage of the getLogById functionality, including validation of all log fields and proper handling of non-existent IDs.

src/Audit/Adapter/ClickHouse.php (3)

7-7: LGTM!

The Database import is necessary for the constants used in getAttributes() and getIndexes() methods.


392-455: LGTM!

The extended indexes are well-structured for query optimization. The composite index on userInternalId and event, along with single-column indexes on the new attributes, will support efficient filtering and retrieval.


704-729: LGTM!

The getById implementation correctly follows the ClickHouse adapter patterns: parameterized queries for safety, tenant filtering for multi-tenancy support, and appropriate result parsing.

Comment on lines +252 to +390
/**
* Override getAttributes to provide extended attributes for ClickHouse.
* Includes existing attributes from parent and adds new missing ones.
*
* @return array<int, array<string, mixed>>
*/
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' => [],
],
];
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for usage of the new attribute fields
echo "=== Searching for usage of new attribute fields ==="
for field in userType userInternalId resourceParent resourceType resourceId resourceInternalId country projectId projectInternalId teamId teamInternalId hostname; do
    echo "--- Searching for: $field ---"
    rg -n -C2 "\b$field\b" --type=php -g '!Test.php' | head -20
done

Repository: utopia-php/audit

Length of output: 11609


🏁 Script executed:

#!/bin/bash
# Examine the create() and createBatch() methods in the ClickHouse adapter
echo "=== Examining create() and createBatch() methods ==="
rg -n -A30 "public function (create|createBatch)" src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 2753


🏁 Script executed:

#!/bin/bash
# Check parent class getAttributes() method
echo "=== Searching for parent class and its getAttributes() ==="
rg -n "class ClickHouse" src/Audit/Adapter/ClickHouse.php | head -5

Repository: utopia-php/audit

Length of output: 150


🏁 Script executed:

#!/bin/bash
# Look for the parent class definition
rg -n "extends" src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Search for where these attributes might be set during initialization
echo "=== Searching for setAttribute or attribute population ==="
rg -n "(setAttribute|setUserType|setResourceType|setProjectId|setTeamId)" src/Audit/Adapter/ClickHouse.php

Repository: utopia-php/audit

Length of output: 118


Fix contradictory field definitions and populate required attributes in create methods.

Multiple attributes are marked as required: true but have default: null, which is contradictory. Required fields should not have null defaults. Additionally, these 12 new attributes (userType, userInternalId, resourceParent, resourceType, resourceId, resourceInternalId, country, projectId, projectInternalId, teamId, teamInternalId, hostname) are never populated in the create() or createBatch() methods, causing ClickHouse INSERT operations to fail since required fields have no values.

Either:

  1. Change required to false for optional attributes and provide appropriate defaults for ClickHouse
  2. If these fields are required, populate them in create() and createBatch() from the caller's input or application context

The attributes are already indexed, indicating they should be populated and available for queries.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In @src/Audit/Adapter/ClickHouse.php:
- Around line 744-759: The code accesses $parsed['select'] (which parseQueries
never returns) and uses null-coalescing on $parsed['filters'] even though
filters always exists; modify the logic in the ClickHouse adapter to always use
$this->getSelectColumns() instead of $parsed['select'] and read filters directly
from $parsed['filters'] (e.g., $conditions = $parsed['filters'];) before
appending the tenant filter from $this->getTenantFilter(); if you plan to
support selectable columns later, update parseQueries to include a 'select'
return entry and the query TYPE_SELECT handling instead of referencing 'select'
here.
- Around line 851-859: Query::TYPE_LIMIT and Query::TYPE_OFFSET handlers cast
$values[0] (a mixed) directly to int which PHPStan flags; update both branches
to safely extract and validate the value before casting: check isset($values[0])
and that it is numeric (or use is_int/is_string checks), then cast to int,
otherwise use a sensible default (e.g. 0), and assign to $params['limit'] and
$params['offset'] respectively; reference the Query::TYPE_LIMIT and
Query::TYPE_OFFSET cases and the $values/$params variables when making the
change.
🧹 Nitpick comments (2)
src/Audit/Query.php (2)

235-254: Consider validating method against known query types.

The parseQuery method validates that method, attribute, and values have correct types, but doesn't verify that method is one of the defined TYPE_* constants. While the ClickHouse adapter silently skips unknown methods, explicit validation here would fail fast and help debug malformed queries.

♻️ Optional improvement
 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));
     }

+    $validMethods = [
+        self::TYPE_EQUAL,
+        self::TYPE_GREATER,
+        self::TYPE_LESSER,
+        self::TYPE_BETWEEN,
+        self::TYPE_IN,
+        self::TYPE_ORDER_DESC,
+        self::TYPE_ORDER_ASC,
+        self::TYPE_LIMIT,
+        self::TYPE_OFFSET,
+    ];
+
+    if (!in_array($method, $validMethods, true)) {
+        throw new \Exception('Unknown query method: ' . $method);
+    }
+
     if (!\is_string($attribute)) {
         throw new \Exception('Invalid query attribute. Must be a string, got ' . \gettype($attribute));
     }

5-10: Consider updating class docblock for broader applicability.

The docblock states this is "for ClickHouse Audit adapter", but the class is generic and could be used by any adapter implementing query parsing. Consider updating the description.

 /**
- * Query class for ClickHouse Audit adapter
+ * Query class for Audit adapters
  *
- * Provides a fluent interface for building ClickHouse queries.
+ * Provides a fluent interface for building audit log queries.
  * Contains only methods needed for audit log operations.
  */
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 207d1dd and ef6b30f.

📒 Files selected for processing (2)
  • src/Audit/Adapter/ClickHouse.php
  • src/Audit/Query.php
🧰 Additional context used
🧬 Code graph analysis (1)
src/Audit/Query.php (1)
src/Audit/Adapter/ClickHouse.php (3)
  • __construct (57-78)
  • query (494-532)
  • parseQueries (788-881)
🪛 GitHub Actions: CodeQL
src/Audit/Adapter/ClickHouse.php

[error] 748-748: PHPStan: Offset 'select' on a non-existent offset in the array structure. See log line for details.


[error] 754-754: PHPStan: Offset 'filters' on array{filters: array, params: array<string, mixed>, orderBy?: array, limit?: int, offset?: int} on left side of ?? always exists and is not nullable.


[error] 852-852: PHPStan: Cannot cast mixed to int.


[error] 857-857: PHPStan: Cannot cast mixed to int.

🔇 Additional comments (8)
src/Audit/Adapter/ClickHouse.php (4)

253-391: LGTM - Extended attributes are well-defined.

The attribute definitions follow the established pattern from the parent class, with appropriate type definitions and required flags. The spread operator usage to merge with parent attributes is clean.


393-456: LGTM - Index definitions are consistent.

The new indexes align with the extended attributes and follow the same structure as parent indexes. Using bloom_filter is appropriate for ClickHouse string-based filtering.


705-730: LGTM - getById implementation is correct.

The method properly applies tenant filtering, uses parameterized queries to prevent SQL injection, and returns null when no result is found.


7-8: LGTM - Imports are appropriate.

The new imports for Query and Database are correctly added for the extended functionality.

src/Audit/Query.php (4)

11-48: LGTM - Class structure and constructor are well-designed.

The Query class follows a clean builder pattern with proper encapsulation. The constructor accepts flexible parameters for different query types.


50-81: LGTM - Accessor methods are straightforward.

The getters provide appropriate access to internal state.


83-186: LGTM - Factory methods provide a clean fluent API.

The static factory methods (equal, lessThan, greaterThan, between, in, orderDesc, orderAsc, limit, offset) offer a type-safe way to construct queries, reducing the chance of typos in method names.


256-287: LGTM - Serialization methods are correct.

The toArray and toString methods properly handle JSON encoding with error handling via JSON_THROW_ON_ERROR.

Comment on lines +806 to +849
switch ($method) {
case Query::TYPE_EQUAL:
$paramName = 'param_' . $paramCounter++;
$filters[] = "{$attribute} = {{$paramName}:String}";
$params[$paramName] = $this->formatParamValue($values[0]);
break;

case Query::TYPE_LESSER:
$paramName = 'param_' . $paramCounter++;
$filters[] = "{$attribute} < {{$paramName}:String}";
$params[$paramName] = $this->formatParamValue($values[0]);
break;

case Query::TYPE_GREATER:
$paramName = 'param_' . $paramCounter++;
$filters[] = "{$attribute} > {{$paramName}:String}";
$params[$paramName] = $this->formatParamValue($values[0]);
break;

case Query::TYPE_BETWEEN:
$paramName1 = 'param_' . $paramCounter++;
$paramName2 = 'param_' . $paramCounter++;
$filters[] = "{$attribute} BETWEEN {{$paramName1}:String} AND {{$paramName2}:String}";
$params[$paramName1] = $this->formatParamValue($values[0]);
$params[$paramName2] = $this->formatParamValue($values[1]);
break;

case Query::TYPE_IN:
$inParams = [];
foreach ($values as $value) {
$paramName = 'param_' . $paramCounter++;
$inParams[] = "{{$paramName}:String}";
$params[$paramName] = $this->formatParamValue($value);
}
$filters[] = "{$attribute} IN (" . implode(', ', $inParams) . ")";
break;

case Query::TYPE_ORDER_DESC:
$orderBy[] = "{$attribute} DESC";
break;

case Query::TYPE_ORDER_ASC:
$orderBy[] = "{$attribute} ASC";
break;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SQL injection risk: Attribute names are interpolated without validation.

The $attribute value from Query::getAttribute() is directly interpolated into SQL fragments (e.g., "{$attribute} = {{$paramName}:String}"). While parameter values use ClickHouse's safe parameter binding, column/attribute names are not validated or escaped.

If user input can influence the attribute name (e.g., via Query::parse() from external JSON), this could allow SQL injection.

🔧 Suggested mitigation

Either validate against an allowlist of known attributes:

private function validateAttribute(string $attribute): void
{
    $validAttributes = array_column($this->getAttributes(), '$id');
    $validAttributes[] = 'id'; // Add 'id' column
    
    if (!in_array($attribute, $validAttributes, true)) {
        throw new Exception("Invalid attribute: {$attribute}");
    }
}

Or escape the identifier before interpolation:

 case Query::TYPE_EQUAL:
     $paramName = 'param_' . $paramCounter++;
-    $filters[] = "{$attribute} = {{$paramName}:String}";
+    $escapedAttr = $this->escapeIdentifier($attribute);
+    $filters[] = "{$escapedAttr} = {{$paramName}:String}";
     $params[$paramName] = $this->formatParamValue($values[0]);
     break;

Apply similar escaping to all cases that use $attribute.

Comment on lines +843 to +849
case Query::TYPE_ORDER_DESC:
$orderBy[] = "{$attribute} DESC";
break;

case Query::TYPE_ORDER_ASC:
$orderBy[] = "{$attribute} ASC";
break;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ORDER BY clause also susceptible to attribute injection.

Same concern as the filter attributes - $attribute is interpolated directly into the ORDER BY clause without validation or escaping.

Apply the same mitigation (allowlist validation or identifier escaping) as suggested for filter attributes.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @tests/Audit/QueryTest.php:
- Around line 105-123: The test testQueryParseQueries uses a JSON query with
"method":"greaterThan" which doesn't match the Query::TYPE_GREATER constant
expected by assertions; update the input JSON in testQueryParseQueries to use
the canonical method name ("greater") or adjust Query::parseQueries to map
"greaterThan" to Query::TYPE_GREATER; specifically, change the JSON entry in the
$queries array from "method":"greaterThan" to "method":"greater" (or add mapping
logic in Query::parseQueries) so the parsed object's getMethod() equals
Query::TYPE_GREATER.
🧹 Nitpick comments (1)
tests/Audit/Adapter/ClickHouseTest.php (1)

399-613: Comprehensive query test coverage.

These tests effectively validate the find method with various query types (equal, multiple filters, IN, ordering, limit/offset). The test data is well-isolated and assertions are appropriate.

Note: Line 537 uses sleep(1) to ensure different timestamps. While this works, it slows down tests. Consider using microsecond precision or mocking time if test performance becomes a concern.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ef6b30f and c83605e.

📒 Files selected for processing (2)
  • tests/Audit/Adapter/ClickHouseTest.php
  • tests/Audit/QueryTest.php
🧰 Additional context used
🧬 Code graph analysis (1)
tests/Audit/QueryTest.php (1)
src/Audit/Query.php (16)
  • Query (11-288)
  • equal (90-93)
  • getMethod (53-56)
  • getValues (69-72)
  • lessThan (102-105)
  • greaterThan (114-117)
  • between (127-130)
  • in (139-142)
  • orderDesc (150-153)
  • orderAsc (161-164)
  • limit (172-175)
  • offset (183-186)
  • parse (195-208)
  • toString (280-287)
  • toArray (261-272)
  • getValue (78-81)
🪛 GitHub Actions: Tests
tests/Audit/QueryTest.php

[error] 121-121: Failed asserting that two strings are equal. Expected 'greater', Actual 'greaterThan'.

🔇 Additional comments (8)
tests/Audit/Adapter/ClickHouseTest.php (3)

9-9: LGTM!

The import of the Query class is necessary for the new find test methods that use Query objects for building queries.


321-397: LGTM!

These tests provide good coverage for verifying that the ClickHouse adapter exposes the correct attributes and indexes. The use of descriptive error messages in assertions will help diagnose issues if the tests fail.


615-669: LGTM!

Good use of logBatch with explicit timestamps to test the between query functionality. The test correctly validates time-based filtering.

tests/Audit/QueryTest.php (5)

13-68: LGTM!

Comprehensive test coverage of all Query factory methods. The assertions correctly verify the method, attribute, and values for each query type.


73-100: LGTM!

Good test coverage for serialization and deserialization. The round-trip testing (create → toString → parse → verify) ensures consistency.


128-154: LGTM!

Good coverage of the getValue method, including testing with default values and queries that have empty values.


159-209: LGTM!

Excellent coverage of error scenarios with appropriate exception messages. These tests ensure that invalid input is properly rejected with clear error messages.


214-224: LGTM!

Good test for complex values with multiple elements. The round-trip verification ensures that multi-value queries (like between) serialize and deserialize correctly.

Comment on lines +105 to +123
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());
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Fix method name mismatch causing pipeline failure.

The pipeline failure indicates that line 121 expects 'greater' but gets 'greaterThan'. The issue is at line 109 where the JSON contains "method":"greaterThan", but the Query::TYPE_GREATER constant is likely defined as 'greater'.

🐛 Proposed fix
     $queries = [
         '{"method":"equal","attribute":"userId","values":["123"]}',
-        '{"method":"greaterThan","attribute":"time","values":["2023-01-01"]}',
+        '{"method":"greater","attribute":"time","values":["2023-01-01"]}',
         '{"method":"limit","values":[10]}'
     ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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());
}
public function testQueryParseQueries(): void
{
$queries = [
'{"method":"equal","attribute":"userId","values":["123"]}',
'{"method":"greater","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());
}
🧰 Tools
🪛 GitHub Actions: Tests

[error] 121-121: Failed asserting that two strings are equal. Expected 'greater', Actual 'greaterThan'.

🤖 Prompt for AI Agents
In @tests/Audit/QueryTest.php around lines 105 - 123, The test
testQueryParseQueries uses a JSON query with "method":"greaterThan" which
doesn't match the Query::TYPE_GREATER constant expected by assertions; update
the input JSON in testQueryParseQueries to use the canonical method name
("greater") or adjust Query::parseQueries to map "greaterThan" to
Query::TYPE_GREATER; specifically, change the JSON entry in the $queries array
from "method":"greaterThan" to "method":"greater" (or add mapping logic in
Query::parseQueries) so the parsed object's getMethod() equals
Query::TYPE_GREATER.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Audit/Adapter/ClickHouse.php (1)

646-702: Missing required fields will cause INSERT failures: userType, resourceType, resourceId, projectId, projectInternalId, teamId, teamInternalId, and hostname are declared as required=true in getAttributes(), which creates NOT NULL columns in the table schema, but create() never populates them.

The setup() method builds columns from getAttributes() and applies NOT NULL constraints to all required=true fields (line 1208). However, create() only inserts the 7 base fields (userId, event, resource, userAgent, ip, location, data) plus id and time. ClickHouse will reject these INSERTs due to missing required columns.

Either:

  1. Add these fields to the create() method and accept them in the public API
  2. Change these attributes to required=false
  3. Provide default values in the column definition
🤖 Fix all issues with AI agents
In @src/Audit/Adapter/ClickHouse.php:
- Around line 893-901: The direct casts of $values[0] to int in the
Query::TYPE_LIMIT and Query::TYPE_OFFSET branches are unsafe because $values is
mixed; validate and normalize the value before assigning to $params: check that
isset($values[0]) and that it is an int or numeric string (use
is_int/is_numeric), then cast to int; if validation fails, either set a safe
default (e.g., 0) or throw a clear exception. Update the logic around the LIMIT
and OFFSET case blocks (referencing Query::TYPE_LIMIT, Query::TYPE_OFFSET,
$values, $limit, $offset, and $params) to perform the validation/coercion and
handle invalid input accordingly.
- Around line 746-758: The code reads $parsed['select'] and uses
$parsed['filters'] with a redundant null-coalescing despite parseQueries() never
populating 'select' and always returning 'filters' => []; update the logic in
the SELECT/WHERE build in ClickHouse.php to stop relying on $parsed['select']
(use getSelectColumns() exclusively or implement select support inside
parseQueries()), and simplify the filters handling by assigning $conditions =
$parsed['filters'] (remove the ?? []), keeping the tenant merge via
getTenantFilter() and the final implode(' AND ', $conditions) as-is.
- Around line 798-804: The code uses a redundant null-coalescing operator for
$parsed['filters'] even though parseQueries() always defines that key; in the
block that builds $conditions (inside the method referencing getTenantFilter()),
replace the usage of $parsed['filters'] ?? [] with the guaranteed
$parsed['filters'] (e.g., set $conditions = $parsed['filters']), keep the
tenantFilter append logic as-is, and ensure the resulting $whereClause
construction remains unchanged; mirror the same fix you applied in find() so
there’s no unnecessary ?? operator.

In @tests/Audit/Adapter/ClickHouseTest.php:
- Around line 865-916: The timestamps in testCountWithBetweenQuery include a
timezone suffix (+00:00) which is incompatible with ClickHouse DateTime64(3);
update the three variables ($time1, $time2, $time3) and the Query::between call
to use timestamps without the timezone suffix (e.g. '2023-01-01 00:00:00',
'2023-06-01 00:00:00', '2023-12-31 23:59:59' and between('time', '2023-05-01
00:00:00', '2023-12-31 00:00:00')), leaving the rest of
testCountWithBetweenQuery and logBatch data unchanged so ClickHouse accepts the
DateTime64(3) values.
- Around line 618-669: The test testFindWithBetweenQuery is using timestamps
with a +00:00 timezone suffix which ClickHouse DateTime64(3) cannot parse;
update the three $time variables passed to logBatch and the Query::between
bounds to use ClickHouse-compatible format (e.g., "YYYY-MM-DD HH:MM:SS" or
"YYYY-MM-DD HH:MM:SS.mmm" without any timezone suffix) so ClickHouse can ingest
and query them; modify the timestamps in testFindWithBetweenQuery (and the
Query::between call) to remove "+00:00" and optionally include milliseconds like
".000" to match DateTime64(3).
🧹 Nitpick comments (1)
tests/Audit/Adapter/ClickHouseTest.php (1)

537-537: Consider using explicit timestamps instead of sleep(1) for faster tests.

Using sleep(1) slows down the test suite. Consider using logBatch with explicit timestamps for deterministic ordering without delay.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c83605e and d1d0a43.

📒 Files selected for processing (2)
  • src/Audit/Adapter/ClickHouse.php
  • tests/Audit/Adapter/ClickHouseTest.php
🧰 Additional context used
🧬 Code graph analysis (2)
tests/Audit/Adapter/ClickHouseTest.php (3)
src/Audit/Audit.php (4)
  • Audit (12-259)
  • log (61-72)
  • getAdapter (31-34)
  • logBatch (82-85)
src/Audit/Query.php (8)
  • Query (11-288)
  • equal (90-93)
  • in (139-142)
  • orderAsc (161-164)
  • orderDesc (150-153)
  • limit (172-175)
  • offset (183-186)
  • between (127-130)
src/Audit/Adapter/ClickHouse.php (5)
  • ClickHouse (18-1516)
  • getAttributes (259-391)
  • getIndexes (399-456)
  • find (739-779)
  • count (788-821)
src/Audit/Adapter/ClickHouse.php (4)
src/Audit/Query.php (7)
  • Query (11-288)
  • parseQueries (217-226)
  • limit (172-175)
  • offset (183-186)
  • getMethod (53-56)
  • getAttribute (61-64)
  • getValues (69-72)
src/Audit/Adapter/SQL.php (3)
  • getAttributes (43-120)
  • getIndexes (142-166)
  • getAttribute (184-193)
src/Audit/Adapter.php (1)
  • getById (35-35)
src/Audit/Log.php (2)
  • Log (15-209)
  • getAttribute (157-160)
🪛 GitHub Actions: CodeQL
src/Audit/Adapter/ClickHouse.php

[error] 748-748: Offset 'select' on array{filters: array, params: array<string, mixed>, orderBy?: array, limit?: int, offset?: int} on left side of ?? does not exist.


[error] 754-754: Offset 'filters' on array{filters: array, params: array<string, mixed>, orderBy?: array, limit?: int, offset?: int} on left side of ?? always exists and is not nullable.


[error] 800-800: Offset 'filters' on array{filters: array, params: array<string, mixed>, orderBy?: array, limit?: int, offset?: int} on left side of ?? always exists and is not nullable.


[error] 894-894: Cannot cast mixed to int.


[error] 899-899: Cannot cast mixed to int.

🪛 GitHub Actions: Tests
tests/Audit/Adapter/ClickHouseTest.php

[error] 662-662: Caused by: ClickHouse query failed with HTTP 400: TYPE_MISMATCH due to converting '+00:00' to DateTime64(3).


[error] 909-909: Caused by: ClickHouse query failed with HTTP 400: TYPE_MISMATCH due to converting '+00:00' to DateTime64(3).

src/Audit/Adapter/ClickHouse.php

[error] 526-526: ClickHouse query execution failed: Cannot convert string '+00:00' to DateTime64(3) during greaterOrEquals comparison. (TYPE_MISMATCH)


[error] 518-518: ClickHouse query execution failed: Cannot convert string '+00:00' to DateTime64(3) during greaterOrEquals comparison. (TYPE_MISMATCH)

🔇 Additional comments (6)
tests/Audit/Adapter/ClickHouseTest.php (2)

324-361: LGTM!

The attribute verification test is well-structured, checking that all extended attributes from the ClickHouse adapter are present.


366-397: LGTM!

The index verification test correctly validates both ClickHouse-specific indexes and inherited parent indexes.

src/Audit/Adapter/ClickHouse.php (4)

7-8: LGTM!

Imports are correctly added for the new Query class and Database constants used in attribute/index definitions.


259-391: LGTM!

The extended attribute definitions follow the parent schema pattern correctly and add all necessary ClickHouse-specific fields.


399-456: LGTM!

Index definitions properly extend parent indexes and add appropriate single and composite indexes for the new attributes.


712-730: LGTM!

The getById method correctly uses parameterized queries and handles tenant filtering.

Comment on lines +746 to +758

// Build SELECT clause
$selectColumns = $parsed['select'] ?? $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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix static analysis issues in query parsing.

CodeQL reports two issues:

  1. $parsed['select'] (line 748) is accessed but never populated by parseQueries() - either remove this or add select support to parseQueries().
  2. $parsed['filters'] ?? [] (line 754) is redundant since parseQueries() always returns 'filters' => [].
🔧 Proposed fix
     // Parse queries
     $parsed = $this->parseQueries($queries);

     // Build SELECT clause
-    $selectColumns = $parsed['select'] ?? $this->getSelectColumns();
+    $selectColumns = $this->getSelectColumns();

     // Build WHERE clause
     $whereClause = '';
     $tenantFilter = $this->getTenantFilter();
     if (!empty($parsed['filters']) || $tenantFilter) {
-        $conditions = $parsed['filters'] ?? [];
+        $conditions = $parsed['filters'];
         if ($tenantFilter) {
             $conditions[] = ltrim($tenantFilter, ' AND');
         }
         $whereClause = ' WHERE ' . implode(' AND ', $conditions);
     }
🧰 Tools
🪛 GitHub Actions: CodeQL

[error] 748-748: Offset 'select' on array{filters: array, params: array<string, mixed>, orderBy?: array, limit?: int, offset?: int} on left side of ?? does not exist.


[error] 754-754: Offset 'filters' on array{filters: array, params: array<string, mixed>, orderBy?: array, limit?: int, offset?: int} on left side of ?? always exists and is not nullable.

🤖 Prompt for AI Agents
In @src/Audit/Adapter/ClickHouse.php around lines 746 - 758, The code reads
$parsed['select'] and uses $parsed['filters'] with a redundant null-coalescing
despite parseQueries() never populating 'select' and always returning 'filters'
=> []; update the logic in the SELECT/WHERE build in ClickHouse.php to stop
relying on $parsed['select'] (use getSelectColumns() exclusively or implement
select support inside parseQueries()), and simplify the filters handling by
assigning $conditions = $parsed['filters'] (remove the ?? []), keeping the
tenant merge via getTenantFilter() and the final implode(' AND ', $conditions)
as-is.

Comment on lines +798 to +804
$tenantFilter = $this->getTenantFilter();
if (!empty($parsed['filters']) || $tenantFilter) {
$conditions = $parsed['filters'] ?? [];
if ($tenantFilter) {
$conditions[] = ltrim($tenantFilter, ' AND');
}
$whereClause = ' WHERE ' . implode(' AND ', $conditions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Redundant null-coalescing operator.

Same issue as in find()$parsed['filters'] is always defined by parseQueries().

🔧 Proposed fix
     if (!empty($parsed['filters']) || $tenantFilter) {
-        $conditions = $parsed['filters'] ?? [];
+        $conditions = $parsed['filters'];
         if ($tenantFilter) {
             $conditions[] = ltrim($tenantFilter, ' AND');
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$tenantFilter = $this->getTenantFilter();
if (!empty($parsed['filters']) || $tenantFilter) {
$conditions = $parsed['filters'] ?? [];
if ($tenantFilter) {
$conditions[] = ltrim($tenantFilter, ' AND');
}
$whereClause = ' WHERE ' . implode(' AND ', $conditions);
$tenantFilter = $this->getTenantFilter();
if (!empty($parsed['filters']) || $tenantFilter) {
$conditions = $parsed['filters'];
if ($tenantFilter) {
$conditions[] = ltrim($tenantFilter, ' AND');
}
$whereClause = ' WHERE ' . implode(' AND ', $conditions);
🧰 Tools
🪛 GitHub Actions: CodeQL

[error] 800-800: Offset 'filters' on array{filters: array, params: array<string, mixed>, orderBy?: array, limit?: int, offset?: int} on left side of ?? always exists and is not nullable.

🤖 Prompt for AI Agents
In @src/Audit/Adapter/ClickHouse.php around lines 798 - 804, The code uses a
redundant null-coalescing operator for $parsed['filters'] even though
parseQueries() always defines that key; in the block that builds $conditions
(inside the method referencing getTenantFilter()), replace the usage of
$parsed['filters'] ?? [] with the guaranteed $parsed['filters'] (e.g., set
$conditions = $parsed['filters']), keep the tenantFilter append logic as-is, and
ensure the resulting $whereClause construction remains unchanged; mirror the
same fix you applied in find() so there’s no unnecessary ?? operator.

Comment on lines +867 to +873
case Query::TYPE_BETWEEN:
$paramName1 = 'param_' . $paramCounter++;
$paramName2 = 'param_' . $paramCounter++;
$filters[] = "{$attribute} BETWEEN {{$paramName1}:String} AND {{$paramName2}:String}";
$params[$paramName1] = $this->formatParamValue($values[0]);
$params[$paramName2] = $this->formatParamValue($values[1]);
break;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

DateTime64 type mismatch in BETWEEN queries.

The pipeline fails when comparing the time column (DateTime64) with String-typed parameters containing timezone suffixes. ClickHouse cannot implicitly convert '+00:00' suffixed strings to DateTime64.

Consider using explicit DateTime64 casting for the time attribute or stripping timezone suffixes before comparison.

🐛 Proposed fix: Use DateTime64 type for time attribute
             case Query::TYPE_BETWEEN:
                 $paramName1 = 'param_' . $paramCounter++;
                 $paramName2 = 'param_' . $paramCounter++;
-                $filters[] = "{$attribute} BETWEEN {{$paramName1}:String} AND {{$paramName2}:String}";
-                $params[$paramName1] = $this->formatParamValue($values[0]);
-                $params[$paramName2] = $this->formatParamValue($values[1]);
+                // Use DateTime64 type for time column, String for others
+                $paramType = ($attribute === 'time') ? 'DateTime64(3)' : 'String';
+                $filters[] = "{$attribute} BETWEEN {{$paramName1}:{$paramType}} AND {{$paramName2}:{$paramType}}";
+                $params[$paramName1] = $this->formatDateTimeParam($values[0]);
+                $params[$paramName2] = $this->formatDateTimeParam($values[1]);
                 break;

Add helper method to format datetime values:

private function formatDateTimeParam(mixed $value): string
{
    $strValue = $this->formatParamValue($value);
    // Remove timezone suffix if present (e.g., +00:00)
    return preg_replace('/[+-]\d{2}:\d{2}$/', '', $strValue) ?? $strValue;
}

Comment on lines +893 to +901
case Query::TYPE_LIMIT:
$limit = (int) $values[0];
$params['limit'] = $limit;
break;

case Query::TYPE_OFFSET:
$offset = (int) $values[0];
$params['offset'] = $offset;
break;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unsafe cast from mixed to int.

CodeQL reports that casting $values[0] (type mixed) directly to int is not type-safe. Add validation or explicit type coercion.

🔧 Proposed fix
             case Query::TYPE_LIMIT:
-                $limit = (int) $values[0];
+                $limit = isset($values[0]) && is_numeric($values[0]) ? (int) $values[0] : 25;
                 $params['limit'] = $limit;
                 break;

             case Query::TYPE_OFFSET:
-                $offset = (int) $values[0];
+                $offset = isset($values[0]) && is_numeric($values[0]) ? (int) $values[0] : 0;
                 $params['offset'] = $offset;
                 break;
🧰 Tools
🪛 GitHub Actions: CodeQL

[error] 894-894: Cannot cast mixed to int.


[error] 899-899: Cannot cast mixed to int.

🤖 Prompt for AI Agents
In @src/Audit/Adapter/ClickHouse.php around lines 893 - 901, The direct casts of
$values[0] to int in the Query::TYPE_LIMIT and Query::TYPE_OFFSET branches are
unsafe because $values is mixed; validate and normalize the value before
assigning to $params: check that isset($values[0]) and that it is an int or
numeric string (use is_int/is_numeric), then cast to int; if validation fails,
either set a safe default (e.g., 0) or throw a clear exception. Update the logic
around the LIMIT and OFFSET case blocks (referencing Query::TYPE_LIMIT,
Query::TYPE_OFFSET, $values, $limit, $offset, and $params) to perform the
validation/coercion and handle invalid input accordingly.

Comment on lines 618 to 669
public function testFindWithBetweenQuery(): void
{
$time1 = '2023-01-01 00:00:00+00:00';
$time2 = '2023-06-01 00:00:00+00:00';
$time3 = '2023-12-31 23:59:59+00:00';

// Create test data with different times using logBatch
$this->audit->logBatch([
[
'userId' => 'betweenUser',
'event' => 'event1',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time1
],
[
'userId' => 'betweenUser',
'event' => 'event2',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time2
],
[
'userId' => 'betweenUser',
'event' => 'event3',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time3
]
]);

/** @var ClickHouse $adapter */
$adapter = $this->audit->getAdapter();

// Test between query
$logs = $adapter->find([
Query::equal('userId', 'betweenUser'),
Query::between('time', '2023-05-01 00:00:00+00:00', '2023-12-31 00:00:00+00:00')
]);

$this->assertIsArray($logs);
$this->assertGreaterThan(0, count($logs));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix datetime format for ClickHouse compatibility.

The pipeline is failing because ClickHouse's DateTime64(3) type cannot parse the +00:00 timezone suffix. ClickHouse expects timestamps in 'YYYY-MM-DD HH:MM:SS.mmm' format without timezone suffix, as the column metadata handles timezone.

🐛 Proposed fix: Remove timezone suffix from test timestamps
     public function testFindWithBetweenQuery(): void
     {
-        $time1 = '2023-01-01 00:00:00+00:00';
-        $time2 = '2023-06-01 00:00:00+00:00';
-        $time3 = '2023-12-31 23:59:59+00:00';
+        $time1 = '2023-01-01 00:00:00.000';
+        $time2 = '2023-06-01 00:00:00.000';
+        $time3 = '2023-12-31 23:59:59.999';
         // ... rest of test setup ...
         
         // Test between query
         $logs = $adapter->find([
             Query::equal('userId', 'betweenUser'),
-            Query::between('time', '2023-05-01 00:00:00+00:00', '2023-12-31 00:00:00+00:00')
+            Query::between('time', '2023-05-01 00:00:00.000', '2023-12-31 00:00:00.000')
         ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function testFindWithBetweenQuery(): void
{
$time1 = '2023-01-01 00:00:00+00:00';
$time2 = '2023-06-01 00:00:00+00:00';
$time3 = '2023-12-31 23:59:59+00:00';
// Create test data with different times using logBatch
$this->audit->logBatch([
[
'userId' => 'betweenUser',
'event' => 'event1',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time1
],
[
'userId' => 'betweenUser',
'event' => 'event2',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time2
],
[
'userId' => 'betweenUser',
'event' => 'event3',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time3
]
]);
/** @var ClickHouse $adapter */
$adapter = $this->audit->getAdapter();
// Test between query
$logs = $adapter->find([
Query::equal('userId', 'betweenUser'),
Query::between('time', '2023-05-01 00:00:00+00:00', '2023-12-31 00:00:00+00:00')
]);
$this->assertIsArray($logs);
$this->assertGreaterThan(0, count($logs));
}
public function testFindWithBetweenQuery(): void
{
$time1 = '2023-01-01 00:00:00.000';
$time2 = '2023-06-01 00:00:00.000';
$time3 = '2023-12-31 23:59:59.999';
// Create test data with different times using logBatch
$this->audit->logBatch([
[
'userId' => 'betweenUser',
'event' => 'event1',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time1
],
[
'userId' => 'betweenUser',
'event' => 'event2',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time2
],
[
'userId' => 'betweenUser',
'event' => 'event3',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time3
]
]);
/** @var ClickHouse $adapter */
$adapter = $this->audit->getAdapter();
// Test between query
$logs = $adapter->find([
Query::equal('userId', 'betweenUser'),
Query::between('time', '2023-05-01 00:00:00.000', '2023-12-31 00:00:00.000')
]);
$this->assertIsArray($logs);
$this->assertGreaterThan(0, count($logs));
}
🧰 Tools
🪛 GitHub Actions: Tests

[error] 662-662: Caused by: ClickHouse query failed with HTTP 400: TYPE_MISMATCH due to converting '+00:00' to DateTime64(3).

🤖 Prompt for AI Agents
In @tests/Audit/Adapter/ClickHouseTest.php around lines 618 - 669, The test
testFindWithBetweenQuery is using timestamps with a +00:00 timezone suffix which
ClickHouse DateTime64(3) cannot parse; update the three $time variables passed
to logBatch and the Query::between bounds to use ClickHouse-compatible format
(e.g., "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD HH:MM:SS.mmm" without any timezone
suffix) so ClickHouse can ingest and query them; modify the timestamps in
testFindWithBetweenQuery (and the Query::between call) to remove "+00:00" and
optionally include milliseconds like ".000" to match DateTime64(3).

Comment on lines 865 to 916
public function testCountWithBetweenQuery(): void
{
$time1 = '2023-01-01 00:00:00+00:00';
$time2 = '2023-06-01 00:00:00+00:00';
$time3 = '2023-12-31 23:59:59+00:00';

// Create test data with different times using logBatch
$this->audit->logBatch([
[
'userId' => 'betweenCountUser',
'event' => 'event1',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time1
],
[
'userId' => 'betweenCountUser',
'event' => 'event2',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time2
],
[
'userId' => 'betweenCountUser',
'event' => 'event3',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time3
]
]);

/** @var ClickHouse $adapter */
$adapter = $this->audit->getAdapter();

// Count with between query
$count = $adapter->count([
Query::equal('userId', 'betweenCountUser'),
Query::between('time', '2023-05-01 00:00:00+00:00', '2023-12-31 00:00:00+00:00')
]);

$this->assertIsInt($count);
$this->assertGreaterThan(0, $count);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix datetime format for ClickHouse compatibility.

Same issue as testFindWithBetweenQuery – the timezone suffix +00:00 is incompatible with ClickHouse's DateTime64(3).

🐛 Proposed fix: Remove timezone suffix from test timestamps
     public function testCountWithBetweenQuery(): void
     {
-        $time1 = '2023-01-01 00:00:00+00:00';
-        $time2 = '2023-06-01 00:00:00+00:00';
-        $time3 = '2023-12-31 23:59:59+00:00';
+        $time1 = '2023-01-01 00:00:00.000';
+        $time2 = '2023-06-01 00:00:00.000';
+        $time3 = '2023-12-31 23:59:59.999';
         // ... rest of test setup ...
         
         // Count with between query
         $count = $adapter->count([
             Query::equal('userId', 'betweenCountUser'),
-            Query::between('time', '2023-05-01 00:00:00+00:00', '2023-12-31 00:00:00+00:00')
+            Query::between('time', '2023-05-01 00:00:00.000', '2023-12-31 00:00:00.000')
         ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function testCountWithBetweenQuery(): void
{
$time1 = '2023-01-01 00:00:00+00:00';
$time2 = '2023-06-01 00:00:00+00:00';
$time3 = '2023-12-31 23:59:59+00:00';
// Create test data with different times using logBatch
$this->audit->logBatch([
[
'userId' => 'betweenCountUser',
'event' => 'event1',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time1
],
[
'userId' => 'betweenCountUser',
'event' => 'event2',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time2
],
[
'userId' => 'betweenCountUser',
'event' => 'event3',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time3
]
]);
/** @var ClickHouse $adapter */
$adapter = $this->audit->getAdapter();
// Count with between query
$count = $adapter->count([
Query::equal('userId', 'betweenCountUser'),
Query::between('time', '2023-05-01 00:00:00+00:00', '2023-12-31 00:00:00+00:00')
]);
$this->assertIsInt($count);
$this->assertGreaterThan(0, $count);
}
public function testCountWithBetweenQuery(): void
{
$time1 = '2023-01-01 00:00:00.000';
$time2 = '2023-06-01 00:00:00.000';
$time3 = '2023-12-31 23:59:59.999';
// Create test data with different times using logBatch
$this->audit->logBatch([
[
'userId' => 'betweenCountUser',
'event' => 'event1',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time1
],
[
'userId' => 'betweenCountUser',
'event' => 'event2',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time2
],
[
'userId' => 'betweenCountUser',
'event' => 'event3',
'resource' => 'test',
'userAgent' => 'Test Agent',
'ip' => '127.0.0.1',
'location' => 'US',
'data' => [],
'time' => $time3
]
]);
/** @var ClickHouse $adapter */
$adapter = $this->audit->getAdapter();
// Count with between query
$count = $adapter->count([
Query::equal('userId', 'betweenCountUser'),
Query::between('time', '2023-05-01 00:00:00.000', '2023-12-31 00:00:00.000')
]);
$this->assertIsInt($count);
$this->assertGreaterThan(0, $count);
}
🧰 Tools
🪛 GitHub Actions: Tests

[error] 909-909: Caused by: ClickHouse query failed with HTTP 400: TYPE_MISMATCH due to converting '+00:00' to DateTime64(3).

🤖 Prompt for AI Agents
In @tests/Audit/Adapter/ClickHouseTest.php around lines 865 - 916, The
timestamps in testCountWithBetweenQuery include a timezone suffix (+00:00) which
is incompatible with ClickHouse DateTime64(3); update the three variables
($time1, $time2, $time3) and the Query::between call to use timestamps without
the timezone suffix (e.g. '2023-01-01 00:00:00', '2023-06-01 00:00:00',
'2023-12-31 23:59:59' and between('time', '2023-05-01 00:00:00', '2023-12-31
00:00:00')), leaving the rest of testCountWithBetweenQuery and logBatch data
unchanged so ClickHouse accepts the DateTime64(3) values.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In @tests/Audit/AuditBase.php:
- Around line 590-674: testFind() assumes data that createLogs() doesn't create
(expects 5 logs for userId 'userId', and resources/events like 'doc/0',
'event_0', 'event_1' that are created in testRetrievalParameters()), causing
failures because setUp() calls cleanup() then createLogs() before each test; fix
by either updating assertions in testFind() to match createLogs() (expect 3 logs
and use existing resource/event values like 'database/document/1', 'update',
'delete'), or add explicit setup inside testFind() to create the missing
logs/resources/events (call the same creation logic used in createLogs() or
replicate the entries) so the filters (equal('userId', ...),
equal('resource','doc/0'), in('event', [...]), between/greaterThan/lessThan on
'time') operate on known data.
🧹 Nitpick comments (2)
src/Audit/Adapter/Database.php (2)

105-127: Docblock claims @throws but implementation swallows all exceptions.

The method catches all exceptions and returns null, but the docblock still declares @throws AuthorizationException|\Exception. This is misleading for callers. Consider either:

  1. Removing the @throws annotation since exceptions are never propagated, or
  2. Re-throwing specific exceptions that callers should handle
♻️ Suggested fix: Remove throws annotation
     /**
      * 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

498-524: Consider also skipping order queries in count operations.

The implementation correctly skips limit and offset queries since they don't affect count results. For consistency and potential performance optimization, consider also skipping orderAsc and orderDesc queries:

♻️ Suggested improvement
         // Skip limit and offset for count queries
         $method = $query->getMethod();
-        if ($method === \Utopia\Audit\Query::TYPE_LIMIT || $method === \Utopia\Audit\Query::TYPE_OFFSET) {
+        if (in_array($method, [
+            \Utopia\Audit\Query::TYPE_LIMIT,
+            \Utopia\Audit\Query::TYPE_OFFSET,
+            \Utopia\Audit\Query::TYPE_ORDER_ASC,
+            \Utopia\Audit\Query::TYPE_ORDER_DESC,
+        ], true)) {
             continue;
         }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d1d0a43 and eea7517.

📒 Files selected for processing (5)
  • src/Audit/Adapter.php
  • src/Audit/Adapter/Database.php
  • src/Audit/Audit.php
  • tests/Audit/Adapter/ClickHouseTest.php
  • tests/Audit/AuditBase.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/Audit/Adapter/ClickHouseTest.php
🧰 Additional context used
🧬 Code graph analysis (3)
src/Audit/Audit.php (3)
src/Audit/Adapter.php (3)
  • getById (35-35)
  • find (224-224)
  • count (234-234)
src/Audit/Adapter/Database.php (3)
  • getById (112-127)
  • find (464-486)
  • count (498-524)
src/Audit/Adapter/ClickHouse.php (3)
  • getById (712-730)
  • find (739-779)
  • count (788-821)
src/Audit/Adapter.php (3)
src/Audit/Adapter/Database.php (3)
  • getById (112-127)
  • find (464-486)
  • count (498-524)
src/Audit/Adapter/ClickHouse.php (3)
  • getById (712-730)
  • find (739-779)
  • count (788-821)
src/Audit/Audit.php (2)
  • find (268-271)
  • count (281-284)
src/Audit/Adapter/Database.php (5)
src/Audit/Adapter.php (3)
  • getById (35-35)
  • find (224-224)
  • count (234-234)
src/Audit/Adapter/ClickHouse.php (4)
  • getById (712-730)
  • find (739-779)
  • query (494-532)
  • count (788-821)
src/Audit/Log.php (2)
  • Log (15-209)
  • getArrayCopy (205-208)
src/Audit/Audit.php (3)
  • find (268-271)
  • Audit (12-285)
  • count (281-284)
src/Audit/Query.php (3)
  • Query (11-288)
  • parseQuery (235-254)
  • toArray (261-272)
🪛 GitHub Actions: Tests
tests/Audit/AuditBase.php

[error] 598-598: Test failed: Utopia\Tests\Audit\Adapter\ClickHouseTest::testFind - Failed asserting that 3 matches expected 5. Step: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests


[error] 684-684: Test failed: Utopia\Tests\Audit\Adapter\ClickHouseTest::testCount - Failed asserting that 3 matches expected 5. Step: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests


[error] 598-598: Test failed: Utopia\Tests\Audit\Adapter\DatabaseTest::testFind - Failed asserting that 3 matches expected 5. Step: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests


[error] 684-684: Test failed: Utopia\Tests\Audit\Adapter\DatabaseTest::testCount - Failed asserting that 3 matches expected 5. Step: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests

🔇 Additional comments (6)
src/Audit/Adapter.php (2)

27-35: LGTM!

The getById abstract method is well-defined with appropriate return type and exception documentation. This aligns with the concrete implementations in Database and ClickHouse adapters.


216-234: LGTM!

The find and count abstract methods are properly declared with typed docblocks specifying array<\Utopia\Audit\Query> as the parameter type. The contract is clear and aligns with the adapter implementations.

src/Audit/Adapter/Database.php (1)

454-486: LGTM!

The find implementation correctly validates query types and converts Audit\Query objects to Database\Query objects using the shared array structure. The use of authorization skip ensures consistent behavior with other adapter methods.

src/Audit/Audit.php (2)

87-98: LGTM!

Clean delegation to the adapter with proper documentation.


259-284: LGTM!

The find and count methods correctly delegate to the adapter with proper type annotations in the docblocks.

tests/Audit/AuditBase.php (1)

158-186: LGTM!

The testGetLogById test properly validates:

  1. Creating a log and retrieving it by ID
  2. Verifying all attributes are correctly persisted
  3. Testing the null return for non-existent IDs

Good comprehensive coverage of the new method.

Comment on lines +590 to +674
public function testFind(): void
{
$userId = 'userId';

// Test 1: Find with equal filter
$logs = $this->audit->find([
\Utopia\Audit\Query::equal('userId', $userId),
]);
$this->assertEquals(5, \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(5, \count($logsDesc));
$this->assertEquals(5, \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));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Test data mismatch causes pipeline failures.

The test expects 5 logs for userId='userId' at line 598, but createLogs() only creates 3 logs for that user. Similarly, the test references resources like 'doc/0' (line 618-619) and events like 'event_0', 'event_1' (line 646) which are created in testRetrievalParameters(), not in createLogs().

Since setUp() runs before each test method—calling cleanup() and createLogs()—data from other tests doesn't persist.

Options to fix:

  1. Update expected counts to match createLogs() output (3 logs for 'userId')
  2. Add the required test data setup within testFind() itself
  3. Use resources/events that actually exist in createLogs() (e.g., 'database/document/1', 'update', 'delete')
🐛 Suggested fix: Add test data setup or fix expected values
 public function testFind(): void
 {
     $userId = 'userId';
+    
+    // Setup additional test data for find tests
+    $userAgent = 'Mozilla/5.0';
+    $ip = '127.0.0.1';
+    $location = 'US';
+    $baseTime = new \DateTime('2024-06-15 12:00:00');
+    $batchEvents = [];
+    for ($i = 0; $i < 5; $i++) {
+        $offset = $i * 60;
+        $logTime = new \DateTime('2024-06-15 12:00:00');
+        $logTime->modify("+{$offset} seconds");
+        $batchEvents[] = [
+            'userId' => $userId,
+            'event' => 'event_' . $i,
+            'resource' => 'doc/' . $i,
+            'userAgent' => $userAgent,
+            'ip' => $ip,
+            'location' => $location,
+            'data' => ['sequence' => $i],
+            'time' => DateTime::format($logTime)
+        ];
+    }
+    $this->audit->logBatch($batchEvents);

     // Test 1: Find with equal filter
     $logs = $this->audit->find([
         \Utopia\Audit\Query::equal('userId', $userId),
     ]);
-    $this->assertEquals(5, \count($logs));
+    $this->assertEquals(8, \count($logs)); // 3 from createLogs() + 5 from batch

Alternatively, update to match existing data:

     // Test 1: Find with equal filter
     $logs = $this->audit->find([
         \Utopia\Audit\Query::equal('userId', $userId),
     ]);
-    $this->assertEquals(5, \count($logs));
+    $this->assertEquals(3, \count($logs)); // createLogs() creates 3 logs for userId

     // ...

     // Test 4: Find with multiple filters
     $logs = $this->audit->find([
         \Utopia\Audit\Query::equal('userId', $userId),
-        \Utopia\Audit\Query::equal('resource', 'doc/0'),
+        \Utopia\Audit\Query::equal('resource', 'database/document/1'),
     ]);
     $this->assertEquals(1, \count($logs));
🧰 Tools
🪛 GitHub Actions: Tests

[error] 598-598: Test failed: Utopia\Tests\Audit\Adapter\ClickHouseTest::testFind - Failed asserting that 3 matches expected 5. Step: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests


[error] 598-598: Test failed: Utopia\Tests\Audit\Adapter\DatabaseTest::testFind - Failed asserting that 3 matches expected 5. Step: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests

🤖 Prompt for AI Agents
In @tests/Audit/AuditBase.php around lines 590 - 674, testFind() assumes data
that createLogs() doesn't create (expects 5 logs for userId 'userId', and
resources/events like 'doc/0', 'event_0', 'event_1' that are created in
testRetrievalParameters()), causing failures because setUp() calls cleanup()
then createLogs() before each test; fix by either updating assertions in
testFind() to match createLogs() (expect 3 logs and use existing resource/event
values like 'database/document/1', 'update', 'delete'), or add explicit setup
inside testFind() to create the missing logs/resources/events (call the same
creation logic used in createLogs() or replicate the entries) so the filters
(equal('userId', ...), equal('resource','doc/0'), in('event', [...]),
between/greaterThan/lessThan on 'time') operate on known data.

Comment on lines +676 to +737
public function testCount(): void
{
$userId = 'userId';

// Test 1: Count with simple filter
$count = $this->audit->count([
\Utopia\Audit\Query::equal('userId', $userId),
]);
$this->assertEquals(5, $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(5, $count); // Should count all 5, 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);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Same test data mismatch as testFind() causes pipeline failure.

Line 684 expects count = 5 for userId='userId', but createLogs() only creates 3 logs for that user. The same resources ('doc/0') and events ('event_0', 'event_1') referenced here don't exist in the test fixture.

Apply the same fix as testFind() - either add test data setup at the beginning of this test or update expected values to match createLogs() output.

🐛 Suggested fix: Update expected count to match test data
     // Test 1: Count with simple filter
     $count = $this->audit->count([
         \Utopia\Audit\Query::equal('userId', $userId),
     ]);
-    $this->assertEquals(5, $count);
+    $this->assertEquals(3, $count); // createLogs() creates 3 logs for userId

     // Test 2: Count with multiple filters
     $count = $this->audit->count([
         \Utopia\Audit\Query::equal('userId', $userId),
-        \Utopia\Audit\Query::equal('resource', 'doc/0'),
+        \Utopia\Audit\Query::equal('resource', 'database/document/1'),
     ]);
     $this->assertEquals(1, $count);

     // Test 3: Count with IN filter
     $count = $this->audit->count([
-        \Utopia\Audit\Query::in('event', ['event_0', 'event_1']),
+        \Utopia\Audit\Query::in('event', ['update', 'delete']),
     ]);
-    $this->assertGreaterThanOrEqual(2, $count);
+    $this->assertEquals(3, $count); // 2 updates + 1 delete

     // 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(5, $count);
+    $this->assertEquals(3, $count); // Should count all 3, not affected by limit/offset
🧰 Tools
🪛 GitHub Actions: Tests

[error] 684-684: Test failed: Utopia\Tests\Audit\Adapter\ClickHouseTest::testCount - Failed asserting that 3 matches expected 5. Step: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests


[error] 684-684: Test failed: Utopia\Tests\Audit\Adapter\DatabaseTest::testCount - Failed asserting that 3 matches expected 5. Step: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants