From 105d2c0d852d41c64ab434897a1961e6f1b62342 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 5 Sep 2026 01:36:52 +1200 Subject: [PATCH 1/4] (fix): preserve audit schemas on the query-lib database API --- packages/audit/composer.json | 12 +- .../audit/src/Audit/Adapter/ClickHouse.php | 542 +++--------------- packages/audit/src/Audit/Adapter/Database.php | 102 ++-- packages/audit/src/Audit/Adapter/SQL.php | 145 +---- .../tests/Audit/Adapter/ClickHouseTest.php | 15 +- .../tests/Audit/Adapter/DatabaseTest.php | 62 +- packages/audit/tests/Audit/AuditBase.php | 6 +- 7 files changed, 243 insertions(+), 641 deletions(-) diff --git a/packages/audit/composer.json b/packages/audit/composer.json index 87d466634..73db800f4 100644 --- a/packages/audit/composer.json +++ b/packages/audit/composer.json @@ -27,11 +27,21 @@ }, "require": { "php": ">=8.5", - "utopia-php/database": "^7.0.0", + "utopia-php/database": "dev-feat-query-lib", "utopia-php/fetch": "^1.1", "utopia-php/query": "0.6.*", "utopia-php/validators": "^0.6" }, + "require-dev": { + "ext-pdo_mysql": "*", + "ext-swoole": "*" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/database.git" + } + ], "config": { "allow-plugins": { "php-http/discovery": true, diff --git a/packages/audit/src/Audit/Adapter/ClickHouse.php b/packages/audit/src/Audit/Adapter/ClickHouse.php index 7b3bdbe08..2efd39ace 100644 --- a/packages/audit/src/Audit/Adapter/ClickHouse.php +++ b/packages/audit/src/Audit/Adapter/ClickHouse.php @@ -5,7 +5,8 @@ use Exception; use Utopia\Audit\Log; use Utopia\Audit\Query; -use Utopia\Database\Database; +use Utopia\Database\Attribute; +use Utopia\Database\Index; use Utopia\Fetch\Client; use Utopia\Query\Builder\ClickHouse as ClickHouseBuilder; use Utopia\Query\Builder\ClickHouse\Format; @@ -375,394 +376,65 @@ public function getRetention(): ?int * Override getAttributes to provide extended attributes for ClickHouse. * Includes existing attributes from parent and adds new missing ones. * - * @return array> + * @return array */ #[\Override] public function getAttributes(): array { $parentAttributes = parent::getAttributes(); - foreach ($parentAttributes as &$attribute) { - if (($attribute['$id'] ?? null) === 'userId') { - $attribute['$id'] = 'actorId'; + foreach ($parentAttributes as $index => $attribute) { + if ($attribute->key === 'userId') { + $parentAttributes[$index] = new Attribute( + key: 'actorId', + type: $attribute->type, + size: $attribute->size, + required: $attribute->required, + signed: $attribute->signed, + array: $attribute->array, + filters: $attribute->filters, + ); break; } } - unset($attribute); return [ ...$parentAttributes, - [ - '$id' => 'actorType', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'actorInternalId', - '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' => [], - ], - // premium geo - [ - '$id' => 'city', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'continentCode', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'subdivisions', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'isp', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'autonomousSystemNumber', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'autonomousSystemOrganization', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'connectionType', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'connectionUsageType', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'connectionOrganization', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - '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' => [], - ], - [ - '$id' => 'sdk', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'sdkVersion', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - // user-agent — parsed OS / client / device dimensions - [ - '$id' => 'osCode', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'osName', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'osVersion', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientType', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientCode', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientName', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientVersion', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientEngine', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'clientEngineVersion', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'deviceName', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'deviceBrand', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'deviceModel', - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], + Attribute::string(key: 'actorType', required: true), + Attribute::string(key: 'actorInternalId'), + Attribute::string(key: 'resourceParent'), + Attribute::string(key: 'resourceType', required: true), + Attribute::string(key: 'resourceId', required: true), + Attribute::string(key: 'resourceInternalId'), + Attribute::string(key: 'country'), + Attribute::string(key: 'city'), + Attribute::string(key: 'continentCode'), + Attribute::string(key: 'subdivisions'), + Attribute::string(key: 'isp'), + Attribute::string(key: 'autonomousSystemNumber'), + Attribute::string(key: 'autonomousSystemOrganization'), + Attribute::string(key: 'connectionType'), + Attribute::string(key: 'connectionUsageType'), + Attribute::string(key: 'connectionOrganization'), + Attribute::string(key: 'projectId', required: true), + Attribute::string(key: 'projectInternalId', required: true), + Attribute::string(key: 'teamId', required: true), + Attribute::string(key: 'teamInternalId', required: true), + Attribute::string(key: 'hostname', required: true), + Attribute::string(key: 'sdk'), + Attribute::string(key: 'sdkVersion'), + Attribute::string(key: 'osCode'), + Attribute::string(key: 'osName'), + Attribute::string(key: 'osVersion'), + Attribute::string(key: 'clientType'), + Attribute::string(key: 'clientCode'), + Attribute::string(key: 'clientName'), + Attribute::string(key: 'clientVersion'), + Attribute::string(key: 'clientEngine'), + Attribute::string(key: 'clientEngineVersion'), + Attribute::string(key: 'deviceName'), + Attribute::string(key: 'deviceBrand'), + Attribute::string(key: 'deviceModel'), ]; } @@ -770,80 +442,37 @@ public function getAttributes(): array * Override getIndexes to provide extended indexes for ClickHouse. * Includes existing indexes from parent and adds new missing ones. * - * @return array> + * @return array */ #[\Override] public function getIndexes(): array { $parentIndexes = parent::getIndexes(); - foreach ($parentIndexes as &$index) { - if (($index['$id'] ?? null) === 'idx_userId_event') { - $index['$id'] = 'idx_actorId_event'; - $index['attributes'] = ['actorId', 'event']; + foreach ($parentIndexes as $index => $definition) { + if ($definition->key === 'idx_userId_event') { + $parentIndexes[$index] = new Index( + key: 'idx_actorId_event', + type: $definition->type, + attributes: ['actorId', 'event'], + lengths: $definition->lengths, + orders: $definition->orders, + ttl: $definition->ttl, + ); break; } } - unset($index); return [ ...$parentIndexes, - [ - '$id' => '_key_actor_internal_and_event', - 'type' => Database::INDEX_KEY, - 'attributes' => ['actorInternalId', 'event'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_project_internal_id', - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectInternalId'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_team_internal_id', - 'type' => Database::INDEX_KEY, - 'attributes' => ['teamInternalId'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_actor_internal_id', - 'type' => Database::INDEX_KEY, - 'attributes' => ['actorInternalId'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_actor_type', - 'type' => Database::INDEX_KEY, - 'attributes' => ['actorType'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_country', - 'type' => Database::INDEX_KEY, - 'attributes' => ['country'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_hostname', - 'type' => Database::INDEX_KEY, - 'attributes' => ['hostname'], - 'lengths' => [], - 'orders' => [], - ], - [ - '$id' => '_key_sdk', - 'type' => Database::INDEX_KEY, - 'attributes' => ['sdk'], - 'lengths' => [], - 'orders' => [], - ], + Index::key(key: '_key_actor_internal_and_event', attributes: ['actorInternalId', 'event']), + Index::key(key: '_key_project_internal_id', attributes: ['projectInternalId']), + Index::key(key: '_key_team_internal_id', attributes: ['teamInternalId']), + Index::key(key: '_key_actor_internal_id', attributes: ['actorInternalId']), + Index::key(key: '_key_actor_type', attributes: ['actorType']), + Index::key(key: '_key_country', attributes: ['country']), + Index::key(key: '_key_hostname', attributes: ['hostname']), + Index::key(key: '_key_sdk', attributes: ['sdk']), ]; } @@ -879,9 +508,7 @@ private function getColumnTypeMap(): array $map = ['id' => 'String']; foreach ($this->getAttributes() as $attribute) { - /** @var string $id */ - $id = $attribute['$id']; - $map[$id] = ($attribute['type'] ?? null) === Database::VAR_DATETIME + $map[$attribute->key] = $attribute->type === ColumnType::Datetime ? 'DateTime64(3)' : 'String'; } @@ -1048,8 +675,7 @@ public function setup(): void $table->string('id')->primary(); foreach ($this->getAttributes() as $attribute) { - /** @var string $id */ - $id = $attribute['$id']; + $id = $attribute->key; // Special handling for time column - must be NOT NULL for partition key if ($id === 'time') { @@ -1058,13 +684,13 @@ public function setup(): void continue; } - $column = $table->addColumn($id, ($attribute['type'] ?? null) === Database::VAR_DATETIME + $column = $table->addColumn($id, $attribute->type === ColumnType::Datetime ? ColumnType::Datetime : ColumnType::String); if (\in_array($id, self::LOW_CARDINALITY_COLUMNS, true)) { $column->lowCardinality(); } - if (empty($attribute['required'])) { + if (! $attribute->required) { $column->nullable(); } } @@ -1076,13 +702,9 @@ public function setup(): void } foreach ($this->getIndexes() as $index) { - /** @var string $indexName */ - $indexName = $index['$id']; - /** @var array $attributes */ - $attributes = $index['attributes']; $table->index( - columns: $attributes, - name: $indexName, + columns: $index->attributes, + name: $index->key, algorithm: IndexAlgorithm::BloomFilter, granularity: 1, ); @@ -1140,8 +762,7 @@ private function getColumnNames(): array { $columns = []; foreach ($this->getAttributes() as $attribute) { - /** @var string $columnName */ - $columnName = $attribute['$id']; + $columnName = $attribute->key; // Exclude id and tenant as they're handled separately if ($columnName !== 'id' && $columnName !== 'tenant') { $columns[] = $columnName; @@ -1185,7 +806,7 @@ private function validateAttributeName(string $attributeName): bool // Check against defined attributes foreach ($this->getAttributes() as $attribute) { - if ($attribute['$id'] === $attributeName) { + if ($attribute->key === $attributeName) { return true; } } @@ -1945,7 +1566,7 @@ public function createBatch(array $logs): bool // Get attribute metadata to determine if required $attributeMetadata = $this->getAttribute($columnName); - $isRequiredAttribute = $attributeMetadata !== null && isset($attributeMetadata['required']) && $attributeMetadata['required']; + $isRequiredAttribute = $attributeMetadata->required ?? false; if ($columnName === 'data') { // Data column - encode remaining non-schema data as JSON @@ -2086,8 +1707,7 @@ private function getSelectColumns(): string // Dynamically add all attribute columns except 'data' foreach ($this->getAttributes() as $attribute) { - $id = $attribute['$id']; - /** @var string $id */ + $id = $attribute->key; if ($id !== 'data') { $columns[] = $this->escapeIdentifier($id); } @@ -2131,17 +1751,17 @@ protected function getColumnDefinition(string $id): string { $attribute = $this->getAttribute($id); - if (!$attribute) { + if (! $attribute instanceof Attribute) { throw new Exception("Attribute {$id} not found"); } // Dynamically determine type based on attribute metadata // DateTime attributes use DateTime64(3), all others use String - $type = (isset($attribute['type']) && $attribute['type'] === Database::VAR_DATETIME) + $type = $attribute->type === ColumnType::Datetime ? 'DateTime64(3)' : 'String'; - $required = (bool) $attribute['required']; + $required = $attribute->required; if ($type === 'String' && \in_array($id, self::LOW_CARDINALITY_COLUMNS, true)) { $columnType = $required diff --git a/packages/audit/src/Audit/Adapter/Database.php b/packages/audit/src/Audit/Adapter/Database.php index 796f5981e..047fa44d6 100644 --- a/packages/audit/src/Audit/Adapter/Database.php +++ b/packages/audit/src/Audit/Adapter/Database.php @@ -4,6 +4,8 @@ use Exception; use Utopia\Audit\Log; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -48,7 +50,7 @@ public function ping(): bool /** * Setup database structure. * - * @throws \Exception + * @throws Exception */ public function setup(): void { @@ -60,11 +62,7 @@ public function setup(): void $indexes = $this->getIndexDocuments(); try { - $this->db->createCollection( - $this->getCollectionName(), - $attributes, - $indexes, - ); + $this->db->createCollection(new Collection(id: $this->getCollectionName(), attributes: $attributes, indexes: $indexes)); } catch (DuplicateException) { // Collection already exists } @@ -73,13 +71,14 @@ public function setup(): void /** * Create an audit log entry. * - * @param array $log - * @throws AuthorizationException|\Exception + * @param array $log + * + * @throws AuthorizationException|Exception */ public function create(array $log): Log { $log['time'] ??= DateTime::now(); - $document = $this->db->getAuthorization()->skip(fn(): \Utopia\Database\Document => $this->db->createDocument($this->getCollectionName(), new Document($log))); + $document = $this->db->getAuthorization()->skip(fn(): Document => $this->db->createDocument($this->getCollectionName(), new Document($log))); return new Log($document->getArrayCopy()); } @@ -87,19 +86,21 @@ public function create(array $log): Log /** * Create multiple audit log entries in batch. * - * @param array> $logs - * @throws AuthorizationException|\Exception + * @param array> $logs + * + * @throws AuthorizationException|Exception */ public function createBatch(array $logs): bool { $this->db->getAuthorization()->skip(function () use ($logs): void { - $documents = array_map(function (array $log): \Utopia\Database\Document { + $documents = array_map(function (array $log): Document { $time = $log['time'] ?? new \DateTime(); if (\is_string($time)) { $time = new \DateTime($time); } \assert($time instanceof \DateTime); $log['time'] = DateTime::format($time); + return new Document($log); }, $logs); $this->db->createDocuments($this->getCollectionName(), $documents); @@ -112,11 +113,12 @@ public function createBatch(array $logs): bool * Get a single log by its ID. * * @return Log|null The log entry or null if not found - * @throws AuthorizationException|\Exception + * + * @throws AuthorizationException|Exception */ public function getById(string $id): ?Log { - $document = $this->db->getAuthorization()->skip(fn(): \Utopia\Database\Document => $this->db->getDocument($this->getCollectionName(), $id)); + $document = $this->db->getAuthorization()->skip(fn(): Document => $this->db->getDocument($this->getCollectionName(), $id)); if ($document->isEmpty()) { return null; @@ -139,6 +141,7 @@ private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array if ($afterStr !== null && $beforeStr !== null) { $queries[] = Query::between('time', $afterStr, $beforeStr); + return $queries; } @@ -157,7 +160,8 @@ private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array * Get audit logs by user ID. * * @return array - * @throws AuthorizationException|\Exception + * + * @throws AuthorizationException|Exception */ public function getByUser( string $userId, @@ -183,13 +187,13 @@ public function getByUser( ); }); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn(Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** * Count audit logs by user ID. * - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function countByUser( string $userId, @@ -198,6 +202,7 @@ public function countByUser( ?int $max = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); + return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( collection: $this->getCollectionName(), queries: [ @@ -212,6 +217,7 @@ public function countByUser( * Get logs by resource. * * @return array + * * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByResource( @@ -238,7 +244,7 @@ public function getByResource( ); }); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn(Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -253,6 +259,7 @@ public function countByResource( ?int $max = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); + return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( collection: $this->getCollectionName(), queries: [ @@ -266,8 +273,9 @@ public function countByResource( /** * Get logs by user and events. * - * @param array $events + * @param array $events * @return array + * * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByUserAndEvents( @@ -296,13 +304,14 @@ public function getByUserAndEvents( ); }); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn(Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** * Count logs by user and events. * - * @param array $events + * @param array $events + * * @throws \Utopia\Database\Exception */ public function countByUserAndEvents( @@ -313,6 +322,7 @@ public function countByUserAndEvents( ?int $max = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); + return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( collection: $this->getCollectionName(), queries: [ @@ -327,8 +337,9 @@ public function countByUserAndEvents( /** * Get logs by resource and events. * - * @param array $events + * @param array $events * @return array + * * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByResourceAndEvents( @@ -357,13 +368,14 @@ public function getByResourceAndEvents( ); }); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn(Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** * Count logs by resource and events. * - * @param array $events + * @param array $events + * * @throws \Utopia\Database\Exception */ public function countByResourceAndEvents( @@ -374,6 +386,7 @@ public function countByResourceAndEvents( ?int $max = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); + return $this->db->getAuthorization()->skip(fn(): int => $this->db->count( collection: $this->getCollectionName(), queries: [ @@ -388,9 +401,7 @@ public function countByResourceAndEvents( /** * Delete logs older than the specified datetime. * - * @param \DateTime $datetime - /** - * @throws AuthorizationException|\Exception + * @throws AuthorizationException|Exception */ public function cleanup(\DateTime $datetime): bool { @@ -422,24 +433,21 @@ public function cleanup(\DateTime $datetime): bool * type mapping internally. However, this implementation is required to satisfy * the abstract method declaration in the base SQL adapter. * - * @param string $id Attribute identifier + * @param string $id Attribute identifier * @return string Database-agnostic column description + * * @throws Exception */ protected function getColumnDefinition(string $id): string { $attribute = $this->getAttribute($id); - if (!$attribute) { + if (! $attribute instanceof Attribute) { throw new Exception("Attribute {$id} not found"); } - // For the Database adapter, we use Utopia's VAR_* type constants internally - // This method provides a description for reference purposes - /** @var string $type */ - $type = $attribute['type']; - /** @var int $size */ - $size = $attribute['size'] ?? 0; + $type = $attribute->type->value; + $size = $attribute->size; if ($size > 0) { return "{$id}: {$type}({$size})"; @@ -453,17 +461,18 @@ protected function getColumnDefinition(string $id): string * * Translates Audit Query objects to Database Query objects. * - * @param array<\Utopia\Audit\Query> $queries - * @return array<\Utopia\Audit\Log> - * @throws AuthorizationException|\Exception + * @param array<\Utopia\Audit\Query> $queries + * @return array + * + * @throws AuthorizationException|Exception */ public function find(array $queries = []): array { $dbQueries = []; foreach ($queries as $query) { - if (!($query instanceof \Utopia\Audit\Query)) { - throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); + if (! ($query instanceof \Utopia\Audit\Query)) { + throw new Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } // Convert Audit Query to Database Query @@ -476,7 +485,7 @@ public function find(array $queries = []): array queries: $dbQueries, )); - return array_map(fn(\Utopia\Database\Document $doc): \Utopia\Audit\Log => new Log($doc->getArrayCopy()), $documents); + return array_map(fn(Document $doc): Log => new Log($doc->getArrayCopy()), $documents); } /** @@ -485,17 +494,18 @@ public function find(array $queries = []): array * Translates Audit Query objects to Database Query objects. * Ignores limit, offset, and cursor queries as they don't apply to count. * - * @param array<\Utopia\Audit\Query> $queries - * @param int|null $max Optional upper bound (inclusive) for the count - * @throws AuthorizationException|\Exception + * @param array<\Utopia\Audit\Query> $queries + * @param int|null $max Optional upper bound (inclusive) for the count + * + * @throws AuthorizationException|Exception */ public function count(array $queries = [], ?int $max = null): int { $dbQueries = []; foreach ($queries as $query) { - if (!($query instanceof \Utopia\Audit\Query)) { - throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); + if (! ($query instanceof \Utopia\Audit\Query)) { + throw new Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } // Skip limit, offset, and cursor queries — they don't apply to count diff --git a/packages/audit/src/Audit/Adapter/SQL.php b/packages/audit/src/Audit/Adapter/SQL.php index f5208a33e..0b0bb2925 100644 --- a/packages/audit/src/Audit/Adapter/SQL.php +++ b/packages/audit/src/Audit/Adapter/SQL.php @@ -3,8 +3,8 @@ namespace Utopia\Audit\Adapter; use Utopia\Audit\Adapter; -use Utopia\Database\Database; -use Utopia\Database\Document; +use Utopia\Database\Attribute; +use Utopia\Database\Index; /** * Base SQL Adapter for Audit @@ -14,7 +14,7 @@ */ abstract class SQL extends Adapter { - public const COLLECTION = 'audit'; + public const string COLLECTION = 'audit'; /** * Get the collection/table name for audit logs. @@ -27,152 +27,63 @@ public function getCollectionName(): string /** * Get attribute definitions for audit logs. * - * Each attribute is an array with the following string keys: - * - $id: string (attribute identifier) - * - type: string - * - size: int - * - required: bool - * - signed: bool - * - array: bool - * - filters: array - * - * @return array> + * @return array */ public function getAttributes(): array { return [ - [ - '$id' => 'userId', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'event', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'resource', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'userAgent', - 'type' => Database::VAR_STRING, - 'size' => 65534, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'ip', - 'type' => Database::VAR_STRING, - 'size' => 45, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'time', - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => 'data', - 'type' => Database::VAR_STRING, - 'size' => 16777216, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => ['json'], - ], + Attribute::string(key: 'userId'), + Attribute::string(key: 'event', required: true), + Attribute::string(key: 'resource'), + Attribute::string(key: 'userAgent', size: 65534, required: true), + Attribute::string(key: 'ip', size: 45, required: true), + Attribute::datetime(key: 'time', filters: ['datetime']), + Attribute::string(key: 'data', size: 16777216, filters: ['json']), ]; } /** - * Get attribute documents for audit logs. + * Get attribute value objects for createCollection. * - * @return array + * @return array */ public function getAttributeDocuments(): array { - return array_map(static fn(array $attribute): \Utopia\Database\Document => new Document($attribute), $this->getAttributes()); + return $this->getAttributes(); } /** * Get index definitions for audit logs. * - * Each index is an array with the following string keys: - * - $id: string (index identifier) - * - type: string - * - attributes: array - * - * @return array> + * @return array */ public function getIndexes(): array { return [ - [ - '$id' => 'idx_event', - 'type' => 'key', - 'attributes' => ['event'], - ], - [ - '$id' => 'idx_userId_event', - 'type' => 'key', - 'attributes' => ['userId', 'event'], - ], - [ - '$id' => 'idx_resource_event', - 'type' => 'key', - 'attributes' => ['resource', 'event'], - ], - [ - '$id' => 'idx_time_desc', - 'type' => 'key', - 'attributes' => ['time'], - ], + Index::key(key: 'idx_event', attributes: ['event']), + Index::key(key: 'idx_userId_event', attributes: ['userId', 'event']), + Index::key(key: 'idx_resource_event', attributes: ['resource', 'event']), + Index::key(key: 'idx_time_desc', attributes: ['time']), ]; } /** - * Get index documents for audit logs. + * Get index value objects for createCollection. * - * @return array + * @return array */ public function getIndexDocuments(): array { - return array_map(static fn(array $index): \Utopia\Database\Document => new Document($index), $this->getIndexes()); + return $this->getIndexes(); } /** - * Get a single attribute by ID. - * - * @return array|null + * Get a single attribute by key. */ - protected function getAttribute(string $id) + protected function getAttribute(string $id): ?Attribute { foreach ($this->getAttributes() as $attribute) { - if ($attribute['$id'] === $id) { + if ($attribute->key === $id) { return $attribute; } } @@ -184,7 +95,7 @@ protected function getAttribute(string $id) * Get SQL column definition for a given attribute ID. * This method is database-specific and must be implemented by each concrete adapter. * - * @param string $id Attribute identifier + * @param string $id Attribute identifier * @return string Database-specific column definition */ abstract protected function getColumnDefinition(string $id): string; @@ -199,9 +110,7 @@ protected function getAllColumnDefinitions(): array { $definitions = []; foreach ($this->getAttributes() as $attribute) { - /** @var string $id */ - $id = $attribute['$id']; - $definitions[] = $this->getColumnDefinition($id); + $definitions[] = $this->getColumnDefinition($attribute->key); } return $definitions; diff --git a/packages/audit/tests/Audit/Adapter/ClickHouseTest.php b/packages/audit/tests/Audit/Adapter/ClickHouseTest.php index cc0b06192..af0b023b7 100644 --- a/packages/audit/tests/Audit/Adapter/ClickHouseTest.php +++ b/packages/audit/tests/Audit/Adapter/ClickHouseTest.php @@ -9,6 +9,9 @@ use Utopia\Audit\Adapter\ClickHouse; use Utopia\Audit\Audit; use Utopia\Audit\Query; +use Utopia\Database\Attribute; +use Utopia\Database\Index; +use Utopia\Query\Schema\ColumnType; use Utopia\Tests\Audit\AuditBase; /** @@ -515,7 +518,7 @@ public function testClickHouseAdapterAttributes(): void ); $attributes = $adapter->getAttributes(); - $attributeIds = array_map(fn(array $attr): mixed => $attr['$id'], $attributes); + $attributeIds = array_map(fn(Attribute $attribute): string => $attribute->key, $attributes); // Verify all expected attributes exist $expectedAttributes = [ @@ -627,7 +630,7 @@ public function testPremiumGeoAttributesAreOptionalStrings(): void $attributes = $adapter->getAttributes(); $byId = []; foreach ($attributes as $attribute) { - $byId[$attribute['$id']] = $attribute; + $byId[$attribute->key] = $attribute; } $geoColumns = [ @@ -644,9 +647,9 @@ public function testPremiumGeoAttributesAreOptionalStrings(): void foreach ($geoColumns as $column) { $this->assertArrayHasKey($column, $byId, "Premium geo attribute '{$column}' not found"); - $this->assertEquals(\Utopia\Database\Database::VAR_STRING, $byId[$column]['type'], "'{$column}' should be a string"); - $this->assertFalse($byId[$column]['required'], "'{$column}' should be optional"); - $this->assertFalse($byId[$column]['array'], "'{$column}' should not be an array"); + $this->assertSame(ColumnType::String, $byId[$column]->type, "'{$column}' should be a string"); + $this->assertFalse($byId[$column]->required, "'{$column}' should be optional"); + $this->assertFalse($byId[$column]->array, "'{$column}' should not be an array"); } } @@ -805,7 +808,7 @@ public function testClickHouseAdapterIndexes(): void ); $indexes = $adapter->getIndexes(); - $indexIds = array_map(fn(array $idx): mixed => $idx['$id'], $indexes); + $indexIds = array_map(fn(Index $index): string => $index->key, $indexes); // Verify all ClickHouse-specific indexes exist $expectedClickHouseIndexes = [ diff --git a/packages/audit/tests/Audit/Adapter/DatabaseTest.php b/packages/audit/tests/Audit/Adapter/DatabaseTest.php index f1a818c7e..702c30c0d 100644 --- a/packages/audit/tests/Audit/Adapter/DatabaseTest.php +++ b/packages/audit/tests/Audit/Adapter/DatabaseTest.php @@ -8,10 +8,16 @@ use PHPUnit\Framework\TestCase; use Utopia\Audit\Adapter; use Utopia\Audit\Audit; +use Utopia\Audit\Log; +use Utopia\Audit\Query; use Utopia\Cache\Adapter\None as NoCache; use Utopia\Cache\Cache; use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Attribute; use Utopia\Database\Database; +use Utopia\Database\Index; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; use Utopia\Tests\Audit\AuditBase; /** @@ -23,14 +29,16 @@ final class DatabaseTest extends TestCase protected function initializeAudit(): void { - $dbHost = getenv('MARIADB_HOST') ?: '127.0.0.1'; - $dbPort = getenv('MARIADB_PORT') ?: '13307'; - $dbUser = 'root'; - $dbPass = 'password'; + $host = getenv('MARIADB_HOST') ?: '127.0.0.1'; + $port = getenv('MARIADB_PORT') ?: '13307'; + $username = 'root'; + $password = 'password'; - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPdoAttributes()); + $attributes = MariaDB::getPdoAttributes(); + $attributes[PDO::ATTR_PERSISTENT] = false; + $connection = new PDO("mysql:host={$host};port={$port};charset=utf8mb4", $username, $password, $attributes); $cache = new Cache(new NoCache()); - $database = new Database(new MariaDB($pdo), $cache); + $database = new Database(new MariaDB($connection), $cache); $database->setDatabase('utopiaTests'); $database->setNamespace('namespace'); @@ -41,4 +49,46 @@ protected function initializeAudit(): void $this->audit->setup(); } } + + public function testSchemaValueObjectsAndNullableLog(): void + { + $adapter = $this->audit->getAdapter(); + $this->assertInstanceOf(Adapter\Database::class, $adapter); + + $attributes = $adapter->getAttributes(); + $this->assertCount(7, $attributes); + foreach ($attributes as $attribute) { + $this->assertInstanceOf(Attribute::class, $attribute); + $this->assertNull($attribute->default); + } + $this->assertSame('userId', $attributes[0]->key); + $this->assertSame(ColumnType::String, $attributes[0]->type); + $this->assertSame(Database::LENGTH_KEY, $attributes[0]->size); + $this->assertFalse($attributes[0]->required); + $this->assertTrue($attributes[1]->required); + $this->assertSame(ColumnType::Datetime, $attributes[5]->type); + $this->assertSame(['datetime'], $attributes[5]->filters); + $this->assertSame(['json'], $attributes[6]->filters); + + $indexes = $adapter->getIndexes(); + $this->assertCount(4, $indexes); + foreach ($indexes as $index) { + $this->assertInstanceOf(Index::class, $index); + $this->assertSame(IndexType::Key, $index->type); + } + $this->assertSame(['userId', 'event'], $indexes[1]->attributes); + + $log = $this->audit->log(null, 'schema.created', 'schema/defaults', 'test', '127.0.0.1'); + $stored = $this->audit->getLogById($log->getId()); + $this->assertInstanceOf(Log::class, $stored); + $this->assertNull($stored->getUserId()); + $this->assertSame([], $stored->getAttribute('data')); + $this->assertNotEmpty($stored->getAttribute('time')); + $logs = $this->audit->find([ + Query::equal('resource', ['schema/defaults']), + Query::containsString('event', ['created']), + ]); + $this->assertCount(1, $logs); + $this->assertSame($log->getId(), $logs[0]->getId()); + } } diff --git a/packages/audit/tests/Audit/AuditBase.php b/packages/audit/tests/Audit/AuditBase.php index e0dcb10f4..1c937d041 100644 --- a/packages/audit/tests/Audit/AuditBase.php +++ b/packages/audit/tests/Audit/AuditBase.php @@ -677,13 +677,13 @@ public function testFind(): void // Test 6: Find with contains filter (substring match, like utopia-php/database) $logs = $this->audit->find([ - \Utopia\Audit\Query::contains('event', ['event_0', 'event_1']), + \Utopia\Audit\Query::containsString('event', ['event_0', 'event_1']), ]); $this->assertGreaterThanOrEqual(2, \count($logs)); // Substring needle matches without an exact value $logs = $this->audit->find([ - \Utopia\Audit\Query::contains('event', ['vent_0']), + \Utopia\Audit\Query::containsString('event', ['vent_0']), ]); $this->assertGreaterThanOrEqual(1, \count($logs)); foreach ($logs as $log) { @@ -761,7 +761,7 @@ public function testCount(): void // Test 3: Count with contains filter (substring match, like utopia-php/database) $count = $this->audit->count([ - \Utopia\Audit\Query::contains('event', ['event_0', 'event_1']), + \Utopia\Audit\Query::containsString('event', ['event_0', 'event_1']), ]); $this->assertGreaterThanOrEqual(2, $count); From 4e53dd58e8bee54ab23c0d9162d16777b2a63c1e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 9 Sep 2026 22:01:48 +1200 Subject: [PATCH 2/4] test(audit): assert schema behaviour instead of mirroring it The schema assertions pinned attribute counts, array positions, column types, sizes, filters, index types and index column ordering straight off the adapter's getAttributes()/getIndexes(). That mirrors the source rather than testing it: the repository requires observable behaviour, and these lines only ever compared the schema definition against a second copy of itself. Every deleted property is already covered through the public API, proven by breaking each one and watching a behavioural test go red: dropping the json filter on data, dropping the datetime filter on time, making idx_userId_event unique and making userId required each fail the existing write/read tests. Index column ordering is the one property with no observable behaviour at all -- swapping it reaches the real DDL and every test still passes -- so no assertion can guard it. userId's size was the only property not otherwise reachable, so it is replaced rather than dropped: testUserIdAcceptsFullKeyLength writes a key-length user id and reads it back, which fails once the column shrinks. Co-Authored-By: Claude Opus 5 --- .../tests/Audit/Adapter/ClickHouseTest.php | 38 --------------- .../tests/Audit/Adapter/DatabaseTest.php | 46 ++++++------------- 2 files changed, 15 insertions(+), 69 deletions(-) diff --git a/packages/audit/tests/Audit/Adapter/ClickHouseTest.php b/packages/audit/tests/Audit/Adapter/ClickHouseTest.php index af0b023b7..887e44249 100644 --- a/packages/audit/tests/Audit/Adapter/ClickHouseTest.php +++ b/packages/audit/tests/Audit/Adapter/ClickHouseTest.php @@ -11,7 +11,6 @@ use Utopia\Audit\Query; use Utopia\Database\Attribute; use Utopia\Database\Index; -use Utopia\Query\Schema\ColumnType; use Utopia\Tests\Audit\AuditBase; /** @@ -616,43 +615,6 @@ public function testUserAgentColumnTypes(): void } } - /** - * Test that premium geo attributes are all optional String columns. - */ - public function testPremiumGeoAttributesAreOptionalStrings(): void - { - $adapter = new ClickHouse( - host: 'clickhouse', - username: 'default', - password: 'clickhouse', - ); - - $attributes = $adapter->getAttributes(); - $byId = []; - foreach ($attributes as $attribute) { - $byId[$attribute->key] = $attribute; - } - - $geoColumns = [ - 'city', - 'continentCode', - 'subdivisions', - 'isp', - 'autonomousSystemNumber', - 'autonomousSystemOrganization', - 'connectionType', - 'connectionUsageType', - 'connectionOrganization', - ]; - - foreach ($geoColumns as $column) { - $this->assertArrayHasKey($column, $byId, "Premium geo attribute '{$column}' not found"); - $this->assertSame(ColumnType::String, $byId[$column]->type, "'{$column}' should be a string"); - $this->assertFalse($byId[$column]->required, "'{$column}' should be optional"); - $this->assertFalse($byId[$column]->array, "'{$column}' should not be an array"); - } - } - /** * Test that premium geo columns get the correct ClickHouse type: * low-cardinality dimensions use LowCardinality(Nullable(String)), while diff --git a/packages/audit/tests/Audit/Adapter/DatabaseTest.php b/packages/audit/tests/Audit/Adapter/DatabaseTest.php index 702c30c0d..758c604cf 100644 --- a/packages/audit/tests/Audit/Adapter/DatabaseTest.php +++ b/packages/audit/tests/Audit/Adapter/DatabaseTest.php @@ -13,11 +13,7 @@ use Utopia\Cache\Adapter\None as NoCache; use Utopia\Cache\Cache; use Utopia\Database\Adapter\MariaDB; -use Utopia\Database\Attribute; use Utopia\Database\Database; -use Utopia\Database\Index; -use Utopia\Query\Schema\ColumnType; -use Utopia\Query\Schema\IndexType; use Utopia\Tests\Audit\AuditBase; /** @@ -50,40 +46,16 @@ protected function initializeAudit(): void } } - public function testSchemaValueObjectsAndNullableLog(): void + public function testLogWithoutUserIdOrData(): void { - $adapter = $this->audit->getAdapter(); - $this->assertInstanceOf(Adapter\Database::class, $adapter); - - $attributes = $adapter->getAttributes(); - $this->assertCount(7, $attributes); - foreach ($attributes as $attribute) { - $this->assertInstanceOf(Attribute::class, $attribute); - $this->assertNull($attribute->default); - } - $this->assertSame('userId', $attributes[0]->key); - $this->assertSame(ColumnType::String, $attributes[0]->type); - $this->assertSame(Database::LENGTH_KEY, $attributes[0]->size); - $this->assertFalse($attributes[0]->required); - $this->assertTrue($attributes[1]->required); - $this->assertSame(ColumnType::Datetime, $attributes[5]->type); - $this->assertSame(['datetime'], $attributes[5]->filters); - $this->assertSame(['json'], $attributes[6]->filters); - - $indexes = $adapter->getIndexes(); - $this->assertCount(4, $indexes); - foreach ($indexes as $index) { - $this->assertInstanceOf(Index::class, $index); - $this->assertSame(IndexType::Key, $index->type); - } - $this->assertSame(['userId', 'event'], $indexes[1]->attributes); - $log = $this->audit->log(null, 'schema.created', 'schema/defaults', 'test', '127.0.0.1'); + $stored = $this->audit->getLogById($log->getId()); $this->assertInstanceOf(Log::class, $stored); $this->assertNull($stored->getUserId()); $this->assertSame([], $stored->getAttribute('data')); $this->assertNotEmpty($stored->getAttribute('time')); + $logs = $this->audit->find([ Query::equal('resource', ['schema/defaults']), Query::containsString('event', ['created']), @@ -91,4 +63,16 @@ public function testSchemaValueObjectsAndNullableLog(): void $this->assertCount(1, $logs); $this->assertSame($log->getId(), $logs[0]->getId()); } + + public function testUserIdAcceptsFullKeyLength(): void + { + $userId = str_repeat('u', Database::LENGTH_KEY); + + $log = $this->audit->log($userId, 'schema.length', 'schema/length', 'test', '127.0.0.1'); + + $stored = $this->audit->getLogById($log->getId()); + $this->assertInstanceOf(Log::class, $stored); + $this->assertSame($userId, $stored->getUserId()); + $this->assertCount(1, $this->audit->getLogsByUser($userId)); + } } From 12ff11bbee9b2042cac80c9529a18c7cdb31a4fc Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 22 Sep 2026 17:05:12 +1200 Subject: [PATCH 3/4] chore(audit): take the database branch from Packagist, not a VCS override CLAUDE.md requires packages to depend on siblings through Packagist constraints. Packagist already serves every branch of utopia-php/database, including feat-query-lib, so the repository override bought nothing and pinned resolution to whatever commit the last lock captured. Without it composer resolves the branch head directly. Co-Authored-By: Claude Opus 5 (1M context) --- packages/audit/composer.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/audit/composer.json b/packages/audit/composer.json index 4f7b30b2e..e9b18aac4 100644 --- a/packages/audit/composer.json +++ b/packages/audit/composer.json @@ -36,12 +36,6 @@ "ext-pdo_mysql": "*", "ext-swoole": "*" }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/utopia-php/database.git" - } - ], "config": { "allow-plugins": { "php-http/discovery": true, From 7c9c8296ccfb104ba28780bafb2cee47252c0c0e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 24 Sep 2026 05:01:45 +1200 Subject: [PATCH 4/4] refactor(audit): read Query's TYPE_* constants from the Method enum Utopia\Audit\Query re-declared its 25 legacy TYPE_* method names as string literals, duplicating values that utopia-php/query's Method enum owns (review finding 10.11). They match today, but nothing tied the two together: if query changed a case's value, the constants would keep the old string with no error anywhere. A query built from one would then throw in Method::from(), and a comparison with getMethod()->value would quietly turn false. Each constant is now Method::X->value, so a changed value carries over on its own, and a renamed or removed case fails PHPStan at the constant and throws on the first Query instantiation. The values and the public API are unchanged; the constants stay untyped, so a subclass that redeclares one still compiles. QueryTest gains a parity test asserting that every TYPE_* constant is a Method value. It passes before and after this change because the values match; it fails as soon as a literal falls out of step with the enum. Co-Authored-By: Claude Opus 5.5 --- packages/audit/src/Audit/Query.php | 61 +++++++++++------------- packages/audit/tests/Audit/QueryTest.php | 21 ++++++++ 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/packages/audit/src/Audit/Query.php b/packages/audit/src/Audit/Query.php index dfba1825c..223a12d75 100644 --- a/packages/audit/src/Audit/Query.php +++ b/packages/audit/src/Audit/Query.php @@ -6,75 +6,68 @@ use Utopia\Query\Query as BaseQuery; /** - * Audit Query - * * Thin extension of `Utopia\Query\Query` so audit consumers share the * canonical query types — including `cursorAfter` / `cursorBefore` and * `parse()` validation — while keeping audit's lenient single-value factory * signatures (the base requires arrays / scalars only; audit accepts mixed * including `DateTime` for the `time` column). * - * Also re-exposes the legacy `TYPE_*` string constants the audit adapter and - * its tests have always used. The base library moved to a `Method` enum in - * 0.3.x; the constants here map to the same string values (`equal`, - * `lessThan`, etc.) so existing call sites keep working. + * Also re-exposes the legacy `TYPE_*` string constants so existing call sites + * keep working. */ class Query extends BaseQuery { - public const TYPE_EQUAL = 'equal'; + public const TYPE_EQUAL = Method::Equal->value; - public const TYPE_NOT_EQUAL = 'notEqual'; + public const TYPE_NOT_EQUAL = Method::NotEqual->value; - public const TYPE_LESSER = 'lessThan'; + public const TYPE_LESSER = Method::LessThan->value; - public const TYPE_LESSER_EQUAL = 'lessThanEqual'; + public const TYPE_LESSER_EQUAL = Method::LessThanEqual->value; - public const TYPE_GREATER = 'greaterThan'; + public const TYPE_GREATER = Method::GreaterThan->value; - public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; + public const TYPE_GREATER_EQUAL = Method::GreaterThanEqual->value; - public const TYPE_BETWEEN = 'between'; + public const TYPE_BETWEEN = Method::Between->value; - public const TYPE_NOT_BETWEEN = 'notBetween'; + public const TYPE_NOT_BETWEEN = Method::NotBetween->value; - public const TYPE_CONTAINS = 'contains'; + public const TYPE_CONTAINS = Method::Contains->value; - public const TYPE_NOT_CONTAINS = 'notContains'; + public const TYPE_NOT_CONTAINS = Method::NotContains->value; - public const TYPE_IS_NULL = 'isNull'; + public const TYPE_IS_NULL = Method::IsNull->value; - public const TYPE_IS_NOT_NULL = 'isNotNull'; + public const TYPE_IS_NOT_NULL = Method::IsNotNull->value; - public const TYPE_STARTS_WITH = 'startsWith'; + public const TYPE_STARTS_WITH = Method::StartsWith->value; - public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; + public const TYPE_NOT_STARTS_WITH = Method::NotStartsWith->value; - public const TYPE_ENDS_WITH = 'endsWith'; + public const TYPE_ENDS_WITH = Method::EndsWith->value; - public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + public const TYPE_NOT_ENDS_WITH = Method::NotEndsWith->value; - public const TYPE_REGEX = 'regex'; + public const TYPE_REGEX = Method::Regex->value; - public const TYPE_SELECT = 'select'; + public const TYPE_SELECT = Method::Select->value; - public const TYPE_ORDER_DESC = 'orderDesc'; + public const TYPE_ORDER_DESC = Method::OrderDesc->value; - public const TYPE_ORDER_ASC = 'orderAsc'; + public const TYPE_ORDER_ASC = Method::OrderAsc->value; - public const TYPE_ORDER_RANDOM = 'orderRandom'; + public const TYPE_ORDER_RANDOM = Method::OrderRandom->value; - public const TYPE_LIMIT = 'limit'; + public const TYPE_LIMIT = Method::Limit->value; - public const TYPE_OFFSET = 'offset'; + public const TYPE_OFFSET = Method::Offset->value; - public const TYPE_CURSOR_AFTER = 'cursorAfter'; + public const TYPE_CURSOR_AFTER = Method::CursorAfter->value; - public const TYPE_CURSOR_BEFORE = 'cursorBefore'; + public const TYPE_CURSOR_BEFORE = Method::CursorBefore->value; /** - * Construct a query with a string method name (legacy `TYPE_*` constants) - * or a `Method` enum case (new 0.3.x API). - * * @param array $values */ public function __construct(Method|string $method, string $attribute = '', array $values = []) diff --git a/packages/audit/tests/Audit/QueryTest.php b/packages/audit/tests/Audit/QueryTest.php index 0232bf63f..38a8bf68c 100644 --- a/packages/audit/tests/Audit/QueryTest.php +++ b/packages/audit/tests/Audit/QueryTest.php @@ -4,8 +4,10 @@ namespace Utopia\Tests\Audit; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Utopia\Audit\Query; +use Utopia\Query\Method; final class QueryTest extends TestCase { @@ -224,4 +226,23 @@ public function testQueryToStringWithComplexValues(): void $this->assertSame('time', $parsed->getAttribute()); $this->assertSame(['2023-01-01', '2024-12-31'], $parsed->getValues()); } + + /** + * @return iterable + */ + public static function constants(): iterable + { + foreach (new \ReflectionClass(Query::class)->getConstants() as $name => $value) { + if (str_starts_with($name, 'TYPE_')) { + yield $name => [$value]; + } + } + } + + #[DataProvider('constants')] + public function testConstantMatchesMethod(mixed $value): void + { + $this->assertIsString($value); + $this->assertSame($value, Method::tryFrom($value)?->value, 'Not a ' . Method::class . ' value'); + } }