From 9fc2c5db69d8ea174caaef384de29496fde00085 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 9 Jun 2025 23:56:30 -0400 Subject: [PATCH 1/9] Add increment + decrement routes --- app/controllers/api/databases.php | 156 +++++++++++++ composer.lock | 24 +- .../e2e/Services/Databases/DatabasesBase.php | 206 ++++++++++++++++++ 3 files changed, 374 insertions(+), 12 deletions(-) diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 6cafc2ffef..887695c087 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -38,6 +38,7 @@ use Utopia\Database\Exception\Relationship as RelationshipException; use Utopia\Database\Exception\Restricted as RestrictedException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Truncate as TruncateException; +use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -62,6 +63,7 @@ use Utopia\Validator\Integer; use Utopia\Validator\IP; use Utopia\Validator\JSON; use Utopia\Validator\Nullable; +use Utopia\Validator\Numeric; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -4462,6 +4464,160 @@ App::put('/v1/databases/:databaseId/collections/:collectionId/documents/:documen $response->dynamic($document, Response::MODEL_DOCUMENT); }); +App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId/:key/increment') + ->desc('Increment document attribute') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.increment') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'databases', + group: 'documents', + name: 'incrementDocumentAttribute', + description: '/docs/references/databases/increment-document-attribute.md', + auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_DOCUMENT, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('attribute', '', new Key(), 'Document ID.') + ->param('value', 1, new Numeric(), 'Value to increment the attribute by. The value must be a number.', true) + ->param('max', null, new Numeric(), 'Maximum value for the attribute. If the current value is greater than this value, an error will be thrown.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->action(function (string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, Response $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage) { + $database = $dbForProject->getDocument('databases', $databaseId); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + try { + $document = $dbForProject->increaseDocumentAttribute( + collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), + id: $documentId, + attribute: $attribute, + value: $value, + max: $max + ); + } catch (ConflictException) { + throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT); + } catch (NotFoundException) { + throw new Exception(Exception::ATTRIBUTE_NOT_FOUND); + } catch (LimitException) { + throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED, 'Attribute `' . $attribute . '` has reached the maximum value of ' . $max); + } catch (TypeException) { + throw new Exception(Exception::ATTRIBUTE_TYPE_INVALID, 'Attribute `' . $attribute . '` is not a number'); + } + + $queueForStatsUsage + ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); + + $queueForEvents + ->setParam('databaseId', $databaseId) + ->setParam('collectionId', $collectionId) + ->setContext('collection', $collection) + ->setContext('database', $database); + + $response->dynamic($document, Response::MODEL_DOCUMENT); + }); + +App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId/:key/decrement') + ->desc('Decrement document attribute') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].decrement') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.decrement') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'databases', + group: 'documents', + name: 'decrementDocumentAttribute', + description: '/docs/references/databases/increment-document-attribute.md', + auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('attribute', '', new Key(), 'Document ID.') + ->param('value', 1, new Numeric(), 'Value to decrement the attribute by. The value must be a number.', true) + ->param('min', null, new Numeric(), 'Minimum value for the attribute. If the current value is lesser than this value, an exception will be thrown.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->action(function (string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, Response $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage) { + $database = $dbForProject->getDocument('databases', $databaseId); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + try { + $document = $dbForProject->decreaseDocumentAttribute( + collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), + id: $documentId, + attribute: $attribute, + value: $value, + min: $min + ); + } catch (ConflictException) { + throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT); + } catch (NotFoundException) { + throw new Exception(Exception::ATTRIBUTE_NOT_FOUND); + } catch (LimitException) { + throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED, 'Attribute "' . $attribute . '" has reached the minimum value of ' . $min); + } catch (TypeException) { + throw new Exception(Exception::ATTRIBUTE_TYPE_INVALID, 'Attribute "' . $attribute . '" is not a number'); + } + + $queueForStatsUsage + ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); + + $queueForEvents + ->setParam('databaseId', $databaseId) + ->setParam('collectionId', $collectionId) + ->setContext('collection', $collection) + ->setContext('database', $database); + + $response->dynamic($document, Response::MODEL_DOCUMENT); + }); + App::patch('/v1/databases/:databaseId/collections/:collectionId/documents') ->desc('Update documents') ->groups(['api', 'database']) diff --git a/composer.lock b/composer.lock index debb3649b8..0ce712ea30 100644 --- a/composer.lock +++ b/composer.lock @@ -3490,16 +3490,16 @@ }, { "name": "utopia-php/database", - "version": "0.71.1", + "version": "0.71.3", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "7b2622d336c2fa4bd6627d5e175083bd4f3c7c0e" + "reference": "f0c28b78548e2b740d940ca17dca30e1e532d53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/7b2622d336c2fa4bd6627d5e175083bd4f3c7c0e", - "reference": "7b2622d336c2fa4bd6627d5e175083bd4f3c7c0e", + "url": "https://api.github.com/repos/utopia-php/database/zipball/f0c28b78548e2b740d940ca17dca30e1e532d53c", + "reference": "f0c28b78548e2b740d940ca17dca30e1e532d53c", "shasum": "" }, "require": { @@ -3540,9 +3540,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.71.1" + "source": "https://github.com/utopia-php/database/tree/0.71.3" }, - "time": "2025-06-09T18:14:46+00:00" + "time": "2025-06-10T03:53:35+00:00" }, { "name": "utopia-php/detector", @@ -4807,16 +4807,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "0.41.1", + "version": "0.41.2", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "6d9318abf4542a757c87abf056557d6afa1dc06b" + "reference": "e9a324efef9080808e07a782be2420cd4454cff7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6d9318abf4542a757c87abf056557d6afa1dc06b", - "reference": "6d9318abf4542a757c87abf056557d6afa1dc06b", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e9a324efef9080808e07a782be2420cd4454cff7", + "reference": "e9a324efef9080808e07a782be2420cd4454cff7", "shasum": "" }, "require": { @@ -4852,9 +4852,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/0.41.1" + "source": "https://github.com/appwrite/sdk-generator/tree/0.41.2" }, - "time": "2025-06-01T04:20:04+00:00" + "time": "2025-06-10T03:08:44+00:00" }, { "name": "doctrine/annotations", diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 6038448889..b18f918049 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -5298,4 +5298,210 @@ trait DatabasesBase 'x-appwrite-key' => $this->getProject()['apiKey'] ])); } + + /** + * @throws \Exception + */ + public function testIncrementAttributeRoutes(): array + { + $database = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'CounterDatabase' + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'CounterCollection', + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + Permission::read(Role::user($this->getUser()['$id'])), + ], + ]); + $collectionId = $collection['body']['$id']; + + // Add integer attribute + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'count', + 'required' => true, + 'default' => 0, + ]); + + \sleep(1); + + // Create document with initial count = 5 + $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => 'counter1', + 'data' => ['count' => 5], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + $this->assertEquals(201, $doc['headers']['status-code']); + + // Increment by default 1 + $inc = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/counter1/count/increment', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + $this->assertEquals(200, $inc['headers']['status-code']); + $this->assertEquals(6, $inc['body']['count']); + + // Verify count = 6 + $get = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/counter1', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(6, $get['body']['count']); + + // Increment by custom value 4 + $inc2 = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/counter1/count/increment', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'value' => 4 + ]); + $this->assertEquals(200, $inc2['headers']['status-code']); + $this->assertEquals(10, $inc2['body']['count']); + + $get2 = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/counter1', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(10, $get2['body']['count']); + + // Test max limit exceeded + $err = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/counter1/count/increment', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), ['max' => 8]); + $this->assertEquals(400, $err['headers']['status-code']); + + // Test attribute not found + $notFound = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/counter1/unknown/increment', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + $this->assertEquals(404, $notFound['headers']['status-code']); + } + + public function testDecrementAttributeRoutes(array $data): void + { + $database = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'CounterDatabase' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'CounterCollection', + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + Permission::read(Role::user($this->getUser()['$id'])), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Add integer attribute + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'count', + 'required' => true, + 'default' => 10, + ]); + + \sleep(1); + + // Create document with initial count = 10 + $documentId = 'counter1'; + $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $documentId, + 'data' => ['count' => 10], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + // Decrement by default 1 (count = 10 -> 9) + $dec = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId . '/count/decrement', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + $this->assertEquals(200, $dec['headers']['status-code']); + $this->assertEquals(9, $dec['body']['count']); + + $get = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(9, $get['body']['count']); + + // Decrement by custom value 3 (count 9 -> 6) + $dec2 = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId . '/count/decrement', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'value' => 3 + ]); + $this->assertEquals(200, $dec2['headers']['status-code']); + $this->assertEquals(6, $dec2['body']['count']); + + $get2 = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(6, $get2['body']['count']); + + // Test min limit exceeded + $err = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId . '/count/decrement', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), ['min' => 7]); + $this->assertEquals(400, $err['headers']['status-code']); + + // Test type error on non-numeric attribute + $typeErr = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId . '/count/decrement', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), ['value' => 'not-a-number']); + $this->assertEquals(400, $typeErr['headers']['status-code']); + } + + } From fd7d4db4c498ee716631d01155d5bb6ed4b0ae81 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Jun 2025 00:22:38 -0400 Subject: [PATCH 2/9] Fix path param --- app/controllers/api/databases.php | 4 ++-- .../e2e/Services/Databases/DatabasesBase.php | 21 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 887695c087..16a8b05339 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -4464,7 +4464,7 @@ App::put('/v1/databases/:databaseId/collections/:collectionId/documents/:documen $response->dynamic($document, Response::MODEL_DOCUMENT); }); -App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId/:key/increment') +App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId/:attribute/increment') ->desc('Increment document attribute') ->groups(['api', 'database']) ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert') @@ -4541,7 +4541,7 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum $response->dynamic($document, Response::MODEL_DOCUMENT); }); -App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId/:key/decrement') +App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId/:attribute/decrement') ->desc('Decrement document attribute') ->groups(['api', 'database']) ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].decrement') diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index b18f918049..54502c44f9 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -5302,7 +5302,7 @@ trait DatabasesBase /** * @throws \Exception */ - public function testIncrementAttributeRoutes(): array + public function testIncrementAttribute(): void { $database = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', @@ -5337,10 +5337,9 @@ trait DatabasesBase ]), [ 'key' => 'count', 'required' => true, - 'default' => 0, ]); - \sleep(1); + \sleep(2); // Create document with initial count = 5 $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ @@ -5348,7 +5347,9 @@ trait DatabasesBase 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'documentId' => 'counter1', - 'data' => ['count' => 5], + 'data' => [ + 'count' => 5 + ], 'permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), @@ -5402,7 +5403,7 @@ trait DatabasesBase $this->assertEquals(404, $notFound['headers']['status-code']); } - public function testDecrementAttributeRoutes(array $data): void + public function testDecrementAttribute(): void { $database = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', @@ -5439,18 +5440,16 @@ trait DatabasesBase ]), [ 'key' => 'count', 'required' => true, - 'default' => 10, ]); - \sleep(1); + \sleep(2); // Create document with initial count = 10 - $documentId = 'counter1'; $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'documentId' => $documentId, + 'documentId' => ID::unique(), 'data' => ['count' => 10], 'permissions' => [ Permission::read(Role::any()), @@ -5458,6 +5457,8 @@ trait DatabasesBase ], ]); + $documentId = $doc['body']['$id']; + // Decrement by default 1 (count = 10 -> 9) $dec = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId . '/count/decrement', array_merge([ 'content-type' => 'application/json', @@ -5495,7 +5496,7 @@ trait DatabasesBase ]), ['min' => 7]); $this->assertEquals(400, $err['headers']['status-code']); - // Test type error on non-numeric attribute + // Test type error on non-numeric attribut $typeErr = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId . '/count/decrement', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], From 5585a75624741bad7d5d8cdf8dd4fe8c42bf945f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Jun 2025 00:22:46 -0400 Subject: [PATCH 3/9] Fix auth skips --- app/controllers/api/databases.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 16a8b05339..7b7bb2d46f 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -4500,12 +4500,12 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum ->inject('queueForEvents') ->inject('queueForStatsUsage') ->action(function (string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, Response $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage) { - $database = $dbForProject->getDocument('databases', $databaseId); + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { throw new Exception(Exception::DATABASE_NOT_FOUND); } - $collection = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId); + $collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); if ($collection->isEmpty()) { throw new Exception(Exception::COLLECTION_NOT_FOUND); } @@ -4577,12 +4577,12 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum ->inject('queueForEvents') ->inject('queueForStatsUsage') ->action(function (string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, Response $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage) { - $database = $dbForProject->getDocument('databases', $databaseId); + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { throw new Exception(Exception::DATABASE_NOT_FOUND); } - $collection = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId); + $collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); if ($collection->isEmpty()) { throw new Exception(Exception::COLLECTION_NOT_FOUND); } From e6881c10d4faa509a4127bade870539437c108ea Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Jun 2025 00:32:38 -0400 Subject: [PATCH 4/9] Fix desc --- app/controllers/api/databases.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 7b7bb2d46f..4fa8c75c11 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -4492,7 +4492,7 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum ->param('databaseId', '', new UID(), 'Database ID.') ->param('collectionId', '', new UID(), 'Collection ID.') ->param('documentId', '', new UID(), 'Document ID.') - ->param('attribute', '', new Key(), 'Document ID.') + ->param('attribute', '', new Key(), 'Attribute key.') ->param('value', 1, new Numeric(), 'Value to increment the attribute by. The value must be a number.', true) ->param('max', null, new Numeric(), 'Maximum value for the attribute. If the current value is greater than this value, an error will be thrown.', true) ->inject('response') @@ -4569,7 +4569,7 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum ->param('databaseId', '', new UID(), 'Database ID.') ->param('collectionId', '', new UID(), 'Collection ID.') ->param('documentId', '', new UID(), 'Document ID.') - ->param('attribute', '', new Key(), 'Document ID.') + ->param('attribute', '', new Key(), 'Attribute key.') ->param('value', 1, new Numeric(), 'Value to decrement the attribute by. The value must be a number.', true) ->param('min', null, new Numeric(), 'Minimum value for the attribute. If the current value is lesser than this value, an exception will be thrown.', true) ->inject('response') From 91b1670e52f72d9df67e4712ee79bc034e3a042f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Jun 2025 00:35:03 -0400 Subject: [PATCH 5/9] Fix event --- app/controllers/api/databases.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 4fa8c75c11..4defbfe5cd 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -4467,7 +4467,7 @@ App::put('/v1/databases/:databaseId/collections/:collectionId/documents/:documen App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId/:attribute/increment') ->desc('Increment document attribute') ->groups(['api', 'database']) - ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert') + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].increment') ->label('scope', 'documents.write') ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'documents.increment') From cf13a4518838264e5199005749b5f90793caee8a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Jun 2025 00:36:55 -0400 Subject: [PATCH 6/9] Docs --- app/controllers/api/databases.php | 2 +- docs/references/databases/decrement-document-attribute.md | 1 + docs/references/databases/increment-document-attribute.md | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/references/databases/decrement-document-attribute.md create mode 100644 docs/references/databases/increment-document-attribute.md diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 4defbfe5cd..6e308fd090 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -4556,7 +4556,7 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum namespace: 'databases', group: 'documents', name: 'decrementDocumentAttribute', - description: '/docs/references/databases/increment-document-attribute.md', + description: '/docs/references/databases/decrement-document-attribute.md', auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT], responses: [ new SDKResponse( diff --git a/docs/references/databases/decrement-document-attribute.md b/docs/references/databases/decrement-document-attribute.md new file mode 100644 index 0000000000..e0ed8140ea --- /dev/null +++ b/docs/references/databases/decrement-document-attribute.md @@ -0,0 +1 @@ +Decrement a specific attribute of a document by a given value. \ No newline at end of file diff --git a/docs/references/databases/increment-document-attribute.md b/docs/references/databases/increment-document-attribute.md new file mode 100644 index 0000000000..13bd612eed --- /dev/null +++ b/docs/references/databases/increment-document-attribute.md @@ -0,0 +1 @@ +Increment a specific attribute of a document by a given value. \ No newline at end of file From 6ea61106f4db799c1e2ec3387123cb9c20e8e269 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Jun 2025 00:37:33 -0400 Subject: [PATCH 7/9] Update model --- app/controllers/api/databases.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 6e308fd090..85fae8ea35 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -4560,8 +4560,8 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT], responses: [ new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, + code: Response::STATUS_CODE_OK, + model: Response::MODEL_DOCUMENT, ) ], contentType: ContentType::JSON From e1a291ff2114ba1146c8735a05bfca871d06ef82 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Jun 2025 11:50:57 -0400 Subject: [PATCH 8/9] Consistent messages --- app/controllers/api/databases.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 85fae8ea35..098361651f 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -4523,9 +4523,9 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum } catch (NotFoundException) { throw new Exception(Exception::ATTRIBUTE_NOT_FOUND); } catch (LimitException) { - throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED, 'Attribute `' . $attribute . '` has reached the maximum value of ' . $max); + throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED, 'Attribute "' . $attribute . '" has reached the maximum value of ' . $max); } catch (TypeException) { - throw new Exception(Exception::ATTRIBUTE_TYPE_INVALID, 'Attribute `' . $attribute . '` is not a number'); + throw new Exception(Exception::ATTRIBUTE_TYPE_INVALID, 'Attribute "' . $attribute . '" is not a number'); } $queueForStatsUsage From ffb27284e5b998f34258eb1e9ec9b3ce08b2a846 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 10 Jun 2025 11:51:17 -0400 Subject: [PATCH 9/9] Type as integer in query base --- composer.lock | 36 +++++++++---------- .../Database/Validator/Queries/Base.php | 11 +++--- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/composer.lock b/composer.lock index 0ce712ea30..6372df41ba 100644 --- a/composer.lock +++ b/composer.lock @@ -3490,16 +3490,16 @@ }, { "name": "utopia-php/database", - "version": "0.71.3", + "version": "0.71.4", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "f0c28b78548e2b740d940ca17dca30e1e532d53c" + "reference": "308cbeb65780f954f9f3abfff2ef17c5941ae00e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/f0c28b78548e2b740d940ca17dca30e1e532d53c", - "reference": "f0c28b78548e2b740d940ca17dca30e1e532d53c", + "url": "https://api.github.com/repos/utopia-php/database/zipball/308cbeb65780f954f9f3abfff2ef17c5941ae00e", + "reference": "308cbeb65780f954f9f3abfff2ef17c5941ae00e", "shasum": "" }, "require": { @@ -3540,9 +3540,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.71.3" + "source": "https://github.com/utopia-php/database/tree/0.71.4" }, - "time": "2025-06-10T03:53:35+00:00" + "time": "2025-06-10T15:47:50+00:00" }, { "name": "utopia-php/detector", @@ -4584,16 +4584,16 @@ }, { "name": "utopia-php/vcs", - "version": "0.10.4", + "version": "0.10.5", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "f635b368909eb3c3fe57344fe43525e74e8fdc03" + "reference": "b358439dc387f6097019eb83ebb9fc258fe9da05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/f635b368909eb3c3fe57344fe43525e74e8fdc03", - "reference": "f635b368909eb3c3fe57344fe43525e74e8fdc03", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/b358439dc387f6097019eb83ebb9fc258fe9da05", + "reference": "b358439dc387f6097019eb83ebb9fc258fe9da05", "shasum": "" }, "require": { @@ -4627,9 +4627,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/0.10.4" + "source": "https://github.com/utopia-php/vcs/tree/0.10.5" }, - "time": "2025-06-02T09:18:36+00:00" + "time": "2025-06-10T15:01:16+00:00" }, { "name": "utopia-php/websocket", @@ -4807,16 +4807,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "0.41.2", + "version": "0.41.4", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "e9a324efef9080808e07a782be2420cd4454cff7" + "reference": "07804269131f411576aac60c795a5ebc3afaa48a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e9a324efef9080808e07a782be2420cd4454cff7", - "reference": "e9a324efef9080808e07a782be2420cd4454cff7", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/07804269131f411576aac60c795a5ebc3afaa48a", + "reference": "07804269131f411576aac60c795a5ebc3afaa48a", "shasum": "" }, "require": { @@ -4852,9 +4852,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/0.41.2" + "source": "https://github.com/appwrite/sdk-generator/tree/0.41.4" }, - "time": "2025-06-10T03:08:44+00:00" + "time": "2025-06-10T08:28:11+00:00" }, { "name": "doctrine/annotations", diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Base.php b/src/Appwrite/Utopia/Database/Validator/Queries/Base.php index 20e7d5cb93..1c5dec44dd 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Base.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Base.php @@ -25,7 +25,7 @@ class Base extends Queries { $config = Config::getParam('collections', []); - $collections = array_merge( + $collections = \array_merge( $config['projects'], $config['buckets'], $config['databases'], @@ -34,7 +34,7 @@ class Base extends Queries ); $collection = $collections[$collection]; - // array for constant lookup time + $allowedAttributesLookup = []; foreach ($allowedAttributes as $attribute) { $allowedAttributesLookup[$attribute] = true; @@ -70,10 +70,9 @@ class Base extends Queries 'type' => Database::VAR_DATETIME, 'array' => false, ]); - - $sequence = new Document([ + $attributes[] = new Document([ 'key' => '$sequence', - 'type' => Database::VAR_STRING, + 'type' => Database::VAR_INTEGER, 'array' => false, ]); @@ -82,7 +81,7 @@ class Base extends Queries new Offset(), new Cursor(), new Filter($attributes, APP_DATABASE_QUERY_MAX_VALUES), - new Order([...$attributes, $sequence]), + new Order($attributes), ]; parent::__construct($validators);