diff --git a/CHANGELOG b/CHANGELOG index 55f9b9fc..426422de 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,17 @@ v3.0.0 * Change: Minimum version of doctrine/dbal is now 4.0 * Add: `Bdf\Prime\Connection\Middleware\DebugStack\DebugStackMiddleware` in replacement of doctrine's `DebugStack` for query logging and debugging * Change: `Bdf\Prime\Prime::configure()` now can take the `middlewares` option to defined middlewares on all connections. +* Change: `Bdf\Prime\Query\QueryRepositoryExtension::by()` now add the requested columns to the select clause if not already present. +* Add: `Bdf\Prime\Query\Contract\Projectionable::addProjection()` to add columns to the select clause, only if not already present. +* Change: Records now supports `by()` method on query to index the result by the given column. +* Add: `Bdf\Prime\Collection\Indexer\RecordIndexer` implementation of `EntityIndexerInterface` for indexing records, using a closure to extract the properties which are not publicly accessible. +* Add: `transformer` parameter on `Bdf\Prime\Record\Field` and `Bdf\Prime\Record\LoadRelation` to apply a transformation on the value when hydrating the record. +* Add: `as` parameter on `Bdf\Prime\Record\LoadRelation` to load the relation as a record instead of the entity. +* Change: `Bdf\Prime\Record\LoadRelation::$relation` is now optional : if not provided, the type of the parameter will be used as relation name. +* Change: on `Bdf\Prime\Record\LoadRelation`, if `as` and `transformer` are not provided, and the parameter type differs from the relation entity class, the parameter type will be used as read record. +* Add: `Bdf\Prime\Relations\RelationInterface::loadRecordByForeignKeys()` to load relation entities as records, without owner entity. +* Fix: `morphMany` relation : the discriminator was not applied when loading a single foreign key, because of the `KeyValueQuery` optimisation. +* Add: `Bdf\Prime\Record\Embedded` to handle embedded values on records BC Breaks: * Remove: `Bdf\Prime\Visitor` package, with mapper and graphviz visitors without replacements @@ -26,6 +37,9 @@ BC Breaks: * `Bdf\Prime\Platform\PlatformInterface::apply()` * Change: use `LockMode` enum instead of int constant on `Bdf\Prime\Query\Contract\Lockable` methods * Change: `Bdf\Prime\Connection\ShardingConnection::getDatabase()` now return the database name of the default connection instead of empty string +* Add: `Bdf\Prime\Query\Contract\Projectionable::addProjection()` method +* Add: `Bdf\Prime\Relations\RelationInterface::loadRecordByForeignKeys()` method to load records instead of entities +* Change: `Bdf\Prime\Record\RecordHydratorInterface::finalize()` now takes raw database rows as last parameter Removal of deprecated APIs: * Remove: `Bdf\Prime\Cache\DoctrineCacheAdapter`. Use `Bdf\Prime\Cache\SimpleCacheAdapter` or `Bdf\Prime\Cache\CachePoolAdapter` instead. diff --git a/src/Collection/Indexer/RecordIndexer.php b/src/Collection/Indexer/RecordIndexer.php new file mode 100644 index 00000000..375c8e00 --- /dev/null +++ b/src/Collection/Indexer/RecordIndexer.php @@ -0,0 +1,113 @@ + + */ +final class RecordIndexer implements EntityIndexerInterface +{ + /** + * All indexed entities + * + * @var E[] + */ + private array $entities = []; + + /** + * Map of indexes + * Indexes are indexed by the key name, and store entities in mode "group by combine" + * + * @var E[][][] + */ + private array $indexed = []; + + + /** + * @param list $indexes List of initial indexes keys to use. Entities will be indexed with theses keys when pushed + */ + public function __construct( + /** + * Extract a property from the record, if the property is not publicly available. + * Takes as first parameter the record instance, and as second the property name. + * + * @var Closure(E, string):mixed + */ + private readonly Closure $extractor, + array $indexes = [] + ) { + $this->indexed = array_fill_keys($indexes, []); + } + + /** + * Push the entity to the indexer + * Active indexes will be updated + * + * @param E $entity Entity to add + * + * @return void + */ + public function push($entity): void + { + $this->entities[] = $entity; + + foreach ($this->indexed as $key => &$indexed) { + $property = $entity->$key ?? ($this->extractor)($entity, $key); + $indexed[$property][] = $entity; + } + } + + /** + * {@inheritdoc} + */ + public function by(string $key): array + { + if (isset($this->indexed[$key])) { + return $this->indexed[$key]; + } + + $indexed = []; + + foreach ($this->entities as $entity) { + $property = $entity->$key ?? ($this->extractor)($entity, $key); + $indexed[$property][] = $entity; + } + + return $this->indexed[$key] = $indexed; + } + + /** + * {@inheritdoc} + */ + public function byOverride(string $key): array + { + $result = []; + + foreach ($this->by($key) as $key => $value) { + $result[$key] = end($value); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function all(): array + { + return $this->entities; + } + + /** + * {@inheritdoc} + */ + public function empty(): bool + { + return empty($this->entities); + } +} diff --git a/src/Query/AbstractReadCommand.php b/src/Query/AbstractReadCommand.php index a730a1cb..639e5583 100644 --- a/src/Query/AbstractReadCommand.php +++ b/src/Query/AbstractReadCommand.php @@ -223,7 +223,7 @@ public function postProcessResult(ResultSetInterface $data): iterable $hydrated[] = $recordManager->instantiate($recordClassName, $row, $platform); } - $hydrated = $recordManager->finalize($recordClassName, $hydrated); + $hydrated = $recordManager->finalize($recordClassName, $hydrated, $proceed); } else { $hydrated = $proceed; } diff --git a/src/Query/Contract/Projectionable.php b/src/Query/Contract/Projectionable.php index 171fc05d..bdcd40c8 100644 --- a/src/Query/Contract/Projectionable.php +++ b/src/Query/Contract/Projectionable.php @@ -21,6 +21,28 @@ interface Projectionable */ public function project($columns = null); + /** + * Adds an item that is to be returned in the query result, if not yet present. + * To define an alias, an associative array must be used, with the alias as key, and expression as value. + * + * Note: To ensure that expressions string will not be parsed, use expression objects, or wrap with `new Raw('...')` + * + * + * $query + * ->project('u.id') + * ->addProjection('p.id') + * ->from('users', 'u'); + * + * + * @param ColumnType|ColumnType[] $columns The selection expression. + * + * @return $this This Query instance. + * + * @see Projectionable::select() for exemples + * @see Projectionable::addSelect() Same as this method, but do not check if the projection is already present. + */ + public function addProjection($columns); + /** * Specifies an item that is to be returned in the query result. * Replaces any previously specified selections, if any. @@ -72,7 +94,9 @@ public function select($columns = null); * @param ColumnType|ColumnType[]|null $columns The selection expression. * * @return $this This Query instance. + * * @see Projectionable::select() for exemples + * @see Projectionable::addProjection() Same as this method, but check if the projection is already present. */ public function addSelect($columns); } diff --git a/src/Query/Extension/ProjectionableTrait.php b/src/Query/Extension/ProjectionableTrait.php index 0dd3560c..3d0bd591 100644 --- a/src/Query/Extension/ProjectionableTrait.php +++ b/src/Query/Extension/ProjectionableTrait.php @@ -5,6 +5,10 @@ use Bdf\Prime\Query\Compiler\CompilerState; use Bdf\Prime\Query\Contract\Projectionable; +use function func_get_args; +use function is_array; +use function is_int; + /** * Trait for @see Projectionable * @@ -23,6 +27,34 @@ public function project($columns = null) return $this->select($columns); } + /** + * @see Projectionable::addProjection() + */ + public function addProjection($columns) + { + // Empty project means that all fields are projected, so no need to add new projections + if ($this->statements['columns'] === []) { + return $this; + } + + $this->compilerState->invalidate('columns'); + + $columns = is_array($columns) ? $columns : [$columns]; + + foreach ($columns as $alias => $column) { + $toAdd = [ + 'column' => $column, + 'alias' => is_int($alias) ? null : $alias, + ]; + + if (!in_array($toAdd, $this->statements['columns'])) { + $this->statements['columns'][] = $toAdd; + } + } + + return $this; + } + /** * @see Projectionable::select() */ diff --git a/src/Query/QueryRepositoryExtension.php b/src/Query/QueryRepositoryExtension.php index 144ad366..8679f8c2 100644 --- a/src/Query/QueryRepositoryExtension.php +++ b/src/Query/QueryRepositoryExtension.php @@ -4,6 +4,8 @@ use BadMethodCallException; use Bdf\Prime\Collection\Indexer\EntityIndexer; +use Bdf\Prime\Collection\Indexer\EntityIndexerInterface; +use Bdf\Prime\Collection\Indexer\RecordIndexer; use Bdf\Prime\Connection\ConnectionInterface; use Bdf\Prime\Exception\EntityNotFoundException; use Bdf\Prime\Exception\PrimeException; @@ -12,6 +14,7 @@ use Bdf\Prime\Mapper\Metadata; use Bdf\Prime\Platform\PlatformInterface; use Bdf\Prime\Query\Closure\ClosureCompiler; +use Bdf\Prime\Query\Contract\Projectionable; use Bdf\Prime\Query\Contract\Query\KeyValueQueryInterface; use Bdf\Prime\Query\Contract\Whereable; use Bdf\Prime\Record\RecordHydratorInterface; @@ -23,10 +26,15 @@ use Closure; use Doctrine\DBAL\Query\Expression\CompositeExpression; +use function array_any; use function array_diff; use function array_keys; +use function array_merge; +use function class_exists; use function count; use function is_array; +use function is_int; +use function spl_object_id; /** * QueryRepositoryExtension @@ -363,6 +371,11 @@ public function by(ReadCommandInterface $query, $attribute, $combine = false) 'combine' => $combine, ]; + // Ensure that the field has been projected + if ($query instanceof Projectionable) { + $query->addProjection($attribute); + } + return $query; } @@ -424,7 +437,21 @@ public function toCriteria(ReadCommandInterface $query): ?array */ public function projection(string $recordClass): ?array { - return $this->recordManager->projection($recordClass); + $projection = $this->recordManager->projection($recordClass); + + if ($this->byOptions && $projection) { + $byAttribute = $this->byOptions['attribute']; + + // The "by" attribute is not project neither as alias nor simple projection (i.e. int key in prime) + if ( + !isset($projection[$byAttribute]) + && !array_any(array_keys($projection, $byAttribute, true), static fn ($value) => is_int($value)) + ) { + $projection[] = $byAttribute; + } + } + + return $projection; } /** @@ -446,9 +473,61 @@ public function instantiate(string $recordClass, array $data, PlatformInterface /** * {@inheritdoc} */ - public function finalize(string $recordClass, array $entities): array + public function finalize(string $recordClass, array $entities, array $rows): array { - $entities = $this->recordManager->finalize($recordClass, $entities); + $isRecord = $recordClass !== $this->repository->entityClass() && class_exists($recordClass); + $byOptions = $this->byOptions; + + $indexer = $isRecord + ? $this->finalizeRecord($recordClass, $entities, $rows) + : $this->finalizeEntity($recordClass, $entities, $rows) + ; + + return match (true) { + $byOptions === null => $indexer->all(), + $byOptions['combine'] => $indexer->by($byOptions['attribute']), + default => $indexer->byOverride($byOptions['attribute']), + }; + } + + /** + * Scope call + * run a scope defined in repository + * + * @param string $name Scope name + * @param array $arguments + * + * @return mixed + */ + public function __call($name, $arguments) + { + /** @var EntityRepository $this->repository */ + $scopes = $this->repository->scopes(); + + if (!isset($scopes[$name])) { + throw new BadMethodCallException('Scope "' . get_class($this->mapper) . '::' . $name . '" not found'); + } + + return $scopes[$name](...$arguments); + } + + /** + * Configure the query + * + * @param ReadCommandInterface $query + * + * @return void + */ + public function apply(ReadCommandInterface $query): void + { + $query->setExtension($this); + $query->setRecordHydrator($this); + $query->as($this->mapper->getEntityClass()); + } + + private function finalizeEntity(string $recordClass, array $entities, array $rows): EntityIndexerInterface + { + $entities = $this->recordManager->finalize($recordClass, $entities, $rows); /** @var EntityRepository $repository */ $repository = $this->repository; @@ -459,11 +538,6 @@ public function finalize(string $recordClass, array $entities): array $withoutRelations = $this->withoutRelations; $byOptions = $this->byOptions; - // @todo handle by() with record. with() cannot be used with record - if (($byOptions || $withRelations) && ($recordClass !== $this->repository->entityClass() && class_exists($recordClass))) { - throw new BadMethodCallException('by() or with() methods are not available with record.'); - } - $indexer = new EntityIndexer($this->mapper, $byOptions ? [$byOptions['attribute']] : []); // Force loading of eager relations @@ -497,50 +571,44 @@ public function finalize(string $recordClass, array $entities): array ); } - switch (true) { - case $byOptions === null: - return $indexer->all(); + return $indexer; + } - case $byOptions['combine']: - return $indexer->by($byOptions['attribute']); + private function finalizeRecord(string $recordClass, array $entities, array $rows): EntityIndexerInterface + { + /** @var EntityRepository $repository */ + $repository = $this->repository; + $entities = $this->recordManager->finalize($recordClass, $entities, $rows); + $byOptions = $this->byOptions; - default: - return $indexer->byOverride($byOptions['attribute']); + if ($this->withRelations) { + throw new BadMethodCallException('with() method is not available with record. Use #[LoadRelation] attribute instead.'); } - } - /** - * Scope call - * run a scope defined in repository - * - * @param string $name Scope name - * @param array $arguments - * - * @return mixed - */ - public function __call($name, $arguments) - { - /** @var EntityRepository $this->repository */ - $scopes = $this->repository->scopes(); + $rowsByObjectId = []; - if (!isset($scopes[$name])) { - throw new BadMethodCallException('Scope "' . get_class($this->mapper) . '::' . $name . '" not found'); + if ($byOptions) { + foreach ($entities as $index => $entity) { + $row = $rows[$index] ?? null; + $rowsByObjectId[spl_object_id($entity)] = $row; + } } - return $scopes[$name](...$arguments); - } + $indexer = new RecordIndexer(function (object $record, string $property) use ($rowsByObjectId, $repository) { + $dbField = $repository->metadata()->attributes[$property]['field'] ?? null; - /** - * Configure the query - * - * @param ReadCommandInterface $query - * - * @return void - */ - public function apply(ReadCommandInterface $query): void - { - $query->setExtension($this); - $query->setRecordHydrator($this); - $query->as($this->mapper->getEntityClass()); + if (!$dbField) { + return null; + } + + return $rowsByObjectId[spl_object_id($record)][$dbField] ?? null; + }, $byOptions ? [$byOptions['attribute']] : []); + + /** @var E $entity */ + foreach ($entities as $entity) { + $indexer->push($entity); + } + + return $indexer; } } diff --git a/src/Record/Embedded.php b/src/Record/Embedded.php new file mode 100644 index 00000000..b2f49da5 --- /dev/null +++ b/src/Record/Embedded.php @@ -0,0 +1,153 @@ +instantiator->projection(); + } + + #[Override] + public function value(PlatformInterface $platform, array $data): mixed + { + return $this->instantiator->instantiate($data, $platform); + } + + /** + * Create the corresponding embedded from a reflection parameter + * + * @param ReflectionParameter $parameter + * @param array|null $attributesMetadata The metadata of attributes, if called from an ORM query. Null on DBAL query. + * @param string|null $fieldPrefix The prefix to use for the fields in case of recursive sub-record + * + * @return self|null The parsed embedded, or null if the parameter is not annotated with Embedded + */ + public static function fromReflectionParameter(ReflectionParameter $parameter, ?array $attributesMetadata = null, ?string $fieldPrefix = null): ?self + { + foreach ($parameter->getAttributes(self::class) as $attribute) { + $embedded = $attribute->newInstance(); + $className = $embedded->className; + + if ($className === null) { + $type = $parameter->getType(); + + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + throw new InvalidArgumentException(sprintf( + 'The parameter %s on %s must have a type or the #[Embedded] attribute must define a className.', + $parameter->getName(), + $parameter->getDeclaringClass()->getName(), + )); + } + + $className = $type->getName(); + } + + $prefix = ($fieldPrefix ?? '') . ($embedded->prefix ?? $parameter->name . ($attributesMetadata ? '.' : '_')); + + return new self( + prefix: $prefix, + className: $className, + instantiator: $embedded->instantiator ?? RecordInstantiator::fromRecordClass($className, $attributesMetadata, $prefix), + ); + } + + return null; + } +} diff --git a/src/Record/Field.php b/src/Record/Field.php index 25c64f5b..93b7a1dc 100644 --- a/src/Record/Field.php +++ b/src/Record/Field.php @@ -3,9 +3,15 @@ namespace Bdf\Prime\Record; use Attribute; +use Bdf\Prime\Platform\PlatformInterface; +use Bdf\Prime\Platform\PlatformTypesInterface; use Bdf\Prime\Query\Expression\ExpressionInterface; +use Override; use ReflectionParameter; +use function assert; +use function is_string; + /** * Define a mapping for a database field to a record constructor parameter * @@ -26,12 +32,16 @@ * // Database field type can be specified to allow parsing the value * #[Field('created_at', type: 'datetime')] * public readonly DateTime $createdAt, + * + * // Use a transformer to parse database value + * #[Field(transformer: CustomData::fromString(...))] + * public readonly CustomData $data, * ) {} * } * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -final class Field +final class Field implements RecordParameterInterface { public function __construct( /** @@ -83,6 +93,16 @@ public function __construct( * If this value is a string, it will be used as alias for the projection. */ public readonly string|false|null $projection = null, + + /** + * A transformer function to apply to the field value. + * + * This transformer will be called with the value parsed by the prime type (if provided) + * before passing it to the parameter. + * + * @var null|callable(mixed):mixed + */ + public readonly mixed $transformer = null, ) { } @@ -94,6 +114,10 @@ public function __construct( */ public function cast(mixed $value): mixed { + if ($this->transformer !== null) { + $value = ($this->transformer)($value); + } + if ($this->castType === null) { return $value; } @@ -101,10 +125,39 @@ public function cast(mixed $value): mixed return $this->castType->cast($value, $this->nullable ?? true); } + #[Override] + public function projection(): array + { + if ($this->projection === false) { + return []; + } + + $name = $this->projection ?? $this->name; + assert($name !== null); + + if ($this->expression) { + return [$name => $this->expression]; + } + + return [$name]; + } + + #[Override] + public function value(PlatformInterface $platform, array $data): mixed + { + $value = $data[$this->name] ?? null; + + if ($this->type !== null) { + $value = $platform->types()->fromDatabase($value, $this->type); + } + + return $this->cast($value); + } + /** * Replace values and return a new instance */ - public function with(?string $name = null, ExpressionInterface|string|null $expression = null, ?string $type = null, ?CastType $castType = null, ?bool $nullable = null, string|false|null $projection = null): self + public function with(?string $name = null, ExpressionInterface|string|null $expression = null, ?string $type = null, ?CastType $castType = null, ?bool $nullable = null, string|false|null $projection = null, ?callable $transformer = null): self { return new self( name: $name ?? $this->name, @@ -113,6 +166,29 @@ public function with(?string $name = null, ExpressionInterface|string|null $expr castType: $castType ?? $this->castType, nullable: $nullable ?? $this->nullable, projection: $projection ?? $this->projection, + transformer: $transformer ?? $this->transformer, + ); + } + + /** + * Resolve database field name and type from the attributes metadata + * + * @param array $attributesMetadata + * @return self + */ + public function withAttributesMetadata(array $attributesMetadata): self + { + $field = $this; + + if ($field->type === null && ($field->expression === null || is_string($field->expression))) { + $field = $field->with( + type: $attributesMetadata[$field->expression ?? $field->name]['type'] ?? null, + ); + } + + return $field->with( + name: $attributesMetadata[$field->name]['field'] ?? $field->name, + projection: $field->projection ?? $field->name, ); } diff --git a/src/Record/LoadRelation.php b/src/Record/LoadRelation.php index 9087b217..fb4cbe25 100644 --- a/src/Record/LoadRelation.php +++ b/src/Record/LoadRelation.php @@ -3,6 +3,7 @@ namespace Bdf\Prime\Record; use Attribute; +use Bdf\Prime\Query\ReadCommandInterface; /** * Mark a constructor parameter to be filled by a relation loading @@ -19,19 +20,45 @@ * // The relation class name can be used if not ambiguous * #[LoadRelation(MyEntity::class)] * public readonly MyEntity $entity, + * + * // The parameter type is used as relation name if not specified + * #[LoadRelation] + * public readonly OtherEntity $other, * ) {} * } * ``` */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] -final class LoadRelation +final readonly class LoadRelation { public function __construct( /** * The relation name to load - * Can be the relation class name if not ambiguous + * + * Can be the relation class name if not ambiguous. + * If null, the type of the parameter will be used as relation name. + */ + public ?string $relation = null, + + /** + * Define the read record type for the relation + * + * If this value is null and no transformer is set, but the parameter type differ from the relation + * entity, the parameter type will be used as read record. + * + * @var class-string|null + * @see ReadCommandInterface::as() + */ + public ?string $as = null, + + /** + * A transformer function to apply to the relation entity (or the read record). + * This transformer will be called before passing it to the parameter. + * + * @var null|callable(mixed):mixed + * @see Field::$transformer */ - public readonly string $relation, + public mixed $transformer = null, ) { } } diff --git a/src/Record/RecordHydratorInterface.php b/src/Record/RecordHydratorInterface.php index bad1d096..aef02266 100644 --- a/src/Record/RecordHydratorInterface.php +++ b/src/Record/RecordHydratorInterface.php @@ -54,10 +54,11 @@ public function instantiate(string $recordClass, array $data, PlatformInterface * * @param class-string $recordClass The record class name * @param array $entities The entities to finalize + * @param array> $rows Raw database rows * * @return array Resulting entities. Usually the same as input entities * * @template R as object */ - public function finalize(string $recordClass, array $entities): array; + public function finalize(string $recordClass, array $entities, array $rows): array; } diff --git a/src/Record/RecordInstantiator.php b/src/Record/RecordInstantiator.php index 1ee7f8c1..68b84026 100644 --- a/src/Record/RecordInstantiator.php +++ b/src/Record/RecordInstantiator.php @@ -29,7 +29,7 @@ public function __construct( public readonly string $recordClass, /** - * @var array + * @var list */ public readonly array $fields, ) { @@ -43,18 +43,8 @@ public function projection(): array $projection = []; - foreach ($this->fields as $name => $field) { - if ($field->projection === false) { - continue; - } - - $name = $field->projection ?? $field->name ?? $name; - - if ($field->expression) { - $projection[$name] = $field->expression; - } else { - $projection[] = $name; - } + foreach ($this->fields as $field) { + $projection = [...$projection, ...$field->projection()]; } return $projection; @@ -67,17 +57,10 @@ public function projection(): array */ public function instantiate(array $data, PlatformInterface $platform): object { - $types = $platform->types(); $constructorParameters = []; - foreach ($this->fields as $name => $field) { - $value = $data[$field->name ?? $name] ?? null; - - if ($field->type !== null) { - $value = $types->fromDatabase($value, $field->type); - } - - $constructorParameters[] = $field->cast($value); + foreach ($this->fields as $field) { + $constructorParameters[] = $field->value($platform, $data); } $recordClass = $this->recordClass; @@ -86,12 +69,15 @@ public function instantiate(array $data, PlatformInterface $platform): object /** * @param class-string $recordClass + * @param array|null $attributesMetadata The metadata of attributes, if called from an ORM query. Null on DBAL query. + * @param string|null $fieldPrefix Prefix to add to fields + * * @return self * @template T as object */ - public static function fromRecordClass(string $recordClass): self + public static function fromRecordClass(string $recordClass, ?array $attributesMetadata = null, ?string $fieldPrefix = null): self { - $reflectionParameters = (new ReflectionClass($recordClass))->getConstructor()?->getParameters(); + $reflectionParameters = new ReflectionClass($recordClass)->getConstructor()?->getParameters(); if ($reflectionParameters === null) { throw new InvalidArgumentException(sprintf('The record class %s must have a constructor', $recordClass)); @@ -100,7 +86,23 @@ public static function fromRecordClass(string $recordClass): self $parameters = []; foreach ($reflectionParameters as $parameter) { - $parameters[$parameter->getName()] = Field::fromReflectionParameter($parameter); + $recordParameter = Embedded::fromReflectionParameter($parameter, $attributesMetadata, $fieldPrefix); + + if ($recordParameter === null) { + $recordParameter = Field::fromReflectionParameter($parameter); + + if ($fieldPrefix !== null && $fieldPrefix !== '') { + $recordParameter = $recordParameter->with( + name: $fieldPrefix . $recordParameter->name, + ); + } + + if ($attributesMetadata) { + $recordParameter = $recordParameter->withAttributesMetadata($attributesMetadata); + } + } + + $parameters[] = $recordParameter; } return new self($recordClass, $parameters); diff --git a/src/Record/RecordParameterInterface.php b/src/Record/RecordParameterInterface.php new file mode 100644 index 00000000..81997d7a --- /dev/null +++ b/src/Record/RecordParameterInterface.php @@ -0,0 +1,31 @@ + + * @see Projectionable::project() For the format + */ + public function projection(): array; + + /** + * Extract the parameter value from the database row + * + * @param PlatformInterface $platform The current database connection platform + * @param array $data The database row + * + * @return mixed + */ + public function value(PlatformInterface $platform, array $data): mixed; +} diff --git a/src/Record/RelationLoader.php b/src/Record/RelationLoader.php index 742305f5..b41fd5d3 100644 --- a/src/Record/RelationLoader.php +++ b/src/Record/RelationLoader.php @@ -30,6 +30,13 @@ public function __construct( * The database field name storing the foreign key of the relation */ public readonly string $foreignKeyField, + + /** + * The read record class name to hydrate instead of the relation entity + * + * @var class-string|null + */ + public readonly ?string $readRecord = null, ) { } @@ -52,7 +59,11 @@ public function load(RepositoryInterface $ownerRepository, array $rows): array } } - $entities = $relation->loadByForeignKeys(array_values($keys)); + $entities = $this->readRecord === null + ? $relation->loadByForeignKeys(array_values($keys)) + : $relation->loadRecordByForeignKeys(array_values($keys), $this->readRecord) + ; + $loaded = $rows; foreach ($rows as $k => $row) { diff --git a/src/Record/RepositoryRecordHydrator.php b/src/Record/RepositoryRecordHydrator.php index f403bdc4..732b5dce 100644 --- a/src/Record/RepositoryRecordHydrator.php +++ b/src/Record/RepositoryRecordHydrator.php @@ -6,9 +6,11 @@ use Bdf\Prime\Repository\RepositoryInterface; use InvalidArgumentException; use ReflectionClass; +use ReflectionNamedType; +use ReflectionParameter; use function class_exists; -use function is_string; +use function is_a; use function sprintf; /** @@ -63,7 +65,7 @@ public function prepare(string $recordClass, array $rows): array /** * {@inheritdoc} */ - public function finalize(string $recordClass, array $entities): array + public function finalize(string $recordClass, array $entities, array $rows): array { return $entities; } @@ -96,50 +98,144 @@ private function instantiator(string $recordClass): EntityRecordInstantiator return $instantiator; } - $constructorParameters = (new ReflectionClass($recordClass))->getConstructor()?->getParameters(); + $constructorParameters = new ReflectionClass($recordClass)->getConstructor()?->getParameters(); if ($constructorParameters === null) { throw new InvalidArgumentException(sprintf('The record class %s must have a constructor', $recordClass)); } - $fields = []; - $relations = []; + $builder = new EntityRecordInstantiatorBuilder($recordClass); $attributesMetadata = $this->repository->metadata()->attributes; foreach ($constructorParameters as $parameter) { - foreach ($parameter->getAttributes(LoadRelation::class) as $loadRelationAttr) { - $relAttr = $loadRelationAttr->newInstance(); - $relObj = $this->repository->relation($relAttr->relation); - $relations[$parameter->name] = new RelationLoader( - relationName: $relAttr->relation, - target: $parameter->name, - foreignKeyProperty: $relObj->localKeyProperty(), - foreignKeyField: $attributesMetadata[$relObj->localKeyProperty()]['field'], - ); - $fields[$parameter->name] = new Field(name: $parameter->name, projection: $relObj->localKeyProperty()); - continue 2; + if ($this->buildRelation($builder, $parameter, $attributesMetadata)) { + continue; } - $field = Field::fromReflectionParameter($parameter); + if ($this->buildEmbedded($builder, $parameter, $attributesMetadata)) { + continue; + } + + $this->buildField($builder, $parameter, $attributesMetadata); + } + + return $this->cache[$recordClass] = $builder->build(); + } + + /** + * @param EntityRecordInstantiatorBuilder $builder + * @param ReflectionParameter $parameter + * @param array $attributesMetadata + * @return bool + */ + private function buildRelation(EntityRecordInstantiatorBuilder $builder, ReflectionParameter $parameter, array $attributesMetadata): bool + { + foreach ($parameter->getAttributes(LoadRelation::class) as $loadRelationAttr) { + $relAttr = $loadRelationAttr->newInstance(); + $parameterType = $parameter->getType() instanceof ReflectionNamedType && !$parameter->getType()->isBuiltin() ? $parameter->getType()->getName() : null; + + $relationName = $relAttr->relation ?? $parameterType; + + if ($relationName === null) { + throw new InvalidArgumentException(sprintf('Cannot determine relation name for parameter %s in class %s. Set the relation name on the LoadRelation attribute, or set the relation class on the parameter type.', $parameter->name, $builder->recordClass)); + } + + $relObj = $this->repository->relation($relationName); + + $readRecord = $relAttr->as; - // resolve the db type from the mapper, if possible - if ($field->type === null && ($field->expression === null || is_string($field->expression))) { - $field = $field->with( - type: $attributesMetadata[$field->expression ?? $field->name]['type'] ?? null, - ); + if ( + $readRecord === null + && $relAttr->transformer === null + && $parameterType !== null + && !is_a($relObj->relationRepository()->entityClass(), $parameterType, true) + ) { + $readRecord = $parameterType; } - $field = $field->with( - name: $attributesMetadata[$field->name]['field'] ?? $field->name, - projection: $field->name - ); + $builder->relation(new RelationLoader( + relationName: $relationName, + target: $parameter->name, + foreignKeyProperty: $relObj->localKeyProperty(), + foreignKeyField: $attributesMetadata[$relObj->localKeyProperty()]['field'], + readRecord: $readRecord, + )); + $builder->parameter(new Field( + name: $parameter->name, + castType: CastType::fromType($parameter->getType()), + nullable: $parameter->allowsNull(), + projection: $relObj->localKeyProperty(), + transformer: $relAttr->transformer, + )); - $fields[$field->name] = $field; + return true; } - return $this->cache[$recordClass] = new EntityRecordInstantiator( - new RecordInstantiator($recordClass, $fields), - $relations + return false; + } + + private function buildEmbedded(EntityRecordInstantiatorBuilder $builder, ReflectionParameter $parameter, array $attributesMetadata): bool + { + $embedded = Embedded::fromReflectionParameter($parameter, $attributesMetadata); + + if (!$embedded) { + return false; + } + + $builder->parameter($embedded); + return true; + } + + private function buildField(EntityRecordInstantiatorBuilder $builder, ReflectionParameter $parameter, array $attributesMetadata): void + { + $field = Field::fromReflectionParameter($parameter)->withAttributesMetadata($attributesMetadata); + + $builder->parameter($field); + } +} + +/** + * @internal + * @template R as object + */ +final class EntityRecordInstantiatorBuilder +{ + /** + * @var list + */ + private array $parameters = []; + + /** + * @var array + */ + private array $relations = []; + + public function __construct( + /** + * @var class-string + */ + public readonly string $recordClass, + ) { + } + + public function parameter(RecordParameterInterface $field): void + { + $this->parameters[] = $field; + } + + public function relation(RelationLoader $relation): void + { + $this->relations[$relation->target] = $relation; + } + + /** + * @return EntityRecordInstantiator + */ + public function build(): EntityRecordInstantiator + { + return new EntityRecordInstantiator( + new RecordInstantiator($this->recordClass, $this->parameters), + $this->relations, ); } } diff --git a/src/Record/SimpleRecordHydrator.php b/src/Record/SimpleRecordHydrator.php index 3452c9bc..a3ef3582 100644 --- a/src/Record/SimpleRecordHydrator.php +++ b/src/Record/SimpleRecordHydrator.php @@ -47,7 +47,7 @@ public function instantiate(string $recordClass, array $data, PlatformInterface /** * {@inheritdoc} */ - public function finalize(string $recordClass, array $entities): array + public function finalize(string $recordClass, array $entities, array $rows): array { return $entities; } diff --git a/src/Relations/AbstractRelation.php b/src/Relations/AbstractRelation.php index 6e936d55..fa61c303 100644 --- a/src/Relations/AbstractRelation.php +++ b/src/Relations/AbstractRelation.php @@ -15,7 +15,6 @@ use Bdf\Prime\Relations\Info\RelationInfoInterface; use Bdf\Prime\Repository\RepositoryInterface; -use function assert; use function is_object; /** @@ -435,6 +434,15 @@ public function loadByForeignKeys(array $keys): array throw new BadMethodCallException('Unsupported operation '.__METHOD__); } + /** + * {@inheritdoc} + */ + #[ReadOperation] + public function loadRecordByForeignKeys(array $keys, string $recordClass): array + { + throw new BadMethodCallException('Unsupported operation '.__METHOD__); + } + /** * {@inheritdoc} */ diff --git a/src/Relations/BelongsToMany.php b/src/Relations/BelongsToMany.php index 4a24250f..fabbd884 100644 --- a/src/Relations/BelongsToMany.php +++ b/src/Relations/BelongsToMany.php @@ -209,7 +209,21 @@ public function link($owner, ?string $queryClass = null): ReadCommandInterface */ public function loadByForeignKeys(array $keys): array { - ['throughEntities' => $throughEntities, 'entities' => $entities] = $this->relations($keys, [], [], []); + return $this->internalLoadByForeignKeys($keys, null); + } + + /** + * {@inheritdoc} + */ + public function loadRecordByForeignKeys(array $keys, string $recordClass): array + { + return $this->internalLoadByForeignKeys($keys, $recordClass); + } + + + protected function internalLoadByForeignKeys(array $keys, ?string $recordClass): array + { + ['throughEntities' => $throughEntities, 'entities' => $entities] = $this->relations($keys, [], [], [], $recordClass); $loaded = []; @@ -274,7 +288,7 @@ protected function throughQuery($key, $constraints = []): ReadCommandInterface /** * Build the query for find related entities */ - protected function relationQuery(array $keys, $constraints): ReadCommandInterface + protected function relationQuery(array $keys, $constraints, bool $recreate = false): ReadCommandInterface { // Constraints can be on relation attributes : builder must be used // @todo Handle "bulk select" @@ -282,7 +296,7 @@ protected function relationQuery(array $keys, $constraints): ReadCommandInterfac return $this->query($keys, $constraints)->by($this->distantKey); } - if ($this->relationQuery) { + if (!$recreate && $this->relationQuery) { return $this->relationQuery->where($this->distantKey, reset($keys)); } @@ -292,7 +306,13 @@ protected function relationQuery(array $keys, $constraints): ReadCommandInterfac return $this->query($keys, $constraints)->by($this->distantKey); } - return $this->relationQuery = $query->by($this->distantKey); + $query->by($this->distantKey); + + if (!$recreate) { + $this->relationQuery = $query; + } + + return $query; } /** @@ -315,7 +335,7 @@ protected function applyThroughConstraints(ReadCommandInterface $query, $constra * {@inheritdoc} */ #[ReadOperation] - protected function relations($keys, $with, $constraints, $without): array + protected function relations($keys, $with, $constraints, $without, ?string $recordClass = null): array { list($constraints, $throughConstraints) = $this->extractConstraints($constraints); @@ -336,10 +356,16 @@ protected function relations($keys, $with, $constraints, $without): array } if ($throughDistants !== []) { - $relations = $this->relationQuery($throughDistants, $constraints) + $relationQuery = $this->relationQuery($throughDistants, $constraints, recreate: $recordClass !== null) ->with($with) ->without($without) - ->all(); + ; + + if ($recordClass !== null) { + $relationQuery->as($recordClass); + } + + $relations = $relationQuery->all(); } else { $relations = []; } diff --git a/src/Relations/HasMany.php b/src/Relations/HasMany.php index b066596a..2b6dcb5d 100644 --- a/src/Relations/HasMany.php +++ b/src/Relations/HasMany.php @@ -5,6 +5,8 @@ use Bdf\Prime\Query\Custom\KeyValue\KeyValueQuery; use Bdf\Prime\Query\ReadCommandInterface; +use function count; + /** * HasMany * @@ -57,15 +59,34 @@ public function loadByForeignKeys(array $keys): array /** * {@inheritdoc} */ - protected function relationQuery($keys, $constraints): ReadCommandInterface + public function loadRecordByForeignKeys(array $keys, string $recordClass): array { - // Constraints can be on relation attributes : builder must be used + $loaded = parent::loadRecordByForeignKeys($keys, $recordClass); + + if (count($keys) === count($loaded)) { + return $loaded; + } + + // Provide empty array for missing keys + foreach ($keys as $key) { + $loaded[$key] ??= []; + } + + return $loaded; + } + + /** + * {@inheritdoc} + */ + protected function relationQuery($keys, $constraints, bool $recreate = false): ReadCommandInterface + { + // Constraints can be set on relation attributes : builder must be used // @todo Handle "bulk select" - if (count($keys) !== 1 || $constraints || $this->constraints) { + if (count($keys) !== 1 || $constraints || $this->constraints || $this->isPolymorphic()) { return $this->query($keys, $constraints)->by($this->distantKey, true); } - if ($this->relationQuery) { + if ($this->relationQuery && !$recreate) { return $this->relationQuery->where($this->distantKey, $keys[0]); } @@ -75,6 +96,12 @@ protected function relationQuery($keys, $constraints): ReadCommandInterface return $this->query($keys, $constraints)->by($this->distantKey, true); } - return $this->relationQuery = $query->by($this->distantKey, true); + $query->by($this->distantKey, true); + + if (!$recreate) { + $this->relationQuery = $query; + } + + return $query; } } diff --git a/src/Relations/HasOne.php b/src/Relations/HasOne.php index 5d4db97f..bc5965d4 100644 --- a/src/Relations/HasOne.php +++ b/src/Relations/HasOne.php @@ -39,15 +39,15 @@ protected function getForeignInfos(): array /** * {@inheritdoc} */ - protected function relationQuery($keys, $constraints): ReadCommandInterface + protected function relationQuery($keys, $constraints, bool $recreate = false): ReadCommandInterface { // Constraints can be on relation attributes : builder must be used // @todo Handle "bulk select" - if (count($keys) !== 1 || $constraints || $this->constraints) { + if (count($keys) !== 1 || $constraints || $this->constraints || $this->isPolymorphic()) { return $this->query($keys, $constraints)->by($this->distantKey); } - if ($this->relationQuery) { + if ($this->relationQuery && !$recreate) { return $this->relationQuery->where($this->distantKey, $keys[0]); } @@ -57,6 +57,12 @@ protected function relationQuery($keys, $constraints): ReadCommandInterface return $this->query($keys, $constraints)->by($this->distantKey); } - return $this->relationQuery = $query->by($this->distantKey); + $query->by($this->distantKey); + + if (!$recreate) { + $this->relationQuery = $query; + } + + return $query; } } diff --git a/src/Relations/MorphTo.php b/src/Relations/MorphTo.php index a7fff364..55784704 100644 --- a/src/Relations/MorphTo.php +++ b/src/Relations/MorphTo.php @@ -69,6 +69,14 @@ public function loadByForeignKeys(array $keys): array throw new \BadMethodCallException('MorphTo relation do not supports querying by foreign keys'); } + /** + * {@inheritdoc} + */ + public function loadRecordByForeignKeys(array $keys, string $recordClass): array + { + throw new \BadMethodCallException('MorphTo relation do not supports querying by foreign keys'); + } + /** * {@inheritdoc} */ @@ -217,7 +225,7 @@ protected function updateDistantInfos(): void /** * {@inheritdoc} */ - protected function relationQuery($keys, $constraints): ReadCommandInterface + protected function relationQuery($keys, $constraints, bool $recreate = false): ReadCommandInterface { return $this->query($keys, $constraints)->by($this->distantKey); } diff --git a/src/Relations/NullRelation.php b/src/Relations/NullRelation.php index 3267334b..a197c984 100644 --- a/src/Relations/NullRelation.php +++ b/src/Relations/NullRelation.php @@ -90,6 +90,14 @@ public function loadByForeignKeys(array $keys): array return []; } + /** + * {@inheritdoc} + */ + public function loadRecordByForeignKeys(array $keys, string $recordClass): array + { + return []; + } + /** * {@inheritdoc} */ diff --git a/src/Relations/OneOrMany.php b/src/Relations/OneOrMany.php index e9ccbaea..bb39b830 100644 --- a/src/Relations/OneOrMany.php +++ b/src/Relations/OneOrMany.php @@ -85,13 +85,19 @@ public function joinRepositories(EntityJoinable $query, string $alias, $discrimi * {@inheritdoc} */ #[ReadOperation] - protected function relations($keys, $with, $constraints, $without): array + protected function relations($keys, $with, $constraints, $without, ?string $recordClass = null): array { - /** @var R[] */ - return $this->relationQuery($keys, $constraints) + $query = $this->relationQuery($keys, $constraints, recreate: $recordClass !== null) ->with($with) ->without($without) - ->all(); + ; + + if ($recordClass !== null) { + $query->as($recordClass); + } + + /** @var array */ + return $query->all(); } /** @@ -255,10 +261,11 @@ abstract protected function getForeignInfos(): array; * * @param array $keys The owner keys * @param array $constraints Constraints to apply on the query + * @param bool $recreate If true, the query will be always recreated instead of using the one in memory * * @return ReadCommandInterface */ - abstract protected function relationQuery($keys, $constraints): ReadCommandInterface; + abstract protected function relationQuery($keys, $constraints, bool $recreate = false): ReadCommandInterface; /** * Check if the entity is the foreign key barrier diff --git a/src/Relations/Relation.php b/src/Relations/Relation.php index 4205f2e0..2e49aed9 100644 --- a/src/Relations/Relation.php +++ b/src/Relations/Relation.php @@ -145,6 +145,15 @@ public function loadByForeignKeys(array $keys): array return $this->relations($keys, [], [], []); } + /** + * {@inheritdoc} + */ + #[ReadOperation] + public function loadRecordByForeignKeys(array $keys, string $recordClass): array + { + return $this->relations($keys, [], [], [], $recordClass); + } + /** * Get the entities * @@ -152,12 +161,13 @@ public function loadByForeignKeys(array $keys): array * @param array $with * @param array $constraints * @param array $without + * @param class-string|null $recordClass The read record class to use instead of the relation entity * * @return array Entities, indexed by the local key value (i.e. foreign key on the owner table). The value may be an array of entities if the relation is a collection, or a single entity if the relation is a single entity * @throws PrimeException */ #[ReadOperation] - abstract protected function relations($keys, $with, $constraints, $without): array; + abstract protected function relations($keys, $with, $constraints, $without, ?string $recordClass = null): array; /** * Set the relation in a collection of entities diff --git a/src/Relations/RelationInterface.php b/src/Relations/RelationInterface.php index c9c9756c..77d8f3d5 100755 --- a/src/Relations/RelationInterface.php +++ b/src/Relations/RelationInterface.php @@ -116,6 +116,24 @@ public function load(EntityIndexerInterface $collection, array $with = [], $cons #[ReadOperation] public function loadByForeignKeys(array $keys): array; + /** + * Manually load relation entities as record by their foreign keys + * + * The keys of the returned array should match with the parameter keys. + * If the related entity is not found, the key may be omitted from the result array. + * No other keys should be present in the result array. + * + * @param list $keys The foreign keys + * @param class-string

