From fd5d31fa177a052b652e588f36ccf83369724a44 Mon Sep 17 00:00:00 2001 From: Dave Mc Nicoll Date: Wed, 25 Feb 2026 20:09:28 +0000 Subject: [PATCH] - Merged getValue override --- src/Attribute/Obj/JsonSerialize.php | 10 ++ src/Attribute/Property/JsonSerialize.php | 10 ++ src/Attribute/Property/WithJoin.php | 2 +- src/Common/EntityField.php | 1 + src/Entity/Field/Datetime.php | 8 +- src/EntityTrait.php | 53 +++++- src/Query/Fragment.php | 2 +- src/Query/OrderBy.php | 12 +- src/Repository.php | 170 ++++++++---------- src/SearchRequest/Attribute/OrderByEnum.php | 9 + .../SearchRequestFromRequestTrait.php | 12 +- 11 files changed, 183 insertions(+), 106 deletions(-) create mode 100644 src/Attribute/Obj/JsonSerialize.php create mode 100644 src/Attribute/Property/JsonSerialize.php create mode 100644 src/SearchRequest/Attribute/OrderByEnum.php diff --git a/src/Attribute/Obj/JsonSerialize.php b/src/Attribute/Obj/JsonSerialize.php new file mode 100644 index 0000000..015153c --- /dev/null +++ b/src/Attribute/Obj/JsonSerialize.php @@ -0,0 +1,10 @@ +format($this->getTimestamp()); } @@ -62,4 +62,8 @@ class Datetime extends \DateTime implements EntityObjectInterface { return (int) ( (int) ($dateTime ?: new DateTime())->format('Ymd') - (int) $this->format('Ymd') ) / 10000; } + public function jsonSerialize(): mixed + { + return $this; + } } diff --git a/src/EntityTrait.php b/src/EntityTrait.php index db9640e..3586891 100644 --- a/src/EntityTrait.php +++ b/src/EntityTrait.php @@ -4,7 +4,8 @@ namespace Ulmus; use Notes\Attribute\Ignore; use Psr\Http\Message\ServerRequestInterface; -use Ulmus\{Attribute\Property\Join, +use Ulmus\{Attribute\Obj\JsonSerialize, + Attribute\Property\Join, Attribute\Property\Relation, Attribute\Property\ResettablePropertyInterface, Attribute\Property\Virtual, @@ -19,6 +20,7 @@ use Ulmus\SearchRequest\{Attribute\SearchParameter, SearchRequestFromRequestTrait, SearchRequestPaginationTrait}; +#[JsonSerialize(includeRelations: true)] trait EntityTrait { use EventTrait; @@ -86,21 +88,25 @@ trait EntityTrait { return $this->entityLoadedDataset; } - $dataset = []; + return iterator_to_array($this->entityYieldDataset($includeRelations, $rewriteValue)); + } + + #[Ignore] + protected function entityYieldDataset(bool $includeRelations = false, bool $rewriteValue = true) : \Generator + { foreach($this->datasetHandler->pull($this) as $field => $value) { - $dataset[$field] = $rewriteValue ? static::repository()->adapter->adapter()->writableValue($value) : $value; + yield $field => $rewriteValue ? static::repository()->adapter->adapter()->writableValue($value) : $value; } if ($includeRelations) { foreach($this->datasetHandler->pullRelation($this) as $field => $object) { - $dataset[$field] = $object; + yield $field => $object; } } - - return $dataset; } + #[Ignore] public function resetVirtualProperties() : self { @@ -219,7 +225,40 @@ trait EntityTrait { #[Ignore] public function jsonSerialize() : mixed { - return $this->entityGetDataset(true, false, false); + # Allows overridding of this method + return $this->_jsonSerialize(); + } + + #[Ignore] + public function _jsonSerialize() : mixed + { + $resolver = static::resolveEntity(); + $objectAttribute = $resolver->getAttributeImplementing(JsonSerialize::class); + + $dataset = []; + + foreach($this->entityYieldDataset($objectAttribute->includeRelations ?? true, false, false) as $key => $value) { + $field = $resolver->searchField($key, EntityResolver::KEY_COLUMN_NAME); + + if ($field) { + $jsonSerialize = $field->getAttribute(\Ulmus\Attribute\Property\JsonSerialize::class); + if ($jsonSerialize) { + # Should we ignore the field ? + if ($jsonSerialize->object->ignoreField) { + continue; + } + } + } + + if (is_object($value) && ($value instanceof JsonSerializable) ) { + $dataset[$key] = $value->jsonSerialize(); + } + else { + $dataset[$key] = $value; + } + } + + return $dataset; } #[Ignore] diff --git a/src/Query/Fragment.php b/src/Query/Fragment.php index 6ed2c36..466e93c 100644 --- a/src/Query/Fragment.php +++ b/src/Query/Fragment.php @@ -34,7 +34,7 @@ abstract class Fragment implements QueryFragmentInterface{ return implode(", ", $list); } - protected function validateFieldType($field) : void + protected function validateFieldType(mixed $field) : void { if ( is_numeric($field) ) { throw new \Ulmus\Exception\InvalidFieldType(sprintf("Validation of field `%s` failed from query `%s`", $field, get_class($this))); diff --git a/src/Query/OrderBy.php b/src/Query/OrderBy.php index 4315f9c..8e83061 100644 --- a/src/Query/OrderBy.php +++ b/src/Query/OrderBy.php @@ -10,6 +10,8 @@ class OrderBy extends Fragment { const SQL_TOKEN = "ORDER BY"; const SQL_COLLATE = "COLLATE"; + # const 'NULLS LAST|FIRST' must be handlded + public function set(array $order) : self { $this->orderBy = $order; @@ -17,9 +19,9 @@ class OrderBy extends Fragment { return $this; } - public function add(string|\Stringable $field, ? string $direction = null) : self + public function add(string|\Stringable $field, string|\Stringable|null $direction = null) : self { - $this->validateFieldType($field); + $this->validateDirectionType($direction); $this->orderBy[] = [ $field, $direction ]; @@ -39,4 +41,10 @@ class OrderBy extends Fragment { ]); } + protected function validateDirectionType(mixed $direction) { + # Could be coming from user-input, we must whitelist + if (is_string($direction) && ! in_array(strtoupper($direction), [ 'ASC', 'DESC' ])) { + throw new \Ulmus\Exception\InvalidFieldType(sprintf("Validation of string `%s` failed from query `%s`", $direction, get_class($this))); + } + } } diff --git a/src/Repository.php b/src/Repository.php index e0d1064..9d8557b 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -6,7 +6,6 @@ use Ulmus\Attribute\Property\{ Field, OrderBy, Where, Having, Relation, Filter, Join, FilterJoin, WithJoin }; use Psr\SimpleCache\CacheInterface; -use Ulmus\Adapter\AdapterInterface; use Ulmus\Common\EntityResolver; use Ulmus\Entity\EntityInterface; use Ulmus\Repository\RepositoryInterface; @@ -26,7 +25,7 @@ class Repository implements RepositoryInterface public ? ConnectionAdapter $adapters; - public QueryBuilder\QueryBuilderInterface $queryBuilder; + public readonly QueryBuilder\QueryBuilderInterface $queryBuilder; protected EntityResolver $entityResolver; @@ -37,11 +36,6 @@ class Repository implements RepositoryInterface $this->alias = $alias; $this->entityResolver = Ulmus::resolveEntity($entity); - $this->setAdapter($adapter); - } - - public function setAdapter(? ConnectionAdapter $adapter = null) : void { - if ($adapter) { $this->adapter = $adapter; @@ -132,6 +126,8 @@ class Repository implements RepositoryInterface public function deleteAll() { + $this->eventExecute(Event\Query\Delete::class, $this); + return $this->deleteSqlQuery()->runDeleteQuery(); } @@ -144,6 +140,15 @@ class Repository implements RepositoryInterface return (bool) $this->wherePrimaryKey($value, false)->deleteOne()->rowCount; } + public function deleteFromCompoundKeys(array $values) : bool + { + foreach($values as $field => $value) { + $this->where($field, $value); + } + + return (bool) $this->deleteOne()->rowCount; + } + public function destroy(object $entity) : bool { if ( ! $this->matchEntity($entity) ) { @@ -152,16 +157,25 @@ class Repository implements RepositoryInterface $primaryKeyDefinition = Ulmus::resolveEntity($this->entityClass)->getPrimaryKeyField(); - if ( $primaryKeyDefinition === null ) { - throw new \Exception(sprintf("No primary key found for entity %s", $this->entityClass)); - } - else { + if ( $primaryKeyDefinition !== null ) { $pkField = key($primaryKeyDefinition); $this->eventExecute(Event\Query\Delete::class, $this, $entity); return $this->deleteFromPk($entity->$pkField); } + else { + $compoundKeyFields = Ulmus::resolveEntity($this->entityClass)->getCompoundKeyFields(); + + if ($compoundKeyFields) { + $this->eventExecute(Event\Query\Delete::class, $this, $entity); + + return $this->deleteFromCompoundKeys(array_combine($compoundKeyFields->column, array_map(fn($column) => $entity->$column, $compoundKeyFields->column))); + } + else { + throw new \Exception(sprintf("No primary key found for entity %s", $this->entityClass)); + } + } } public function destroyAll(EntityCollection $collection) : void @@ -224,20 +238,24 @@ class Repository implements RepositoryInterface } else { if ( $primaryKeyDefinition === null ) { - if ( null !== $compoundKeyFields = Ulmus::resolveEntity($this->entityClass)->getCompoundKeyFields() ) { - throw new \Exception("TO DO!"); - } - else { - throw new \Exception(sprintf("No primary key found for entity %s", $this->entityClass)); + if ( null === $compoundKeyFields = Ulmus::resolveEntity($this->entityClass)->getCompoundKeyFields() ) { + throw new \Exception(sprintf("No primary key or compound key found for entity %s", $this->entityClass)); } } $diff = $fieldsAndValue ?? $this->generateWritableDataset($entity); if ( [] !== $diff ) { - $pkField = key($primaryKeyDefinition); - $pkFieldName = $primaryKeyDefinition[$pkField]->name ?? $pkField; - $this->where($pkFieldName, $dataset[$pkFieldName]); + if ($primaryKeyDefinition) { + $pkField = key($primaryKeyDefinition); + $pkFieldName = $primaryKeyDefinition[$pkField]->name ?? $pkField; + $this->where($pkFieldName, $dataset[$pkFieldName]); + } + else { + foreach($compoundKeyFields->column as $field) { + $this->where($field, $dataset[$field]); + } + } $update = $this->updateSqlQuery($diff)->runUpdateQuery(); @@ -336,6 +354,14 @@ class Repository implements RepositoryInterface return $e1 !== $e2; } + if ($e1 instanceof \BackedEnum) { + $e1 = $e1->value; + } + + if ($e2 instanceof \BackedEnum) { + $e2 = $e2->value; + } + return (string) $e1 !== (string) $e2; }); } @@ -424,8 +450,7 @@ class Repository implements RepositoryInterface public function withJoin(string|array $fields, array $options = []) : self { - $selectObj = $this->queryBuilder->getFragment(Query\Select::class); - $canSelect = empty($selectObj) || $selectObj->isInternalSelect === true; + $canSelect = null === $this->queryBuilder->getFragment(Query\Select::class); if ( $canSelect ) { $select = $this->entityResolver->fieldList(EntityResolver::KEY_COLUMN_NAME, true); @@ -433,8 +458,13 @@ class Repository implements RepositoryInterface } # @TODO Apply FILTER annotation to this too ! - foreach(array_filter((array) $fields, fn($e) => $e && ! isset($this->joined[$e]) ) as $item) { - $this->joined[$item] = true; + foreach(array_filter((array) $fields) as $item) { + if ( isset($this->joined[$item]) ) { + continue; + } + else { + $this->joined[$item] = true; + } $attribute = $this->entityResolver->searchFieldAnnotation($item, [ Join::class ]) ?: $this->entityResolver->searchFieldAnnotation($item, [ Relation::class ]); @@ -446,19 +476,19 @@ class Repository implements RepositoryInterface } if ( $attribute ) { - $attribute->alias ??= $item; + $alias = $attribute->alias ?? $item; - $attribute->entity ??= $this->entityResolver->getPropertyEntityType($item); + $entity = $attribute->entity ?? $this->entityResolver->reflectedClass->getProperties(true)[$item]->getTypes()[0]->type; - foreach($attribute->entity::resolveEntity()->fieldList(Common\EntityResolver::KEY_COLUMN_NAME, true) as $key => $field) { - if ( null === $attribute->entity::resolveEntity()->searchFieldAnnotation($field->name, [ Relation\Ignore::class ]) ) { - $escAlias = $this->escapeIdentifier($attribute->alias); + foreach($entity::resolveEntity()->fieldList(Common\EntityResolver::KEY_COLUMN_NAME, true) as $key => $field) { + if ( null === $entity::resolveEntity()->searchFieldAnnotation($field->name, [ Relation\Ignore::class ]) ) { + $escAlias = $this->escapeIdentifier($alias); $fieldName = $this->escapeIdentifier($key); - $name = $attribute->entity::resolveEntity()->searchFieldAnnotation($field->name, [ Field::class ])->name ?? $field->name; + $name = $entity::resolveEntity()->searchFieldAnnotation($field->name, [ Field::class ])->name ?? $field->name; if ($canSelect) { - $this->select("$escAlias.$fieldName as {$attribute->alias}\${$name}"); + $this->select("$escAlias.$fieldName as $alias\${$name}"); } } } @@ -467,15 +497,15 @@ class Repository implements RepositoryInterface if ( ! in_array(WithOptionEnum::SkipWhere, $options)) { foreach($this->entityResolver->searchFieldAnnotationList($item, [ Where::class ] ) as $condition) { - if ( is_object($condition->field) && ( $condition->field->entityClass !== $attribute->entity ) ) { - $this->where(is_object($condition->field) ? $condition->field : $attribute->entity::field($condition->field), $condition->getValue(), $condition->operator); + if ( is_object($condition->field) && ( $condition->field->entityClass !== $entity ) ) { + $this->where(is_object($condition->field) ? $condition->field : $entity::field($condition->field), $condition->getValue(), $condition->operator); } } } if ( ! in_array(WithOptionEnum::SkipHaving, $options)) { foreach ($this->entityResolver->searchFieldAnnotationList($item, [ Having::class ]) as $condition) { - $this->having(is_object($condition->field) ? $condition->field : $attribute->entity::field($condition->field), $condition->getValue(), $condition->operator); + $this->having(is_object($condition->field) ? $condition->field : $entity::field($condition->field), $condition->getValue(), $condition->operator); } } @@ -488,9 +518,10 @@ class Repository implements RepositoryInterface $this->close(); $key = is_string($attribute->key) ? $this->entityClass::field($attribute->key) : $attribute->key; - $foreignKey = $this->evalClosure($attribute->foreignKey, $attribute->entity, $attribute->alias); - $this->join($isRelation ? "LEFT" : $attribute->type, $attribute->entity::resolveEntity()->tableName(), $key, $attribute->foreignKey($this), $attribute->alias, function($join) use ($item, $attribute, $options) { + $foreignKey = is_string($attribute->foreignKey) ? $entity::field($attribute->foreignKey, $alias) : $attribute->foreignKey; + + $this->join("LEFT", $entity::resolveEntity()->tableName(), $key, $foreignKey, $alias, function($join) use ($item, $entity, $alias, $options) { if ( ! in_array(WithOptionEnum::SkipJoinWhere, $options)) { foreach($this->entityResolver->searchFieldAnnotationList($item, [ Where::class ]) as $condition) { if ( ! is_object($condition->field) ) { @@ -501,10 +532,10 @@ class Repository implements RepositoryInterface } # Adding directly - if ( $field->entityClass === $attribute->entity ) { - $field->alias = $attribute->alias; + if ( $field->entityClass === $entity ) { + $field->alias = $alias; - $join->where(is_object($field) ? $field : $attribute->entity::field($field, $attribute->alias), $condition->getValue(), $condition->operator); + $join->where(is_object($field) ? $field : $entity::field($field, $alias), $condition->getValue(), $condition->operator); } } } @@ -521,12 +552,6 @@ class Repository implements RepositoryInterface } } - if ($canSelect) { - if ( $selectObj ??= $this->queryBuilder->getFragment(Query\Select::class) ) { - $selectObj->isInternalSelect = true; - } - } - return $this; } @@ -544,7 +569,7 @@ class Repository implements RepositoryInterface # Apply FILTER annotation to this too ! foreach(array_filter((array) $fields) as $item) { if ( $relation = $this->entityResolver->searchFieldAnnotation($item, [ Relation::class ]) ) { - $relation->alias ??= $item; + $alias = $relation->alias ?? $item; if ( $relation->isManyToMany() ) { $entity = $relation->bridge; @@ -553,14 +578,13 @@ class Repository implements RepositoryInterface extract(Repository\RelationBuilder::relationAnnotations($item, $relation)); - $repository->join(Query\Join::TYPE_INNER, $bridgeEntity->tableName(), $relation->bridge::field($relationRelation->key, $relation->bridgeField), $relationRelation->entity::field($relationRelation->foreignKey, $relation->alias), $relation->bridgeField) + $repository->join(Query\Join::TYPE_INNER, $bridgeEntity->tableName(), $relation->bridge::field($relationRelation->key, $relation->bridgeField), $relationRelation->entity::field($relationRelation->foreignKey, $alias), $relation->bridgeField) ->where( $entity::field($bridgeRelation->key, $relation->bridgeField), $entity::field($bridgeRelation->foreignKey, $this->alias)) - ->selectJsonEntity($relationRelation->entity, $relation->alias)->open(); + ->selectJsonEntity($relationRelation->entity, $alias)->open(); } else { - $relation->entity ??= $this->entityResolver->getPropertyEntityType($name); - $entity = $relation->entity; - $repository = $relation->entity::repository()->selectJsonEntity($entity, $relation->alias)->open(); + $entity = $relation->entity ?? $this->entityResolver->properties[$item]['type']; + $repository = $entity::repository()->selectJsonEntity($entity, $alias)->open(); } # $relation->isManyToMany() and $repository->selectJsonEntity($relation->bridge, $relation->bridgeField, true); @@ -569,21 +593,16 @@ class Repository implements RepositoryInterface $repository->where(is_object($condition->field) ? $condition->field : $entity::field($condition->field), $condition->getValue(), $condition->operator); } - foreach($this->entityResolver->searchFieldAnnotationList($item, [ Having::class ] ) as $condition) { $repository->having(is_object($condition->field) ? $condition->field : $entity::field($condition->field), $condition->getValue(), $condition->operator); } - foreach ($this->entityResolver->searchFieldAnnotationList($item, [ Filter::class ]) as $filter) { - call_user_func_array([$this->entityClass, $filter->method], [$this, $item, true]); - } - $repository->close(); $key = is_string($relation->key) ? $this->entityClass::field($relation->key) : $relation->key; if (! $relation->isManyToMany() ) { - $foreignKey = is_string($relation->foreignKey) ? $entity::field($relation->foreignKey, $relation->alias) : $relation->foreignKey; + $foreignKey = is_string($relation->foreignKey) ? $entity::field($relation->foreignKey, $alias) : $relation->foreignKey; $repository->where( $foreignKey, $key); } @@ -604,9 +623,8 @@ class Repository implements RepositoryInterface if (null !== ($relation = $this->entityResolver->searchFieldAnnotation($name, [ Relation::class ] ))) { $order = $this->entityResolver->searchFieldAnnotationList($name, [ OrderBy::class ]); $where = $this->entityResolver->searchFieldAnnotationList($name, [ Where::class ]); - $filters = $this->entityResolver->searchFieldAnnotationList($name, [ Filter::class ]); - $baseEntity = $relation->entity ?? $relation->bridge ?? $this->entityResolver->getPropertyEntityType($name); + $baseEntity = $relation->entity ?? $relation->bridge ?? $this->entityResolver->properties[$name]['type']; $baseEntityResolver = $baseEntity::resolveEntity(); $property = ($baseEntityResolver->field($relation->foreignKey, 01, false) ?: $baseEntityResolver->field($relation->foreignKey, 02))['name']; @@ -625,10 +643,6 @@ class Repository implements RepositoryInterface $repository->where($condition->field, $condition->getValue(/* why repository sent here ??? $this */), $condition->operator, $condition->condition); } - foreach ($filters as $filter) { - call_user_func_array([ $this->entityClass, $filter->method ], [ $repository, $name, true ]); - } - foreach ($order as $item) { $repository->orderBy($item->field, $item->order); } @@ -647,8 +661,7 @@ class Repository implements RepositoryInterface $repository->where($key, $values); - $loadMethod = $relation->isOneToOne() ? 'loadAll' : $relation->function(); - $results = $repository->{$loadMethod}(); + $results = $repository->loadAll(); if ($relation->isOneToOne()) { foreach ($collection as $item) { @@ -658,30 +671,13 @@ class Repository implements RepositoryInterface elseif ($relation->isOneToMany()) { foreach ($collection as $item) { $item->$name = $baseEntity::entityCollection(); - $search = $results->searchAll($item->$entityProperty, $property); - $item->$name->mergeWith($search); + $item->$name->mergeWith($results->searchAll($item->$entityProperty, $property)); } } } } } - ## AWAITING THE Closures in constant expression from PHP 8.5 ! - protected function evalClosure(mixed $content, string $entityClass, mixed $alias = self::DEFAULT_ALIAS) : mixed - { - if (is_string($content)) { - if ( str_starts_with($content, 'fn(') ) { - $closure = eval("return $content;"); - - return $closure($this); - } - - return $entityClass::field($content, $alias); - } - - return $content; - } - public function filterServerRequest(SearchRequest\SearchRequestInterface $searchRequest, bool $count = true) : self { if ($count) { @@ -778,15 +774,7 @@ class Repository implements RepositoryInterface public function instanciateEntity(? string $entityClass = null) : object { - $entityClass ??= $this->entityClass; - - try { - $entity = new $entityClass(); - } - catch (\Throwable $ex) { - $entity = ( new \ReflectionClass($entityClass) )->newInstanceWithoutConstructor(); - } - + $entity = ( new \ReflectionClass($entityClass ?? $this->entityClass) )->newInstanceWithoutConstructor(); $entity->initializeEntity(); return $entity; diff --git a/src/SearchRequest/Attribute/OrderByEnum.php b/src/SearchRequest/Attribute/OrderByEnum.php new file mode 100644 index 0000000..a2c413c --- /dev/null +++ b/src/SearchRequest/Attribute/OrderByEnum.php @@ -0,0 +1,9 @@ +objectReflection = ObjectReflection::fromClass(static::class)->reflectClass(); - + if (method_exists($this, 'prepare')) { $this->prepare($request); } @@ -83,7 +83,7 @@ trait SearchRequestFromRequestTrait $this->page = $queryParams->offsetExists('page') ? $queryParams['page'] : 1; $this->applyValues( - fn($propertyName, $attribute) => $this->getValueFromSource($request, $propertyName, $attribute) + fn($propertyName, $attribute) =>$this->getValueFromSource($request, $propertyName, $attribute) ); $operators = $request->getAttribute(SearchRequestInterface::SEARCH_REQUEST_OPERATORS); @@ -227,6 +227,14 @@ trait SearchRequestFromRequestTrait protected function getValueFromSource(RequestInterface $request, string $propertyName, SearchParameter $attribute) : mixed { + $classAttributes = $this->objectReflection->getAttributes(SearchRequestParameter::class); + $searchRequestAttribute = $classAttributes[0]->object; + $className = $searchRequestAttribute->class; + + if ( $override = $request->getAttribute(sprintf("searchRequest.%s:%s", $className, $propertyName)) ) { + return $override; + } + $queryParamName = $attribute->getParameters() ?: [ $propertyName ]; foreach($attribute->getSources() as $source) {