From e0d3a71d11e71f0a6f4f0560832ecf630490f634 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 5 Feb 2026 01:09:17 +1300 Subject: [PATCH] Add transformers --- src/Database/Database.php | 55 +++ tests/e2e/Adapter/Base.php | 2 + tests/e2e/Adapter/Scopes/TransformerTests.php | 383 ++++++++++++++++++ tests/unit/TransformerTest.php | 98 +++++ 4 files changed, 538 insertions(+) create mode 100644 tests/e2e/Adapter/Scopes/TransformerTests.php create mode 100644 tests/unit/TransformerTest.php diff --git a/src/Database/Database.php b/src/Database/Database.php index d07205505..cfa96dddb 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -443,6 +443,11 @@ class Database */ protected array $documentTypes = []; + /** + * Document transformer callback + * @var callable|null + */ + protected $transformer = null; /** * @var Authorization @@ -1134,6 +1139,47 @@ public function getInstanceFilters(): array return $this->instanceFilters; } + /** + * Set document transformer + * + * Called after decode for every document retrieved, including nested relationships. + * Transformer receives custom document types (if mapped via setDocumentType) and + * should return the same type. + * + * @param callable|null $transformer fn(Document $document, Document $collection, Database $db): Document + * @return static + */ + public function setTransformer(?callable $transformer): static + { + $this->transformer = $transformer; + return $this; + } + + /** + * Get document transformer + * + * @return callable|null + */ + public function getTransformer(): ?callable + { + return $this->transformer; + } + + /** + * Transform a document using the configured transformer + * + * @param Document $document + * @param Document $collection + * @return Document + */ + protected function transform(Document $document, Document $collection): Document + { + if ($this->transformer === null || $document->isEmpty()) { + return $document; + } + return ($this->transformer)($document, $collection, $this); + } + /** * Enable validation * @@ -4309,6 +4355,8 @@ public function getDocument(string $collection, string $id, array $queries = [], $document = $documents[0]; } + $document = $this->transform($document, $collection); + $relationships = \array_filter( $collection->getAttribute('attributes', []), fn ($attribute) => $attribute['type'] === Database::VAR_RELATIONSHIP @@ -5036,6 +5084,8 @@ public function createDocument(string $collection, Document $document): Document $document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy()); } + $document = $this->transform($document, $collection); + $this->trigger(self::EVENT_DOCUMENT_CREATE, $document); return $document; @@ -5734,6 +5784,8 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy()); } + $document = $this->transform($document, $collection); + $this->trigger(self::EVENT_DOCUMENT_UPDATE, $document); return $document; @@ -6774,6 +6826,8 @@ public function upsertDocumentsWithIncrease( $doc = $this->decode($collection, $doc); } + $doc = $this->transform($doc, $collection); + if ($this->getSharedTables() && $this->getTenantPerDocument()) { $this->withTenant($doc->getTenant(), function () use ($collection, $doc) { $this->purgeCachedDocument($collection->getId(), $doc->getId()); @@ -7843,6 +7897,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $node->setAttribute('$collection', $collection->getId()); } + $node = $this->transform($node, $collection); $results[$index] = $node; } diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 4baeba35b..27f344ab0 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -15,6 +15,7 @@ use Tests\E2E\Adapter\Scopes\RelationshipTests; use Tests\E2E\Adapter\Scopes\SchemalessTests; use Tests\E2E\Adapter\Scopes\SpatialTests; +use Tests\E2E\Adapter\Scopes\TransformerTests; use Tests\E2E\Adapter\Scopes\VectorTests; use Utopia\Database\Database; use Utopia\Database\Validator\Authorization; @@ -34,6 +35,7 @@ abstract class Base extends TestCase use SpatialTests; use SchemalessTests; use ObjectAttributeTests; + use TransformerTests; use VectorTests; use GeneralTests; diff --git a/tests/e2e/Adapter/Scopes/TransformerTests.php b/tests/e2e/Adapter/Scopes/TransformerTests.php new file mode 100644 index 000000000..6f25c588d --- /dev/null +++ b/tests/e2e/Adapter/Scopes/TransformerTests.php @@ -0,0 +1,383 @@ +setAttribute('transformed', true); + return $document; + }; + + $result = $database->setTransformer($transformer); + + $this->assertInstanceOf(Database::class, $result); + $this->assertSame($transformer, $database->getTransformer()); + + // Cleanup + $database->setTransformer(null); + } + + public function testGetTransformerReturnsNull(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Ensure no transformer is set + $database->setTransformer(null); + + $this->assertNull($database->getTransformer()); + } + + public function testTransformerWithGetDocument(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('transformerTestGet', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + $database->createAttribute('transformerTestGet', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('transformerTestGet', 'email', Database::VAR_STRING, 255, true); + + // Create a document first (without transformer) + $created = $database->createDocument('transformerTestGet', new Document([ + '$id' => ID::unique(), + 'name' => 'Test User', + 'email' => 'test@example.com', + '$permissions' => [Permission::read(Role::any())], + ])); + + // Set up transformer + $transformerCalled = false; + $receivedCollection = null; + $receivedDatabase = null; + + $database->setTransformer(function (Document $document, Document $collection, Database $db) use (&$transformerCalled, &$receivedCollection, &$receivedDatabase): Document { + $transformerCalled = true; + $receivedCollection = $collection; + $receivedDatabase = $db; + $document->setAttribute('_transformed', true); + $document->setAttribute('_customAttribute', 'added by transformer'); + return $document; + }); + + // Get document - transformer should be called + $fetched = $database->getDocument('transformerTestGet', $created->getId()); + + $this->assertTrue($transformerCalled, 'Transformer should be called'); + $this->assertNotNull($receivedCollection, 'Transformer should receive collection'); + $this->assertEquals('transformerTestGet', $receivedCollection->getId()); + $this->assertInstanceOf(Database::class, $receivedDatabase); + $this->assertTrue($fetched->getAttribute('_transformed')); + $this->assertEquals('added by transformer', $fetched->getAttribute('_customAttribute')); + $this->assertEquals('Test User', $fetched->getAttribute('name')); + + // Cleanup + $database->setTransformer(null); + $database->deleteCollection('transformerTestGet'); + } + + public function testTransformerWithFind(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('transformerTestFind', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createAttribute('transformerTestFind', 'title', Database::VAR_STRING, 255, true); + + // Create documents without transformer + $database->setTransformer(null); + + $database->createDocument('transformerTestFind', new Document([ + '$id' => ID::unique(), + 'title' => 'First Post', + '$permissions' => [Permission::read(Role::any())], + ])); + + $database->createDocument('transformerTestFind', new Document([ + '$id' => ID::unique(), + 'title' => 'Second Post', + '$permissions' => [Permission::read(Role::any())], + ])); + + // Set up transformer that counts calls + $callCount = 0; + + $database->setTransformer(function (Document $document, Document $collection, Database $db) use (&$callCount): Document { + $callCount++; + $document->setAttribute('_index', $callCount); + return $document; + }); + + // Find documents + $documents = $database->find('transformerTestFind', [Query::limit(10)]); + + $this->assertCount(2, $documents); + $this->assertEquals(2, $callCount, 'Transformer should be called for each document'); + + // Verify each document was transformed + foreach ($documents as $doc) { + $this->assertTrue($doc->attributeExists('_index'), 'Each document should have _index attribute'); + } + + // Cleanup + $database->setTransformer(null); + $database->deleteCollection('transformerTestFind'); + } + + public function testTransformerWithCreateDocument(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('transformerTestCreate', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createAttribute('transformerTestCreate', 'name', Database::VAR_STRING, 255, true); + + // Set up transformer + $transformerCalled = false; + + $database->setTransformer(function (Document $document, Document $collection, Database $db) use (&$transformerCalled): Document { + $transformerCalled = true; + $document->setAttribute('_createdViaApi', true); + return $document; + }); + + // Create document - transformer should be called on returned document + $created = $database->createDocument('transformerTestCreate', new Document([ + '$id' => ID::unique(), + 'name' => 'New User', + '$permissions' => [Permission::read(Role::any())], + ])); + + $this->assertTrue($transformerCalled, 'Transformer should be called on create'); + $this->assertTrue($created->getAttribute('_createdViaApi')); + $this->assertEquals('New User', $created->getAttribute('name')); + + // Cleanup + $database->setTransformer(null); + $database->deleteCollection('transformerTestCreate'); + } + + public function testTransformerWithUpdateDocument(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('transformerTestUpdate', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ]); + + $database->createAttribute('transformerTestUpdate', 'status', Database::VAR_STRING, 50, true); + + // Create document without transformer + $database->setTransformer(null); + + $created = $database->createDocument('transformerTestUpdate', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + // Set up transformer for update + $transformerCalled = false; + + $database->setTransformer(function (Document $document, Document $collection, Database $db) use (&$transformerCalled): Document { + $transformerCalled = true; + $document->setAttribute('_lastModifiedBy', 'system'); + return $document; + }); + + // Update document + $updated = $database->updateDocument('transformerTestUpdate', $created->getId(), new Document([ + '$id' => $created->getId(), + 'status' => 'active', + ])); + + $this->assertTrue($transformerCalled, 'Transformer should be called on update'); + $this->assertEquals('system', $updated->getAttribute('_lastModifiedBy')); + $this->assertEquals('active', $updated->getAttribute('status')); + + // Cleanup + $database->setTransformer(null); + $database->deleteCollection('transformerTestUpdate'); + } + + public function testTransformerSkippedForEmptyDocument(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('transformerTestEmpty', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createAttribute('transformerTestEmpty', 'name', Database::VAR_STRING, 255, true); + + // Set up transformer + $transformerCalled = false; + + $database->setTransformer(function (Document $document, Document $collection, Database $db) use (&$transformerCalled): Document { + $transformerCalled = true; + return $document; + }); + + // Try to get non-existent document + $result = $database->getDocument('transformerTestEmpty', 'nonexistent-id'); + + $this->assertTrue($result->isEmpty(), 'Document should be empty'); + $this->assertFalse($transformerCalled, 'Transformer should NOT be called for empty documents'); + + // Cleanup + $database->setTransformer(null); + $database->deleteCollection('transformerTestEmpty'); + } + + public function testTransformerClearedWithNull(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('transformerTestClear', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createAttribute('transformerTestClear', 'value', Database::VAR_STRING, 255, true); + + // Create document + $created = $database->createDocument('transformerTestClear', new Document([ + '$id' => ID::unique(), + 'value' => 'test', + '$permissions' => [Permission::read(Role::any())], + ])); + + // Set transformer + $database->setTransformer(function (Document $document, Document $collection, Database $db): Document { + $document->setAttribute('_transformed', true); + return $document; + }); + + // Verify transformer works + $fetched = $database->getDocument('transformerTestClear', $created->getId()); + $this->assertTrue($fetched->getAttribute('_transformed')); + + // Clear transformer + $database->setTransformer(null); + + // Verify transformer no longer runs + $fetchedAgain = $database->getDocument('transformerTestClear', $created->getId()); + $this->assertNull($fetchedAgain->getAttribute('_transformed')); + + // Cleanup + $database->deleteCollection('transformerTestClear'); + } + + public function testTransformerReceivesCorrectParameters(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('transformerTestParams', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createAttribute('transformerTestParams', 'data', Database::VAR_STRING, 255, true); + + // Create document without transformer + $database->setTransformer(null); + + $created = $database->createDocument('transformerTestParams', new Document([ + '$id' => ID::unique(), + 'data' => 'test value', + '$permissions' => [Permission::read(Role::any())], + ])); + + // Set up transformer that captures parameters + $capturedDocument = null; + $capturedCollection = null; + $capturedDatabase = null; + + $database->setTransformer(function (Document $document, Document $collection, Database $db) use (&$capturedDocument, &$capturedCollection, &$capturedDatabase): Document { + $capturedDocument = $document; + $capturedCollection = $collection; + $capturedDatabase = $db; + return $document; + }); + + // Fetch document + $database->getDocument('transformerTestParams', $created->getId()); + + // Verify parameters + $this->assertInstanceOf(Document::class, $capturedDocument); + $this->assertEquals($created->getId(), $capturedDocument->getId()); + $this->assertEquals('test value', $capturedDocument->getAttribute('data')); + + $this->assertInstanceOf(Document::class, $capturedCollection); + $this->assertEquals('transformerTestParams', $capturedCollection->getId()); + + $this->assertInstanceOf(Database::class, $capturedDatabase); + $this->assertSame($database, $capturedDatabase); + + // Cleanup + $database->setTransformer(null); + $database->deleteCollection('transformerTestParams'); + } + + public function testTransformerMethodChaining(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $transformer = function (Document $document, Document $collection, Database $db): Document { + return $document; + }; + + $result = $database->setTransformer($transformer); + + $this->assertInstanceOf(Database::class, $result); + + // Test chaining with other methods + $result2 = $database + ->setTransformer($transformer) + ->setTransformer(null); + + $this->assertInstanceOf(Database::class, $result2); + $this->assertNull($database->getTransformer()); + } +} diff --git a/tests/unit/TransformerTest.php b/tests/unit/TransformerTest.php new file mode 100644 index 000000000..19baae63f --- /dev/null +++ b/tests/unit/TransformerTest.php @@ -0,0 +1,98 @@ +createMock(Database::class); + + // Create a reflection to access the protected property + $reflection = new \ReflectionClass(Database::class); + $property = $reflection->getProperty('transformer'); + $property->setAccessible(true); + + // Initially null + $this->assertNull($property->getValue($database)); + } + + public function testSetAndGetTransformer(): void + { + // Create a mock that allows calling the real methods + $database = $this->getMockBuilder(Database::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $transformer = function (Document $document, Document $collection, Database $db): Document { + $document->setAttribute('transformed', true); + return $document; + }; + + $result = $database->setTransformer($transformer); + + // Test fluent interface + $this->assertInstanceOf(Database::class, $result); + + // Test getter + $this->assertSame($transformer, $database->getTransformer()); + } + + public function testGetTransformerReturnsNullByDefault(): void + { + $database = $this->getMockBuilder(Database::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $this->assertNull($database->getTransformer()); + } + + public function testSetTransformerToNull(): void + { + $database = $this->getMockBuilder(Database::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $transformer = function (Document $document, Document $collection, Database $db): Document { + return $document; + }; + + $database->setTransformer($transformer); + $this->assertNotNull($database->getTransformer()); + + $database->setTransformer(null); + $this->assertNull($database->getTransformer()); + } + + public function testTransformerMethodChaining(): void + { + $database = $this->getMockBuilder(Database::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $transformer1 = function (Document $document, Document $collection, Database $db): Document { + $document->setAttribute('first', true); + return $document; + }; + + $transformer2 = function (Document $document, Document $collection, Database $db): Document { + $document->setAttribute('second', true); + return $document; + }; + + // Test chaining + $database + ->setTransformer($transformer1) + ->setTransformer($transformer2); + + $this->assertSame($transformer2, $database->getTransformer()); + } +}