Merge pull request #9986 from appwrite/feat-incr

Add increment + decrement routes
This commit is contained in:
Jake Barnby 2025-06-10 13:19:24 -04:00 committed by GitHub
commit ff92b23d8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 388 additions and 24 deletions

View file

@ -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'])

36
composer.lock generated
View file

@ -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",

View file

@ -0,0 +1 @@
Decrement a specific attribute of a document by a given value.

View file

@ -0,0 +1 @@
Increment a specific attribute of a document by a given value.

View file

@ -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);

View file

@ -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']);
}
}