diff --git a/src/Database/Database.php b/src/Database/Database.php index 692908ee6..4769599a9 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -428,6 +428,8 @@ class Database protected bool $validate = true; + protected bool $dropUnknownAttributes = false; + protected bool $preserveDates = false; protected bool $skipDuplicates = false; @@ -1495,6 +1497,25 @@ protected function createDocumentInstance(string $collection, array $data): Docu return new $className($data); } + public function getDropUnknownAttributes(): bool + { + return $this->dropUnknownAttributes; + } + + /** + * Drop attributes missing from the collection schema instead of rejecting the write. + * + * Enable this where the schema is owned by the application rather than the caller, so a + * deploy that writes an attribute before its migration has run degrades to a warning + * instead of failing every write. + */ + public function setDropUnknownAttributes(bool $drop): static + { + $this->dropUnknownAttributes = $drop; + + return $this; + } + public function getPreserveDates(): bool { return $this->preserveDates; @@ -6316,6 +6337,11 @@ public function updateDocument(string $collection, string $id, Document $documen } $document = new Document($document); + // Ahead of change detection: a dropped attribute is never persisted, so + // counting it as a change would bump $updatedAt and fire an update event + // for a write that leaves the stored document identical. + $document = $this->removeUnknownAttributes($collection, $document); + $attributes = $collection->getAttribute('attributes', []); $relationships = \array_filter($attributes, function ($attribute) { @@ -7392,6 +7418,8 @@ public function upsertDocumentsWithIncrease( foreach ($documents as $key => $document) { $old = $existingDocs[$this->tenantKey($document)] ?? new Document(); + $document = $this->removeUnknownAttributes($collection, $document); + // Extract operators early to avoid comparison issues $documentArray = $document->getArrayCopy(); $extracted = Operator::extractOperators($documentArray); @@ -9231,9 +9259,57 @@ public static function addFilter(string $name, callable $encode, callable $decod ]; } + /** + * Remove attributes the collection schema does not declare. + * + * Used ahead of change detection on update/upsert so a dropped key is not + * counted as a write. Encode also calls this after iterating attributes. + * + * @param Document $collection + * @param Document $document + * @param array|null $known Attribute ids already collected (e.g. during encode) + * + * @return Document + */ + protected function removeUnknownAttributes(Document $collection, Document $document, ?array $known = null): Document + { + if (!$this->dropUnknownAttributes || !$this->adapter->getSupportForAttributes()) { + return $document; + } + + if ($known === null) { + $known = []; + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $known[$attribute['$id'] ?? ''] = true; + } + } + + $dropped = []; + foreach (\array_keys($document->getArrayCopy()) as $key) { + if (\str_starts_with($key, '$') || isset($known[$key])) { + continue; + } + + $dropped[] = $key; + $document->removeAttribute($key); + } + + if (!empty($dropped)) { + Console::warning( + 'Dropped unknown attributes "' . \implode('", "', $dropped) . '" from collection "' . $collection->getId() . '"' + . ($this->adapter->getTenant() === null ? '' : ' on tenant ' . $this->adapter->getTenant()) + ); + } + + return $document; + } + /** * Encode Document * + * When dropUnknownAttributes is enabled, attributes missing from the + * collection schema are removed here while the known set is collected. + * * @param Document $collection * @param Document $document * @param bool $applyDefaults Whether to apply default values to null attributes @@ -9249,8 +9325,10 @@ public function encode(Document $collection, Document $document, bool $applyDefa $attributes[] = $attribute; } + $known = []; foreach ($attributes as $attribute) { $key = $attribute['$id'] ?? ''; + $known[$key] = true; $array = $attribute['array'] ?? false; $default = $attribute['default'] ?? null; $filters = $attribute['filters'] ?? []; @@ -9303,7 +9381,7 @@ public function encode(Document $collection, Document $document, bool $applyDefa $document->setAttribute($key, $value); } - return $document; + return $this->removeUnknownAttributes($collection, $document, $known); } /** diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index 86beb5d0a..a0151cb92 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -130,6 +130,15 @@ public function setTenant(int|string|null $tenant): static return $this; } + public function setDropUnknownAttributes(bool $drop): static + { + $this->delegate(__FUNCTION__, \func_get_args()); + + $this->dropUnknownAttributes = $drop; + + return $this; + } + public function setPreserveDates(bool $preserve): static { $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 7cf1b9ede..50671db36 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -9149,4 +9149,98 @@ public function testCreateDocumentsSkipDuplicatesRelationships(): void $this->assertSame(['existingChild', 'newChild', 'retryChild'], $allChildIds); } + + public function testDropUnknownAttributes(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(__FUNCTION__); + $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'known', Database::VAR_STRING, 128, false)); + + try { + $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'strict', + '$permissions' => $permissions, + 'known' => 'kept', + 'unknown' => 'dropped', + ])); + $this->fail('Unknown attribute was accepted while dropping is disabled'); + } catch (StructureException $e) { + $this->assertEquals('Invalid document structure: Unknown attribute: "unknown"', $e->getMessage()); + } + + $database->setDropUnknownAttributes(true); + + try { + $collection = $database->getCollection(__FUNCTION__); + $encoded = $database->encode($collection, new Document([ + '$id' => 'encoded', + '$collection' => __FUNCTION__, + 'known' => 'kept', + 'unknown' => 'dropped', + ])); + $this->assertEquals('kept', $encoded->getAttribute('known')); + $this->assertNull($encoded->getAttribute('unknown'), 'Unknown attribute survived encode'); + + $created = $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'lenient', + '$permissions' => $permissions, + 'known' => 'kept', + 'unknown' => 'dropped', + ])); + + $this->assertEquals('kept', $created->getAttribute('known')); + $this->assertNull($created->getAttribute('unknown'), 'Unknown attribute survived the create'); + + $database->purgeCachedDocument(__FUNCTION__, 'lenient'); + $stored = $database->getDocument(__FUNCTION__, 'lenient'); + $this->assertEquals('kept', $stored->getAttribute('known')); + $this->assertNull($stored->getAttribute('unknown'), 'Unknown attribute reached storage on create'); + + $updated = $database->updateDocument(__FUNCTION__, 'lenient', new Document([ + '$id' => 'lenient', + '$permissions' => $permissions, + 'known' => 'changed', + 'unknown' => 'dropped', + ])); + + $this->assertEquals('changed', $updated->getAttribute('known')); + $this->assertNull($updated->getAttribute('unknown'), 'Unknown attribute survived the update'); + + $database->purgeCachedDocument(__FUNCTION__, 'lenient'); + $stored = $database->getDocument(__FUNCTION__, 'lenient'); + $this->assertEquals('changed', $stored->getAttribute('known')); + $this->assertNull($stored->getAttribute('unknown'), 'Unknown attribute reached storage on update'); + + \usleep(5000); + + $unchanged = $database->updateDocument(__FUNCTION__, 'lenient', new Document([ + '$id' => 'lenient', + '$permissions' => $permissions, + 'known' => 'changed', + 'unknown' => 'dropped', + ])); + + $this->assertEquals( + $stored->getUpdatedAt(), + $unchanged->getUpdatedAt(), + 'A write carrying only a dropped attribute counted as a change' + ); + } finally { + $database->setDropUnknownAttributes(false); + } + } }