diff --git a/phpunit.xml b/phpunit.xml index 2a0531cfd..34365d48d 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,7 +7,7 @@ convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" - stopOnFailure="false" + stopOnFailure="true" > diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 599da878e..2c41c784e 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -977,9 +977,23 @@ public function updateDocument(Document $collection, string $id, Document $docum $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); + + if ($document->offsetExists('$updatedAt')) { + $attributes['_updatedAt'] = $document->getUpdatedAt(); + } + if ($document->offsetExists('$createdAt')) { + $attributes['_createdAt'] = $document->getCreatedAt(); + } + if ($document->offsetExists('$id')) { + $attributes['_uid'] = $document->getId(); + } + if ($document->offsetExists('$permissions')) { + $attributes['_permissions'] = json_encode($document->getPermissions()); + } + + if (empty($attributes)) { + return $document; + } $name = $this->filter($collection); $columns = ''; @@ -998,7 +1012,8 @@ public function updateDocument(Document $collection, string $id, Document $docum * Get current permissions from the database */ $sqlPermissions = $this->getPDO()->prepare($sql); - $sqlPermissions->bindValue(':_uid', $document->getId()); + + $sqlPermissions->bindValue(':_uid', $id); if ($this->sharedTables) { $sqlPermissions->bindValue(':_tenant', $this->tenant); @@ -1071,7 +1086,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); - $stmtRemovePermissions->bindValue(':_uid', $document->getId()); + $stmtRemovePermissions->bindValue(':_uid', $id); if ($this->sharedTables) { $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); @@ -1119,7 +1134,8 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $newUid = $document->offsetExists('$id') ? $document->getId() : $id; + $stmtAddPermissions->bindValue(":_uid", $newUid); if ($this->sharedTables) { $stmtAddPermissions->bindValue(":_tenant", $this->tenant); @@ -1168,7 +1184,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = " UPDATE {$this->getSQLTable($name)} - SET {$columns} _uid = :_newUid + SET " . \rtrim($columns, ',') . " WHERE _id=:_sequence {$this->getTenantQuery($collection)} "; @@ -1178,7 +1194,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_sequence', $document->getSequence()); - $stmt->bindValue(':_newUid', $document->getId()); if ($this->sharedTables) { $stmt->bindValue(':_tenant', $this->tenant); diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 7760baf4d..2a1b9da67 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -1246,7 +1246,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $document->setAttribute($attribute, $value); } - $newId = $document->getId(); + $newId = $document->offsetExists('$id') ? $document->getId() : $id; $newKey = $this->documentKey($newId); if ($newId !== $id && isset($this->data[$key]['documents'][$newKey])) { throw new DuplicateException('Document already exists'); @@ -1254,6 +1254,21 @@ public function updateDocument(Document $collection, string $id, Document $docum $update = $this->documentToRow($document); + // For partial updates, documentToRow unconditionally maps internal fields + // ($id โ†’ _uid, $createdAt โ†’ _createdAt, $permissions โ†’ _permissions) from + // the partial document, which would overwrite existing row values with + // empty defaults. Strip any internal field not actually present in the + // document so the sparse array_merge below only touches what the caller set. + if (!$document->offsetExists('$id')) { + unset($update['_uid']); + } + if (!$document->offsetExists('$createdAt')) { + unset($update['_createdAt']); + } + if (!$document->offsetExists('$permissions')) { + unset($update['_permissions']); + } + // Sparse update โ€” MariaDB's UPDATE only sets columns present in the // document; absent columns retain their previous values. The wrapper // relies on this for relationship updates, where it removes @@ -1338,6 +1353,9 @@ public function updateDocument(Document $collection, string $id, Document $docum if ($newId !== $id) { $this->removePermissionsForDocument($key, $newId, $tenant, $this->sharedTables); } + if (! $document->offsetExists('$id')) { + $document->setAttribute('$id', $newId); + } $this->writePermissions($key, $document); } elseif ($newId !== $id) { // Rename-only path: rebind every permission entry whose document diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index d81cdec0b..866f8a0ed 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1136,9 +1136,23 @@ public function updateDocument(Document $collection, string $id, Document $docum $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); + + if ($document->offsetExists('$updatedAt')) { + $attributes['_updatedAt'] = $document->getUpdatedAt(); + } + if ($document->offsetExists('$createdAt')) { + $attributes['_createdAt'] = $document->getCreatedAt(); + } + if ($document->offsetExists('$id')) { + $attributes['_uid'] = $document->getId(); + } + if ($document->offsetExists('$permissions')) { + $attributes['_permissions'] = json_encode($document->getPermissions()); + } + + if (empty($attributes)) { + return $document; + } $name = $this->filter($collection); $columns = ''; @@ -1157,7 +1171,7 @@ public function updateDocument(Document $collection, string $id, Document $docum * Get current permissions from the database */ $permissionsStmt = $this->getPDO()->prepare($sql); - $permissionsStmt->bindValue(':_uid', $document->getId()); + $permissionsStmt->bindValue(':_uid', $id); if ($this->sharedTables) { $permissionsStmt->bindValue(':_tenant', $this->tenant); @@ -1230,7 +1244,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); - $stmtRemovePermissions->bindValue(':_uid', $document->getId()); + $stmtRemovePermissions->bindValue(':_uid', $id); if ($this->sharedTables) { $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); @@ -1264,7 +1278,8 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $newUid = $document->offsetExists('$id') ? $document->getId() : $id; + $stmtAddPermissions->bindValue(":_uid", $newUid); if ($this->sharedTables) { $stmtAddPermissions->bindValue(':_tenant', $this->tenant); } @@ -1312,7 +1327,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = " UPDATE {$this->getSQLTable($name)} - SET {$columns} _uid = :_newUid + SET " . \rtrim($columns, ',') . " WHERE _id=:_sequence {$this->getTenantQuery($collection)} "; @@ -1322,7 +1337,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_sequence', $document->getSequence()); - $stmt->bindValue(':_newUid', $document->getId()); if ($this->sharedTables) { $stmt->bindValue(':_tenant', $this->tenant); diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 5b07ce3b7..778fe563d 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1272,9 +1272,23 @@ public function updateDocument(Document $collection, string $id, Document $docum $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); + + if ($document->offsetExists('$updatedAt')) { + $attributes['_updatedAt'] = $document->getUpdatedAt(); + } + if ($document->offsetExists('$createdAt')) { + $attributes['_createdAt'] = $document->getCreatedAt(); + } + if ($document->offsetExists('$id')) { + $attributes['_uid'] = $document->getId(); + } + if ($document->offsetExists('$permissions')) { + $attributes['_permissions'] = json_encode($document->getPermissions()); + } + + if (empty($attributes)) { + return $document; + } if ($this->sharedTables) { $attributes['_tenant'] = $this->tenant; @@ -1297,7 +1311,7 @@ public function updateDocument(Document $collection, string $id, Document $docum * Get current permissions from the database */ $permissionsStmt = $this->getPDO()->prepare($sql); - $permissionsStmt->bindValue(':_uid', $document->getId()); + $permissionsStmt->bindValue(':_uid', $id); if ($this->sharedTables) { $permissionsStmt->bindValue(':_tenant', $this->tenant); @@ -1369,7 +1383,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); - $stmtRemovePermissions->bindValue(':_uid', $document->getId()); + $stmtRemovePermissions->bindValue(':_uid', $id); if ($this->sharedTables) { $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); @@ -1404,7 +1418,8 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $newUid = $document->offsetExists('$id') ? $document->getId() : $id; + $stmtAddPermissions->bindValue(":_uid", $newUid); if ($this->sharedTables) { $stmtAddPermissions->bindValue(":_tenant", $this->tenant); } @@ -1456,7 +1471,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $sql = " UPDATE `{$this->getNamespace()}_{$name}` - SET {$columns}, _uid = :_newUid + SET {$columns} WHERE _uid = :_existingUid {$this->getTenantQuery($collection)} "; @@ -1466,7 +1481,6 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_existingUid', $id); - $stmt->bindValue(':_newUid', $document->getId()); if ($this->sharedTables) { $stmt->bindValue(':_tenant', $this->tenant); diff --git a/src/Database/Database.php b/src/Database/Database.php index 37b903d13..117922c14 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6149,8 +6149,10 @@ public function updateDocument(string $collection, string $id, Document $documen } $collection = $this->silent(fn () => $this->getCollection($collection)); + $inputKeys = \array_keys($document->getArrayCopy()); $newUpdatedAt = $document->getUpdatedAt(); - $document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt) { + + $document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt, $inputKeys) { $time = DateTime::now(); $old = $this->authorization->skip(fn () => $this->silent( fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) @@ -6170,6 +6172,7 @@ public function updateDocument(string $collection, string $id, Document $documen $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); } + $createdAt = $document->getCreatedAt(); $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); @@ -6326,7 +6329,7 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->encode($collection, $document); if ($this->validate) { - $structureValidator = new Structure( + $structureValidator = new PartialStructure( $collection, $this->adapter->getIdAttributeType(), $this->adapter->getMinDateTime(), @@ -6335,7 +6338,10 @@ public function updateDocument(string $collection, string $id, Document $documen supportUnsignedBigInt: $this->adapter->getSupportForUnsignedBigInt(), currentDocument: $old ); - if (!$structureValidator->isValid($document)) { // Make sure updated structure still apply collection rules (if any) + + $partialDocument = new Document(\array_intersect_key($document->getArrayCopy(), \array_flip($inputKeys))); + + if (!$structureValidator->isValid($partialDocument)) { // Validate only user-provided fields throw new StructureException($structureValidator->getDescription()); } } @@ -6346,7 +6352,11 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->adapter->castingBefore($collection, $document); - $this->adapter->updateDocument($collection, $id, $document, $skipPermissionsUpdate); + $adapterDocument = new Document(\array_intersect_key($document->getArrayCopy(), \array_flip($inputKeys))); + $adapterDocument->setAttribute('$sequence', $old->getSequence()); + $adapterDocument->setAttribute('$updatedAt', $document->getUpdatedAt()); + + $this->adapter->updateDocument($collection, $id, $adapterDocument, $skipPermissionsUpdate); $document = $this->adapter->castingAfter($collection, $document); @@ -6385,6 +6395,7 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $documents[0]; } + $document = $this->casting($collection, $document); $document = $this->decode($collection, $document); // Convert to custom document type if mapped diff --git a/src/Database/Document.php b/src/Database/Document.php index d50a957ca..21fcb86c5 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -3,6 +3,7 @@ namespace Utopia\Database; use ArrayObject; +use MongoDB\BSON\UTCDateTime; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Structure as StructureException; @@ -157,17 +158,17 @@ public function getPermissionsByType(string $type): array } /** - * @return string|null + * @return string|null|UTCDateTime */ - public function getCreatedAt(): ?string + public function getCreatedAt(): string|UTCDateTime|null { return $this->getAttribute('$createdAt'); } /** - * @return string|null + * @return string|null|UTCDateTime */ - public function getUpdatedAt(): ?string + public function getUpdatedAt(): string|UTCDateTime|null { return $this->getAttribute('$updatedAt'); } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 23cc3b623..1ebefaa19 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -4645,6 +4645,44 @@ public function testEncodeDecode(): void new Document(['$id' => '3', 'label' => 'z']), ], $result->getAttribute('tags')); } + + // public function testPartialUpdateDocument(): void + // { + // /** @var Database $database */ + // $database = $this->getDatabase(); + // + // $database->createCollection(__FUNCTION__); + // $database->createAttribute(__FUNCTION__, 'string', Database::VAR_STRING, 128, true); + // + // // Insert initial documents + // $database->createDocument(__FUNCTION__, new Document([ + // '$id' => 'sam', + // 'string' => 'text๐Ÿ“ ' . $i, + // ])); + // + // var_dump('======================================================================================='); + // var_dump('======================================================================================='); + // + // /** + // * test Update $id + // */ + // $partial = new Document([ + // '$id'=> 'sam', + // ]); + // + // $partial = $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $partial); + // var_dump($partial); + // $this->assertEquals('sam', $partial->getId()); + // + // + // $partial = $this->getDatabase()->getDocument($document->getCollection(), $partial->getId()); + // var_dump($partial); + // $this->assertEquals('sam', $partial->getId()); + // $this->assertEquals('text๐Ÿ“', $partial->getAttribute('string')); + // + // $this->assertEquals('shmuel', 'fogel'); + // } + /** * @depends testGetDocument */