Merge remote-tracking branch 'upstream/1.8.x' into documents-db-api

This commit is contained in:
ArnabChatterjee20k
2025-10-14 21:18:38 +05:30
43 changed files with 697 additions and 148 deletions
+5
View File
@@ -103,6 +103,11 @@ CLI::setResource('console', function () {
return new Document(Config::getParam('console'));
}, []);
CLI::setResource(
'isResourceBlocked',
fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false
);
CLI::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache) {
$databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools
+5
View File
@@ -578,6 +578,11 @@ return [
'description' => 'The requested runtime is either inactive or unsupported. Please check the value of the _APP_FUNCTIONS_RUNTIMES environment variable.',
'code' => 404,
],
Exception::FUNCTION_ALREADY_EXISTS => [
'name' => Exception::FUNCTION_ALREADY_EXISTS,
'description' => 'Function with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
'code' => 409,
],
Exception::FUNCTION_ENTRYPOINT_MISSING => [
'name' => Exception::FUNCTION_ENTRYPOINT_MISSING,
'description' => 'Entrypoint for your Appwrite Function is missing. Please specify it when making deployment or update the entrypoint under your function\'s "Settings" > "Configuration" > "Entrypoint".',
+1 -1
View File
@@ -357,7 +357,7 @@ return [
[
'key' => 'dotnet',
'name' => '.NET',
'version' => '0.21.1',
'version' => '0.21.2',
'url' => 'https://github.com/appwrite/sdk-for-dotnet',
'package' => 'https://www.nuget.org/packages/Appwrite',
'enabled' => true,
+2 -2
View File
@@ -5247,7 +5247,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"tags": [
"databases"
@@ -8389,7 +8389,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"tags": [
"tablesDB"
@@ -5646,7 +5646,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"tags": [
"databases"
@@ -33781,7 +33781,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"tags": [
"tablesDB"
+2 -2
View File
@@ -5198,7 +5198,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"tags": [
"databases"
@@ -24311,7 +24311,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"tags": [
"tablesDB"
@@ -5247,7 +5247,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"tags": [
"databases"
@@ -8389,7 +8389,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"tags": [
"tablesDB"
@@ -5646,7 +5646,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"tags": [
"databases"
@@ -33781,7 +33781,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"tags": [
"tablesDB"
@@ -5198,7 +5198,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"tags": [
"databases"
@@ -24311,7 +24311,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"tags": [
"tablesDB"
+2 -2
View File
@@ -5386,7 +5386,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"consumes": [
"application\/json"
@@ -8463,7 +8463,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"consumes": [
"application\/json"
+2 -2
View File
@@ -5805,7 +5805,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"consumes": [
"application\/json"
@@ -33897,7 +33897,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"consumes": [
"application\/json"
+2 -2
View File
@@ -5345,7 +5345,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"consumes": [
"application\/json"
@@ -24483,7 +24483,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"consumes": [
"application\/json"
+2 -2
View File
@@ -5386,7 +5386,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"consumes": [
"application\/json"
@@ -8463,7 +8463,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"consumes": [
"application\/json"
@@ -5805,7 +5805,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"consumes": [
"application\/json"
@@ -33897,7 +33897,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"consumes": [
"application\/json"
+2 -2
View File
@@ -5345,7 +5345,7 @@
},
"\/databases\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "databasesCreateOperations",
"consumes": [
"application\/json"
@@ -24483,7 +24483,7 @@
},
"\/tablesdb\/transactions\/{transactionId}\/operations": {
"post": {
"summary": "Create operations scoped to a transaction",
"summary": "Create operations",
"operationId": "tablesDBCreateOperations",
"consumes": [
"application\/json"
+4 -4
View File
@@ -606,8 +606,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$message = $th->getMessage();
// sanitize 5xx errors
if ($code >= 500 && !App::isDevelopment()) {
// sanitize 0 && 5xx errors
if (($code === 0 || $code >= 500) && !App::isDevelopment()) {
$message = 'Error: Server Error';
}
@@ -719,8 +719,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$message = $th->getMessage();
// sanitize 5xx errors
if ($code >= 500 && !App::isDevelopment()) {
// sanitize 0 && 5xx errors
if (($code === 0 || $code >= 500) && !App::isDevelopment()) {
$message = 'Error: Server Error';
}
+1 -1
View File
@@ -64,7 +64,7 @@
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.18.*",
"utopia-php/messaging": "0.19.*",
"utopia-php/migration": "dev-feat-documents-db as 1.1.1",
"utopia-php/orchestration": "0.9.*",
"utopia-php/platform": "0.7.*",
Generated
+11 -11
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1e4422c6cbca9b7b99c329ecc02e695d",
"content-hash": "5634dc368b359aa28cbb2960c32bccd3",
"packages": [
{
"name": "adhocore/jwt",
@@ -4290,16 +4290,16 @@
},
{
"name": "utopia-php/messaging",
"version": "0.18.2",
"version": "0.19.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/messaging.git",
"reference": "0d364edacf4d4867964c7e17f653031dd39394bf"
"reference": "0b866d54e70c792a3c4f5ca9c12a6d358a31f4b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/messaging/zipball/0d364edacf4d4867964c7e17f653031dd39394bf",
"reference": "0d364edacf4d4867964c7e17f653031dd39394bf",
"url": "https://api.github.com/repos/utopia-php/messaging/zipball/0b866d54e70c792a3c4f5ca9c12a6d358a31f4b8",
"reference": "0b866d54e70c792a3c4f5ca9c12a6d358a31f4b8",
"shasum": ""
},
"require": {
@@ -4335,9 +4335,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/messaging/issues",
"source": "https://github.com/utopia-php/messaging/tree/0.18.2"
"source": "https://github.com/utopia-php/messaging/tree/0.19.0"
},
"time": "2025-07-21T18:27:03+00:00"
"time": "2025-10-14T11:46:49+00:00"
},
{
"name": "utopia-php/migration",
@@ -4345,12 +4345,12 @@
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "b77e8c2ced7f6dc412dc2669b43a719fba19fcd6"
"reference": "0a3abfaad2e4a35280d4e401cd510147d6ddd719"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/b77e8c2ced7f6dc412dc2669b43a719fba19fcd6",
"reference": "b77e8c2ced7f6dc412dc2669b43a719fba19fcd6",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/0a3abfaad2e4a35280d4e401cd510147d6ddd719",
"reference": "0a3abfaad2e4a35280d4e401cd510147d6ddd719",
"shasum": ""
},
"require": {
@@ -4393,7 +4393,7 @@
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/feat-documents-db"
},
"time": "2025-10-14T15:02:00+00:00"
"time": "2025-10-14T15:30:04+00:00"
},
{
"name": "utopia-php/mongo",
+4
View File
@@ -1,5 +1,9 @@
# Change Log
## 0.21.2
* Fix: handle Object[] during array deserialization
## 0.21.1
* Add transaction support for Databases and TablesDB
+1
View File
@@ -164,6 +164,7 @@ class Exception extends \Exception
/** Functions */
public const string FUNCTION_NOT_FOUND = 'function_not_found';
public const string FUNCTION_ALREADY_EXISTS = 'function_already_exists';
public const string FUNCTION_RUNTIME_UNSUPPORTED = 'function_runtime_unsupported';
public const string FUNCTION_ENTRYPOINT_MISSING = 'function_entrypoint_missing';
public const string FUNCTION_SYNCHRONOUS_TIMEOUT = 'function_synchronous_timeout';
@@ -205,6 +205,31 @@ abstract class Action extends AppwriteAction
return $this->isCollectionsAPI() ? 'collection' : 'table';
}
/**
* Get the correct attribute/column key for increment/decrement operations.
*/
protected function getAttributeKey(): string
{
return $this->isCollectionsAPI() ? 'attribute' : 'column';
}
/**
* Get the key used in ID parameters (e.g., 'collectionId' or 'tableId').
*/
protected function getGroupId(): string
{
return $this->getCollectionsEventsContext() . 'Id';
}
/**
* Get the resource ID key for the current action.
*/
protected function getResourceId(): string
{
$resource = $this->isCollectionsAPI() ? 'document' : 'row';
return $resource . 'Id';
}
/**
* Remove configured removable attributes from a document.
* Used for relationship path handling to remove API-specific attributes.
@@ -137,7 +137,7 @@ class Decrement extends Action
'documentId' => $documentId,
'action' => 'decrement',
'data' => [
'attribute' => $attribute,
$this->getAttributeKey() => $attribute,
'value' => $value,
'min' => $min,
],
@@ -154,9 +154,10 @@ class Decrement extends Action
});
// Return successful response without actually decrementing
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
$attribute => $value,
]);
@@ -137,7 +137,7 @@ class Increment extends Action
'documentId' => $documentId,
'action' => 'increment',
'data' => [
'attribute' => $attribute,
$this->getAttributeKey() => $attribute,
'value' => $value,
'max' => $max,
],
@@ -154,9 +154,10 @@ class Increment extends Action
});
// Return successful response without actually incrementing
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
$attribute => $value,
]);
@@ -422,9 +422,10 @@ class Create extends Action
'total' => \count($documents),
]), $this->getBulkResponseModel());
} else {
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documents[0]['$id'] ?? $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
...$documents[0]
]);
@@ -296,9 +296,10 @@ class Update extends Action
});
// Return successful response without actually updating document
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
...$document->getArrayCopy(),
...$data
@@ -307,9 +307,10 @@ class Upsert extends Action
});
// Return successful response without actually upserting document
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
...$data
]);
@@ -39,7 +39,7 @@ class Create extends Action
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/databases/transactions/:transactionId/operations')
->desc('Create operations scoped to a transaction')
->desc('Create operations')
->groups(['api', 'database', 'transactions'])
->label('scope', 'documents.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
@@ -167,8 +167,10 @@ class Update extends Action
}
}
$totalOperations++;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + 1;
if (!\in_array($action, ['bulkCreate', 'bulkUpdate', 'bulkUpsert', 'bulkDelete'])) {
$totalOperations++;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + 1;
}
if ($data instanceof Document) {
$data = $data->getArrayCopy();
@@ -194,16 +196,24 @@ class Update extends Action
$this->handleDecrementOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state);
break;
case 'bulkCreate':
$this->handleBulkCreateOperation($dbForProject, $collectionId, $data, $createdAt, $state);
$count = $this->handleBulkCreateOperation($dbForProject, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkUpdate':
$this->handleBulkUpdateOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$count = $this->handleBulkUpdateOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkUpsert':
$this->handleBulkUpsertOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$count = $this->handleBulkUpsertOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkDelete':
$this->handleBulkDeleteOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$count = $this->handleBulkDeleteOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
}
}
@@ -547,6 +557,28 @@ class Update extends Action
});
}
/**
* Get the attribute/column name from data, with fallback for cross-API compatibility
*
* @param array $data The operation data
* @return string The attribute/column name
*/
private function getAttributeNameFromData(array $data): string
{
$expectedKey = $this->getAttributeKey();
if (isset($data[$expectedKey])) {
return $data[$expectedKey];
}
// Try the opposite key for cross-API compatibility
$fallbackKey = $expectedKey === 'attribute' ? 'column' : 'attribute';
if (isset($data[$fallbackKey])) {
return $data[$fallbackKey];
}
return '';
}
/**
* Handle increment operation
*
@@ -569,23 +601,24 @@ class Update extends Action
array &$state
): void {
$dependent = isset($state[$collectionId][$documentId]);
$attribute = $this->getAttributeNameFromData($data);
if ($dependent) {
$state[$collectionId][$documentId] = $dbForProject->increaseDocumentAttribute(
collection: $collectionId,
id: $documentId,
attribute: $data[$this->getAttributeKey()],
attribute: $attribute,
value: $data['value'] ?? 1,
max: $data['max'] ?? null
);
return;
}
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $documentId, $data, &$state) {
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $documentId, $data, &$state, $attribute) {
$state[$collectionId][$documentId] = $dbForProject->increaseDocumentAttribute(
collection: $collectionId,
id: $documentId,
attribute: $data[$this->getAttributeKey()],
attribute: $attribute,
value: $data['value'] ?? 1,
max: $data['max'] ?? null
);
@@ -614,23 +647,24 @@ class Update extends Action
array &$state
): void {
$dependent = isset($state[$collectionId][$documentId]);
$attribute = $this->getAttributeNameFromData($data);
if ($dependent) {
$state[$collectionId][$documentId] = $dbForProject->decreaseDocumentAttribute(
collection: $collectionId,
id: $documentId,
attribute: $data[$this->getAttributeKey()],
attribute: $attribute,
value: $data['value'] ?? 1,
min: $data['min'] ?? null
);
return;
}
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $documentId, $data, &$state) {
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $documentId, $data, &$state, $attribute) {
$state[$collectionId][$documentId] = $dbForProject->decreaseDocumentAttribute(
collection: $collectionId,
id: $documentId,
attribute: $data[$this->getAttributeKey()],
attribute: $attribute,
value: $data['value'] ?? 1,
min: $data['min'] ?? null
);
@@ -645,7 +679,7 @@ class Update extends Action
* @param array $data
* @param \DateTime $createdAt
* @param array &$state
* @return void
* @return int Number of documents created
* @throws \Utopia\Database\Exception
*/
private function handleBulkCreateOperation(
@@ -654,13 +688,14 @@ class Update extends Action
array $data,
\DateTime $createdAt,
array &$state
): void {
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $data, &$state) {
): int {
$count = 0;
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $data, &$state, &$count) {
$documents = \array_map(function ($doc) {
return $doc instanceof Document ? $doc : new Document($doc);
}, $data);
$dbForProject->createDocuments(
$count = $dbForProject->createDocuments(
$collectionId,
$documents,
onNext: function (Document $document) use (&$state, $collectionId) {
@@ -668,6 +703,7 @@ class Update extends Action
}
);
});
return $count;
}
/**
@@ -679,7 +715,7 @@ class Update extends Action
* @param array $data
* @param \DateTime $createdAt
* @param array &$state
* @return void
* @return int Number of documents updated
* @throws \Utopia\Database\Exception
* @throws \Utopia\Database\Exception\Query
* @throws ConflictException
@@ -691,7 +727,7 @@ class Update extends Action
array $data,
\DateTime $createdAt,
array &$state
): void {
): int {
$queries = Query::parseQueries($data['queries'] ?? []);
$updateData = new Document($data['data']);
@@ -701,7 +737,7 @@ class Update extends Action
// Clone the document before passing to updateDocuments to prevent mutation
// The database layer mutates the input document, which would corrupt transaction state
$dbForProject->updateDocuments(
$count = $dbForProject->updateDocuments(
$collectionId,
clone $updateData,
$queries,
@@ -739,6 +775,8 @@ class Update extends Action
);
}
}
return $count;
}
/**
@@ -750,7 +788,7 @@ class Update extends Action
* @param array $data
* @param \DateTime $createdAt
* @param array &$state
* @return void
* @return int Number of documents upserted
* @throws ConflictException
* @throws \Utopia\Database\Exception
*/
@@ -761,14 +799,14 @@ class Update extends Action
array $data,
\DateTime $createdAt,
array &$state
): void {
): int {
$documents = \array_map(function ($doc) {
return $doc instanceof Document ? $doc : new Document($doc);
}, $data);
$mergedDocuments = $transactionState->applyBulkUpsertToState($collectionId, $documents, $state);
$dbForProject->upsertDocuments(
$count = $dbForProject->upsertDocuments(
$collectionId,
$mergedDocuments,
onNext: function (Document $upserted, ?Document $old) use (&$state, $collectionId, $createdAt) {
@@ -786,6 +824,8 @@ class Update extends Action
$state[$collectionId][$upserted->getId()] = $upserted;
}
);
return $count;
}
/**
@@ -797,7 +837,7 @@ class Update extends Action
* @param array $data
* @param \DateTime $createdAt
* @param array &$state
* @return void
* @return int Number of documents deleted
* @throws \Utopia\Database\Exception\Query
* @throws ConflictException
* @throws \Utopia\Database\Exception
@@ -809,10 +849,10 @@ class Update extends Action
array $data,
\DateTime $createdAt,
array &$state
): void {
): int {
$queries = Query::parseQueries($data['queries'] ?? []);
$dbForProject->deleteDocuments(
$count = $dbForProject->deleteDocuments(
$collectionId,
$queries,
onNext: function (Document $deleted, Document $old) use (&$state, $collectionId, $createdAt) {
@@ -832,5 +872,7 @@ class Update extends Action
);
$transactionState->applyBulkDeleteToState($collectionId, $queries, $state);
return $count;
}
}
@@ -30,7 +30,7 @@ class Create extends TransactionsCreate
->setHttpPath('/v1/tablesdb/transactions')
->desc('Create transaction')
->groups(['api', 'database', 'transactions'])
->label('scope', 'rows.write')
->label('scope', ['documents.write', 'rows.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'tablesDB',
@@ -30,7 +30,7 @@ class Delete extends TransactionsDelete
->setHttpPath('/v1/tablesdb/transactions/:transactionId')
->desc('Delete transaction')
->groups(['api', 'database', 'transactions'])
->label('scope', 'rows.write')
->label('scope', ['documents.write', 'rows.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'tablesDB',
@@ -30,7 +30,7 @@ class Get extends TransactionsGet
->setHttpPath('/v1/tablesdb/transactions/:transactionId')
->desc('Get transaction')
->groups(['api', 'database', 'transactions'])
->label('scope', 'rows.read')
->label('scope', ['documents.read', 'rows.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'tablesDB',
@@ -30,9 +30,9 @@ class Create extends OperationsCreate
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/tablesdb/transactions/:transactionId/operations')
->desc('Create operations scoped to a transaction')
->desc('Create operations')
->groups(['api', 'database', 'transactions'])
->label('scope', 'rows.write')
->label('scope', ['documents.write', 'rows.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'tablesDB',
@@ -31,7 +31,7 @@ class Update extends TransactionsUpdate
->setHttpPath('/v1/tablesdb/transactions/:transactionId')
->desc('Update transaction')
->groups(['api', 'database', 'transactions'])
->label('scope', 'rows.write')
->label('scope', ['documents.write', 'rows.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'tablesDB',
@@ -30,7 +30,7 @@ class XList extends TransactionsList
->setHttpPath('/v1/tablesdb/transactions')
->desc('List transactions')
->groups(['api', 'database', 'transactions'])
->label('scope', 'rows.read')
->label('scope', ['documents.read', 'rows.read'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('sdk', new Method(
namespace: 'tablesDB',
@@ -23,6 +23,7 @@ use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
@@ -201,36 +202,40 @@ class Create extends Base
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
}
$function = $dbForProject->createDocument('functions', new Document([
'$id' => $functionId,
'execute' => $execute,
'enabled' => $enabled,
'live' => true,
'logging' => $logging,
'name' => $name,
'runtime' => $runtime,
'deploymentInternalId' => '',
'deploymentId' => '',
'events' => $events,
'schedule' => $schedule,
'scheduleInternalId' => '',
'scheduleId' => '',
'timeout' => $timeout,
'entrypoint' => $entrypoint,
'commands' => $commands,
'scopes' => $scopes,
'search' => implode(' ', [$functionId, $name, $runtime]),
'version' => 'v5',
'installationId' => $installation->getId(),
'installationInternalId' => $installation->getSequence(),
'providerRepositoryId' => $providerRepositoryId,
'repositoryId' => '',
'repositoryInternalId' => '',
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
'specification' => $specification
]));
try {
$function = $dbForProject->createDocument('functions', new Document([
'$id' => $functionId,
'execute' => $execute,
'enabled' => $enabled,
'live' => true,
'logging' => $logging,
'name' => $name,
'runtime' => $runtime,
'deploymentInternalId' => '',
'deploymentId' => '',
'events' => $events,
'schedule' => $schedule,
'scheduleInternalId' => '',
'scheduleId' => '',
'timeout' => $timeout,
'entrypoint' => $entrypoint,
'commands' => $commands,
'scopes' => $scopes,
'search' => implode(' ', [$functionId, $name, $runtime]),
'version' => 'v5',
'installationId' => $installation->getId(),
'installationInternalId' => $installation->getSequence(),
'providerRepositoryId' => $providerRepositoryId,
'repositoryId' => '',
'repositoryInternalId' => '',
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
'specification' => $specification
]));
} catch (DuplicateException) {
throw new Exception(Exception::FUNCTION_ALREADY_EXISTS);
}
$schedule = Authorization::skip(
fn () => $dbForPlatform->createDocument('schedules', new Document([
+19
View File
@@ -280,6 +280,25 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
continue;
}
// Check if the latest commit on the target branch already has a release
$latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null';
$latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? '');
if (!empty($latestCommitSha)) {
$latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null';
$latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? '');
if (!empty($latestReleaseTag)) {
$tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null';
$tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? '');
if (!empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) {
Console::warning("Latest commit on {$releaseTarget} already has a release ({$latestReleaseTag}) for {$language['name']} SDK, skipping to avoid empty release...");
continue;
}
}
}
$previousVersion = '';
$tagListCommand = 'gh release list --repo "' . $repoName . '" --limit 1 --json tagName --jq ".[0].tagName" 2>&1';
$previousVersion = trim(\shell_exec($tagListCommand) ?? '');
+31 -24
View File
@@ -49,6 +49,7 @@ abstract class ScheduleBase extends Action
->inject('publisherMigrations')
->inject('publisherFunctions')
->inject('publisherMessaging')
->inject('isResourceBlocked')
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('telemetry')
@@ -71,7 +72,7 @@ abstract class ScheduleBase extends Action
* 2. Create timer that sync all changes from 'schedules' collection to local copy. Only reading changes thanks to 'resourceUpdatedAt' attribute
* 3. Create timer that prepares coroutines for soon-to-execute schedules. When it's ready, coroutine sleeps until exact time before sending request to worker.
*/
public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void
public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void
{
Console::title(\ucfirst(static::getSupportedResource()) . ' scheduler V1');
Console::success(APP_NAME . ' ' . \ucfirst(static::getSupportedResource()) . ' scheduler v1 has started');
@@ -88,16 +89,16 @@ abstract class ScheduleBase extends Action
// start with "0" to load all active documents.
$lastSyncUpdate = "0";
$this->collectSchedules($dbForPlatform, $getProjectDB, $lastSyncUpdate);
$this->collectSchedules($dbForPlatform, $getProjectDB, $lastSyncUpdate, $isResourceBlocked);
Console::success("Starting timers at " . DateTime::now());
/**
* The timer synchronize $schedules copy with database collection.
*/
Timer::tick(static::UPDATE_TIMER * 1000, function () use ($dbForPlatform, $getProjectDB, &$lastSyncUpdate) {
Timer::tick(static::UPDATE_TIMER * 1000, function () use ($dbForPlatform, $getProjectDB, &$lastSyncUpdate, $isResourceBlocked) {
$time = DateTime::now();
Console::log("Sync tick: Running at $time");
$this->collectSchedules($dbForPlatform, $getProjectDB, $lastSyncUpdate);
$this->collectSchedules($dbForPlatform, $getProjectDB, $lastSyncUpdate, $isResourceBlocked);
});
while (true) {
@@ -112,7 +113,7 @@ abstract class ScheduleBase extends Action
}
}
private function collectSchedules(Database $dbForPlatform, callable $getProjectDB, string &$lastSyncUpdate): void
private function collectSchedules(Database $dbForPlatform, callable $getProjectDB, string &$lastSyncUpdate, callable $isResourceBlocked): void
{
// If we haven't synced yet, load all active schedules
$initialLoad = $lastSyncUpdate === "0";
@@ -178,34 +179,40 @@ abstract class ScheduleBase extends Action
$paginationQueries[] = Query::greaterThanEqual('resourceUpdatedAt', $lastSyncUpdate);
}
$results = $dbForPlatform->find('schedules', $paginationQueries);
$collectionId = static::getCollectionId();
$schedules = $dbForPlatform->find('schedules', $paginationQueries);
$sum = count($schedules);
$total += $sum;
$sum = count($results);
$total = $total + $sum;
foreach ($schedules as $schedule) {
$existing = $this->schedules[$schedule->getSequence()] ?? null;
$updated = strtotime($existing['resourceUpdatedAt'] ?? '0') !== strtotime($schedule['resourceUpdatedAt'] ?? '0');
foreach ($results as $document) {
$localDocument = $this->schedules[$document->getSequence()] ?? null;
if ($localDocument !== null) {
if (!$document['active']) {
Console::info("Removing: {$document['resourceType']}::{$document['resourceId']}");
unset($this->schedules[$document->getSequence()]);
} elseif (strtotime($localDocument['resourceUpdatedAt']) !== strtotime($document['resourceUpdatedAt'])) {
Console::info("Updating: {$document['resourceType']}::{$document['resourceId']}");
$this->schedules[$document->getSequence()] = $getSchedule($document);
}
} else {
if ($existing === null || $updated) {
try {
$this->schedules[$document->getSequence()] = $getSchedule($document);
$candidate = $getSchedule($schedule);
} catch (\Throwable $th) {
$collectionId = static::getCollectionId();
Console::error("Failed to load schedule for project {$document['projectId']} {$collectionId} {$document['resourceId']}");
Console::error("Failed to load schedule for project {$schedule['projectId']} {$collectionId} {$schedule['resourceId']}");
Console::error($th->getMessage());
continue;
}
if (!$candidate['active']) {
unset($this->schedules[$schedule->getSequence()]);
continue;
}
if ($isResourceBlocked($candidate['project'], $collectionId, $candidate['resourceId'])) {
unset($this->schedules[$schedule->getSequence()]);
continue;
}
Console::info("Updating: {$schedule['resourceType']}::{$schedule['resourceId']}");
$this->schedules[$schedule->getSequence()] = $candidate;
}
}
$latestDocument = \end($results);
$latestDocument = \end($schedules);
}
$lastSyncUpdate = $time;
@@ -593,6 +593,14 @@ class Messaging extends Action
$content = $data['content'];
$html = $data['html'] ?? false;
// For SMTP, move all recipients to BCC and use default recipient in TO field
if ($provider->getAttribute('provider') === 'smtp') {
foreach ($to as $recipient) {
$bcc[] = ['email' => $recipient];
}
$to = [];
}
return new Email(
$to,
$subject,
+10 -10
View File
@@ -18,24 +18,24 @@ class StatsUsage extends Action
/**
* In memory per project metrics calculation
*/
private array $stats = [];
private int $lastTriggeredTime = 0;
private int $keys = 0;
private const INFINITY_PERIOD = '_inf_';
private const BATCH_SIZE_DEVELOPMENT = 1;
private const BATCH_SIZE_PRODUCTION = 10_000;
protected array $stats = [];
protected int $lastTriggeredTime = 0;
protected int $keys = 0;
protected const INFINITY_PERIOD = '_inf_';
protected const BATCH_SIZE_DEVELOPMENT = 1;
protected const BATCH_SIZE_PRODUCTION = 10_000;
/**
* Stats for batch write separated per project
* @var array
*/
private array $projects = [];
protected array $projects = [];
/**
* Array of stat documents to batch write to logsDB
* @var array
*/
private array $statDocuments = [];
protected array $statDocuments = [];
protected Registry $register;
@@ -101,7 +101,7 @@ class StatsUsage extends Action
return 'stats-usage';
}
private function getBatchSize(): int
protected function getBatchSize(): int
{
return System::getEnv('_APP_ENV', 'development') === 'development'
? self::BATCH_SIZE_DEVELOPMENT
@@ -195,7 +195,7 @@ class StatsUsage extends Action
* @param callable(): Database $getProjectDB
* @return void
*/
private function reduce(Document $project, Document $document, array &$metrics, callable $getProjectDB): void
protected function reduce(Document $project, Document $document, array &$metrics, callable $getProjectDB): void
{
$dbForProject = $getProjectDB($project);
@@ -82,9 +82,8 @@ class Document extends Any
{
$document->removeAttribute('$collection');
$document->removeAttribute('$tenant');
$sequence = $document->getAttribute('$sequence', 0);
// to support both -> uuids(mongodb) and numeric ids(mariadb/mysql)
if (is_numeric($sequence)) {
if (!$document->isEmpty() && is_numeric($document->getAttribute('$sequence', 0))) {
$document->setAttribute('$sequence', (int)$document->getAttribute('$sequence', 0));
}
@@ -3732,6 +3732,149 @@ trait TransactionsBase
$this->assertEquals(0, $doc['body']['score']);
}
/**
* Test individual increment/decrement endpoints with transactions for Legacy Collections API
* This test ensures that:
* 1. Transaction logs store the correct attribute key ('attribute' for Collections API)
* 2. Mock responses return the correct ID keys ('$collectionId' not '$tableId')
*/
public function testIncrementDecrementEndpointsWithTransaction(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'IncrDecrEndpointTestDB'
]);
$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' => 'AccountsCollection',
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Add balance 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' => 'balance',
'required' => false,
'default' => 0,
]);
sleep(2);
// Create initial documents
$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' => 'joe',
'data' => ['balance' => 100]
]);
$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' => 'jane',
'data' => ['balance' => 50]
]);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(201, $transaction['headers']['status-code']);
$transactionId = $transaction['body']['$id'];
// Test: Decrement using individual endpoint - should store 'attribute' not 'column' in transaction log
$decrementResponse = $this->client->call(
Client::METHOD_PATCH,
"/databases/{$databaseId}/collections/{$collectionId}/documents/joe/balance/decrement",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 50,
'min' => 0,
]
);
// Test: Response should return '$collectionId' not '$tableId' for Collections API
$this->assertEquals(200, $decrementResponse['headers']['status-code']);
$this->assertArrayHasKey('$collectionId', $decrementResponse['body'], 'Response should contain $collectionId for Collections API');
$this->assertArrayNotHasKey('$tableId', $decrementResponse['body'], 'Response should not contain $tableId for Collections API');
$this->assertEquals($collectionId, $decrementResponse['body']['$collectionId']);
// Test increment endpoint
$incrementResponse = $this->client->call(
Client::METHOD_PATCH,
"/databases/{$databaseId}/collections/{$collectionId}/documents/jane/balance/increment",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 50,
]
);
$this->assertEquals(200, $incrementResponse['headers']['status-code']);
$this->assertArrayHasKey('$collectionId', $incrementResponse['body'], 'Response should contain $collectionId for Collections API');
$this->assertArrayNotHasKey('$tableId', $incrementResponse['body'], 'Response should not contain $tableId for Collections API');
$this->assertEquals($collectionId, $incrementResponse['body']['$collectionId']);
// Commit transaction - this will fail if transaction log has 'column' instead of 'attribute'
$commitResponse = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'commit' => true
]);
$this->assertEquals(200, $commitResponse['headers']['status-code'], 'Transaction commit should succeed');
// Verify final values
$joe = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents/joe", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$jane = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents/jane", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $joe['headers']['status-code']);
$this->assertEquals(50, $joe['body']['balance'], 'Joe should have 100 - 50 = 50');
$this->assertEquals(200, $jane['headers']['status-code']);
$this->assertEquals(100, $jane['body']['balance'], 'Jane should have 50 + 50 = 100');
}
/**
* Test bulk update operations in transaction
*/
@@ -3867,6 +3867,252 @@ trait TransactionsBase
$this->assertEquals('updated', $row['body']['status'], 'Status should be updated');
}
/**
* Test individual increment/decrement endpoints with transactions
* This test ensures that:
* 1. Transaction logs store the correct attribute key ('column' for TablesDB)
* 2. Mock responses return the correct ID keys ('$tableId' not '$collectionId')
*/
public function testIncrementDecrementEndpointsWithTransaction(): void
{
// Create database and table
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'IncrDecrEndpointTestDB'
]);
$databaseId = $database['body']['$id'];
$table = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'AccountsTable',
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
],
]);
$tableId = $table['body']['$id'];
// Add balance column
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/columns/integer", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'balance',
'required' => false,
'default' => 0,
]);
sleep(2);
// Create initial rows
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/rows", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'rowId' => 'joe',
'data' => ['balance' => 100]
]);
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/rows", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'rowId' => 'jane',
'data' => ['balance' => 50]
]);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(201, $transaction['headers']['status-code']);
$transactionId = $transaction['body']['$id'];
// Test Bug 1: Decrement using individual endpoint - should store 'column' not 'attribute' in transaction log
$decrementResponse = $this->client->call(
Client::METHOD_PATCH,
"/tablesdb/{$databaseId}/tables/{$tableId}/rows/joe/balance/decrement",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 50,
'min' => 0,
]
);
// Test Bug 2: Response should return '$tableId' not '$collectionId'
$this->assertEquals(200, $decrementResponse['headers']['status-code']);
$this->assertArrayHasKey('$tableId', $decrementResponse['body'], 'Response should contain $tableId for TablesDB API');
$this->assertArrayNotHasKey('$collectionId', $decrementResponse['body'], 'Response should not contain $collectionId for TablesDB API');
$this->assertEquals($tableId, $decrementResponse['body']['$tableId']);
// Test increment endpoint
$incrementResponse = $this->client->call(
Client::METHOD_PATCH,
"/tablesdb/{$databaseId}/tables/{$tableId}/rows/jane/balance/increment",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 50,
]
);
$this->assertEquals(200, $incrementResponse['headers']['status-code']);
$this->assertArrayHasKey('$tableId', $incrementResponse['body'], 'Response should contain $tableId for TablesDB API');
$this->assertArrayNotHasKey('$collectionId', $incrementResponse['body'], 'Response should not contain $collectionId for TablesDB API');
$this->assertEquals($tableId, $incrementResponse['body']['$tableId']);
// Commit transaction - this will fail if transaction log has 'attribute' instead of 'column'
$commitResponse = $this->client->call(Client::METHOD_PATCH, "/tablesdb/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'commit' => true
]);
$this->assertEquals(200, $commitResponse['headers']['status-code'], 'Transaction commit should succeed');
// Verify final values
$joe = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/joe", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$jane = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/jane", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $joe['headers']['status-code']);
$this->assertEquals(50, $joe['body']['balance'], 'Joe should have 100 - 50 = 50');
$this->assertEquals(200, $jane['headers']['status-code']);
$this->assertEquals(100, $jane['body']['balance'], 'Jane should have 50 + 50 = 100');
}
/**
* Test cross-API compatibility: stage operations via TablesDB, commit via Collections API
* This ensures fallback logic works when APIs are mixed
*/
public function testCrossAPIIncrementDecrement(): void
{
// Create database and table
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'CrossAPITestDB'
]);
$databaseId = $database['body']['$id'];
$table = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'CrossAPITable',
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
],
]);
$tableId = $table['body']['$id'];
// Add balance column
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/columns/integer", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'balance',
'required' => false,
'default' => 0,
]);
sleep(2);
// Create initial row
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/rows", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'rowId' => 'test',
'data' => ['balance' => 100]
]);
// Create transaction using TablesDB API
$transaction = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$transactionId = $transaction['body']['$id'];
// Stage operations using TablesDB API (will store 'column' key)
$this->client->call(
Client::METHOD_PATCH,
"/tablesdb/{$databaseId}/tables/{$tableId}/rows/test/balance/decrement",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 30,
]
);
// Commit using Collections API (expects 'attribute' key but should fallback to 'column')
$commitResponse = $this->client->call(
Client::METHOD_PATCH,
"/databases/transactions/{$transactionId}",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'commit' => true
]
);
$this->assertEquals(200, $commitResponse['headers']['status-code'], 'Cross-API commit should succeed');
// Verify final value
$row = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/test", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $row['headers']['status-code']);
$this->assertEquals(70, $row['body']['balance'], 'Balance should be 100 - 30 = 70');
}
public function testBulkUpdateWithDependentDocuments(): void
{
// Create database and table
@@ -32,6 +32,41 @@ class FunctionsCustomClientTest extends Scope
'timeout' => 10,
]);
$this->assertEquals(401, $function['headers']['status-code']);
/**
* Test for DUPLICATE functionId
*/
$functionId = $this->setupFunction([
'functionId' => ID::unique(),
'name' => 'Test',
'execute' => [Role::user($this->getUser()['$id'])->toString()],
'runtime' => 'node-22',
'entrypoint' => 'index.js',
'events' => [
'users.*.create',
'users.*.delete',
],
'timeout' => 10,
]);
$response = $this->client->call(Client::METHOD_POST, '/functions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'functionId' => $functionId,
'name' => 'Test',
'execute' => [Role::user($this->getUser()['$id'])->toString()],
'runtime' => 'node-22',
'entrypoint' => 'index.js',
'events' => [
'users.*.create',
'users.*.delete',
],
'timeout' => 10,
]);
$this->assertEquals(409, $response['headers']['status-code']);
}
public function testCreateExecution()