344 lines
10 KiB
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;
|
|
}
|
|
}
|