Skip to content

Commit 3111aa2

Browse files
abnegateclaude
andcommitted
feat(orm): map entities onto collections
Splits out of the query-lib migration, which had carried this along with it. The mapper is a feature we chose; the migration is a move every caller has to make, and a reviewer could not take one without the other while they shared a branch. An entity is a plain class carrying attributes -- Entity, Column, Id, BelongsTo, Embedded, SoftDelete and the lifecycle hooks -- which MetadataFactory reads once and EntityMapper turns into the Attribute and Index objects createCollection now takes. EntityManager tracks what has been loaded and what has changed, and flush() writes the difference. Database gains persistEntity, findEntity, findEntities, flushEntities and the rest as the entry points. Introspector::generateEntityClass() goes the other way, reading an existing collection and emitting the entity class for it. Stacked on feat-query-lib rather than main: the mapper is written against Attribute, Collection and ColumnType, which the migration introduces. Two things to settle before this is used anywhere, neither of which the unit tests can answer: The IdentityMap is a plain array on an EntityManager held by the Database handle, and nothing in here is coroutine-scoped. Cloud shares one handle across coroutines -- that is why its pool pins per coroutine -- so an identity map on that handle would accumulate process-wide and be shared between concurrent requests. It needs the same scoping the pool pins get, or an EntityManager per request, before a Swoole caller touches it. Nothing exercises it against a real engine. The 3,729 lines of tests here are unit tests over the mapping and the unit of work; there is no E2E, and no caller in appwrite or cloud. Flush ordering against relationships and the interaction with the existing document cache are the parts I would want a live test over first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 771a8e2 commit 3111aa2

53 files changed

Lines changed: 5681 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/Database/Database.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Database
4141
use Traits\Collections;
4242
use Traits\Databases;
4343
use Traits\Documents;
44+
use Traits\Entities;
4445
use Traits\Indexes;
4546
use Traits\Relationships;
4647
use Traits\Transactions;

