add: upsert for collections and tables.

This commit is contained in:
Darshan 2025-06-12 14:31:28 +05:30
parent 7beae21535
commit 95bc85f18d
4 changed files with 379 additions and 0 deletions

View file

@ -0,0 +1,297 @@
<?php
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents;
use Appwrite\Auth\Auth;
use Appwrite\Event\Event;
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Conflict as ConflictException;
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Exception\Relationship as RelationshipException;
use Utopia\Database\Exception\Structure as StructureException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Permissions;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Swoole\Response as SwooleResponse;
use Utopia\Validator\JSON;
class Upsert extends Action
{
use HTTP;
public static function getName(): string
{
return 'upsertDocument';
}
protected function getResponseModel(): string
{
return UtopiaResponse::MODEL_DOCUMENT;
}
public function __construct()
{
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId')
->desc('Upsert document')
->groups(['api', 'database'])
->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert')
->label('scope', 'documents.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'document.upsert')
->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}')
->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', [
new Method(
namespace: $this->getSdkNamespace(),
group: $this->getSdkGroup(),
name: self::getName(),
description: '/docs/references/databases/upsert-document.md',
auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_CREATED,
model: $this->getResponseModel(),
)
],
contentType: ContentType::JSON
),
])
->param('databaseId', '', new UID(), 'Database ID.')
->param('collectionId', '', new UID(), 'Collection ID.')
->param('documentId', '', new CustomId(), 'Document ID.')
->param('data', [], new JSON(), 'Document data as JSON object. Include all required attributes of the document to be created or updated.')
->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, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
->inject('requestTimestamp')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->callback($this->action(...));
}
public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void
{
$data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array
if (empty($data) && \is_null($permissions)) {
throw new Exception($this->getMissingPayloadException());
}
$isAPIKey = Auth::isAppUser(Authorization::getRoles());
$isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles());
$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());
}
// Map aggregate permissions into the multiple permissions they represent.
$permissions = Permission::aggregate($permissions, [
Database::PERMISSION_READ,
Database::PERMISSION_UPDATE,
Database::PERMISSION_DELETE,
]);
// Users can only manage their own roles, API keys and Admin users can manage any
$roles = Authorization::getRoles();
if (!$isAPIKey && !$isPrivilegedUser && !\is_null($permissions)) {
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(', ', $roles) . ')');
}
}
}
}
$data['$id'] = $documentId;
$data['$permissions'] = $permissions;
$newDocument = new Document($data);
$operations = 0;
$setCollection = (function (Document $collection, Document $document) use (&$setCollection, $dbForProject, $database, &$operations) {
$operations++;
$relationships = \array_filter(
$collection->getAttribute('attributes', []),
fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP
);
foreach ($relationships as $relationship) {
$related = $document->getAttribute($relationship->getAttribute('key'));
if (empty($related)) {
continue;
}
$isList = \is_array($related) && \array_values($related) === $related;
if ($isList) {
$relations = $related;
} else {
$relations = [$related];
}
$relatedCollectionId = $relationship->getAttribute('relatedCollection');
$relatedCollection = Authorization::skip(
fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $relatedCollectionId)
);
foreach ($relations as &$relation) {
// If the relation is an array it can be either update or create a child document.
if (
\is_array($relation)
&& \array_values($relation) !== $relation
&& !isset($relation['$id'])
) {
$relation['$id'] = ID::unique();
$relation = new Document($relation);
}
if ($relation instanceof Document) {
$oldDocument = Authorization::skip(fn () => $dbForProject->getDocument(
'database_' . $database->getSequence() . '_collection_' . $relatedCollection->getSequence(),
$relation->getId()
));
$relation->removeAttribute('$collectionId');
$relation->removeAttribute('$databaseId');
// Attribute $collection is required for Utopia.
$relation->setAttribute(
'$collection',
'database_' . $database->getSequence() . '_collection_' . $relatedCollection->getSequence()
);
if ($oldDocument->isEmpty()) {
if (isset($relation['$id']) && $relation['$id'] === 'unique()') {
$relation['$id'] = ID::unique();
}
}
$setCollection($relatedCollection, $relation);
}
}
if ($isList) {
$document->setAttribute($relationship->getAttribute('key'), \array_values($relations));
} else {
$document->setAttribute($relationship->getAttribute('key'), \reset($relations));
}
}
});
$setCollection($collection, $newDocument);
$queueForStatsUsage
->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations));
$upserted = [];
try {
$dbForProject->createOrUpdateDocuments(
'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(),
[$newDocument],
onNext: function (Document $document) use (&$upserted) {
$upserted[] = $document;
},
);
} catch (ConflictException) {
throw new Exception($this->getConflictException());
} catch (DuplicateException) {
throw new Exception($this->getDuplicateException());
} catch (RelationshipException $e) {
throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, $e->getMessage());
} catch (StructureException $e) {
throw new Exception($this->getInvalidStructureException(), $e->getMessage());
}
$document = $upserted[0];
// Add $collectionId and $databaseId for all documents
$processDocument = function (Document $table, Document $document) use (&$processDocument, $dbForProject, $database) {
$document->setAttribute('$databaseId', $database->getId());
$document->setAttribute('$collectionId', $table->getId());
$relationships = \array_filter(
$table->getAttribute('attributes', []),
fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP
);
foreach ($relationships as $relationship) {
$related = $document->getAttribute($relationship->getAttribute('key'));
if (empty($related)) {
continue;
}
if (!\is_array($related)) {
$related = [$related];
}
$relatedCollectionId = $relationship->getAttribute('relatedCollection');
$relatedCollection = Authorization::skip(
fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $relatedCollectionId)
);
foreach ($related as $relation) {
if ($relation instanceof Document) {
$processDocument($relatedCollection, $relation);
}
}
}
};
$processDocument($collection, $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)
->setContext('database', $database)
->setParam('collectionId', $collection->getId())
->setParam('tableId', $collection->getId())
->setParam('documentId', $document->getId())
->setParam('rowId', $document->getId())
->setContext($this->getCollectionsEventsContext(), $collection)
->setPayload($response->getPayload(), sensitive: $relationships);
$response->dynamic(
$document,
$this->getResponseModel()
);
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Rows;
use Appwrite\Platform\Modules\Databases\Context;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Upsert as DocumentUpsert;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response as UtopiaResponse;
use Utopia\Database\Database;
use Utopia\Database\Validator\Permissions;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Swoole\Response as SwooleResponse;
use Utopia\Validator\JSON;
class Upsert extends DocumentUpsert
{
use HTTP;
public static function getName(): string
{
return 'upsertRow';
}
protected function getResponseModel(): string
{
return UtopiaResponse::MODEL_ROW;
}
public function __construct()
{
$this->setContext(Context::DATABASE_ROWS);
$this
->setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/databases/:databaseId/tables/:tableId/rows/:rowId')
->desc('Upsert row')
->groups(['api', 'database'])
->label('event', 'databases.[databaseId].tables.[tableId].rows.[rowId].upsert')
->label('scope', 'documents.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('audits.event', 'row.upsert')
->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}/row/{response.$id}')
->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}')
->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2)
->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
->label('sdk', [
new Method(
namespace: $this->getSdkNamespace(),
group: $this->getSdkGroup(),
name: self::getName(),
description: '/docs/references/databases/upsert-document.md',
auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: SwooleResponse::STATUS_CODE_CREATED,
model: $this->getResponseModel(),
)
],
contentType: ContentType::JSON
),
])
->param('databaseId', '', new UID(), 'Database ID.')
->param('tableId', '', new UID(), 'Table ID.')
->param('rowId', '', new UID(), 'Row ID.')
->param('data', [], new JSON(), 'Row data as JSON object. Include all required columns of the row to be created or updated.', 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, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true)
->inject('requestTimestamp')
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->callback($this->action(...));
}
}

