From 9fc2c5db69d8ea174caaef384de29496fde00085 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 9 Jun 2025 23:56:30 -0400 Subject: [PATCH] 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']); + } + + }