diff --git a/composer.lock b/composer.lock index 6c5cb2f70..84049247e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0ad14b2121f07c2fe936eaaa82186cbc", + "content-hash": "43c3ff88660f90baf3ddf7ec8bc5136d", "packages": [ { "name": "brick/math", @@ -658,16 +658,16 @@ }, { "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.2.1", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "66c3b98e998a726691c92e6405a82e6e7b8b169d" + "reference": "585bafddd4ae6565de154610b10a787a455c9ba0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/66c3b98e998a726691c92e6405a82e6e7b8b169d", - "reference": "66c3b98e998a726691c92e6405a82e6e7b8b169d", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/585bafddd4ae6565de154610b10a787a455c9ba0", + "reference": "585bafddd4ae6565de154610b10a787a455c9ba0", "shasum": "" }, "require": { @@ -717,7 +717,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2024-10-30T11:49:49+00:00" + "time": "2025-01-15T23:07:07+00:00" }, { "name": "open-telemetry/sdk", @@ -2052,16 +2052,16 @@ }, { "name": "utopia-php/compression", - "version": "0.1.2", + "version": "0.1.3", "source": { "type": "git", "url": "https://github.com/utopia-php/compression.git", - "reference": "6062f70596415f8d5de40a589367b0eb2a435f98" + "reference": "66f093557ba66d98245e562036182016c7dcfe8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/compression/zipball/6062f70596415f8d5de40a589367b0eb2a435f98", - "reference": "6062f70596415f8d5de40a589367b0eb2a435f98", + "url": "https://api.github.com/repos/utopia-php/compression/zipball/66f093557ba66d98245e562036182016c7dcfe8a", + "reference": "66f093557ba66d98245e562036182016c7dcfe8a", "shasum": "" }, "require": { @@ -2092,22 +2092,22 @@ ], "support": { "issues": "https://github.com/utopia-php/compression/issues", - "source": "https://github.com/utopia-php/compression/tree/0.1.2" + "source": "https://github.com/utopia-php/compression/tree/0.1.3" }, - "time": "2024-11-08T14:59:54+00:00" + "time": "2025-01-15T15:15:51+00:00" }, { "name": "utopia-php/framework", - "version": "0.33.15", + "version": "0.33.16", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "83b0628900c2c53e8c3efbf069f3e13050295edc" + "reference": "e91d4c560d1b809e25faa63d564fef034363b50f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/83b0628900c2c53e8c3efbf069f3e13050295edc", - "reference": "83b0628900c2c53e8c3efbf069f3e13050295edc", + "url": "https://api.github.com/repos/utopia-php/http/zipball/e91d4c560d1b809e25faa63d564fef034363b50f", + "reference": "e91d4c560d1b809e25faa63d564fef034363b50f", "shasum": "" }, "require": { @@ -2139,9 +2139,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.15" + "source": "https://github.com/utopia-php/http/tree/0.33.16" }, - "time": "2024-12-10T13:07:04+00:00" + "time": "2025-01-16T15:58:50+00:00" }, { "name": "utopia-php/mongo", @@ -2390,16 +2390,16 @@ }, { "name": "laravel/pint", - "version": "v1.19.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "8169513746e1bac70c85d6ea1524d9225d4886f0" + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/8169513746e1bac70c85d6ea1524d9225d4886f0", - "reference": "8169513746e1bac70c85d6ea1524d9225d4886f0", + "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", "shasum": "" }, "require": { @@ -2452,7 +2452,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2024-12-30T16:20:10+00:00" + "time": "2025-01-14T16:20:53+00:00" }, { "name": "myclabs/deep-copy", @@ -2724,16 +2724,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.15", + "version": "1.12.16", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1" + "reference": "e0bb5cb78545aae631220735aa706eac633a6be9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c91d4e8bc056f46cf653656e6f71004b254574d1", - "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e0bb5cb78545aae631220735aa706eac633a6be9", + "reference": "e0bb5cb78545aae631220735aa706eac633a6be9", "shasum": "" }, "require": { @@ -2778,7 +2778,7 @@ "type": "github" } ], - "time": "2025-01-05T16:40:22+00:00" + "time": "2025-01-21T14:50:05+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 73535ea11..8f831e7d4 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -637,6 +637,26 @@ abstract public function updateDocument(string $collection, string $id, Document */ abstract public function updateDocuments(string $collection, Document $updates, array $documents): int; + /** + * Create documents if they do not exist, otherwise update them. + * + * If both attribute and value are not empty, only the specified attribute will be increased, by the provided value. + * + * @param string $collection + * @param string $attribute + * @param int|float $value + * @param array $documents + * @param int $batchSize + * @return array + */ + abstract public function createOrUpdateDocuments( + string $collection, + string $attribute, + int|float $value, + array $documents, + int $batchSize + ): array; + /** * Delete Document * @@ -875,6 +895,13 @@ abstract public function getSupportForGetConnectionId(): bool; */ abstract public function getSupportForCastIndexArray(): bool; + /** + * Is upserting supported? + * + * @return bool + */ + abstract public function getSupportForUpserts(): bool; + /** * Get current attribute count from collection document * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index d4c15d032..aaf3b9d7c 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -330,6 +330,7 @@ public function deleteCollection(string $id): bool * * @param string $collection * @return bool + * @throws DatabaseException */ public function analyzeCollection(string $collection): bool { @@ -980,7 +981,7 @@ public function createDocuments(string $collection, array $documents, int $batch try { $name = $this->filter($collection); $batches = \array_chunk($documents, \max(1, $batchSize)); - $internalIds = []; + $documentIds = \array_map(fn ($document) => $document->getId(), $documents); foreach ($batches as $batch) { $bindIndex = 0; @@ -1075,18 +1076,36 @@ public function createDocuments(string $collection, array $documents, int $batch $stmtPermissions?->execute(); } } - } catch (PDOException $e) { - throw $this->processException($e); - } - foreach ($documents as $document) { - if (!isset($internalIds[$document->getId()])) { - $document['$internalId'] = $this->getDocument( - $collection, - $document->getId(), - [Query::select(['$internalId'])] - )->getInternalId(); + // Get internal IDs + $sql = " + SELECT _uid, _id + FROM {$this->getSQLTable($collection)} + WHERE _uid IN (" . implode(',', array_map(fn ($index) => ":_key_{$index}", array_keys($documentIds))) . ") + {$this->getTenantQuery($collection)} + "; + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($documentIds as $index => $id) { + $stmt->bindValue(":_key_{$index}", $id); } + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + $stmt->execute(); + $internalIds = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); // Fetch as [documentId => internalId] + $stmt->closeCursor(); + + foreach ($documents as $document) { + if (isset($internalIds[$document->getId()])) { + $document['$internalId'] = $internalIds[$document->getId()]; + } + } + } catch (PDOException $e) { + throw $this->processException($e); } return $documents; @@ -1381,10 +1400,10 @@ public function updateDocuments(string $collection, Document $updates, array $do } $sql = " - UPDATE {$this->getSQLTable($name)} - SET {$columns} - {$sqlWhere} - "; + UPDATE {$this->getSQLTable($name)} + SET {$columns} + {$sqlWhere} + "; $sql = $this->trigger(Database::EVENT_DOCUMENTS_UPDATE, $sql); $stmt = $this->getPDO()->prepare($sql); @@ -1572,6 +1591,262 @@ public function updateDocuments(string $collection, Document $updates, array $do return $affected; } + /** + * @param string $collection + * @param string $attribute + * @param int|float $value + * @param array $documents + * @param int $batchSize + * @return array + * @throws DatabaseException + */ + public function createOrUpdateDocuments( + string $collection, + string $attribute, + int|float $value, + array $documents, + int $batchSize + ): array { + if (empty($documents)) { + return $documents; + } + + try { + $name = $this->filter($collection); + $attribute = $this->filter($attribute); + $batches = \array_chunk($documents, \max(1, $batchSize)); + + foreach ($batches as $batch) { + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + + $documentIds = array_map(fn ($doc) => $doc->getId(), $batch); + + foreach ($batch as $index => $document) { + /** + * @var array $attributes + */ + $attributes = $document->getAttributes(); + $attributes['_uid'] = $document->getId(); + $attributes['_createdAt'] = $document->getCreatedAt(); + $attributes['_updatedAt'] = $document->getUpdatedAt(); + $attributes['_permissions'] = \json_encode($document->getPermissions()); + + if (!empty($document->getInternalId())) { + $attributes['_id'] = $document->getInternalId(); + } + + if ($this->sharedTables) { + $attributes['_tenant'] = $this->tenant; + } + + $columns = []; + foreach (\array_keys($attributes) as $key => $attr) { + $columns[$key] = "`{$this->filter($attr)}`"; + } + + $columns = '(' . \implode(', ', $columns) . ')'; + + $bindKeys = []; + + foreach ($attributes as $attrValue) { + if (\is_array($attrValue)) { + $attrValue = \json_encode($attrValue); + } + $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + $bindValues[$bindKey] = $attrValue; + $bindIndex++; + } + + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + } + + if (!empty($attribute) && !empty($value)) { + // Increment specific column by the specified value + $updateColumns = [ + "`{$attribute}` = `{$attribute}` + :_increment" + ]; + } elseif (!empty($attribute) && empty($value)) { + // Increment specific column by its new value in place + $updateColumns = [ + "`{$attribute}` = `{$attribute}` + VALUES(`{$attribute}`)" + ]; + } else { + // Update all columns + $updateColumns = []; + foreach (\array_keys($attributes) as $key => $attr) { + $updateColumns[] = "`{$this->filter($attr)}` = VALUES(`{$this->filter($attr)}`)"; + } + } + + $stmt = $this->getPDO()->prepare( + " + INSERT INTO {$this->getSQLTable($name)} {$columns} + VALUES " . \implode(', ', $batchKeys) . " + ON DUPLICATE KEY UPDATE + " . \implode(', ', $updateColumns) + ); + + foreach ($bindValues as $key => $binding) { + $stmt->bindValue($key, $binding, $this->getPDOType($binding)); + } + + if (!empty($attribute) && !empty($value)) { + $stmt->bindValue(':_increment', $value, $this->getPDOType($value)); + } + + $stmt->execute(); + + // Fetch existing permissions in bulk after data updates + $sql = " + SELECT _document, _type, _permission + FROM {$this->getSQLTable($name . '_perms')} + WHERE _document IN (" . \implode(',', \array_map(fn ($index) => ":_key_{$index}", \array_keys($documentIds))) . ") + {$this->getTenantQuery($collection)} + "; + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($documentIds as $index => $id) { + $stmt->bindValue(":_key_{$index}", $id); + } + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + $stmt->execute(); + $existing = $stmt->fetchAll(); + $stmt->closeCursor(); + + // Group permissions by document + $permissionsByDocument = []; + foreach ($existing as $row) { + $permissionsByDocument[$row['_document']][$row['_type']][] = $row['_permission']; + } + + foreach ($documentIds as $id) { + foreach (Database::PERMISSIONS as $type) { + $permissionsByDocument[$id][$type] = $permissionsByDocument[$id][$type] ?? []; + } + } + + $removeQueries = []; + $removeBindValues = []; + $addQueries = []; + $addBindValues = []; + + foreach ($batch as $index => $document) { + $currentPermissions = $permissionsByDocument[$document->getId()] ?? []; + + // Calculate removals + foreach (Database::PERMISSIONS as $type) { + $toRemove = \array_diff($currentPermissions[$type], $document->getPermissionsByType($type)); + if (!empty($toRemove)) { + $removeQueries[] = "( + _document = :uid_{$index} + {$this->getTenantQuery($collection)} + AND _type = '{$type}' + AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") + )"; + $removeBindValues[":uid_{$index}"] = $document->getId(); + foreach ($toRemove as $i => $perm) { + $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; + } + } + } + + // Calculate additions + foreach (Database::PERMISSIONS as $type) { + $toAdd = \array_diff($document->getPermissionsByType($type), $currentPermissions[$type]); + foreach ($toAdd as $i => $permission) { + $addQuery = "(:uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + + if ($this->sharedTables) { + $addQuery .= ", :_tenant)"; + } else { + $addQuery .= ")"; + } + + $addQueries[] = $addQuery; + $addBindValues[":uid_{$index}"] = $document->getId(); + $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + } + } + } + + // Execute permission removals + if (!empty($removeQueries)) { + $removeQuery = \implode(' OR ', $removeQueries); + $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); + foreach ($removeBindValues as $key => $value) { + $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); + } + if ($this->sharedTables) { + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); + } + $stmtRemovePermissions->execute(); + } + + // Execute permission additions + if (!empty($addQuery)) { + $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; + if ($this->sharedTables) { + $sqlAddPermissions .= ", _tenant)"; + } else { + $sqlAddPermissions .= ")"; + } + $addQuery = \implode(', ', $addQueries); + $sqlAddPermissions .= " VALUES {$addQuery}"; + $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + foreach ($addBindValues as $key => $value) { + $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); + } + if ($this->sharedTables) { + $stmtAddPermissions->bindValue(':_tenant', $this->tenant); + } + + $stmtAddPermissions->execute(); + } + } + + // Get internal IDs + $sql = " + SELECT _uid, _id + FROM {$this->getSQLTable($collection)} + WHERE _uid IN (" . \implode(',', \array_map(fn ($index) => ":_key_{$index}", \array_keys($documentIds))) . ") + {$this->getTenantQuery($collection)} + "; + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($documentIds as $index => $id) { + $stmt->bindValue(":_key_{$index}", $id); + } + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + $stmt->execute(); + $internalIds = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); // Fetch as [documentId => internalId] + $stmt->closeCursor(); + + foreach ($documents as $document) { + if (isset($internalIds[$document->getId()])) { + $document['$internalId'] = $internalIds[$document->getId()]; + } + } + } catch (PDOException $e) { + throw $this->processException($e); + } + + return $documents; + } + /** * Increase or decrease an attribute value * @@ -1602,7 +1877,6 @@ public function increaseDocumentAttribute(string $collection, string $id, string {$this->getTenantQuery($collection)} "; - $sql .= $sqlMax . $sqlMin; $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); @@ -2346,6 +2620,11 @@ public function getSupportForTimeouts(): bool return true; } + public function getSupportForUpserts(): bool + { + return true; + } + /** * Set max execution time * @param int $milliseconds diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index b67db87ec..27d77ccfe 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -891,6 +891,19 @@ public function updateDocuments(string $collection, Document $updates, array $do return 1; } + /** + * @param string $collection + * @param string $attribute + * @param float|int $value + * @param array $documents + * @param int $batchSize + * @return array + */ + public function createOrUpdateDocuments(string $collection, string $attribute, float|int $value, array $documents, int $batchSize): array + { + return $documents; + } + /** * Increase or decrease an attribute value * @@ -1803,6 +1816,11 @@ public function getSupportForCastIndexArray(): bool return false; } + public function getSupportForUpserts(): bool + { + return false; + } + /** * Get current attribute count from collection document * diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index d808a82dc..127da6bbe 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1474,6 +1474,7 @@ public function updateDocuments(string $collection, Document $updates, array $do $permissionsStmt->execute(); $permissions = $permissionsStmt->fetchAll(); + $permissionsStmt->closeCursor(); $initial = []; foreach (Database::PERMISSIONS as $type) { @@ -1585,7 +1586,7 @@ public function updateDocuments(string $collection, Document $updates, array $do $sqlAddPermissions .= ')'; } - $sqlAddPermissions .= " VALUES {$addQuery}"; + $sqlAddPermissions .= " VALUES {$addQuery}"; $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); @@ -1604,6 +1605,19 @@ public function updateDocuments(string $collection, Document $updates, array $do return $affected; } + /** + * @param string $collection + * @param string $attribute + * @param float|int $value + * @param array $documents + * @param int $batchSize + * @return array + */ + public function createOrUpdateDocuments(string $collection, string $attribute, float|int $value, array $documents, int $batchSize): array + { + return $documents; + } + /** * Increase or decrease an attribute value * @@ -2431,6 +2445,11 @@ public function getSupportForSchemaAttributes(): bool return false; } + public function getSupportForUpserts(): bool + { + return false; + } + /** * @return string */ diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index f731d8bdd..ae05e3d9b 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -232,7 +232,6 @@ public function getDocument(string $collection, string $id, array $queries = [], } $stmt->execute(); - $document = $stmt->fetchAll(); $stmt->closeCursor(); diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 8603a6cbb..88b57e590 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -936,6 +936,11 @@ public function getSupportForSchemaAttributes(): bool return false; } + public function getSupportForUpserts(): bool + { + return false; + } + /** * Get SQL Index Type * diff --git a/src/Database/Database.php b/src/Database/Database.php index 60bf3e267..88958bed8 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3387,15 +3387,13 @@ public function createDocument(string $collection, Document $document): Document * @param string $collection * @param array $documents * @param int $batchSize - * * @return array - * - * @throws AuthorizationException - * @throws StructureException - * @throws Exception */ - public function createDocuments(string $collection, array $documents, int $batchSize = self::INSERT_BATCH_SIZE): array - { + public function createDocuments( + string $collection, + array $documents, + int $batchSize = self::INSERT_BATCH_SIZE, + ): array { if (empty($documents)) { return []; } @@ -3433,7 +3431,11 @@ public function createDocuments(string $collection, array $documents, int $batch } $documents = $this->withTransaction(function () use ($collection, $documents, $batchSize) { - return $this->adapter->createDocuments($collection->getId(), $documents, $batchSize); + return $this->adapter->createDocuments( + $collection->getId(), + $documents, + $batchSize, + ); }); foreach ($documents as $key => $document) { @@ -4533,6 +4535,151 @@ private function getJunctionCollection(Document $collection, Document $relatedCo : '_' . $relatedCollection->getInternalId() . '_' . $collection->getInternalId(); } + /** + * Create or update documents + * + * @param string $collection + * @param array $documents + * @param int $batchSize + * @return array + * @throws StructureException + */ + public function createOrUpdateDocuments( + string $collection, + array $documents, + int $batchSize = self::INSERT_BATCH_SIZE + ): array { + return $this->createOrUpdateDocumentsWithIncrease( + $collection, + '', + 0, + $documents, + $batchSize + ); + } + + /** + * Create or update documents + * + * @param string $collection + * @param string $attribute + * @param array $documents + * @param int $batchSize + * @return array + * @throws StructureException + * @throws \Throwable + */ + public function createOrUpdateDocumentsWithInplaceIncrease( + string $collection, + string $attribute, + array $documents, + int $batchSize = self::INSERT_BATCH_SIZE + ): array { + return $this->createOrUpdateDocumentsWithIncrease( + $collection, + $attribute, + 0, + $documents, + $batchSize + ); + } + + /** + * @param string $collection + * @param string $attribute + * @param int|float $value + * @param array $documents + * @param int $batchSize + * @return array + * @throws StructureException + * @throws \Throwable + * @throws Exception + */ + public function createOrUpdateDocumentsWithIncrease( + string $collection, + string $attribute, + int|float $value, + array $documents, + int $batchSize = self::INSERT_BATCH_SIZE + ): array { + if (empty($documents)) { + return []; + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $time = DateTime::now(); + + foreach ($documents as $key => $document) { + $old = Authorization::skip(fn () => $this->silent(fn () => $this->getDocument($collection->getId(), $document->getId()))); + + if (!$old->isEmpty()) { + $validator = new Authorization(self::PERMISSION_UPDATE); + + if (!$validator->isValid([ + ...$collection->getUpdate(), + ...($collection->getAttribute('documentSecurity') ? $old->getUpdate() : []) + ])) { + throw new AuthorizationException($validator->getDescription()); + } + } + + $createdAt = $document->getCreatedAt(); + $updatedAt = $document->getUpdatedAt(); + + $document + ->setAttribute('$id', empty($document->getId()) ? ID::unique() : $document->getId()) + ->setAttribute('$collection', $collection->getId()) + ->setAttribute('$createdAt', empty($createdAt) || !$this->preserveDates ? $time : $createdAt) + ->setAttribute('$updatedAt', empty($updatedAt) || !$this->preserveDates ? $time : $updatedAt); + + $document = $this->encode($collection, $document); + + $validator = new Structure( + $collection, + $this->adapter->getMinDateTime(), + $this->adapter->getMaxDateTime(), + ); + + if (!$validator->isValid($document)) { + throw new StructureException($validator->getDescription()); + } + + if ($this->resolveRelationships) { + $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); + } + + $documents[$key] = $document; + } + + $documents = $this->withTransaction(function () use ($collection, $attribute, $value, $documents, $batchSize) { + return $this->adapter->createOrUpdateDocuments( + $collection->getId(), + $attribute, + $value, + $documents, + $batchSize, + ); + }); + + foreach ($documents as $key => $document) { + if ($this->resolveRelationships) { + $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); + } + + $documents[$key] = $this->decode($collection, $document); + + $this->purgeCachedDocument($collection->getId(), $document->getId()); + } + + $this->trigger(self::EVENT_DOCUMENTS_CREATE, new Document([ + '$collection' => $collection->getId(), + 'modified' => count($documents) + ])); + + return $documents; + } + /** * Increase a document attribute by a value * diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index e100ee69e..815d55b84 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -2230,6 +2230,313 @@ public function testCreateDocuments(): array return $documents; } + public function testCreateOrUpdateDocuments(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForUpserts()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collection = 'testCreateOrUpdateDocuments'; + + static::getDatabase()->createCollection($collection); + + static::getDatabase()->createAttribute($collection, 'string', Database::VAR_STRING, 128, true); + static::getDatabase()->createAttribute($collection, 'integer', Database::VAR_INTEGER, 0, true); + static::getDatabase()->createAttribute($collection, 'bigint', Database::VAR_INTEGER, 8, true); + + $documents = [ + new Document([ + '$id' => 'first', + 'string' => 'text📝', + 'integer' => 5, + 'bigint' => Database::BIG_INT_MAX, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]), + new Document([ + '$id' => 'second', + 'string' => 'text📝', + 'integer' => 5, + 'bigint' => Database::BIG_INT_MAX, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]), + ]; + + $documents = static::getDatabase()->createOrUpdateDocuments($collection, $documents); + + $this->assertEquals(2, count($documents)); + + foreach ($documents as $document) { + $this->assertNotEmpty(true, $document->getId()); + $this->assertIsString($document->getAttribute('string')); + $this->assertEquals('text📝', $document->getAttribute('string')); // Also makes sure an emoji is working + $this->assertIsInt($document->getAttribute('integer')); + $this->assertEquals(5, $document->getAttribute('integer')); + $this->assertIsInt($document->getAttribute('bigint')); + $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + } + + $documents = static::getDatabase()->find($collection); + + $this->assertEquals(2, count($documents)); + + foreach ($documents as $document) { + $this->assertNotEmpty(true, $document->getId()); + $this->assertIsString($document->getAttribute('string')); + $this->assertEquals('text📝', $document->getAttribute('string')); // Also makes sure an emoji is working + $this->assertIsInt($document->getAttribute('integer')); + $this->assertEquals(5, $document->getAttribute('integer')); + $this->assertIsInt($document->getAttribute('bigint')); + $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + } + + $documents[0]->setAttribute('string', 'new text📝'); + $documents[0]->setAttribute('integer', 10); + $documents[1]->setAttribute('string', 'new text📝'); + $documents[1]->setAttribute('integer', 10); + + $documents = static::getDatabase()->createOrUpdateDocuments($collection, $documents); + + $this->assertEquals(2, count($documents)); + + foreach ($documents as $document) { + $this->assertNotEmpty(true, $document->getId()); + $this->assertIsString($document->getAttribute('string')); + $this->assertEquals('new text📝', $document->getAttribute('string')); // Also makes sure an emoji is working + $this->assertIsInt($document->getAttribute('integer')); + $this->assertEquals(10, $document->getAttribute('integer')); + $this->assertIsInt($document->getAttribute('bigint')); + $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + } + + $documents = static::getDatabase()->find($collection); + + $this->assertEquals(2, count($documents)); + + foreach ($documents as $document) { + $this->assertNotEmpty(true, $document->getId()); + $this->assertIsString($document->getAttribute('string')); + $this->assertEquals('new text📝', $document->getAttribute('string')); // Also makes sure an emoji is working + $this->assertIsInt($document->getAttribute('integer')); + $this->assertEquals(10, $document->getAttribute('integer')); + $this->assertIsInt($document->getAttribute('bigint')); + $this->assertEquals(Database::BIG_INT_MAX, $document->getAttribute('bigint')); + } + } + + public function testCreateOrUpdateDocumentsWithIncrease(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForUpserts()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collection = 'testCreateOrUpdateWithIncrease'; + + static::getDatabase()->createCollection($collection); + static::getDatabase()->createAttribute($collection, 'string', Database::VAR_STRING, 128, true); + static::getDatabase()->createAttribute($collection, 'integer', Database::VAR_INTEGER, 0, true); + + $documents = [ + new Document([ + '$id' => 'first', + 'string' => 'text📝', + 'integer' => 5, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]), + new Document([ + '$id' => 'second', + 'string' => 'text📝', + 'integer' => 5, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]), + ]; + + static::getDatabase()->createDocuments($collection, $documents); + + static::getDatabase()->createOrUpdateDocumentsWithIncrease( + collection: $collection, + attribute:'integer', + value: 1, + documents: $documents + ); + + $documents = static::getDatabase()->find($collection); + + foreach ($documents as $document) { + $this->assertEquals(6, $document->getAttribute('integer')); + } + + static::getDatabase()->createOrUpdateDocumentsWithIncrease( + collection: $collection, + attribute:'integer', + value: -1, + documents: $documents + ); + + $documents = static::getDatabase()->find($collection); + + foreach ($documents as $document) { + $this->assertEquals(5, $document->getAttribute('integer')); + } + } + + public function testCreateOrUpdateDocumentsWithInplaceIncrease(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForUpserts()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collection = 'testCreateOrUpdateInplace'; + + static::getDatabase()->createCollection($collection); + static::getDatabase()->createAttribute($collection, 'string', Database::VAR_STRING, 128, true); + static::getDatabase()->createAttribute($collection, 'integer', Database::VAR_INTEGER, 0, true); + + $documents = [ + new Document([ + '$id' => 'first', + 'string' => 'text📝', + 'integer' => 5, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]), + new Document([ + '$id' => 'second', + 'string' => 'text📝', + 'integer' => 5, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]), + ]; + + static::getDatabase()->createDocuments($collection, $documents); + + $documents[0]->setAttribute('integer', 1); + $documents[1]->setAttribute('integer', 1); + + static::getDatabase()->createOrUpdateDocumentsWithInplaceIncrease( + collection: $collection, + attribute:'integer', + documents: $documents + ); + + $documents = static::getDatabase()->find($collection); + + foreach ($documents as $document) { + $this->assertEquals(6, $document->getAttribute('integer')); + } + } + + public function testCreateOrUpdateDocumentsPermissions(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForUpserts()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collection = 'testCreateOrUpdateDocumentPermissions'; + + static::getDatabase()->createCollection($collection); + static::getDatabase()->createAttribute($collection, 'string', Database::VAR_STRING, 128, true); + + $document = new Document([ + '$id' => 'first', + 'string' => 'text📝', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]); + + static::getDatabase()->createOrUpdateDocuments($collection, [$document]); + + try { + static::getDatabase()->createOrUpdateDocuments($collection, [$document->setAttribute('string', 'updated')]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(AuthorizationException::class, $e); + } + + $document = new Document([ + '$id' => 'second', + 'string' => 'text📝', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + static::getDatabase()->createOrUpdateDocuments($collection, [$document]); + + $documents = static::getDatabase()->createOrUpdateDocuments( + $collection, + [$document->setAttribute('string', 'updated')] + ); + + $this->assertEquals(1, count($documents)); + $this->assertEquals('updated', $documents[0]->getAttribute('string')); + + $document = new Document([ + '$id' => 'third', + 'string' => 'text📝', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + static::getDatabase()->createOrUpdateDocuments($collection, [$document]); + + $newPermissions = [ + Permission::read(Role::any()), + Permission::update(Role::user('user1')), + Permission::delete(Role::user('user1')), + ]; + + $documents = static::getDatabase()->createOrUpdateDocuments( + $collection, + [$document->setAttribute('$permissions', $newPermissions)] + ); + + $this->assertEquals(1, count($documents)); + $this->assertEquals($newPermissions, $documents[0]->getPermissions()); + + $document = static::getDatabase()->getDocument($collection, 'third'); + + $this->assertEquals($newPermissions, $document->getPermissions()); + } + public function testRespectNulls(): Document { static::getDatabase()->createCollection('documents_nulls');