diff --git a/app/config/specs/open-api3-1.8.x-console.json b/app/config/specs/open-api3-1.8.x-console.json index 1d831b7fd2..6b6ab7409f 100644 --- a/app/config/specs/open-api3-1.8.x-console.json +++ b/app/config/specs/open-api3-1.8.x-console.json @@ -23026,7 +23026,7 @@ "tags": [ "migrations" ], - "description": "Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in an Appwrite Storage bucket.", + "description": "Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete.", "responses": { "202": { "description": "Migration", @@ -23076,11 +23076,6 @@ "description": "Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.", "x-example": "" }, - "bucketId": { - "type": "string", - "description": "Storage bucket unique ID where the exported CSV will be stored.", - "x-example": "" - }, "filename": { "type": "string", "description": "The name of the file to be created for the export, excluding the .csv extension.", @@ -23130,7 +23125,6 @@ }, "required": [ "resourceId", - "bucketId", "filename" ] } diff --git a/app/config/specs/open-api3-latest-console.json b/app/config/specs/open-api3-latest-console.json index 03b60a0e10..12d2d30ab9 100644 --- a/app/config/specs/open-api3-latest-console.json +++ b/app/config/specs/open-api3-latest-console.json @@ -23039,7 +23039,7 @@ "tags": [ "migrations" ], - "description": "Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in an Appwrite Storage bucket.", + "description": "Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete.", "responses": { "202": { "description": "Migration", @@ -23089,11 +23089,6 @@ "description": "Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.", "x-example": "" }, - "bucketId": { - "type": "string", - "description": "Storage bucket unique ID where the exported CSV will be stored.", - "x-example": "" - }, "filename": { "type": "string", "description": "The name of the file to be created for the export, excluding the .csv extension.", @@ -23143,7 +23138,6 @@ }, "required": [ "resourceId", - "bucketId", "filename" ] } diff --git a/app/config/specs/swagger2-1.8.x-console.json b/app/config/specs/swagger2-1.8.x-console.json index be829c0de0..384011f2fd 100644 --- a/app/config/specs/swagger2-1.8.x-console.json +++ b/app/config/specs/swagger2-1.8.x-console.json @@ -23127,7 +23127,7 @@ "tags": [ "migrations" ], - "description": "Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in an Appwrite Storage bucket.", + "description": "Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete.", "responses": { "202": { "description": "Migration", @@ -23175,12 +23175,6 @@ "default": null, "x-example": "" }, - "bucketId": { - "type": "string", - "description": "Storage bucket unique ID where the exported CSV will be stored.", - "default": null, - "x-example": "" - }, "filename": { "type": "string", "description": "The name of the file to be created for the export, excluding the .csv extension.", @@ -23238,7 +23232,6 @@ }, "required": [ "resourceId", - "bucketId", "filename" ] } diff --git a/app/config/specs/swagger2-latest-console.json b/app/config/specs/swagger2-latest-console.json index 611cbf1e1d..efa5d9bdee 100644 --- a/app/config/specs/swagger2-latest-console.json +++ b/app/config/specs/swagger2-latest-console.json @@ -23141,7 +23141,7 @@ "tags": [ "migrations" ], - "description": "Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in an Appwrite Storage bucket.", + "description": "Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete.", "responses": { "202": { "description": "Migration", @@ -23189,12 +23189,6 @@ "default": null, "x-example": "" }, - "bucketId": { - "type": "string", - "description": "Storage bucket unique ID where the exported CSV will be stored.", - "default": null, - "x-example": "" - }, "filename": { "type": "string", "description": "The name of the file to be created for the export, excluding the .csv extension.", @@ -23252,7 +23246,6 @@ }, "required": [ "resourceId", - "bucketId", "filename" ] } diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 16188c51c5..f9d134db05 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -462,7 +462,6 @@ App::post('/v1/migrations/csv/exports') ] )) ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') - ->param('bucketId', '', new UID(), 'Storage bucket unique ID where the exported CSV will be stored.') ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.') ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) @@ -474,13 +473,13 @@ App::post('/v1/migrations/csv/exports') ->inject('user') ->inject('response') ->inject('dbForProject') + ->inject('dbForPlatform') ->inject('authorization') ->inject('project') ->inject('queueForEvents') ->inject('queueForMigrations') ->action(function ( string $resourceId, - string $bucketId, string $filename, array $columns, array $queries, @@ -492,6 +491,7 @@ App::post('/v1/migrations/csv/exports') Document $user, Response $response, Database $dbForProject, + Database $dbForPlatform, Authorization $authorization, Document $project, Event $queueForEvents, @@ -503,7 +503,7 @@ App::post('/v1/migrations/csv/exports') throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); if ($bucket->isEmpty()) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } @@ -549,7 +549,7 @@ App::post('/v1/migrations/csv/exports') 'resourceData' => '{}', 'errors' => [], 'options' => [ - 'bucketId' => $bucketId, + 'bucketId' => 'default', // Always use internal bucket 'filename' => $filename, 'columns' => $columns, 'queries' => $queries, diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index a0164c3164..b3058b7dbf 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1475,13 +1475,12 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') ->inject('response') ->inject('request') ->inject('dbForProject') + ->inject('dbForPlatform') ->inject('project') ->inject('mode') ->inject('deviceForFiles') ->inject('authorization') - ->action(function (string $bucketId, string $fileId, string $jwt, Response $response, Request $request, Database $dbForProject, Document $project, string $mode, Device $deviceForFiles, Authorization $authorization) { - $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - + ->action(function (string $bucketId, string $fileId, string $jwt, Response $response, Request $request, Database $dbForProject, Database $dbForPlatform, Document $project, string $mode, Device $deviceForFiles, Authorization $authorization) { $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); try { @@ -1498,15 +1497,18 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); } + $isInternal = $decoded['internal'] ?? false; + $dbForProject = $isInternal ? $dbForPlatform : $dbForProject; + $isAPIKey = Auth::isAppUser($authorization->getRoles()); $isPrivilegedUser = Auth::isPrivilegedUser($authorization->getRoles()); + $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } $file = $authorization->skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - if ($file->isEmpty()) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } @@ -1514,7 +1516,6 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push') $mimes = Config::getParam('storage-mimes'); $path = $file->getAttribute('path', ''); - if (!$deviceForFiles->exists($path)) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); } diff --git a/app/init/constants.php b/app/init/constants.php index 3b81785690..e11fdf9a54 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -138,6 +138,7 @@ const DELETE_TYPE_TOPIC = 'topic'; const DELETE_TYPE_TARGET = 'target'; const DELETE_TYPE_EXPIRED_TARGETS = 'invalid_targets'; const DELETE_TYPE_SESSION_TARGETS = 'session_targets'; +const DELETE_TYPE_CSV_EXPORTS = 'csv_exports'; const DELETE_TYPE_MAINTENANCE = 'maintenance'; // Message types diff --git a/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-export.md b/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-export.md index e56afae786..61eceabcd8 100644 --- a/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-export.md +++ b/docs/examples/1.8.x/console-cli/examples/migrations/create-csv-export.md @@ -1,4 +1,3 @@ appwrite migrations create-csv-export \ --resource-id \ - --bucket-id \ --filename diff --git a/docs/examples/1.8.x/console-web/examples/migrations/create-csv-export.md b/docs/examples/1.8.x/console-web/examples/migrations/create-csv-export.md index e1b909a852..89f779fc4c 100644 --- a/docs/examples/1.8.x/console-web/examples/migrations/create-csv-export.md +++ b/docs/examples/1.8.x/console-web/examples/migrations/create-csv-export.md @@ -8,7 +8,6 @@ const migrations = new Migrations(client); const result = await migrations.createCSVExport({ resourceId: '', - bucketId: '', filename: '', columns: [], // optional queries: [], // optional diff --git a/docs/references/migrations/migration-csv-export.md b/docs/references/migrations/migration-csv-export.md index 866faed2d2..069dda895e 100644 --- a/docs/references/migrations/migration-csv-export.md +++ b/docs/references/migrations/migration-csv-export.md @@ -1 +1 @@ -Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in an Appwrite Storage bucket. \ No newline at end of file +Export documents to a CSV file from your Appwrite database. This endpoint allows you to export documents to a CSV file stored in a secure internal bucket. You'll receive an email with a download link when the export is complete. \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php index 884b9c5589..728e732cc5 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php @@ -2,9 +2,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases; -use Utopia\Platform\Action as UtopiaAction; +use Appwrite\Extend\Exception; +use Appwrite\Platform\Action as AppwriteAction; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Operator; -class Action extends UtopiaAction +class Action extends AppwriteAction { private string $context = 'legacy'; @@ -13,11 +17,81 @@ class Action extends UtopiaAction return $this->context; } - public function setHttpPath(string $path): UtopiaAction + public function setHttpPath(string $path): AppwriteAction { if (\str_contains($path, '/tablesdb')) { $this->context = 'tablesdb'; } return parent::setHttpPath($path); } + + /** + * Parse operator strings in data array and convert them to Operator objects. + * + * @param array $data The data array that may contain operator JSON strings or arrays + * @param Document $collection The collection document to check for relationship attributes + * @return array The data array with operators converted to Operator objects + * @throws Exception If an operator string is invalid + */ + protected function parseOperators(array $data, Document $collection): array + { + $relationshipKeys = []; + foreach ($collection->getAttribute('attributes', []) as $attribute) { + if ($attribute->getAttribute('type') === Database::VAR_RELATIONSHIP) { + $relationshipKeys[$attribute->getAttribute('key')] = true; + } + } + + foreach ($data as $key => $value) { + if (!\is_string($key)) { + if (\is_array($value)) { + $data[$key] = $this->parseOperators($value, $collection); + } + continue; + } + + if (\str_starts_with($key, '$')) { + continue; + } + + if (isset($relationshipKeys[$key])) { + continue; + } + + // Handle operator as JSON string (from API requests) + if (\is_string($value)) { + $decoded = \json_decode($value, true); + + if ( + \is_array($decoded) && + isset($decoded['method']) && + \is_string($decoded['method']) && + Operator::isMethod($decoded['method']) + ) { + try { + $data[$key] = Operator::parse($value); + } catch (\Exception $e) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid operator for attribute "' . $key . '": ' . $e->getMessage()); + } + } + } + // Handle operator as array (from transaction logs after serialization) + elseif ( + \is_array($value) && + isset($value['method']) && + \is_string($value['method']) && + Operator::isMethod($value['method']) + ) { + try { + $data[$key] = Operator::parseOperator($value); + } catch (\Exception $e) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid operator for attribute "' . $key . '": ' . $e->getMessage()); + } + } elseif (\is_array($value)) { + $data[$key] = $this->parseOperators($value, $collection); + } + } + + return $data; + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 3ce7ab70d8..14b09777a8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -4,13 +4,12 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documen use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Platform\Action as AppwriteAction; +use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Operator; use Utopia\Database\Validator\Authorization; -abstract class Action extends AppwriteAction +abstract class Action extends DatabasesAction { /** * @var string|null The current context (either 'row' or 'document') @@ -22,7 +21,7 @@ abstract class Action extends AppwriteAction */ abstract protected function getResponseModel(): string; - public function setHttpPath(string $path): AppwriteAction + public function setHttpPath(string $path): DatabasesAction { if (str_contains($path, '/tablesdb/')) { $this->context = ROWS; @@ -340,53 +339,6 @@ abstract class Action extends AppwriteAction return true; } - /** - * Parse operator strings in data array and convert them to Operator objects. - * - * @param array $data The data array that may contain operator JSON strings - * @param Document $collection The collection document to check for relationship attributes - * @return array The data array with operators converted to Operator objects - * @throws Exception If an operator string is invalid - */ - protected function parseOperators(array $data, Document $collection): array - { - $relationshipKeys = []; - foreach ($collection->getAttribute('attributes', []) as $attribute) { - if ($attribute->getAttribute('type') === Database::VAR_RELATIONSHIP) { - $relationshipKeys[$attribute->getAttribute('key')] = true; - } - } - - foreach ($data as $key => $value) { - if (\str_starts_with($key, '$')) { - continue; - } - - if (isset($relationshipKeys[$key])) { - continue; - } - - if (\is_string($value)) { - $decoded = \json_decode($value, true); - - if ( - \is_array($decoded) && - isset($decoded['method']) && - \is_string($decoded['method']) && - Operator::isMethod($decoded['method']) - ) { - try { - $data[$key] = Operator::parse($value); - } catch (\Exception $e) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid operator for attribute "' . $key . '": ' . $e->getMessage()); - } - } - } - } - - return $data; - } - /** * For triggering different queues for each document for a bulk documents * @param string $event 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 860a72f78e..158a44c1b3 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 @@ -154,6 +154,8 @@ class Decrement extends Action ); }); + $queueForEvents->reset(); + // Return successful response without actually decrementing $groupId = $this->getGroupId(); $mockDocument = new Document([ 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 e88a100636..9045954789 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 @@ -154,6 +154,8 @@ class Increment extends Action ); }); + $queueForEvents->reset(); + // Return successful response without actually incrementing $groupId = $this->getGroupId(); $mockDocument = new Document([ 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 76acff63ab..fdc4c96fe4 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 @@ -151,6 +151,8 @@ class Delete extends Action ); }); + $queueForEvents->reset(); + // Return successful response without actually deleting documents $response->dynamic(new Document([ $this->getSDKGroup() => [], 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 b190ddde19..4adf11311e 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 @@ -108,7 +108,9 @@ class Update extends Action throw new Exception($this->getParentNotFoundException()); } - $data = $this->parseOperators($data, $collection); + if ($transactionId === null) { + $data = $this->parseOperators($data, $collection); + } $hasRelationships = \array_filter( $collection->getAttribute('attributes', []), @@ -175,6 +177,8 @@ class Update extends Action ); }); + $queueForEvents->reset(); + // Return successful response without actually updating documents $response->dynamic(new Document([ $this->getSDKGroup() => [], 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 5a3712cc26..d30135de75 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 @@ -108,7 +108,9 @@ class Upsert extends Action } foreach ($documents as $key => $document) { - $document = $this->parseOperators($document, $collection); + if ($transactionId === null) { + $document = $this->parseOperators($document, $collection); + } $document = $this->removeReadonlyAttributes($document, privileged: true); $documents[$key] = new Document($document); } @@ -150,6 +152,8 @@ class Upsert extends Action ); }); + $queueForEvents->reset(); + // Return successful response without actually upserting documents $response->dynamic(new Document([ $this->getSDKGroup() => [], 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 35678119c9..ec3db59668 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 @@ -419,6 +419,8 @@ class Create extends Action ); }); + $queueForEvents->reset(); + // Return successful response without actually creating documents if ($isBulk) { $response->dynamic(new Document([ 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 21aec5c1cb..93ad7dc2a8 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 @@ -179,6 +179,8 @@ class Delete extends Action ); }); + $queueForEvents->reset(); + // Return successful response without actually deleting document $response->noContent(); return; 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 3f664d4b9e..0aca4a08c3 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 @@ -114,7 +114,9 @@ class Update extends Action throw new Exception($this->getParentNotFoundException()); } - $data = $this->parseOperators($data, $collection); + if ($transactionId === null) { + $data = $this->parseOperators($data, $collection); + } // Read permission should not be required for update /** @var Document $document */ @@ -245,7 +247,6 @@ class Update extends Action ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); - // Handle transaction staging if ($transactionId !== null) { $transaction = ($isAPIKey || $isPrivilegedUser) @@ -304,6 +305,9 @@ class Update extends Action ...$document->getArrayCopy(), ...$data ]); + + $queueForEvents->reset(); + $response ->setStatusCode(SwooleResponse::STATUS_CODE_OK) ->dynamic($mockDocument, $this->getResponseModel()); 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 4e745c761b..5ec455b947 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 @@ -120,7 +120,9 @@ class Upsert extends Action throw new Exception($this->getParentNotFoundException()); } - $data = $this->parseOperators($data, $collection); + if ($transactionId === null) { + $data = $this->parseOperators($data, $collection); + } $allowedPermissions = [ Database::PERMISSION_READ, @@ -304,6 +306,8 @@ class Upsert extends Action ); }); + $queueForEvents->reset(); + // Return successful response without actually upserting document $groupId = $this->getGroupId(); $mockDocument = new Document([ diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php index 8915ae6141..e2a4491736 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php @@ -2,16 +2,16 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Transactions; -use Utopia\Platform\Action as UtopiaAction; +use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction; -abstract class Action extends UtopiaAction +abstract class Action extends DatabasesAction { /** * The current API context (either 'table' or 'collection'). */ private ?string $context = COLLECTIONS; - public function setHttpPath(string $path): UtopiaAction + public function setHttpPath(string $path): DatabasesAction { if (\str_contains($path, '/tablesdb')) { $this->context = TABLES; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index 4f488445f0..bf84b05e04 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -150,6 +150,7 @@ class Update extends Action ])); $state = []; + $collections = []; foreach ($operations as $operation) { $databaseInternalId = $operation['databaseInternalId']; @@ -160,6 +161,21 @@ class Update extends Action $action = $operation['action']; $data = $operation['data']; + if ($data instanceof Document) { + $data = $data->getArrayCopy(); + } + + if (!isset($collections[$collectionId])) { + $collections[$collectionId] = Authorization::skip( + fn () => $dbForProject->getCollection($collectionId) + ); + } + $collection = $collections[$collectionId]; + + if (\is_array($data) && !empty($data)) { + $data = $this->parseOperators($data, $collection); + } + if ($action === 'delete' && $documentId && empty($data)) { $doc = $dbForProject->getDocument($collectionId, $documentId); if (!$doc->isEmpty()) { @@ -173,10 +189,6 @@ class Update extends Action $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + 1; } - if ($data instanceof Document) { - $data = $data->getArrayCopy(); - } - switch ($action) { case 'create': $this->handleCreateOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php index 036e8783d4..f5785d0bb4 100644 --- a/src/Appwrite/Platform/Tasks/Maintenance.php +++ b/src/Appwrite/Platform/Tasks/Maintenance.php @@ -95,6 +95,7 @@ class Maintenance extends Action $this->renewCertificates($dbForPlatform, $queueForCertificates); $this->notifyDeleteCache($cacheRetention, $queueForDeletes); $this->notifyDeleteSchedules($schedulesDeletionRetention, $queueForDeletes); + $this->notifyDeleteCSVExports($queueForDeletes); }, $interval, $delay); } @@ -106,6 +107,13 @@ class Maintenance extends Action ->trigger(); } + private function notifyDeleteCSVExports(Delete $queueForDeletes): void + { + $queueForDeletes + ->setType(DELETE_TYPE_CSV_EXPORTS) + ->trigger(); + } + private function renewCertificates(Database $dbForPlatform, Certificate $queueForCertificate): void { $time = DatabaseDateTime::now(); diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 60e615efc1..808adabb24 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -177,6 +177,9 @@ class Deletes extends Action case DELETE_TYPE_SESSION_TARGETS: $this->deleteSessionTargets($project, $getProjectDB, $document); break; + case DELETE_TYPE_CSV_EXPORTS: + $this->deleteOldCSVExports($dbForPlatform, $deviceForFiles); + break; case DELETE_TYPE_MAINTENANCE: $this->deleteExpiredTargets($project, $getProjectDB); $this->deleteExecutionLogs($project, $getProjectDB, $executionRetention); @@ -717,6 +720,41 @@ class Deletes extends Action ], $dbForProject); } + /** + * @param Database $dbForPlatform + * @param Device $deviceForFiles + * @return void + * @throws Exception|Throwable + */ + private function deleteOldCSVExports(Database $dbForPlatform, Device $deviceForFiles): void + { + $bucket = $dbForPlatform->getDocument('buckets', 'default'); + + if ($bucket->isEmpty()) { + Console::warning('Default bucket not found, skipping CSV export cleanup'); + return; + } + + $oneWeekAgo = DateTime::addSeconds(new \DateTime(), -1 * 60 * 60 * 24 * 7); // 1 week + + Console::info("Deleting CSV export files older than " . $oneWeekAgo); + + $this->deleteByGroup('bucket_' . $bucket->getSequence(), [ + Query::select([...$this->selects, '$createdAt', 'name', 'path']), + Query::equal('bucketId', ['default']), + Query::createdBefore($oneWeekAgo), + Query::endsWith('name', ['.csv']), + Query::orderDesc('$createdAt'), + Query::orderDesc(), + ], $dbForPlatform, function (Document $file) use ($deviceForFiles) { + $path = $file->getAttribute('path'); + if ($deviceForFiles->exists($path)) { + $deviceForFiles->delete($path); + Console::success('Deleted CSV file: ' . $file->getAttribute('name')); + } + }); + } + /** * @param Database $dbForPlatform * @param string $datetime diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 9f3400adf4..0bd5c50e04 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -11,11 +11,15 @@ use Utopia\CLI\Console; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Conflict; use Utopia\Database\Exception\Restricted; use Utopia\Database\Exception\Structure; use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; use Utopia\Database\Query; +use Utopia\Database\Validator\Authorization; use Utopia\Locale\Locale; use Utopia\Migration\Destination; use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; @@ -222,6 +226,7 @@ class Migrations extends Action } /** + * @throws AuthorizationException * @throws Structure * @throws Conflict * @throws \Utopia\Database\Exception @@ -280,6 +285,7 @@ class Migrations extends Action } /** + * @throws AuthorizationException * @throws Conflict * @throws Restricted * @throws Structure @@ -418,6 +424,7 @@ class Migrations extends Action * @param Document $migration * @param Mail $queueForMails * @return void + * @throws AuthorizationException * @throws Structure * @throws \Utopia\Database\Exception * @throws Exception @@ -428,13 +435,20 @@ class Migrations extends Action Mail $queueForMails ): void { $options = $migration->getAttribute('options', []); - $bucketId = $options['bucketId'] ?? null; + $bucketId = 'default'; // Always use platform default bucket $filename = $options['filename'] ?? 'export_' . \time(); $userInternalId = $options['userInternalId'] ?? ''; + $user = $this->dbForPlatform->findOne('users', [ + Query::equal('$sequence', [$userInternalId]) + ]); - $bucket = $this->dbForProject->getDocument('buckets', $bucketId); + if ($user->isEmpty()) { + throw new \Exception('User ' . $userInternalId . ' not found'); + } + + $bucket = Authorization::skip(fn () => $this->dbForPlatform->getDocument('buckets', $bucketId)); if ($bucket->isEmpty()) { - throw new \Exception("Bucket not found: $bucketId"); + throw new \Exception('Bucket not found'); } $path = $this->deviceForFiles->getPath($bucketId . '/' . $this->sanitizeFilename($filename) . '.csv'); @@ -465,7 +479,7 @@ class Migrations extends Action $this->sendCSVEmail( success: false, project: $project, - userInternalId: $userInternalId, + user: $user, options: $options, queueForMails: $queueForMails, sizeMB: $sizeMB @@ -475,9 +489,11 @@ class Migrations extends Action } } - $this->dbForProject->createDocument('bucket_' . $bucket->getSequence(), new Document([ + $this->dbForPlatform->createDocument('bucket_' . $bucket->getSequence(), new Document([ '$id' => $fileId, - '$permissions' => [], + '$permissions' => [ + Permission::read(Role::user($user->getId())), + ], 'bucketId' => $bucket->getId(), 'bucketInternalId' => $bucket->getSequence(), 'name' => $filename, @@ -507,6 +523,7 @@ class Migrations extends Action 'bucketId' => $bucketId, 'fileId' => $fileId, 'projectId' => $project->getId(), + 'internal' => true, ]); // Generate download URL with JWT @@ -517,7 +534,7 @@ class Migrations extends Action $this->sendCSVEmail( success: true, project: $project, - userInternalId: $userInternalId, + user: $user, options: $options, queueForMails: $queueForMails, downloadUrl: $downloadUrl @@ -529,7 +546,7 @@ class Migrations extends Action * * @param bool $success Whether the export was successful * @param Document $project - * @param string $userInternalId Internal ID of the user + * @param Document $user The user who triggered the operation * @param array $options Migration options * @param Mail $queueForMails * @param string $downloadUrl Download URL for successful exports @@ -540,7 +557,7 @@ class Migrations extends Action protected function sendCSVEmail( bool $success, Document $project, - string $userInternalId, + Document $user, array $options, Mail $queueForMails, string $downloadUrl = '', @@ -550,12 +567,8 @@ class Migrations extends Action return; } - $user = $this->dbForPlatform->findOne('users', [ - Query::equal('$sequence', [$userInternalId]) - ]); - if ($user->isEmpty()) { - Console::warning("User not found for CSV export notification: $userInternalId"); + Console::warning("User not found for CSV export notification: {$user->getInternalId()}"); return; } diff --git a/tests/e2e/Services/Databases/TablesDB/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/TablesDB/Transactions/TransactionsBase.php index efa3b52cef..488dc60239 100644 --- a/tests/e2e/Services/Databases/TablesDB/Transactions/TransactionsBase.php +++ b/tests/e2e/Services/Databases/TablesDB/Transactions/TransactionsBase.php @@ -6,6 +6,7 @@ use Tests\E2E\Client; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Operator; use Utopia\Database\Query; trait TransactionsBase @@ -5561,4 +5562,395 @@ trait TransactionsBase $this->assertEquals('Updated after upsert', $response['body']['name']); $this->assertEquals(20, $response['body']['counter']); } + + /** + * Test array operators in transactions using updateRow with transactionId + * This tests the fix for operators not being parsed when stored in transaction logs + */ + public function testArrayOperatorsWithUpdateRow(): void + { + // Create database + $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' => 'ArrayOperatorsTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create table with array column + $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' => 'Items', + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + // Create array column + $column = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/columns/string", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'items', + 'size' => 255, + 'required' => false, + 'array' => true, + ]); + + $this->assertEquals(202, $column['headers']['status-code']); + sleep(2); // Wait for column to be created + + // Create initial row with some items + $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-row', + 'data' => [ + 'items' => ['item1', 'item2', 'item3', 'item4'] + ] + ]); + + $this->assertEquals(201, $row['headers']['status-code']); + $this->assertEquals(['item1', 'item2', 'item3', 'item4'], $row['body']['items']); + + // 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 arrayRemove operator + $updateResponse = $this->client->call(Client::METHOD_PATCH, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/test-row", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'transactionId' => $transactionId, + 'data' => [ + 'items' => Operator::arrayRemove('item2')->toString() + ] + ]); + + $this->assertEquals(200, $updateResponse['headers']['status-code']); + + // Test arrayInsert operator + $updateResponse = $this->client->call(Client::METHOD_PATCH, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/test-row", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'transactionId' => $transactionId, + 'data' => [ + 'items' => Operator::arrayInsert(2, 'newItem')->toString() + ] + ]); + + $this->assertEquals(200, $updateResponse['headers']['status-code']); + + // Commit transaction + $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']); + + // Verify the operations were applied correctly + $row = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/test-row", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $row['headers']['status-code']); + // After removing item2: ['item1', 'item3', 'item4'] + // After inserting 'newItem' at index 2: ['item1', 'item3', 'newItem', 'item4'] + $this->assertEquals(['item1', 'item3', 'newItem', 'item4'], $row['body']['items']); + } + + /** + * Test array operators in transactions using createOperations + * This tests the fix for operators not being parsed in bulk operation creation + */ + public function testArrayOperatorsWithCreateOperations(): void + { + // Create database + $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' => 'ArrayOperatorsBulkTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create table with array column + $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' => 'Tags', + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + // Create array column + $column = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/columns/string", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'tags', + 'size' => 255, + 'required' => false, + 'array' => true, + ]); + + $this->assertEquals(202, $column['headers']['status-code']); + sleep(2); // Wait for column to be created + + // Create initial row + $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' => 'doc1', + 'data' => [ + 'tags' => ['php', 'javascript', 'python', 'ruby'] + ] + ]); + + $this->assertEquals(201, $row['headers']['status-code']); + + // 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']; + + // Create operations using bulk createOperations endpoint with array operators + $operations = $this->client->call(Client::METHOD_POST, "/tablesdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'operations' => [ + [ + 'action' => 'update', + 'databaseId' => $databaseId, + 'tableId' => $tableId, + 'rowId' => 'doc1', + 'data' => [ + 'tags' => Operator::arrayRemove('javascript')->toString() + ] + ], + [ + 'action' => 'update', + 'databaseId' => $databaseId, + 'tableId' => $tableId, + 'rowId' => 'doc1', + 'data' => [ + 'tags' => Operator::arrayAppend(['go', 'rust'])->toString() + ] + ] + ] + ]); + + $this->assertEquals(201, $operations['headers']['status-code']); + $this->assertEquals(2, $operations['body']['operations']); + + // Commit transaction + $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']); + + // Verify the operations were applied correctly + $row = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/doc1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $row['headers']['status-code']); + // After removing 'javascript': ['php', 'python', 'ruby'] + // After appending ['go', 'rust']: ['php', 'python', 'ruby', 'go', 'rust'] + $this->assertEquals(['php', 'python', 'ruby', 'go', 'rust'], $row['body']['tags']); + } + + /** + * Test multiple array operators in a single transaction + * This tests all common array operators to ensure comprehensive coverage + */ + public function testMultipleArrayOperators(): void + { + // Create database + $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' => 'MultipleOperatorsTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create table + $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' => 'Arrays', + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + // Create multiple array columns + $columns = [ + ['columnId' => 'list1', 'name' => 'List1'], + ['columnId' => 'list2', 'name' => 'List2'], + ['columnId' => 'list3', 'name' => 'List3'], + ]; + + foreach ($columns as $col) { + $column = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/columns/string", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => $col['columnId'], + 'size' => 255, + 'required' => false, + 'array' => true, + ]); + $this->assertEquals(202, $column['headers']['status-code']); + } + + sleep(2); // Wait for columns to be created + + // Create initial row + $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' => 'multi-ops', + 'data' => [ + 'list1' => ['a', 'b', 'c'], + 'list2' => ['x', 'y', 'z'], + 'list3' => ['1', '2', '3', '4', '5'] + ] + ]); + + $this->assertEquals(201, $row['headers']['status-code']); + + // 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 arrayPrepend + $this->client->call(Client::METHOD_PATCH, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/multi-ops", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'transactionId' => $transactionId, + 'data' => [ + 'list1' => Operator::arrayPrepend(['z'])->toString() + ] + ]); + + // Test arrayAppend + $this->client->call(Client::METHOD_PATCH, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/multi-ops", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'transactionId' => $transactionId, + 'data' => [ + 'list2' => Operator::arrayAppend(['w'])->toString() + ] + ]); + + // Test arrayRemove + $this->client->call(Client::METHOD_PATCH, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/multi-ops", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'transactionId' => $transactionId, + 'data' => [ + 'list3' => Operator::arrayRemove('3')->toString() + ] + ]); + + // Commit transaction + $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']); + + // Verify all operations were applied correctly + $row = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/multi-ops", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals(['z', 'a', 'b', 'c'], $row['body']['list1'], 'arrayPrepend should add element at the beginning'); + $this->assertEquals(['x', 'y', 'z', 'w'], $row['body']['list2'], 'arrayAppend should add element at the end'); + $this->assertEquals(['1', '2', '4', '5'], $row['body']['list3'], 'arrayRemove should remove the element'); + } } diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 16e58c9c2c..f16864960e 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1282,39 +1282,11 @@ trait MigrationsBase $this->assertEquals(200, $docs['headers']['status-code']); $this->assertEquals(10, $docs['body']['total'], 'Expected 10 documents but got ' . $docs['body']['total']); - // Create a storage bucket for the export - $bucketIdUnique = ID::unique(); - $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'bucketId' => $bucketIdUnique, - 'name' => 'Test Export Bucket', - 'permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'fileSecurity' => false, - 'enabled' => true, - 'maximumFileSize' => 10485760, // 10MB - 'allowedFileExtensions' => ['csv'], - 'compression' => 'none', - 'encryption' => false, - 'antivirus' => false - ]); - - $this->assertEquals(201, $bucket['headers']['status-code']); - $bucketId = $bucket['body']['$id']; - - // Perform CSV export with notification enabled + // Perform CSV export with notification enabled (uses internal bucket) $migration = $this->client->call(Client::METHOD_POST, '/migrations/csv/exports', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders()), [ - 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $collectionId, 'filename' => 'test-export', 'columns' => [], @@ -1329,7 +1301,7 @@ trait MigrationsBase $this->assertNotEmpty($migration['body']['$id']); $migrationId = $migration['body']['$id']; - $this->assertEventually(function () use ($bucketId, $migrationId) { + $this->assertEventually(function () use ($migrationId) { $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1341,55 +1313,10 @@ trait MigrationsBase $this->assertEquals('completed', $response['body']['status']); $this->assertEquals('Appwrite', $response['body']['source']); $this->assertEquals('CSV', $response['body']['destination']); - $this->assertEquals($bucketId, $response['body']['options']['bucketId']); return true; }, 30000, 500); - // Check that the file was created in the bucket - // Query files by filename - $files = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'queries' => [ - Query::equal('name', ['test-export'])->toString() - ] - ]); - - $this->assertEquals(200, $files['headers']['status-code']); - $this->assertEquals(1, $files['body']['total'], 'Expected exactly one file with name "test-export"'); - - // Get the exported file - $file = $files['body']['files'][0]; - $fileId = $file['$id']; - - $this->assertEquals($bucketId, $file['bucketId']); - $this->assertEquals('test-export', $file['name']); - $this->assertEquals('text/csv', $file['mimeType']); - $this->assertGreaterThan(0, $file['sizeOriginal']); - - // Download and verify CSV content - $download = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', \array_merge([ - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $download['headers']['status-code']); - - $csvContent = $download['body']; - $lines = explode("\n", trim($csvContent)); - $this->assertCount(11, $lines); - $this->assertStringContainsString('$id', $lines[0]); - $this->assertStringContainsString('$permissions', $lines[0]); - $this->assertStringContainsString('$createdAt', $lines[0]); - $this->assertStringContainsString('$updatedAt', $lines[0]); - $this->assertStringContainsString('name', $lines[0]); - $this->assertStringContainsString('email', $lines[0]); - - $this->assertStringContainsString('Test User 1', $lines[1]); - $this->assertStringContainsString('user1@appwrite.io', $lines[1]); - // Check that email was sent with download link $lastEmail = $this->getLastEmail(); $this->assertNotEmpty($lastEmail); @@ -1407,28 +1334,25 @@ trait MigrationsBase \parse_str($components['query'] ?? '', $queryParams); $this->assertArrayHasKey('jwt', $queryParams, 'JWT not found in download URL'); $this->assertNotEmpty($queryParams['jwt']); + $this->assertArrayHasKey('project', $queryParams, 'Project not found in download URL'); + $this->assertStringContainsString('/storage/buckets/default/files/', $downloadUrl); // Test download with JWT $path = \str_replace('/v1', '', $components['path']); $downloadWithJwt = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']); $this->assertEquals(200, $downloadWithJwt['headers']['status-code'], 'Failed to download file with JWT'); - $this->assertEquals($csvContent, $downloadWithJwt['body'], 'Downloaded content differs from original'); - // Test that download without JWT fails - $downloadWithoutJwt = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download'); - $this->assertEquals(404, $downloadWithoutJwt['headers']['status-code'], 'File should not be downloadable without JWT'); + // Verify the downloaded content is valid CSV + $csvData = $downloadWithJwt['body']; + $this->assertNotEmpty($csvData, 'CSV export should not be empty'); + $this->assertStringContainsString('name', $csvData, 'CSV should contain the name column header'); + $this->assertStringContainsString('email', $csvData, 'CSV should contain the email column header'); + $this->assertStringContainsString('Test User 1', $csvData, 'CSV should contain test data'); - $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, [ - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]); + // Cleanup $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]); - $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [ - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]); } }