From 36fab3a7f6b68120b6aced6c8a0a9cb967ec91d9 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 3 Nov 2025 10:32:21 +0000 Subject: [PATCH 1/5] Feat: External types --- README.md | 38 +++ src/Database/Database.php | 123 ++++++- tests/e2e/Adapter/Base.php | 2 + .../Scopes/CustomDocumentTypeTests.php | 305 ++++++++++++++++++ 4 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php diff --git a/README.md b/README.md index 835bee0ee..91ac65f3a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,44 @@ A list of the utopia/php concepts and their relevant equivalent using the differ Attribute filters are functions that manipulate attributes before saving them to the database and after retrieving them from the database. You can add filters using the `Database::addFilter($name, $encode, $decode)` where `$name` is the name of the filter that we can add later to attribute `filters` array. `$encode` and `$decode` are the functions used to encode and decode the attribute, respectively. There are also instance-level filters that can only be defined while constructing the `Database` instance. Instance level filters override the static filters if they have the same name. +### Custom Document Types + +The database library supports mapping custom document classes to specific collections, enabling a domain-driven design approach. This allows you to create collection-specific classes (like `User`, `Post`, `Product`) that extend the base `Document` class with custom methods and business logic. + +```php +// Define a custom document class +class User extends Document +{ + public function getEmail(): string + { + return $this->getAttribute('email', ''); + } + + public function isAdmin(): bool + { + return $this->getAttribute('role') === 'admin'; + } +} + +// Register the custom type +$database->setDocumentType('users', User::class); + +// Now all documents from 'users' collection are User instances +$user = $database->getDocument('users', 'user123'); +$email = $user->getEmail(); // Use custom methods +if ($user->isAdmin()) { + // Domain logic +} +``` + +**Benefits:** +- ✅ Domain-driven design with business logic in domain objects +- ✅ Type safety with IDE autocomplete for custom methods +- ✅ Code organization and encapsulation +- ✅ Fully backwards compatible + +For complete documentation, see [docs/custom-document-types.md](docs/custom-document-types.md). + ### Reserved Attributes - `$id` - the document unique ID, you can set your own custom ID or a random UID will be generated by the library. diff --git a/src/Database/Database.php b/src/Database/Database.php index cf2c4cdf0..958d30a04 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -414,6 +414,12 @@ class Database */ protected array $relationshipDeleteStack = []; + /** + * Type mapping for collections to custom document classes + * @var array> + */ + protected array $documentTypes = []; + /** * @param Adapter $adapter * @param Cache $cache @@ -1186,6 +1192,79 @@ public function getTenantPerDocument(): bool return $this->adapter->getTenantPerDocument(); } + /** + * Set custom document class for a collection + * + * @param string $collection Collection ID + * @param class-string $className Fully qualified class name that extends Document + * @return static + * @throws DatabaseException + */ + public function setDocumentType(string $collection, string $className): static + { + if (!\class_exists($className)) { + throw new DatabaseException("Class {$className} does not exist"); + } + + if (!\is_subclass_of($className, Document::class)) { + throw new DatabaseException("Class {$className} must extend " . Document::class); + } + + $this->documentTypes[$collection] = $className; + + return $this; + } + + /** + * Get custom document class for a collection + * + * @param string $collection Collection ID + * @return class-string|null + */ + public function getDocumentType(string $collection): ?string + { + return $this->documentTypes[$collection] ?? null; + } + + /** + * Clear document type mapping for a collection + * + * @param string $collection Collection ID + * @return static + */ + public function clearDocumentType(string $collection): static + { + unset($this->documentTypes[$collection]); + + return $this; + } + + /** + * Clear all document type mappings + * + * @return static + */ + public function clearAllDocumentTypes(): static + { + $this->documentTypes = []; + + return $this; + } + + /** + * Create a document instance of the appropriate type + * + * @param string $collection Collection ID + * @param array $data Document data + * @return Document + */ + protected function createDocumentInstance(string $collection, array $data): Document + { + $className = $this->documentTypes[$collection] ?? Document::class; + + return new $className($data); + } + public function getPreserveDates(): bool { return $this->preserveDates; @@ -3625,11 +3704,12 @@ public function deleteIndex(string $collection, string $id): bool /** * Get Document * + * @template T of Document * @param string $collection * @param string $id * @param Query[] $queries * @param bool $forUpdate - * @return Document + * @return T|Document * @throws NotFoundException * @throws QueryException * @throws Exception @@ -3691,14 +3771,14 @@ public function getDocument(string $collection, string $id, array $queries = [], } if ($cached) { - $document = new Document($cached); + $document = $this->createDocumentInstance($collection->getId(), $cached); if ($collection->getId() !== self::METADATA) { if (!$validator->isValid([ ...$collection->getRead(), ...($documentSecurity ? $document->getRead() : []) ])) { - return new Document(); + return $this->createDocumentInstance($collection->getId(), []); } } @@ -3715,11 +3795,16 @@ public function getDocument(string $collection, string $id, array $queries = [], ); if ($document->isEmpty()) { - return $document; + return $this->createDocumentInstance($collection->getId(), []); } $document = $this->adapter->castingAfter($collection, $document); + // Convert to custom document type if mapped + if (isset($this->documentTypes[$collection->getId()])) { + $document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy()); + } + $document->setAttribute('$collection', $collection->getId()); if ($collection->getId() !== self::METADATA) { @@ -3727,7 +3812,7 @@ public function getDocument(string $collection, string $id, array $queries = [], ...$collection->getRead(), ...($documentSecurity ? $document->getRead() : []) ])) { - return new Document(); + return $this->createDocumentInstance($collection->getId(), []); } } @@ -4359,11 +4444,10 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu /** * Create Document * + * @template T of Document * @param string $collection * @param Document $document - * - * @return Document - * + * @return T|Document * @throws AuthorizationException * @throws DatabaseException * @throws StructureException @@ -4462,6 +4546,11 @@ public function createDocument(string $collection, Document $document): Document $document = $this->casting($collection, $document); $document = $this->decode($collection, $document); + // Convert to custom document type if mapped + if (isset($this->documentTypes[$collection->getId()])) { + $document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy()); + } + $this->trigger(self::EVENT_DOCUMENT_CREATE, $document); return $document; @@ -4911,11 +5000,11 @@ private function relateDocumentsById( /** * Update Document * + * @template T of Document * @param string $collection * @param string $id * @param Document $document - * @return Document - * + * @return T|Document * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException @@ -5146,6 +5235,11 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->decode($collection, $document); + // Convert to custom document type if mapped + if (isset($this->documentTypes[$collection->getId()])) { + $document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy()); + } + $this->trigger(self::EVENT_DOCUMENT_UPDATE, $document); return $document; @@ -7017,11 +7111,11 @@ public function purgeCachedDocument(string $collectionId, ?string $id): bool /** * Find Documents * + * @template T of Document * @param string $collection * @param array $queries * @param string $forPermission - * - * @return array + * @return array * @throws DatabaseException * @throws QueryException * @throws TimeoutException @@ -7157,6 +7251,11 @@ public function find(string $collection, array $queries = [], string $forPermiss $node = $this->casting($collection, $node); $node = $this->decode($collection, $node, $selections); + // Convert to custom document type if mapped + if (isset($this->documentTypes[$collection->getId()])) { + $node = $this->createDocumentInstance($collection->getId(), $node->getArrayCopy()); + } + if (!$node->isEmpty()) { $node->setAttribute('$collection', $collection->getId()); } diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 441125241..3643d8d60 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Tests\E2E\Adapter\Scopes\AttributeTests; use Tests\E2E\Adapter\Scopes\CollectionTests; +use Tests\E2E\Adapter\Scopes\CustomDocumentTypeTests; use Tests\E2E\Adapter\Scopes\DocumentTests; use Tests\E2E\Adapter\Scopes\GeneralTests; use Tests\E2E\Adapter\Scopes\IndexTests; @@ -22,6 +23,7 @@ abstract class Base extends TestCase { use CollectionTests; + use CustomDocumentTypeTests; use DocumentTests; use AttributeTests; use IndexTests; diff --git a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php new file mode 100644 index 000000000..037494b90 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php @@ -0,0 +1,305 @@ +getAttribute('email', ''); + } + + public function getName(): string + { + return $this->getAttribute('name', ''); + } + + public function isActive(): bool + { + return $this->getAttribute('status') === 'active'; + } +} + +class TestPost extends Document +{ + public function getTitle(): string + { + return $this->getAttribute('title', ''); + } + + public function getContent(): string + { + return $this->getAttribute('content', ''); + } +} + +trait CustomDocumentTypeTests +{ + public function testSetDocumentType(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $database->setDocumentType('users', TestUser::class); + + $this->assertEquals( + TestUser::class, + $database->getDocumentType('users') + ); + } + + public function testGetDocumentTypeReturnsNull(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $this->assertNull($database->getDocumentType('nonexistent_collection')); + } + + public function testSetDocumentTypeWithInvalidClass(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not exist'); + + $database->setDocumentType('users', 'NonExistentClass'); + } + + public function testSetDocumentTypeWithNonDocumentClass(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('must extend'); + + $database->setDocumentType('users', \stdClass::class); + } + + public function testClearDocumentType(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $database->setDocumentType('users', TestUser::class); + $this->assertEquals(TestUser::class, $database->getDocumentType('users')); + + $database->clearDocumentType('users'); + $this->assertNull($database->getDocumentType('users')); + } + + public function testClearAllDocumentTypes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $database->setDocumentType('users', TestUser::class); + $database->setDocumentType('posts', TestPost::class); + + $this->assertEquals(TestUser::class, $database->getDocumentType('users')); + $this->assertEquals(TestPost::class, $database->getDocumentType('posts')); + + $database->clearAllDocumentTypes(); + + $this->assertNull($database->getDocumentType('users')); + $this->assertNull($database->getDocumentType('posts')); + } + + public function testMethodChaining(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $result = $database->setDocumentType('users', TestUser::class); + + $this->assertInstanceOf(Database::class, $result); + + $database + ->setDocumentType('users', TestUser::class) + ->setDocumentType('posts', TestPost::class); + + $this->assertEquals(TestUser::class, $database->getDocumentType('users')); + $this->assertEquals(TestPost::class, $database->getDocumentType('posts')); + } + + public function testCustomDocumentTypeWithGetDocument(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('customUsers', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + $database->createAttribute('customUsers', 'email', Database::VAR_STRING, 255, true); + $database->createAttribute('customUsers', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('customUsers', 'status', Database::VAR_STRING, 50, true); + + $database->setDocumentType('customUsers', TestUser::class); + + /** @var TestUser $created */ + $created = $database->createDocument('customUsers', new Document([ + '$id' => ID::unique(), + 'email' => 'test@example.com', + 'name' => 'Test User', + 'status' => 'active', + '$permissions' => [Permission::read(Role::any())], + ])); + + // Verify it's a TestUser instance + $this->assertInstanceOf(TestUser::class, $created); + $this->assertEquals('test@example.com', $created->getEmail()); + $this->assertEquals('Test User', $created->getName()); + $this->assertTrue($created->isActive()); + + // Get document and verify type + /** @var TestUser $fetched */ + $fetched = $database->getDocument('customUsers', $created->getId()); + $this->assertInstanceOf(TestUser::class, $fetched); + $this->assertEquals('test@example.com', $fetched->getEmail()); + $this->assertTrue($fetched->isActive()); + + // Cleanup + $database->deleteCollection('customUsers'); + $database->clearDocumentType('customUsers'); + } + + public function testCustomDocumentTypeWithFind(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('customPosts', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createAttribute('customPosts', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('customPosts', 'content', Database::VAR_STRING, 5000, true); + + // Register custom type + $database->setDocumentType('customPosts', TestPost::class); + + // Create multiple documents + $post1 = $database->createDocument('customPosts', new Document([ + '$id' => ID::unique(), + 'title' => 'First Post', + 'content' => 'This is the first post', + '$permissions' => [Permission::read(Role::any())], + ])); + + $post2 = $database->createDocument('customPosts', new Document([ + '$id' => ID::unique(), + 'title' => 'Second Post', + 'content' => 'This is the second post', + '$permissions' => [Permission::read(Role::any())], + ])); + + // Find documents + /** @var TestPost[] $posts */ + $posts = $database->find('customPosts', [Query::limit(10)]); + + $this->assertCount(2, $posts); + $this->assertInstanceOf(TestPost::class, $posts[0]); + $this->assertInstanceOf(TestPost::class, $posts[1]); + $this->assertEquals('First Post', $posts[0]->getTitle()); + $this->assertEquals('Second Post', $posts[1]->getTitle()); + + // Cleanup + $database->deleteCollection('customPosts'); + $database->clearDocumentType('customPosts'); + } + + public function testCustomDocumentTypeWithUpdateDocument(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection + $database->createCollection('customUsersUpdate', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ]); + + $database->createAttribute('customUsersUpdate', 'email', Database::VAR_STRING, 255, true); + $database->createAttribute('customUsersUpdate', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('customUsersUpdate', 'status', Database::VAR_STRING, 50, true); + + // Register custom type + $database->setDocumentType('customUsersUpdate', TestUser::class); + + // Create document + /** @var TestUser $created */ + $created = $database->createDocument('customUsersUpdate', new Document([ + '$id' => ID::unique(), + 'email' => 'original@example.com', + 'name' => 'Original Name', + 'status' => 'active', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + // Update document + /** @var TestUser $updated */ + $updated = $database->updateDocument('customUsersUpdate', $created->getId(), new Document([ + '$id' => $created->getId(), + 'email' => 'updated@example.com', + 'name' => 'Updated Name', + 'status' => 'inactive', + ])); + + // Verify it's still TestUser and has updated values + $this->assertInstanceOf(TestUser::class, $updated); + $this->assertEquals('updated@example.com', $updated->getEmail()); + $this->assertEquals('Updated Name', $updated->getName()); + $this->assertFalse($updated->isActive()); + + // Cleanup + $database->deleteCollection('customUsersUpdate'); + $database->clearDocumentType('customUsersUpdate'); + } + + public function testDefaultDocumentForUnmappedCollection(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Create collection without custom type + $database->createCollection('unmappedCollection', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createAttribute('unmappedCollection', 'data', Database::VAR_STRING, 255, true); + + // Create document + $created = $database->createDocument('unmappedCollection', new Document([ + '$id' => ID::unique(), + 'data' => 'test data', + '$permissions' => [Permission::read(Role::any())], + ])); + + // Should be regular Document, not custom type + $this->assertInstanceOf(Document::class, $created); + $this->assertNotInstanceOf(TestUser::class, $created); + + // Cleanup + $database->deleteCollection('unmappedCollection'); + } +} From 15382204ab8a39a1571256d89e46aba00ca93338 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 3 Nov 2025 10:38:29 +0000 Subject: [PATCH 2/5] Fix phpstan errors --- src/Database/Database.php | 12 ++++-------- tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php | 10 +++++----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 958d30a04..7673e2aa2 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3704,12 +3704,11 @@ public function deleteIndex(string $collection, string $id): bool /** * Get Document * - * @template T of Document * @param string $collection * @param string $id * @param Query[] $queries * @param bool $forUpdate - * @return T|Document + * @return Document * @throws NotFoundException * @throws QueryException * @throws Exception @@ -4444,10 +4443,9 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu /** * Create Document * - * @template T of Document * @param string $collection * @param Document $document - * @return T|Document + * @return Document * @throws AuthorizationException * @throws DatabaseException * @throws StructureException @@ -5000,11 +4998,10 @@ private function relateDocumentsById( /** * Update Document * - * @template T of Document * @param string $collection * @param string $id * @param Document $document - * @return T|Document + * @return Document * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException @@ -7111,11 +7108,10 @@ public function purgeCachedDocument(string $collectionId, ?string $id): bool /** * Find Documents * - * @template T of Document * @param string $collection * @param array $queries * @param string $forPermission - * @return array + * @return array * @throws DatabaseException * @throws QueryException * @throws TimeoutException diff --git a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php index 037494b90..48ad6027f 100644 --- a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php +++ b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php @@ -72,18 +72,18 @@ public function testSetDocumentTypeWithInvalidClass(): void $this->expectException(DatabaseException::class); $this->expectExceptionMessage('does not exist'); - + + // @phpstan-ignore-next-line - Testing with invalid class name $database->setDocumentType('users', 'NonExistentClass'); - } - - public function testSetDocumentTypeWithNonDocumentClass(): void + } public function testSetDocumentTypeWithNonDocumentClass(): void { /** @var Database $database */ $database = static::getDatabase(); $this->expectException(DatabaseException::class); $this->expectExceptionMessage('must extend'); - + + // @phpstan-ignore-next-line - Testing with non-Document class $database->setDocumentType('users', \stdClass::class); } From 9436abec3b4229d1954e1b8b4242b7f755ecbb9e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 3 Nov 2025 10:39:37 +0000 Subject: [PATCH 3/5] format --- tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php index 48ad6027f..3a18c861b 100644 --- a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php +++ b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php @@ -72,7 +72,7 @@ public function testSetDocumentTypeWithInvalidClass(): void $this->expectException(DatabaseException::class); $this->expectExceptionMessage('does not exist'); - + // @phpstan-ignore-next-line - Testing with invalid class name $database->setDocumentType('users', 'NonExistentClass'); } public function testSetDocumentTypeWithNonDocumentClass(): void @@ -82,7 +82,7 @@ public function testSetDocumentTypeWithInvalidClass(): void $this->expectException(DatabaseException::class); $this->expectExceptionMessage('must extend'); - + // @phpstan-ignore-next-line - Testing with non-Document class $database->setDocumentType('users', \stdClass::class); } From 0ae843eeaae1a8cd1a05b0cb480687bbf8b84264 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 3 Nov 2025 10:42:00 +0000 Subject: [PATCH 4/5] wire cleanup --- tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php index 3a18c861b..9953e73e2 100644 --- a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php +++ b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php @@ -55,6 +55,9 @@ public function testSetDocumentType(): void TestUser::class, $database->getDocumentType('users') ); + + // Cleanup + $database->clearDocumentType('users'); } public function testGetDocumentTypeReturnsNull(): void @@ -63,6 +66,8 @@ public function testGetDocumentTypeReturnsNull(): void $database = static::getDatabase(); $this->assertNull($database->getDocumentType('nonexistent_collection')); + + // No cleanup needed - no types were set } public function testSetDocumentTypeWithInvalidClass(): void @@ -131,6 +136,9 @@ public function testMethodChaining(): void $this->assertEquals(TestUser::class, $database->getDocumentType('users')); $this->assertEquals(TestPost::class, $database->getDocumentType('posts')); + + // Cleanup to prevent test pollution + $database->clearAllDocumentTypes(); } public function testCustomDocumentTypeWithGetDocument(): void From a3384011cd912053192af80f22c72f2e77ffdd14 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 3 Nov 2025 10:43:04 +0000 Subject: [PATCH 5/5] fix missing link --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 91ac65f3a..15ff0da75 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,6 @@ if ($user->isAdmin()) { - ✅ Code organization and encapsulation - ✅ Fully backwards compatible -For complete documentation, see [docs/custom-document-types.md](docs/custom-document-types.md). - ### Reserved Attributes - `$id` - the document unique ID, you can set your own custom ID or a random UID will be generated by the library.