mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge branch '1.8.x' into refactor-auth-single-instance
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-51
@@ -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
|
||||
|
||||
+2
@@ -154,6 +154,8 @@ class Decrement extends Action
|
||||
);
|
||||
});
|
||||
|
||||
$queueForEvents->reset();
|
||||
|
||||
// Return successful response without actually decrementing
|
||||
$groupId = $this->getGroupId();
|
||||
$mockDocument = new Document([
|
||||
|
||||
+2
@@ -154,6 +154,8 @@ class Increment extends Action
|
||||
);
|
||||
});
|
||||
|
||||
$queueForEvents->reset();
|
||||
|
||||
// Return successful response without actually incrementing
|
||||
$groupId = $this->getGroupId();
|
||||
$mockDocument = new Document([
|
||||
|
||||
+2
@@ -151,6 +151,8 @@ class Delete extends Action
|
||||
);
|
||||
});
|
||||
|
||||
$queueForEvents->reset();
|
||||
|
||||
// Return successful response without actually deleting documents
|
||||
$response->dynamic(new Document([
|
||||
$this->getSDKGroup() => [],
|
||||
|
||||
+5
-1
@@ -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() => [],
|
||||
|
||||
+5
-1
@@ -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() => [],
|
||||
|
||||
+2
@@ -419,6 +419,8 @@ class Create extends Action
|
||||
);
|
||||
});
|
||||
|
||||
$queueForEvents->reset();
|
||||
|
||||
// Return successful response without actually creating documents
|
||||
if ($isBulk) {
|
||||
$response->dynamic(new Document([
|
||||
|
||||
+2
@@ -179,6 +179,8 @@ class Delete extends Action
|
||||
);
|
||||
});
|
||||
|
||||
$queueForEvents->reset();
|
||||
|
||||
// Return successful response without actually deleting document
|
||||
$response->noContent();
|
||||
return;
|
||||
|
||||
+6
-2
@@ -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());
|
||||
|
||||
+5
-1
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user