diff --git a/app/config/specs/open-api3-latest-client.json b/app/config/specs/open-api3-latest-client.json index 0a38f16346..55be1565b7 100644 --- a/app/config/specs/open-api3-latest-client.json +++ b/app/config/specs/open-api3-latest-client.json @@ -4620,6 +4620,14 @@ "description": "Document data as JSON object.", "x-example": "{}" }, + "documents": { + "type": "array", + "description": "Array of documents data as JSON object.", + "x-example": null, + "items": { + "type": "object" + } + }, "permissions": { "type": "array", "description": "An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", @@ -4628,11 +4636,7 @@ "type": "string" } } - }, - "required": [ - "documentId", - "data" - ] + } } } } diff --git a/app/config/specs/open-api3-latest-console.json b/app/config/specs/open-api3-latest-console.json index 00ac994446..a98e06dc69 100644 --- a/app/config/specs/open-api3-latest-console.json +++ b/app/config/specs/open-api3-latest-console.json @@ -8189,6 +8189,14 @@ "description": "Document data as JSON object.", "x-example": "{}" }, + "documents": { + "type": "array", + "description": "Array of documents data as JSON object.", + "x-example": null, + "items": { + "type": "object" + } + }, "permissions": { "type": "array", "description": "An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", @@ -8197,11 +8205,7 @@ "type": "string" } } - }, - "required": [ - "documentId", - "data" - ] + } } } } diff --git a/app/config/specs/open-api3-latest-server.json b/app/config/specs/open-api3-latest-server.json index cf76b72014..2c1e276c07 100644 --- a/app/config/specs/open-api3-latest-server.json +++ b/app/config/specs/open-api3-latest-server.json @@ -7718,6 +7718,14 @@ "description": "Document data as JSON object.", "x-example": "{}" }, + "documents": { + "type": "array", + "description": "Array of documents data as JSON object.", + "x-example": null, + "items": { + "type": "object" + } + }, "permissions": { "type": "array", "description": "An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", @@ -7726,11 +7734,7 @@ "type": "string" } } - }, - "required": [ - "documentId", - "data" - ] + } } } } diff --git a/app/config/specs/swagger2-latest-client.json b/app/config/specs/swagger2-latest-client.json index 7a59bb87ea..8dce505cac 100644 --- a/app/config/specs/swagger2-latest-client.json +++ b/app/config/specs/swagger2-latest-client.json @@ -4776,15 +4776,24 @@ "documentId": { "type": "string", "description": "Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can't start with a special char. Max length is 36 chars.", - "default": null, + "default": "", "x-example": "" }, "data": { "type": "object", "description": "Document data as JSON object.", - "default": {}, + "default": [], "x-example": "{}" }, + "documents": { + "type": "array", + "description": "Array of documents data as JSON object.", + "default": [], + "x-example": null, + "items": { + "type": "object" + } + }, "permissions": { "type": "array", "description": "An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", @@ -4794,11 +4803,7 @@ "type": "string" } } - }, - "required": [ - "documentId", - "data" - ] + } } } ] diff --git a/app/config/specs/swagger2-latest-console.json b/app/config/specs/swagger2-latest-console.json index 52473c5538..a09e8999fb 100644 --- a/app/config/specs/swagger2-latest-console.json +++ b/app/config/specs/swagger2-latest-console.json @@ -8332,15 +8332,24 @@ "documentId": { "type": "string", "description": "Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can't start with a special char. Max length is 36 chars.", - "default": null, + "default": "", "x-example": "" }, "data": { "type": "object", "description": "Document data as JSON object.", - "default": {}, + "default": [], "x-example": "{}" }, + "documents": { + "type": "array", + "description": "Array of documents data as JSON object.", + "default": [], + "x-example": null, + "items": { + "type": "object" + } + }, "permissions": { "type": "array", "description": "An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", @@ -8350,11 +8359,7 @@ "type": "string" } } - }, - "required": [ - "documentId", - "data" - ] + } } } ] diff --git a/app/config/specs/swagger2-latest-server.json b/app/config/specs/swagger2-latest-server.json index b9c2e86451..d0d6eb530d 100644 --- a/app/config/specs/swagger2-latest-server.json +++ b/app/config/specs/swagger2-latest-server.json @@ -7848,15 +7848,24 @@ "documentId": { "type": "string", "description": "Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can't start with a special char. Max length is 36 chars.", - "default": null, + "default": "", "x-example": "" }, "data": { "type": "object", "description": "Document data as JSON object.", - "default": {}, + "default": [], "x-example": "{}" }, + "documents": { + "type": "array", + "description": "Array of documents data as JSON object.", + "default": [], + "x-example": null, + "items": { + "type": "object" + } + }, "permissions": { "type": "array", "description": "An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https:\/\/appwrite.io\/docs\/permissions).", @@ -7866,11 +7875,7 @@ "type": "string" } } - }, - "required": [ - "documentId", - "data" - ] + } } } ] diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 099dfef8a8..593e3b39d9 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -2845,7 +2845,6 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') ->alias('/v1/database/collections/:collectionId/documents', ['databaseId' => 'default']) ->desc('Create document') ->groups(['api', 'database']) - ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].create') ->label('scope', 'documents.write') ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'document.create') @@ -2863,26 +2862,41 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') ->label('sdk.offline.model', '/databases/{databaseId}/collections/{collectionId}/documents') ->label('sdk.offline.key', '{documentId}') ->param('databaseId', '', new UID(), 'Database ID.') - ->param('documentId', '', new CustomId(), 'Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('documentId', '', new CustomId(), 'Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true) ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define attributes before creating documents.') - ->param('data', [], new JSON(), 'Document data as JSON object.') + ->param('data', [], new JSON(), 'Document data as JSON object.', true) + ->param('documents', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_DOCUMENTS_SIZE), 'Array of documents data as JSON object.', true) ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->inject('response') ->inject('dbForProject') ->inject('user') ->inject('queueForEvents') ->inject('queueForUsage') + ->inject('project') ->inject('mode') - ->action(function (string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, Response $response, Database $dbForProject, Document $user, Event $queueForEvents, Usage $queueForUsage, string $mode) { - + ->action(function (string $databaseId, ?string $documentId, string $collectionId, string|array|null $data, ?array $documents, ?array $permissions, Response $response, Database $dbForProject, Document $user, Event $queueForEvents, Usage $queueForUsage, Document $project, string $mode) { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array + $isBulk = true; - if (empty($data)) { + if (empty($data) && empty($documents)) { throw new Exception(Exception::DOCUMENT_MISSING_DATA); } - if (isset($data['$id'])) { - throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, '$id is not allowed for creating new documents, try update instead'); + if (!empty($data) && !empty($documents)) { + throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, 'You can only send one of the following parameters: data, documents'); + } + + if (!empty($data) && empty($documentId)) { + throw new Exception(Exception::DOCUMENT_MISSING_DATA, 'Document ID is required when creating a single document'); + } + + if (!empty($documents) && !empty($documentId)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "documentId" is disallowed when creating multiple documents, use $id inside the documents'); + } + + if (!empty($data)) { + $isBulk = false; + $documents = [$data]; } $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -2906,43 +2920,46 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') Database::PERMISSION_DELETE, ]; - // Map aggregate permissions to into the set of individual permissions they represent. - $permissions = Permission::aggregate($permissions, $allowedPermissions); - - // Add permissions for current the user if none were provided. - if (\is_null($permissions)) { - $permissions = []; - if (!empty($user->getId())) { - foreach ($allowedPermissions as $permission) { - $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); - } + $setPermissions = function (Document $document, ?array $permissions) use ($user, $allowedPermissions, $isAPIKey, $isPrivilegedUser, $isBulk) { + // Map aggregate permissions to into the set of individual permissions they represent. + if ($isBulk) { + $permissions = $document['$permissions'] ?? null; } - } - // Users can only manage their own roles, API keys and Admin users can manage any - if (!$isAPIKey && !$isPrivilegedUser) { - foreach (Database::PERMISSIONS as $type) { - foreach ($permissions as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() != $type) { - continue; - } - $role = (new Role( - $permission->getRole(), - $permission->getIdentifier(), - $permission->getDimension() - ))->toString(); - if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', Authorization::getRoles()) . ')'); + $permissions = Permission::aggregate($permissions, $allowedPermissions); + + // Add permissions for current the user if none were provided. + if (\is_null($permissions)) { + $permissions = []; + if (!empty($user->getId())) { + foreach ($allowedPermissions as $permission) { + $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); } } } - } - $data['$collection'] = $collection->getId(); // Adding this param to make API easier for developers - $data['$id'] = $documentId == 'unique()' ? ID::unique() : $documentId; - $data['$permissions'] = $permissions; - $document = new Document($data); + // Users can only manage their own roles, API keys and Admin users can manage any + if (!$isAPIKey && !$isPrivilegedUser) { + foreach (Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + if (!Authorization::isRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', Authorization::getRoles()) . ')'); + } + } + } + } + + $document->setAttribute('$permissions', $permissions); + }; $checkPermissions = function (Document $collection, Document $document, string $permission) use (&$checkPermissions, $dbForProject, $database) { $documentSecurity = $collection->getAttribute('documentSecurity', false); @@ -3024,10 +3041,29 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') } }; - $checkPermissions($collection, $document, Database::PERMISSION_CREATE); + $documents = array_map(function ($document) use ($collection, $permissions, $checkPermissions, $isBulk, $documentId, $setPermissions) { + $document['$collection'] = $collection->getId(); + + if (!$isBulk) { + $document['$id'] = $documentId == 'unique()' ? ID::unique() : $documentId; + } else { + if (empty($document['$id'])) { + throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, '$id is required inside documents when creating bulk documents'); + } + + $document['$id'] = $document['$id'] == 'unique()' ? ID::unique() : $document['$id']; + } + + $document = new Document($document); + + $setPermissions($document, $permissions); + $checkPermissions($collection, $document, Database::PERMISSION_CREATE); + + return $document; + }, $documents); try { - $document = $dbForProject->createDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $document); + $dbForProject->createDocuments('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documents); } catch (StructureException $e) { throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage()); } catch (DuplicateException $e) { @@ -3036,8 +3072,15 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') throw new Exception(Exception::COLLECTION_NOT_FOUND); } + $queueForEvents + ->setParam('databaseId', $databaseId) + ->setParam('collectionId', $collection->getId()) + ->setContext('collection', $collection) + ->setContext('database', $database); + // Add $collectionId and $databaseId for all documents - $processDocument = function (Document $collection, Document $document) use (&$processDocument, $dbForProject, $database) { + $processDocument = function (Document $collection, Document &$document) use (&$processDocument, $dbForProject, $database) { + $document->removeAttribute('$collection'); $document->setAttribute('$databaseId', $database->getId()); $document->setAttribute('$collectionId', $collection->getId()); @@ -3069,28 +3112,26 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') } }; - $processDocument($collection, $document); + foreach ($documents as $document) { + $processDocument($collection, $document); + } - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($document, Response::MODEL_DOCUMENT); - - $relationships = \array_map( - fn ($document) => $document->getAttribute('key'), - \array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP - ) - ); - - $queueForEvents - ->setParam('databaseId', $databaseId) - ->setParam('collectionId', $collection->getId()) - ->setParam('documentId', $document->getId()) - ->setContext('collection', $collection) - ->setContext('database', $database) - ->setPayload($response->getPayload(), sensitive: $relationships); + if ($isBulk) { + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document([ + 'total' => count($documents), + 'documents' => $documents + ]), Response::MODEL_DOCUMENT_LIST); + } else { + $queueForEvents + ->setParam('documentId', $document->getId()) + ->setEvent('databases.[databaseId].collections.[collectionId].documents.[documentId].create'); + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($documents[0], Response::MODEL_DOCUMENT); + } $queueForUsage ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getInternalId(), $collection->getInternalId()], METRIC_DATABASE_ID_COLLECTION_ID_STORAGE), 1); // per collection @@ -3173,7 +3214,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') return false; } - $document->removeAttribute('$collection'); $document->setAttribute('$databaseId', $database->getId()); $document->setAttribute('$collectionId', $collection->getId()); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index c5771fb921..a49ad11a10 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -503,6 +503,7 @@ App::init() $dbForProject ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForUsage)) + ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForUsage)) ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', fn ($event, $document) => $usageDatabaseListener($event, $document, $queueForUsage)) ->on(Database::EVENT_DOCUMENT_CREATE, 'create-trigger-events', fn ($event, $document) => $eventDatabaseListener( $document, diff --git a/app/init.php b/app/init.php index c9ec2e0061..d2ba5df7b7 100644 --- a/app/init.php +++ b/app/init.php @@ -113,6 +113,7 @@ const APP_LIMIT_COMPRESSION = 20_000_000; //20MB const APP_LIMIT_ARRAY_PARAMS_SIZE = 100; // Default maximum of how many elements can there be in API parameter that expects array value const APP_LIMIT_ARRAY_LABELS_SIZE = 1000; // Default maximum of how many labels elements can there be in API parameter that expects array value const APP_LIMIT_ARRAY_ELEMENT_SIZE = 4096; // Default maximum length of element in array parameter represented by maximum URL length. +const APP_LIMIT_ARRAY_DOCUMENTS_SIZE = 10_000; // Default maximum of how many documents can be inserted in a single request const APP_LIMIT_SUBQUERY = 1000; const APP_LIMIT_SUBSCRIBERS_SUBQUERY = 1_000_000; const APP_LIMIT_WRITE_RATE_DEFAULT = 60; // Default maximum write rate per rate period diff --git a/tests/benchmarks/bulk-operations.js b/tests/benchmarks/bulk-operations.js new file mode 100644 index 0000000000..a60129735a --- /dev/null +++ b/tests/benchmarks/bulk-operations.js @@ -0,0 +1,32 @@ +import { check } from "k6"; +import http from "k6/http"; + +const amount = 100_000; +const databaseId = "674918b20017411b94b2"; +const collectionId = "674918b4002b46c47d5d"; + +const documents = Array(amount).fill({ + $id: "unique()", + name: "asd", +}); + +export default function () { + const payload = JSON.stringify({ + documents, + }); + + const res = http.post(`http://localhost/v1/databases/${databaseId}/collections/${collectionId}/documents`, + payload, + { + headers: { + "X-Appwrite-Key": "standard_fa89c4834660f39e95ca2c2996fe7dd4ff498725e37c09323234c009570c1719f1c10610bf3541cf9ead120c107e41397a4eae1c787c83bdf577857bbc5963341641c77f582cc41e11a0d50eb4c2e4b1fda74418a8b9a253d6e63008e33560ba35310b9dc2fed5f09ca599e646f744cc6308b8ccd27ff04f9e498ec5a5f2c3db", + "X-Appwrite-Project": "674818bc0017934d58dd", + "Content-Type": "application/json", + }, + } + ); + + check(res, { + "status is 200": (r) => r.status === 201, + }); +} diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index c262abfab1..39f34d09f5 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -4800,6 +4800,310 @@ trait DatabasesBase ])); } + public function testBulkCreate(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Bulk Create Perms', + ]); + + $this->assertNotEmpty($database['body']['$id']); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Bulk Create Perms', + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + + $data = [ + '$id' => $collection['body']['$id'], + 'databaseId' => $collection['body']['databaseId'] + ]; + + // Await attribute + $numberAttribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $data['$id'] . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'number', + 'required' => true, + ]); + + $this->assertEquals(202, $numberAttribute['headers']['status-code']); + + sleep(1); + + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'number' => 1, + ], + [ + '$id' => ID::unique(), + 'number' => 2, + ], + [ + '$id' => ID::unique(), + 'number' => 3, + ], + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertCount(3, $response['body']['documents']); + + $response = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(3, $response['body']['documents']); + + // TEST FAIL - Can't use data and document together + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'number' => 5 + ], + 'documents' => [ + [ + '$id' => ID::unique(), + 'number' => 1, + ] + ], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('You can only send one of the following parameters: data, documents', $response['body']['message']); + + // TEST FAIL - Can't use $documentId and create bulk documents + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'documents' => [ + [ + '$id' => ID::unique(), + 'number' => 1, + ] + ], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // TEST FAIL - Can't miss $id in bulk documents + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => [ + [ + 'number' => 1, + ] + ], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('$id is required inside documents when creating bulk documents', $response['body']['message']); + + // TEST FAIL - Can't miss number in bulk documents + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'number' => 1, + ], + [ + '$id' => ID::unique(), + ], + ], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // TEST FAIL - Can't push more than 10000 documents + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => array_fill(0, 10001, [ + '$id' => ID::unique(), + 'number' => 1, + ]), + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('Invalid `documents` param: Value must a valid array no longer than 10000 items and Value must be a valid JSON string', $response['body']['message']); + } + + public function testBulkCreateRelationships(): void + { + $database = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Bulk Creates Relationships' + ]); + + $this->assertNotEmpty($database['body']['$id']); + + $databaseId = $database['body']['$id']; + + $collection1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Collection1', + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collection2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Collection2', + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collection1 = $collection1['body']['$id']; + $collection2 = $collection2['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collection1 . '/attributes/relationship', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'relatedCollectionId' => $collection2, + 'type' => Database::RELATION_ONE_TO_MANY, + 'key' => 'collection2', + 'onDelete' => Database::RELATION_MUTATE_RESTRICT, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collection1 . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collection2 . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + sleep(3); + + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collection1}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Document 1', + 'collection2' => [ + [ + '$id' => ID::unique(), + 'name' => 'Document 2', + ], + [ + '$id' => ID::unique(), + 'name' => 'Document 3', + ], + ], + ], + [ + '$id' => ID::unique(), + 'name' => 'Document 2', + 'collection2' => [ + [ + '$id' => ID::unique(), + 'name' => 'Document 4', + ], + [ + '$id' => ID::unique(), + 'name' => 'Document 5', + ], + ], + ], + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['documents']); + + $response = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collection1}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(2, $response['body']['total']); + + $response = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collection2}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(4, $response['body']['total']); + } + public function testBulkDeletes(): void { // Create database @@ -4855,19 +5159,23 @@ trait DatabasesBase // Create documents $createBulkDocuments = function ($amount = 10) use ($data) { - for ($x = 0; $x <= $amount; $x++) { - $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $data['databaseId'] . '/collections/' . $data['$id'] . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'number' => $x, - ], - ]); + $documents = []; - $this->assertEquals(201, $doc['headers']['status-code']); + for ($x = 0; $x <= $amount; $x++) { + $documents[] = [ + '$id' => ID::unique(), + 'number' => $x, + ]; } + + $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $data['databaseId'] . '/collections/' . $data['$id'] . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => $documents, + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); }; $createBulkDocuments(); @@ -5503,19 +5811,23 @@ trait DatabasesBase // Create documents $createBulkDocuments = function ($amount = 10) use ($data) { - for ($x = 1; $x <= $amount; $x++) { - $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $data['databaseId'] . '/collections/' . $data['$id'] . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'number' => $x, - ], - ]); + $documents = []; - $this->assertEquals(201, $doc['headers']['status-code']); + for ($x = 1; $x <= $amount; $x++) { + $documents[] = [ + '$id' => ID::unique(), + 'number' => $x, + ]; } + + $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $data['databaseId'] . '/collections/' . $data['$id'] . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => $documents, + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); }; $createBulkDocuments(); diff --git a/tests/e2e/Services/Databases/DatabasesCustomClientTest.php b/tests/e2e/Services/Databases/DatabasesCustomClientTest.php index 12f5c0216d..279657201b 100644 --- a/tests/e2e/Services/Databases/DatabasesCustomClientTest.php +++ b/tests/e2e/Services/Databases/DatabasesCustomClientTest.php @@ -892,6 +892,219 @@ class DatabasesCustomClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); } + public function testBulkCreatePermissions(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Bulk Create' + ]); + + $this->assertNotEmpty($database['body']['$id']); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Bulk Create', + 'documentSecurity' => false, + 'permissions' => [ + Permission::read(Role::any()), + Permission::delete(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + + + $data = [ + '$id' => $collection['body']['$id'], + 'databaseId' => $collection['body']['databaseId'] + ]; + + // Await attribute + $numberAttribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $data['$id'] . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'number', + 'required' => true, + ]); + + $this->assertEquals(202, $numberAttribute['headers']['status-code']); + + sleep(2); + + // TEST FAIL - Can't create document with missing collection level permissions + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'number' => 1, + ], + [ + '$id' => ID::unique(), + 'number' => 2, + ], + [ + '$id' => ID::unique(), + 'number' => 3, + ], + ], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $response['body']['message']); + + // TEST FAIL - Can't create document with missing document level permissions + $collection = $this->client->call(Client::METHOD_PUT, '/databases/' . $data['databaseId'] . '/collections/' . $data['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'Bulk Creates Perms', + 'documentSecurity' => true + ]); + + $this->assertEquals(200, $collection['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'number' => 1, + ], + [ + '$id' => ID::unique(), + 'number' => 2, + ], + [ + '$id' => ID::unique(), + 'number' => 3, + ], + ], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals('The current user is not authorized to perform the requested action.', $response['body']['message']); + + // TEST FAIL - Can't create document with permissions that aren't our own. + $collection = $this->client->call(Client::METHOD_PUT, '/databases/' . $data['databaseId'] . '/collections/' . $data['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'Bulk Creates Perms', + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertEquals(200, $collection['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'number' => 1, + '$permissions' => [ + Permission::create(Role::user('aaaaaa')), + Permission::read(Role::user('aaaaaa')), + Permission::update(Role::user('aaaaaa')), + Permission::delete(Role::user('aaaaaa')), + ] + ], + [ + '$id' => ID::unique(), + 'number' => 2, + '$permissions' => [ + Permission::create(Role::user('aaaaaa')), + Permission::read(Role::user('aaaaaa')), + Permission::update(Role::user('aaaaaa')), + Permission::delete(Role::user('aaaaaa')), + ] + ], + [ + '$id' => ID::unique(), + 'number' => 3, + '$permissions' => [ + Permission::create(Role::user('aaaaaa')), + Permission::read(Role::user('aaaaaa')), + Permission::update(Role::user('aaaaaa')), + Permission::delete(Role::user('aaaaaa')), + ] + ], + ], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // TEST SUCCESS - Can create document with our own permissions. + $response = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$data['$id']}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'number' => 1, + '$permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ], + [ + '$id' => ID::unique(), + 'number' => 2, + '$permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ], + [ + '$id' => ID::unique(), + 'number' => 3, + '$permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ], + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertCount(3, $response['body']['documents']); + } + // Bulk Updates public function testBulkUpdatesPermissions(): void {