diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 6cafc2ffef..098361651f 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/:attribute/increment') + ->desc('Increment document attribute') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].increment') + ->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(), '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') + ->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 = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = Authorization::skip(fn () => $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/:attribute/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/decrement-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(), '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') + ->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 = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = Authorization::skip(fn () => $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..6372df41ba 100644 --- a/composer.lock +++ b/composer.lock @@ -3490,16 +3490,16 @@ }, { "name": "utopia-php/database", - "version": "0.71.1", + "version": "0.71.4", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "7b2622d336c2fa4bd6627d5e175083bd4f3c7c0e" + "reference": "308cbeb65780f954f9f3abfff2ef17c5941ae00e" }, "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/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.1" + "source": "https://github.com/utopia-php/database/tree/0.71.4" }, - "time": "2025-06-09T18:14:46+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.1", + "version": "0.41.4", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "6d9318abf4542a757c87abf056557d6afa1dc06b" + "reference": "07804269131f411576aac60c795a5ebc3afaa48a" }, "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/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.1" + "source": "https://github.com/appwrite/sdk-generator/tree/0.41.4" }, - "time": "2025-06-01T04:20:04+00:00" + "time": "2025-06-10T08:28:11+00:00" }, { "name": "doctrine/annotations", 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 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); diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 6038448889..54502c44f9 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -5298,4 +5298,211 @@ trait DatabasesBase 'x-appwrite-key' => $this->getProject()['apiKey'] ])); } + + /** + * @throws \Exception + */ + public function testIncrementAttribute(): 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, + ]); + + \sleep(2); + + // 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 testDecrementAttribute(): 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, + ]); + + \sleep(2); + + // Create document with initial count = 10 + $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' => ID::unique(), + 'data' => ['count' => 10], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $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', + '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 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'], + ]), ['value' => 'not-a-number']); + $this->assertEquals(400, $typeErr['headers']['status-code']); + } + + }