Merge branch '1.8.x' into refactor-auth-single-instance

This commit is contained in:
Jake Barnby
2025-11-14 05:21:40 +00:00
committed by GitHub
28 changed files with 622 additions and 209 deletions
@@ -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": "<ID1:ID2>"
},
"bucketId": {
"type": "string",
"description": "Storage bucket unique ID where the exported CSV will be stored.",
"x-example": "<BUCKET_ID>"
},
"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"
]
}
@@ -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": "<ID1:ID2>"
},
"bucketId": {
"type": "string",
"description": "Storage bucket unique ID where the exported CSV will be stored.",
"x-example": "<BUCKET_ID>"
},
"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"
]
}
+1 -8
View File
@@ -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": "<ID1:ID2>"
},
"bucketId": {
"type": "string",
"description": "Storage bucket unique ID where the exported CSV will be stored.",
"default": null,
"x-example": "<BUCKET_ID>"
},
"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"
]
}
@@ -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": "<ID1:ID2>"
},
"bucketId": {
"type": "string",
"description": "Storage bucket unique ID where the exported CSV will be stored.",
"default": null,
"x-example": "<BUCKET_ID>"
},
"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"
]
}
+4 -4
View File
@@ -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,
+6 -5
View File
@@ -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);
}
+1
View File
@@ -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
@@ -1,4 +1,3 @@
appwrite migrations create-csv-export \
--resource-id <ID1:ID2> \
--bucket-id <BUCKET_ID> \
--filename <FILENAME>
@@ -8,7 +8,6 @@ const migrations = new Migrations(client);
const result = await migrations.createCSVExport({
resourceId: '<ID1:ID2>',
bucketId: '<BUCKET_ID>',
filename: '<FILENAME>',
columns: [], // optional
queries: [], // optional
@@ -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.
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.
@@ -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;
}
}
@@ -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
@@ -154,6 +154,8 @@ class Decrement extends Action
);
});
$queueForEvents->reset();
// Return successful response without actually decrementing
$groupId = $this->getGroupId();
$mockDocument = new Document([
@@ -154,6 +154,8 @@ class Increment extends Action
);
});
$queueForEvents->reset();
// Return successful response without actually incrementing
$groupId = $this->getGroupId();
$mockDocument = new Document([
@@ -151,6 +151,8 @@ class Delete extends Action
);
});
$queueForEvents->reset();
// Return successful response without actually deleting documents
$response->dynamic(new Document([
$this->getSDKGroup() => [],
@@ -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() => [],
@@ -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() => [],
@@ -419,6 +419,8 @@ class Create extends Action
);
});
$queueForEvents->reset();
// Return successful response without actually creating documents
if ($isBulk) {
$response->dynamic(new Document([
@@ -179,6 +179,8 @@ class Delete extends Action
);
});
$queueForEvents->reset();
// Return successful response without actually deleting document
$response->noContent();
return;
@@ -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());
@@ -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([
@@ -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;
@@ -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);
@@ -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();
+38
View File
@@ -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
+27 -14
View File
@@ -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;
}
@@ -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');
}
}
@@ -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']
]);
}
}