Skip to content

Commit d6b8454

Browse files
committed
Feat: External types
1 parent 76568b8 commit d6b8454

4 files changed

Lines changed: 455 additions & 12 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,44 @@ A list of the utopia/php concepts and their relevant equivalent using the differ
3232

3333
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.
3434

35+
### Custom Document Types
36+
37+
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.
38+
39+
```php
40+
// Define a custom document class
41+
class User extends Document
42+
{
43+
public function getEmail(): string
44+
{
45+
return $this->getAttribute('email', '');
46+
}
47+
48+
public function isAdmin(): bool
49+
{
50+
return $this->getAttribute('role') === 'admin';
51+
}
52+
}
53+
54+
// Register the custom type
55+
$database->setDocumentType('users', User::class);
56+
57+
// Now all documents from 'users' collection are User instances
58+
$user = $database->getDocument('users', 'user123');
59+
$email = $user->getEmail(); // Use custom methods
60+
if ($user->isAdmin()) {
61+
// Domain logic
62+
}
63+
```
64+
65+
**Benefits:**
66+
- ✅ Domain-driven design with business logic in domain objects
67+
- ✅ Type safety with IDE autocomplete for custom methods
68+
- ✅ Code organization and encapsulation
69+
- ✅ Fully backwards compatible
70+
71+
For complete documentation, see [docs/custom-document-types.md](docs/custom-document-types.md).
72+
3573
### Reserved Attributes
3674

3775
- `$id` - the document unique ID, you can set your own custom ID or a random UID will be generated by the library.

src/Database/Database.php

Lines changed: 110 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,12 @@ class Database
414414
*/
415415
protected array $relationshipDeleteStack = [];
416416

417+
/**
418+
* Type mapping for collections to custom document classes
419+
* @var array<string, class-string<Document>>
420+
*/
421+
protected array $documentTypes = [];
422+
417423
/**
418424
* @param Adapter $adapter
419425
* @param Cache $cache
@@ -1202,6 +1208,78 @@ public function enableLocks(bool $enabled): static
12021208

12031209
return $this;
12041210
}
1211+
/**
1212+
* Set custom document class for a collection
1213+
*
1214+
* @param string $collection Collection ID
1215+
* @param class-string<Document> $className Fully qualified class name that extends Document
1216+
* @return static
1217+
* @throws DatabaseException
1218+
*/
1219+
public function setDocumentType(string $collection, string $className): static
1220+
{
1221+
if (!\class_exists($className)) {
1222+
throw new DatabaseException("Class {$className} does not exist");
1223+
}
1224+
1225+
if (!\is_subclass_of($className, Document::class)) {
1226+
throw new DatabaseException("Class {$className} must extend " . Document::class);
1227+
}
1228+
1229+
$this->documentTypes[$collection] = $className;
1230+
1231+
return $this;
1232+
}
1233+
1234+
/**
1235+
* Get custom document class for a collection
1236+
*
1237+
* @param string $collection Collection ID
1238+
* @return class-string<Document>|null
1239+
*/
1240+
public function getDocumentType(string $collection): ?string
1241+
{
1242+
return $this->documentTypes[$collection] ?? null;
1243+
}
1244+
1245+
/**
1246+
* Clear document type mapping for a collection
1247+
*
1248+
* @param string $collection Collection ID
1249+
* @return static
1250+
*/
1251+
public function clearDocumentType(string $collection): static
1252+
{
1253+
unset($this->documentTypes[$collection]);
1254+
1255+
return $this;
1256+
}
1257+
1258+
/**
1259+
* Clear all document type mappings
1260+
*
1261+
* @return static
1262+
*/
1263+
public function clearAllDocumentTypes(): static
1264+
{
1265+
$this->documentTypes = [];
1266+
1267+
return $this;
1268+
}
1269+
1270+
/**
1271+
* Create a document instance of the appropriate type
1272+
*
1273+
* @param string $collection Collection ID
1274+
* @param array<string, mixed> $data Document data
1275+
* @return Document
1276+
*/
1277+
protected function createDocumentInstance(string $collection, array $data): Document
1278+
{
1279+
$className = $this->documentTypes[$collection] ?? Document::class;
1280+
1281+
return new $className($data);
1282+
}
12051283