$recordClass The read record class to return + * + * @return array Records, indexed by the foreign key. The value can be a single entity for single entity relation, or an array of entities for collection relation + * @throws PrimeException + * + * @template P as object + */ + #[ReadOperation] + public function loadRecordByForeignKeys(array $keys, string $recordClass): array; + /** * Load relation if not yet loaded * diff --git a/src/Sharding/Query/ShardingKeyValueQuery.php b/src/Sharding/Query/ShardingKeyValueQuery.php index 6252f927..151c9009 100644 --- a/src/Sharding/Query/ShardingKeyValueQuery.php +++ b/src/Sharding/Query/ShardingKeyValueQuery.php @@ -17,6 +17,8 @@ use Bdf\Prime\Sharding\Extension\ShardPicker; use Bdf\Prime\Sharding\ShardingConnection; +use function is_array; + /** * Handle simple key/value query on sharding connection * If the distribution key is found on the filters, the corresponding sharding query is used @@ -86,6 +88,31 @@ public function project($columns = null) return $this; } + /** + * {@inheritdoc} + */ + public function addProjection($columns) + { + // Empty project means that all fields are projected, so no need to add new projections + if ($this->statements['columns'] === []) { + return $this; + } + + $columns = is_array($columns) ? $columns : [$columns]; + + foreach ($columns as $alias => $column) { + if (is_int($alias)) { + if (!in_array($column, $this->statements['columns'])) { + $this->statements['columns'][] = $column; + } + } elseif (!isset($this->statements['columns'][$alias])) { + $this->statements['columns'][$alias] = $column; + } + } + + return $this; + } + /** * {@inheritdoc} */ diff --git a/tests/CRUDTest.php b/tests/CRUDTest.php index b4b65f07..45c4eae9 100755 --- a/tests/CRUDTest.php +++ b/tests/CRUDTest.php @@ -3,6 +3,9 @@ namespace Bdf\Prime; use Bdf\Prime\Exception\DBALException; +use Bdf\Prime\Query\Expression\Attribute; +use Bdf\Prime\Record\Embedded; +use Bdf\Prime\Record\Field; use Bdf\Prime\Record\LoadRelation; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\SqlitePlatform; @@ -575,6 +578,32 @@ public function test_record() ], $records); } + public function test_record_with_transformer() + { + $this->pack()->nonPersist([ + new User([ + 'id' => 12, + 'name' => 'John', + 'roles' => ['2'], + 'customer' => new Customer(['id' => '1']), + ]), + new User([ + 'id' => 13, + 'name' => 'Mark', + 'roles' => ['5'], + 'customer' => new Customer(['id' => '1']), + ]), + ]); + + $records = User::repository()->builder()->as(RecordWithTransformer::class)->all(); + + $this->assertContainsOnly(RecordWithTransformer::class, $records); + $this->assertEquals([ + new RecordWithTransformer('12', '61409aa1fd47d4a5332de23cbf59a36f'), + new RecordWithTransformer('13', 'b82a9a13f4651e9abcbde90cd24ce2cb'), + ], $records); + } + public function test_record_with_relation() { $this->pack()->nonPersist([ @@ -627,6 +656,372 @@ public function test_record_with_relation() ], $records); } + public function test_record_with_relation_record() + { + $this->pack()->nonPersist([ + $customer1 = new Customer(['id' => 1, 'name' => 'Customer 1']), + $customer2 = new Customer(['id' => 2, 'name' => 'Customer 2']), + new User([ + 'id' => 12, + 'name' => 'John', + 'roles' => ['2'], + 'customer' => $customer1, + ]), + new User([ + 'id' => 13, + 'name' => 'Mark', + 'roles' => ['5'], + 'customer' => $customer2, + ]), + $doc1 = new Document([ + 'id' => 1, + 'customerId' => 1, + 'uploaderType' => 'user', + 'uploaderId' => 12, + 'contact' => new Contact([ + 'name' => 'John', + ]), + ]), + $doc2 = new Document([ + 'id' => 2, + 'customerId' => 1, + 'uploaderType' => 'user', + 'uploaderId' => 12, + 'contact' => new Contact([ + 'name' => 'Jean', + ]), + ]), + $doc3 = new Document([ + 'id' => 3, + 'customerId' => 2, + 'uploaderType' => 'user', + 'uploaderId' => 13, + 'contact' => new Contact([ + 'name' => 'Michel', + ]), + ]), + ]); + + $records = User::repository()->builder()->as(NameAndCustomerRecord::class)->all(); + + $this->assertEquals([ + new NameAndCustomerRecord('John', new CustomerRecord(1, 'Customer 1', true)), + new NameAndCustomerRecord('Mark', new CustomerRecord(2, 'Customer 2', true)), + ], $records); + + $records = User::repository()->builder()->as(NameAndDocumentsRecord::class)->all(); + + $this->assertEquals([ + new NameAndDocumentsRecord('John', [new DocumentRecord(1, 'John'), new DocumentRecord(2, 'Jean')]), + new NameAndDocumentsRecord('Mark', [new DocumentRecord(3, 'Michel')]), + ], $records); + } + + public function test_record_with_sub_record() + { + $this->pack()->nonPersist([ + $customer1 = new Customer(['id' => 1, 'name' => 'Customer 1']), + $customer2 = new Customer(['id' => 2, 'name' => 'Customer 2']), + new User([ + 'id' => 12, + 'name' => 'John', + 'roles' => ['2'], + 'customer' => $customer1, + ]), + new User([ + 'id' => 13, + 'name' => 'Mark', + 'roles' => ['5'], + 'customer' => $customer2, + ]), + $doc1 = new Document([ + 'id' => 1, + 'customerId' => 1, + 'uploaderType' => 'user', + 'uploaderId' => 12, + 'contact' => new Contact([ + 'name' => 'John', + ]), + ]), + $doc2 = new Document([ + 'id' => 2, + 'customerId' => 1, + 'uploaderType' => 'user', + 'uploaderId' => 12, + 'contact' => new Contact([ + 'name' => 'Jean', + 'location' => new Location([ + 'address' => '12 rue de la Paix', + 'city' => 'Roubaix', + ]) + ]), + ]), + $doc3 = new Document([ + 'id' => 3, + 'customerId' => 2, + 'uploaderType' => 'user', + 'uploaderId' => 13, + 'contact' => new Contact([ + 'name' => 'Michel', + ]), + ]), + $doc4 = new Document([ + 'id' => 4, + 'customerId' => 2, + 'uploaderType' => 'admin', + 'uploaderId' => 24, + ]), + ]); + + $records = Document::repository()->builder()->as(DocumentRecordWithSubRecord::class)->all(); + + $this->assertEquals([ + new DocumentRecordWithSubRecord(1, new UploaderRecord(12, 'user'), new DocumentContactRecord('John', null, null)), + new DocumentRecordWithSubRecord(2, new UploaderRecord(12, 'user'), new DocumentContactRecord('Jean', '12 rue de la Paix', 'Roubaix')), + new DocumentRecordWithSubRecord(3, new UploaderRecord(13, 'user'), new DocumentContactRecord('Michel', null, null)), + new DocumentRecordWithSubRecord(4, new UploaderRecord(24, 'admin'), new DocumentContactRecord(null, null, null)), + ], $records); + } + + public function test_record_with_nested_sub_record() + { + $this->declareDocumentsForRecord(); + + $records = Document::repository()->builder()->as(DocumentRecordWithNestedSubRecord::class)->all(); + + $this->assertEquals([ + new DocumentRecordWithNestedSubRecord(1, new ContactRecord('John', new LocationRecord(null, null))), + new DocumentRecordWithNestedSubRecord(2, new ContactRecord('Jean', new LocationRecord('12 rue de la Paix', 'Roubaix'))), + new DocumentRecordWithNestedSubRecord(3, new ContactRecord('Michel', new LocationRecord(null, null))), + new DocumentRecordWithNestedSubRecord(4, new ContactRecord(null, new LocationRecord(null, null))), + ], $records); + } + + public function test_record_with_sub_record_and_filters() + { + $this->declareDocumentsForRecord(); + + $records = Document::repository()->builder() + ->where('contact.name', 'Jean') + ->as(DocumentRecordWithNestedSubRecord::class) + ->all() + ; + + $this->assertEquals([ + new DocumentRecordWithNestedSubRecord(2, new ContactRecord('Jean', new LocationRecord('12 rue de la Paix', 'Roubaix'))), + ], $records); + + $records = Document::repository()->builder() + ->where('contact.location.city', 'Roubaix') + ->as(DocumentRecordWithNestedSubRecord::class) + ->all() + ; + + $this->assertEquals([ + new DocumentRecordWithNestedSubRecord(2, new ContactRecord('Jean', new LocationRecord('12 rue de la Paix', 'Roubaix'))), + ], $records); + } + + public function test_record_with_sub_record_and_relation() + { + $this->declareDocumentsForRecord(); + + $records = Document::repository()->builder()->as(DocumentRecordWithSubRecordAndRelation::class)->all(); + + $this->assertEquals([ + new DocumentRecordWithSubRecordAndRelation(1, new ContactRecord('John', new LocationRecord(null, null)), 'Customer 1'), + new DocumentRecordWithSubRecordAndRelation(2, new ContactRecord('Jean', new LocationRecord('12 rue de la Paix', 'Roubaix')), 'Customer 1'), + new DocumentRecordWithSubRecordAndRelation(3, new ContactRecord('Michel', new LocationRecord(null, null)), 'Customer 2'), + new DocumentRecordWithSubRecordAndRelation(4, new ContactRecord(null, new LocationRecord(null, null)), 'Customer 2'), + ], $records); + } + + public function test_record_with_sub_record_and_by() + { + $this->declareDocumentsForRecord(); + + $records = Document::repository()->builder()->by('contact.name')->as(DocumentRecordWithNestedSubRecord::class)->all(); + + $this->assertEquals([ + 'John' => new DocumentRecordWithNestedSubRecord(1, new ContactRecord('John', new LocationRecord(null, null))), + 'Jean' => new DocumentRecordWithNestedSubRecord(2, new ContactRecord('Jean', new LocationRecord('12 rue de la Paix', 'Roubaix'))), + 'Michel' => new DocumentRecordWithNestedSubRecord(3, new ContactRecord('Michel', new LocationRecord(null, null))), + '' => new DocumentRecordWithNestedSubRecord(4, new ContactRecord(null, new LocationRecord(null, null))), + ], $records); + } + + private function declareDocumentsForRecord(): void + { + $this->pack()->nonPersist([ + $customer1 = new Customer(['id' => 1, 'name' => 'Customer 1']), + $customer2 = new Customer(['id' => 2, 'name' => 'Customer 2']), + new User([ + 'id' => 12, + 'name' => 'John', + 'roles' => ['2'], + 'customer' => $customer1, + ]), + new User([ + 'id' => 13, + 'name' => 'Mark', + 'roles' => ['5'], + 'customer' => $customer2, + ]), + new Document([ + 'id' => 1, + 'customerId' => 1, + 'uploaderType' => 'user', + 'uploaderId' => 12, + 'contact' => new Contact([ + 'name' => 'John', + ]), + ]), + new Document([ + 'id' => 2, + 'customerId' => 1, + 'uploaderType' => 'user', + 'uploaderId' => 12, + 'contact' => new Contact([ + 'name' => 'Jean', + 'location' => new Location([ + 'address' => '12 rue de la Paix', + 'city' => 'Roubaix', + ]) + ]), + ]), + new Document([ + 'id' => 3, + 'customerId' => 2, + 'uploaderType' => 'user', + 'uploaderId' => 13, + 'contact' => new Contact([ + 'name' => 'Michel', + ]), + ]), + new Document([ + 'id' => 4, + 'customerId' => 2, + 'uploaderType' => 'admin', + 'uploaderId' => 24, + ]), + ]); + } + + public function test_record_with_by() + { + $this->declareUsersForRecord(); + + $records = User::repository()->builder()->by('name')->as(IdNameRecord::class)->all(); + + $this->assertEquals([ + 'John' => new IdNameRecord('12', 'John'), + 'Mark' => new IdNameRecord('13', 'Mark'), + 'Paul' => new IdNameRecord('14', 'Paul'), + ], $records); + } + + public function test_record_with_by_combine() + { + $this->declareUsersForRecord(); + + $records = User::repository()->builder()->by('name', true)->as(IdNameRecord::class)->all(); + + $this->assertEquals([ + 'John' => [new IdNameRecord('12', 'John')], + 'Mark' => [new IdNameRecord('13', 'Mark')], + 'Paul' => [new IdNameRecord('14', 'Paul')], + ], $records); + } + + public function test_record_with_by_on_attribute_not_declared_on_record() + { + $this->declareUsersForRecord(); + + $query = User::repository()->builder()->by('id')->as(NameOnlyRecord::class); + + $this->assertEquals('SELECT t0.name_, t0.id_ FROM user_ t0', $query->toSql()); + $this->assertEquals([ + 12 => new NameOnlyRecord('John'), + 13 => new NameOnlyRecord('Mark'), + 14 => new NameOnlyRecord('Paul'), + ], $query->all()); + } + + public function test_record_with_by_on_embedded_attribute_not_declared_on_record() + { + $this->declareUsersForRecord(); + + $query = User::repository()->builder()->by('customer.id', true)->as(NameOnlyRecord::class); + + $this->assertEquals('SELECT t0.name_, t0.customer_id FROM user_ t0', $query->toSql()); + $this->assertEquals([ + 1 => [new NameOnlyRecord('John'), new NameOnlyRecord('Paul')], + 2 => [new NameOnlyRecord('Mark')], + ], $query->all()); + } + + public function test_record_with_by_on_renamed_property() + { + $this->declareUsersForRecord(); + + $records = User::repository()->builder()->by('name')->as(RecordWithRenamedProperty::class)->all(); + + $this->assertEquals([ + 'John' => new RecordWithRenamedProperty('John'), + 'Mark' => new RecordWithRenamedProperty('Mark'), + 'Paul' => new RecordWithRenamedProperty('Paul'), + ], $records); + } + + public function test_record_with_by_and_relation() + { + $this->declareUsersForRecord(); + + $records = User::repository()->builder()->by('id')->as(NameAndCustomerRecord::class)->all(); + + $this->assertEquals([ + 12 => new NameAndCustomerRecord('John', new CustomerRecord(1, 'Customer 1', true)), + 13 => new NameAndCustomerRecord('Mark', new CustomerRecord(2, 'Customer 2', true)), + 14 => new NameAndCustomerRecord('Paul', new CustomerRecord(1, 'Customer 1', true)), + ], $records); + } + + public function test_record_with_with_should_raise_error() + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('with() method is not available with record. Use #[LoadRelation] attribute instead.'); + + $this->declareUsersForRecord(); + + User::repository()->builder()->with('customer')->as(IdNameRecord::class)->all(); + } + + private function declareUsersForRecord(): void + { + $this->pack()->nonPersist([ + new Customer(['id' => 1, 'name' => 'Customer 1']), + new Customer(['id' => 2, 'name' => 'Customer 2']), + new User([ + 'id' => 12, + 'name' => 'John', + 'roles' => ['2'], + 'customer' => new Customer(['id' => '1']), + ]), + new User([ + 'id' => 13, + 'name' => 'Mark', + 'roles' => ['5'], + 'customer' => new Customer(['id' => '2']), + ]), + new User([ + 'id' => 14, + 'name' => 'Paul', + 'roles' => ['5'], + 'customer' => new Customer(['id' => '1']), + ]), + ]); + } + public function test_with_custom_storage_type() { $this->pack()->declareEntity(EntityWithCustomStorageType::class); @@ -657,16 +1052,60 @@ public function __construct( ) {} } +class NameOnlyRecord +{ + public function __construct( + public readonly string $name, + ) {} +} + +class RecordWithRenamedProperty +{ + public function __construct( + #[Field('name')] + public readonly string $label, + ) {} +} + +class RecordWithTransformer +{ + public function __construct( + public readonly string $id, + #[Field(transformer: 'md5')] + public readonly string $name, + ) {} +} + class NameAndCustomer { public function __construct( public readonly string $name, - #[LoadRelation(Customer::class)] + #[LoadRelation] public readonly Customer $customer, ) {} } +class NameAndCustomerRecord +{ + public function __construct( + public readonly string $name, + + #[LoadRelation(Customer::class)] + public readonly CustomerRecord $customer, + ) {} +} + +final readonly class CustomerRecord +{ + public function __construct( + public int $id, + public string $name, + #[Field(expression: new Attribute('parentId', '%s IS NULL'))] + public bool $isParent, + ) {} +} + class NameAndDocuments { public function __construct( @@ -676,3 +1115,105 @@ public function __construct( public readonly array $documents, ) {} } + +class NameAndDocumentsRecord +{ + public function __construct( + public readonly string $name, + + #[LoadRelation(Document::class, as: DocumentRecord::class)] + public readonly array $documents, + ) {} +} + +final readonly class DocumentRecord +{ + public function __construct( + public int $id, + #[Field('contact.name')] + public string $contact, + ) {} +} + +final readonly class DocumentRecordWithSubRecord +{ + public function __construct( + public int $id, + + #[Embedded('')] + public UploaderRecord $uploader, + + #[Embedded] + public DocumentContactRecord $contact, + ) {} +} + +final readonly class DocumentContactRecord +{ + public function __construct( + public ?string $name, + + #[Field('location.address')] + public ?string $address, + + #[Field('location.city')] + public ?string $city, + ) {} +} + +final readonly class UploaderRecord +{ + public function __construct( + #[Field('uploaderId')] + public int $id, + + #[Field('uploaderType')] + public string $type, + ) {} +} + +final readonly class DocumentRecordWithNestedSubRecord +{ + public function __construct( + public int $id, + + #[Embedded] + public ContactRecord $contact, + ) {} +} + +final readonly class DocumentRecordWithSubRecordAndRelation +{ + public function __construct( + public int $id, + + #[Embedded] + public ContactRecord $contact, + + #[LoadRelation('customer', transformer: [self::class, 'customerName'])] + public string $customerName, + ) {} + + public static function customerName(Customer $customer): string + { + return $customer->name; + } +} + +final readonly class ContactRecord +{ + public function __construct( + public ?string $name, + + #[Embedded] + public LocationRecord $location, + ) {} +} + +final readonly class LocationRecord +{ + public function __construct( + public ?string $address, + public ?string $city, + ) {} +} diff --git a/tests/Query/Custom/KeyValue/KeyValueSqlCompilerTest.php b/tests/Query/Custom/KeyValue/KeyValueSqlCompilerTest.php index 85879e29..f32bd392 100644 --- a/tests/Query/Custom/KeyValue/KeyValueSqlCompilerTest.php +++ b/tests/Query/Custom/KeyValue/KeyValueSqlCompilerTest.php @@ -136,6 +136,61 @@ public function test_compileSelect_projection_expression() $this->assertEquals($this->connection->prepare('SELECT foreign_key || "-" || name as foo FROM test_'), $this->compiler->compileSelect($query)); } + /** + * + */ + public function test_compileSelect_addProjection() + { + $query = $this->query()->from('test_')->project('id')->addProjection('name'); + + $this->assertEquals($this->connection->prepare('SELECT id, name FROM test_'), $this->compiler->compileSelect($query)); + } + + /** + * + */ + public function test_compileSelect_addProjection_without_projection_should_be_ignored() + { + $query = $this->query()->from('test_')->addProjection('name'); + + $this->assertEquals($this->connection->prepare('SELECT * FROM test_'), $this->compiler->compileSelect($query)); + } + + /** + * + */ + public function test_compileSelect_addProjection_already_projected_should_be_ignored() + { + $query = $this->query()->from('test_')->project(['id', 'name'])->addProjection(['name', 'foreign_key']); + + $this->assertEquals($this->connection->prepare('SELECT id, name, foreign_key FROM test_'), $this->compiler->compileSelect($query)); + } + + /** + * + */ + public function test_compileSelect_addProjection_should_recompile_query() + { + $query = $this->query()->from('test_')->project('id'); + + $this->assertEquals($this->connection->prepare('SELECT id FROM test_'), $this->compiler->compileSelect($query)); + $this->assertEquals($this->connection->prepare('SELECT id, name FROM test_'), $this->compiler->compileSelect($query->addProjection('name'))); + } + + /** + * + */ + public function test_compileSelect_addProjection_with_orm_preprocessor() + { + $query = (new KeyValueQuery($this->connection, new OrmPreprocessor(User::repository()))) + ->from('user_') + ->project('customer.id') + ->addProjection(['faction.id', 'customer.id']) + ; + + $this->assertEquals($this->connection->prepare('SELECT customer_id, faction_id FROM user_'), $this->compiler->compileSelect($query)); + } + /** * */ diff --git a/tests/Query/QueryOrmTest.php b/tests/Query/QueryOrmTest.php index 38f3df2a..d1ef292f 100644 --- a/tests/Query/QueryOrmTest.php +++ b/tests/Query/QueryOrmTest.php @@ -16,6 +16,7 @@ use Bdf\Prime\Query\Expression\Raw; use Bdf\Prime\Query\Expression\RawValue; use Bdf\Prime\Query\Expression\Value; +use Bdf\Prime\Record\Field; use Bdf\Prime\Repository\RepositoryInterface; use Bdf\Prime\Right; use Bdf\Prime\TestEntity; @@ -1259,6 +1260,43 @@ public function test_reuse_query_with_constraint_should_always_add_constraints() $this->assertEquals('SELECT t0.* FROM entity_with_constraint t0 WHERE t0.name = ? AND (t0.enabled = ?)', $query->where('name', '')->toSql()); } + public function test_addProjection_without_projection_should_be_ignored() + { + $this->assertSame('SELECT t0.* FROM test_ t0', $this->query->addProjection('name')->toSql()); + } + + public function test_addProjection_should_resolve_attribute() + { + $this->assertSame( + 'SELECT t0.name, t0.foreign_key FROM test_ t0', + $this->query->select('name')->addProjection('foreign.id')->toSql() + ); + } + + public function test_addProjection_with_alias() + { + $this->assertSame( + 'SELECT t0.name, t0.foreign_key as foreignId FROM test_ t0', + $this->query->select('name')->addProjection(['foreignId' => 'foreign.id'])->toSql() + ); + } + + public function test_addProjection_already_projected_attribute_should_be_ignored() + { + $this->assertSame( + 'SELECT t0.id, t0.name FROM test_ t0', + $this->query->select(['id', 'name'])->addProjection(['name', 'id'])->toSql() + ); + } + + public function test_addProjection_should_invalidate_compiled_query() + { + $this->query->select('name'); + + $this->assertSame('SELECT t0.name FROM test_ t0', $this->query->toSql()); + $this->assertSame('SELECT t0.name, t0.id FROM test_ t0', $this->query->addProjection('id')->toSql()); + } + public function test_as_should_define_projection() { $r = new class('', '') { @@ -1270,4 +1308,137 @@ public function __construct( $this->assertSame('SELECT t0.id, t0.name FROM test_ t0', $this->query->as($r::class)->toSql()); } + + public function test_by_without_record_should_not_change_projection() + { + $this->assertSame('SELECT t0.* FROM test_ t0', $this->query->by('id')->toSql()); + } + + public function test_by_with_select_should_add_column() + { + $this->assertSame('SELECT t0.name, t0.id FROM test_ t0', $this->query->select('name')->by('id')->toSql()); + } + + public function test_by_with_select_column_already_present() + { + $this->assertSame('SELECT t0.id, t0.name FROM test_ t0', $this->query->select(['id', 'name'])->by('id')->toSql()); + } + + public function test_as_with_by_should_add_missing_attribute_on_projection() + { + $r = new class('') { + public function __construct( + public readonly string $name, + ) {} + }; + + $this->assertSame('SELECT t0.name, t0.id FROM test_ t0', $this->query->by('id')->as($r::class)->toSql()); + } + + public function test_as_then_by_should_add_missing_attribute_on_projection() + { + $r = new class('') { + public function __construct( + public readonly string $name, + ) {} + }; + + $this->assertSame('SELECT t0.name, t0.id FROM test_ t0', $this->query->as($r::class)->by('id')->toSql()); + } + + public function test_as_with_by_should_not_duplicate_projected_attribute() + { + $r = new class('', '') { + public function __construct( + public readonly string $id, + public readonly string $name, + ) {} + }; + + $this->assertSame('SELECT t0.id, t0.name FROM test_ t0', $this->query->by('name')->as($r::class)->toSql()); + } + + public function test_as_then_by_should_not_duplicate_projected_attribute() + { + $r = new class('', '') { + public function __construct( + public readonly string $id, + public readonly string $name, + ) {} + }; + + $this->assertSame('SELECT t0.id, t0.name FROM test_ t0', $this->query->as($r::class)->by('name')->toSql()); + } + + public function test_as_with_by_on_aliased_attribute_should_add_attribute_on_projection() + { + $r = new class('') { + public function __construct( + public readonly string $name, + ) {} + }; + + $this->assertSame('SELECT t0.name, t0.foreign_key FROM test_ t0', $this->query->by('foreign.id')->as($r::class)->toSql()); + } + + /** + * A record field declared with an expression is projected as "alias => expression", + * so the attribute is only present as the *value* of the projection, not as a projected column. + * It must still be added to the select clause. + */ + public function test_as_with_by_on_expression_field_should_add_attribute_on_projection() + { + $r = new class('') { + public function __construct( + #[Field(expression: 'name')] + public readonly string $label, + ) {} + }; + + $this->assertSame('SELECT t0.name as label, t0.name FROM test_ t0', $this->query->by('name')->as($r::class)->toSql()); + } + + public function test_as_then_by_on_expression_field_should_add_attribute_on_projection() + { + $r = new class('') { + public function __construct( + #[Field(expression: 'name')] + public readonly string $label, + ) {} + }; + + $this->assertSame('SELECT t0.name as label, t0.name FROM test_ t0', $this->query->as($r::class)->by('name')->toSql()); + } + + public function test_as_with_by_on_expression_field_aliased_with_the_attribute_name_should_not_duplicate_projection() + { + $r = new class('') { + public function __construct( + #[Field('name', expression: new Raw('LOWER(name)'))] + public readonly string $name, + ) {} + }; + + $this->assertSame('SELECT LOWER(name) as name FROM test_ t0', $this->query->by('name')->as($r::class)->toSql()); + } + + /** + * Both call orders must index the records on the value of the by() attribute, + * not collapse them into a single bucket. + */ + public function test_as_and_by_on_expression_field_should_index_on_the_attribute_value() + { + $this->repository->insert(new TestEntity(['id' => 1, 'name' => 'John'])); + $this->repository->insert(new TestEntity(['id' => 2, 'name' => 'Mark'])); + + $r = new class('') { + public function __construct( + #[Field(expression: 'name')] + public readonly string $label, + ) {} + }; + + $this->assertSame(['John', 'Mark'], array_keys($this->repository->builder()->by('name')->as($r::class)->all())); + $this->assertSame(['John', 'Mark'], array_keys($this->repository->builder()->as($r::class)->by('name')->all())); + } } diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index f73e51e7..0bcfd8ee 100755 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -17,6 +17,7 @@ use Bdf\Prime\Query\Expression\Now; use Bdf\Prime\Query\Expression\Raw; use Bdf\Prime\Query\Factory\QueryFactoryInterface; +use Bdf\Prime\Record\Embedded; use Bdf\Prime\Record\Field; use Bdf\Prime\Types\TypeInterface; use DateTime; @@ -1629,6 +1630,132 @@ public function test_count_alias() ); } + public function test_addProjection_without_projection_should_be_ignored() + { + $this->assertEquals('SELECT * FROM test_', $this->query()->addProjection('name')->toSql()); + $this->assertEquals('SELECT * FROM test_', $this->query()->select()->addProjection('name')->toSql()); + $this->assertEquals('SELECT * FROM test_', $this->query()->select('*')->addProjection('name')->toSql()); + $this->assertEquals('SELECT * FROM test_', $this->query()->select(['*'])->addProjection(['name', 'id'])->toSql()); + } + + public function test_addProjection_should_return_this() + { + $query = $this->query(); + + $this->assertSame($query, $query->addProjection('name')); + $this->assertSame($query, $query->select('id')->addProjection('name')); + } + + public function test_addProjection_single_column() + { + $this->assertEquals( + 'SELECT id, name FROM test_', + $this->query()->select('id')->addProjection('name')->toSql() + ); + } + + public function test_addProjection_multiple_columns() + { + $this->assertEquals( + 'SELECT id, name, date_insert FROM test_', + $this->query()->select('id')->addProjection(['name', 'date_insert'])->toSql() + ); + } + + public function test_addProjection_with_alias() + { + $this->assertEquals( + 'SELECT id, name as myName FROM test_', + $this->query()->select('id')->addProjection(['myName' => 'name'])->toSql() + ); + } + + public function test_addProjection_with_expression() + { + $this->assertEquals( + 'SELECT id, MAX(id) as maxId FROM test_', + $this->query()->select('id')->addProjection(['maxId' => new Raw('MAX(id)')])->toSql() + ); + } + + public function test_addProjection_already_projected_column_should_be_ignored() + { + $this->assertEquals( + 'SELECT id, name FROM test_', + $this->query()->select(['id', 'name'])->addProjection('name')->toSql() + ); + + $this->assertEquals( + 'SELECT id, name FROM test_', + $this->query()->select(['id', 'name'])->addProjection(['id', 'name'])->toSql() + ); + + $this->assertEquals( + 'SELECT id, name, date_insert FROM test_', + $this->query()->select(['id', 'name'])->addProjection(['name', 'date_insert'])->toSql() + ); + } + + public function test_addProjection_already_projected_column_with_same_alias_should_be_ignored() + { + $this->assertEquals( + 'SELECT name as myName FROM test_', + $this->query()->select(['myName' => 'name'])->addProjection(['myName' => 'name'])->toSql() + ); + } + + public function test_addProjection_already_projected_column_with_other_alias_should_be_added() + { + $this->assertEquals( + 'SELECT name, name as myName FROM test_', + $this->query()->select('name')->addProjection(['myName' => 'name'])->toSql() + ); + + $this->assertEquals( + 'SELECT name as myName, name as otherName FROM test_', + $this->query()->select(['myName' => 'name'])->addProjection(['otherName' => 'name'])->toSql() + ); + } + + public function test_addProjection_already_projected_expression_should_be_ignored() + { + $this->assertEquals( + 'SELECT id, MAX(id) as maxId FROM test_', + $this->query() + ->select(['id', 'maxId' => new Raw('MAX(id)')]) + ->addProjection(['maxId' => new Raw('MAX(id)')]) + ->toSql() + ); + } + + public function test_addProjection_should_invalidate_compiled_query() + { + $query = $this->query()->select('id'); + + $this->assertEquals('SELECT id FROM test_', $query->toSql()); + $this->assertEquals('SELECT id, name FROM test_', $query->addProjection('name')->toSql()); + $this->assertEquals('SELECT id, name FROM test_', $query->addProjection('id')->toSql()); + } + + public function test_addProjection_then_select_should_reset_projection() + { + $query = $this->query()->select('id')->addProjection('name'); + + $this->assertEquals('SELECT id, name FROM test_', $query->toSql()); + $this->assertEquals('SELECT date_insert FROM test_', $query->select('date_insert')->toSql()); + } + + public function test_addProjection_and_execute() + { + $this->push(['id' => 1, 'name' => 'John']); + $this->push(['id' => 2, 'name' => 'Mickey']); + + $this->assertEquals([ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Mickey'], + ], $this->query()->select('id')->addProjection('name')->order('id')->all()); + } + public function test_whereReplace() { $query = $this->query()->whereReplace('id', 1); @@ -2045,6 +2172,58 @@ public function __construct( $this->assertNull($records[2]->createdAt); } + /** + * + */ + public function test_record_with_embedded_without_prefix() + { + $this->push([ + 'id' => 1, + 'name' => 'test-name1', + 'date_insert' => new \DateTime('2025-01-21 12:00:00'), + ]); + $this->push([ + 'id' => 2, + 'name' => 'test-name2', + ]); + + $query = $this->query()->as(DbalRecordWithFlatEmbedded::class); + $records = $query->all(); + + $this->assertSame('SELECT id, name, date_insert FROM test_', $query->toSql()); + $this->assertContainsOnly(DbalRecordWithFlatEmbedded::class, $records); + $this->assertEquals([ + new DbalRecordWithFlatEmbedded(1, new DbalEmbeddedData('test-name1', new DateTime('2025-01-21 12:00:00'))), + new DbalRecordWithFlatEmbedded(2, new DbalEmbeddedData('test-name2', null)), + ], $records); + } + + /** + * + */ + public function test_record_with_embedded_prefix() + { + $this->push([ + 'id' => 1, + 'name' => 'test-name1', + 'date_insert' => new \DateTime('2025-01-21 12:00:00'), + ]); + $this->push([ + 'id' => 2, + 'name' => 'test-name2', + ]); + + $query = $this->query()->as(DbalRecordWithPrefixedEmbedded::class); + $records = $query->all(); + + $this->assertSame('SELECT id, date_insert FROM test_', $query->toSql()); + $this->assertContainsOnly(DbalRecordWithPrefixedEmbedded::class, $records); + $this->assertEquals([ + new DbalRecordWithPrefixedEmbedded(1, new DbalEmbeddedDate(new DateTime('2025-01-21 12:00:00'))), + new DbalRecordWithPrefixedEmbedded(2, new DbalEmbeddedDate(null)), + ], $records); + } + public function test_where_filter_entry() { $this->push([ @@ -2169,3 +2348,41 @@ public function getIterator(): Generator $this->assertSame('SELECT * FROM test_ WHERE id > ? OR name LIKE ?', $query->toSql()); } } + +class DbalRecordWithFlatEmbedded +{ + public function __construct( + public readonly int $id = 0, + + #[Embedded('')] + public readonly ?DbalEmbeddedData $data = null, + ) {} +} + +class DbalEmbeddedData +{ + public function __construct( + public readonly ?string $name = null, + + #[Field('date_insert', type: TypeInterface::DATETIME)] + public readonly ?DateTime $createdAt = null, + ) {} +} + +class DbalRecordWithPrefixedEmbedded +{ + public function __construct( + public readonly int $id = 0, + + #[Embedded('date_')] + public readonly ?DbalEmbeddedDate $date = null, + ) {} +} + +class DbalEmbeddedDate +{ + public function __construct( + #[Field(type: TypeInterface::DATETIME)] + public readonly ?DateTime $insert = null, + ) {} +} diff --git a/tests/Record/EmbeddedTest.php b/tests/Record/EmbeddedTest.php new file mode 100644 index 00000000..1bbd6ff9 --- /dev/null +++ b/tests/Record/EmbeddedTest.php @@ -0,0 +1,317 @@ +unsetPrime(); + } + + public function test_without_attribute() + { + $this->assertNull(Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'id'))); + $this->assertNull(Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'id'), ['id' => ['field' => 'id_', 'type' => 'bigint']])); + } + + public function test_dbal_default_prefix() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'sub')); + + $this->assertSame('sub_', $embedded->prefix); + $this->assertSame(EmbeddedSubRecord::class, $embedded->className); + $this->assertSame(EmbeddedSubRecord::class, $embedded->instantiator->recordClass); + $this->assertEquals([ + new Field('sub_name', castType: CastType::String, nullable: false), + new Field('sub_value', castType: CastType::Integer, nullable: true), + ], $embedded->instantiator->fields); + + $this->assertSame(['sub_name', 'sub_value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedSubRecord('foo', 42), + $embedded->value($this->createMock(PlatformInterface::class), ['id' => 1, 'sub_name' => 'foo', 'sub_value' => '42']) + ); + } + + public function test_orm_default_prefix() + { + $this->configurePrime(); + $platform = $this->prime()->connection('test')->platform(); + + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'sub'), [ + 'id' => ['field' => 'id_', 'type' => 'bigint'], + 'sub.name' => ['field' => 'sub_name_', 'type' => 'string'], + 'sub.value' => ['field' => 'sub_value_', 'type' => 'integer'], + ]); + + $this->assertSame('sub.', $embedded->prefix); + $this->assertSame(EmbeddedSubRecord::class, $embedded->className); + $this->assertEquals([ + new Field('sub_name_', type: 'string', castType: CastType::String, nullable: false, projection: 'sub.name'), + new Field('sub_value_', type: 'integer', castType: CastType::Integer, nullable: true, projection: 'sub.value'), + ], $embedded->instantiator->fields); + + $this->assertSame(['sub.name', 'sub.value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedSubRecord('foo', 42), + $embedded->value($platform, ['id_' => 1, 'sub_name_' => 'foo', 'sub_value_' => '42']) + ); + } + + public function test_explicit_prefix() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'withPrefix')); + + $this->assertSame('other_', $embedded->prefix); + $this->assertSame(['other_name', 'other_value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedSubRecord('foo', 42), + $embedded->value($this->createMock(PlatformInterface::class), ['other_name' => 'foo', 'other_value' => '42']) + ); + } + + public function test_empty_prefix() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'flat')); + + $this->assertSame('', $embedded->prefix); + $this->assertSame(['name', 'value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedSubRecord('foo', 42), + $embedded->value($this->createMock(PlatformInterface::class), ['name' => 'foo', 'value' => '42']) + ); + } + + public function test_explicit_class_name() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'untyped')); + + $this->assertSame('untyped_', $embedded->prefix); + $this->assertSame(EmbeddedSubRecord::class, $embedded->className); + $this->assertSame(['untyped_name', 'untyped_value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedSubRecord('foo', 42), + $embedded->value($this->createMock(PlatformInterface::class), ['untyped_name' => 'foo', 'untyped_value' => '42']) + ); + } + + public function test_class_name_should_take_precedence_over_the_parameter_type() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'withClassName')); + + $this->assertSame(EmbeddedOtherSubRecord::class, $embedded->className); + $this->assertSame(['withClassName_foo'], $embedded->projection()); + $this->assertEquals( + new EmbeddedOtherSubRecord('bar'), + $embedded->value($this->createMock(PlatformInterface::class), ['withClassName_foo' => 'bar']) + ); + } + + public function test_field_prefix() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'sub'), null, 'parent_'); + + $this->assertSame('parent_sub_', $embedded->prefix); + $this->assertSame(['parent_sub_name', 'parent_sub_value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedSubRecord('foo', 42), + $embedded->value($this->createMock(PlatformInterface::class), ['parent_sub_name' => 'foo', 'parent_sub_value' => '42']) + ); + } + + public function test_field_prefix_with_explicit_prefix() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'withPrefix'), null, 'parent_'); + + $this->assertSame('parent_other_', $embedded->prefix); + $this->assertSame(['parent_other_name', 'parent_other_value'], $embedded->projection()); + } + + public function test_field_prefix_on_orm() + { + $this->configurePrime(); + $platform = $this->prime()->connection('test')->platform(); + + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'sub'), [ + 'parent.sub.name' => ['field' => 'sub_name_', 'type' => 'string'], + 'parent.sub.value' => ['field' => 'sub_value_', 'type' => 'integer'], + ], 'parent.'); + + $this->assertSame('parent.sub.', $embedded->prefix); + $this->assertSame(['parent.sub.name', 'parent.sub.value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedSubRecord('foo', 42), + $embedded->value($platform, ['sub_name_' => 'foo', 'sub_value_' => '42']) + ); + } + + public function test_nested_embedded_on_dbal() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedNestedHolder::class, 'nested')); + + $this->assertSame('nested_', $embedded->prefix); + $this->assertSame(['nested_id', 'nested_sub_name', 'nested_sub_value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedNestedSubRecord(1, new EmbeddedSubRecord('foo', 42)), + $embedded->value($this->createMock(PlatformInterface::class), [ + 'nested_id' => '1', + 'nested_sub_name' => 'foo', + 'nested_sub_value' => '42', + ]) + ); + } + + public function test_nested_embedded_on_orm() + { + $this->configurePrime(); + $platform = $this->prime()->connection('test')->platform(); + + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedNestedHolder::class, 'nested'), [ + 'nested.id' => ['field' => 'nested_id_', 'type' => 'bigint'], + 'nested.sub.name' => ['field' => 'sub_name_', 'type' => 'string'], + 'nested.sub.value' => ['field' => 'sub_value_', 'type' => 'integer'], + ]); + + $this->assertSame('nested.', $embedded->prefix); + $this->assertSame(['nested.id', 'nested.sub.name', 'nested.sub.value'], $embedded->projection()); + $this->assertEquals( + new EmbeddedNestedSubRecord(1, new EmbeddedSubRecord('foo', 42)), + $embedded->value($platform, [ + 'nested_id_' => '1', + 'sub_name_' => 'foo', + 'sub_value_' => '42', + ]) + ); + } + + public function test_custom_instantiator() + { + $embedded = Embedded::fromReflectionParameter($this->parameter(EmbeddedHolder::class, 'withInstantiator')); + + $this->assertSame('withInstantiator_', $embedded->prefix); + $this->assertSame(EmbeddedOtherSubRecord::class, $embedded->instantiator->recordClass); + $this->assertSame(['custom_foo'], $embedded->projection()); + $this->assertEquals( + new EmbeddedOtherSubRecord('bar'), + $embedded->value($this->createMock(PlatformInterface::class), ['custom_foo' => 'bar']) + ); + } + + public function test_error_missing_type() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The parameter missingType on Bdf\Prime\Record\EmbeddedInvalidHolder must have a type or the #[Embedded] attribute must define a className.'); + + Embedded::fromReflectionParameter($this->parameter(EmbeddedInvalidHolder::class, 'missingType')); + } + + public function test_error_builtin_type() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The parameter builtinType on Bdf\Prime\Record\EmbeddedInvalidHolder must have a type or the #[Embedded] attribute must define a className.'); + + Embedded::fromReflectionParameter($this->parameter(EmbeddedInvalidHolder::class, 'builtinType')); + } + + public function test_error_union_type() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The parameter unionType on Bdf\Prime\Record\EmbeddedInvalidHolder must have a type or the #[Embedded] attribute must define a className.'); + + Embedded::fromReflectionParameter($this->parameter(EmbeddedInvalidHolder::class, 'unionType')); + } + + private function parameter(string $class, string $name): ReflectionParameter + { + foreach (new ReflectionClass($class)->getConstructor()->getParameters() as $parameter) { + if ($parameter->getName() === $name) { + return $parameter; + } + } + + throw new InvalidArgumentException(sprintf('The parameter %s is not defined on %s', $name, $class)); + } +} + +class EmbeddedSubRecord +{ + public function __construct( + public readonly string $name, + public readonly ?int $value, + ) {} +} + +class EmbeddedOtherSubRecord +{ + public function __construct( + public readonly ?string $foo, + ) {} +} + +class EmbeddedNestedSubRecord +{ + public function __construct( + public readonly int $id, + #[Embedded] + public readonly EmbeddedSubRecord $sub, + ) {} +} + +class EmbeddedHolder +{ + public function __construct( + public readonly int $id, + + #[Embedded] + public readonly EmbeddedSubRecord $sub, + + #[Embedded('other_')] + public readonly EmbeddedSubRecord $withPrefix, + + #[Embedded('')] + public readonly EmbeddedSubRecord $flat, + + #[Embedded(className: EmbeddedSubRecord::class)] + public readonly mixed $untyped, + + #[Embedded(className: EmbeddedOtherSubRecord::class)] + public readonly object $withClassName, + + #[Embedded(instantiator: new RecordInstantiator(EmbeddedOtherSubRecord::class, [new Field('custom_foo')]))] + public readonly EmbeddedOtherSubRecord $withInstantiator, + ) {} +} + +class EmbeddedNestedHolder +{ + public function __construct( + #[Embedded] + public readonly EmbeddedNestedSubRecord $nested, + ) {} +} + +class EmbeddedInvalidHolder +{ + public function __construct( + #[Embedded] + public readonly mixed $missingType, + + #[Embedded] + public readonly string $builtinType, + + #[Embedded] + public readonly EmbeddedSubRecord|EmbeddedOtherSubRecord $unionType, + ) {} +} diff --git a/tests/Record/FieldTest.php b/tests/Record/FieldTest.php index 185f95cc..bcd83a6f 100644 --- a/tests/Record/FieldTest.php +++ b/tests/Record/FieldTest.php @@ -2,13 +2,26 @@ namespace Record; +use Bdf\Prime\Platform\PlatformInterface; +use Bdf\Prime\PrimeTestCase; use Bdf\Prime\Query\Expression\Raw; use Bdf\Prime\Record\CastType; use Bdf\Prime\Record\Field; +use Bdf\Prime\Types\TypeInterface; use PHPUnit\Framework\TestCase; +use function strrev; +use function strtoupper; + class FieldTest extends TestCase { + use PrimeTestCase; + + protected function tearDown(): void + { + $this->unsetPrime(); + } + public function test_cast() { $this->assertSame('123', (new Field())->cast('123')); @@ -17,6 +30,8 @@ public function test_cast() $this->assertSame(null, (new Field(castType: CastType::Integer))->cast('')); $this->assertSame(0, (new Field(castType: CastType::Integer, nullable: false))->cast('')); $this->assertSame(null, (new Field(castType: CastType::Integer, nullable: true))->cast('')); + $this->assertSame('321', (new Field(transformer: strrev(...)))->cast('123')); + $this->assertSame(321, (new Field(castType: CastType::Integer, transformer: strrev(...)))->cast('123')); } public function test_empty() @@ -209,4 +224,142 @@ public function __construct( $this->assertEquals(new Raw('expression'), $field->expression); $this->assertNull($field->type); } + + public function test_projection() + { + $this->assertSame(['name'], (new Field('name'))->projection()); + $this->assertSame(['alias'], (new Field('name', projection: 'alias'))->projection()); + $this->assertSame([], (new Field('name', projection: false))->projection()); + } + + public function test_projection_with_expression() + { + $this->assertEquals(['name' => new Raw('expression')], (new Field('name', expression: new Raw('expression')))->projection()); + $this->assertEquals(['alias' => new Raw('expression')], (new Field('name', expression: new Raw('expression'), projection: 'alias'))->projection()); + $this->assertSame([], (new Field('name', expression: new Raw('expression'), projection: false))->projection()); + $this->assertSame(['name' => 'other_field'], (new Field('name', expression: 'other_field'))->projection()); + } + + public function test_value() + { + $platform = $this->createMock(PlatformInterface::class); + + $this->assertSame('bar', (new Field('foo'))->value($platform, ['foo' => 'bar', 'other' => 'baz'])); + $this->assertNull((new Field('foo'))->value($platform, ['other' => 'baz'])); + $this->assertNull((new Field('foo'))->value($platform, ['foo' => null])); + } + + public function test_value_with_cast() + { + $platform = $this->createMock(PlatformInterface::class); + + $this->assertSame(123, (new Field('foo', castType: CastType::Integer))->value($platform, ['foo' => '123'])); + $this->assertNull((new Field('foo', castType: CastType::Integer))->value($platform, [])); + $this->assertSame(0, (new Field('foo', castType: CastType::Integer, nullable: false))->value($platform, [])); + } + + public function test_value_with_transformer() + { + $platform = $this->createMock(PlatformInterface::class); + + $this->assertSame('OOF', (new Field('foo', transformer: fn (?string $value) => strtoupper(strrev($value))))->value($platform, ['foo' => 'foo'])); + } + + public function test_value_with_type() + { + $this->configurePrime(); + $platform = $this->prime()->connection('test')->platform(); + + $this->assertEquals( + new \DateTime('2025-02-01 15:25:03'), + (new Field('foo', type: TypeInterface::DATETIME))->value($platform, ['foo' => '2025-02-01 15:25:03']) + ); + $this->assertNull((new Field('foo', type: TypeInterface::DATETIME))->value($platform, [])); + $this->assertSame(123, (new Field('foo', type: TypeInterface::INTEGER))->value($platform, ['foo' => '123'])); + } + + public function test_value_with_type_and_transformer() + { + $this->configurePrime(); + $platform = $this->prime()->connection('test')->platform(); + + $this->assertSame( + '2025-02-01', + (new Field('foo', type: TypeInterface::DATETIME, transformer: fn (?\DateTimeInterface $date) => $date?->format('Y-m-d'))) + ->value($platform, ['foo' => '2025-02-01 15:25:03']) + ); + } + + public function test_withAttributesMetadata() + { + $field = (new Field('name', castType: CastType::String, nullable: false))->withAttributesMetadata([ + 'name' => ['field' => 'name_', 'type' => 'string'], + 'other' => ['field' => 'other_', 'type' => 'integer'], + ]); + + $this->assertEquals(new Field('name_', type: 'string', castType: CastType::String, nullable: false, projection: 'name'), $field); + } + + public function test_withAttributesMetadata_with_unknown_attribute() + { + $field = (new Field('name', castType: CastType::String, nullable: false))->withAttributesMetadata([ + 'other' => ['field' => 'other_', 'type' => 'integer'], + ]); + + $this->assertEquals(new Field('name', castType: CastType::String, nullable: false, projection: 'name'), $field); + } + + public function test_withAttributesMetadata_should_keep_explicit_type() + { + $field = (new Field('name', type: 'json', castType: CastType::Mixed))->withAttributesMetadata([ + 'name' => ['field' => 'name_', 'type' => 'string'], + ]); + + $this->assertEquals(new Field('name_', type: 'json', castType: CastType::Mixed, projection: 'name'), $field); + } + + public function test_withAttributesMetadata_with_string_expression() + { + $field = (new Field('alias', expression: 'other'))->withAttributesMetadata([ + 'alias' => ['field' => 'alias_', 'type' => 'string'], + 'other' => ['field' => 'other_', 'type' => 'integer'], + ]); + + $this->assertEquals(new Field('alias_', expression: 'other', type: 'integer', projection: 'alias'), $field); + } + + public function test_withAttributesMetadata_with_object_expression() + { + $field = (new Field('alias', expression: new Raw('COUNT(*)')))->withAttributesMetadata([ + 'alias' => ['field' => 'alias_', 'type' => 'string'], + ]); + + $this->assertEquals(new Field('alias_', expression: new Raw('COUNT(*)'), projection: 'alias'), $field); + } + + /** + * The projection explicitly defined on the attribute must not be overwritten by the attribute name + */ + public function test_withAttributesMetadata_should_keep_custom_projection() + { + $field = (new Field('name', projection: 't2.name'))->withAttributesMetadata([ + 'name' => ['field' => 'name_', 'type' => 'string'], + ]); + + $this->assertSame('t2.name', $field->projection); + $this->assertSame(['t2.name'], $field->projection()); + } + + /** + * A disabled projection must not be re-enabled by the attributes metadata resolution + */ + public function test_withAttributesMetadata_should_keep_disabled_projection() + { + $field = (new Field('name', projection: false))->withAttributesMetadata([ + 'name' => ['field' => 'name_', 'type' => 'string'], + ]); + + $this->assertFalse($field->projection); + $this->assertSame([], $field->projection()); + } } diff --git a/tests/Record/RecordInstantiatorTest.php b/tests/Record/RecordInstantiatorTest.php index 182f7d33..cec0ae50 100644 --- a/tests/Record/RecordInstantiatorTest.php +++ b/tests/Record/RecordInstantiatorTest.php @@ -23,8 +23,8 @@ public function test_simple() $this->assertSame(SimpleRecord::class, $instantiator->recordClass); $this->assertEquals([ - 'name' => new Field('name', castType: CastType::String, nullable: false), - 'value' => new Field('value', castType: CastType::Integer, nullable: false), + new Field('name', castType: CastType::String, nullable: false), + new Field('value', castType: CastType::Integer, nullable: false), ], $instantiator->fields); $this->assertSame(['name', 'value'], $instantiator->projection()); @@ -37,8 +37,8 @@ public function test_name_mapping() $this->assertSame(RecordWithNameMapping::class, $instantiator->recordClass); $this->assertEquals([ - 'name' => new Field('_name', castType: CastType::String, nullable: false), - 'value' => new Field('_value', castType: CastType::Integer, nullable: false), + new Field('_name', castType: CastType::String, nullable: false), + new Field('_value', castType: CastType::Integer, nullable: false), ], $instantiator->fields); $this->assertSame(['_name', '_value'], $instantiator->projection()); @@ -54,8 +54,8 @@ public function test_dbal_type() $this->assertSame(RecordWithDbalType::class, $instantiator->recordClass); $this->assertEquals([ - 'name' => new Field('name', castType: CastType::String, nullable: false), - 'value' => new Field('value', type: 'datetime', castType: CastType::Mixed, nullable: false), + new Field('name', castType: CastType::String, nullable: false), + new Field('value', type: 'datetime', castType: CastType::Mixed, nullable: false), ], $instantiator->fields); $this->assertSame(['name', 'value'], $instantiator->projection()); @@ -68,8 +68,8 @@ public function test_with_expression() $this->assertSame(RecordWithExpression::class, $instantiator->recordClass); $this->assertEquals([ - 'name' => new Field('name', castType: CastType::String, nullable: false), - 'value' => new Field('value', expression: new Raw('foo'), castType: CastType::Integer, nullable: false), + new Field('name', castType: CastType::String, nullable: false), + new Field('value', expression: new Raw('foo'), castType: CastType::Integer, nullable: false), ], $instantiator->fields); $this->assertEquals(['name', 'value' => new Raw('foo')], $instantiator->projection()); @@ -82,13 +82,200 @@ public function test_with_projection() $this->assertSame(RecordWithCustomProjection::class, $instantiator->recordClass); $this->assertEquals([ - 'name' => new Field('name', castType: CastType::String, nullable: false, projection: 't2.jajajaja'), - 'value' => new Field('value', castType: CastType::Integer, nullable: false, projection: false), + new Field('name', castType: CastType::String, nullable: false, projection: 't2.jajajaja'), + new Field('value', castType: CastType::Integer, nullable: false, projection: false), ], $instantiator->fields); $this->assertEquals(['t2.jajajaja'], $instantiator->projection()); $this->assertEquals(new RecordWithCustomProjection('foo', 123), $instantiator->instantiate(['name' => 'foo', 'value' => '123'], $this->createMock(PlatformInterface::class))); } + + public function test_error_without_constructor() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The record class Bdf\Prime\Record\RecordWithoutConstructor must have a constructor'); + + RecordInstantiator::fromRecordClass(RecordWithoutConstructor::class); + } + + public function test_with_field_prefix() + { + $instantiator = RecordInstantiator::fromRecordClass(SimpleRecord::class, fieldPrefix: 'foo_'); + + $this->assertEquals([ + new Field('foo_name', castType: CastType::String, nullable: false), + new Field('foo_value', castType: CastType::Integer, nullable: false), + ], $instantiator->fields); + + $this->assertSame(['foo_name', 'foo_value'], $instantiator->projection()); + $this->assertEquals( + new SimpleRecord('foo', 123), + $instantiator->instantiate(['foo_name' => 'foo', 'foo_value' => 123, 'name' => 'ignored'], $this->createMock(PlatformInterface::class)) + ); + } + + public function test_with_empty_field_prefix() + { + $instantiator = RecordInstantiator::fromRecordClass(SimpleRecord::class, fieldPrefix: ''); + + $this->assertEquals([ + new Field('name', castType: CastType::String, nullable: false), + new Field('value', castType: CastType::Integer, nullable: false), + ], $instantiator->fields); + + $this->assertSame(['name', 'value'], $instantiator->projection()); + } + + public function test_with_field_prefix_should_be_applied_on_mapped_name() + { + $instantiator = RecordInstantiator::fromRecordClass(RecordWithNameMapping::class, fieldPrefix: 'foo_'); + + $this->assertEquals([ + new Field('foo__name', castType: CastType::String, nullable: false), + new Field('foo__value', castType: CastType::Integer, nullable: false), + ], $instantiator->fields); + + $this->assertSame(['foo__name', 'foo__value'], $instantiator->projection()); + } + + public function test_with_attributes_metadata() + { + $this->configurePrime(); + $platform = $this->prime()->connection('test')->platform(); + + $instantiator = RecordInstantiator::fromRecordClass(SimpleRecord::class, [ + 'name' => ['field' => 'name_', 'type' => 'string'], + 'value' => ['field' => 'value_', 'type' => 'integer'], + ]); + + $this->assertEquals([ + new Field('name_', type: 'string', castType: CastType::String, nullable: false, projection: 'name'), + new Field('value_', type: 'integer', castType: CastType::Integer, nullable: false, projection: 'value'), + ], $instantiator->fields); + + $this->assertSame(['name', 'value'], $instantiator->projection()); + $this->assertEquals(new SimpleRecord('foo', 123), $instantiator->instantiate(['name_' => 'foo', 'value_' => '123'], $platform)); + } + + public function test_with_attributes_metadata_and_field_prefix() + { + $this->configurePrime(); + $platform = $this->prime()->connection('test')->platform(); + + $instantiator = RecordInstantiator::fromRecordClass(SimpleRecord::class, [ + 'sub.name' => ['field' => 'name_', 'type' => 'string'], + 'sub.value' => ['field' => 'value_', 'type' => 'integer'], + ], 'sub.'); + + $this->assertEquals([ + new Field('name_', type: 'string', castType: CastType::String, nullable: false, projection: 'sub.name'), + new Field('value_', type: 'integer', castType: CastType::Integer, nullable: false, projection: 'sub.value'), + ], $instantiator->fields); + + $this->assertSame(['sub.name', 'sub.value'], $instantiator->projection()); + $this->assertEquals(new SimpleRecord('foo', 123), $instantiator->instantiate(['name_' => 'foo', 'value_' => '123'], $platform)); + } + + public function test_with_attributes_metadata_and_unknown_field() + { + $instantiator = RecordInstantiator::fromRecordClass(SimpleRecord::class, [ + 'name' => ['field' => 'name_', 'type' => 'string'], + ]); + + $this->assertEquals([ + new Field('name_', type: 'string', castType: CastType::String, nullable: false, projection: 'name'), + new Field('value', castType: CastType::Integer, nullable: false, projection: 'value'), + ], $instantiator->fields); + + $this->assertSame(['name', 'value'], $instantiator->projection()); + } + + /** + * The projection explicitly defined on the Field attribute (custom alias, or false to disable it) + * must be kept when the attributes metadata are resolved. + */ + public function test_with_attributes_metadata_should_keep_custom_projection() + { + $instantiator = RecordInstantiator::fromRecordClass(RecordWithCustomProjection::class, [ + 'name' => ['field' => 'name_', 'type' => 'string'], + 'value' => ['field' => 'value_', 'type' => 'integer'], + ]); + + $this->assertEquals(['t2.jajajaja'], $instantiator->projection()); + } + + public function test_with_embedded() + { + $instantiator = RecordInstantiator::fromRecordClass(RecordWithEmbedded::class); + + $this->assertSame(RecordWithEmbedded::class, $instantiator->recordClass); + $this->assertEquals([ + new Field('id', castType: CastType::Integer, nullable: false), + Embedded::fromReflectionParameter(new \ReflectionClass(RecordWithEmbedded::class)->getConstructor()->getParameters()[1]), + ], $instantiator->fields); + + $this->assertSame(['id', 'sub_name', 'sub_value'], $instantiator->projection()); + $this->assertEquals( + new RecordWithEmbedded(1, new SimpleRecord('foo', 123)), + $instantiator->instantiate(['id' => '1', 'sub_name' => 'foo', 'sub_value' => '123'], $this->createMock(PlatformInterface::class)) + ); + } + + public function test_with_embedded_and_field_prefix() + { + $instantiator = RecordInstantiator::fromRecordClass(RecordWithEmbedded::class, fieldPrefix: 'root_'); + + $this->assertSame(['root_id', 'root_sub_name', 'root_sub_value'], $instantiator->projection()); + $this->assertEquals( + new RecordWithEmbedded(1, new SimpleRecord('foo', 123)), + $instantiator->instantiate(['root_id' => '1', 'root_sub_name' => 'foo', 'root_sub_value' => '123'], $this->createMock(PlatformInterface::class)) + ); + } + + public function test_with_embedded_and_attributes_metadata() + { + $this->configurePrime(); + $platform = $this->prime()->connection('test')->platform(); + + $instantiator = RecordInstantiator::fromRecordClass(RecordWithEmbedded::class, [ + 'id' => ['field' => 'id_', 'type' => 'bigint'], + 'sub.name' => ['field' => 'sub_name_', 'type' => 'string'], + 'sub.value' => ['field' => 'sub_value_', 'type' => 'integer'], + ]); + + $this->assertSame(['id', 'sub.name', 'sub.value'], $instantiator->projection()); + $this->assertEquals( + new RecordWithEmbedded(1, new SimpleRecord('foo', 123)), + $instantiator->instantiate(['id_' => '1', 'sub_name_' => 'foo', 'sub_value_' => '123'], $platform) + ); + } + + public function test_with_flat_embedded() + { + $instantiator = RecordInstantiator::fromRecordClass(RecordWithFlatEmbedded::class); + + $this->assertSame(['id', 'name', 'value'], $instantiator->projection()); + $this->assertEquals( + new RecordWithFlatEmbedded(1, new SimpleRecord('foo', 123)), + $instantiator->instantiate(['id' => '1', 'name' => 'foo', 'value' => '123'], $this->createMock(PlatformInterface::class)) + ); + } + + public function test_with_nested_embedded() + { + $instantiator = RecordInstantiator::fromRecordClass(RecordWithNestedEmbedded::class); + + $this->assertSame(['id', 'root_id', 'root_sub_name', 'root_sub_value'], $instantiator->projection()); + $this->assertEquals( + new RecordWithNestedEmbedded(1, new RecordWithEmbedded(2, new SimpleRecord('foo', 123))), + $instantiator->instantiate([ + 'id' => '1', + 'root_id' => '2', + 'root_sub_name' => 'foo', + 'root_sub_value' => '123', + ], $this->createMock(PlatformInterface::class)) + ); + } } class SimpleRecord @@ -136,3 +323,34 @@ public function __construct( public readonly int $value, ) {} } + +class RecordWithoutConstructor +{ +} + +class RecordWithEmbedded +{ + public function __construct( + public readonly int $id, + #[Embedded] + public readonly SimpleRecord $sub, + ) {} +} + +class RecordWithFlatEmbedded +{ + public function __construct( + public readonly int $id, + #[Embedded('')] + public readonly SimpleRecord $sub, + ) {} +} + +class RecordWithNestedEmbedded +{ + public function __construct( + public readonly int $id, + #[Embedded] + public readonly RecordWithEmbedded $root, + ) {} +} diff --git a/tests/Record/RelationLoaderTest.php b/tests/Record/RelationLoaderTest.php index 0a4f85b1..5a5ca5ff 100644 --- a/tests/Record/RelationLoaderTest.php +++ b/tests/Record/RelationLoaderTest.php @@ -3,7 +3,10 @@ namespace Record; use Bdf\Prime\Customer; +use Bdf\Prime\CustomerPack; +use Bdf\Prime\Pack; use Bdf\Prime\PrimeTestCase; +use Bdf\Prime\Record\Field; use Bdf\Prime\Record\RelationLoader; use Bdf\Prime\User; use PHPUnit\Framework\TestCase; @@ -130,4 +133,222 @@ public function test_load_many_relation() ['id' => 404, 'u' => []], ], $loaded); } + + public function test_load_simple_relation_as_record() + { + $this->declareUsers(); + + $loader = new RelationLoader( + 'customer', + 'c', + 'customerId', + 'customer_id', + CustomerRecord::class + ); + + $rows = [ + ['id' => 1, 'customer_id' => 1], + ['id' => 2, 'customer_id' => 2], + ['id' => 3, 'customer_id' => 1], + ['id' => 4, 'customer_id' => 404], + ]; + + $loaded = $loader->load($this->prime()->repository(User::class), $rows); + + $this->assertEquals([ + ['id' => 1, 'customer_id' => 1, 'c' => new CustomerRecord(1, 'John Inc.')], + ['id' => 2, 'customer_id' => 2, 'c' => new CustomerRecord(2, 'Doe Ltd.')], + ['id' => 3, 'customer_id' => 1, 'c' => new CustomerRecord(1, 'John Inc.')], + ['id' => 4, 'customer_id' => 404, 'c' => null], + ], $loaded); + } + + public function test_load_simple_relation_as_record_with_field_mapping() + { + $this->declareUsers(); + + $loader = new RelationLoader( + 'customer', + 'c', + 'customerId', + 'customer_id', + CustomerRecordWithMapping::class + ); + + $rows = [ + ['id' => 1, 'customer_id' => 1], + ]; + + $loaded = $loader->load($this->prime()->repository(User::class), $rows); + + $this->assertEquals([ + ['id' => 1, 'customer_id' => 1, 'c' => new CustomerRecordWithMapping('JOHN INC.')], + ], $loaded); + } + + public function test_load_many_relation_as_record() + { + $this->declareUsers(); + + $loader = new RelationLoader( + 'users', + 'u', + 'id', + 'id', + UserRecord::class + ); + + $rows = [ + ['id' => 1], + ['id' => 2], + ['id' => 404], + ]; + + $loaded = $loader->load($this->prime()->repository(Customer::class), $rows); + + $this->assertEquals([ + ['id' => 1, 'u' => [new UserRecord(1, 'John Miller'), new UserRecord(3, 'Mickey Mouse')]], + ['id' => 2, 'u' => [new UserRecord(2, 'Jane Doe')]], + ['id' => 404, 'u' => []], + ], $loaded); + } + + public function test_load_belongsToMany_relation_as_record() + { + $this->pack()->nonPersist([ + new Customer(['id' => 1, 'name' => 'John Inc.']), + new Customer(['id' => 2, 'name' => 'Doe Ltd.']), + new Pack(['id' => 1, 'label' => 'Pack referencement']), + new Pack(['id' => 2, 'label' => 'Pack classic']), + new CustomerPack(['customerId' => 1, 'packId' => 1]), + new CustomerPack(['customerId' => 1, 'packId' => 2]), + new CustomerPack(['customerId' => 2, 'packId' => 2]), + ]); + + $loader = new RelationLoader( + 'packs', + 'p', + 'id', + 'id', + PackRecord::class + ); + + $rows = [ + ['id' => 1], + ['id' => 2], + ['id' => 404], + ]; + + $loaded = $loader->load($this->prime()->repository(Customer::class), $rows); + + $this->assertEquals([ + ['id' => 1, 'p' => [new PackRecord(1, 'Pack referencement'), new PackRecord(2, 'Pack classic')]], + ['id' => 2, 'p' => [new PackRecord(2, 'Pack classic')]], + ['id' => 404, 'p' => []], + ], $loaded); + } + + public function test_load_as_record_without_foreign_key() + { + $this->declareUsers(); + + $loader = new RelationLoader( + 'customer', + 'c', + 'customerId', + 'customer_id', + CustomerRecord::class + ); + + $rows = [ + ['id' => 1, 'customer_id' => null], + ['id' => 2], + ]; + + $loaded = $loader->load($this->prime()->repository(User::class), $rows); + + $this->assertEquals([ + ['id' => 1, 'customer_id' => null, 'c' => null], + ['id' => 2, 'c' => null], + ], $loaded); + } + + public function test_load_as_record_with_empty_rows() + { + $this->declareUsers(); + + $loader = new RelationLoader( + 'customer', + 'c', + 'customerId', + 'customer_id', + CustomerRecord::class + ); + + $this->assertSame([], $loader->load($this->prime()->repository(User::class), [])); + } + + private function declareUsers(): void + { + $this->pack()->nonPersist([ + $customer1 = new Customer([ + 'id' => 1, + 'name' => 'John Inc.', + ]), + $customer2 = new Customer([ + 'id' => 2, + 'name' => 'Doe Ltd.', + ]), + new User([ + 'id' => 1, + 'name' => 'John Miller', + 'customer' => $customer1, + 'roles' => ['admin', 'user'], + ]), + new User([ + 'id' => 2, + 'name' => 'Jane Doe', + 'customer' => $customer2, + 'roles' => ['admin', 'user'], + ]), + new User([ + 'id' => 3, + 'name' => 'Mickey Mouse', + 'customer' => $customer1, + 'roles' => ['user'], + ]), + ]); + } +} + +final readonly class CustomerRecord +{ + public function __construct( + public int $id, + public string $name, + ) {} +} + +final readonly class CustomerRecordWithMapping +{ + public function __construct( + #[Field('name', transformer: 'strtoupper')] + public string $label, + ) {} +} + +final readonly class UserRecord +{ + public function __construct( + public int $id, + public string $name, + ) {} +} + +final readonly class PackRecord +{ + public function __construct( + public int $id, + public string $label, + ) {} } diff --git a/tests/Record/RepositoryRecordHydratorTest.php b/tests/Record/RepositoryRecordHydratorTest.php index 686a7967..7bb406fb 100644 --- a/tests/Record/RepositoryRecordHydratorTest.php +++ b/tests/Record/RepositoryRecordHydratorTest.php @@ -3,11 +3,16 @@ namespace Bdf\Prime\Record; use Bdf\Prime\Customer; +use Bdf\Prime\Document; +use Bdf\Prime\Faction; use Bdf\Prime\PrimeTestCase; use Bdf\Prime\Test\TestPack; use Bdf\Prime\User; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use function sprintf; + class RepositoryRecordHydratorTest extends TestCase { use PrimeTestCase; @@ -74,7 +79,7 @@ public function test_base_entity() 'customer' => new Customer(['id' => 1]), 'roles' => ['admin', 'user'], ]), $hydrator->instantiate(User::class, $rows[0], $this->prime()->connection('test')->platform())); - $this->assertSame($rows, $hydrator->finalize(User::class, $rows)); + $this->assertSame($rows, $hydrator->finalize(User::class, $rows, $rows)); } public function test_simple() @@ -89,7 +94,7 @@ public function test_simple() $this->assertSame(['id', 'name'], $hydrator->projection(SimpleOrmRecord::class)); $this->assertSame($rows, $hydrator->prepare(SimpleOrmRecord::class, $rows)); $this->assertEquals(new SimpleOrmRecord(1, 'John Miller'), $hydrator->instantiate(SimpleOrmRecord::class, $rows[0], $this->prime()->connection('test')->platform())); - $this->assertSame($rows, $hydrator->finalize(SimpleOrmRecord::class, $rows)); + $this->assertSame($rows, $hydrator->finalize(SimpleOrmRecord::class, $rows, $rows)); } public function test_with_name_mapping() @@ -104,7 +109,7 @@ public function test_with_name_mapping() $this->assertSame(['id', 'name'], $hydrator->projection(OrmRecordWithNameMapping::class)); $this->assertSame($rows, $hydrator->prepare(OrmRecordWithNameMapping::class, $rows)); $this->assertEquals(new OrmRecordWithNameMapping(1, 'John Miller'), $hydrator->instantiate(OrmRecordWithNameMapping::class, $rows[0], $this->prime()->connection('test')->platform())); - $this->assertSame($rows, $hydrator->finalize(OrmRecordWithNameMapping::class, $rows)); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithNameMapping::class, $rows, $rows)); } public function test_with_relation() @@ -124,7 +129,158 @@ public function test_with_relation() $this->assertSame(['name', 'customer.id'], $hydrator->projection(OrmRecordWithRelation::class)); $this->assertEquals($expectedRows, $hydrator->prepare(OrmRecordWithRelation::class, $rows)); $this->assertEquals(new OrmRecordWithRelation('John Miller', new Customer(['id' => 1, 'name' => 'John Inc.'])), $hydrator->instantiate(OrmRecordWithRelation::class, $expectedRows[0], $this->prime()->connection('test')->platform())); - $this->assertSame($rows, $hydrator->finalize(OrmRecordWithRelation::class, $rows)); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithRelation::class, $rows, $rows)); + } + + public function test_with_relation_implicit_type() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ['id_' => 2, 'name_' => 'Jane Doe', 'customer_id' => 2, 'roles_' => ',admin,user,'], + ['id_' => 3, 'name_' => 'Mickey Mouse', 'customer_id' => 1, 'roles_' => ',user,'], + ]; + + $expectedRows = $rows; + $expectedRows[0]['customer'] = new Customer(['id' => 1, 'name' => 'John Inc.']); + $expectedRows[1]['customer'] = new Customer(['id' => 2, 'name' => 'Doe Ltd.']); + $expectedRows[2]['customer'] = new Customer(['id' => 1, 'name' => 'John Inc.']); + + $this->assertSame(['name', 'customer.id'], $hydrator->projection(OrmRecordWithRelationImplicit::class)); + $this->assertEquals($expectedRows, $hydrator->prepare(OrmRecordWithRelationImplicit::class, $rows)); + $this->assertEquals(new OrmRecordWithRelationImplicit('John Miller', new Customer(['id' => 1, 'name' => 'John Inc.'])), $hydrator->instantiate(OrmRecordWithRelationImplicit::class, $expectedRows[0], $this->prime()->connection('test')->platform())); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithRelationImplicit::class, $rows, $rows)); + } + + public function test_with_relation_transformer() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ['id_' => 2, 'name_' => 'Jane Doe', 'customer_id' => 2, 'roles_' => ',admin,user,'], + ['id_' => 3, 'name_' => 'Mickey Mouse', 'customer_id' => 1, 'roles_' => ',user,'], + ]; + + $expectedRows = $rows; + $expectedRows[0]['customer'] = new Customer(['id' => 1, 'name' => 'John Inc.']); + $expectedRows[1]['customer'] = new Customer(['id' => 2, 'name' => 'Doe Ltd.']); + $expectedRows[2]['customer'] = new Customer(['id' => 1, 'name' => 'John Inc.']); + + $this->assertSame(['name', 'customer.id'], $hydrator->projection(OrmRecordWithRelationTransformer::class)); + $this->assertEquals($expectedRows, $hydrator->prepare(OrmRecordWithRelationTransformer::class, $rows)); + $this->assertEquals(new OrmRecordWithRelationTransformer('John Miller', 'John Inc. (1)'), $hydrator->instantiate(OrmRecordWithRelationTransformer::class, $expectedRows[0], $this->prime()->connection('test')->platform())); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithRelationTransformer::class, $rows, $rows)); + } + + public function test_with_relation_as_record() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ['id_' => 2, 'name_' => 'Jane Doe', 'customer_id' => 2, 'roles_' => ',admin,user,'], + ['id_' => 3, 'name_' => 'Mickey Mouse', 'customer_id' => 1, 'roles_' => ',user,'], + ]; + + $expectedRows = $rows; + $expectedRows[0]['customer'] = new CustomerAsRecord(1, 'John Inc.'); + $expectedRows[1]['customer'] = new CustomerAsRecord(2, 'Doe Ltd.'); + $expectedRows[2]['customer'] = new CustomerAsRecord(1, 'John Inc.'); + + $this->assertSame(['name', 'customer.id'], $hydrator->projection(OrmRecordWithRelationAsRecord::class)); + $this->assertEquals($expectedRows, $hydrator->prepare(OrmRecordWithRelationAsRecord::class, $rows)); + $this->assertEquals(new OrmRecordWithRelationAsRecord('John Miller', new CustomerAsRecord(1, 'John Inc.')), $hydrator->instantiate(OrmRecordWithRelationAsRecord::class, $expectedRows[0], $this->prime()->connection('test')->platform())); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithRelationAsRecord::class, $rows, $rows)); + } + + public function test_with_relation_as_record_explicitly_defined() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ['id_' => 2, 'name_' => 'Jane Doe', 'customer_id' => 2, 'roles_' => ',admin,user,'], + ['id_' => 3, 'name_' => 'Mickey Mouse', 'customer_id' => 1, 'roles_' => ',user,'], + ]; + + $expectedRows = $rows; + $expectedRows[0]['customer'] = new CustomerAsRecord(1, 'John Inc.'); + $expectedRows[1]['customer'] = new CustomerAsRecord(2, 'Doe Ltd.'); + $expectedRows[2]['customer'] = new CustomerAsRecord(1, 'John Inc.'); + + $this->assertSame(['name', 'customer.id'], $hydrator->projection(OrmRecordWithRelationAsExplicitRecord::class)); + $this->assertEquals($expectedRows, $hydrator->prepare(OrmRecordWithRelationAsExplicitRecord::class, $rows)); + $this->assertEquals(new OrmRecordWithRelationAsExplicitRecord('John Miller', new CustomerAsRecord(1, 'John Inc.')), $hydrator->instantiate(OrmRecordWithRelationAsExplicitRecord::class, $expectedRows[0], $this->prime()->connection('test')->platform())); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithRelationAsExplicitRecord::class, $rows, $rows)); + } + + public function test_with_relation_as_record_and_transformer() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ['id_' => 2, 'name_' => 'Jane Doe', 'customer_id' => 2, 'roles_' => ',admin,user,'], + ['id_' => 3, 'name_' => 'Mickey Mouse', 'customer_id' => 1, 'roles_' => ',user,'], + ]; + + $expectedRows = $rows; + $expectedRows[0]['customer'] = new CustomerAsRecord(1, 'John Inc.'); + $expectedRows[1]['customer'] = new CustomerAsRecord(2, 'Doe Ltd.'); + $expectedRows[2]['customer'] = new CustomerAsRecord(1, 'John Inc.'); + + $this->assertSame(['name', 'customer.id'], $hydrator->projection(OrmRecordWithRelationAsRecordAndTransformer::class)); + $this->assertEquals($expectedRows, $hydrator->prepare(OrmRecordWithRelationAsRecordAndTransformer::class, $rows)); + $this->assertEquals(new OrmRecordWithRelationAsRecordAndTransformer('John Miller', 'John Inc. (1)'), $hydrator->instantiate(OrmRecordWithRelationAsRecordAndTransformer::class, $expectedRows[0], $this->prime()->connection('test')->platform())); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithRelationAsRecordAndTransformer::class, $rows, $rows)); + } + + public function test_with_nullable_relation_as_record() + { + $this->pack()->nonPersist([ + new Faction([ + 'id' => 1, + 'name' => 'Sith', + 'domain' => 'user', + 'enabled' => true, + ]), + ]); + + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'faction_id' => 1, 'roles_' => ',admin,user,'], + ['id_' => 2, 'name_' => 'Jane Doe', 'customer_id' => 2, 'faction_id' => null, 'roles_' => ',admin,user,'], + ]; + + $expectedRows = $rows; + $expectedRows[0]['faction'] = new FactionAsRecord(1, 'Sith'); + $expectedRows[1]['faction'] = null; + + $this->assertSame(['name', 'faction.id'], $hydrator->projection(OrmRecordWithNullableRelationAsRecord::class)); + $this->assertEquals($expectedRows, $hydrator->prepare(OrmRecordWithNullableRelationAsRecord::class, $rows)); + $this->assertEquals(new OrmRecordWithNullableRelationAsRecord('John Miller', new FactionAsRecord(1, 'Sith')), $hydrator->instantiate(OrmRecordWithNullableRelationAsRecord::class, $expectedRows[0], $this->prime()->connection('test')->platform())); + $this->assertEquals(new OrmRecordWithNullableRelationAsRecord('Jane Doe', null), $hydrator->instantiate(OrmRecordWithNullableRelationAsRecord::class, $expectedRows[1], $this->prime()->connection('test')->platform())); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithNullableRelationAsRecord::class, $rows, $rows)); + } + + public function test_with_collection_relation_as_record() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(Customer::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Inc.'], + ['id_' => 2, 'name_' => 'Doe Ltd.'], + ['id_' => 404, 'name_' => 'Unknown'], + ]; + + $expectedRows = $rows; + $expectedRows[0]['users'] = [new UserAsRecord(1, 'John Miller'), new UserAsRecord(3, 'Mickey Mouse')]; + $expectedRows[1]['users'] = [new UserAsRecord(2, 'Jane Doe')]; + $expectedRows[2]['users'] = []; + + $this->assertSame(['name', 'id'], $hydrator->projection(OrmRecordWithCollectionRelationAsRecord::class)); + $this->assertEquals($expectedRows, $hydrator->prepare(OrmRecordWithCollectionRelationAsRecord::class, $rows)); + $this->assertEquals( + new OrmRecordWithCollectionRelationAsRecord('John Inc.', [new UserAsRecord(1, 'John Miller'), new UserAsRecord(3, 'Mickey Mouse')]), + $hydrator->instantiate(OrmRecordWithCollectionRelationAsRecord::class, $expectedRows[0], $this->prime()->connection('test')->platform()) + ); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithCollectionRelationAsRecord::class, $rows, $rows)); } public function test_with_implicit_type() @@ -139,7 +295,123 @@ public function test_with_implicit_type() $this->assertSame(['name', 'roles'], $hydrator->projection(OrmRecordWithImplicitType::class)); $this->assertSame($rows, $hydrator->prepare(OrmRecordWithImplicitType::class, $rows)); $this->assertEquals(new OrmRecordWithImplicitType('John Miller', ['admin', 'user']), $hydrator->instantiate(OrmRecordWithImplicitType::class, $rows[0], $this->prime()->connection('test')->platform())); - $this->assertSame($rows, $hydrator->finalize(OrmRecordWithImplicitType::class, $rows)); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithImplicitType::class, $rows, $rows)); + } + + public function test_with_embedded() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(Document::class)); + $rows = [ + ['id_' => 1, 'customer_id' => 1, 'uploader_type' => 'user', 'uploader_id' => 12, 'contact_name' => 'John', 'contact_address' => '12 rue de la Paix', 'contact_city' => 'Roubaix'], + ['id_' => 2, 'customer_id' => 1, 'uploader_type' => 'admin', 'uploader_id' => 24, 'contact_name' => null, 'contact_address' => null, 'contact_city' => null], + ]; + + $this->assertSame(['id', 'contact.name', 'contact.location.address', 'contact.location.city'], $hydrator->projection(OrmRecordWithEmbedded::class)); + $this->assertSame($rows, $hydrator->prepare(OrmRecordWithEmbedded::class, $rows)); + $this->assertEquals( + new OrmRecordWithEmbedded(1, new OrmContactRecord('John', new OrmLocationRecord('12 rue de la Paix', 'Roubaix'))), + $hydrator->instantiate(OrmRecordWithEmbedded::class, $rows[0], $this->prime()->connection('test')->platform()) + ); + $this->assertEquals( + new OrmRecordWithEmbedded(2, new OrmContactRecord(null, new OrmLocationRecord(null, null))), + $hydrator->instantiate(OrmRecordWithEmbedded::class, $rows[1], $this->prime()->connection('test')->platform()) + ); + $this->assertSame($rows, $hydrator->finalize(OrmRecordWithEmbedded::class, $rows, $rows)); + } + + public function test_with_embedded_without_prefix() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(Document::class)); + $rows = [ + ['id_' => 1, 'customer_id' => 1, 'uploader_type' => 'user', 'uploader_id' => 12, 'contact_name' => 'John', 'contact_address' => null, 'contact_city' => null], + ]; + + $this->assertSame(['id', 'uploaderId', 'uploaderType'], $hydrator->projection(OrmRecordWithFlatEmbedded::class)); + $this->assertEquals( + new OrmRecordWithFlatEmbedded(1, new OrmUploaderRecord(12, 'user')), + $hydrator->instantiate(OrmRecordWithFlatEmbedded::class, $rows[0], $this->prime()->connection('test')->platform()) + ); + } + + public function test_with_embedded_custom_prefix() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(Document::class)); + $rows = [ + ['id_' => 1, 'customer_id' => 1, 'uploader_type' => 'user', 'uploader_id' => 12, 'contact_name' => 'John', 'contact_address' => '12 rue de la Paix', 'contact_city' => 'Roubaix'], + ]; + + $this->assertSame(['id', 'contact.location.address', 'contact.location.city'], $hydrator->projection(OrmRecordWithEmbeddedCustomPrefix::class)); + $this->assertEquals( + new OrmRecordWithEmbeddedCustomPrefix(1, new OrmLocationRecord('12 rue de la Paix', 'Roubaix')), + $hydrator->instantiate(OrmRecordWithEmbeddedCustomPrefix::class, $rows[0], $this->prime()->connection('test')->platform()) + ); + } + + public function test_with_embedded_on_entity_embedded_relation() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ]; + + $this->assertSame(['name', 'customer.id'], $hydrator->projection(OrmRecordWithEmbeddedRelationKey::class)); + $this->assertEquals( + new OrmRecordWithEmbeddedRelationKey('John Miller', new OrmCustomerKeyRecord(1)), + $hydrator->instantiate(OrmRecordWithEmbeddedRelationKey::class, $rows[0], $this->prime()->connection('test')->platform()) + ); + } + + public function test_with_embedded_and_type_resolution() + { + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ]; + + $this->assertSame(['id', 'roles'], $hydrator->projection(OrmRecordWithEmbeddedTypedField::class)); + $this->assertEquals( + new OrmRecordWithEmbeddedTypedField(new OrmUserDataRecord(1, ['admin', 'user'])), + $hydrator->instantiate(OrmRecordWithEmbeddedTypedField::class, $rows[0], $this->prime()->connection('test')->platform()) + ); + } + + public function test_error_embedded_without_type() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The parameter embedded on Bdf\Prime\Record\MissingEmbeddedType must have a type or the #[Embedded] attribute must define a className.'); + + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ]; + + $hydrator->instantiate(MissingEmbeddedType::class, $rows[0], $this->prime()->connection('test')->platform()); + } + + public function test_error_no_constructor() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The record class Bdf\Prime\Record\WithoutConstructor must have a constructor'); + + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ]; + + $hydrator->instantiate(WithoutConstructor::class, $rows[0], $this->prime()->connection('test')->platform()); + } + + public function test_error_relation_without_name() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot determine relation name for parameter relation in class Bdf\Prime\Record\MissingRelationType. Set the relation name on the LoadRelation attribute, or set the relation class on the parameter type.'); + + $hydrator = new RepositoryRecordHydrator($this->prime()->repository(User::class)); + $rows = [ + ['id_' => 1, 'name_' => 'John Miller', 'customer_id' => 1, 'roles_' => ',admin,user,'], + ]; + + $hydrator->instantiate(MissingRelationType::class, $rows[0], $this->prime()->connection('test')->platform()); } } @@ -170,6 +442,105 @@ public function __construct( ) {} } +class OrmRecordWithRelationImplicit +{ + public function __construct( + public readonly string $name, + #[LoadRelation] + public readonly Customer $customer, + ) {} +} + +class OrmRecordWithRelationTransformer +{ + public function __construct( + public readonly string $name, + #[LoadRelation(Customer::class, transformer: [self::class, 'formatCustomer'])] + public readonly string $customer, + ) {} + + public static function formatCustomer(Customer $customer): string + { + return sprintf('%s (%d)', $customer->name, $customer->id); + } +} + +class OrmRecordWithRelationAsRecord +{ + public function __construct( + public readonly string $name, + #[LoadRelation('customer')] + public readonly CustomerAsRecord $customer, + ) {} +} + +class OrmRecordWithRelationAsExplicitRecord +{ + public function __construct( + public readonly string $name, + #[LoadRelation(Customer::class, as: CustomerAsRecord::class)] + public readonly CustomerRecordInterface $customer, + ) {} +} + +class OrmRecordWithRelationAsRecordAndTransformer +{ + public function __construct( + public readonly string $name, + #[LoadRelation(Customer::class, as: CustomerAsRecord::class, transformer: [self::class, 'formatCustomer'])] + public readonly string $customer, + ) {} + + public static function formatCustomer(CustomerAsRecord $customer): string + { + return sprintf('%s (%d)', $customer->name, $customer->id); + } +} + +class OrmRecordWithNullableRelationAsRecord +{ + public function __construct( + public readonly string $name, + #[LoadRelation('faction')] + public readonly ?FactionAsRecord $faction, + ) {} +} + +class OrmRecordWithCollectionRelationAsRecord +{ + public function __construct( + public readonly string $name, + #[LoadRelation('users', as: UserAsRecord::class)] + public readonly array $users, + ) {} +} + +interface CustomerRecordInterface {} + +final readonly class CustomerAsRecord implements CustomerRecordInterface +{ + public function __construct( + public int $id, + public string $name, + ) {} +} + +final readonly class FactionAsRecord +{ + public function __construct( + public int $id, + public string $name, + ) {} +} + +final readonly class UserAsRecord +{ + public function __construct( + public int $id, + public string $name, + ) {} +} + class OrmRecordWithImplicitType { public function __construct( @@ -177,3 +548,108 @@ public function __construct( public readonly array $roles, ) {} } + +class OrmRecordWithEmbedded +{ + public function __construct( + public readonly int $id, + #[Embedded] + public readonly OrmContactRecord $contact, + ) {} +} + +class OrmContactRecord +{ + public function __construct( + public readonly ?string $name, + #[Embedded] + public readonly OrmLocationRecord $location, + ) {} +} + +class OrmLocationRecord +{ + public function __construct( + public readonly ?string $address, + public readonly ?string $city, + ) {} +} + +class OrmRecordWithFlatEmbedded +{ + public function __construct( + public readonly int $id, + #[Embedded('')] + public readonly OrmUploaderRecord $uploader, + ) {} +} + +class OrmUploaderRecord +{ + public function __construct( + #[Field('uploaderId')] + public readonly int $id, + #[Field('uploaderType')] + public readonly string $type, + ) {} +} + +class OrmRecordWithEmbeddedCustomPrefix +{ + public function __construct( + public readonly int $id, + #[Embedded('contact.location.')] + public readonly OrmLocationRecord $location, + ) {} +} + +class OrmRecordWithEmbeddedRelationKey +{ + public function __construct( + public readonly string $name, + #[Embedded] + public readonly OrmCustomerKeyRecord $customer, + ) {} +} + +class OrmCustomerKeyRecord +{ + public function __construct( + public readonly int $id, + ) {} +} + +class OrmRecordWithEmbeddedTypedField +{ + public function __construct( + #[Embedded('')] + public readonly OrmUserDataRecord $data, + ) {} +} + +class OrmUserDataRecord +{ + public function __construct( + public readonly int $id, + public readonly array $roles, + ) {} +} + +class MissingEmbeddedType +{ + public function __construct( + public readonly string $name, + #[Embedded] + public readonly mixed $embedded, + ) {} +} + +class WithoutConstructor {} + +class MissingRelationType +{ + public function __construct( + #[LoadRelation] + public array $relation, + ) {} +} diff --git a/tests/Record/SimpleRecordHydratorTest.php b/tests/Record/SimpleRecordHydratorTest.php index 3cda9a58..ed04e0db 100644 --- a/tests/Record/SimpleRecordHydratorTest.php +++ b/tests/Record/SimpleRecordHydratorTest.php @@ -20,7 +20,7 @@ public function test_simple() $this->assertSame(['id', 'name'], $hydrator->projection(SimpleDbalRecord::class)); $this->assertSame($rows, $hydrator->prepare(SimpleDbalRecord::class, $rows)); $this->assertEquals(new SimpleDbalRecord(1, 'John Miller'), $hydrator->instantiate(SimpleDbalRecord::class, $rows[0], $this->createMock(PlatformInterface::class))); - $this->assertSame($rows, $hydrator->finalize(SimpleDbalRecord::class, $rows)); + $this->assertSame($rows, $hydrator->finalize(SimpleDbalRecord::class, $rows, $rows)); } public function test_with_name_mapping() @@ -35,9 +35,30 @@ public function test_with_name_mapping() $this->assertSame(['id', 'name'], $hydrator->projection(DbalRecordWithNameMapping::class)); $this->assertSame($rows, $hydrator->prepare(DbalRecordWithNameMapping::class, $rows)); $this->assertEquals(new DbalRecordWithNameMapping(1, 'John Miller'), $hydrator->instantiate(DbalRecordWithNameMapping::class, $rows[0], $this->createMock(PlatformInterface::class))); - $this->assertSame($rows, $hydrator->finalize(DbalRecordWithNameMapping::class, $rows)); + $this->assertSame($rows, $hydrator->finalize(DbalRecordWithNameMapping::class, $rows, $rows)); } + public function test_with_embedded() + { + $hydrator = new SimpleRecordHydrator(); + $rows = [ + ['id' => 1, 'name' => 'John Miller', 'customer_id' => 1, 'customer_name' => 'John Inc.'], + ['id' => 2, 'name' => 'Jane Doe', 'customer_id' => 2, 'customer_name' => 'Doe Ltd.'], + ]; + + $this->assertSame(['id', 'name', 'customer_id', 'customer_name'], $hydrator->projection(DbalRecordWithEmbedded::class)); + $this->assertSame($rows, $hydrator->prepare(DbalRecordWithEmbedded::class, $rows)); + $this->assertEquals( + new DbalRecordWithEmbedded(1, 'John Miller', new DbalCustomerRecord(1, 'John Inc.')), + $hydrator->instantiate(DbalRecordWithEmbedded::class, $rows[0], $this->createMock(PlatformInterface::class)) + ); + $this->assertSame($rows, $hydrator->finalize(DbalRecordWithEmbedded::class, $rows, $rows)); + } + + public function test_instance_should_be_shared() + { + $this->assertSame(SimpleRecordHydrator::instance(), SimpleRecordHydrator::instance()); + } } class SimpleDbalRecord @@ -57,3 +78,21 @@ public function __construct( public readonly string $bar, ) {} } + +class DbalRecordWithEmbedded +{ + public function __construct( + public readonly int $id, + public readonly string $name, + #[Embedded] + public readonly DbalCustomerRecord $customer, + ) {} +} + +class DbalCustomerRecord +{ + public function __construct( + public readonly int $id, + public readonly string $name, + ) {} +} diff --git a/tests/Relations/BelongsToManyTest.php b/tests/Relations/BelongsToManyTest.php index c9dbd1e5..3d041cc3 100755 --- a/tests/Relations/BelongsToManyTest.php +++ b/tests/Relations/BelongsToManyTest.php @@ -136,6 +136,78 @@ public function test_loadByForeignKey() ], $loaded); } + public function test_loadRecordByForeignKey() + { + $this->pack()->nonPersist([ + new Customer([ + 'id' => '456', + 'name' => 'Customer', + ]), + new CustomerPack([ + 'customerId' => '456', + 'packId' => 1, + ]), + new CustomerPack([ + 'customerId' => '456', + 'packId' => 4, + ]), + ]); + + $relation = Customer::repository()->relation('packs'); + $loaded = $relation->loadRecordByForeignKeys(['123', '456', '404'], BelongsToManyPackRecord::class); + + $this->assertEquals([ + '123' => [ + new BelongsToManyPackRecord(1, 'Pack referencement'), + new BelongsToManyPackRecord(2, 'Pack classic'), + ], + '456' => [ + new BelongsToManyPackRecord(1, 'Pack referencement'), + new BelongsToManyPackRecord(4, 'Pack empty2'), + ], + '404' => [], + ], $loaded); + } + + public function test_loadRecordByForeignKey_empty() + { + $relation = Customer::repository()->relation('packs'); + + $this->assertSame([], $relation->loadRecordByForeignKeys([], BelongsToManyPackRecord::class)); + $this->assertSame(['404' => []], $relation->loadRecordByForeignKeys(['404'], BelongsToManyPackRecord::class)); + } + + /** + * With a single distant key the relation query is a KeyValueQuery kept in memory : + * loading records must not register the record class nor its projection on the cached query. + */ + public function test_loadRecordByForeignKey_should_not_alter_the_cached_relation_query() + { + $this->pack()->nonPersist([ + new Customer([ + 'id' => '789', + 'name' => 'Customer', + ]), + new CustomerPack([ + 'customerId' => '789', + 'packId' => 3, + ]), + ]); + + $relation = Customer::repository()->relation('packs'); + $expected = ['789' => [$this->getTestPack()->get('pack-empty')]]; + + $this->assertEquals($expected, $relation->loadByForeignKeys(['789'])); + + $this->assertEquals( + ['789' => [new BelongsToManyPackRecord(3, 'Pack empty')]], + $relation->loadRecordByForeignKeys(['789'], BelongsToManyPackRecord::class) + ); + + // The entity query must still return fully hydrated entities + $this->assertEquals($expected, $relation->loadByForeignKeys(['789'])); + } + /** * */ @@ -697,3 +769,11 @@ public function test_reload() } class MyCustomQuery extends Query {} + +final readonly class BelongsToManyPackRecord +{ + public function __construct( + public int $id, + public string $label, + ) {} +} diff --git a/tests/Relations/BelongsToTest.php b/tests/Relations/BelongsToTest.php index c43123db..35dd18f1 100755 --- a/tests/Relations/BelongsToTest.php +++ b/tests/Relations/BelongsToTest.php @@ -131,6 +131,30 @@ public function test_loadByForeignKeys() ], $customers); } + public function test_loadRecordByForeignKeys() + { + $repository = Prime::repository(User::class); + $customer = $this->getTestPack()->get('customer'); + $customer2 = $this->getTestPack()->get('customer2'); + + $relation = $repository->relation('customer'); + $customers = $relation->loadRecordByForeignKeys([$customer->id, $customer2->id, 404], BelongsToCustomerRecord::class); + + $this->assertContainsOnly(BelongsToCustomerRecord::class, $customers); + $this->assertEquals([ + $customer->id => new BelongsToCustomerRecord((int) $customer->id, $customer->name), + $customer2->id => new BelongsToCustomerRecord((int) $customer2->id, $customer2->name), + ], $customers); + } + + public function test_loadRecordByForeignKeys_empty() + { + $relation = Prime::repository(User::class)->relation('customer'); + + $this->assertSame([], $relation->loadRecordByForeignKeys([], BelongsToCustomerRecord::class)); + $this->assertSame([], $relation->loadRecordByForeignKeys([404, 405], BelongsToCustomerRecord::class)); + } + /** * */ @@ -811,3 +835,11 @@ public function test_reload() $this->assertEntity($project, $commit->author->project); } } + +final readonly class BelongsToCustomerRecord +{ + public function __construct( + public int $id, + public string $name, + ) {} +} diff --git a/tests/Relations/ByInheritanceTest.php b/tests/Relations/ByInheritanceTest.php index e540e5f7..310346e5 100755 --- a/tests/Relations/ByInheritanceTest.php +++ b/tests/Relations/ByInheritanceTest.php @@ -101,6 +101,15 @@ public function test_loadByForeignKeys() $relation->loadByForeignKeys(['10', '321']); } + public function test_loadRecordByForeignKeys() + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Unsupported operation Bdf\Prime\Relations\AbstractRelation::loadRecordByForeignKeys'); + + $relation = Task::repository()->relation('target'); + $relation->loadRecordByForeignKeys(['10', '321'], \stdClass::class); + } + /** * */ diff --git a/tests/Relations/CustomRelationTest.php b/tests/Relations/CustomRelationTest.php index 7675bf44..5725ca18 100644 --- a/tests/Relations/CustomRelationTest.php +++ b/tests/Relations/CustomRelationTest.php @@ -36,6 +36,17 @@ protected function tearDown(): void $this->primeStop(); } + /** + * + */ + public function test_loadRecordByForeignKeys_not_supported() + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Unsupported operation Bdf\Prime\Relations\AbstractRelation::loadRecordByForeignKeys'); + + EntityWithCustomRelation::repository()->relation('distant')->loadRecordByForeignKeys(['123'], \stdClass::class); + } + /** * */ diff --git a/tests/Relations/HasManyTest.php b/tests/Relations/HasManyTest.php index 59e56ed6..ee1c005f 100755 --- a/tests/Relations/HasManyTest.php +++ b/tests/Relations/HasManyTest.php @@ -124,6 +124,62 @@ public function test_loadByForeignKeys() ], $loaded); } + public function test_loadRecordByForeignKeys() + { + $this->pack()->nonPersist([ + new Document([ + 'id' => '3', + 'customerId' => '456', + 'uploaderType' => 'admin', + 'uploaderId' => '1', + ]), + ]); + + $relation = Customer::repository()->relation('documents'); + + $loaded = $relation->loadRecordByForeignKeys(['123', '456', '404'], HasManyDocumentRecord::class); + + $this->assertEquals([ + '123' => [ + new HasManyDocumentRecord(1, 'admin'), + new HasManyDocumentRecord(2, 'user'), + ], + '456' => [new HasManyDocumentRecord(3, 'admin')], + '404' => [], + ], $loaded); + } + + public function test_loadRecordByForeignKeys_empty() + { + $relation = Customer::repository()->relation('documents'); + + $this->assertSame([], $relation->loadRecordByForeignKeys([], HasManyDocumentRecord::class)); + $this->assertSame(['404' => []], $relation->loadRecordByForeignKeys(['404'], HasManyDocumentRecord::class)); + } + + /** + * The relation query is cached (i.e. HasMany::$relationQuery), so the record class + * must not be kept on the next call, which loads entities + */ + public function test_loadRecordByForeignKeys_should_not_keep_record_class_on_next_load() + { + $relation = Customer::repository()->relation('documents'); + + // Use a single key to enable the KeyValueQuery optimisation, which caches the query + $this->assertEquals( + ['123' => [new HasManyDocumentRecord(1, 'admin'), new HasManyDocumentRecord(2, 'user')]], + $relation->loadRecordByForeignKeys(['123'], HasManyDocumentRecord::class) + ); + + $this->assertEquals( + ['123' => [ + $this->getTestPack()->get('document-admin'), + $this->getTestPack()->get('document-user'), + ]], + $relation->loadByForeignKeys(['123']) + ); + } + /** * */ @@ -524,3 +580,11 @@ public function test_reload() $this->assertNotSame($loadedDocuments, $customer->documents); } } + +final readonly class HasManyDocumentRecord +{ + public function __construct( + public int $id, + public string $uploaderType, + ) {} +} diff --git a/tests/Relations/HasOneTest.php b/tests/Relations/HasOneTest.php index 032a12c5..db216de0 100755 --- a/tests/Relations/HasOneTest.php +++ b/tests/Relations/HasOneTest.php @@ -2,11 +2,13 @@ namespace Bdf\Prime\Relations; +use Bdf\Prime\Admin; use Bdf\Prime\Collection\Indexer\EntityIndexer; use Bdf\Prime\Collection\Indexer\SingleEntityIndexer; use Bdf\Prime\Commit; use Bdf\Prime\Company; use Bdf\Prime\Developer; +use Bdf\Prime\Document; use Bdf\Prime\Prime; use Bdf\Prime\PrimeTestCase; use Bdf\Prime\Customer; @@ -53,6 +55,26 @@ protected function declareTestData($pack) 'address' => '1 rue chez toi', 'city' => 'MAISON', ]), + + // Polymorphic relation (morphOne) : Admin::mainDocument + 'admin' => new Admin([ + 'id' => '10', + 'name' => 'Admin User', + 'roles' => [1], + ]), + 'document-admin' => new Document([ + 'id' => '10', + 'customerId' => '123', + 'uploaderType' => 'admin', + 'uploaderId' => '10', + ]), + // Uploaded by a user, but sharing the owner key space of the admin relation + 'document-user' => new Document([ + 'id' => '20', + 'customerId' => '123', + 'uploaderType' => 'user', + 'uploaderId' => '321', + ]), ]); } @@ -84,6 +106,49 @@ public function test_loadByForeignKeys() ], $entities); } + public function test_loadRecordByForeignKeys() + { + $relation = Customer::repository()->relation('location'); + $records = $relation->loadRecordByForeignKeys(['123', '321'], HasOneLocationRecord::class); + + $this->assertContainsOnly(HasOneLocationRecord::class, $records); + $this->assertEquals([ + '123' => new HasOneLocationRecord(123, 'MAISON'), + ], $records); + } + + public function test_loadRecordByForeignKeys_empty() + { + $relation = Customer::repository()->relation('location'); + + $this->assertSame([], $relation->loadRecordByForeignKeys([], HasOneLocationRecord::class)); + $this->assertSame([], $relation->loadRecordByForeignKeys(['404'], HasOneLocationRecord::class)); + } + + /** + * The relation query is cached (i.e. HasOne::$relationQuery), so the record class + * must not be kept on the next call, which loads entities + */ + public function test_loadRecordByForeignKeys_should_not_keep_record_class_on_next_load() + { + $relation = Customer::repository()->relation('location'); + + // Use a single key to enable the KeyValueQuery optimisation, which caches the query + $this->assertEquals( + ['123' => new HasOneLocationRecord(123, 'MAISON')], + $relation->loadRecordByForeignKeys(['123'], HasOneLocationRecord::class) + ); + + $this->assertEquals( + ['123' => new Location([ + 'id' => '123', + 'address' => '1 rue chez toi', + 'city' => 'MAISON', + ])], + $relation->loadByForeignKeys(['123']) + ); + } + /** * */ @@ -417,6 +482,35 @@ public function test_load_self_relation_chain() $this->assertEntity($grandParent, $child->parent->parent); } + /** + * morphOne is a polymorphic HasOne : the KeyValueQuery optimisation used for a single + * foreign key must not skip the discriminator constraint. + * + * @see \Bdf\Prime\Relations\Builder\RelationBuilder::morphOne() + */ + public function test_morph_loadByForeignKeys_should_apply_discriminator_on_single_key() + { + $relation = Admin::repository()->relation('mainDocument'); + + $this->assertEquals([ + 10 => $this->getTestPack()->get('document-admin'), + ], $relation->loadByForeignKeys(['10'])); + + // The document 20 has been uploaded by a user : it must not be loaded by the admin relation + $this->assertSame([], $relation->loadByForeignKeys(['321'])); + } + + public function test_morph_loadRecordByForeignKeys_should_apply_discriminator_on_single_key() + { + $relation = Admin::repository()->relation('mainDocument'); + + $this->assertEquals([ + 10 => new HasOneDocumentRecord(10, 'admin'), + ], $relation->loadRecordByForeignKeys(['10'], HasOneDocumentRecord::class)); + + $this->assertSame([], $relation->loadRecordByForeignKeys(['321'], HasOneDocumentRecord::class)); + } + /** * */ @@ -446,3 +540,19 @@ public function test_reload() $this->assertNotSame($loadedLocation, $customer->location); } } + +final readonly class HasOneLocationRecord +{ + public function __construct( + public int $id, + public string $city, + ) {} +} + +final readonly class HasOneDocumentRecord +{ + public function __construct( + public int $id, + public string $uploaderType, + ) {} +} diff --git a/tests/Relations/MorphManyTest.php b/tests/Relations/MorphManyTest.php index 3a41d4ee..997c569b 100755 --- a/tests/Relations/MorphManyTest.php +++ b/tests/Relations/MorphManyTest.php @@ -96,6 +96,33 @@ public function test_loadByForeignKeys() ], $loaded); } + public function test_loadRecordByForeignKeys() + { + $relation = Admin::repository()->relation('documents'); + $loaded = $relation->loadRecordByForeignKeys(['10', '404'], MorphManyDocumentRecord::class); + + $this->assertEquals([ + 10 => [ + new MorphManyDocumentRecord(10, 'admin'), + ], + 404 => [], + ], $loaded); + } + + public function test_loadRecordByForeignKeys_should_apply_discriminator() + { + // The user 321 has a document, but it should not be loaded by the admin relation + $relation = Admin::repository()->relation('documents'); + + $this->assertSame(['321' => []], $relation->loadRecordByForeignKeys(['321'], MorphManyDocumentRecord::class)); + + $relation = User::repository()->relation('documents'); + + $this->assertEquals([ + 321 => [new MorphManyDocumentRecord(20, 'user')], + ], $relation->loadRecordByForeignKeys(['321'], MorphManyDocumentRecord::class)); + } + /** * */ @@ -217,3 +244,11 @@ public function test_reload() $this->assertNotSame($loadedDocuments, $user->documents); } } + +final readonly class MorphManyDocumentRecord +{ + public function __construct( + public int $id, + public string $uploaderType, + ) {} +} diff --git a/tests/Relations/MorphToTest.php b/tests/Relations/MorphToTest.php index 5a65db94..3bdddc5e 100755 --- a/tests/Relations/MorphToTest.php +++ b/tests/Relations/MorphToTest.php @@ -104,6 +104,15 @@ public function test_loadByForeignKeys() $relation->loadByForeignKeys(['10', '321']); } + public function test_loadRecordByForeignKeys() + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('MorphTo relation do not supports querying by foreign keys'); + + $relation = Document::repository()->relation('uploader'); + $relation->loadRecordByForeignKeys(['10', '321'], MorphToUploaderRecord::class); + } + /** * */ @@ -682,3 +691,11 @@ public function test_null_relation() $fkNull->relation('uploader')->query(); } } + +final readonly class MorphToUploaderRecord +{ + public function __construct( + public int $id, + public string $name, + ) {} +} diff --git a/tests/Relations/NullRelationTest.php b/tests/Relations/NullRelationTest.php index 6042141f..8f010d93 100755 --- a/tests/Relations/NullRelationTest.php +++ b/tests/Relations/NullRelationTest.php @@ -87,6 +87,11 @@ public function test_loadByForeignKeys() $this->assertSame([], User::repository()->relation('none')->loadByForeignKeys([1, 2, 3])); } + public function test_loadRecordByForeignKeys() + { + $this->assertSame([], User::repository()->relation('none')->loadRecordByForeignKeys([1, 2, 3], \stdClass::class)); + } + /** * */ diff --git a/tests/Sharding/Query/ShardingKeyValueQueryTest.php b/tests/Sharding/Query/ShardingKeyValueQueryTest.php index 674c88ba..8d8d94b7 100644 --- a/tests/Sharding/Query/ShardingKeyValueQueryTest.php +++ b/tests/Sharding/Query/ShardingKeyValueQueryTest.php @@ -196,6 +196,92 @@ public function test_project() ], $this->query()->from('test2')->project(['id', 'value'])->all()); } + /** + * + */ + public function test_addProjection() + { + $this->connection->insert('test2', ['id' => 1, 'value' => 'John', 'other' => 'b']); + $this->connection->insert('test2', ['id' => 2, 'value' => 'Bob', 'other' => 'a']); + + $this->assertEquals([ + ['id' => 2, 'value' => 'Bob'], + ['id' => 1, 'value' => 'John'], + ], $this->query()->from('test2')->project('id')->addProjection('value')->all()); + } + + /** + * + */ + public function test_addProjection_multiple_columns() + { + $this->connection->insert('test2', ['id' => 1, 'value' => 'John', 'other' => 'b']); + $this->connection->insert('test2', ['id' => 2, 'value' => 'Bob', 'other' => 'a']); + + $this->assertEquals([ + ['id' => 2, 'value' => 'Bob', 'other' => 'a'], + ['id' => 1, 'value' => 'John', 'other' => 'b'], + ], $this->query()->from('test2')->project('id')->addProjection(['value', 'other'])->all()); + } + + /** + * + */ + public function test_addProjection_without_projection_should_be_ignored() + { + $this->connection->insert('test2', ['id' => 1, 'value' => 'John', 'other' => 'b']); + $this->connection->insert('test2', ['id' => 2, 'value' => 'Bob', 'other' => 'a']); + + $this->assertEquals([ + ['id' => 2, 'value' => 'Bob', 'other' => 'a'], + ['id' => 1, 'value' => 'John', 'other' => 'b'], + ], $this->query()->from('test2')->addProjection('value')->all()); + } + + /** + * + */ + public function test_addProjection_already_projected_should_be_ignored() + { + $this->connection->insert('test2', ['id' => 1, 'value' => 'John', 'other' => 'b']); + $this->connection->insert('test2', ['id' => 2, 'value' => 'Bob', 'other' => 'a']); + + $query = $this->query()->from('test2')->project(['id', 'value'])->addProjection(['value', 'id', 'other']); + + $this->assertSame(['id', 'value', 'other'], $query->statements['columns']); + $this->assertEquals([ + ['id' => 2, 'value' => 'Bob', 'other' => 'a'], + ['id' => 1, 'value' => 'John', 'other' => 'b'], + ], $query->all()); + } + + /** + * + */ + public function test_addProjection_with_alias() + { + $this->connection->insert('test2', ['id' => 1, 'value' => 'John', 'other' => 'b']); + $this->connection->insert('test2', ['id' => 2, 'value' => 'Bob', 'other' => 'a']); + + $query = $this->query()->from('test2')->project(['id'])->addProjection(['myValue' => 'value']); + + $this->assertSame(['id', 'myValue' => 'value'], $query->statements['columns']); + $this->assertEquals([ + ['id' => 2, 'myValue' => 'Bob'], + ['id' => 1, 'myValue' => 'John'], + ], $query->all()); + } + + /** + * + */ + public function test_addProjection_already_projected_alias_should_be_ignored() + { + $query = $this->query()->from('test2')->project(['myValue' => 'value'])->addProjection(['myValue' => 'other']); + + $this->assertSame(['myValue' => 'value'], $query->statements['columns']); + } + /** * */ diff --git a/tests/_files/relation.php b/tests/_files/relation.php index e409e089..95edcca6 100755 --- a/tests/_files/relation.php +++ b/tests/_files/relation.php @@ -419,6 +419,9 @@ public function buildRelations(RelationBuilder $builder): void $builder->on('documents') ->morphMany(Document::class.'::uploaderId', 'uploaderType=admin'); + + $builder->on('mainDocument') + ->morphOne(Document::class.'::uploaderId', 'uploaderType=admin'); } }