- Merged getValue override

This commit is contained in:
Dave Mc Nicoll 2026-02-25 20:09:28 +00:00
parent b0b3aca6d9
commit fd5d31fa17
11 changed files with 183 additions and 106 deletions

View File

@ -0,0 +1,10 @@
<?php
namespace Ulmus\Attribute\Obj;
#[\Attribute(\Attribute::TARGET_CLASS)]
class JsonSerialize {
public function __construct(
public bool $includeRelations = true,
) {}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Ulmus\Attribute\Property;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class JsonSerialize {
public function __construct(
public bool $ignoreField = false,
) {}
}

View File

@ -5,6 +5,6 @@ namespace Ulmus\Attribute\Property;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class WithJoin {
public function __construct(
public array $joins = []
public string|array $joins = []
) {}
}

View File

@ -43,6 +43,7 @@ class EntityField implements WhereRawParameter
case 'string':
case 'float':
case 'double':
case 'mixed':
return true;
}

View File

@ -4,7 +4,7 @@ namespace Ulmus\Entity\Field;
use Ulmus\Entity\EntityObjectInterface;
class Datetime extends \DateTime implements EntityObjectInterface {
class Datetime extends \DateTime implements EntityObjectInterface, \JsonSerializable {
public string $format = "Y-m-d H:i:s";
@ -52,7 +52,7 @@ class Datetime extends \DateTime implements EntityObjectInterface {
public function formatLocaleIntl(int $dateFormatter = \IntlDateFormatter::LONG, int $timeFormatter = \IntlDateFormatter::NONE) : string
{
$formatter = new \IntlDateFormatter(\Locale::getDefault(), $dateFormatter, $timeFormatter);
$formatter = new \IntlDateFormatter(\Locale::getDefault(), $dateFormatter, $timeFormatter, \Locale::getRegion(""));
return $formatter->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;
}
}

View File

@ -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]

View File

@ -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)));

View File

@ -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)));
}
}
}

View File

@ -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;

View File

@ -0,0 +1,9 @@
<?php
namespace Ulmus\SearchRequest\Attribute;
enum OrderByEnum : string
{
case Ascending = "ASC";
case Descending = "DESC";
}

View File

@ -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) {