Model/src/Query/EntityQuery.php

344 lines
10 KiB
PHP

<?php
namespace Nest\Model\Query;
use Nest\Database\Database;
use Nest\Database\Query\SelectQuery;
use Nest\Database\Exceptions\Query\MissingConditionValueException;
use Nest\Database\Exceptions\UnknownDatabaseException;
use Nest\Exceptions\InvalidTypeException;
use Nest\Model\Exceptions\UndefinedRelationException;
use Nest\Model\Exceptions\UnhandledPropertyTypeException;
use Nest\Types\Exceptions\IncompatibleTypeException;
use Nest\Model\Entity;
use function Nest\Utils\array_first;
use function Nest\Utils\array_first_or_val;
/**
* Query builder for an entity.
* @template Entity
*/
class EntityQuery extends SelectQuery
{
/**
* Normalize a relations definition array.
* @param array<string|int, string|callable(EntityQuery): void> $relations Relations to normalize.
* @return array<string, (callable(EntityQuery): void)|true> Relations definition.
*/
public static function normalizeRelationsDefinition(array $relations): array
{
// Normalize relations array.
foreach ($relations as $key => $relation)
{ // Add each relation to eager load to the with array.
if (!is_string($key))
{ // If key is an integer, then $relation is a string key.
$relations[$relation] = true;
unset($relations[$key]);
}
}
// Return normalized relations array.
return $relations;
}
/**
* Relations that should be eager loaded.
* @var array<string, (callable(EntityQuery): void)|true>
*/
protected array $with = [];
/**
* @inheritDoc
* @param Entity $entity Reference entity.
*/
public function __construct(Database $database, string $table, protected Entity $entity)
{
parent::__construct($database, $table);
}
/**
* Create a new entity instance.
* @return Entity Entity instance.
*/
protected function newEntity(): Entity
{
// Create a new entity instance.
return $this->entity->new();
}
/**
* Get queried entity primary key name.
* @return string Primary key name.
*/
public function getPrimaryKeyName(): string
{
return array_first_or_val($this->entity->getPrimaryFields());
}
/**
* Add a where condition for the primary key of the entity.
* @param mixed|array $keyValue Entity primary key value(s) to match.
* @return $this
*/
public function whereKey(mixed $keyValue): static
{
return $this->whereIn($this->getPrimaryKeyName(), is_array($keyValue) ? $keyValue : [$keyValue]);
}
/**
* Make a relation prefix for the given relation.
* @param string $relationKey Relation key.
* @return string Relation prefix.
*/
private function getRelationPrefix(string $relationKey): string
{
return "{$this->entity->getTableName()}_{$relationKey}_";
}
/**
* Go through all first-level relations to eager load and add joins and selected columns for them.
* @param bool $generateSelect Set if SELECTed columns must be generated or not.
* @return string[] Loaded inline relations.
* @throws UndefinedRelationException
*/
private function addInlineRelations(bool $generateSelect): array
{
if (empty($this->with)) return [];
// Initialize list of inline loaded relations.
$inlineRelations = [];
foreach ($this->with as $relationKey => $relationCallable)
{ // For each relation, if it's inline, append a JOIN and SELECT for it.
// Get the required relation.
$relation = $this->entity->getRelation($relationKey);
if ($relation->canInlineLoad())
{ // If the relation is not multiple, it can be retrieved inline.
$relation->setupInlineLoading($this);
if ($generateSelect)
// Generate SELECT for the relation.
$this->select(...$relation->newEntity()->sqlSelectFields($this->getRelationPrefix($relationKey)));
if (is_callable($relationCallable))
// Call the callable with the alterable query.
$relationCallable($this);
// Add to inline loaded relations.
$inlineRelations[] = $relationKey;
}
}
return $inlineRelations;
}
/**
* Get queried entities.
* @param string|null $indexProperty A property to use as entities array index.
* @return Entity[]|array<mixed, Entity[]> Retrieved entities.
* @throws MissingConditionValueException
* @throws IncompatibleTypeException
* @throws InvalidTypeException
* @throws UndefinedRelationException
* @throws UnknownDatabaseException
* @throws UnhandledPropertyTypeException
*/
public function get(?string $indexProperty = null): array
{
// If there's no custom SELECT, generate it.
$generateSelect = empty($this->selected);
if ($generateSelect)
// Generate SELECT for the main entity.
$this->select(...$this->entity->sqlSelectFields());
// Add inline relations retrieval to the query.
$inlineRelations = $this->addInlineRelations($generateSelect);
// Map entities with their inline relations, if there are some.
$entities = $this->mapEntities($this->execute(), $inlineRelations, $indexProperty);
// Load all remaining relations (remove already loaded inline relations).
$relationsToLoad = $this->with ?? [];
foreach ($inlineRelations as $inlineRelation)
unset($relationsToLoad[$inlineRelation]);
$this->load($entities, $relationsToLoad);
return $entities;
}
/**
* Retrieve the entities with the given keys.
* @param mixed[] $keysValues Entity primary keys values to match.
* @return Entity[] Retrieved entities.
* @throws IncompatibleTypeException
* @throws InvalidTypeException
* @throws MissingConditionValueException
* @throws UndefinedRelationException
* @throws UnhandledPropertyTypeException
* @throws UnknownDatabaseException
*/
public function findMany(array $keysValues): array
{
return $this->whereKey($keysValues)->get();
}
/**
* Retrieve the first entity of the query.
* @return Entity Retrieved entity.
* @throws IncompatibleTypeException
* @throws InvalidTypeException
* @throws MissingConditionValueException
* @throws UndefinedRelationException
* @throws UnhandledPropertyTypeException
* @throws UnknownDatabaseException
*/
public function first(): Entity
{
return array_first($this->limit(1)->get());
}
/**
* Retrieve the entity with the given key.
* @param mixed $keyValue Primary key value of the entity.
* @return Entity Retrieved entity.
* @throws IncompatibleTypeException
* @throws InvalidTypeException
* @throws MissingConditionValueException
* @throws UndefinedRelationException
* @throws UnhandledPropertyTypeException
* @throws UnknownDatabaseException
*/
public function find(mixed $keyValue): Entity
{
return $this->whereKey($keyValue)->first();
}
/**
* Map raw objects results to entities.
* @param object[] $objects Raw objects results.
* @param string[] $inlineRelations Inline relations to map, if there are some.
* @return Entity[] The entities.
* @throws IncompatibleTypeException
* @throws InvalidTypeException
* @throws UndefinedRelationException
* @throws UnhandledPropertyTypeException
*/
protected function mapEntities(array $objects, array $inlineRelations = [], ?string $indexProperty = null): array
{
// Initialize entities array.
$entities = [];
foreach ($objects as $object)
{ // Map each object to an entity.
$entity = $this->mapEntity($object);
// Append to entities array, depending on index property name.
if (empty($indexProperty))
$entities[] = $entity;
else
{
if (empty($object->$indexProperty)) $object->$indexProperty = [];
$entities[$object->$indexProperty][] = $entity;
}
// Map inline relations.
foreach ($inlineRelations as $inlineRelation)
{ // For each inline relation, map its data.
$relation = $this->entity->getRelation($inlineRelation);
// Create a new entity as property value.
$propertyValue = $relation->newEntity();
$propertyValue->fromSqlProperties($object, $this->getRelationPrefix($inlineRelation));
// Set the relation property.
$entity->setOriginalProperty($inlineRelation, $propertyValue);
}
}
// Return parsed entities.
return $entities;
}
/**
* Map raw object result to an entity.
* @param object $object Raw object.
* @return Entity The entity.
* @throws IncompatibleTypeException
* @throws InvalidTypeException
*/
protected function mapEntity(object $object): Entity
{
// Create a new entity.
$entity = $this->newEntity();
// Read entity from SQL properties.
$entity->fromSqlProperties($object);
// Return parsed entity.
return $entity;
}
/**
* Eager load given relations.
* @param Entity[] $entities Entities for which to load relations.
* @param array<string, callable(EntityQuery): void|true> $relationsToLoad Relations to load.
* @return void
* @throws IncompatibleTypeException
* @throws InvalidTypeException
* @throws MissingConditionValueException
* @throws UndefinedRelationException
* @throws UnknownDatabaseException
* @throws UnhandledPropertyTypeException
*/
public function load(array $entities, array $relationsToLoad): void
{
foreach ($relationsToLoad as $relationKey => $relationCallable)
{ // For each relation to load...
// Get relation instance.
$relation = $this->entity->getRelation($relationKey);
// Build base query to retrieve relations.
$query = $relation->queryFor($entities);
// Apply query modifier callable.
if (is_callable($relationCallable))
$relationCallable($query);
// Retrieve models and associate them with the right entities.
$models = $query->get("__reference_key");
foreach ($entities as &$entity)
{ // Associate the retrieved model(s) to every entity.
if (!empty($models[$entityId = $entity->{array_first_or_val($entity->getPrimaryFields())}]))
$entity->setOriginalProperty($relationKey, $relation->isMultiple() ? $models[$entityId] : array_first($models[$entityId]));
}
}
}
/**
* Add relations to eager load.
* @param array<string|int, string|callable(EntityQuery): void> $relations Relations that should be eager loaded.
* @return $this
*/
public function with(array $relations): static
{
// Normalize relations array and add them in the with array.
array_merge($this->with, EntityQuery::normalizeRelationsDefinition($relations));
return $this;
}
/**
* Add relations to NOT eager load.
* @param string[] $relations Relations that shouldn't be eager loaded.
* @return $this
*/
public function without(array $relations): static
{
foreach ($relations as $relation)
{ // For each relation to NOT eager load, remove it from the with array.
if (!empty($this->with[$relation])) unset($this->with[$relation]);
}
return $this;
}
}