- 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;
}
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
{
return $this->reflectedClass->getProperties(true)[$name]->getTypes()[0]->type ?? false;

View File

@ -84,7 +84,7 @@ abstract class Sql {
return $value;
}
public static function collate(string $name) : string
public static function collate(string $name) : object
{
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));

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 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 isLoaded() : bool;
public function jsonSerialize() : mixed;

View File

@ -3,6 +3,6 @@
namespace Ulmus\Entity;
interface EntityObjectInterface {
public function load(...$arguments);
public function load(mixed ...$arguments);
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 function load(...$arguments)
public function load(mixed ...$arguments)
{
$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 ) {
return $obj->load(...$arguments);
}
elseif ( ($obj = new $type() ) instanceof JsonUnserializable ) {
$obj->jsonUnserialize(json_decode($arguments[0], true));
return $obj;
}
else {
return new $type(...$arguments);
}
@ -24,8 +29,14 @@ class ObjectInstanciator {
if ( $obj instanceof EntityObjectInterface ) {
return $obj->save();
}
return (string) $obj;
elseif ( $obj instanceof \JsonSerializable ) {
return json_encode($obj->jsonSerialize());
}
elseif ( $obj instanceof \Stringable ) {
return (string) $obj;
}
throw new \InvalidArgumentException("Object %s could not be converted as an acceptable format.");
}
public function enum(\UnitEnum $obj)

View File

@ -5,11 +5,11 @@ namespace Ulmus\Entity\Sqlite;
use Notes\Common\ReflectedProperty;
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};
#[Table]
class Column
class Column implements EntityInterface
{
use \Ulmus\EntityTrait;

View File

@ -4,11 +4,11 @@ namespace Ulmus\Entity\Sqlite;
use Ulmus\EntityCollection;
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};
#[Table(name: "sqlite_master")]
class Schema
class Schema implements EntityInterface
{
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
{
$instance = new static($data);
$instance->entityClass = $entityClass;
if ($entityClass) {
$instance->defineEntityClass($entityClass);
}
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
{
$idx = 0;
@ -383,7 +401,7 @@ class EntityCollection extends \ArrayObject implements \JsonSerializable {
$className = $entityClass ?: $this->entityClass;
return ( new $className() )->fromArray($dataset);
return new $className($dataset) ;
}
public function arrayToEntities(array $list, ? string /*stringable*/ $entityClass = null) : self

View File

@ -3,7 +3,9 @@
namespace Ulmus;
use Notes\Attribute\Ignore;
use Psr\Http\Message\ServerRequestInterface;
use Ulmus\{Attribute\Obj\JsonSerialize,
Attribute\Property\Join,
Attribute\Property\Relation,
@ -14,11 +16,8 @@ use Ulmus\{Attribute\Obj\JsonSerialize,
Entity\DatasetHandler,
Entity\EntityInterface,
QueryBuilder\QueryBuilderInterface};
use Ulmus\SearchRequest\{Attribute\SearchParameter,
SearchMethodEnum,
SearchRequestInterface,
SearchRequestFromRequestTrait,
SearchRequestPaginationTrait};
use Ulmus\{Attribute\RegisterEntityEventInterface, Entity\EntityValueModifier, SearchRequest, Event};
#[JsonSerialize(includeRelations: true)]
trait EntityTrait {
@ -48,6 +47,9 @@ trait EntityTrait {
#[Ignore]
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);
if ($dataset) {
@ -57,18 +59,39 @@ trait EntityTrait {
$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]
public function entityFillFromDataset(iterable $dataset, bool $overwriteDataset = false) : self
{
$properties = $this->resolveEntity()->reflectedClass->getProperties(true);
$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->entityDatasetUnmatchedFields = $handler->getReturn();
$this->eventExecute(Event\Entity\DatasetFill::class, $this);
$this->entityDatasetUnmatchedFields = $generator->getReturn();
# Keeping original data to diff on UPDATE query
if ( ! $loaded ) {
@ -77,7 +100,7 @@ trait EntityTrait {
elseif ($overwriteDataset) {
$this->entityLoadedDataset = array_change_key_case(is_array($dataset) ? $dataset : iterator_to_array($dataset), \CASE_LOWER) + $this->entityLoadedDataset;
}
return $this;
}
@ -91,7 +114,6 @@ trait EntityTrait {
return iterator_to_array($this->entityYieldDataset($includeRelations, $rewriteValue));
}
#[Ignore]
protected function entityYieldDataset(bool $includeRelations = false, bool $rewriteValue = true) : \Generator
{
@ -106,13 +128,11 @@ trait EntityTrait {
}
}
#[Ignore]
public function resetVirtualProperties() : self
{
foreach($this->resolveEntity()->reflectedClass->getProperties(true) as $field => $property) {
foreach($property->attributes as $tag) {
if ( $tag->object instanceof ResettablePropertyInterface ) {
unset($this->$field);
}
@ -133,7 +153,7 @@ trait EntityTrait {
}
#[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);
@ -193,7 +213,7 @@ trait EntityTrait {
{
$rel = static::resolveEntity()->searchFieldAnnotation($name, [ Attribute\Property\Relation::class ]);
if ( $this->isLoaded() && $rel ) {
if ( $rel && $this->isLoaded() ) {
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();
}
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
{
if ( is_array($entity) ) {
$entity = ( new $this->entityClass() )->fromArray($entity);
$entity = new $this->entityClass($entity);
}
if ( ! $this->matchEntity($entity) ) {
@ -245,6 +245,8 @@ class Repository implements RepositoryInterface
$diff = $fieldsAndValue ?? $this->generateWritableDataset($entity);
# dump($diff);
if ( [] !== $diff ) {
if ($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)));
}
# @TODO Apply FILTER annotation to this too !
foreach(array_filter((array) $fields) as $item) {
if ( isset($this->joined[$item]) ) {
continue;
}
else {
$this->joined[$item] = true;
}
$fields = (array) $fields;
usort($fields, fn($e1, $e2) => str_contains($e1, '.') <=> str_contains($e2, '.'));
# @TODO Apply fff annotation to this too !
foreach(array_filter($fields, fn($e) => $e && ! isset($this->joined[$e]) ) as $item) {
$this->joined[$item] = true;
$attribute = $this->entityResolver->searchFieldAnnotation($item, [ Join::class ]) ?:
$this->entityResolver->searchFieldAnnotation($item, [ Relation::class ]);
@ -682,9 +682,15 @@ class Repository implements RepositoryInterface
{
if ($count) {
$searchRequest->applyCount($this->serverRequestCountRepository());
}
$searchRequest->apply($this);
# @TODO Handle no request to do if count is 0 here
#if ($searchRequest->count) {
$searchRequest->apply($this);
# }
}
else {
$searchRequest->apply($this);
}
return $this;
}
@ -772,9 +778,17 @@ class Repository implements RepositoryInterface
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();
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;
@ -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 ) {
case $relation->isOneToOne():
@ -267,7 +267,7 @@ class RelationBuilder
if ($vars) {
if ( [] !== $data = (array_values(array_unique($vars)) !== [ null ] ? $vars : []) ) {
return ( new $entity() )->fromArray($data)->resetVirtualProperties();
return ( new $entity($data) )->resetVirtualProperties();
}
else {
return $this->fieldIsNullable($name) ? null : new $entity();

View File

@ -2,6 +2,8 @@
namespace Ulmus\SearchRequest;
use Ulmus\Repository;
trait SearchRequestPaginationTrait {
public int $count = 0;
@ -72,4 +74,61 @@ trait SearchRequestPaginationTrait {
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;
}
}