diff --git a/src/Database/Database.php b/src/Database/Database.php index b44048b81..ae6f8c2dd 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -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; diff --git a/src/Database/ORM/ColumnMapping.php b/src/Database/ORM/ColumnMapping.php new file mode 100644 index 000000000..bd9d8b27b --- /dev/null +++ b/src/Database/ORM/ColumnMapping.php @@ -0,0 +1,15 @@ +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 $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; + } + + $document = $this->db->getDocument($metadata->collection, $id); + + 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 $className + * @param array $queries + * @return array + */ + 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 $className + * @param array $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); + + 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; + } +} diff --git a/src/Database/ORM/EntityMapper.php b/src/Database/ORM/EntityMapper.php new file mode 100644 index 000000000..aaf5e07fe --- /dev/null +++ b/src/Database/ORM/EntityMapper.php @@ -0,0 +1,398 @@ +> */ + private static array $reflectionPropertyCache = []; + + /** @var array> */ + private static array $reflectionClassCache = []; + + private MetadataFactory $metadataFactory; + + public function __construct(MetadataFactory $metadataFactory) + { + $this->metadataFactory = $metadataFactory; + } + + private function getReflectionProperty(string $class, string $property): \ReflectionProperty + { + if (! \class_exists($class) && ! \interface_exists($class)) { + throw new \RuntimeException("Unknown class {$class}"); + } + if (!isset(self::$reflectionPropertyCache[$class][$property])) { + self::$reflectionPropertyCache[$class][$property] = new \ReflectionProperty($class, $property); + } + return self::$reflectionPropertyCache[$class][$property]; + } + + /** + * @return \ReflectionClass + */ + private function getReflectionClass(string $class): \ReflectionClass + { + if (! \class_exists($class)) { + throw new \RuntimeException("Unknown class {$class}"); + } + if (!isset(self::$reflectionClassCache[$class])) { + self::$reflectionClassCache[$class] = new \ReflectionClass($class); + } + return self::$reflectionClassCache[$class]; + } + + /** + * @param \SplObjectStorage|null $visited + */ + public function toDocument(object $entity, EntityMetadata $metadata, ?\SplObjectStorage $visited = null): Document + { + $visited ??= new \SplObjectStorage(); + + if (isset($visited[$entity])) { + $data = []; + if ($metadata->idProperty !== null) { + $id = $this->getPropertyValue($entity, $metadata->idProperty); + if ($id !== null && $id !== '') { + $data[Document::ID] = $id; + } + } + + return new Document($data); + } + + $visited[$entity] = true; + + $data = []; + + if ($metadata->idProperty !== null) { + $data[Document::ID] = $this->getPropertyValue($entity, $metadata->idProperty); + } + + if ($metadata->versionProperty !== null) { + $data[Document::VERSION] = $this->getPropertyValue($entity, $metadata->versionProperty); + } + + if ($metadata->createdAtProperty !== null) { + $data[Document::CREATED_AT] = $this->getPropertyValue($entity, $metadata->createdAtProperty); + } + + if ($metadata->updatedAtProperty !== null) { + $data[Document::UPDATED_AT] = $this->getPropertyValue($entity, $metadata->updatedAtProperty); + } + + if ($metadata->tenantProperty !== null) { + $data[Document::TENANT] = $this->getPropertyValue($entity, $metadata->tenantProperty); + } + + if ($metadata->permissionsProperty !== null) { + $data[Document::PERMISSIONS] = $this->getPropertyValue($entity, $metadata->permissionsProperty) ?? []; + } + + foreach ($metadata->columns as $mapping) { + $value = $this->getPropertyValue($entity, $mapping->propertyName); + $data[$mapping->documentKey] = $value; + } + + foreach ($metadata->embeddables as $mapping) { + $value = $this->getPropertyValue($entity, $mapping->propertyName); + if ($value === null) { + continue; + } + $embType = $this->metadataFactory->getTypeRegistry()?->getEmbeddable($mapping->typeName); + if ($embType !== null) { + foreach ($embType->decompose($value) as $key => $val) { + $data[$mapping->prefix . $key] = $val; + } + } + } + + foreach ($metadata->relationships as $mapping) { + $value = $this->getPropertyValue($entity, $mapping->propertyName); + + if ($value === null) { + $data[$mapping->documentKey] = null; + + continue; + } + + if (\is_array($value)) { + $data[$mapping->documentKey] = \array_map(function (mixed $item) use ($mapping, $visited): mixed { + if (\is_object($item)) { + $relMeta = $this->metadataFactory->getMetadata($mapping->targetClass); + + return $this->toDocument($item, $relMeta, $visited); + } + + return $item; + }, $value); + } elseif (\is_object($value)) { + $relMeta = $this->metadataFactory->getMetadata($mapping->targetClass); + $data[$mapping->documentKey] = $this->toDocument($value, $relMeta, $visited); + } else { + $data[$mapping->documentKey] = $value; + } + } + + return new Document($data); + } + + public function toEntity(Document $document, EntityMetadata $metadata, IdentityMap $identityMap): object + { + $id = $document->getId(); + + if ($id !== '' && $identityMap->has($metadata->collection, $id)) { + /** @var object $existing */ + $existing = $identityMap->get($metadata->collection, $id); + + return $existing; + } + + $ref = $this->getReflectionClass($metadata->className); + $entity = $ref->newInstanceWithoutConstructor(); + + if ($id !== '') { + $identityMap->put($metadata->collection, $id, $entity); + } + + if ($metadata->idProperty !== null) { + $this->setPropertyValue($entity, $metadata->idProperty, $id); + } + + if ($metadata->versionProperty !== null) { + $this->setPropertyValue($entity, $metadata->versionProperty, $document->getAttribute(Document::VERSION)); + } + + if ($metadata->createdAtProperty !== null) { + $this->setPropertyValue($entity, $metadata->createdAtProperty, $document->getAttribute(Document::CREATED_AT)); + } + + if ($metadata->updatedAtProperty !== null) { + $this->setPropertyValue($entity, $metadata->updatedAtProperty, $document->getAttribute(Document::UPDATED_AT)); + } + + if ($metadata->tenantProperty !== null) { + $this->setPropertyValue($entity, $metadata->tenantProperty, $document->getAttribute(Document::TENANT)); + } + + if ($metadata->permissionsProperty !== null) { + $this->setPropertyValue($entity, $metadata->permissionsProperty, $document->getPermissions()); + } + + foreach ($metadata->columns as $mapping) { + $value = $document->getAttribute($mapping->documentKey, $mapping->column->default); + $this->setPropertyValue($entity, $mapping->propertyName, $value); + } + + foreach ($metadata->embeddables as $mapping) { + $embType = $this->metadataFactory->getTypeRegistry()?->getEmbeddable($mapping->typeName); + if ($embType !== null) { + $values = []; + foreach ($embType->attributes() as $attr) { + $values[$attr->key] = $document->getAttribute($mapping->prefix . $attr->key); + } + $this->setPropertyValue($entity, $mapping->propertyName, $embType->compose($values)); + } + } + + foreach ($metadata->relationships as $mapping) { + $value = $document->getAttribute($mapping->documentKey); + + if ($value === null) { + $isArray = $mapping->type === \Utopia\Database\RelationType::OneToMany + || $mapping->type === \Utopia\Database\RelationType::ManyToMany; + $this->setPropertyValue($entity, $mapping->propertyName, $isArray ? [] : null); + + continue; + } + + $relMeta = $this->metadataFactory->getMetadata($mapping->targetClass); + + if (\is_array($value)) { + $related = \array_map(function (mixed $item) use ($relMeta, $identityMap): mixed { + if ($item instanceof Document && ! $item->isEmpty()) { + return $this->toEntity($item, $relMeta, $identityMap); + } + + return $item; + }, $value); + $this->setPropertyValue($entity, $mapping->propertyName, $related); + } elseif ($value instanceof Document && ! $value->isEmpty()) { + $this->setPropertyValue($entity, $mapping->propertyName, $this->toEntity($value, $relMeta, $identityMap)); + } else { + $this->setPropertyValue($entity, $mapping->propertyName, $value); + } + } + + return $entity; + } + + public function applyDocumentToEntity(Document $document, object $entity, EntityMetadata $metadata): void + { + if ($metadata->idProperty !== null) { + $this->setPropertyValue($entity, $metadata->idProperty, $document->getId()); + } + + if ($metadata->versionProperty !== null) { + $this->setPropertyValue($entity, $metadata->versionProperty, $document->getAttribute(Document::VERSION)); + } + + if ($metadata->createdAtProperty !== null) { + $this->setPropertyValue($entity, $metadata->createdAtProperty, $document->getAttribute(Document::CREATED_AT)); + } + + if ($metadata->updatedAtProperty !== null) { + $this->setPropertyValue($entity, $metadata->updatedAtProperty, $document->getAttribute(Document::UPDATED_AT)); + } + } + + /** + * @return array + */ + public function takeSnapshot(object $entity, EntityMetadata $metadata): array + { + $snapshot = []; + + if ($metadata->idProperty !== null) { + $snapshot[Document::ID] = $this->getPropertyValue($entity, $metadata->idProperty); + } + + foreach ($metadata->columns as $mapping) { + $snapshot[$mapping->documentKey] = $this->getPropertyValue($entity, $mapping->propertyName); + } + + foreach ($metadata->relationships as $mapping) { + $value = $this->getPropertyValue($entity, $mapping->propertyName); + + if (\is_array($value)) { + $snapshot[$mapping->documentKey] = \array_map(function (mixed $item) use ($mapping): mixed { + if (\is_object($item)) { + $relMeta = $this->metadataFactory->getMetadata($mapping->targetClass); + + return $this->getId($item, $relMeta); + } + + return $item; + }, $value); + } elseif (\is_object($value)) { + $relMeta = $this->metadataFactory->getMetadata($mapping->targetClass); + $snapshot[$mapping->documentKey] = $this->getId($value, $relMeta); + } else { + $snapshot[$mapping->documentKey] = $value; + } + } + + return $snapshot; + } + + public function getId(object $entity, EntityMetadata $metadata): ?string + { + if ($metadata->idProperty === null) { + return null; + } + + /** @var string|null $value */ + $value = $this->getPropertyValue($entity, $metadata->idProperty); + + return $value; + } + + /** + * @return array{collection: Collection, relationships: array} + */ + public function toCollectionDefinitions(EntityMetadata $metadata): array + { + $attributes = []; + foreach ($metadata->columns as $mapping) { + $col = $mapping->column; + $attributes[] = new Attribute( + key: $mapping->documentKey, + type: $col->type, + size: $col->size, + required: $col->required, + default: $col->default, + signed: $col->signed, + array: $col->array, + format: $col->format, + formatOptions: $col->formatOptions, + filters: $col->filters, + ); + } + + foreach ($metadata->embeddables as $mapping) { + $embType = $this->metadataFactory->getTypeRegistry()?->getEmbeddable($mapping->typeName); + if ($embType !== null) { + foreach ($embType->attributes() as $attr) { + $prefixed = clone $attr; + $prefixed->key = $mapping->prefix . $attr->key; + $attributes[] = $prefixed; + } + } + } + + $indexes = []; + foreach ($metadata->indexes as $tableIndex) { + $indexes[] = new Index( + key: $tableIndex->key, + type: $tableIndex->type, + attributes: $tableIndex->attributes, + lengths: $tableIndex->lengths, + orders: $tableIndex->orders, + ); + } + + $collection = new Collection( + id: $metadata->collection, + name: $metadata->collection, + attributes: $attributes, + indexes: $indexes, + permissions: $metadata->permissions, + documentSecurity: $metadata->documentSecurity, + ); + + $relationships = []; + foreach ($metadata->relationships as $mapping) { + $relMeta = $this->metadataFactory->getMetadata($mapping->targetClass); + + $relationships[] = new RelationshipModel( + collection: $metadata->collection, + relatedCollection: $relMeta->collection, + type: $mapping->type, + twoWay: $mapping->twoWay, + key: $mapping->documentKey, + twoWayKey: $mapping->twoWayKey, + onDelete: $mapping->onDelete, + side: RelationSide::Parent, + ); + } + + return [ + 'collection' => $collection, + 'relationships' => $relationships, + ]; + } + + private function getPropertyValue(object $entity, string $property): mixed + { + $ref = $this->getReflectionProperty($entity::class, $property); + + if (! $ref->isInitialized($entity)) { + return null; + } + + return $ref->getValue($entity); + } + + private function setPropertyValue(object $entity, string $property, mixed $value): void + { + $ref = $this->getReflectionProperty($entity::class, $property); + $ref->setValue($entity, $value); + } +} diff --git a/src/Database/ORM/EntityMetadata.php b/src/Database/ORM/EntityMetadata.php new file mode 100644 index 000000000..1d1e4a56e --- /dev/null +++ b/src/Database/ORM/EntityMetadata.php @@ -0,0 +1,46 @@ + $columns + * @param array $relationships + * @param array $indexes + * @param array $permissions + * @param array $embeddables + * @param array $prePersistCallbacks + * @param array $postPersistCallbacks + * @param array $preUpdateCallbacks + * @param array $postUpdateCallbacks + * @param array $preRemoveCallbacks + * @param array $postRemoveCallbacks + */ + public function __construct( + public readonly string $className, + public readonly string $collection, + public readonly bool $documentSecurity, + public readonly array $permissions, + public readonly ?string $idProperty, + public readonly ?string $versionProperty, + public readonly ?string $createdAtProperty, + public readonly ?string $updatedAtProperty, + public readonly ?string $tenantProperty, + public readonly ?string $permissionsProperty, + public readonly array $columns, + public readonly array $relationships, + public readonly array $indexes, + public readonly array $embeddables = [], + public readonly ?string $softDeleteColumn = null, + public readonly array $prePersistCallbacks = [], + public readonly array $postPersistCallbacks = [], + public readonly array $preUpdateCallbacks = [], + public readonly array $postUpdateCallbacks = [], + public readonly array $preRemoveCallbacks = [], + public readonly array $postRemoveCallbacks = [], + ) { + } +} diff --git a/src/Database/ORM/EntityState.php b/src/Database/ORM/EntityState.php new file mode 100644 index 000000000..54d7cf868 --- /dev/null +++ b/src/Database/ORM/EntityState.php @@ -0,0 +1,10 @@ +> */ + private array $map = []; + + public function put(string $collection, string $id, object $entity): void + { + $this->map[$collection][$id] = $entity; + } + + public function get(string $collection, string $id): ?object + { + return $this->map[$collection][$id] ?? null; + } + + public function has(string $collection, string $id): bool + { + return isset($this->map[$collection][$id]); + } + + public function remove(string $collection, string $id): void + { + unset($this->map[$collection][$id]); + } + + public function clear(): void + { + $this->map = []; + } + + /** + * @return \Generator + */ + public function all(): \Generator + { + foreach ($this->map as $collection) { + foreach ($collection as $entity) { + yield $entity; + } + } + } + + /** + * @return array> + */ + public function snapshot(): array + { + return $this->map; + } + + /** + * @param array> $map + */ + public function restore(array $map): void + { + $this->map = $map; + } +} diff --git a/src/Database/ORM/Mapping/BelongsTo.php b/src/Database/ORM/Mapping/BelongsTo.php new file mode 100644 index 000000000..89caff5dc --- /dev/null +++ b/src/Database/ORM/Mapping/BelongsTo.php @@ -0,0 +1,18 @@ + $formatOptions + * @param array $filters + */ + public function __construct( + public ColumnType $type = ColumnType::String, + public int $size = 0, + public bool $required = false, + public mixed $default = null, + public bool $signed = true, + public bool $array = false, + public ?string $format = null, + public array $formatOptions = [], + public array $filters = [], + public ?string $key = null, + ) { + } +} diff --git a/src/Database/ORM/Mapping/CreatedAt.php b/src/Database/ORM/Mapping/CreatedAt.php new file mode 100644 index 000000000..f4b9d57db --- /dev/null +++ b/src/Database/ORM/Mapping/CreatedAt.php @@ -0,0 +1,8 @@ + $permissions + */ + public function __construct( + public string $collection, + public bool $documentSecurity = true, + public array $permissions = [], + ) { + } +} diff --git a/src/Database/ORM/Mapping/HasMany.php b/src/Database/ORM/Mapping/HasMany.php new file mode 100644 index 000000000..ad8657f7b --- /dev/null +++ b/src/Database/ORM/Mapping/HasMany.php @@ -0,0 +1,18 @@ + $attributes + * @param array $lengths + * @param array $orders + */ + public function __construct( + public string $key, + public IndexType $type = IndexType::Index, + public array $attributes = [], + public array $lengths = [], + public array $orders = [], + ) { + } +} diff --git a/src/Database/ORM/Mapping/Tenant.php b/src/Database/ORM/Mapping/Tenant.php new file mode 100644 index 000000000..58475bc49 --- /dev/null +++ b/src/Database/ORM/Mapping/Tenant.php @@ -0,0 +1,8 @@ + */ + private static array $cache = []; + + private ?TypeRegistry $typeRegistry = null; + + public function setTypeRegistry(?TypeRegistry $typeRegistry): void + { + $this->typeRegistry = $typeRegistry; + } + + public function getTypeRegistry(): ?TypeRegistry + { + return $this->typeRegistry; + } + + public function getMetadata(string $className): EntityMetadata + { + if (isset(self::$cache[$className])) { + return self::$cache[$className]; + } + + if (! \class_exists($className)) { + throw new \RuntimeException("Class {$className} does not exist"); + } + + $ref = new ReflectionClass($className); + $entityAttrs = $ref->getAttributes(Entity::class); + + if ($entityAttrs === []) { + throw new \RuntimeException("Class {$className} is not annotated with #[Entity]"); + } + + /** @var Entity $entity */ + $entity = $entityAttrs[0]->newInstance(); + + $softDeleteAttrs = $ref->getAttributes(SoftDelete::class); + $softDeleteColumn = null; + $softDelete = null; + if ($softDeleteAttrs !== []) { + /** @var SoftDelete $softDelete */ + $softDelete = $softDeleteAttrs[0]->newInstance(); + $softDeleteColumn = $softDelete->column; + } + + $idProperty = null; + $versionProperty = null; + $createdAtProperty = null; + $updatedAtProperty = null; + $tenantProperty = null; + $permissionsProperty = null; + $columns = []; + $relationships = []; + $embeddables = []; + + foreach ($ref->getProperties() as $prop) { + $name = $prop->getName(); + + if ($prop->getAttributes(Id::class)) { + $idProperty = $name; + + continue; + } + + if ($prop->getAttributes(Version::class)) { + $versionProperty = $name; + + continue; + } + + if ($prop->getAttributes(CreatedAt::class)) { + $createdAtProperty = $name; + + continue; + } + + if ($prop->getAttributes(UpdatedAt::class)) { + $updatedAtProperty = $name; + + continue; + } + + if ($prop->getAttributes(Tenant::class)) { + $tenantProperty = $name; + + continue; + } + + if ($prop->getAttributes(Permissions::class)) { + $permissionsProperty = $name; + + continue; + } + + $embeddedAttrs = $prop->getAttributes(Embedded::class); + if ($embeddedAttrs !== []) { + /** @var Embedded $emb */ + $emb = $embeddedAttrs[0]->newInstance(); + $embeddables[$name] = new EmbeddableMapping($name, $emb->type, $emb->prefix ?: $name . '_'); + + continue; + } + + $columnAttrs = $prop->getAttributes(Column::class); + if ($columnAttrs !== []) { + /** @var Column $col */ + $col = $columnAttrs[0]->newInstance(); + $docKey = $col->key ?? $name; + $columns[$name] = new ColumnMapping($name, $docKey, $col); + + continue; + } + + $rel = $this->parseRelationship($prop, $name); + if ($rel !== null) { + $relationships[$name] = $rel; + } + } + + if ($softDelete !== null) { + if (! $ref->hasProperty($softDeleteColumn)) { + throw new \RuntimeException("#[SoftDelete] column '{$softDeleteColumn}' is not a property of {$className}"); + } + + $mapped = false; + foreach ($columns as $mapping) { + if ($mapping->propertyName === $softDeleteColumn || $mapping->documentKey === $softDeleteColumn) { + $mapped = true; + break; + } + } + + if (! $mapped) { + $columns[$softDeleteColumn] = new ColumnMapping( + $softDeleteColumn, + $softDeleteColumn, + new Column(type: $softDelete->type, key: $softDeleteColumn), + ); + } + } + + $indexes = []; + foreach ($ref->getAttributes(TableIndex::class) as $idxAttr) { + $indexes[] = $idxAttr->newInstance(); + } + + $lifecycleCallbacks = $this->parseLifecycleCallbacks($ref); + + $metadata = new EntityMetadata( + className: $className, + collection: $entity->collection, + documentSecurity: $entity->documentSecurity, + permissions: $entity->permissions, + idProperty: $idProperty, + versionProperty: $versionProperty, + createdAtProperty: $createdAtProperty, + updatedAtProperty: $updatedAtProperty, + tenantProperty: $tenantProperty, + permissionsProperty: $permissionsProperty, + columns: $columns, + relationships: $relationships, + indexes: $indexes, + embeddables: $embeddables, + softDeleteColumn: $softDeleteColumn, + prePersistCallbacks: $lifecycleCallbacks['prePersist'], + postPersistCallbacks: $lifecycleCallbacks['postPersist'], + preUpdateCallbacks: $lifecycleCallbacks['preUpdate'], + postUpdateCallbacks: $lifecycleCallbacks['postUpdate'], + preRemoveCallbacks: $lifecycleCallbacks['preRemove'], + postRemoveCallbacks: $lifecycleCallbacks['postRemove'], + ); + + self::$cache[$className] = $metadata; + + return $metadata; + } + + /** + * Get the collection name for an entity class. + */ + public function getCollection(string $className): string + { + return $this->getMetadata($className)->collection; + } + + /** + * Clear the metadata cache (useful for testing). + */ + public static function clearCache(): void + { + self::$cache = []; + } + + private function parseRelationship(\ReflectionProperty $prop, string $name): ?RelationshipMapping + { + $hasOne = $prop->getAttributes(HasOne::class); + if ($hasOne !== []) { + /** @var HasOne $attr */ + $attr = $hasOne[0]->newInstance(); + + return new RelationshipMapping( + propertyName: $name, + documentKey: $attr->key ?: $name, + type: RelationType::OneToOne, + targetClass: $attr->target, + twoWayKey: $attr->twoWayKey, + twoWay: $attr->twoWay, + onDelete: $attr->onDelete, + ); + } + + $belongsTo = $prop->getAttributes(BelongsTo::class); + if ($belongsTo !== []) { + /** @var BelongsTo $attr */ + $attr = $belongsTo[0]->newInstance(); + + return new RelationshipMapping( + propertyName: $name, + documentKey: $attr->key ?: $name, + type: RelationType::ManyToOne, + targetClass: $attr->target, + twoWayKey: $attr->twoWayKey, + twoWay: $attr->twoWay, + onDelete: $attr->onDelete, + ); + } + + $hasMany = $prop->getAttributes(HasMany::class); + if ($hasMany !== []) { + /** @var HasMany $attr */ + $attr = $hasMany[0]->newInstance(); + + return new RelationshipMapping( + propertyName: $name, + documentKey: $attr->key ?: $name, + type: RelationType::OneToMany, + targetClass: $attr->target, + twoWayKey: $attr->twoWayKey, + twoWay: $attr->twoWay, + onDelete: $attr->onDelete, + ); + } + + $belongsToMany = $prop->getAttributes(BelongsToMany::class); + if ($belongsToMany !== []) { + /** @var BelongsToMany $attr */ + $attr = $belongsToMany[0]->newInstance(); + + return new RelationshipMapping( + propertyName: $name, + documentKey: $attr->key ?: $name, + type: RelationType::ManyToMany, + targetClass: $attr->target, + twoWayKey: $attr->twoWayKey, + twoWay: $attr->twoWay, + onDelete: $attr->onDelete, + ); + } + + return null; + } + + /** + * @param ReflectionClass $ref + * @return array{prePersist: array, postPersist: array, preUpdate: array, postUpdate: array, preRemove: array, postRemove: array} + */ + private function parseLifecycleCallbacks(ReflectionClass $ref): array + { + $callbacks = [ + 'prePersist' => [], + 'postPersist' => [], + 'preUpdate' => [], + 'postUpdate' => [], + 'preRemove' => [], + 'postRemove' => [], + ]; + + foreach ($ref->getMethods() as $method) { + $name = $method->getName(); + + if ($method->getAttributes(PrePersist::class)) { + $callbacks['prePersist'][] = $name; + } + + if ($method->getAttributes(PostPersist::class)) { + $callbacks['postPersist'][] = $name; + } + + if ($method->getAttributes(PreUpdate::class)) { + $callbacks['preUpdate'][] = $name; + } + + if ($method->getAttributes(PostUpdate::class)) { + $callbacks['postUpdate'][] = $name; + } + + if ($method->getAttributes(PreRemove::class)) { + $callbacks['preRemove'][] = $name; + } + + if ($method->getAttributes(PostRemove::class)) { + $callbacks['postRemove'][] = $name; + } + } + + return $callbacks; + } +} diff --git a/src/Database/ORM/RelationshipMapping.php b/src/Database/ORM/RelationshipMapping.php new file mode 100644 index 000000000..6dc0455b6 --- /dev/null +++ b/src/Database/ORM/RelationshipMapping.php @@ -0,0 +1,20 @@ + */ + private SplObjectStorage $entityStates; + + /** @var SplObjectStorage> */ + private SplObjectStorage $originalSnapshots; + + /** @var array */ + private array $scheduledInsertions = []; + + /** @var array */ + private array $scheduledDeletions = []; + + private IdentityMap $identityMap; + + private MetadataFactory $metadataFactory; + + private EntityMapper $entityMapper; + + public function __construct( + IdentityMap $identityMap, + MetadataFactory $metadataFactory, + EntityMapper $entityMapper, + ) { + $this->identityMap = $identityMap; + $this->metadataFactory = $metadataFactory; + $this->entityMapper = $entityMapper; + $this->entityStates = new SplObjectStorage(); + $this->originalSnapshots = new SplObjectStorage(); + } + + public function persist(object $entity): void + { + if ($this->entityStates->contains($entity)) { + $state = $this->entityStates[$entity]; + + if ($state === EntityState::Managed) { + return; + } + + if ($state === EntityState::Removed) { + $this->entityStates[$entity] = EntityState::Managed; + $key = \array_search($entity, $this->scheduledDeletions, true); + if ($key !== false) { + unset($this->scheduledDeletions[$key]); + } + + return; + } + } + + $this->entityStates[$entity] = EntityState::New; + $this->scheduledInsertions[] = $entity; + + $this->cascadePersist($entity); + } + + public function remove(object $entity): void + { + if (! $this->entityStates->contains($entity)) { + return; + } + + $state = $this->entityStates[$entity]; + + if ($state === EntityState::New) { + unset($this->entityStates[$entity]); + $key = \array_search($entity, $this->scheduledInsertions, true); + if ($key !== false) { + unset($this->scheduledInsertions[$key]); + } + + return; + } + + if ($state === EntityState::Managed) { + $metadata = $this->metadataFactory->getMetadata($entity::class); + if ($metadata->softDeleteColumn !== null) { + $ref = new \ReflectionProperty($entity, $metadata->softDeleteColumn); + $ref->setValue($entity, \date('Y-m-d H:i:s')); + + return; + } + + $this->entityStates[$entity] = EntityState::Removed; + $this->scheduledDeletions[] = $entity; + } + } + + public function forceRemove(object $entity): void + { + if (! $this->entityStates->contains($entity)) { + return; + } + + $state = $this->entityStates[$entity]; + + if ($state === EntityState::New) { + unset($this->entityStates[$entity]); + $key = \array_search($entity, $this->scheduledInsertions, true); + if ($key !== false) { + unset($this->scheduledInsertions[$key]); + } + + return; + } + + if ($state === EntityState::Managed) { + $this->entityStates[$entity] = EntityState::Removed; + $this->scheduledDeletions[] = $entity; + } + } + + public function restore(object $entity): void + { + $metadata = $this->metadataFactory->getMetadata($entity::class); + if ($metadata->softDeleteColumn === null) { + return; + } + + $ref = new \ReflectionProperty($entity, $metadata->softDeleteColumn); + $ref->setValue($entity, null); + } + + public function registerManaged(object $entity, EntityMetadata $metadata): void + { + $this->entityStates[$entity] = EntityState::Managed; + $this->originalSnapshots[$entity] = $this->entityMapper->takeSnapshot($entity, $metadata); + } + + public function flush(Database $db): void + { + /** @var array> $inserts */ + $inserts = []; + /** @var array> $updates */ + $updates = []; + /** @var array> $deletes */ + $deletes = []; + + foreach ($this->scheduledInsertions as $entity) { + $metadata = $this->metadataFactory->getMetadata($entity::class); + $inserts[$metadata->collection][] = $entity; + } + + foreach ($this->identityMap->all() as $entity) { + if (! $this->entityStates->contains($entity)) { + continue; + } + + if ($this->entityStates[$entity] !== EntityState::Managed) { + continue; + } + + $metadata = $this->metadataFactory->getMetadata($entity::class); + $currentSnapshot = $this->entityMapper->takeSnapshot($entity, $metadata); + $originalSnapshot = $this->originalSnapshots->contains($entity) + ? $this->originalSnapshots[$entity] + : []; + + if ($currentSnapshot !== $originalSnapshot) { + $updates[$metadata->collection][] = $entity; + } + } + + foreach ($this->scheduledDeletions as $entity) { + $metadata = $this->metadataFactory->getMetadata($entity::class); + $deletes[$metadata->collection][] = $entity; + } + + if ($inserts === [] && $updates === [] && $deletes === []) { + return; + } + + $previousIdentity = $this->identityMap->snapshot(); + $previousStates = clone $this->entityStates; + $previousSnapshots = clone $this->originalSnapshots; + $idBackup = []; + foreach ($inserts as $entities) { + foreach ($entities as $entity) { + $metadata = $this->metadataFactory->getMetadata($entity::class); + $idBackup[] = [$entity, $metadata, $this->entityMapper->getId($entity, $metadata)]; + } + } + + try { + $this->commit($db, $inserts, $updates, $deletes); + } catch (\Throwable $e) { + $this->identityMap->restore($previousIdentity); + $this->entityStates = $previousStates; + $this->originalSnapshots = $previousSnapshots; + foreach ($idBackup as [$entity, $metadata, $id]) { + if ($metadata->idProperty !== null) { + $ref = new \ReflectionProperty($entity, $metadata->idProperty); + $ref->setValue($entity, $id ?? ''); + } + } + + throw $e; + } + + $this->scheduledInsertions = []; + $this->scheduledDeletions = []; + } + + /** + * @param array> $inserts + * @param array> $updates + * @param array> $deletes + */ + private function commit(Database $db, array $inserts, array $updates, array $deletes): void + { + $db->withTransaction(function () use ($db, $inserts, $updates, $deletes): void { + foreach ($inserts as $collection => $entities) { + $documents = []; + $entityMap = []; + + foreach ($entities as $entity) { + $metadata = $this->metadataFactory->getMetadata($entity::class); + $this->invokeCallbacks($entity, $metadata->prePersistCallbacks); + $doc = $this->entityMapper->toDocument($entity, $metadata); + $documents[] = $doc; + $entityMap[] = $entity; + } + + if (\count($documents) === 1) { + $created = $db->createDocument($collection, $documents[0]); + $metadata = $this->metadataFactory->getMetadata($entityMap[0]::class); + $this->entityMapper->applyDocumentToEntity($created, $entityMap[0], $metadata); + $this->identityMap->put($collection, $created->getId(), $entityMap[0]); + $this->entityStates[$entityMap[0]] = EntityState::Managed; + $this->originalSnapshots[$entityMap[0]] = $this->entityMapper->takeSnapshot($entityMap[0], $metadata); + $this->invokeCallbacks($entityMap[0], $metadata->postPersistCallbacks); + } else { + $idx = 0; + $db->createDocuments($collection, $documents, Database::INSERT_BATCH_SIZE, function (Document $created) use (&$entityMap, &$idx, $collection): void { + if (! isset($entityMap[$idx])) { + return; + } + $entity = $entityMap[$idx]; + $metadata = $this->metadataFactory->getMetadata($entity::class); + $this->entityMapper->applyDocumentToEntity($created, $entity, $metadata); + $this->identityMap->put($collection, $created->getId(), $entity); + $this->entityStates[$entity] = EntityState::Managed; + $this->originalSnapshots[$entity] = $this->entityMapper->takeSnapshot($entity, $metadata); + $this->invokeCallbacks($entity, $metadata->postPersistCallbacks); + $idx++; + }); + } + } + + foreach ($updates as $collection => $entities) { + foreach ($entities as $entity) { + $metadata = $this->metadataFactory->getMetadata($entity::class); + $this->invokeCallbacks($entity, $metadata->preUpdateCallbacks); + $document = $this->entityMapper->toDocument($entity, $metadata); + $id = $this->entityMapper->getId($entity, $metadata); + + if ($id === null) { + continue; + } + + $updated = $db->updateDocument($collection, $id, $document); + $this->entityMapper->applyDocumentToEntity($updated, $entity, $metadata); + $this->originalSnapshots[$entity] = $this->entityMapper->takeSnapshot($entity, $metadata); + $this->invokeCallbacks($entity, $metadata->postUpdateCallbacks); + } + } + + foreach ($deletes as $collection => $entities) { + foreach ($entities as $entity) { + $metadata = $this->metadataFactory->getMetadata($entity::class); + $id = $this->entityMapper->getId($entity, $metadata); + + if ($id === null) { + continue; + } + + $this->invokeCallbacks($entity, $metadata->preRemoveCallbacks); + $db->deleteDocument($collection, $id); + $this->identityMap->remove($collection, $id); + $this->entityStates->detach($entity); + + if ($this->originalSnapshots->contains($entity)) { + $this->originalSnapshots->detach($entity); + } + + $this->invokeCallbacks($entity, $metadata->postRemoveCallbacks); + } + } + }); + } + + public function detach(object $entity): void + { + if ($this->entityStates->contains($entity)) { + $this->entityStates->detach($entity); + } + + if ($this->originalSnapshots->contains($entity)) { + $this->originalSnapshots->detach($entity); + } + + $key = \array_search($entity, $this->scheduledInsertions, true); + if ($key !== false) { + unset($this->scheduledInsertions[$key]); + } + + $key = \array_search($entity, $this->scheduledDeletions, true); + if ($key !== false) { + unset($this->scheduledDeletions[$key]); + } + + $metadata = $this->metadataFactory->getMetadata($entity::class); + $id = $this->entityMapper->getId($entity, $metadata); + + if ($id !== null) { + $this->identityMap->remove($metadata->collection, $id); + } + } + + public function clear(): void + { + $this->entityStates = new SplObjectStorage(); + $this->originalSnapshots = new SplObjectStorage(); + $this->scheduledInsertions = []; + $this->scheduledDeletions = []; + $this->identityMap->clear(); + } + + public function getState(object $entity): ?EntityState + { + if (! $this->entityStates->contains($entity)) { + return null; + } + + return $this->entityStates[$entity]; + } + + public function getIdentityMap(): IdentityMap + { + return $this->identityMap; + } + + private function cascadePersist(object $entity): void + { + $metadata = $this->metadataFactory->getMetadata($entity::class); + + foreach ($metadata->relationships as $mapping) { + $ref = new \ReflectionProperty($entity, $mapping->propertyName); + + if (! $ref->isInitialized($entity)) { + continue; + } + + $value = $ref->getValue($entity); + + if ($value === null) { + continue; + } + + if (\is_array($value)) { + foreach ($value as $related) { + if (\is_object($related) && ! $this->entityStates->contains($related)) { + $this->persist($related); + } + } + } elseif (\is_object($value) && ! $this->entityStates->contains($value)) { + $this->persist($value); + } + } + } + + /** + * @param array $methods + */ + private function invokeCallbacks(object $entity, array $methods): void + { + foreach ($methods as $method) { + $entity->{$method}(); + } + } +} diff --git a/src/Database/Traits/Entities.php b/src/Database/Traits/Entities.php new file mode 100644 index 000000000..eb517bb55 --- /dev/null +++ b/src/Database/Traits/Entities.php @@ -0,0 +1,91 @@ +entityManager === null) { + $this->entityManager = new EntityManager($this); + } + + return $this->entityManager; + } + + public function persistEntity(object $entity): void + { + $this->getEntityManager()->persist($entity); + } + + public function removeEntity(object $entity): void + { + $this->getEntityManager()->remove($entity); + } + + /** + * Flush all pending entity changes to the database. + */ + public function flushEntities(): void + { + $this->getEntityManager()->flush(); + } + + /** + * @template T of object + * @param class-string $className + * @return T|null + */ + public function findEntity(string $className, string $id, bool $withTrashed = false): ?object + { + return $this->getEntityManager()->find($className, $id, $withTrashed); + } + + /** + * @template T of object + * @param class-string $className + * @param array $queries + * @return array + */ + public function findEntities(string $className, array $queries = []): array + { + return $this->getEntityManager()->findMany($className, $queries); + } + + /** + * @template T of object + * @param class-string $className + * @param array $queries + * @return T|null + */ + public function findOneEntity(string $className, array $queries = []): ?object + { + return $this->getEntityManager()->findOne($className, $queries); + } + + public function createCollectionFromEntity(string $className): Document + { + return $this->getEntityManager()->createCollectionFromEntity($className); + } + + public function syncCollectionFromEntity(string $className): void + { + $this->getEntityManager()->syncCollectionFromEntity($className); + } + + public function detachEntity(object $entity): void + { + $this->getEntityManager()->detach($entity); + } + + public function clearEntityManager(): void + { + $this->getEntityManager()->clear(); + } +} diff --git a/tests/unit/ORM/EmbeddableTest.php b/tests/unit/ORM/EmbeddableTest.php new file mode 100644 index 000000000..755cd4abd --- /dev/null +++ b/tests/unit/ORM/EmbeddableTest.php @@ -0,0 +1,141 @@ +factory = new MetadataFactory(); + } + + public function testMetadataFactoryParsesEmbeddedAttribute(): void + { + $metadata = $this->factory->getMetadata(EmbeddableEntity::class); + + $this->assertArrayHasKey('address', $metadata->embeddables); + } + + public function testEmbeddableMappingHasCorrectPropertyName(): void + { + $metadata = $this->factory->getMetadata(EmbeddableEntity::class); + $mapping = $metadata->embeddables['address']; + + $this->assertEquals('address', $mapping->propertyName); + } + + public function testEmbeddableMappingHasCorrectTypeName(): void + { + $metadata = $this->factory->getMetadata(EmbeddableEntity::class); + $mapping = $metadata->embeddables['address']; + + $this->assertEquals('address', $mapping->typeName); + } + + public function testDefaultPrefixIsPropertyNameWithUnderscore(): void + { + $metadata = $this->factory->getMetadata(EmbeddableEntity::class); + $mapping = $metadata->embeddables['address']; + + $this->assertEquals('address_', $mapping->prefix); + } + + public function testCustomPrefixOverridesDefault(): void + { + $metadata = $this->factory->getMetadata(CustomPrefixEmbeddableEntity::class); + $mapping = $metadata->embeddables['homeAddress']; + + $this->assertEquals('home_', $mapping->prefix); + } + + public function testEntityWithoutEmbeddablesHasEmptyArray(): void + { + $metadata = $this->factory->getMetadata(NoEmbeddableEntity::class); + + $this->assertEmpty($metadata->embeddables); + } + + public function testMultipleEmbeddablesAreParsed(): void + { + $metadata = $this->factory->getMetadata(MultiEmbeddableEntity::class); + + $this->assertCount(2, $metadata->embeddables); + $this->assertArrayHasKey('billing', $metadata->embeddables); + $this->assertArrayHasKey('shipping', $metadata->embeddables); + } + + public function testMultipleEmbeddablesHaveDistinctPrefixes(): void + { + $metadata = $this->factory->getMetadata(MultiEmbeddableEntity::class); + + $this->assertEquals('billing_', $metadata->embeddables['billing']->prefix); + $this->assertEquals('ship_', $metadata->embeddables['shipping']->prefix); + } + + public function testEmbeddableMappingConstructorSetsReadonlyProperties(): void + { + $mapping = new EmbeddableMapping('myProp', 'myType', 'my_'); + + $this->assertEquals('myProp', $mapping->propertyName); + $this->assertEquals('myType', $mapping->typeName); + $this->assertEquals('my_', $mapping->prefix); + } +} diff --git a/tests/unit/ORM/EntityManagerTest.php b/tests/unit/ORM/EntityManagerTest.php new file mode 100644 index 000000000..7b98efd75 --- /dev/null +++ b/tests/unit/ORM/EntityManagerTest.php @@ -0,0 +1,608 @@ +db = $this->createMock(Database::class); + $this->em = new EntityManager($this->db); + } + + public function testPersistDelegatesToUnitOfWork(): void + { + $entity = new TestEntity(); + $entity->id = 'persist-1'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $this->em->persist($entity); + + $this->assertEquals(EntityState::New, $this->em->getUnitOfWork()->getState($entity)); + } + + public function testRemoveDelegatesToUnitOfWork(): void + { + $entity = new TestEntity(); + $entity->id = 'remove-1'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $this->em->getIdentityMap()->put('users', 'remove-1', $entity); + $this->em->getUnitOfWork()->registerManaged($entity, $metadata); + + $this->em->remove($entity); + + $this->assertEquals(EntityState::Removed, $this->em->getUnitOfWork()->getState($entity)); + } + + public function testFindChecksIdentityMapFirst(): void + { + $entity = new TestEntity(); + $entity->id = 'cached-1'; + $entity->name = 'Cached'; + $entity->email = 'cached@example.com'; + + $this->em->getIdentityMap()->put('users', 'cached-1', $entity); + + $this->db->expects($this->never()) + ->method('getDocument'); + + $result = $this->em->find(TestEntity::class, 'cached-1'); + + $this->assertSame($entity, $result); + } + + public function testFindFallsBackToDatabase(): void + { + $doc = new Document([ + '$id' => 'db-1', + '$version' => 1, + 'name' => 'FromDB', + 'email' => 'db@example.com', + 'age' => 30, + 'active' => true, + ]); + + $this->db->expects($this->once()) + ->method('getDocument') + ->with('users', 'db-1') + ->willReturn($doc); + + /** @var TestEntity $result */ + $result = $this->em->find(TestEntity::class, 'db-1'); + + $this->assertEquals('db-1', $result->id); + $this->assertEquals('FromDB', $result->name); + } + + public function testFindReturnsNullForEmptyDocument(): void + { + $this->db->expects($this->once()) + ->method('getDocument') + ->willReturn(new Document()); + + $result = $this->em->find(TestEntity::class, 'nonexistent'); + + $this->assertNull($result); + } + + public function testFindRegistersEntityAsManaged(): void + { + $doc = new Document([ + '$id' => 'managed-find-1', + 'name' => 'Managed', + 'email' => 'managed@example.com', + 'age' => 25, + 'active' => true, + ]); + + $this->db->method('getDocument')->willReturn($doc); + + $result = $this->em->find(TestEntity::class, 'managed-find-1'); + + $this->assertNotNull($result); + $this->assertEquals(EntityState::Managed, $this->em->getUnitOfWork()->getState($result)); + } + + public function testFindPutsEntityInIdentityMap(): void + { + $doc = new Document([ + '$id' => 'identity-1', + 'name' => 'Identity', + 'email' => 'identity@example.com', + 'age' => 20, + 'active' => true, + ]); + + $this->db->method('getDocument')->willReturn($doc); + + $this->em->find(TestEntity::class, 'identity-1'); + + $this->assertTrue($this->em->getIdentityMap()->has('users', 'identity-1')); + } + + public function testFindReturnsSameInstanceOnSecondCall(): void + { + $doc = new Document([ + '$id' => 'repeat-1', + 'name' => 'Repeat', + 'email' => 'repeat@example.com', + 'age' => 20, + 'active' => true, + ]); + + $this->db->expects($this->once()) + ->method('getDocument') + ->willReturn($doc); + + $first = $this->em->find(TestEntity::class, 'repeat-1'); + $second = $this->em->find(TestEntity::class, 'repeat-1'); + + $this->assertSame($first, $second); + } + + public function testFindManyHydratesAllDocuments(): void + { + $docs = [ + new Document([ + '$id' => 'many-1', + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'age' => 25, + 'active' => true, + ]), + new Document([ + '$id' => 'many-2', + 'name' => 'Bob', + 'email' => 'bob@example.com', + 'age' => 30, + 'active' => false, + ]), + ]; + + $this->db->expects($this->once()) + ->method('find') + ->with('users', []) + ->willReturn($docs); + + $results = $this->em->findMany(TestEntity::class); + + $this->assertCount(2, $results); + $this->assertEquals('Alice', $results[0]->name); + $this->assertEquals('Bob', $results[1]->name); + } + + public function testFindManyWithEmptyResults(): void + { + $this->db->method('find')->willReturn([]); + + $results = $this->em->findMany(TestEntity::class); + + $this->assertEmpty($results); + } + + public function testFindManyRegistersAllAsManaged(): void + { + $docs = [ + new Document([ + '$id' => 'managed-many-1', + 'name' => 'A', + 'email' => 'a@example.com', + 'age' => 20, + 'active' => true, + ]), + new Document([ + '$id' => 'managed-many-2', + 'name' => 'B', + 'email' => 'b@example.com', + 'age' => 25, + 'active' => true, + ]), + ]; + + $this->db->method('find')->willReturn($docs); + + $results = $this->em->findMany(TestEntity::class); + + foreach ($results as $entity) { + $this->assertEquals(EntityState::Managed, $this->em->getUnitOfWork()->getState($entity)); + } + } + + public function testFindManyWithQueries(): void + { + $queries = [Query::equal('active', [true])]; + + $this->db->expects($this->once()) + ->method('find') + ->with('users', $queries) + ->willReturn([]); + + $this->em->findMany(TestEntity::class, $queries); + } + + public function testFindOneAddsLimitAndReturnsFirst(): void + { + $doc = new Document([ + '$id' => 'one-1', + 'name' => 'Only', + 'email' => 'only@example.com', + 'age' => 30, + 'active' => true, + ]); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'users', + $this->callback(function (array $queries) { + $lastQuery = end($queries); + + return $lastQuery instanceof Query + && $lastQuery->getMethod()->value === 'limit'; + }) + ) + ->willReturn([$doc]); + + /** @var TestEntity $result */ + $result = $this->em->findOne(TestEntity::class); + + $this->assertEquals('Only', $result->name); + } + + public function testFindOneReturnsNullWhenNoResults(): void + { + $this->db->method('find')->willReturn([]); + + $result = $this->em->findOne(TestEntity::class); + + $this->assertNull($result); + } + + public function testFindOneWithCustomQueries(): void + { + $this->db->expects($this->once()) + ->method('find') + ->with( + 'users', + $this->callback(function (array $queries) { + return count($queries) === 2; + }) + ) + ->willReturn([]); + + $this->em->findOne(TestEntity::class, [Query::equal('name', ['Test'])]); + } + + public function testFindOneRegistersAsManaged(): void + { + $doc = new Document([ + '$id' => 'managed-one-1', + 'name' => 'Managed', + 'email' => 'managed@example.com', + 'age' => 25, + 'active' => true, + ]); + + $this->db->method('find')->willReturn([$doc]); + + $result = $this->em->findOne(TestEntity::class); + + $this->assertNotNull($result); + $this->assertEquals(EntityState::Managed, $this->em->getUnitOfWork()->getState($result)); + } + + public function testCreateCollectionFromEntityCallsCreateCollection(): void + { + $this->db->expects($this->once()) + ->method('createCollection') + ->with($this->callback(function (mixed $collection): bool { + return $collection instanceof Collection + && $collection->id === 'users' + && $collection->documentSecurity === true; + })) + ->willReturn(new Collection(id: 'users')); + + $this->db->expects($this->once()) + ->method('createRelationship') + ->with($this->isInstanceOf(\Utopia\Database\Relationship::class)); + + $this->em->createCollectionFromEntity(TestEntity::class); + } + + public function testCreateCollectionFromEntityReturnsDocument(): void + { + $returnDoc = new Collection(id: 'users'); + + $this->db->method('createCollection')->willReturn($returnDoc); + $this->db->method('createRelationship')->willReturn(true); + + $result = $this->em->createCollectionFromEntity(TestEntity::class); + + $this->assertEquals('users', $result->getId()); + } + + public function testCreateCollectionFromEntityWithNoRelationships(): void + { + $this->db->expects($this->once()) + ->method('createCollection') + ->willReturn(new Collection(id: 'posts')); + + $this->db->expects($this->once()) + ->method('createRelationship'); + + $this->em->createCollectionFromEntity(TestPost::class); + } + + public function testDetachDelegatesToUnitOfWork(): void + { + $entity = new TestEntity(); + $entity->id = 'detach-1'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $this->em->persist($entity); + $this->assertEquals(EntityState::New, $this->em->getUnitOfWork()->getState($entity)); + + $this->em->detach($entity); + + $this->assertNull($this->em->getUnitOfWork()->getState($entity)); + } + + public function testClearResetsUnitOfWork(): void + { + $entity = new TestEntity(); + $entity->id = 'clear-1'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $this->em->persist($entity); + $this->em->clear(); + + $this->assertNull($this->em->getUnitOfWork()->getState($entity)); + } + + public function testClearResetsIdentityMap(): void + { + $entity = new TestEntity(); + $entity->id = 'clear-map-1'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $this->em->getIdentityMap()->put('users', 'clear-map-1', $entity); + $this->em->clear(); + + $this->assertEmpty(\iterator_to_array($this->em->getIdentityMap()->all())); + } + + public function testFlushDelegatesToUnitOfWork(): void + { + $this->db->expects($this->never()) + ->method('withTransaction'); + + $this->em->flush(); + } + + public function testFlushWithPendingInsert(): void + { + $entity = new TestEntity(); + $entity->id = 'flush-1'; + $entity->name = 'Flush'; + $entity->email = 'flush@example.com'; + $entity->age = 25; + $entity->active = true; + + $this->em->persist($entity); + + $createdDoc = new Document([ + '$id' => 'flush-1', + '$version' => 1, + '$createdAt' => '2024-01-01 00:00:00', + '$updatedAt' => '2024-01-01 00:00:00', + 'name' => 'Flush', + 'email' => 'flush@example.com', + 'age' => 25, + 'active' => true, + ]); + + $this->db->expects($this->once()) + ->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $this->db->expects($this->once()) + ->method('createDocument') + ->with('users', $this->isInstanceOf(Document::class)) + ->willReturn($createdDoc); + + $this->em->flush(); + + $this->assertEquals(EntityState::Managed, $this->em->getUnitOfWork()->getState($entity)); + } + + public function testFlushWithPendingDelete(): void + { + $entity = new TestEntity(); + $entity->id = 'flush-del-1'; + $entity->name = 'Delete'; + $entity->email = 'delete@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $this->em->getIdentityMap()->put('users', 'flush-del-1', $entity); + $this->em->getUnitOfWork()->registerManaged($entity, $metadata); + $this->em->remove($entity); + + $this->db->expects($this->once()) + ->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $this->db->expects($this->once()) + ->method('deleteDocument') + ->with('users', 'flush-del-1'); + + $this->em->flush(); + } + + public function testFlushWithPendingUpdate(): void + { + $entity = new TestEntity(); + $entity->id = 'flush-upd-1'; + $entity->name = 'Before'; + $entity->email = 'update@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $this->em->getIdentityMap()->put('users', 'flush-upd-1', $entity); + $this->em->getUnitOfWork()->registerManaged($entity, $metadata); + + $entity->name = 'After'; + + $updatedDoc = new Document([ + '$id' => 'flush-upd-1', + '$version' => 2, + '$createdAt' => '2024-01-01 00:00:00', + '$updatedAt' => '2024-01-02 00:00:00', + 'name' => 'After', + 'email' => 'update@example.com', + 'age' => 20, + 'active' => true, + ]); + + $this->db->expects($this->once()) + ->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $this->db->expects($this->once()) + ->method('updateDocument') + ->with('users', 'flush-upd-1', $this->isInstanceOf(Document::class)) + ->willReturn($updatedDoc); + + $this->em->flush(); + } + + public function testPersistMultipleEntities(): void + { + $e1 = new TestEntity(); + $e1->id = 'multi-1'; + $e1->name = 'A'; + $e1->email = 'a@example.com'; + + $e2 = new TestEntity(); + $e2->id = 'multi-2'; + $e2->name = 'B'; + $e2->email = 'b@example.com'; + + $this->em->persist($e1); + $this->em->persist($e2); + + $this->assertEquals(EntityState::New, $this->em->getUnitOfWork()->getState($e1)); + $this->assertEquals(EntityState::New, $this->em->getUnitOfWork()->getState($e2)); + } + + public function testRemoveUntrackedEntityDoesNothing(): void + { + $entity = new TestEntity(); + $entity->id = 'untracked-1'; + $entity->name = 'Untracked'; + $entity->email = 'untracked@example.com'; + + $this->em->remove($entity); + + $this->assertNull($this->em->getUnitOfWork()->getState($entity)); + } + + public function testPersistThenRemoveNewEntity(): void + { + $entity = new TestEntity(); + $entity->id = 'pr-1'; + $entity->name = 'PersistRemove'; + $entity->email = 'pr@example.com'; + + $this->em->persist($entity); + $this->em->remove($entity); + + $this->assertNull($this->em->getUnitOfWork()->getState($entity)); + } + + public function testPersistCascadesToRelationships(): void + { + $post = new TestPost(); + $post->id = 'cascade-post-1'; + $post->title = 'Cascade Post'; + $post->content = 'Content'; + + $user = new TestEntity(); + $user->id = 'cascade-user-1'; + $user->name = 'User'; + $user->email = 'user@example.com'; + $user->posts = [$post]; + + $this->em->persist($user); + + $this->assertEquals(EntityState::New, $this->em->getUnitOfWork()->getState($user)); + $this->assertEquals(EntityState::New, $this->em->getUnitOfWork()->getState($post)); + } + + public function testDetachRemovesFromIdentityMap(): void + { + $entity = new TestEntity(); + $entity->id = 'detach-map-1'; + $entity->name = 'DetachMap'; + $entity->email = 'detachmap@example.com'; + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $this->em->getIdentityMap()->put('users', 'detach-map-1', $entity); + $this->em->getUnitOfWork()->registerManaged($entity, $metadata); + + $this->em->detach($entity); + + $this->assertFalse($this->em->getIdentityMap()->has('users', 'detach-map-1')); + } + + public function testFindManyPutsEntitiesInIdentityMap(): void + { + $docs = [ + new Document([ + '$id' => 'findmany-map-1', + 'name' => 'A', + 'email' => 'a@example.com', + 'age' => 20, + 'active' => true, + ]), + ]; + + $this->db->method('find')->willReturn($docs); + + $this->em->findMany(TestEntity::class); + + $this->assertTrue($this->em->getIdentityMap()->has('users', 'findmany-map-1')); + } +} diff --git a/tests/unit/ORM/EntityMapperAdvancedTest.php b/tests/unit/ORM/EntityMapperAdvancedTest.php new file mode 100644 index 000000000..d07e661a8 --- /dev/null +++ b/tests/unit/ORM/EntityMapperAdvancedTest.php @@ -0,0 +1,469 @@ +metadataFactory = new MetadataFactory(); + $this->mapper = new EntityMapper($this->metadataFactory); + } + + public function testToDocumentWithNullSingleRelationship(): void + { + $post = new TestPost(); + $post->id = 'post-null-rel'; + $post->title = 'No Author'; + $post->content = 'Content'; + $post->author = null; + + $metadata = $this->metadataFactory->getMetadata(TestPost::class); + $doc = $this->mapper->toDocument($post, $metadata); + + $this->assertNull($doc->getAttribute('author')); + } + + public function testToDocumentWithNullArrayRelationship(): void + { + $entity = new TestEntity(); + $entity->id = 'user-null-posts'; + $entity->name = 'No Posts'; + $entity->email = 'noposts@example.com'; + $entity->age = 20; + $entity->active = true; + $entity->posts = []; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $doc = $this->mapper->toDocument($entity, $metadata); + + $this->assertEquals([], $doc->getAttribute('posts')); + } + + public function testToDocumentWithNestedEntityObjectsInRelationships(): void + { + $post = new TestPost(); + $post->id = 'nested-post-1'; + $post->title = 'Nested'; + $post->content = 'Content'; + + $entity = new TestEntity(); + $entity->id = 'user-nested'; + $entity->name = 'With Posts'; + $entity->email = 'nested@example.com'; + $entity->age = 30; + $entity->active = true; + $entity->posts = [$post]; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $doc = $this->mapper->toDocument($entity, $metadata); + + $posts = $doc->getDocuments('posts'); + $this->assertCount(1, $posts); + $this->assertEquals('nested-post-1', $posts[0]->getId()); + $this->assertEquals('Nested', $posts[0]->getAttribute('title')); + } + + public function testToDocumentWithStringIdsInRelationships(): void + { + $entity = new TestEntity(); + $entity->id = 'user-string-rels'; + $entity->name = 'String Rels'; + $entity->email = 'stringrels@example.com'; + $entity->age = 25; + $entity->active = true; + $entity->posts = ['post-id-1', 'post-id-2']; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $doc = $this->mapper->toDocument($entity, $metadata); + + $posts = $doc->getAttribute('posts'); + $this->assertEquals(['post-id-1', 'post-id-2'], $posts); + } + + public function testToDocumentWithSingleObjectRelationship(): void + { + $author = new TestEntity(); + $author->id = 'author-obj-1'; + $author->name = 'Author'; + $author->email = 'author@example.com'; + $author->age = 40; + $author->active = true; + + $post = new TestPost(); + $post->id = 'post-obj-rel'; + $post->title = 'Post'; + $post->content = 'Content'; + $post->author = $author; + + $metadata = $this->metadataFactory->getMetadata(TestPost::class); + $doc = $this->mapper->toDocument($post, $metadata); + + $authorDoc = $doc->getAttribute('author'); + $this->assertInstanceOf(Document::class, $authorDoc); + $this->assertEquals('author-obj-1', $authorDoc->getAttribute('$id')); + } + + public function testToEntityWithNestedDocumentRelationships(): void + { + $postDoc = new Document([ + '$id' => 'nested-doc-post', + 'title' => 'Nested Post', + 'content' => 'Content', + ]); + + $userDoc = new Document([ + '$id' => 'nested-doc-user', + 'name' => 'User', + 'email' => 'user@example.com', + 'age' => 25, + 'active' => true, + 'posts' => [$postDoc], + ]); + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $identityMap = new IdentityMap(); + + /** @var TestEntity $entity */ + $entity = $this->mapper->toEntity($userDoc, $metadata, $identityMap); + + $this->assertCount(1, $entity->posts); + $this->assertInstanceOf(TestPost::class, $entity->posts[0]); + $this->assertEquals('nested-doc-post', $entity->posts[0]->id); + $this->assertEquals('Nested Post', $entity->posts[0]->title); + } + + public function testToEntityWithEmptyRelationshipArrays(): void + { + $doc = new Document([ + '$id' => 'empty-rels', + 'name' => 'NoRels', + 'email' => 'norels@example.com', + 'age' => 20, + 'active' => true, + 'posts' => null, + ]); + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $identityMap = new IdentityMap(); + + /** @var TestEntity $entity */ + $entity = $this->mapper->toEntity($doc, $metadata, $identityMap); + + $this->assertEquals([], $entity->posts); + } + + public function testToEntityHandlesMixedArray(): void + { + $postDoc = new Document([ + '$id' => 'mixed-post-1', + 'title' => 'Mixed', + 'content' => 'Content', + ]); + + $doc = new Document([ + '$id' => 'mixed-user', + 'name' => 'Mixed', + 'email' => 'mixed@example.com', + 'age' => 25, + 'active' => true, + 'posts' => [$postDoc, 'string-id-1'], + ]); + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $identityMap = new IdentityMap(); + + /** @var TestEntity $entity */ + $entity = $this->mapper->toEntity($doc, $metadata, $identityMap); + + $this->assertCount(2, $entity->posts); + $this->assertInstanceOf(TestPost::class, $entity->posts[0]); + $this->assertEquals('string-id-1', $entity->posts[1]); + } + + public function testToEntityWithUninitializedPropertiesDoesNotCrash(): void + { + $doc = new Document([ + '$id' => 'uninit-1', + 'name' => 'Uninit', + 'email' => 'uninit@example.com', + 'age' => 20, + 'active' => true, + ]); + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $identityMap = new IdentityMap(); + + $entity = $this->mapper->toEntity($doc, $metadata, $identityMap); + + $this->assertInstanceOf(TestEntity::class, $entity); + } + + public function testTakeSnapshotStoresRelationshipIdsNotFullObjects(): void + { + $post = new TestPost(); + $post->id = 'snap-post-1'; + $post->title = 'Snap Post'; + $post->content = 'Content'; + + $entity = new TestEntity(); + $entity->id = 'snap-user-1'; + $entity->name = 'Snap User'; + $entity->email = 'snap@example.com'; + $entity->age = 30; + $entity->active = true; + $entity->posts = [$post]; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $snapshot = $this->mapper->takeSnapshot($entity, $metadata); + + $this->assertEquals(['snap-post-1'], $snapshot['posts']); + } + + public function testTakeSnapshotWithEmptyRelationships(): void + { + $entity = new TestEntity(); + $entity->id = 'snap-empty-1'; + $entity->name = 'Snap Empty'; + $entity->email = 'snapempty@example.com'; + $entity->age = 20; + $entity->active = true; + $entity->posts = []; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $snapshot = $this->mapper->takeSnapshot($entity, $metadata); + + $this->assertEquals([], $snapshot['posts']); + } + + public function testTakeSnapshotWithSingleObjectRelationship(): void + { + $author = new TestEntity(); + $author->id = 'snap-author-1'; + $author->name = 'Author'; + $author->email = 'author@example.com'; + $author->age = 40; + $author->active = true; + + $post = new TestPost(); + $post->id = 'snap-post-obj'; + $post->title = 'Title'; + $post->content = 'Content'; + $post->author = $author; + + $metadata = $this->metadataFactory->getMetadata(TestPost::class); + $snapshot = $this->mapper->takeSnapshot($post, $metadata); + + $this->assertEquals('snap-author-1', $snapshot['author']); + } + + public function testTakeSnapshotWithStringRelationship(): void + { + $post = new TestPost(); + $post->id = 'snap-str-1'; + $post->title = 'String Rel'; + $post->content = 'Content'; + $post->author = 'author-id-string'; + + $metadata = $this->metadataFactory->getMetadata(TestPost::class); + $snapshot = $this->mapper->takeSnapshot($post, $metadata); + + $this->assertEquals('author-id-string', $snapshot['author']); + } + + public function testToCollectionDefinitionsGeneratesCorrectRelationshipTypes(): void + { + $metadata = $this->metadataFactory->getMetadata(TestAllRelationsEntity::class); + $defs = $this->mapper->toCollectionDefinitions($metadata); + + $relationships = $defs['relationships']; + + $this->assertCount(4, $relationships); + + $types = array_map(fn ($r) => $r->type, $relationships); + $this->assertContains(RelationType::OneToOne, $types); + $this->assertContains(RelationType::ManyToOne, $types); + $this->assertContains(RelationType::OneToMany, $types); + $this->assertContains(RelationType::ManyToMany, $types); + } + + public function testToCollectionDefinitionsGeneratesCorrectAttributes(): void + { + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $defs = $this->mapper->toCollectionDefinitions($metadata); + + $collection = $defs['collection']; + $attrs = $collection->attributes; + + $this->assertCount(4, $attrs); + + $nameAttr = $attrs[0]; + $this->assertEquals('name', $nameAttr->key); + $this->assertEquals(ColumnType::String, $nameAttr->type); + $this->assertEquals(255, $nameAttr->size); + $this->assertTrue($nameAttr->required); + + $emailAttr = $attrs[1]; + $this->assertEquals('email', $emailAttr->key); + $this->assertEquals(ColumnType::String, $emailAttr->type); + + $ageAttr = $attrs[2]; + $this->assertEquals('age', $ageAttr->key); + $this->assertEquals(ColumnType::Integer, $ageAttr->type); + + $activeAttr = $attrs[3]; + $this->assertEquals('active', $activeAttr->key); + $this->assertEquals(ColumnType::Boolean, $activeAttr->type); + } + + public function testToCollectionDefinitionsWithCustomKeyColumn(): void + { + $metadata = $this->metadataFactory->getMetadata(TestCustomKeyEntity::class); + $defs = $this->mapper->toCollectionDefinitions($metadata); + + $attrs = $defs['collection']->attributes; + $this->assertCount(1, $attrs); + $this->assertEquals('display_name', $attrs[0]->key); + } + + public function testToCollectionDefinitionsRelationshipKeys(): void + { + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $defs = $this->mapper->toCollectionDefinitions($metadata); + + $relationships = $defs['relationships']; + $this->assertCount(1, $relationships); + $this->assertEquals('users', $relationships[0]->collection); + $this->assertEquals('posts', $relationships[0]->relatedCollection); + $this->assertEquals('posts', $relationships[0]->key); + $this->assertEquals('author', $relationships[0]->twoWayKey); + $this->assertTrue($relationships[0]->twoWay); + } + + public function testRoundTripEntityDocumentEntity(): void + { + $entity = new TestEntity(); + $entity->id = 'round-trip-1'; + $entity->name = 'RoundTrip'; + $entity->email = 'roundtrip@example.com'; + $entity->age = 42; + $entity->active = false; + $entity->version = 3; + $entity->permissions = ['read("any")']; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $doc = $this->mapper->toDocument($entity, $metadata); + + $identityMap = new IdentityMap(); + /** @var TestEntity $restored */ + $restored = $this->mapper->toEntity($doc, $metadata, $identityMap); + + $this->assertEquals($entity->id, $restored->id); + $this->assertEquals($entity->name, $restored->name); + $this->assertEquals($entity->email, $restored->email); + $this->assertEquals($entity->age, $restored->age); + $this->assertEquals($entity->active, $restored->active); + $this->assertEquals($entity->version, $restored->version); + $this->assertEquals($entity->permissions, $restored->permissions); + } + + public function testToEntityWithSingleDocumentRelationship(): void + { + $authorDoc = new Document([ + '$id' => 'author-doc-1', + 'name' => 'Author', + 'email' => 'author@example.com', + 'age' => 35, + 'active' => true, + ]); + + $postDoc = new Document([ + '$id' => 'post-with-author', + 'title' => 'Post', + 'content' => 'Content', + 'author' => $authorDoc, + ]); + + $metadata = $this->metadataFactory->getMetadata(TestPost::class); + $identityMap = new IdentityMap(); + + /** @var TestPost $post */ + $post = $this->mapper->toEntity($postDoc, $metadata, $identityMap); + + $this->assertInstanceOf(TestEntity::class, $post->author); + $this->assertEquals('author-doc-1', $post->author->id); + } + + public function testToEntityWithStringRelationshipValue(): void + { + $postDoc = new Document([ + '$id' => 'post-string-author', + 'title' => 'Post', + 'content' => 'Content', + 'author' => 'author-string-id', + ]); + + $metadata = $this->metadataFactory->getMetadata(TestPost::class); + $identityMap = new IdentityMap(); + + /** @var TestPost $post */ + $post = $this->mapper->toEntity($postDoc, $metadata, $identityMap); + + $this->assertEquals('author-string-id', $post->author); + } + + public function testToEntityWithNullRelationshipSetsDefault(): void + { + $postDoc = new Document([ + '$id' => 'post-null-author', + 'title' => 'Post', + 'content' => 'Content', + 'author' => null, + ]); + + $metadata = $this->metadataFactory->getMetadata(TestPost::class); + $identityMap = new IdentityMap(); + + /** @var TestPost $post */ + $post = $this->mapper->toEntity($postDoc, $metadata, $identityMap); + + $this->assertNull($post->author); + } + + public function testToDocumentIncludesTenantProperty(): void + { + $entity = new TestTenantEntity(); + $entity->id = 'tenant-1'; + $entity->tenantId = 'org-123'; + $entity->name = 'Tenant Item'; + + $metadata = $this->metadataFactory->getMetadata(TestTenantEntity::class); + $doc = $this->mapper->toDocument($entity, $metadata); + + $this->assertEquals('org-123', $doc->getAttribute('$tenant')); + } + + public function testGetIdReturnsNullWhenNoIdProperty(): void + { + $entity = new TestEntity(); + $entity->id = 'test-id'; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $result = $this->mapper->getId($entity, $metadata); + + $this->assertEquals('test-id', $result); + } +} diff --git a/tests/unit/ORM/EntityMapperTest.php b/tests/unit/ORM/EntityMapperTest.php new file mode 100644 index 000000000..f2f547a89 --- /dev/null +++ b/tests/unit/ORM/EntityMapperTest.php @@ -0,0 +1,226 @@ +metadataFactory = new MetadataFactory(); + $this->mapper = new EntityMapper($this->metadataFactory); + } + + public function testToDocument(): void + { + $entity = new TestEntity(); + $entity->id = 'user-123'; + $entity->name = 'John'; + $entity->email = 'john@example.com'; + $entity->age = 30; + $entity->active = true; + $entity->version = 1; + $entity->permissions = ['read("any")']; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $doc = $this->mapper->toDocument($entity, $metadata); + + $this->assertEquals('user-123', $doc->getAttribute('$id')); + $this->assertEquals('John', $doc->getAttribute('name')); + $this->assertEquals('john@example.com', $doc->getAttribute('email')); + $this->assertEquals(30, $doc->getAttribute('age')); + $this->assertTrue($doc->getAttribute('active')); + $this->assertEquals(1, $doc->getAttribute('$version')); + $this->assertEquals(['read("any")'], $doc->getAttribute('$permissions')); + } + + public function testToEntity(): void + { + $doc = new Document([ + '$id' => 'user-456', + '$version' => 2, + '$createdAt' => '2024-01-01 00:00:00', + '$updatedAt' => '2024-01-02 00:00:00', + '$permissions' => ['read("any")'], + 'name' => 'Jane', + 'email' => 'jane@example.com', + 'age' => 25, + 'active' => false, + ]); + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $identityMap = new IdentityMap(); + + /** @var TestEntity $entity */ + $entity = $this->mapper->toEntity($doc, $metadata, $identityMap); + + $this->assertEquals('user-456', $entity->id); + $this->assertEquals(2, $entity->version); + $this->assertEquals('2024-01-01 00:00:00', $entity->createdAt); + $this->assertEquals('2024-01-02 00:00:00', $entity->updatedAt); + $this->assertEquals(['read("any")'], $entity->permissions); + $this->assertEquals('Jane', $entity->name); + $this->assertEquals('jane@example.com', $entity->email); + $this->assertEquals(25, $entity->age); + $this->assertFalse($entity->active); + } + + public function testToDocumentBreaksRelationshipCycles(): void + { + $author = new TestEntity(); + $author->id = 'user-cycle'; + $author->name = 'Cyclic'; + $author->email = 'cycle@example.com'; + + $post = new TestPost(); + $post->id = 'post-cycle'; + $post->title = 'Loop'; + $post->author = $author; + $author->posts = [$post]; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $doc = $this->mapper->toDocument($author, $metadata); + + $this->assertSame('user-cycle', $doc->getId()); + $posts = $doc->getAttribute('posts'); + $this->assertIsArray($posts); + $this->assertCount(1, $posts); + $this->assertInstanceOf(Document::class, $posts[0]); + $this->assertSame('post-cycle', $posts[0]->getId()); + $nestedAuthor = $posts[0]->getAttribute('author'); + $this->assertInstanceOf(Document::class, $nestedAuthor); + $this->assertSame('user-cycle', $nestedAuthor->getId()); + $this->assertFalse($nestedAuthor->offsetExists('posts')); + } + + public function testToEntityUsesIdentityMap(): void + { + $doc = new Document([ + '$id' => 'user-789', + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'age' => 28, + 'active' => true, + ]); + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $identityMap = new IdentityMap(); + + $entity1 = $this->mapper->toEntity($doc, $metadata, $identityMap); + $entity2 = $this->mapper->toEntity($doc, $metadata, $identityMap); + + $this->assertSame($entity1, $entity2); + } + + public function testTakeSnapshot(): void + { + $entity = new TestEntity(); + $entity->id = 'snap-1'; + $entity->name = 'Bob'; + $entity->email = 'bob@example.com'; + $entity->age = 35; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $snapshot = $this->mapper->takeSnapshot($entity, $metadata); + + $this->assertEquals('snap-1', $snapshot['$id']); + $this->assertEquals('Bob', $snapshot['name']); + $this->assertEquals('bob@example.com', $snapshot['email']); + $this->assertEquals(35, $snapshot['age']); + $this->assertTrue($snapshot['active']); + } + + public function testSnapshotChangesDetected(): void + { + $entity = new TestEntity(); + $entity->id = 'snap-2'; + $entity->name = 'Before'; + $entity->email = 'before@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $snapshot1 = $this->mapper->takeSnapshot($entity, $metadata); + + $entity->name = 'After'; + $snapshot2 = $this->mapper->takeSnapshot($entity, $metadata); + + $this->assertNotEquals($snapshot1, $snapshot2); + $this->assertEquals('Before', $snapshot1['name']); + $this->assertEquals('After', $snapshot2['name']); + } + + public function testGetId(): void + { + $entity = new TestEntity(); + $entity->id = 'id-test'; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->assertEquals('id-test', $this->mapper->getId($entity, $metadata)); + } + + public function testToCollectionDefinitions(): void + { + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $defs = $this->mapper->toCollectionDefinitions($metadata); + + $collection = $defs['collection']; + $relationships = $defs['relationships']; + + $this->assertEquals('users', $collection->id); + $this->assertTrue($collection->documentSecurity); + $this->assertCount(4, $collection->attributes); + $this->assertCount(2, $collection->indexes); + + $attrKeys = array_map(fn ($a) => $a->key, $collection->attributes); + $this->assertContains('name', $attrKeys); + $this->assertContains('email', $attrKeys); + $this->assertContains('age', $attrKeys); + $this->assertContains('active', $attrKeys); + + $nameAttr = $collection->attributes[0]; + $this->assertEquals(ColumnType::String, $nameAttr->type); + $this->assertEquals(255, $nameAttr->size); + $this->assertTrue($nameAttr->required); + + $this->assertCount(1, $relationships); + $this->assertEquals('users', $relationships[0]->collection); + $this->assertEquals('posts', $relationships[0]->relatedCollection); + } + + public function testApplyDocumentToEntity(): void + { + $entity = new TestEntity(); + $entity->id = ''; + $entity->version = null; + $entity->createdAt = null; + $entity->updatedAt = null; + + $doc = new Document([ + '$id' => 'generated-id', + '$version' => 1, + '$createdAt' => '2024-06-01 12:00:00', + '$updatedAt' => '2024-06-01 12:00:00', + ]); + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->mapper->applyDocumentToEntity($doc, $entity, $metadata); + + $this->assertEquals('generated-id', $entity->id); + $this->assertEquals(1, $entity->version); + $this->assertEquals('2024-06-01 12:00:00', $entity->createdAt); + $this->assertEquals('2024-06-01 12:00:00', $entity->updatedAt); + } +} diff --git a/tests/unit/ORM/EntitySchemasSyncTest.php b/tests/unit/ORM/EntitySchemasSyncTest.php new file mode 100644 index 000000000..7c20d3dee --- /dev/null +++ b/tests/unit/ORM/EntitySchemasSyncTest.php @@ -0,0 +1,295 @@ +db = $this->createMock(Database::class); + $this->adapter = self::createStub(Adapter::class); + $this->adapter->method('getDatabase')->willReturn('test_db'); + $this->db->method('getAdapter')->willReturn($this->adapter); + $this->em = new EntityManager($this->db); + } + + public function testSyncCreatesCollectionWhenItDoesNotExist(): void + { + $this->db->expects($this->once()) + ->method('exists') + ->with('test_db', 'users') + ->willReturn(false); + + $this->db->expects($this->once()) + ->method('createCollection') + ->with($this->callback(function (mixed $collection): bool { + return $collection instanceof Collection + && $collection->id === 'users'; + })) + ->willReturn(new Collection(id: 'users')); + + $this->db->expects($this->once()) + ->method('createRelationship'); + + $this->em->syncCollectionFromEntity(TestEntity::class); + } + + public function testSyncDiffsAndAppliesChangesWhenCollectionExists(): void + { + $this->db->expects($this->once()) + ->method('exists') + ->with('test_db', 'users') + ->willReturn(true); + + $collectionDoc = new Collection( + id: 'users', + name: 'users', + attributes: [ + Attribute::string(key: 'name', size: 255, required: true), + ], + indexes: [], + permissions: [], + documentSecurity: true, + ); + + $this->db->expects($this->once()) + ->method('getCollection') + ->with('users') + ->willReturn($collectionDoc); + + $this->db->expects($this->never()) + ->method('createCollection'); + + $this->db->expects($this->atLeastOnce()) + ->method('createAttribute'); + + $this->em->syncCollectionFromEntity(TestEntity::class); + } + + public function testSyncIsNoOpWhenNoChangesNeeded(): void + { + $this->db->expects($this->once()) + ->method('exists') + ->with('test_db', 'users') + ->willReturn(true); + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $defs = $this->em->getEntityMapper()->toCollectionDefinitions($metadata); + + /** @var \Utopia\Database\Collection $desired */ + $desired = $defs['collection']; + + $collectionDoc = new Collection( + id: 'users', + name: 'users', + attributes: $desired->attributes, + indexes: $desired->indexes, + permissions: [], + documentSecurity: true, + ); + + $this->db->expects($this->once()) + ->method('getCollection') + ->with('users') + ->willReturn($collectionDoc); + + $this->db->expects($this->never()) + ->method('createCollection'); + + $this->db->expects($this->never()) + ->method('createAttribute'); + + $this->db->expects($this->never()) + ->method('deleteAttribute'); + + $this->db->expects($this->never()) + ->method('createIndex'); + + $this->db->expects($this->never()) + ->method('deleteIndex'); + + $this->em->syncCollectionFromEntity(TestEntity::class); + } + + public function testSyncDetectsNewAttributes(): void + { + $this->db->expects($this->once()) + ->method('exists') + ->with('test_db', 'users') + ->willReturn(true); + + $collectionDoc = new Collection( + id: 'users', + name: 'users', + attributes: [], + indexes: [], + permissions: [], + documentSecurity: true, + ); + + $this->db->expects($this->once()) + ->method('getCollection') + ->with('users') + ->willReturn($collectionDoc); + + $this->db->expects($this->atLeastOnce()) + ->method('createAttribute'); + + $this->em->syncCollectionFromEntity(TestEntity::class); + } + + public function testSyncDetectsDroppedAttributes(): void + { + $this->db->expects($this->once()) + ->method('exists') + ->with('test_db', 'users') + ->willReturn(true); + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $defs = $this->em->getEntityMapper()->toCollectionDefinitions($metadata); + + /** @var \Utopia\Database\Collection $desired */ + $desired = $defs['collection']; + + $extraAttr = Attribute::string(key: 'obsolete_field', size: 100); + + $collectionDoc = new Collection( + id: 'users', + name: 'users', + attributes: [...$desired->attributes, $extraAttr], + indexes: $desired->indexes, + permissions: [], + documentSecurity: true, + ); + + $this->db->expects($this->once()) + ->method('getCollection') + ->with('users') + ->willReturn($collectionDoc); + + $this->db->expects($this->once()) + ->method('deleteAttribute') + ->with('users', 'obsolete_field'); + + $this->em->syncCollectionFromEntity(TestEntity::class); + } + + public function testSyncDetectsNewIndexes(): void + { + $this->db->expects($this->once()) + ->method('exists') + ->with('test_db', 'users') + ->willReturn(true); + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $defs = $this->em->getEntityMapper()->toCollectionDefinitions($metadata); + + /** @var \Utopia\Database\Collection $desired */ + $desired = $defs['collection']; + + $collectionDoc = new Collection( + id: 'users', + name: 'users', + attributes: $desired->attributes, + indexes: [], + permissions: [], + documentSecurity: true, + ); + + $this->db->expects($this->once()) + ->method('getCollection') + ->with('users') + ->willReturn($collectionDoc); + + $this->db->expects($this->atLeastOnce()) + ->method('createIndex'); + + $this->em->syncCollectionFromEntity(TestEntity::class); + } + + public function testSyncDetectsDroppedIndexes(): void + { + $this->db->expects($this->once()) + ->method('exists') + ->with('test_db', 'users') + ->willReturn(true); + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $defs = $this->em->getEntityMapper()->toCollectionDefinitions($metadata); + + /** @var \Utopia\Database\Collection $desired */ + $desired = $defs['collection']; + + $extraIndex = new \Utopia\Database\Index(key: 'idx_old', type: \Utopia\Query\Schema\IndexType::Index, attributes: ['name']); + + $collectionDoc = new Collection( + id: 'users', + name: 'users', + attributes: $desired->attributes, + indexes: [...$desired->indexes, $extraIndex], + permissions: [], + documentSecurity: true, + ); + + $this->db->expects($this->once()) + ->method('getCollection') + ->with('users') + ->willReturn($collectionDoc); + + $this->db->expects($this->once()) + ->method('deleteIndex') + ->with('users', 'idx_old'); + + $this->em->syncCollectionFromEntity(TestEntity::class); + } + + public function testSyncDoesNotCallCreateCollectionWhenAlreadyExists(): void + { + $this->db->expects($this->once()) + ->method('exists') + ->with('test_db', 'users') + ->willReturn(true); + + $metadata = $this->em->getMetadataFactory()->getMetadata(TestEntity::class); + $defs = $this->em->getEntityMapper()->toCollectionDefinitions($metadata); + + /** @var \Utopia\Database\Collection $desired */ + $desired = $defs['collection']; + + $collectionDoc = new Collection( + id: 'users', + name: 'users', + attributes: $desired->attributes, + indexes: $desired->indexes, + permissions: [], + documentSecurity: true, + ); + + $this->db->expects($this->once()) + ->method('getCollection') + ->with('users') + ->willReturn($collectionDoc); + + $this->db->expects($this->never()) + ->method('createCollection'); + + $this->em->syncCollectionFromEntity(TestEntity::class); + } +} diff --git a/tests/unit/ORM/IdentityMapTest.php b/tests/unit/ORM/IdentityMapTest.php new file mode 100644 index 000000000..8d03407cb --- /dev/null +++ b/tests/unit/ORM/IdentityMapTest.php @@ -0,0 +1,95 @@ +map = new IdentityMap(); + } + + public function testPutAndGet(): void + { + $entity = new \stdClass(); + $entity->name = 'test'; + + $this->map->put('users', 'abc123', $entity); + + $this->assertSame($entity, $this->map->get('users', 'abc123')); + } + + public function testGetReturnsNullForMissing(): void + { + $this->assertNull($this->map->get('users', 'nonexistent')); + $this->assertNull($this->map->get('nonexistent', 'abc')); + } + + public function testHas(): void + { + $entity = new \stdClass(); + $this->map->put('users', 'abc', $entity); + + $this->assertTrue($this->map->has('users', 'abc')); + $this->assertFalse($this->map->has('users', 'xyz')); + $this->assertFalse($this->map->has('other', 'abc')); + } + + public function testRemove(): void + { + $entity = new \stdClass(); + $this->map->put('users', 'abc', $entity); + $this->map->remove('users', 'abc'); + + $this->assertFalse($this->map->has('users', 'abc')); + $this->assertNull($this->map->get('users', 'abc')); + } + + public function testClear(): void + { + $this->map->put('users', 'a', new \stdClass()); + $this->map->put('users', 'b', new \stdClass()); + $this->map->put('posts', 'c', new \stdClass()); + + $this->map->clear(); + + $this->assertEmpty(\iterator_to_array($this->map->all())); + $this->assertFalse($this->map->has('users', 'a')); + } + + public function testAll(): void + { + $e1 = new \stdClass(); + $e2 = new \stdClass(); + $e3 = new \stdClass(); + + $this->map->put('users', 'a', $e1); + $this->map->put('users', 'b', $e2); + $this->map->put('posts', 'c', $e3); + + $all = \iterator_to_array($this->map->all(), false); + $this->assertCount(3, $all); + $this->assertContains($e1, $all); + $this->assertContains($e2, $all); + $this->assertContains($e3, $all); + } + + public function testOverwrite(): void + { + $e1 = new \stdClass(); + $e1->v = 1; + $e2 = new \stdClass(); + $e2->v = 2; + + $this->map->put('users', 'a', $e1); + $this->map->put('users', 'a', $e2); + + $this->assertSame($e2, $this->map->get('users', 'a')); + $this->assertCount(1, \iterator_to_array($this->map->all(), false)); + } +} diff --git a/tests/unit/ORM/LifecycleCallbackTest.php b/tests/unit/ORM/LifecycleCallbackTest.php new file mode 100644 index 000000000..04c892cc4 --- /dev/null +++ b/tests/unit/ORM/LifecycleCallbackTest.php @@ -0,0 +1,216 @@ + */ + public array $callLog = []; + + #[PrePersist] + public function onPrePersist(): void + { + $this->callLog[] = 'prePersist'; + } + + #[PostPersist] + public function onPostPersist(): void + { + $this->callLog[] = 'postPersist'; + } + + #[PreUpdate] + public function onPreUpdate(): void + { + $this->callLog[] = 'preUpdate'; + } + + #[PostUpdate] + public function onPostUpdate(): void + { + $this->callLog[] = 'postUpdate'; + } + + #[PreRemove] + public function onPreRemove(): void + { + $this->callLog[] = 'preRemove'; + } + + #[PostRemove] + public function onPostRemove(): void + { + $this->callLog[] = 'postRemove'; + } +} + +#[Entity(collection: 'multi_callback_entities')] +class MultiCallbackEntity +{ + #[Id] + public string $id = ''; + + #[Column(type: ColumnType::String, size: 255)] + public string $name = ''; + + /** @var list */ + public array $callLog = []; + + #[PrePersist] + public function firstPrePersist(): void + { + $this->callLog[] = 'firstPrePersist'; + } + + #[PrePersist] + public function secondPrePersist(): void + { + $this->callLog[] = 'secondPrePersist'; + } +} + +#[Entity(collection: 'no_callback_entities')] +class NoCallbackEntity +{ + #[Id] + public string $id = ''; + + #[Column(type: ColumnType::String, size: 255)] + public string $name = ''; +} + +class LifecycleCallbackTest extends TestCase +{ + protected MetadataFactory $factory; + + protected function setUp(): void + { + MetadataFactory::clearCache(); + $this->factory = new MetadataFactory(); + } + + public function testMetadataFactoryParsesPrePersistCallback(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertContains('onPrePersist', $metadata->prePersistCallbacks); + } + + public function testMetadataFactoryParsesPostPersistCallback(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertContains('onPostPersist', $metadata->postPersistCallbacks); + } + + public function testMetadataFactoryParsesPreUpdateCallback(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertContains('onPreUpdate', $metadata->preUpdateCallbacks); + } + + public function testMetadataFactoryParsesPostUpdateCallback(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertContains('onPostUpdate', $metadata->postUpdateCallbacks); + } + + public function testMetadataFactoryParsesPreRemoveCallback(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertContains('onPreRemove', $metadata->preRemoveCallbacks); + } + + public function testMetadataFactoryParsesPostRemoveCallback(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertContains('onPostRemove', $metadata->postRemoveCallbacks); + } + + public function testMetadataFactoryParsesMultipleCallbacksOfSameType(): void + { + $metadata = $this->factory->getMetadata(MultiCallbackEntity::class); + + $this->assertCount(2, $metadata->prePersistCallbacks); + $this->assertContains('firstPrePersist', $metadata->prePersistCallbacks); + $this->assertContains('secondPrePersist', $metadata->prePersistCallbacks); + } + + public function testEntityWithoutCallbacksHasEmptyArrays(): void + { + $metadata = $this->factory->getMetadata(NoCallbackEntity::class); + + $this->assertEmpty($metadata->prePersistCallbacks); + $this->assertEmpty($metadata->postPersistCallbacks); + $this->assertEmpty($metadata->preUpdateCallbacks); + $this->assertEmpty($metadata->postUpdateCallbacks); + $this->assertEmpty($metadata->preRemoveCallbacks); + $this->assertEmpty($metadata->postRemoveCallbacks); + } + + public function testPrePersistCallbackCountIsExactlyOne(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertCount(1, $metadata->prePersistCallbacks); + } + + public function testPostPersistCallbackCountIsExactlyOne(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertCount(1, $metadata->postPersistCallbacks); + } + + public function testPreUpdateCallbackCountIsExactlyOne(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertCount(1, $metadata->preUpdateCallbacks); + } + + public function testPostUpdateCallbackCountIsExactlyOne(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertCount(1, $metadata->postUpdateCallbacks); + } + + public function testPreRemoveCallbackCountIsExactlyOne(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertCount(1, $metadata->preRemoveCallbacks); + } + + public function testPostRemoveCallbackCountIsExactlyOne(): void + { + $metadata = $this->factory->getMetadata(LifecycleEntity::class); + + $this->assertCount(1, $metadata->postRemoveCallbacks); + } +} diff --git a/tests/unit/ORM/MappingAttributeTest.php b/tests/unit/ORM/MappingAttributeTest.php new file mode 100644 index 000000000..824f14af6 --- /dev/null +++ b/tests/unit/ORM/MappingAttributeTest.php @@ -0,0 +1,447 @@ +factory = new MetadataFactory(); + } + + public function testEntityAttributeWithAllParameters(): void + { + $entity = new Entity( + collection: 'custom_collection', + documentSecurity: false, + permissions: ['read("any")', 'write("users")'], + ); + + $this->assertEquals('custom_collection', $entity->collection); + $this->assertFalse($entity->documentSecurity); + $this->assertEquals(['read("any")', 'write("users")'], $entity->permissions); + } + + public function testEntityAttributeWithDefaults(): void + { + $entity = new Entity(collection: 'test'); + + $this->assertEquals('test', $entity->collection); + $this->assertTrue($entity->documentSecurity); + $this->assertEquals([], $entity->permissions); + } + + public function testColumnAttributeWithAllParameters(): void + { + $column = new Column( + type: ColumnType::String, + size: 500, + required: true, + default: 'hello', + signed: false, + array: true, + format: 'email', + formatOptions: ['domain' => 'example.com'], + filters: ['trim', 'lowercase'], + key: 'custom_key', + ); + + $this->assertEquals(ColumnType::String, $column->type); + $this->assertEquals(500, $column->size); + $this->assertTrue($column->required); + $this->assertEquals('hello', $column->default); + $this->assertFalse($column->signed); + $this->assertTrue($column->array); + $this->assertEquals('email', $column->format); + $this->assertEquals(['domain' => 'example.com'], $column->formatOptions); + $this->assertEquals(['trim', 'lowercase'], $column->filters); + $this->assertEquals('custom_key', $column->key); + } + + public function testColumnAttributeWithDefaults(): void + { + $column = new Column(); + + $this->assertEquals(ColumnType::String, $column->type); + $this->assertEquals(0, $column->size); + $this->assertFalse($column->required); + $this->assertNull($column->default); + $this->assertTrue($column->signed); + $this->assertFalse($column->array); + $this->assertNull($column->format); + $this->assertEquals([], $column->formatOptions); + $this->assertEquals([], $column->filters); + $this->assertNull($column->key); + } + + public function testColumnWithCustomKeyOverride(): void + { + $column = new Column(type: ColumnType::Integer, key: 'db_age'); + + $this->assertEquals('db_age', $column->key); + $this->assertEquals(ColumnType::Integer, $column->type); + } + + public function testIdAttributeIsMarker(): void + { + $ref = new \ReflectionClass(Id::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + $attr = $attrs[0]->newInstance(); + $this->assertEquals(\Attribute::TARGET_PROPERTY, $attr->flags); + } + + public function testVersionAttributeIsMarker(): void + { + $ref = new \ReflectionClass(Version::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + } + + public function testCreatedAtAttributeIsMarker(): void + { + $ref = new \ReflectionClass(CreatedAt::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + } + + public function testUpdatedAtAttributeIsMarker(): void + { + $ref = new \ReflectionClass(UpdatedAt::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + } + + public function testTenantAttributeIsMarker(): void + { + $ref = new \ReflectionClass(Tenant::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + } + + public function testPermissionsAttributeIsMarker(): void + { + $ref = new \ReflectionClass(Permissions::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + } + + public function testHasOneWithAllParameters(): void + { + $hasOne = new HasOne( + target: TestEntity::class, + key: 'profile', + twoWayKey: 'user', + twoWay: false, + onDelete: ForeignKeyAction::Cascade, + ); + + $this->assertEquals(TestEntity::class, $hasOne->target); + $this->assertEquals('profile', $hasOne->key); + $this->assertEquals('user', $hasOne->twoWayKey); + $this->assertFalse($hasOne->twoWay); + $this->assertEquals(ForeignKeyAction::Cascade, $hasOne->onDelete); + } + + public function testHasOneWithDefaults(): void + { + $hasOne = new HasOne(target: TestEntity::class); + + $this->assertEquals(TestEntity::class, $hasOne->target); + $this->assertEquals('', $hasOne->key); + $this->assertEquals('', $hasOne->twoWayKey); + $this->assertTrue($hasOne->twoWay); + $this->assertEquals(ForeignKeyAction::Restrict, $hasOne->onDelete); + } + + public function testBelongsToWithAllParameters(): void + { + $belongsTo = new BelongsTo( + target: TestEntity::class, + key: 'author', + twoWayKey: 'posts', + twoWay: false, + onDelete: ForeignKeyAction::Cascade, + ); + + $this->assertEquals(TestEntity::class, $belongsTo->target); + $this->assertEquals('author', $belongsTo->key); + $this->assertEquals('posts', $belongsTo->twoWayKey); + $this->assertFalse($belongsTo->twoWay); + $this->assertEquals(ForeignKeyAction::Cascade, $belongsTo->onDelete); + } + + public function testBelongsToWithDefaults(): void + { + $belongsTo = new BelongsTo(target: TestEntity::class); + + $this->assertEquals(ForeignKeyAction::Restrict, $belongsTo->onDelete); + $this->assertTrue($belongsTo->twoWay); + } + + public function testHasManyDefaultOnDeleteIsSetNull(): void + { + $hasMany = new HasMany(target: TestPost::class); + + $this->assertEquals(ForeignKeyAction::SetNull, $hasMany->onDelete); + } + + public function testHasManyWithAllParameters(): void + { + $hasMany = new HasMany( + target: TestPost::class, + key: 'posts', + twoWayKey: 'author', + twoWay: false, + onDelete: ForeignKeyAction::Cascade, + ); + + $this->assertEquals(TestPost::class, $hasMany->target); + $this->assertEquals('posts', $hasMany->key); + $this->assertEquals('author', $hasMany->twoWayKey); + $this->assertFalse($hasMany->twoWay); + $this->assertEquals(ForeignKeyAction::Cascade, $hasMany->onDelete); + } + + public function testBelongsToManyDefaultOnDeleteIsCascade(): void + { + $belongsToMany = new BelongsToMany(target: TestEntity::class); + + $this->assertEquals(ForeignKeyAction::Cascade, $belongsToMany->onDelete); + } + + public function testBelongsToManyWithAllParameters(): void + { + $belongsToMany = new BelongsToMany( + target: TestEntity::class, + key: 'tags', + twoWayKey: 'posts', + twoWay: false, + onDelete: ForeignKeyAction::SetNull, + ); + + $this->assertEquals(TestEntity::class, $belongsToMany->target); + $this->assertEquals('tags', $belongsToMany->key); + $this->assertEquals('posts', $belongsToMany->twoWayKey); + $this->assertFalse($belongsToMany->twoWay); + $this->assertEquals(ForeignKeyAction::SetNull, $belongsToMany->onDelete); + } + + public function testTableIndexWithAllParameters(): void + { + $index = new TableIndex( + key: 'idx_test', + type: IndexType::Fulltext, + attributes: ['title', 'content'], + lengths: [100, 200], + orders: [Order::Asc, Order::Desc], + ); + + $this->assertEquals('idx_test', $index->key); + $this->assertEquals(IndexType::Fulltext, $index->type); + $this->assertEquals(['title', 'content'], $index->attributes); + $this->assertEquals([100, 200], $index->lengths); + $this->assertEquals([Order::Asc, Order::Desc], $index->orders); + } + + public function testTableIndexWithDefaults(): void + { + $index = new TableIndex(key: 'idx_basic'); + + $this->assertEquals('idx_basic', $index->key); + $this->assertEquals(IndexType::Index, $index->type); + $this->assertEquals([], $index->attributes); + $this->assertEquals([], $index->lengths); + $this->assertEquals([], $index->orders); + } + + public function testTableIndexIsRepeatable(): void + { + $ref = new \ReflectionClass(TableIndex::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + $attr = $attrs[0]->newInstance(); + $this->assertTrue(($attr->flags & \Attribute::IS_REPEATABLE) !== 0); + } + + public function testTestEntityHasTwoIndexes(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertCount(2, $metadata->indexes); + } + + public function testEntityWithNoRelationships(): void + { + $metadata = $this->factory->getMetadata(TestNoRelationsEntity::class); + + $this->assertEmpty($metadata->relationships); + $this->assertEquals('no_relations', $metadata->collection); + } + + public function testEntityWithCustomKeyOnColumn(): void + { + $metadata = $this->factory->getMetadata(TestCustomKeyEntity::class); + + $this->assertArrayHasKey('displayName', $metadata->columns); + $this->assertEquals('display_name', $metadata->columns['displayName']->documentKey); + $this->assertEquals('displayName', $metadata->columns['displayName']->propertyName); + } + + public function testEntityWithTenantAttribute(): void + { + $metadata = $this->factory->getMetadata(TestTenantEntity::class); + + $this->assertEquals('tenantId', $metadata->tenantProperty); + $this->assertEquals('tenant_items', $metadata->collection); + } + + public function testEntityWithAllRelationshipTypes(): void + { + $metadata = $this->factory->getMetadata(TestAllRelationsEntity::class); + + $this->assertCount(4, $metadata->relationships); + $this->assertArrayHasKey('profile', $metadata->relationships); + $this->assertArrayHasKey('team', $metadata->relationships); + $this->assertArrayHasKey('posts', $metadata->relationships); + $this->assertArrayHasKey('tags', $metadata->relationships); + + $this->assertEquals(RelationType::OneToOne, $metadata->relationships['profile']->type); + $this->assertEquals(RelationType::ManyToOne, $metadata->relationships['team']->type); + $this->assertEquals(RelationType::OneToMany, $metadata->relationships['posts']->type); + $this->assertEquals(RelationType::ManyToMany, $metadata->relationships['tags']->type); + } + + public function testEntityWithNoIndexes(): void + { + $metadata = $this->factory->getMetadata(TestNoRelationsEntity::class); + + $this->assertEmpty($metadata->indexes); + } + + public function testEntityAttributeTargetsClass(): void + { + $ref = new \ReflectionClass(Entity::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + $attr = $attrs[0]->newInstance(); + $this->assertEquals(\Attribute::TARGET_CLASS, $attr->flags); + } + + public function testColumnAttributeTargetsProperty(): void + { + $ref = new \ReflectionClass(Column::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + $attr = $attrs[0]->newInstance(); + $this->assertEquals(\Attribute::TARGET_PROPERTY, $attr->flags); + } + + public function testTableIndexTargetsClassAndIsRepeatable(): void + { + $ref = new \ReflectionClass(TableIndex::class); + $attrs = $ref->getAttributes(\Attribute::class); + $attr = $attrs[0]->newInstance(); + + $this->assertTrue(($attr->flags & \Attribute::TARGET_CLASS) !== 0); + $this->assertTrue(($attr->flags & \Attribute::IS_REPEATABLE) !== 0); + } + + public function testColumnWithEveryColumnType(): void + { + $types = [ + ColumnType::String, + ColumnType::Integer, + ColumnType::Boolean, + ColumnType::Float, + ColumnType::Datetime, + ColumnType::Json, + ]; + + foreach ($types as $type) { + $column = new Column(type: $type); + $this->assertEquals($type, $column->type); + } + } + + public function testHasOneAttributeTargetsProperty(): void + { + $ref = new \ReflectionClass(HasOne::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + $attr = $attrs[0]->newInstance(); + $this->assertEquals(\Attribute::TARGET_PROPERTY, $attr->flags); + } + + public function testHasManyAttributeTargetsProperty(): void + { + $ref = new \ReflectionClass(HasMany::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + } + + public function testBelongsToAttributeTargetsProperty(): void + { + $ref = new \ReflectionClass(BelongsTo::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + } + + public function testBelongsToManyAttributeTargetsProperty(): void + { + $ref = new \ReflectionClass(BelongsToMany::class); + $attrs = $ref->getAttributes(\Attribute::class); + + $this->assertNotEmpty($attrs); + } + + public function testEntityWithPermissionsInAttribute(): void + { + $metadata = $this->factory->getMetadata(TestPermissionEntity::class); + + $this->assertEquals(['read("any")', 'write("users")'], $metadata->permissions); + } + + public function testEntityWithDocumentSecurityFalse(): void + { + $metadata = $this->factory->getMetadata(TestPermissionEntity::class); + + $this->assertFalse($metadata->documentSecurity); + } +} diff --git a/tests/unit/ORM/MetadataFactoryTest.php b/tests/unit/ORM/MetadataFactoryTest.php new file mode 100644 index 000000000..b5293b52a --- /dev/null +++ b/tests/unit/ORM/MetadataFactoryTest.php @@ -0,0 +1,141 @@ +factory = new MetadataFactory(); + } + + public function testParseEntityAttribute(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertEquals('users', $metadata->collection); + $this->assertTrue($metadata->documentSecurity); + $this->assertEquals(TestEntity::class, $metadata->className); + } + + public function testParseIdProperty(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertEquals('id', $metadata->idProperty); + } + + public function testParseVersionProperty(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertEquals('version', $metadata->versionProperty); + } + + public function testParseTimestampProperties(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertEquals('createdAt', $metadata->createdAtProperty); + $this->assertEquals('updatedAt', $metadata->updatedAtProperty); + } + + public function testParsePermissionsProperty(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertEquals('permissions', $metadata->permissionsProperty); + } + + public function testParseColumns(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertCount(4, $metadata->columns); + $this->assertArrayHasKey('name', $metadata->columns); + $this->assertArrayHasKey('email', $metadata->columns); + $this->assertArrayHasKey('age', $metadata->columns); + $this->assertArrayHasKey('active', $metadata->columns); + + $nameMapping = $metadata->columns['name']; + $this->assertEquals('name', $nameMapping->propertyName); + $this->assertEquals('name', $nameMapping->documentKey); + $this->assertEquals(ColumnType::String, $nameMapping->column->type); + $this->assertEquals(255, $nameMapping->column->size); + $this->assertTrue($nameMapping->column->required); + + $ageMapping = $metadata->columns['age']; + $this->assertEquals(ColumnType::Integer, $ageMapping->column->type); + $this->assertFalse($ageMapping->column->required); + } + + public function testParseRelationships(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertCount(1, $metadata->relationships); + $this->assertArrayHasKey('posts', $metadata->relationships); + + $rel = $metadata->relationships['posts']; + $this->assertEquals('posts', $rel->propertyName); + $this->assertEquals('posts', $rel->documentKey); + $this->assertEquals(RelationType::OneToMany, $rel->type); + $this->assertEquals(TestPost::class, $rel->targetClass); + $this->assertEquals('author', $rel->twoWayKey); + $this->assertTrue($rel->twoWay); + } + + public function testParseIndexes(): void + { + $metadata = $this->factory->getMetadata(TestEntity::class); + + $this->assertCount(2, $metadata->indexes); + $this->assertEquals('idx_email', $metadata->indexes[0]->key); + $this->assertEquals(IndexType::Unique, $metadata->indexes[0]->type); + $this->assertEquals(['email'], $metadata->indexes[0]->attributes); + + $this->assertEquals('idx_name', $metadata->indexes[1]->key); + $this->assertEquals(IndexType::Index, $metadata->indexes[1]->type); + } + + public function testCaching(): void + { + $metadata1 = $this->factory->getMetadata(TestEntity::class); + $metadata2 = $this->factory->getMetadata(TestEntity::class); + + $this->assertSame($metadata1, $metadata2); + } + + public function testGetCollection(): void + { + $this->assertEquals('users', $this->factory->getCollection(TestEntity::class)); + $this->assertEquals('posts', $this->factory->getCollection(TestPost::class)); + } + + public function testNonEntityThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->factory->getMetadata(\stdClass::class); + } + + public function testBelongsToRelationship(): void + { + $metadata = $this->factory->getMetadata(TestPost::class); + + $this->assertCount(1, $metadata->relationships); + $this->assertArrayHasKey('author', $metadata->relationships); + + $rel = $metadata->relationships['author']; + $this->assertEquals(RelationType::ManyToOne, $rel->type); + $this->assertEquals(TestEntity::class, $rel->targetClass); + } +} diff --git a/tests/unit/ORM/SoftDeleteTest.php b/tests/unit/ORM/SoftDeleteTest.php new file mode 100644 index 000000000..7f3da5357 --- /dev/null +++ b/tests/unit/ORM/SoftDeleteTest.php @@ -0,0 +1,206 @@ +metadataFactory = new MetadataFactory(); + $this->identityMap = new IdentityMap(); + $mapper = new EntityMapper($this->metadataFactory); + $this->uow = new UnitOfWork($this->identityMap, $this->metadataFactory, $mapper); + } + + public function testMetadataFactoryParsesSoftDeleteAttribute(): void + { + $metadata = $this->metadataFactory->getMetadata(SoftDeleteEntity::class); + + $this->assertEquals('deletedAt', $metadata->softDeleteColumn); + $this->assertArrayHasKey('deletedAt', $metadata->columns); + $this->assertSame('deletedAt', $metadata->columns['deletedAt']->documentKey); + $this->assertSame(ColumnType::Datetime, $metadata->columns['deletedAt']->column->type); + } + + public function testMetadataFactoryParsesSoftDeleteWithCustomColumn(): void + { + $metadata = $this->metadataFactory->getMetadata(CustomSoftDeleteEntity::class); + + $this->assertEquals('removedAt', $metadata->softDeleteColumn); + } + + public function testEntityWithoutSoftDeleteHasNullColumn(): void + { + $metadata = $this->metadataFactory->getMetadata(HardDeleteEntity::class); + + $this->assertNull($metadata->softDeleteColumn); + } + + public function testRemoveSetsDeletedAtOnSoftDeletableEntity(): void + { + $entity = new SoftDeleteEntity(); + $entity->id = 'soft-1'; + $entity->name = 'Soft'; + + $metadata = $this->metadataFactory->getMetadata(SoftDeleteEntity::class); + $this->identityMap->put('soft_items', 'soft-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $this->assertNull($entity->deletedAt); + + $this->uow->remove($entity); + + $this->assertNotNull($entity->deletedAt); + $this->assertEquals(EntityState::Managed, $this->uow->getState($entity)); + } + + public function testRemoveSchedulesDeletionOnNonSoftDeletableEntity(): void + { + $entity = new HardDeleteEntity(); + $entity->id = 'hard-1'; + $entity->name = 'Hard'; + + $metadata = $this->metadataFactory->getMetadata(HardDeleteEntity::class); + $this->identityMap->put('hard_items', 'hard-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $this->uow->remove($entity); + + $this->assertEquals(EntityState::Removed, $this->uow->getState($entity)); + } + + public function testForceRemoveAlwaysSchedulesRealDeletion(): void + { + $entity = new SoftDeleteEntity(); + $entity->id = 'force-1'; + $entity->name = 'Force'; + + $metadata = $this->metadataFactory->getMetadata(SoftDeleteEntity::class); + $this->identityMap->put('soft_items', 'force-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $this->uow->forceRemove($entity); + + $this->assertEquals(EntityState::Removed, $this->uow->getState($entity)); + } + + public function testForceRemoveOnNonSoftDeletableEntitySchedulesDeletion(): void + { + $entity = new HardDeleteEntity(); + $entity->id = 'force-hard-1'; + $entity->name = 'ForceHard'; + + $metadata = $this->metadataFactory->getMetadata(HardDeleteEntity::class); + $this->identityMap->put('hard_items', 'force-hard-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $this->uow->forceRemove($entity); + + $this->assertEquals(EntityState::Removed, $this->uow->getState($entity)); + } + + public function testRestoreClearsDeletedAt(): void + { + $entity = new SoftDeleteEntity(); + $entity->id = 'restore-1'; + $entity->name = 'Restore'; + $entity->deletedAt = '2024-01-01 00:00:00'; + + $this->uow->restore($entity); + + $this->assertNull($entity->deletedAt); + } + + public function testRestoreIsNoOpWithoutSoftDelete(): void + { + $entity = new HardDeleteEntity(); + $entity->id = 'restore-hard-1'; + $entity->name = 'RestoreHard'; + + $this->uow->restore($entity); + + $this->assertNull($this->uow->getState($entity)); + } + + public function testSoftDeleteDoesNotScheduleDeletion(): void + { + $entity = new SoftDeleteEntity(); + $entity->id = 'no-schedule-1'; + $entity->name = 'NoSchedule'; + + $metadata = $this->metadataFactory->getMetadata(SoftDeleteEntity::class); + $this->identityMap->put('soft_items', 'no-schedule-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $this->uow->remove($entity); + + $this->assertNotEquals(EntityState::Removed, $this->uow->getState($entity)); + } + + public function testRestoreWithCustomColumnClearsValue(): void + { + $entity = new CustomSoftDeleteEntity(); + $entity->id = 'restore-custom-1'; + $entity->name = 'RestoreCustom'; + $entity->removedAt = '2024-06-15 12:00:00'; + + $this->uow->restore($entity); + + $this->assertNull($entity->removedAt); + } +} diff --git a/tests/unit/ORM/TestAllRelationsEntity.php b/tests/unit/ORM/TestAllRelationsEntity.php new file mode 100644 index 000000000..962b99cde --- /dev/null +++ b/tests/unit/ORM/TestAllRelationsEntity.php @@ -0,0 +1,31 @@ + */ + #[HasMany(target: TestPost::class, key: 'posts', twoWayKey: 'author')] + public array $posts = []; + + /** @var array */ + #[BelongsToMany(target: TestNoRelationsEntity::class, key: 'tags', twoWayKey: 'items')] + public array $tags = []; +} diff --git a/tests/unit/ORM/TestCustomKeyEntity.php b/tests/unit/ORM/TestCustomKeyEntity.php new file mode 100644 index 000000000..cee5a8f43 --- /dev/null +++ b/tests/unit/ORM/TestCustomKeyEntity.php @@ -0,0 +1,18 @@ + */ + #[Permissions] + public array $permissions = []; + + #[Column(type: ColumnType::String, size: 255, required: true)] + public string $name = ''; + + #[Column(type: ColumnType::String, size: 255, required: true)] + public string $email = ''; + + #[Column(type: ColumnType::Integer, size: 0)] + public int $age = 0; + + #[Column(type: ColumnType::Boolean)] + public bool $active = true; + + /** @var array */ + #[HasMany(target: TestPost::class, key: 'posts', twoWayKey: 'author')] + public array $posts = []; +} diff --git a/tests/unit/ORM/TestNoRelationsEntity.php b/tests/unit/ORM/TestNoRelationsEntity.php new file mode 100644 index 000000000..c60c14039 --- /dev/null +++ b/tests/unit/ORM/TestNoRelationsEntity.php @@ -0,0 +1,18 @@ +identityMap = new IdentityMap(); + $this->metadataFactory = new MetadataFactory(); + $this->mapper = new EntityMapper($this->metadataFactory); + $this->uow = new UnitOfWork($this->identityMap, $this->metadataFactory, $this->mapper); + } + + public function testFlushWithNoChangesDoesNothing(): void + { + $db = $this->createMock(Database::class); + + $db->expects($this->never()) + ->method('withTransaction'); + + $this->uow->flush($db); + } + + public function testFlushProcessesInsertsBeforeUpdatesBeforeDeletes(): void + { + $insertEntity = new TestEntity(); + $insertEntity->id = 'insert-1'; + $insertEntity->name = 'Insert'; + $insertEntity->email = 'insert@example.com'; + $insertEntity->age = 20; + $insertEntity->active = true; + + $updateEntity = new TestEntity(); + $updateEntity->id = 'update-1'; + $updateEntity->name = 'Before'; + $updateEntity->email = 'update@example.com'; + $updateEntity->age = 25; + $updateEntity->active = true; + + $deleteEntity = new TestEntity(); + $deleteEntity->id = 'delete-1'; + $deleteEntity->name = 'Delete'; + $deleteEntity->email = 'delete@example.com'; + $deleteEntity->age = 30; + $deleteEntity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + + $this->identityMap->put('users', 'update-1', $updateEntity); + $this->uow->registerManaged($updateEntity, $metadata); + $updateEntity->name = 'After'; + + $this->identityMap->put('users', 'delete-1', $deleteEntity); + $this->uow->registerManaged($deleteEntity, $metadata); + $this->uow->remove($deleteEntity); + + $this->uow->persist($insertEntity); + + $callOrder = []; + $db = $this->createMock(Database::class); + + $db->expects($this->once()) + ->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $db->method('createDocument') + ->willReturnCallback(function (string $collection, Document $doc) use (&$callOrder) { + $callOrder[] = 'insert'; + + return $doc; + }); + + $db->method('updateDocument') + ->willReturnCallback(function (string $collection, string $id, Document $doc) use (&$callOrder) { + $callOrder[] = 'update'; + + return $doc; + }); + + $db->method('deleteDocument') + ->willReturnCallback(function (string $collection, string $id) use (&$callOrder) { + $callOrder[] = 'delete'; + + return true; + }); + + $this->uow->flush($db); + + $this->assertEquals(['insert', 'update', 'delete'], $callOrder); + } + + public function testFlushRestoresIdentityMapWhenTransactionFails(): void + { + $entity = new TestEntity(); + $entity->id = 'rollback-1'; + $entity->name = 'Rollback'; + $entity->email = 'rollback@example.com'; + + $this->uow->persist($entity); + + $db = $this->createMock(Database::class); + $db->method('withTransaction')->willReturnCallback(function (callable $callback) { + $callback(); + throw new \RuntimeException('commit failed'); + }); + $db->method('createDocument')->willReturnCallback( + fn (string $collection, Document $doc): Document => $doc + ); + + try { + $this->uow->flush($db); + $this->fail('Expected flush to throw'); + } catch (\RuntimeException $exception) { + $this->assertSame('commit failed', $exception->getMessage()); + } + + $this->assertFalse($this->identityMap->has('users', 'rollback-1')); + $this->assertSame(EntityState::New, $this->uow->getState($entity)); + $this->assertSame('rollback-1', $entity->id); + } + + public function testRegisterManagedSetsStateAndTakesSnapshot(): void + { + $entity = new TestEntity(); + $entity->id = 'reg-1'; + $entity->name = 'Registered'; + $entity->email = 'reg@example.com'; + $entity->age = 30; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->uow->registerManaged($entity, $metadata); + + $this->assertEquals(EntityState::Managed, $this->uow->getState($entity)); + } + + public function testDirtyDetectionUnchangedEntityNotQueuedForUpdate(): void + { + $entity = new TestEntity(); + $entity->id = 'dirty-no-1'; + $entity->name = 'Clean'; + $entity->email = 'clean@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'dirty-no-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $db = $this->createMock(Database::class); + + $db->expects($this->never()) + ->method('withTransaction'); + + $db->expects($this->never()) + ->method('updateDocument'); + + $this->uow->flush($db); + } + + public function testDirtyDetectionChangedColumnQueuedForUpdate(): void + { + $entity = new TestEntity(); + $entity->id = 'dirty-col-1'; + $entity->name = 'Before'; + $entity->email = 'dirty@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'dirty-col-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $entity->name = 'After'; + + $db = $this->createMock(Database::class); + + $db->expects($this->once()) + ->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $updatedDoc = new Document([ + '$id' => 'dirty-col-1', + '$version' => 2, + '$createdAt' => '2024-01-01 00:00:00', + '$updatedAt' => '2024-01-02 00:00:00', + 'name' => 'After', + ]); + + $db->expects($this->once()) + ->method('updateDocument') + ->with('users', 'dirty-col-1', $this->isInstanceOf(Document::class)) + ->willReturn($updatedDoc); + + $this->uow->flush($db); + } + + public function testDirtyDetectionChangedRelationshipQueuedForUpdate(): void + { + $entity = new TestEntity(); + $entity->id = 'dirty-rel-1'; + $entity->name = 'User'; + $entity->email = 'user@example.com'; + $entity->age = 25; + $entity->active = true; + $entity->posts = []; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'dirty-rel-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $post = new TestPost(); + $post->id = 'new-post-1'; + $post->title = 'New Post'; + $post->content = 'Content'; + $entity->posts = [$post]; + + $db = $this->createMock(Database::class); + + $db->expects($this->once()) + ->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $db->expects($this->once()) + ->method('updateDocument') + ->willReturn(new Document(['$id' => 'dirty-rel-1'])); + + $this->uow->flush($db); + } + + public function testDetachRemovesFromIdentityMap(): void + { + $entity = new TestEntity(); + $entity->id = 'detach-map-1'; + $entity->name = 'Detach'; + $entity->email = 'detach@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'detach-map-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $this->uow->detach($entity); + + $this->assertFalse($this->identityMap->has('users', 'detach-map-1')); + } + + public function testDetachRemovesFromScheduledInsertions(): void + { + $entity = new TestEntity(); + $entity->id = 'detach-ins-1'; + $entity->name = 'DetachIns'; + $entity->email = 'detachins@example.com'; + $entity->age = 20; + $entity->active = true; + + $this->uow->persist($entity); + $this->assertEquals(EntityState::New, $this->uow->getState($entity)); + + $this->uow->detach($entity); + + $this->assertNull($this->uow->getState($entity)); + + $db = $this->createMock(Database::class); + $db->expects($this->never())->method('withTransaction'); + $db->expects($this->never())->method('createDocument'); + + $this->uow->flush($db); + } + + public function testDetachRemovesFromScheduledDeletions(): void + { + $entity = new TestEntity(); + $entity->id = 'detach-del-1'; + $entity->name = 'DetachDel'; + $entity->email = 'detachdel@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'detach-del-1', $entity); + $this->uow->registerManaged($entity, $metadata); + $this->uow->remove($entity); + $this->assertEquals(EntityState::Removed, $this->uow->getState($entity)); + + $this->uow->detach($entity); + + $this->assertNull($this->uow->getState($entity)); + + $db = $this->createMock(Database::class); + $db->expects($this->never())->method('withTransaction'); + $db->expects($this->never())->method('deleteDocument'); + + $this->uow->flush($db); + } + + public function testClearResetsAllSplObjectStorage(): void + { + $e1 = new TestEntity(); + $e1->id = 'clear-1'; + $e1->name = 'A'; + $e1->email = 'a@example.com'; + $e1->age = 20; + $e1->active = true; + + $e2 = new TestEntity(); + $e2->id = 'clear-2'; + $e2->name = 'B'; + $e2->email = 'b@example.com'; + $e2->age = 25; + $e2->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'clear-2', $e2); + $this->uow->registerManaged($e2, $metadata); + + $this->uow->persist($e1); + $this->uow->remove($e2); + + $this->uow->clear(); + + $this->assertNull($this->uow->getState($e1)); + $this->assertNull($this->uow->getState($e2)); + $this->assertEmpty(\iterator_to_array($this->identityMap->all())); + } + + public function testCascadePersistDeeplyNestedEntities(): void + { + $innerPost = new TestPost(); + $innerPost->id = 'deep-post'; + $innerPost->title = 'Deep Post'; + $innerPost->content = 'Content'; + + $author = new TestEntity(); + $author->id = 'deep-author'; + $author->name = 'Deep Author'; + $author->email = 'deep@example.com'; + $author->age = 30; + $author->active = true; + $author->posts = [$innerPost]; + + $innerPost->author = $author; + + $outerUser = new TestEntity(); + $outerUser->id = 'outer-user'; + $outerUser->name = 'Outer'; + $outerUser->email = 'outer@example.com'; + $outerUser->age = 40; + $outerUser->active = true; + $outerUser->posts = [$innerPost]; + + $this->uow->persist($outerUser); + + $this->assertEquals(EntityState::New, $this->uow->getState($outerUser)); + $this->assertEquals(EntityState::New, $this->uow->getState($innerPost)); + $this->assertEquals(EntityState::New, $this->uow->getState($author)); + } + + public function testCascadePersistDoesNotRepersistTrackedEntities(): void + { + $post = new TestPost(); + $post->id = 'tracked-post'; + $post->title = 'Tracked'; + $post->content = 'Content'; + + $user = new TestEntity(); + $user->id = 'tracked-user'; + $user->name = 'Tracked'; + $user->email = 'tracked@example.com'; + $user->age = 25; + $user->active = true; + $user->posts = [$post]; + + $this->uow->persist($post); + $this->assertEquals(EntityState::New, $this->uow->getState($post)); + + $this->uow->persist($user); + $this->assertEquals(EntityState::New, $this->uow->getState($user)); + $this->assertEquals(EntityState::New, $this->uow->getState($post)); + } + + public function testRemoveUntrackedEntityDoesNothing(): void + { + $entity = new TestEntity(); + $entity->id = 'untracked-1'; + $entity->name = 'Untracked'; + $entity->email = 'untracked@example.com'; + + $this->uow->remove($entity); + + $this->assertNull($this->uow->getState($entity)); + } + + public function testFlushClearsScheduledInsertionsAfterExecution(): void + { + $entity = new TestEntity(); + $entity->id = 'flush-clear-1'; + $entity->name = 'FlushClear'; + $entity->email = 'flushclear@example.com'; + $entity->age = 20; + $entity->active = true; + + $this->uow->persist($entity); + + $db = self::createStub(Database::class); + $db->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $createdDoc = new Document([ + '$id' => 'flush-clear-1', + '$version' => 1, + '$createdAt' => '2024-01-01 00:00:00', + '$updatedAt' => '2024-01-01 00:00:00', + ]); + + $db->method('createDocument')->willReturn($createdDoc); + + $this->uow->flush($db); + + $db2 = $this->createMock(Database::class); + $db2->expects($this->never())->method('withTransaction'); + + $this->uow->flush($db2); + } + + public function testFlushClearsScheduledDeletionsAfterExecution(): void + { + $entity = new TestEntity(); + $entity->id = 'flush-del-clear'; + $entity->name = 'FlushDelClear'; + $entity->email = 'flushdelclear@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'flush-del-clear', $entity); + $this->uow->registerManaged($entity, $metadata); + $this->uow->remove($entity); + + $db = self::createStub(Database::class); + $db->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $db->method('deleteDocument')->willReturn(true); + + $this->uow->flush($db); + + $db2 = $this->createMock(Database::class); + $db2->expects($this->never())->method('withTransaction'); + + $this->uow->flush($db2); + } + + public function testFlushInsertTransitionsEntityToManaged(): void + { + $entity = new TestEntity(); + $entity->id = 'transition-1'; + $entity->name = 'Transition'; + $entity->email = 'transition@example.com'; + $entity->age = 20; + $entity->active = true; + + $this->uow->persist($entity); + $this->assertEquals(EntityState::New, $this->uow->getState($entity)); + + $db = self::createStub(Database::class); + $db->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + + $createdDoc = new Document([ + '$id' => 'transition-1', + '$version' => 1, + '$createdAt' => '2024-01-01 00:00:00', + '$updatedAt' => '2024-01-01 00:00:00', + ]); + + $db->method('createDocument')->willReturn($createdDoc); + + $this->uow->flush($db); + + $this->assertEquals(EntityState::Managed, $this->uow->getState($entity)); + } + + public function testFlushDeleteRemovesEntityFromTracking(): void + { + $entity = new TestEntity(); + $entity->id = 'del-track-1'; + $entity->name = 'DelTrack'; + $entity->email = 'deltrack@example.com'; + $entity->age = 20; + $entity->active = true; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'del-track-1', $entity); + $this->uow->registerManaged($entity, $metadata); + $this->uow->remove($entity); + + $db = self::createStub(Database::class); + $db->method('withTransaction') + ->willReturnCallback(function (callable $callback) { + return $callback(); + }); + $db->method('deleteDocument')->willReturn(true); + + $this->uow->flush($db); + + $this->assertNull($this->uow->getState($entity)); + $this->assertFalse($this->identityMap->has('users', 'del-track-1')); + } +} diff --git a/tests/unit/ORM/UnitOfWorkTest.php b/tests/unit/ORM/UnitOfWorkTest.php new file mode 100644 index 000000000..07a493187 --- /dev/null +++ b/tests/unit/ORM/UnitOfWorkTest.php @@ -0,0 +1,159 @@ +identityMap = new IdentityMap(); + $this->metadataFactory = new MetadataFactory(); + $mapper = new EntityMapper($this->metadataFactory); + $this->uow = new UnitOfWork($this->identityMap, $this->metadataFactory, $mapper); + } + + public function testPersistNewEntity(): void + { + $entity = new TestEntity(); + $entity->id = 'new-1'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $this->uow->persist($entity); + + $this->assertEquals(EntityState::New, $this->uow->getState($entity)); + } + + public function testPersistIdempotent(): void + { + $entity = new TestEntity(); + $entity->id = 'new-2'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $this->uow->persist($entity); + $this->uow->persist($entity); + + $this->assertEquals(EntityState::New, $this->uow->getState($entity)); + } + + public function testRemoveNewEntityUnracks(): void + { + $entity = new TestEntity(); + $entity->id = 'new-3'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $this->uow->persist($entity); + $this->uow->remove($entity); + + $this->assertNull($this->uow->getState($entity)); + } + + public function testRemoveManagedEntitySchedulesDeletion(): void + { + $entity = new TestEntity(); + $entity->id = 'managed-1'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'managed-1', $entity); + $this->uow->registerManaged($entity, $metadata); + + $this->assertEquals(EntityState::Managed, $this->uow->getState($entity)); + + $this->uow->remove($entity); + + $this->assertEquals(EntityState::Removed, $this->uow->getState($entity)); + } + + public function testPersistRemovedEntityRestoresManaged(): void + { + $entity = new TestEntity(); + $entity->id = 'managed-2'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $metadata = $this->metadataFactory->getMetadata(TestEntity::class); + $this->identityMap->put('users', 'managed-2', $entity); + $this->uow->registerManaged($entity, $metadata); + $this->uow->remove($entity); + $this->uow->persist($entity); + + $this->assertEquals(EntityState::Managed, $this->uow->getState($entity)); + } + + public function testDetach(): void + { + $entity = new TestEntity(); + $entity->id = 'detach-1'; + $entity->name = 'Test'; + $entity->email = 'test@example.com'; + + $this->uow->persist($entity); + $this->uow->detach($entity); + + $this->assertNull($this->uow->getState($entity)); + } + + public function testClear(): void + { + $e1 = new TestEntity(); + $e1->id = 'clear-1'; + $e1->name = 'A'; + $e1->email = 'a@example.com'; + + $e2 = new TestEntity(); + $e2->id = 'clear-2'; + $e2->name = 'B'; + $e2->email = 'b@example.com'; + + $this->uow->persist($e1); + $this->uow->persist($e2); + $this->uow->clear(); + + $this->assertNull($this->uow->getState($e1)); + $this->assertNull($this->uow->getState($e2)); + $this->assertEmpty(\iterator_to_array($this->identityMap->all())); + } + + public function testGetStateReturnsNullForUntracked(): void + { + $entity = new TestEntity(); + $this->assertNull($this->uow->getState($entity)); + } + + public function testCascadePersistRelatedEntities(): void + { + $post = new TestPost(); + $post->id = 'post-1'; + $post->title = 'My Post'; + $post->content = 'Content'; + + $user = new TestEntity(); + $user->id = 'cascade-1'; + $user->name = 'User'; + $user->email = 'user@example.com'; + $user->posts = [$post]; + + $this->uow->persist($user); + + $this->assertEquals(EntityState::New, $this->uow->getState($user)); + $this->assertEquals(EntityState::New, $this->uow->getState($post)); + } +}