diff --git a/README.md b/README.md index 835bee0ee..15ff0da75 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 fcb421f15..b1de4f03e 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 @@ -1203,6 +1209,79 @@ public function enableLocks(bool $enabled): static return $this; } + /** + * 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; @@ -3708,14 +3787,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(), []); } } @@ -3732,11 +3811,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) { @@ -3744,7 +3828,7 @@ public function getDocument(string $collection, string $id, array $queries = [], ...$collection->getRead(), ...($documentSecurity ? $document->getRead() : []) ])) { - return new Document(); + return $this->createDocumentInstance($collection->getId(), []); } } @@ -4378,9 +4462,7 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu * * @param string $collection * @param Document $document - * * @return Document - * * @throws AuthorizationException * @throws DatabaseException * @throws StructureException @@ -4481,6 +4563,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; @@ -4936,7 +5023,6 @@ private function relateDocumentsById( * @param string $id * @param Document $document * @return Document - * * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException @@ -5169,6 +5255,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; @@ -7047,7 +7138,6 @@ public function purgeCachedDocument(string $collectionId, ?string $id): bool * @param string $collection * @param array $queries * @param string $forPermission - * * @return array * @throws DatabaseException * @throws QueryException @@ -7184,6 +7274,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/src/Database/Mirror.php b/src/Database/Mirror.php index dac23a063..d9a6d09df 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -1099,4 +1099,46 @@ protected function logError(string $action, \Throwable $err): void $callback($action, $err); } } + + /** + * 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 + */ + public function setDocumentType(string $collection, string $className): static + { + $this->delegate(__FUNCTION__, \func_get_args()); + $this->documentTypes[$collection] = $className; + return $this; + } + + /** + * Clear document type mapping for a collection + * + * @param string $collection Collection ID + * @return static + */ + public function clearDocumentType(string $collection): static + { + $this->delegate(__FUNCTION__, \func_get_args()); + unset($this->documentTypes[$collection]); + + return $this; + } + + /** + * Clear all document type mappings + * + * @return static + */ + public function clearAllDocumentTypes(): static + { + $this->delegate(__FUNCTION__); + $this->documentTypes = []; + + return $this; + } + } 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..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'); + } +}