- Added ArrayOf attributes and DatasetFill/DatasetPull events. ArrayCollection is also working with ArrayOf attributes

This commit is contained in:
Dave M. 2026-04-13 17:00:29 +00:00
parent fd5d31fa17
commit 009e30eb7e
21 changed files with 322 additions and 43 deletions

View File

@ -0,0 +1,72 @@
<?php
namespace Ulmus\Attribute\Property;
use Notes\Common\ReflectedProperty;
use Ulmus\Entity\EntityInterface;
use Ulmus\Entity\JsonUnserializable;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ArrayOf implements \Ulmus\Entity\EntityValueModifier
{
public function __construct(
protected string $type,
) {}
public function push(mixed $value, ReflectedProperty $property): mixed
{
$return = [];
foreach($value as $index => $item) {
if (! is_object($item)) {
if (enum_exists($this->type)) {
if ($this->type::tryFrom($item) === null) {
throw new \InvalidArgumentException(
sprintf("Given item '%s' is not a valid enum for type %s ; try one of theses instead : %s", $item, $this->type, implode("', '", array_map(fn($e) => $e->value, $this->type::cases())))
);
} else {
$value = $this->type::from($item);
}
}
elseif (class_exists($this->type)) {
if (is_subclass_of($this->type, JsonUnserializable::class)) {
$value = (new \ReflectionClass($this->type))->newInstanceWithoutConstructor();
$value->jsonUnserialize($item);
}
else {
$value = new $this->type($item);
}
}
$return[$index] = $value;
}
}
if (! $property->type->builtIn) {
$cls = $property->type->type;
return new $cls($return);
}
return $return;
}
public function pull(mixed $value, ReflectedProperty $property) : mixed
{
$return = [];
foreach($value as $index => $item) {
if (is_object($item)) {
if (enum_exists($this->type)) {
$value = $item->value;
} elseif (class_exists($this->type)) {
$value = (string) $value;
}
$return[$index] = $value;
}
}
return $return;
}
}

View File

@ -33,4 +33,19 @@ class Join implements ResettablePropertyInterface {
return $this->foreignKey; return $this->foreignKey;
} }
public function isOneToOne() : bool
{
return true;
}
public function isOneToMany() : bool
{
return false;
}
public function isManyToMany() : bool
{
return false;
}
} }

View File

@ -0,0 +1,10 @@
<?php
namespace Ulmus\Attribute;
use Ulmus\Entity\EntityInterface;
interface RegisterEntityEventInterface
{
public function attach(EntityInterface $entity, string $field);
}

View File

