From 43c69796f1ad3d8a20ed403dab9d04c4b2d855d0 Mon Sep 17 00:00:00 2001 From: Claudear <262350598+claudear@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:33:35 +0000 Subject: [PATCH 1/2] feat(appwrite): create the abuse table schema inline Appwrite now builds columns and indexes sent inline with create table from the same mapping the dedicated per-column endpoints use, so the workaround of create table + one request per column is no longer needed (appwrite/appwrite#13174). Setup now sends the whole schema with the table in a single request. Inline columns and indexes come back available, so the two polling loops are gone from the cold path. A table left over from a setup that did not run to completion still falls back to the per-column endpoints, since inline definitions only apply while the table is being created; both paths now read the schema from one list instead of restating it. Also bumps appwrite/appwrite to 27.1, and fixes the readiness poll: list responses carry raw payloads rather than hydrated models, so reading status as a property always yielded '' and the poll could only ever time out. Co-Authored-By: Claude Opus 5 --- composer.json | 2 +- composer.lock | 76 +++++++++- .../Adapters/TimeLimit/Appwrite/TablesDB.php | 142 +++++++++++++++--- tests/Abuse/Appwrite/TablesDBTest.php | 112 ++++++++++++++ 4 files changed, 300 insertions(+), 32 deletions(-) diff --git a/composer.json b/composer.json index 0138037..a79d771 100755 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "ext-redis": "*", "utopia-php/database": "^7.0.0", "utopia-php/pools": "2.*", - "appwrite/appwrite": "^26.0" + "appwrite/appwrite": "^27.1" }, "require-dev": { "phpunit/phpunit": "9.*", diff --git a/composer.lock b/composer.lock index 5f2aab6..a690369 100644 --- a/composer.lock +++ b/composer.lock @@ -4,23 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4aff8f896ffc6d7981242fdf9ce459cc", + "content-hash": "bfae95b93046381a457db8b4f33d5b35", "packages": [ + { + "name": "adhocore/jwt", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/adhocore/php-jwt.git", + "reference": "b7055ae9024a6627f19f9873b5db19b0f1dcc4e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/adhocore/php-jwt/zipball/b7055ae9024a6627f19f9873b5db19b0f1dcc4e7", + "reference": "b7055ae9024a6627f19f9873b5db19b0f1dcc4e7", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ahc\\Jwt\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jitendra Adhikari", + "email": "jiten.adhikary@gmail.com" + } + ], + "description": "Ultra lightweight JSON web token (JWT) library for PHP5.5+.", + "keywords": [ + "auth", + "json-web-token", + "jwt", + "jwt-auth", + "jwt-php", + "token" + ], + "support": { + "issues": "https://github.com/adhocore/php-jwt/issues", + "source": "https://github.com/adhocore/php-jwt/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://paypal.me/ji10", + "type": "custom" + }, + { + "url": "https://github.com/adhocore", + "type": "github" + } + ], + "time": "2026-05-30T07:58:15+00:00" + }, { "name": "appwrite/appwrite", - "version": "26.0.0", + "version": "27.1.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-for-php.git", - "reference": "bc26d0f0cfa4699de12d6af4df70ac0729ab7918" + "reference": "24685bc34928c0e5479d4977dc47ea4aa721f025" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/bc26d0f0cfa4699de12d6af4df70ac0729ab7918", - "reference": "bc26d0f0cfa4699de12d6af4df70ac0729ab7918", + "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/24685bc34928c0e5479d4977dc47ea4aa721f025", + "reference": "24685bc34928c0e5479d4977dc47ea4aa721f025", "shasum": "" }, "require": { + "adhocore/jwt": "^1.1", "ext-curl": "*", "ext-json": "*", "php": ">=8.2.0" @@ -43,10 +105,10 @@ "support": { "email": "team@appwrite.io", "issues": "https://github.com/appwrite/sdk-for-php/issues", - "source": "https://github.com/appwrite/sdk-for-php/tree/26.0.0", + "source": "https://github.com/appwrite/sdk-for-php/tree/27.1.0", "url": "https://appwrite.io/support" }, - "time": "2026-06-17T04:32:07+00:00" + "time": "2026-07-24T06:10:52+00:00" }, { "name": "brick/math", diff --git a/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php b/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php index 7c95da4..f568e06 100644 --- a/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php +++ b/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php @@ -44,11 +44,17 @@ public function setup(): void } $this->createDatabase(); - $this->createTable(); - $this->createColumns(); - $this->waitForResourcesReady('columns'); - $this->createIndexes(); - $this->waitForResourcesReady('indexes'); + + if (! $this->createTable()) { + // The table is left over from a setup that did not run to completion, + // so some of its columns or indexes may be missing. Inline definitions + // only apply while the table is being created, so add them one by one. + $this->createColumns(); + $this->waitForResourcesReady('columns'); + $this->createIndexes(); + $this->waitForResourcesReady('indexes'); + } + $this->createLockTable(); } @@ -70,36 +76,98 @@ protected function createDatabase(): void ); } - protected function createTable(): void + /** + * Create the abuse table along with its columns and indexes in one request. + * + * Inline columns and indexes are created synchronously and come back + * available, so there is nothing to poll for afterwards. + * + * @return bool false when the table already existed + */ + protected function createTable(): bool { - $this->executeWithSilentError( - fn () => $this->tablesDB->createTable($this->databaseId, self::TABLE_ID, self::TABLE_NAME), + return $this->executeWithSilentError( + fn () => $this->tablesDB->createTable( + $this->databaseId, + self::TABLE_ID, + self::TABLE_NAME, + columns: $this->columnDefinitions(), + indexes: $this->indexDefinitions(), + ), 'table_already_exists' ); } - protected function createColumns(): void + /** + * Columns sent inline when the table is created. + * + * createColumns() repairs a table that already exists from the same list. + * + * @return array + */ + protected function columnDefinitions(): array + { + return [ + ['key' => 'key', 'type' => 'string', 'size' => 255, 'required' => true], + ['key' => 'time', 'type' => 'datetime', 'required' => true], + ['key' => 'count', 'type' => 'integer', 'required' => true, 'min' => 0, 'max' => PHP_INT_MAX], + ]; + } + + /** + * Indexes sent inline when the table is created. + * + * createIndexes() repairs a table that already exists from the same list. + * + * An inline definition names its columns under 'attributes', even though + * the index that comes back reports them under 'columns'. + * + * @return array}> + */ + protected function indexDefinitions(): array { - $columns = [ - fn () => $this->tablesDB->createStringColumn($this->databaseId, self::TABLE_ID, 'key', 255, true), - fn () => $this->tablesDB->createDatetimeColumn($this->databaseId, self::TABLE_ID, 'time', true), - fn () => $this->tablesDB->createIntegerColumn($this->databaseId, self::TABLE_ID, 'count', true, 0, PHP_INT_MAX) + return [ + ['key' => 'unique1', 'type' => (string) TablesDBIndexType::UNIQUE(), 'attributes' => ['key', 'time']], + ['key' => 'index2', 'type' => (string) TablesDBIndexType::KEY(), 'attributes' => ['time']], ]; + } + + /** + * Add the columns to a table that already exists, one endpoint per type. + */ + protected function createColumns(): void + { + foreach ($this->columnDefinitions() as $column) { + $key = $column['key']; + $required = $column['required']; + + $createColumnFunction = match ($column['type']) { + 'string' => fn () => $this->tablesDB->createStringColumn($this->databaseId, self::TABLE_ID, $key, $column['size'] ?? 0, $required), + 'datetime' => fn () => $this->tablesDB->createDatetimeColumn($this->databaseId, self::TABLE_ID, $key, $required), + 'integer' => fn () => $this->tablesDB->createIntegerColumn($this->databaseId, self::TABLE_ID, $key, $required, $column['min'] ?? null, $column['max'] ?? null), + default => throw new \Exception("No endpoint for column '{$key}'."), + }; - foreach ($columns as $createColumnFunction) { $this->executeWithSilentError($createColumnFunction, 'column_already_exists'); } } + /** + * Add the indexes to a table that already exists. + */ protected function createIndexes(): void { - $indexes = [ - fn () => $this->tablesDB->createIndex($this->databaseId, self::TABLE_ID, 'unique1', TablesDBIndexType::UNIQUE(), ['key', 'time']), - fn () => $this->tablesDB->createIndex($this->databaseId, self::TABLE_ID, 'index2', TablesDBIndexType::KEY(), ['time']) - ]; - - foreach ($indexes as $createIndexFunction) { - $this->executeWithSilentError($createIndexFunction, 'index_already_exists'); + foreach ($this->indexDefinitions() as $index) { + $this->executeWithSilentError( + fn () => $this->tablesDB->createIndex( + $this->databaseId, + self::TABLE_ID, + $index['key'], + TablesDBIndexType::from($index['type']), + $index['attributes'], + ), + 'index_already_exists' + ); } } @@ -115,8 +183,7 @@ protected function waitForResourcesReady(string $resourceType): void ? $this->tablesDB->listColumns($this->databaseId, self::TABLE_ID, [Query::notEqual('status', 'available'), Query::limit(1)])->columns : $this->tablesDB->listIndexes($this->databaseId, self::TABLE_ID, [Query::notEqual('status', 'available'), Query::limit(1)])->indexes; - // Column models expose status as a ColumnStatus enum; index models as a plain string. Cast for both. - $resources = \array_filter($resources, fn ($resource) => (string) $resource->status !== 'available'); + $resources = \array_filter($resources, fn ($resource) => $this->resourceStatus($resource) !== 'available'); if (\count($resources) === 0) { return; @@ -128,6 +195,26 @@ protected function waitForResourcesReady(string $resourceType): void throw new \Exception("Failed to setup {$resourceType}."); } + /** + * Read the status off a listed column or index. + * + * List responses carry the raw payloads rather than hydrated models, so + * both an array and a model exposing a status are accepted. Column models + * hold a ColumnStatus enum, index models a plain string. + */ + protected function resourceStatus(mixed $resource): string + { + $status = null; + + if (\is_array($resource)) { + $status = $resource['status'] ?? null; + } elseif (\is_object($resource) && \property_exists($resource, 'status')) { + $status = $resource->status; + } + + return \is_scalar($status) || $status instanceof \Stringable ? (string) $status : ''; + } + protected function createLockTable(): void { $this->executeWithSilentError( @@ -136,14 +223,21 @@ protected function createLockTable(): void ); } - protected function executeWithSilentError(callable $callback, string $allowedErrorType): void + /** + * @return bool false when the call failed with the tolerated error + */ + protected function executeWithSilentError(callable $callback, string $allowedErrorType): bool { try { $callback(); + + return true; } catch (AppwriteException $err) { if ($err->getType() !== $allowedErrorType) { throw $err; } + + return false; } } diff --git a/tests/Abuse/Appwrite/TablesDBTest.php b/tests/Abuse/Appwrite/TablesDBTest.php index f45897d..b439cd6 100755 --- a/tests/Abuse/Appwrite/TablesDBTest.php +++ b/tests/Abuse/Appwrite/TablesDBTest.php @@ -3,6 +3,8 @@ namespace Utopia\Tests; use Appwrite\Client; +use Appwrite\Services\TablesDB as TablesDBService; +use Utopia\Abuse\Abuse; use Utopia\Abuse\Adapters\TimeLimit; use Utopia\Abuse\Adapters\TimeLimit\Appwrite\TablesDB; @@ -37,6 +39,116 @@ public function getAdapter(string $key, int $limit, int $seconds): TimeLimit return new TablesDB($key, $limit, $seconds, self::$client, self::$databaseId); } + /** + * The schema is sent inline with the table, so assert it lands exactly as + * the dedicated per-column endpoints would have created it. + */ + public function testSetupCreatesSchema(): void + { + $tablesDB = new TablesDBService(self::$client); + + $columns = $this->indexByKey($tablesDB->listColumns(self::$databaseId, TablesDB::TABLE_ID)->columns); + + $this->assertCount(3, $columns); + + $this->assertSame('string', $columns['key']['type']); + $this->assertSame(255, $columns['key']['size']); + $this->assertTrue($columns['key']['required']); + + $this->assertSame('datetime', $columns['time']['type']); + $this->assertTrue($columns['time']['required']); + + $this->assertSame('integer', $columns['count']['type']); + $this->assertTrue($columns['count']['required']); + $this->assertEquals(0, $columns['count']['min']); + $this->assertEquals(PHP_INT_MAX, $columns['count']['max']); + + $indexes = $this->indexByKey($tablesDB->listIndexes(self::$databaseId, TablesDB::TABLE_ID)->indexes); + + $this->assertCount(2, $indexes); + + $this->assertSame('unique', $indexes['unique1']['type']); + $this->assertSame(['key', 'time'], $indexes['unique1']['columns']); + + $this->assertSame('key', $indexes['index2']['type']); + $this->assertSame(['time'], $indexes['index2']['columns']); + } + + /** + * A table left behind by a setup that did not run to completion is missing + * its columns and indexes, and they can no longer be sent inline. Setup has + * to fill them in one by one instead. + */ + public function testSetupRepairsPartiallyCreatedTable(): void + { + $databaseId = 'abuse-cicd-repair-' . \uniqid(); + $tablesDB = new TablesDBService(self::$client); + + $tablesDB->create($databaseId, TablesDB::DATABASE_NAME); + + try { + $tablesDB->createTable($databaseId, TablesDB::TABLE_ID, TablesDB::TABLE_NAME); + + $adapter = new TablesDB('repair-{{ip}}', 2, 60, self::$client, $databaseId); + $adapter->setup(); + + $columns = $this->indexByKey($tablesDB->listColumns($databaseId, TablesDB::TABLE_ID)->columns); + $indexes = $this->indexByKey($tablesDB->listIndexes($databaseId, TablesDB::TABLE_ID)->indexes); + + $this->assertCount(3, $columns); + $this->assertArrayHasKey('key', $columns); + $this->assertArrayHasKey('time', $columns); + $this->assertArrayHasKey('count', $columns); + + $this->assertCount(2, $indexes); + $this->assertArrayHasKey('unique1', $indexes); + $this->assertArrayHasKey('index2', $indexes); + + $adapter->setParam('{{ip}}', '0.0.0.20'); + $abuse = new Abuse($adapter); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), true); + } finally { + $tablesDB->delete($databaseId); + } + } + + /** + * Setup runs on every boot, so it has to be a no-op once the table is there. + */ + public function testSetupIsIdempotent(): void + { + $adapter = new TablesDB('', 1, 1, self::$client, self::$databaseId); + $adapter->setup(); + + $tablesDB = new TablesDBService(self::$client); + + $this->assertCount(3, $tablesDB->listColumns(self::$databaseId, TablesDB::TABLE_ID)->columns); + $this->assertCount(2, $tablesDB->listIndexes(self::$databaseId, TablesDB::TABLE_ID)->indexes); + } + + /** + * @param array $resources + * @return array> + */ + private function indexByKey(array $resources): array + { + $byKey = []; + + foreach ($resources as $resource) { + $this->assertIsArray($resource); + $this->assertSame('available', $resource['status']); + + $key = $resource['key']; + $this->assertIsString($key); + + $byKey[$key] = $resource; + } + + return $byKey; + } + public static function tearDownAfterClass(): void { } From 6b358a2eed887bb362a82a7cbdaeb65220923da5 Mon Sep 17 00:00:00 2001 From: Claudear <262350598+claudear@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:37:55 +0000 Subject: [PATCH 2/2] fix(tests): read listed indexes as models, columns as raw payloads listIndexes hydrates each entry into a ColumnIndex; listColumns does not, because the SDK has no single model for the union of column types. The schema assertions treated both as arrays. Co-Authored-By: Claude Opus 5 --- .../Adapters/TimeLimit/Appwrite/TablesDB.php | 6 +-- tests/Abuse/Appwrite/TablesDBTest.php | 54 +++++++++++++------ 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php b/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php index f568e06..fcd2563 100644 --- a/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php +++ b/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php @@ -198,9 +198,9 @@ protected function waitForResourcesReady(string $resourceType): void /** * Read the status off a listed column or index. * - * List responses carry the raw payloads rather than hydrated models, so - * both an array and a model exposing a status are accepted. Column models - * hold a ColumnStatus enum, index models a plain string. + * A listed column arrives as the raw payload, since the SDK has no single + * model to hydrate the union of column types into, while a listed index + * arrives as a ColumnIndex. Accept either shape. */ protected function resourceStatus(mixed $resource): string { diff --git a/tests/Abuse/Appwrite/TablesDBTest.php b/tests/Abuse/Appwrite/TablesDBTest.php index b439cd6..42fd0a4 100755 --- a/tests/Abuse/Appwrite/TablesDBTest.php +++ b/tests/Abuse/Appwrite/TablesDBTest.php @@ -3,6 +3,7 @@ namespace Utopia\Tests; use Appwrite\Client; +use Appwrite\Models\ColumnIndex; use Appwrite\Services\TablesDB as TablesDBService; use Utopia\Abuse\Abuse; use Utopia\Abuse\Adapters\TimeLimit; @@ -47,7 +48,7 @@ public function testSetupCreatesSchema(): void { $tablesDB = new TablesDBService(self::$client); - $columns = $this->indexByKey($tablesDB->listColumns(self::$databaseId, TablesDB::TABLE_ID)->columns); + $columns = $this->columnsByKey($tablesDB->listColumns(self::$databaseId, TablesDB::TABLE_ID)->columns); $this->assertCount(3, $columns); @@ -63,15 +64,15 @@ public function testSetupCreatesSchema(): void $this->assertEquals(0, $columns['count']['min']); $this->assertEquals(PHP_INT_MAX, $columns['count']['max']); - $indexes = $this->indexByKey($tablesDB->listIndexes(self::$databaseId, TablesDB::TABLE_ID)->indexes); + $indexes = $this->indexesByKey($tablesDB->listIndexes(self::$databaseId, TablesDB::TABLE_ID)->indexes); $this->assertCount(2, $indexes); - $this->assertSame('unique', $indexes['unique1']['type']); - $this->assertSame(['key', 'time'], $indexes['unique1']['columns']); + $this->assertSame('unique', $indexes['unique1']->type); + $this->assertSame(['key', 'time'], $indexes['unique1']->columns); - $this->assertSame('key', $indexes['index2']['type']); - $this->assertSame(['time'], $indexes['index2']['columns']); + $this->assertSame('key', $indexes['index2']->type); + $this->assertSame(['time'], $indexes['index2']->columns); } /** @@ -92,8 +93,8 @@ public function testSetupRepairsPartiallyCreatedTable(): void $adapter = new TablesDB('repair-{{ip}}', 2, 60, self::$client, $databaseId); $adapter->setup(); - $columns = $this->indexByKey($tablesDB->listColumns($databaseId, TablesDB::TABLE_ID)->columns); - $indexes = $this->indexByKey($tablesDB->listIndexes($databaseId, TablesDB::TABLE_ID)->indexes); + $columns = $this->columnsByKey($tablesDB->listColumns($databaseId, TablesDB::TABLE_ID)->columns); + $indexes = $this->indexesByKey($tablesDB->listIndexes($databaseId, TablesDB::TABLE_ID)->indexes); $this->assertCount(3, $columns); $this->assertArrayHasKey('key', $columns); @@ -129,21 +130,44 @@ public function testSetupIsIdempotent(): void } /** - * @param array $resources + * A listed column arrives as the raw payload: the SDK has no single model + * to hydrate the union of column types into. + * + * @param array $columns * @return array> */ - private function indexByKey(array $resources): array + private function columnsByKey(array $columns): array { $byKey = []; - foreach ($resources as $resource) { - $this->assertIsArray($resource); - $this->assertSame('available', $resource['status']); + foreach ($columns as $column) { + $this->assertIsArray($column); + $this->assertSame('available', $column['status']); - $key = $resource['key']; + $key = $column['key']; $this->assertIsString($key); - $byKey[$key] = $resource; + $byKey[$key] = $column; + } + + return $byKey; + } + + /** + * A listed index, unlike a column, arrives hydrated. + * + * @param array $indexes + * @return array + */ + private function indexesByKey(array $indexes): array + { + $byKey = []; + + foreach ($indexes as $index) { + $this->assertInstanceOf(ColumnIndex::class, $index); + $this->assertSame('available', $index->status); + + $byKey[$index->key] = $index; } return $byKey;