‎src/Database/ORM/ColumnMapping.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Utopia\Database\ORM;
4+
5+
use Utopia\Database\ORM\Mapping\Column;
6+
7+
class ColumnMapping
8+
{
9+
public function __construct(
10+
public readonly string $propertyName,
11+
public readonly string $documentKey,
12+
public readonly Column $column,
13+
) {
14+
}
15+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Utopia\Database\ORM;
4+
5+
class EmbeddableMapping
6+
{
7+
public function __construct(
8+
public readonly string $propertyName,
9+
public readonly string $typeName,
10+
public readonly string $prefix,
11+
) {
12+
}
13+
}

‎src/Database/ORM/EntityManager.php‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
<?php
2+
3+
namespace Utopia\Database\ORM;
4+
5+
use Utopia\Database\Database;
6+
use Utopia\Database\Document;
7+
use Utopia\Database\Query;
8+
9+
class EntityManager
10+
{
11+
private UnitOfWork $unitOfWork;
12+
13+
private IdentityMap $identityMap;
14+
15+
private MetadataFactory $metadataFactory;
16+
17+
private EntityMapper $entityMapper;
18+
19+
private Database $db;
20+
21+
public function __construct(Database $db)
22+
{
23+
$this->db = $db;
24+
$this->identityMap = new IdentityMap();
25+
$this->metadataFactory = new MetadataFactory();
26+
$this->entityMapper = new EntityMapper($this->metadataFactory);
27+
$this->unitOfWork = new UnitOfWork(
28+
$this->identityMap,
29+
$this->metadataFactory,
30+
$this->entityMapper,
31+
);
32+
}
33+
34+
public function persist(object $entity): void
35+
{
36+
$this->unitOfWork->persist($entity);
37+
}
38+
39+
public function remove(object $entity): void
40+
{
41+
$this->unitOfWork->remove($entity);
42+
}
43+
44+
public function forceRemove(object $entity): void
45+
{
46+
$this->unitOfWork->forceRemove($entity);
47+
}
48+
49+
public function restore(object $entity): void
50+
{
51+
$this->unitOfWork->restore($entity);
52+
}
53+
54+
public function flush(): void
55+
{
56+
$this->unitOfWork->flush($this->db);
57+
}
58+
59+
/**
60+
* @template T of object
61+
* @param class-string<T> $className
62+
* @return T|null
63+
*/
64+
public function find(string $className, string $id): ?object
65+
{
66+
$metadata = $this->metadataFactory->getMetadata($className);
67+
68+
$existing = $this->identityMap->get($metadata->collection, $id);
69+
if ($existing !== null) {
70+
/** @var T $existing */
71+
return $existing;
72+
}
73+
74+
$document = $this->db->getDocument($metadata->collection, $id);
75+
76+
if ($document->isEmpty()) {
77+
return null;
78+
}
79+
80+
/** @var T $entity */
81+
$entity = $this->entityMapper->toEntity($document, $metadata, $this->identityMap);
82+
$this->unitOfWork->registerManaged($entity, $metadata);
83+
84+
return $entity;
85+
}
86+
87+
/**
88+
* @template T of object
89+
* @param class-string<T> $className
90+
* @param array<Query> $queries
91+
* @return array<T>
92+
*/
93+
public function findMany(string $className, array $queries = [], bool $withTrashed = false): array
94+
{
95+
$metadata = $this->metadataFactory->getMetadata($className);
96+
97+
if (! $withTrashed && $metadata->softDeleteColumn !== null) {
98+
$queries[] = Query::isNull($metadata->softDeleteColumn);
99+
}
100+
101+
$documents = $this->db->find($metadata->collection, $queries);
102+
$entities = [];
103+
104+
foreach ($documents as $document) {
105+
/** @var T $entity */
106+
$entity = $this->entityMapper->toEntity($document, $metadata, $this->identityMap);
107+
$this->unitOfWork->registerManaged($entity, $metadata);
108+
$entities[] = $entity;
109+
}
110+
111+
return $entities;
112+
}
113+
114+
/**
115+
* @template T of object
116+
* @param class-string<T> $className
117+
* @param array<Query> $queries
118+
* @return T|null
119+
*/
120+
public function findOne(string $className, array $queries = []): ?object
121+
{
122+
$queries[] = Query::limit(1);
123+
$results = $this->findMany($className, $queries);
124+
125+
if ($results === []) {
126+
return null;
127+
}
128+
129+
/** @var T */
130+
return $results[0];
131+
}
132+
133+
public function createCollectionFromEntity(string $className): Document
134+
{
135+
$metadata = $this->metadataFactory->getMetadata($className);
136+
$defs = $this->entityMapper->toCollectionDefinitions($metadata);
137+
138+
/** @var \Utopia\Database\Collection $collection */
139+
$collection = $defs['collection'];
140+
/** @var array<\Utopia\Database\Relationship> $relationships */
141+
$relationships = $defs['relationships'];
142+
143+
$doc = $this->db->createCollection($collection);
144+
145+
foreach ($relationships as $relationship) {
146+
$this->db->createRelationship($relationship);
147+
}
148+
149+
return $doc;
150+
}
151+
152+
public function syncCollectionFromEntity(string $className): void
153+
{
154+
$metadata = $this->metadataFactory->getMetadata($className);
155+
$defs = $this->entityMapper->toCollectionDefinitions($metadata);
156+
157+
/** @var \Utopia\Database\Collection $desired */
158+
$desired = $defs['collection'];
159+
160+
if (! $this->db->exists($this->db->getAdapter()->getDatabase(), $metadata->collection)) {
161+
$this->createCollectionFromEntity($className);
162+
163+
return;
164+
}
165+
166+
$current = $this->db->getCollection($metadata->collection);
167+
168+
$differ = new \Utopia\Database\Schema\Diff();
169+
$diff = $differ->diff($current, $desired);
170+
171+
if ($diff->hasChanges()) {
172+
$diff->apply($this->db, $metadata->collection);
173+
}
174+
}
175+
176+
public function detach(object $entity): void
177+
{
178+
$this->unitOfWork->detach($entity);
179+
}
180+
181+
public function clear(): void
182+
{
183+
$this->unitOfWork->clear();
184+
}
185+
186+
public function getUnitOfWork(): UnitOfWork
187+
{
188+
return $this->unitOfWork;
189+
}
190+
191+
public function getIdentityMap(): IdentityMap
192+
{
193+
return $this->identityMap;
194+
}
195+
196+
public function getMetadataFactory(): MetadataFactory
197+
{
198+
return $this->metadataFactory;
199+
}
200+
201+
public function getEntityMapper(): EntityMapper
202+
{
203+
return $this->entityMapper;
204+
}
205+
}

0 commit comments

Comments
 (0)