12061284
public function getPreserveDates(): bool
12071285
{
@@ -3642,11 +3720,12 @@ public function deleteIndex(string $collection, string $id): bool
36423720
/**
36433721
* Get Document
36443722
*
3723+
* @template T of Document
36453724
* @param string $collection
36463725
* @param string $id
36473726
* @param Query[] $queries
36483727
* @param bool $forUpdate
3649-
* @return Document
3728+
* @return T|Document
36503729
* @throws NotFoundException
36513730
* @throws QueryException
36523731
* @throws Exception
@@ -3708,14 +3787,14 @@ public function getDocument(string $collection, string $id, array $queries = [],
37083787
}
37093788

37103789
if ($cached) {
3711-
$document = new Document($cached);
3790+
$document = $this->createDocumentInstance($collection->getId(), $cached);
37123791

37133792
if ($collection->getId() !== self::METADATA) {
37143793
if (!$validator->isValid([
37153794
...$collection->getRead(),
37163795
...($documentSecurity ? $document->getRead() : [])
37173796
])) {
3718-
return new Document();
3797+
return $this->createDocumentInstance($collection->getId(), []);
37193798
}
37203799
}
37213800

@@ -3732,19 +3811,24 @@ public function getDocument(string $collection, string $id, array $queries = [],
37323811
);
37333812

37343813
if ($document->isEmpty()) {
3735-
return $document;
3814+
return $this->createDocumentInstance($collection->getId(), []);
37363815
}
37373816

37383817
$document = $this->adapter->castingAfter($collection, $document);
37393818

3819+
// Convert to custom document type if mapped
3820+
if (isset($this->documentTypes[$collection->getId()])) {
3821+
$document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy());
3822+
}
3823+
37403824
$document->setAttribute('$collection', $collection->getId());
37413825

37423826
if ($collection->getId() !== self::METADATA) {
37433827
if (!$validator->isValid([
37443828
...$collection->getRead(),
37453829
...($documentSecurity ? $document->getRead() : [])
37463830
])) {
3747-
return new Document();
3831+
return $this->createDocumentInstance($collection->getId(), []);
37483832
}
37493833
}
37503834

@@ -4376,11 +4460,10 @@ private function applySelectFiltersToDocuments(array $documents, array $selectQu
43764460
/**
43774461
* Create Document
43784462
*
4463+
* @template T of Document
43794464
* @param string $collection
43804465
* @param Document $document
4381-
*
4382-
* @return Document
4383-
*
4466+
* @return T|Document
43844467
* @throws AuthorizationException
43854468
* @throws DatabaseException
43864469
* @throws StructureException
@@ -4481,6 +4564,11 @@ public function createDocument(string $collection, Document $document): Document
44814564
$document = $this->casting($collection, $document);
44824565
$document = $this->decode($collection, $document);
44834566

4567+
// Convert to custom document type if mapped
4568+
if (isset($this->documentTypes[$collection->getId()])) {
4569+
$document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy());
4570+
}
4571+
44844572
$this->trigger(self::EVENT_DOCUMENT_CREATE, $document);
44854573

44864574
return $document;
@@ -4932,11 +5020,11 @@ private function relateDocumentsById(
49325020
/**
49335021
* Update Document
49345022
*
5023+
* @template T of Document
49355024
* @param string $collection
49365025
* @param string $id
49375026
* @param Document $document
4938-
* @return Document
4939-
*
5027+
* @return T|Document
49405028
* @throws AuthorizationException
49415029
* @throws ConflictException
49425030
* @throws DatabaseException
@@ -5169,6 +5257,11 @@ public function updateDocument(string $collection, string $id, Document $documen
51695257

51705258
$document = $this->decode($collection, $document);
51715259

5260+
// Convert to custom document type if mapped
5261+
if (isset($this->documentTypes[$collection->getId()])) {
5262+
$document = $this->createDocumentInstance($collection->getId(), $document->getArrayCopy());
5263+
}
5264+
51725265
$this->trigger(self::EVENT_DOCUMENT_UPDATE, $document);
51735266

51745267
return $document;
@@ -7044,11 +7137,11 @@ public function purgeCachedDocument(string $collectionId, ?string $id): bool
70447137
/**
70457138
* Find Documents
70467139
*
7140+
* @template T of Document
70477141
* @param string $collection
70487142
* @param array<Query> $queries
70497143
* @param string $forPermission
7050-
*
7051-
* @return array<Document>
7144+
* @return array<T|Document>
70527145
* @throws DatabaseException
70537146
* @throws QueryException
70547147
* @throws TimeoutException
@@ -7184,6 +7277,11 @@ public function find(string $collection, array $queries = [], string $forPermiss
71847277
$node = $this->casting($collection, $node);
71857278
$node = $this->decode($collection, $node, $selections);
71867279

7280+
// Convert to custom document type if mapped
7281+
if (isset($this->documentTypes[$collection->getId()])) {
7282+
$node = $this->createDocumentInstance($collection->getId(), $node->getArrayCopy());
7283+
}
7284+
71877285
if (!$node->isEmpty()) {
71887286
$node->setAttribute('$collection', $collection->getId());
71897287
}

tests/e2e/Adapter/Base.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use PHPUnit\Framework\TestCase;
66
use Tests\E2E\Adapter\Scopes\AttributeTests;
77
use Tests\E2E\Adapter\Scopes\CollectionTests;
8+
use Tests\E2E\Adapter\Scopes\CustomDocumentTypeTests;
89
use Tests\E2E\Adapter\Scopes\DocumentTests;
910
use Tests\E2E\Adapter\Scopes\GeneralTests;
1011
use Tests\E2E\Adapter\Scopes\IndexTests;
@@ -22,6 +23,7 @@
2223
abstract class Base extends TestCase
2324
{
2425
use CollectionTests;
26+
use CustomDocumentTypeTests;
2527
use DocumentTests;
2628
use AttributeTests;
2729
use IndexTests;

0 commit comments

Comments
 (0)