diff --git a/README.md b/README.md index 12e901bb3..309966b1d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,42 @@ 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 + ### 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 fa1718b54..c2cbecf19 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -421,6 +421,12 @@ class Database */ private Authorization $authorization; + /** + * Type mapping for collections to custom document classes + * @var array> + */ + protected array $documentTypes = []; + /** * @param Adapter $adapter * @param Cache $cache @@ -1218,6 +1224,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; @@ -3722,7 +3801,7 @@ 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) { @@ -3730,7 +3809,7 @@ public function getDocument(string $collection, string $id, array $queries = [], ...$collection->getRead(), ...($documentSecurity ? $document->getRead() : []) ]))) { - return new Document(); + return $this->createDocumentInstance($collection->getId(), []); } } @@ -3747,11 +3826,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) { @@ -3759,7 +3843,7 @@ public function getDocument(string $collection, string $id, array $queries = [], ...$collection->getRead(), ...($documentSecurity ? $document->getRead() : []) ]))) { - return new Document(); + return $this->createDocumentInstance($collection->getId(), []); } } @@ -4393,9 +4477,7 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu * * @param string $collection * @param Document $document - * * @return Document - * * @throws AuthorizationException * @throws DatabaseException * @throws StructureException @@ -4496,6 +4578,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; @@ -4950,7 +5037,6 @@ private function relateDocumentsById( * @param string $id * @param Document $document * @return Document - * * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException @@ -5185,6 +5271,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; @@ -7053,7 +7144,6 @@ public function purgeCachedDocument(string $collectionId, ?string $id): bool * @param string $collection * @param array $queries * @param string $forPermission - * * @return array * @throws DatabaseException * @throws QueryException @@ -7190,6 +7280,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 c98bc6d6e..1e0ebc880 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..9953e73e2 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php @@ -0,0 +1,313 @@ +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') + ); + + // Cleanup + $database->clearDocumentType('users'); + } + + public function testGetDocumentTypeReturnsNull(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $this->assertNull($database->getDocumentType('nonexistent_collection')); + + // No cleanup needed - no types were set + } + + public function testSetDocumentTypeWithInvalidClass(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $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 + { + /** @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); + } + + 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')); + + // Cleanup to prevent test pollution + $database->clearAllDocumentTypes(); + } + + 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'); + } +}