Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class Database
use Traits\Collections;
use Traits\Databases;
use Traits\Documents;
use Traits\Entities;
use Traits\Indexes;
use Traits\Relationships;
use Traits\Transactions;
Expand Down
15 changes: 15 additions & 0 deletions src/Database/ORM/ColumnMapping.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Utopia\Database\ORM;

use Utopia\Database\ORM\Mapping\Column;

class ColumnMapping
{
public function __construct(
public readonly string $propertyName,
public readonly string $documentKey,
public readonly Column $column,
) {
}
}
13 changes: 13 additions & 0 deletions src/Database/ORM/EmbeddableMapping.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Utopia\Database\ORM;

class EmbeddableMapping
{
public function __construct(
public readonly string $propertyName,
public readonly string $typeName,
public readonly string $prefix,
) {
}
}
216 changes: 216 additions & 0 deletions src/Database/ORM/EntityManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<?php

namespace Utopia\Database\ORM;

use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;

class EntityManager
{
private UnitOfWork $unitOfWork;

private IdentityMap $identityMap;

private MetadataFactory $metadataFactory;

private EntityMapper $entityMapper;

private Database $db;

public function __construct(Database $db)
{
$this->db = $db;
$this->identityMap = new IdentityMap();
$this->metadataFactory = new MetadataFactory();
$this->entityMapper = new EntityMapper($this->metadataFactory);
$this->unitOfWork = new UnitOfWork(
$this->identityMap,
$this->metadataFactory,
$this->entityMapper,
);
}

public function persist(object $entity): void
{
$this->unitOfWork->persist($entity);
}

public function remove(object $entity): void
{
$this->unitOfWork->remove($entity);
}

public function forceRemove(object $entity): void
{
$this->unitOfWork->forceRemove($entity);
}

public function restore(object $entity): void
{
$this->unitOfWork->restore($entity);
}

public function flush(): void
{
$this->unitOfWork->flush($this->db);
}

/**
* @template T of object
* @param class-string<T> $className
* @return T|null
*/
public function find(string $className, string $id, bool $withTrashed = false): ?object
{
$metadata = $this->metadataFactory->getMetadata($className);

$existing = $this->identityMap->get($metadata->collection, $id);
if ($existing !== null) {
/** @var T $existing */
return $existing;
Comment on lines +68 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cached soft-deletes remain visible

When the same EntityManager first loads a soft-deleted entity with withTrashed=true and then performs a default lookup for that ID, find() returns the identity-mapped instance before reaching the soft-delete check, causing the default lookup to expose an entity that default listings exclude.

Knowledge Base Used: Document lifecycle and representation

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/ORM/EntityManager.php
Line: 68-71

Comment:
**Cached soft-deletes remain visible**

When the same `EntityManager` first loads a soft-deleted entity with `withTrashed=true` and then performs a default lookup for that ID, `find()` returns the identity-mapped instance before reaching the soft-delete check, causing the default lookup to expose an entity that default listings exclude.

**Knowledge Base Used:** [Document lifecycle and representation](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/utopia-php/database/-/docs/document-lifecycle.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

}

$document = $this->db->getDocument($metadata->collection, $id);
Comment on lines +68 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Soft-deleted entities remain visible

When an ID belongs to a soft-deleted entity, find() returns it from the identity map or loads it through getDocument() without applying the soft-delete filter, causing ID lookups to expose records that findMany() and findOne() hide by default.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/ORM/EntityManager.php
Line: 68-74

Comment:
**Soft-deleted entities remain visible**

When an ID belongs to a soft-deleted entity, `find()` returns it from the identity map or loads it through `getDocument()` without applying the soft-delete filter, causing ID lookups to expose records that `findMany()` and `findOne()` hide by default.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex


if ($document->isEmpty()) {
return null;
}

// findMany() hides soft-deleted rows unless asked for them. Looking one up
// by id has to hide them too, or the same record is absent from a listing
// and present from a direct fetch.
if (
! $withTrashed
&& $metadata->softDeleteColumn !== null
&& $document->getAttribute($metadata->softDeleteColumn) !== null
) {
return null;
}

/** @var T $entity */
$entity = $this->entityMapper->toEntity($document, $metadata, $this->identityMap);
$this->unitOfWork->registerManaged($entity, $metadata);

return $entity;
}

/**
* @template T of object
* @param class-string<T> $className
* @param array<Query> $queries
* @return array<T>
*/
public function findMany(string $className, array $queries = [], bool $withTrashed = false): array
{
$metadata = $this->metadataFactory->getMetadata($className);

if (! $withTrashed && $metadata->softDeleteColumn !== null) {
$queries[] = Query::isNull($metadata->softDeleteColumn);
}

$documents = $this->db->find($metadata->collection, $queries);
$entities = [];

foreach ($documents as $document) {
/** @var T $entity */
$entity = $this->entityMapper->toEntity($document, $metadata, $this->identityMap);
$this->unitOfWork->registerManaged($entity, $metadata);
$entities[] = $entity;
}

return $entities;
}

/**
* @template T of object
* @param class-string<T> $className
* @param array<Query> $queries
* @return T|null
*/
public function findOne(string $className, array $queries = []): ?object
{
$queries[] = Query::limit(1);
$results = $this->findMany($className, $queries);

if ($results === []) {
return null;
}

/** @var T */
return $results[0];
}

public function createCollectionFromEntity(string $className): Document
{
$metadata = $this->metadataFactory->getMetadata($className);
$defs = $this->entityMapper->toCollectionDefinitions($metadata);

/** @var \Utopia\Database\Collection $collection */
$collection = $defs['collection'];
/** @var array<\Utopia\Database\Relationship> $relationships */
$relationships = $defs['relationships'];

$doc = $this->db->createCollection($collection);

foreach ($relationships as $relationship) {
$this->db->createRelationship($relationship);
}

return $doc;
}

public function syncCollectionFromEntity(string $className): void
{
$metadata = $this->metadataFactory->getMetadata($className);
$defs = $this->entityMapper->toCollectionDefinitions($metadata);

/** @var \Utopia\Database\Collection $desired */
$desired = $defs['collection'];

if (! $this->db->exists($this->db->getAdapter()->getDatabase(), $metadata->collection)) {
$this->createCollectionFromEntity($className);

return;
}

$current = $this->db->getCollection($metadata->collection);

$differ = new \Utopia\Database\Schema\Diff();
$diff = $differ->diff($current, $desired);
Comment on lines +166 to +180

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Relationship synchronization is omitted

When an existing collection's relationship annotations are added, removed, or changed, this branch applies only the collection attribute/index diff and ignores defs['relationships'], leaving relationship metadata and backend structures missing or stale and potentially treating existing relationship attributes as invalid attribute removals.

Knowledge Base Used: Collection schema management

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/ORM/EntityManager.php
Line: 155-169

Comment:
**Relationship synchronization is omitted**

When an existing collection's relationship annotations are added, removed, or changed, this branch applies only the collection attribute/index diff and ignores `defs['relationships']`, leaving relationship metadata and backend structures missing or stale and potentially treating existing relationship attributes as invalid attribute removals.

**Knowledge Base Used:** [Collection schema management](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/utopia-php/database/-/docs/collection-schema-management.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex


if ($diff->hasChanges()) {
$diff->apply($this->db, $metadata->collection);
}
}

public function detach(object $entity): void
{
$this->unitOfWork->detach($entity);
}

public function clear(): void
{
$this->unitOfWork->clear();
}

public function getUnitOfWork(): UnitOfWork
{
return $this->unitOfWork;
}

public function getIdentityMap(): IdentityMap
{
return $this->identityMap;
}

public function getMetadataFactory(): MetadataFactory
{
return $this->metadataFactory;
}

public function getEntityMapper(): EntityMapper
{
return $this->entityMapper;
}
}
Loading
Loading