From 7169e672459468c3ec5d3f9043cd6a7147f5c574 Mon Sep 17 00:00:00 2001 From: Darshan Date: Wed, 11 Jun 2025 20:05:09 +0530 Subject: [PATCH] add/update: `createDocuments` on module structure. --- .../Collections/Documents/Action.php | 25 +- .../Collections/Documents/Create.php | 269 +++++++++++++----- .../Http/Databases/Tables/Rows/Create.php | 38 ++- 3 files changed, 257 insertions(+), 75 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 32bb314cd2..c4e41d415f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -17,6 +17,11 @@ abstract class Action extends UtopiaAction */ abstract protected function getResponseModel(): string; + /** + * Get the response model used in the SDK and HTTP responses for bulk action. + */ + abstract protected function getBulkResponseModel(): string; + /** * Set the context to either `row` or `document`. * @@ -25,12 +30,22 @@ abstract class Action extends UtopiaAction final protected function setContext(string $context): void { if (!\in_array($context, [DATABASE_ROWS_CONTEXT, DATABASE_DOCUMENTS_CONTEXT], true)) { - throw new \InvalidArgumentException("Invalid context '{$context}'. Use `DATABASE_ROWS_CONTEXT` or `DATABASE_DOCUMENTS_CONTEXT`"); + throw new \InvalidArgumentException("Invalid context '$context'. Use `DATABASE_ROWS_CONTEXT` or `DATABASE_DOCUMENTS_CONTEXT`"); } $this->context = $context; } + /** + * Get the plural of the given name. + * + * Used for endpoints with multiple sdk methods. + */ + final protected function getBulkActionName(string $name): string + { + return "{$name}s"; + } + /** * Get the current context. */ @@ -67,6 +82,14 @@ abstract class Action extends UtopiaAction return $this->isCollectionsAPI() ? 'collections' : 'tables'; } + /** + * Get the correct attribute/column structure context for errors. + */ + final protected function getStructureContext(): string + { + return $this->isCollectionsAPI() ? 'attributes' : 'columns'; + } + /** * Get the appropriate parent level not found exception. */ diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index aef2ef4ace..b55731a4f2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -9,6 +9,7 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; +use Appwrite\SDK\Parameter; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response as UtopiaResponse; @@ -26,6 +27,7 @@ use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; use Utopia\Platform\Scope\HTTP; use Utopia\Swoole\Response as SwooleResponse; +use Utopia\Validator\ArrayList; use Utopia\Validator\JSON; class Create extends Action @@ -42,6 +44,11 @@ class Create extends Action return UtopiaResponse::MODEL_DOCUMENT; } + protected function getBulkResponseModel(): string + { + return UtopiaResponse::MODEL_DOCUMENT_LIST; + } + public function __construct() { $this @@ -70,14 +77,41 @@ class Create extends Action model: $this->getResponseModel(), ) ], - contentType: ContentType::JSON + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documentId', optional: false), + new Parameter('data', optional: false), + new Parameter('permissions', optional: true), + ] + ), + new Method( + namespace: $this->getSdkNamespace(), + group: $this->getSdkGroup(), + name: $this->getBulkActionName(self::getName()), + description: '/docs/references/databases/create-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getBulkResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documents', optional: false), + ] ) ]) ->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('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) + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan']) ->inject('response') ->inject('dbForProject') ->inject('user') @@ -86,77 +120,128 @@ class Create extends Action ->callback([$this, 'action']); } - public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage): void { - $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array + $data = \is_string($data) + ? \json_decode($data, true) + : $data; - if (empty($data)) { + /** + * Determine which internal path to call, single or bulk + */ + if (empty($data) && empty($documents)) { + // No single or bulk documents provided throw new Exception($this->getMissingDataException()); } - - if (isset($data['$id'])) { - // `rows` or `documents` in message. - throw new Exception($this->getInvalidStructureException(), '$id is not allowed for creating new ' . $this->getContext() . 's, try update instead'); + if (!empty($data) && !empty($documents)) { + // Both single and bulk documents provided + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'You can only send one of the following parameters: data, ' . $this->getSdkGroup()); + } + if (!empty($data) && empty($documentId)) { + // Single document provided without document ID + $document = $this->isCollectionsAPI() ? 'Document' : 'Row'; + $message = "$document ID is required when creating a single " . strtolower($document) . '.'; + throw new Exception($this->getMissingDataException(), $message); + } + if (!empty($documents) && !empty($documentId)) { + // Bulk documents provided with document ID + $documentId = $this->isCollectionsAPI() ? 'documentId' : 'rowId'; + throw new Exception( + Exception::GENERAL_BAD_REQUEST, + "Param \"$documentId\" is not allowed when creating multiple " . $this->getSdkGroup() . ', set "$id" on each instead.' + ); + } + if (!empty($documents) && !empty($permissions)) { + // Bulk documents provided with permissions + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "permissions" is disallowed when creating multiple ' . $this->getSdkGroup() . ', set "$permissions" on each instead'); } - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $isBulk = true; + if (!empty($data)) { + // Single document provided, convert to single item array + // But remember that it was single to respond with a single document + $isBulk = false; + $documents = [$data]; + } $isAPIKey = Auth::isAppUser(Authorization::getRoles()); $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + if ($isBulk && !$isAPIKey && !$isPrivilegedUser) { + throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE); + } + + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::DATABASE_NOT_FOUND); } $collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception($this->getParentNotFoundException()); } - $allowedPermissions = [ - Database::PERMISSION_READ, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE, - ]; + $hasRelationships = \array_filter( + $collection->getAttribute('attributes', []), + fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP + ); - // 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(); - } - } + if ($isBulk && $hasRelationships) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Bulk create is not supported for ' . $this->getSdkNamespace() .' with relationship ' . $this->getStructureContext()); } - // 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()) . ')'); + $setPermissions = function (Document $document, ?array $permissions) use ($user, $isAPIKey, $isPrivilegedUser, $isBulk) { + $allowedPermissions = [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]; + + // If bulk, we need to validate permissions explicitly per document + if ($isBulk) { + $permissions = $document['$permissions'] ?? null; + if (!empty($permissions)) { + $validator = new Permissions(); + if (!$validator->isValid($permissions)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, $validator->getDescription()); } } } - } - $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); + $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(); + } + } + } + + // 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); + }; $operations = 0; @@ -242,10 +327,36 @@ class Create extends Action } }; - $checkPermissions($collection, $document, Database::PERMISSION_CREATE); + $documents = \array_map(function ($document) use ($collection, $permissions, $checkPermissions, $isBulk, $documentId, $setPermissions) { + $document['$collection'] = $collection->getId(); + + // Determine the source ID depending on whether it's a bulk operation. + $sourceId = $isBulk + ? ($document['$id'] ?? ID::unique()) + : $documentId; + + // If bulk, we need to validate ID explicitly + if ($isBulk) { + $validator = new CustomId(); + if (!$validator->isValid($sourceId)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, $validator->getDescription()); + } + } + + // Assign a unique ID if needed, otherwise use the provided ID. + $document['$id'] = $sourceId === 'unique()' ? ID::unique() : $sourceId; + $document = new Document($document); + $setPermissions($document, $permissions); + $checkPermissions($collection, $document, Database::PERMISSION_CREATE); + + return $document; + }, $documents); try { - $document = $dbForProject->createDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $document); + $dbForProject->createDocuments( + 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), + $documents + ); } catch (DuplicateException) { throw new Exception($this->getDuplicateException()); } catch (NotFoundException) { @@ -256,8 +367,22 @@ class Create extends Action throw new Exception($this->getInvalidStructureException(), $e->getMessage()); } + $queueForEvents + ->setParam('databaseId', $databaseId) + ->setParam('collectionId', $collection->getId()) + ->setContext('collection', $collection) + ->setContext('database', $database); + + $queueForEvents + ->setParam('databaseId', $databaseId) + ->setContext('database', $database) + ->setParam('collectionId', $collection->getId()) + ->setParam('tableId', $collection->getId()) + ->setContext($this->getCollectionsEventsContext(), $collection); + // Add $collectionId and $databaseId for all documents $processDocument = function (Document $table, Document $document) use (&$processDocument, $dbForProject, $database) { + $document->removeAttribute('$collection'); $document->setAttribute('$databaseId', $database->getId()); $document->setAttribute('$collectionId', $table->getId()); @@ -289,34 +414,34 @@ class Create extends Action } }; - $processDocument($collection, $document); + foreach ($documents as $document) { + $processDocument($collection, $document); + } $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); // per collection + ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations)); // per collection - $response->addHeader('X-Debug-Operations', $operations); + $response->setStatusCode(SwooleResponse::STATUS_CODE_CREATED); - $response - ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) - ->dynamic($document, $this->getResponseModel()); + if ($isBulk) { + $response->dynamic(new Document([ + 'total' => count($documents), + $this->getSdkGroup() => $documents + ]), $this->getBulkResponseModel()); - $relationships = \array_map( - fn ($document) => $document->getAttribute('key'), - \array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP - ) - ); + return; + } $queueForEvents - ->setParam('databaseId', $databaseId) - ->setContext('database', $database) - ->setParam('collectionId', $collection->getId()) - ->setParam('tableId', $collection->getId()) - ->setParam('documentId', $document->getId()) - ->setParam('rowId', $document->getId()) - ->setPayload($response->getPayload(), sensitive: $relationships) - ->setContext($this->getCollectionsEventsContext(), $collection); + ->setParam('documentId', $documents[0]->getId()) + ->setParam('rowId', $documents[0]->getId()) + // TODO: @itznotabug - check if the events mirroring works here! + ->setEvent('databases.[databaseId].collections.[collectionId].documents.[documentId].create'); + + $response->dynamic( + $documents[0], + $this->getResponseModel() + ); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Tables/Rows/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Tables/Rows/Create.php index d041630894..881e562c10 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Tables/Rows/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Tables/Rows/Create.php @@ -6,6 +6,7 @@ use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Cre use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; +use Appwrite\SDK\Parameter; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response as UtopiaResponse; @@ -14,6 +15,7 @@ use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; use Utopia\Platform\Scope\HTTP; use Utopia\Swoole\Response as SwooleResponse; +use Utopia\Validator\ArrayList; use Utopia\Validator\JSON; class Create extends DocumentCreate @@ -30,6 +32,11 @@ class Create extends DocumentCreate return UtopiaResponse::MODEL_ROW; } + protected function getBulkResponseModel(): string + { + return UtopiaResponse::MODEL_ROW_LIST; + } + public function __construct() { $this->setContext(DATABASE_ROWS_CONTEXT); @@ -57,10 +64,36 @@ class Create extends DocumentCreate responses: [ new SDKResponse( code: SwooleResponse::STATUS_CODE_CREATED, - model: self::getResponseModel(), + model: $this->getResponseModel(), ) ], - contentType: ContentType::JSON + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('tableId', optional: false), + new Parameter('rowId', optional: false), + new Parameter('data', optional: false), + new Parameter('permissions', optional: true), + ] + ), + new Method( + namespace: $this->getSdkNamespace(), + group: $this->getSdkGroup(), + name: $this->getBulkActionName(self::getName()), + description: '/docs/references/databases/create-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getBulkResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('tableId', optional: false), + new Parameter('rows', optional: false), + ] ) ]) ->param('databaseId', '', new UID(), 'Database ID.') @@ -68,6 +101,7 @@ class Create extends DocumentCreate ->param('tableId', '', new UID(), 'Table ID. You can create a new table using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define columns before creating rows.') ->param('data', [], new JSON(), 'Row data as JSON object.') ->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) + ->param('rows', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan']) ->inject('response') ->inject('dbForProject') ->inject('user')