From 5886c53ce3044916fb22b8ca7f74ea67e0cc6110 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 11 Aug 2025 08:43:30 +0000 Subject: [PATCH] Add transaction staging support for document operations Co-authored-by: jakeb994 --- .../Documents/Attribute/Decrement.php | 45 ++++++++++++++++ .../Documents/Attribute/Increment.php | 45 ++++++++++++++++ .../Collections/Documents/Bulk/Delete.php | 38 ++++++++++++++ .../Collections/Documents/Bulk/Update.php | 39 ++++++++++++++ .../Collections/Documents/Bulk/Upsert.php | 39 ++++++++++++++ .../Collections/Documents/Create.php | 51 +++++++++++++++++++ .../Collections/Documents/Delete.php | 33 ++++++++++++ .../Collections/Documents/Update.php | 42 +++++++++++++++ .../Collections/Documents/Upsert.php | 41 +++++++++++++++ 9 files changed, 373 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index fa6bc93845..8742499348 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -94,6 +94,51 @@ class Decrement extends Action throw new Exception($this->getParentNotFoundException()); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operation in transaction logs + $staged = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => $documentId, + 'action' => 'decrement', + 'data' => [ + 'attribute' => $attribute, + 'value' => $value, + 'min' => $min, + ], + ]); + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocument('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + 1 + ); + }); + + // Return successful response without actually decrementing + $mockDocument = new Document([ + '$id' => $documentId, + '$collectionId' => $collectionId, + '$databaseId' => $databaseId, + $attribute => $value, // Mock response - actual value would be computed during commit + ]); + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($mockDocument, $this->getResponseModel()); + return; + } + try { $document = $dbForProject->decreaseDocumentAttribute( collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index 5bfbc96c7b..f497d781f1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -94,6 +94,51 @@ class Increment extends Action throw new Exception($this->getParentNotFoundException()); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operation in transaction logs + $staged = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => $documentId, + 'action' => 'increment', + 'data' => [ + 'attribute' => $attribute, + 'value' => $value, + 'max' => $max, + ], + ]); + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocument('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + 1 + ); + }); + + // Return successful response without actually incrementing + $mockDocument = new Document([ + '$id' => $documentId, + '$collectionId' => $collectionId, + '$databaseId' => $databaseId, + $attribute => $value, // Mock response - actual value would be computed during commit + ]); + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($mockDocument, $this->getResponseModel()); + return; + } + try { $document = $dbForProject->increaseDocumentAttribute( collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php index 42206983bd..19c9c7f005 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php @@ -109,6 +109,44 @@ class Delete extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operation in transaction logs + $staged = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => null, // Bulk operation doesn't have specific document ID + 'action' => 'bulkDelete', + 'data' => [ + 'queries' => $queries, + ], + ]); + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocument('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + 1 + ); + }); + + // Return successful response without actually deleting documents + $response->dynamic(new Document([ + $this->getSdkGroup() => [], + 'total' => 0, // Can't predict how many would be deleted + ]), $this->getResponseModel()); + return; + } + $documents = []; try { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php index 0a199c6eb7..c2e2218c4e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php @@ -121,6 +121,45 @@ class Update extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operation in transaction logs + $staged = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => null, // Bulk operation doesn't have specific document ID + 'action' => 'bulkUpdate', + 'data' => [ + 'data' => $data, + 'queries' => $queries, + ], + ]); + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocument('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + 1 + ); + }); + + // Return successful response without actually updating documents + $response->dynamic(new Document([ + $this->getSdkGroup() => [], + 'total' => 0, // Can't predict how many would be updated + ]), $this->getResponseModel()); + return; + } + if ($data['$permissions']) { $validator = new Permissions(); if (!$validator->isValid($data['$permissions'])) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php index 01844b52f4..ba05ce414d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php @@ -100,6 +100,45 @@ class Upsert extends Action throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Bulk upsert is not supported for ' . $this->getSdkNamespace() . ' with relationship attributes'); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operations in transaction logs + $staged = []; + foreach ($documents as $document) { + $staged[] = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => $document['$id'] ?? ID::unique(), + 'action' => 'upsert', + 'data' => $document, + ]); + } + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocuments('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + \count($staged) + ); + }); + + // Return successful response without actually upserting documents + $response->dynamic(new Document([ + $this->getSdkGroup() => [], + 'total' => \count($documents), + ]), $this->getResponseModel()); + return; + } + foreach ($documents as $key => $document) { $documents[$key] = new Document($document); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 5d4629e7a5..bf447454ed 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -189,6 +189,57 @@ class Create extends Action throw new Exception($this->getParentNotFoundException()); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operation(s) in transaction logs + $staged = []; + foreach ($documents as $document) { + $staged[] = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => $document['$id'] ?? $documentId ?? ID::unique(), + 'action' => 'create', + 'data' => $document, + ]); + } + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocuments('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + \count($staged) + ); + }); + + // Return successful response without actually creating documents + if ($isBulk) { + $response->dynamic(new Document([ + $this->getSdkGroup() => [], + 'total' => \count($documents), + ]), $this->getBulkResponseModel()); + } else { + $mockDocument = new Document([ + '$id' => $documents[0]['$id'] ?? $documentId, + '$collectionId' => $collectionId, + '$databaseId' => $databaseId, + ...$documents[0] + ]); + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) + ->dynamic($mockDocument, $this->getResponseModel()); + } + return; + } + $hasRelationships = \array_filter( $collection->getAttribute('attributes', []), fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index 75377fe42b..e6dc7e1555 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -104,6 +104,39 @@ class Delete extends Action throw new Exception($this->getNotFoundException()); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operation in transaction logs + $staged = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => $documentId, + 'action' => 'delete', + 'data' => [], + ]); + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocument('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + 1 + ); + }); + + // Return successful response without actually deleting document + $response->noContent(); + return; + } + try { $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $database, $collection, $documentId) { $dbForProject->deleteDocument( diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index cda4563a4c..33a1ebcc7a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -128,6 +128,48 @@ class Update extends Action throw new Exception($this->getNotFoundException()); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operation in transaction logs + $staged = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => $documentId, + 'action' => 'update', + 'data' => $data, + ]); + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocument('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + 1 + ); + }); + + // Return successful response without actually updating document + $mockDocument = new Document([ + '$id' => $documentId, + '$collectionId' => $collectionId, + '$databaseId' => $databaseId, + ...$document->getArrayCopy(), + ...$data + ]); + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($mockDocument, $this->getResponseModel()); + return; + } + // Map aggregate permissions into the multiple permissions they represent. $permissions = Permission::aggregate($permissions, [ Database::PERMISSION_READ, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index bdd5c1de0a..61fdbd312d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -111,6 +111,47 @@ class Upsert extends Action throw new Exception($this->getParentNotFoundException()); } + // Handle transaction staging + if ($transactionId !== null) { + $transaction = $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); + } + + // Stage the operation in transaction logs + $staged = new Document([ + '$id' => ID::unique(), + 'databaseInternalId' => $database->getSequence(), + 'collectionInternalId' => $collection->getSequence(), + 'transactionInternalId' => $transaction->getSequence(), + 'documentId' => $documentId, + 'action' => 'upsert', + 'data' => $data, + ]); + + $dbForProject->withTransaction(function () use ($dbForProject, $transactionId, $staged) { + $dbForProject->createDocument('transactionLogs', $staged); + $dbForProject->increaseDocumentAttribute( + 'transactions', + $transactionId, + 'operations', + 1 + ); + }); + + // Return successful response without actually upserting document + $mockDocument = new Document([ + '$id' => $documentId, + '$collectionId' => $collectionId, + '$databaseId' => $databaseId, + ...$data + ]); + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) + ->dynamic($mockDocument, $this->getResponseModel()); + return; + } + $allowedPermissions = [ Database::PERMISSION_READ, Database::PERMISSION_UPDATE,