View file

@ -31,6 +31,7 @@ use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Cre
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Delete as DeleteDocument;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Get as GetDocument;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Update as UpdateDocument;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Upsert as UpsertDocument;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\XList as ListDocuments;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Get as GetCollection;
use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Indexes\Create as CreateIndex;
@ -78,6 +79,7 @@ class Collections extends Base
$service->addAction(CreateDocument::getName(), new CreateDocument());
$service->addAction(GetDocument::getName(), new GetDocument());
$service->addAction(UpdateDocument::getName(), new UpdateDocument());
$service->addAction(UpsertDocument::getName(), new UpsertDocument());
$service->addAction(DeleteDocument::getName(), new DeleteDocument());
$service->addAction(ListDocuments::getName(), new ListDocuments());
}

View file

@ -38,6 +38,7 @@ use Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Rows\Delete as Del
use Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Rows\Get as GetRow;
use Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Rows\Logs\XList as ListRowLogs;
use Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Rows\Update as UpdateRow;
use Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Rows\Upsert as UpsertRow;
use Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Rows\XList as ListRows;
use Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Update as UpdateTable;
use Appwrite\Platform\Modules\Databases\Http\Databases\Tables\Usage\Get as GetTableUsage;
@ -135,6 +136,7 @@ class Tables extends Base
$service->addAction(CreateRow::getName(), new CreateRow());
$service->addAction(GetRow::getName(), new GetRow());
$service->addAction(UpdateRow::getName(), new UpdateRow());
$service->addAction(UpsertRow::getName(), new UpsertRow());
$service->addAction(DeleteRow::getName(), new DeleteRow());
$service->addAction(ListRows::getName(), new ListRows());
$service->addAction(ListRowLogs::getName(), new ListRowLogs());