@ -115,6 +115,13 @@ class EntityResolver {
} }
} }
public function getPropertyHavingAttribute(array|object|string $type) : array
{
return array_filter(array_map(
fn($e) => $e->getAttributes(is_object($type) ? $type::class : $type), $this->reflectedClass->getProperties(true)
));
}
public function getPropertyEntityType(string $name) : false|string public function getPropertyEntityType(string $name) : false|string
{ {
return $this->reflectedClass->getProperties(true)[$name]->getTypes()[0]->type ?? false; return $this->reflectedClass->getProperties(true)[$name]->getTypes()[0]->type ?? false;

View File

@ -84,7 +84,7 @@ abstract class Sql {
return $value; return $value;
} }
public static function collate(string $name) : string public static function collate(string $name) : object
{ {
if ( ! preg_match('/^[a-z0-9$_]+$/i',$name) ) { if ( ! preg_match('/^[a-z0-9$_]+$/i',$name) ) {
throw new \InvalidArgumentException(sprintf("Given identifier '%s' should contains supported characters in function name (a-Z, 0-9, $ and _)", $name)); throw new \InvalidArgumentException(sprintf("Given identifier '%s' should contains supported characters in function name (a-Z, 0-9, $ and _)", $name));

View File

@ -0,0 +1,23 @@
<?php
namespace Ulmus\Entity;
use Ulmus\EntityCollection;
class ArrayCollection extends EntityCollection implements JsonUnserializable
{
public function jsonUnserialize(array $json): void
{
$this->fromarray($json);
}
public function fromArray(array $datasets, ? string /*stringable*/ $entityClass = null) : self
{
foreach($datasets as $dataset) {
\ArrayObject::append($dataset);
}
return $this;
}
}

View File

@ -13,7 +13,7 @@ interface EntityInterface extends SearchableInterface /* extends \JsonSerializab
{ {
public function fromArray(iterable $dataset) : static; public function fromArray(iterable $dataset) : static;
public function entityGetDataset(bool $includeRelations = false, bool $returnSource = false) : array; public function entityGetDataset(bool $includeRelations = false, bool $returnSource = false) : array;
public function toArray($includeRelations = false, array $filterFields = null) : array; public function toArray($includeRelations = false, array $filterFields = []) : array;
public function toCollection() : EntityCollection; public function toCollection() : EntityCollection;
public function isLoaded() : bool; public function isLoaded() : bool;
public function jsonSerialize() : mixed; public function jsonSerialize() : mixed;

View File

@ -3,6 +3,6 @@
namespace Ulmus\Entity; namespace Ulmus\Entity;
interface EntityObjectInterface { interface EntityObjectInterface {
public function load(...$arguments); public function load(mixed ...$arguments);
public function save(); public function save();
} }

View File

@ -0,0 +1,8 @@
<?php
namespace Ulmus\Entity;
interface EntityValueModifier {
public function push(mixed $value, \Notes\Common\ReflectedProperty $property) : mixed;
public function pull(mixed $value, \Notes\Common\ReflectedProperty $property) : mixed;
}

View File

@ -8,7 +8,7 @@ class Datetime extends \DateTime implements EntityObjectInterface, \JsonSerializ
public string $format = "Y-m-d H:i:s"; public string $format = "Y-m-d H:i:s";
public function load(...$arguments) public function load(mixed ...$arguments)
{ {
$value = $arguments[0]; $value = $arguments[0];

View File

@ -0,0 +1,8 @@
<?php
namespace Ulmus\Entity;
interface JsonUnserializable extends \JsonSerializable
{
public function jsonUnserialize(array $json): void;
}

View File

@ -14,6 +14,11 @@ class ObjectInstanciator {
elseif ( ($obj = new $type() ) instanceof EntityObjectInterface ) { elseif ( ($obj = new $type() ) instanceof EntityObjectInterface ) {
return $obj->load(...$arguments); return $obj->load(...$arguments);
} }
elseif ( ($obj = new $type() ) instanceof JsonUnserializable ) {
$obj->jsonUnserialize(json_decode($arguments[0], true));
return $obj;
}
else { else {
return new $type(...$arguments); return new $type(...$arguments);
} }
@ -24,10 +29,16 @@ class ObjectInstanciator {
if ( $obj instanceof EntityObjectInterface ) { if ( $obj instanceof EntityObjectInterface ) {
return $obj->save(); return $obj->save();
} }
elseif ( $obj instanceof \JsonSerializable ) {
return json_encode($obj->jsonSerialize());
}
elseif ( $obj instanceof \Stringable ) {
return (string) $obj; return (string) $obj;
} }
throw new \InvalidArgumentException("Object %s could not be converted as an acceptable format.");
}
public function enum(\UnitEnum $obj) public function enum(\UnitEnum $obj)
{ {
if (! $obj instanceof \BackedEnum) { if (! $obj instanceof \BackedEnum) {

View File

@ -5,11 +5,11 @@ namespace Ulmus\Entity\Sqlite;
use Notes\Common\ReflectedProperty; use Notes\Common\ReflectedProperty;
use Ulmus\EntityCollection; use Ulmus\EntityCollection;
use Ulmus\{Attribute\Obj\Table}; use Ulmus\{Attribute\Obj\Table, Entity\EntityInterface};
use Ulmus\Attribute\Property\{Field, Filter, FilterJoin, Relation, Join, Virtual, Where}; use Ulmus\Attribute\Property\{Field, Filter, FilterJoin, Relation, Join, Virtual, Where};
#[Table] #[Table]
class Column class Column implements EntityInterface
{ {
use \Ulmus\EntityTrait; use \Ulmus\EntityTrait;

View File

@ -4,11 +4,11 @@ namespace Ulmus\Entity\Sqlite;
use Ulmus\EntityCollection; use Ulmus\EntityCollection;
use Ulmus\Query\{From, Select}; use Ulmus\Query\{From, Select};
use Ulmus\{Attribute\Obj\Table, Repository, Ulmus}; use Ulmus\{Attribute\Obj\Table, Entity\EntityInterface, Repository, Ulmus};
use Ulmus\Attribute\Property\{Field, Filter, FilterJoin, Relation, Join, Virtual, Where}; use Ulmus\Attribute\Property\{Field, Filter, FilterJoin, Relation, Join, Virtual, Where};
#[Table(name: "sqlite_master")] #[Table(name: "sqlite_master")]
class Schema class Schema implements EntityInterface
{ {
use \Ulmus\EntityTrait; use \Ulmus\EntityTrait;

View File

@ -11,11 +11,29 @@ class EntityCollection extends \ArrayObject implements \JsonSerializable {
public static function instance(array $data = [], null|string $entityClass = null) : self public static function instance(array $data = [], null|string $entityClass = null) : self
{ {
$instance = new static($data); $instance = new static($data);
$instance->entityClass = $entityClass;
if ($entityClass) {
$instance->defineEntityClass($entityClass);
}
return $instance; return $instance;
} }
public function defineEntityClass(string $entityClass) : self
{
if ($this->entityClass !== null) {
throw new \LogicException("This methods should only be used to convert instantiated EntityCollection outside of entities.");
}
$this->entityClass = $entityClass;
foreach($this as &$arr) {
$arr = new $entityClass($arr);
}
return $this;
}
public function filters(Callable $callback, bool $yieldValueOnly = false) : Generator public function filters(Callable $callback, bool $yieldValueOnly = false) : Generator
{ {
$idx = 0; $idx = 0;
@ -383,7 +401,7 @@ class EntityCollection extends \ArrayObject implements \JsonSerializable {
$className = $entityClass ?: $this->entityClass; $className = $entityClass ?: $this->entityClass;
return ( new $className() )->fromArray($dataset); return new $className($dataset) ;
} }
public function arrayToEntities(array $list, ? string /*stringable*/ $entityClass = null) : self public function arrayToEntities(array $list, ? string /*stringable*/ $entityClass = null) : self

View File

@ -3,7 +3,9 @@
namespace Ulmus; namespace Ulmus;
use Notes\Attribute\Ignore; use Notes\Attribute\Ignore;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Ulmus\{Attribute\Obj\JsonSerialize, use Ulmus\{Attribute\Obj\JsonSerialize,
Attribute\Property\Join, Attribute\Property\Join,
Attribute\Property\Relation, Attribute\Property\Relation,
@ -14,11 +16,8 @@ use Ulmus\{Attribute\Obj\JsonSerialize,
Entity\DatasetHandler, Entity\DatasetHandler,
Entity\EntityInterface, Entity\EntityInterface,
QueryBuilder\QueryBuilderInterface}; QueryBuilder\QueryBuilderInterface};
use Ulmus\SearchRequest\{Attribute\SearchParameter,
SearchMethodEnum, use Ulmus\{Attribute\RegisterEntityEventInterface, Entity\EntityValueModifier, SearchRequest, Event};
SearchRequestInterface,
SearchRequestFromRequestTrait,
SearchRequestPaginationTrait};
#[JsonSerialize(includeRelations: true)] #[JsonSerialize(includeRelations: true)]
trait EntityTrait { trait EntityTrait {
@ -48,6 +47,9 @@ trait EntityTrait {
#[Ignore] #[Ignore]
public function initializeEntity(iterable|null $dataset = null) : void public function initializeEntity(iterable|null $dataset = null) : void
{ {
# Attributs can attach themselves on events thrown by this trait
$this->entityRegisterAttributes();
$this->datasetHandler = new DatasetHandler(static::resolveEntity(), $this->entityStrictFieldsDeclaration); $this->datasetHandler = new DatasetHandler(static::resolveEntity(), $this->entityStrictFieldsDeclaration);
if ($dataset) { if ($dataset) {
@ -57,18 +59,39 @@ trait EntityTrait {
$this->resetVirtualProperties(); $this->resetVirtualProperties();
} }
#[Ignore]
public function entityRegisterAttributes() : void
{
foreach($this->resolveEntity()->reflectedClass->getProperties(true) as $field => $property) {
foreach($property->attributes as $tag) {
if ( $tag->object instanceof RegisterEntityEventInterface ) {
$tag->object->attach($this, $field);
}
}
}
}
#[Ignore] #[Ignore]
public function entityFillFromDataset(iterable $dataset, bool $overwriteDataset = false) : self public function entityFillFromDataset(iterable $dataset, bool $overwriteDataset = false) : self
{ {
$properties = $this->resolveEntity()->reflectedClass->getProperties(true);
$loaded = $this->isLoaded(); $loaded = $this->isLoaded();
$handler = $this->datasetHandler->push($dataset); $generator = $this->datasetHandler->push($dataset);
foreach($generator as $field => $value) {
foreach($properties[$field]->attributes as $tag) {
if ( $tag->object instanceof EntityValueModifier ) {
$value = $tag->object->push($value, $properties[$field]);
}
}
foreach($handler as $field => $value) {
$this->$field = $value; $this->$field = $value;
} }
$this->entityDatasetUnmatchedFields = $handler->getReturn(); $this->eventExecute(Event\Entity\DatasetFill::class, $this);
$this->entityDatasetUnmatchedFields = $generator->getReturn();
# Keeping original data to diff on UPDATE query # Keeping original data to diff on UPDATE query
if ( ! $loaded ) { if ( ! $loaded ) {
@ -91,7 +114,6 @@ trait EntityTrait {
return iterator_to_array($this->entityYieldDataset($includeRelations, $rewriteValue)); return iterator_to_array($this->entityYieldDataset($includeRelations, $rewriteValue));
} }
#[Ignore] #[Ignore]
protected function entityYieldDataset(bool $includeRelations = false, bool $rewriteValue = true) : \Generator protected function entityYieldDataset(bool $includeRelations = false, bool $rewriteValue = true) : \Generator
{ {
@ -106,13 +128,11 @@ trait EntityTrait {
} }
} }
#[Ignore] #[Ignore]
public function resetVirtualProperties() : self public function resetVirtualProperties() : self
{ {
foreach($this->resolveEntity()->reflectedClass->getProperties(true) as $field => $property) { foreach($this->resolveEntity()->reflectedClass->getProperties(true) as $field => $property) {
foreach($property->attributes as $tag) { foreach($property->attributes as $tag) {
if ( $tag->object instanceof ResettablePropertyInterface ) { if ( $tag->object instanceof ResettablePropertyInterface ) {
unset($this->$field); unset($this->$field);
} }
@ -133,7 +153,7 @@ trait EntityTrait {
} }
#[Ignore] #[Ignore]
public function toArray($includeRelations = false, array $filterFields = null, bool $rewriteValue = true) : array public function toArray($includeRelations = false, array $filterFields = [], bool $rewriteValue = true) : array
{ {
$dataset = $this->entityGetDataset($includeRelations, false, $rewriteValue); $dataset = $this->entityGetDataset($includeRelations, false, $rewriteValue);
@ -193,7 +213,7 @@ trait EntityTrait {
{ {
$rel = static::resolveEntity()->searchFieldAnnotation($name, [ Attribute\Property\Relation::class ]); $rel = static::resolveEntity()->searchFieldAnnotation($name, [ Attribute\Property\Relation::class ]);
if ( $this->isLoaded() && $rel ) { if ( $rel && $this->isLoaded() ) {
return true; return true;
} }
@ -250,7 +270,7 @@ trait EntityTrait {
} }
} }
if (is_object($value) && ($value instanceof JsonSerializable) ) { if (is_object($value) && ($value instanceof \JsonSerializable) ) {
$dataset[$key] = $value->jsonSerialize(); $dataset[$key] = $value->jsonSerialize();
} }
else { else {

View File

@ -0,0 +1,7 @@
<?php
namespace Ulmus\Event\Entity;
interface DatasetFill {
public function execute(\Generator $generator, \Ulmus\Entity\EntityInterface $entity) : void;
}

View File

@ -0,0 +1,7 @@
<?php
namespace Ulmus\Event\Entity;
interface DatasetPull {
public function execute(\Generator $generator, \Ulmus\Entity\EntityInterface $entity) : void;
}

View File

@ -199,7 +199,7 @@ class Repository implements RepositoryInterface
public function save(object|array $entity, ? array $fieldsAndValue = null, bool $replace = false) : bool public function save(object|array $entity, ? array $fieldsAndValue = null, bool $replace = false) : bool
{ {
if ( is_array($entity) ) { if ( is_array($entity) ) {
$entity = ( new $this->entityClass() )->fromArray($entity); $entity = new $this->entityClass($entity);
} }
if ( ! $this->matchEntity($entity) ) { if ( ! $this->matchEntity($entity) ) {
@ -245,6 +245,8 @@ class Repository implements RepositoryInterface
$diff = $fieldsAndValue ?? $this->generateWritableDataset($entity); $diff = $fieldsAndValue ?? $this->generateWritableDataset($entity);
# dump($diff);
if ( [] !== $diff ) { if ( [] !== $diff ) {
if ($primaryKeyDefinition) { if ($primaryKeyDefinition) {
$pkField = key($primaryKeyDefinition); $pkField = key($primaryKeyDefinition);
@ -457,14 +459,12 @@ class Repository implements RepositoryInterface
$this->select($this->entityClass::fields(array_map(fn($f) => $f['object']->name ?? $f['name'], $select))); $this->select($this->entityClass::fields(array_map(fn($f) => $f['object']->name ?? $f['name'], $select)));
} }
# @TODO Apply FILTER annotation to this too ! $fields = (array) $fields;
foreach(array_filter((array) $fields) as $item) { usort($fields, fn($e1, $e2) => str_contains($e1, '.') <=> str_contains($e2, '.'));
if ( isset($this->joined[$item]) ) {
continue; # @TODO Apply fff annotation to this too !
} foreach(array_filter($fields, fn($e) => $e && ! isset($this->joined[$e]) ) as $item) {
else {
$this->joined[$item] = true; $this->joined[$item] = true;
}
$attribute = $this->entityResolver->searchFieldAnnotation($item, [ Join::class ]) ?: $attribute = $this->entityResolver->searchFieldAnnotation($item, [ Join::class ]) ?:
$this->entityResolver->searchFieldAnnotation($item, [ Relation::class ]); $this->entityResolver->searchFieldAnnotation($item, [ Relation::class ]);
@ -682,9 +682,15 @@ class Repository implements RepositoryInterface
{ {
if ($count) { if ($count) {
$searchRequest->applyCount($this->serverRequestCountRepository()); $searchRequest->applyCount($this->serverRequestCountRepository());
}
# @TODO Handle no request to do if count is 0 here
#if ($searchRequest->count) {
$searchRequest->apply($this); $searchRequest->apply($this);
# }
}
else {
$searchRequest->apply($this);
}
return $this; return $this;
} }
@ -772,9 +778,17 @@ class Repository implements RepositoryInterface
return $this->entityClass::entityCollection(...$arguments); return $this->entityClass::entityCollection(...$arguments);
} }
public function instanciateEntity(? string $entityClass = null) : object public function instanciateEntity(? string $entityClass = null, array $dataset = []) : object
{ {
$entity = ( new \ReflectionClass($entityClass ?? $this->entityClass) )->newInstanceWithoutConstructor(); $entityClass ??= $this->entityClass;
try {
$entity = new $entityClass($dataset);
}
catch (\Throwable $ex) {
$entity = ( new \ReflectionClass($entityClass) )->newInstanceWithoutConstructor()->fromArray($dataset);
}
$entity->initializeEntity(); $entity->initializeEntity();
return $entity; return $entity;

View File

@ -200,7 +200,7 @@ class RelationBuilder
} }
} }
protected function instanciateEmptyEntity(string $name, Relation $relation) : object protected function instanciateEmptyEntity(string $name, Relation|Join $relation) : object
{ {
$class = $relation->entity ?? $this->resolver->reflectedClass->getProperties()[$name]->getTypes()[0]->type; $class = $relation->entity ?? $this->resolver->reflectedClass->getProperties()[$name]->getTypes()[0]->type;
@ -208,7 +208,7 @@ class RelationBuilder
} }
protected function instanciateEmptyObject(string $name, Relation $relation) : object protected function instanciateEmptyObject(string $name, Relation|Join $relation) : object
{ {
switch( true ) { switch( true ) {
case $relation->isOneToOne(): case $relation->isOneToOne():
@ -267,7 +267,7 @@ class RelationBuilder
if ($vars) { if ($vars) {
if ( [] !== $data = (array_values(array_unique($vars)) !== [ null ] ? $vars : []) ) { if ( [] !== $data = (array_values(array_unique($vars)) !== [ null ] ? $vars : []) ) {
return ( new $entity() )->fromArray($data)->resetVirtualProperties(); return ( new $entity($data) )->resetVirtualProperties();
} }
else { else {
return $this->fieldIsNullable($name) ? null : new $entity(); return $this->fieldIsNullable($name) ? null : new $entity();

View File

@ -2,6 +2,8 @@
namespace Ulmus\SearchRequest; namespace Ulmus\SearchRequest;
use Ulmus\Repository;
trait SearchRequestPaginationTrait { trait SearchRequestPaginationTrait {
public int $count = 0; public int $count = 0;
@ -72,4 +74,61 @@ trait SearchRequestPaginationTrait {
return $this; return $this;
} }
public function wheresConditions() : iterable
{
return array_filter(($this->wheresConditions ?? []) + [
], fn($i) => ! is_null($i) ) + [ ];
}
public function wheresOperators() : iterable
{
return array_filter(($this->wheresOperators ?? []) + [
], fn($i) => ! is_null($i) ) + [ ];
}
public function likesConditions() : iterable
{
return array_filter(($this->likesConditions ?? []) + [
], fn($i) => ! is_null($i) ) + [ ];
}
public function likesOperators() : iterable
{
return array_filter(($this->likesOperators ?? []) + [
], fn($i) => ! is_null($i) ) + [ ];
}
public function filter(Repository $repository) : Repository
{
return $repository;
}
public function applyCount(Repository\RepositoryInterface $repository): SearchRequestInterface
{
$this->count = $this->filter($repository)
->wheres($this->wheres(), $this->wheresOperators(), $this->wheresConditions())
->likes($this->likes(), $this->likesConditions())
->groups($this->groups())
->count();
return $this;
}
public function apply(Repository\RepositoryInterface $repository): SearchRequestInterface
{
$this->filter($repository)
->wheres($this->wheres(), $this->wheresOperators(), $this->wheresConditions())
->likes($this->likes(), $this->likesConditions())
->orders($this->orders())
->groups($this->groups())
->offset($this->offset())
->limit($this->limit());
return $this;
}
} }