Initialize Nest model library.

This commit is contained in:
Madeorsk 2024-11-08 17:12:46 +01:00
commit fb8a0078bd
Signed by: Madeorsk
GPG key ID: 677E51CA765BB79F
35 changed files with 3269 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# IDEA
.idea/
*.iml
# Composer
vendor/

47
composer.json Normal file
View file

@ -0,0 +1,47 @@
{
"version": "dev-main",
"name": "nest/model",
"description": "Nest model service.",
"type": "library",
"authors": [
{
"name": "Madeorsk",
"email": "madeorsk@protonmail.com"
}
],
"autoload": {
"psr-4": {
"Nest\\Model\\": "src/"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://code.zeptotech.net/Nest/Core"
},
{
"type": "vcs",
"url": "https://code.zeptotech.net/Nest/Events"
},
{
"type": "vcs",
"url": "https://code.zeptotech.net/Nest/Types"
},
{
"type": "vcs",
"url": "https://code.zeptotech.net/Nest/Database"
},
{
"type": "vcs",
"url": "https://code.zeptotech.net/Nest/Cli"
}
],
"minimum-stability": "dev",
"require": {
"php": "^8.3",
"nest/core": "dev-main",
"nest/events": "dev-main",
"nest/types": "dev-main",
"nest/database": "dev-main"
}
}

1034
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

46
src/ArrayBlueprint.php Normal file
View file

@ -0,0 +1,46 @@
<?php
namespace Nest\Model;
abstract class ArrayBlueprint
{
protected string $type;
protected ?string $table = null;
protected ?string $foreignKeyName = null;
protected ?string $foreignValueName = null;
/**
* @param class-string $entityClass Entity class where the field is defined.
* @param string $name Name of the field.
*/
public function __construct(protected string $entityClass, protected string $name)
{
}
public function type(string $type): static
{
$this->type = $type;
return $this;
}
public function table(string $table): static
{
$this->table = $table;
return $this;
}
public function foreignKeyName(string $foreignKeyName): static
{
$this->foreignValueName = $foreignKeyName;
return $this;
}
public function foreignValueName(string $foreignValueName): static
{
$this->foreignValueName = $foreignValueName;
return $this;
}
}

120
src/ArrayInstance.php Normal file
View file

@ -0,0 +1,120 @@
<?php
namespace Nest\Model;
use Nest\Database\Query\DeleteQuery;
use Nest\Database\Query\InsertQuery;
use Nest\Database\Query\Raw;
use Nest\Database\Query\SelectQuery;
use Nest\Database\Exceptions\NotCurrentTransactionException;
use Nest\Database\Exceptions\Query\MissingConditionValueException;
use Nest\Database\Exceptions\UnknownDatabaseException;
use Nest\Model\Query\EntityQuery;
use function Nest\Utils\array_first_or_val;
use function Nest\Utils\str_snake_singularize;
class ArrayInstance extends ReadableArrayBlueprint
{
/**
* Create an array field instance from an array field definition.
* @param ArrayBlueprint $array
*/
public function __construct(ArrayBlueprint $array)
{
parent::__construct($array->entityClass, $array->name);
// Copy all fields.
$this->type = $array->type;
$this->table = $array->table;
$this->foreignKeyName = $array->foreignKeyName;
$this->foreignValueName = $array->foreignValueName;
}
/**
* Get current table / foreign key / foreign value state.
* @param string $fromTable Table from which to get values.
* @return array{string, string, string} Table, Foreign key, Foreign value.
*/
public function getState(string $fromTable): array
{
return [
$table = $this->getTable() ?? "{$fromTable}_{$this->name}",
$foreignKey = $this->getForeignKeyName() ?? "$table.".str_snake_singularize($fromTable)."_id",
$foreignValue = $this->getForeignValueName() ?? "$table.".str_snake_singularize($this->name),
];
}
/**
* Extract a column name from a fully qualified name.
* @return string The column name.
*/
private function getColumnName(string $fullyQualifiedName): string
{
return substr($fullyQualifiedName, strrpos($fullyQualifiedName, ".") + 1);
}
/**
* Setup inline loading for the current array field in an entity query.
* @param EntityQuery $baseQuery The entity query to alter.
* @param string $prefix Prefix to add to the name of the properties.
* @return Raw Generated select subquery.
* @throws MissingConditionValueException
*/
public function genSelect(EntityQuery $baseQuery, string $prefix = ""): Raw
{
// Get all auto values for undefined tables or keys.
[$table, $foreignKey, $foreignValue] = $this->getState($baseQuery->getTableName());
// Build subquery SELECT SQL.
$sql = ((new SelectQuery($baseQuery->getDatabase(), $table))
->select($foreignValue)
->whereColumn("{$baseQuery->getTableName()}.".$baseQuery->getPrimaryKeyName(), "=", $foreignKey))->build();
// Return the raw SELECT of a JSON array.
return new Raw(
"ARRAY_TO_JSON(ARRAY($sql->sql)) AS $prefix$this->name",
$sql->bindings,
);
}
/**
* @param Entity $entity Entity for which to save.
* @param array $value Array to save.
* @return void
* @throws MissingConditionValueException
* @throws UnknownDatabaseException
* @throws NotCurrentTransactionException
*/
public function save(Entity $entity, array $value): void
{
// Start a transaction for the whole save.
$transaction = $entity->getDatabase()->startTransaction();
// Get all auto values for undefined tables or keys.
[$table, $foreignKey, $foreignValue] = $this->getState($entity->getTableName());
// Get entity primary key value.
$entityKey = $entity->{array_first_or_val($entity->getPrimaryFields())};
// Delete existing values.
((new DeleteQuery($entity->getDatabase(), $table))
->where($foreignKey, "=", $entityKey))
->execute();
// Get actual columns names.
$foreignKey = $this->getColumnName($foreignKey);
$foreignValue = $this->getColumnName($foreignValue);
// Insert new ones.
((new InsertQuery($entity->getDatabase(), $table)))
// Insert all values of the array.
->values(...array_map(fn (mixed $val) => ([
$foreignKey => $entityKey,
$foreignValue => $val,
]), $value))
->execute();
// Commit transaction.
$transaction->commit();
}
}

125
src/Entities.php Normal file
View file

@ -0,0 +1,125 @@
<?php
namespace Nest\Model;
use Nest\Database\Exceptions\Query\MissingConditionValueException;
use Nest\Database\Exceptions\Query\NoPrimaryFieldException;
use Nest\Database\Exceptions\UnknownDatabaseException;
use Nest\Exceptions\InvalidTypeException;
use Nest\Model\Exceptions\MissingRequiredFieldException;
use Nest\Model\Exceptions\UndefinedRelationException;
use Nest\Types\Exceptions\IncompatibleTypeException;
use Nest\Model\Query\EntityQuery;
use function Nest\Utils\array_first;
/**
* Class of a collection of entities, to perform operations on all of them.
* @template Entity
*/
class Entities
{
/**
* Entities.
* @var Entity[]
*/
protected array $entities;
/**
* Create a collection of entities with an array of entities.
* @param Entity[] $entities
*/
public function __construct(array $entities)
{
$this->entities = $entities;
}
/**
* Get entities array.
* @return Entity[] The entities.
*/
public function get(): array
{
return $this->entities;
}
/**
* Set values to all the entities.
* @param string $name Name of the value to set.
* @param mixed $value Value to set.
* @return void
*/
public function __set(string $name, mixed $value): void
{
foreach ($this->entities as $entity)
$entity->$name = $value;
}
/**
* Get all values with the given name in the entities.
* @param string $name Name of the value to get.
* @return array Array of values.
*/
public function __get(string $name): array
{
$result = [];
foreach ($this->entities as $entity)
$result[] = $entity->$name;
return $result;
}
/**
* Call the given method on all entities.
* @param string $name Name of the method to call.
* @param array $arguments Arguments of the method.
* @return array Results of all calls.
*/
public function __call(string $name, array $arguments): array
{
$results = [];
foreach ($this->entities as $entity)
$results[] = $entity->$name(...$arguments);
return $results;
}
/**
* Save current entities states.
* @return bool True if something was saved, false otherwise.
* @throws IncompatibleTypeException
* @throws MissingConditionValueException
* @throws MissingRequiredFieldException
* @throws NoPrimaryFieldException
* @throws InvalidTypeException
*/
public function save(): bool
{
$result = false;
foreach ($this->entities as $entity)
// Set result to true if needed.
$result = $result || $entity->save();
return $result;
}
/**
* Load given relation in the entities.
* @param array<string|int, string|callable(EntityQuery): void> $relations Relations to load.
* @return $this
* @throws UnknownDatabaseException
* @throws IncompatibleTypeException
* @throws InvalidTypeException
* @throws MissingConditionValueException
* @throws UndefinedRelationException
*/
public function load(array $relations): static
{
// Do nothing if there are no entities.
if (empty($this->entities)) return $this;
// Normalize relations array.
$relations = EntityQuery::normalizeRelationsDefinition($relations);
// Perform load for all the entities.
array_first($this->entities)->query()->load($this->entities, $relations);
return $this;
}
}

586
src/Entity.php Normal file
View file

@ -0,0 +1,586 @@
<?php
namespace Nest\Model;
use Carbon\Carbon;
use Nest\Application;
use Nest\Database\Database;
use Nest\Database\Query\QueryBuilder;
use Nest\Database\Query\Raw;
use Nest\Events\HasEvents;
use Nest\Database\Exceptions\Query\MissingConditionValueException;
use Nest\Database\Exceptions\Query\NoPrimaryFieldException;
use Nest\Database\Exceptions\UnknownDatabaseException;
use Nest\Exceptions\InvalidTypeException;
use Nest\Model\Exceptions\MissingRequiredFieldException;
use Nest\Model\Exceptions\UndefinedRelationException;
use Nest\Model\Exceptions\UnhandledPropertyTypeException;
use Nest\Types\Exceptions\IncompatibleTypeException;
use Nest\Model\Events\AfterDelete;
use Nest\Model\Events\AfterInsert;
use Nest\Model\Events\AfterLoad;
use Nest\Model\Events\AfterPropertiesInitialization;
use Nest\Model\Events\AfterSave;
use Nest\Model\Events\AfterUpdate;
use Nest\Model\Events\BeforeDelete;
use Nest\Model\Events\BeforeInsert;
use Nest\Model\Events\BeforeLoad;
use Nest\Model\Events\BeforePropertiesInitialization;
use Nest\Model\Events\BeforeSave;
use Nest\Model\Events\BeforeUpdate;
use Nest\Model\Events\EntityDefinitionEvent;
use Nest\Model\Query\EntityQuery;
use Nest\Types\SqlType;
use function Nest\Utils\array_first;
use function Nest\Utils\get_class_name_from_fullname;
use function Nest\Utils\str_camel_to_snake;
use function Nest\Utils\str_snake_pluralize;
/**
* Class of an entity of the model.
*/
abstract class Entity
{
use HasEvents;
/**
* Set if the entity is new (should be inserted) or not.
* @var bool
*/
protected bool $isNew = true;
/**
* Original properties values.
* @var array
*/
protected array $originals = [];
/**
* Entity definition cache.
* @var EntityBlueprint
*/
private EntityBlueprint $_definition;
/**
* Definition of the entity.
* @param EntityBlueprint $blueprint Entity blueprint to define.
* @return EntityBlueprint Defined entity blueprint.
*/
public abstract function definition(EntityBlueprint $blueprint): EntityBlueprint;
/**
* Get the entity definition (from cache if already requested).
* @return EntityBlueprint Defined entity blueprint.
*/
private function _getDefinition(): EntityBlueprint
{
if (empty($this->_definition))
{ // Get a new definition and save it in cached definition.
$this->_definition = $this->definition(new EntityBlueprint(static::class));
$this->getEventsManager()->fire(new EntityDefinitionEvent($this));
}
// Return cached definition.
return $this->_definition;
}
/**
* Initialize entity.
*/
public function __construct()
{
// Initialize properties.
$this->initializeProperties();
}
/**
* Initialize entity default properties.
* @return void
*/
protected function initializeProperties(): void
{
$this->getEventsManager()->fire(new BeforePropertiesInitialization($this));
foreach ($this->_getDefinition()->getProperties() as $propertyName => $property)
{ // For each property, set its default value if there is one.
if ($property instanceof ReadableFieldBlueprint && $property->hasCurrentDateByDefault())
$this->$propertyName = Carbon::now();
}
$this->getEventsManager()->fire(new AfterPropertiesInitialization($this));
}
/**
* Guess the entity database table name from its class name.
* Always use a plural snake_cased name.
* @return string The database table name of the entity, based on its class name.
*/
private function guessTableName(): string
{
return str_snake_pluralize(str_camel_to_snake(get_class_name_from_fullname(
(new \ReflectionClass($this))->isAnonymous() ? get_parent_class(static::class) : static::class,
)));
}
/**
* Get the entity database table name.
* @return string The database table name of the entity.
*/
public function getTableName(): string
{
return $this->_getDefinition()->getTable() ?? $this->guessTableName();
}
/**
* Check that the required fields are set.
* @return string[] Names of the missing required fields.
*/
public function getMissingRequiredFields(): array
{
// Get current entity definition.
$entityBlueprint = $this->_getDefinition();
// Get its required fields.
$requiredFields = array_filter($entityBlueprint->getProperties(), fn ($field) => $field instanceof ReadableFieldBlueprint && $field->isRequired());
// Initialize missing required fields list.
$missingRequiredFields = [];
foreach ($requiredFields as $requiredFieldName => $requiredField)
{ // If any required field is not set, add it in the list.
if (!isset($this->{$requiredFieldName}))
// A required field is not set.
$missingRequiredFields[] = $requiredFieldName;
}
return $missingRequiredFields; // All the missing required fields.
}
/**
* Check that all the required fields are set.
* Throw an exception if some are not set.
* @return void
* @throws MissingRequiredFieldException
*/
public function checkRequiredFields(): void
{
if (!empty($missingFields = $this->getMissingRequiredFields()))
// Some required fields are missing, throwing an exception.
throw new MissingRequiredFieldException($missingFields);
}
/**
* Get primary fields.
* @return string|string[] The primary field name, or array of fields when there are multiple primary fields.
*/
public function getPrimaryFields(): string|array
{
// Search primary fields.
$primaryFields = array_filter($this->_getDefinition()->getProperties(), fn ($field) => $field instanceof ReadableFieldBlueprint && $field->isPrimary());
// Return primary field(s).
return count($primaryFields) == 1 ? array_key_first($primaryFields) : array_keys($primaryFields);
}
/**
* Get database of the entity.
* @return Database Database of the entity.
* @throws UnknownDatabaseException
*/
public function getDatabase(): Database
{
return Application::get()->databases()->db($this->_getDefinition()->getDatabase());
}
/**
* Create a new SQL query for the entity.
* @return QueryBuilder SQL query builder.
* @throws UnknownDatabaseException
*/
public function sql(): QueryBuilder
{
return $this->getDatabase()->query($this->getTableName());
}
/**
* Create a new entity query.
* @return EntityQuery<static> Entity query builder.
* @throws UnknownDatabaseException
*/
public function query(): EntityQuery
{
// Create the query.
$query = new EntityQuery($this->getDatabase(), $this->getTableName(), $this);
// Automatically fill "with" with eager loaded properties.
$query->with(array_filter(array_map(fn ($property) => (
$property instanceof ReadableEntityPropertyBlueprint && $property->doEagerLoad()
), $this->_getDefinition()->getProperties())));
return $query; // Return the created query.
}
/**
* Load given relations in the entity.
* @param array<string|int, string|callable(EntityQuery): void> $relations Relations to load.
* @return $this
* @throws UnknownDatabaseException
* @throws IncompatibleTypeException
* @throws InvalidTypeException
* @throws MissingConditionValueException
* @throws UndefinedRelationException
* @throws UnhandledPropertyTypeException
*/
public function load(array $relations): static
{
// Normalize relations array.
$relations = EntityQuery::normalizeRelationsDefinition($relations);
$this->getEventsManager()->fire(new BeforeLoad($this, $relations));
// Perform load for the current entity.
$this->query()->load([$this], $relations);
$this->getEventsManager()->fire(new AfterLoad($this, $relations));
return $this;
}
/**
* Create a new entity of the same type.
* @return static A new entity of the same type.
*/
public function new(): static
{
return new static();
}
/**
* Set an original property value.
* @param string $propertyName Property name.
* @param mixed $propertyValue Property value.
* @return void
*/
public function setOriginalProperty(string $propertyName, mixed $propertyValue): void
{
$this->$propertyName = $propertyValue;
$this->originals[$propertyName] = $propertyValue;
}
/**
* Find changes made on the entity.
* @return array<string, mixed> New properties values.
*/
public function diff(): array
{
// Initialize diff array.
$diff = [];
foreach ($this->_getDefinition()->getProperties() as $propertyName => $propertyDefinition)
{ // Add current property value to diff if it's different from original value.
if (!($propertyDefinition instanceof FieldBlueprint || $propertyDefinition instanceof ArrayBlueprint))
// Only keep normal fields.
continue;
if (isset($this->$propertyName) && (!isset($this->originals[$propertyName]) || $this->originals[$propertyName] != $this->$propertyName))
// Current value is different from original one, adding it to the diff.
$diff[$propertyName] = $this->$propertyName;
}
return $diff; // Return properties diff.
}
/**
* Save the current entity state.
* @return bool True if something was saved, false otherwise.
* @throws IncompatibleTypeException
* @throws MissingConditionValueException
* @throws MissingRequiredFieldException
* @throws NoPrimaryFieldException
* @throws UnhandledPropertyTypeException
* @throws UnknownDatabaseException
*/
public function save(): bool
{
$this->getEventsManager()->fire(new BeforeSave($this));
// Check required fields to save.
$this->checkRequiredFields();
if ($this->isNew)
{ // This is a new entity, inserting it, then reading the result.
$this->fromSqlProperties($this->insert());
}
else
{ // The entity already exists, updating it.
// Get entity diff.
$diff = $this->diff();
// No diff, return false because there is nothing to update.
if (empty($diff)) return false;
// Get properties definition.
$properties = $this->_getDefinition()->getProperties();
foreach ($diff as $propertyName => $propertyValue)
{ // For each property in diff, check if it's an array field.
if ($properties[$propertyName] instanceof ArrayBlueprint)
{ // If the property is an array field, save it then remove it from diff.
(new ArrayInstance($properties[$propertyName]))->save($this, $propertyValue);
unset($diff[$propertyName]);
}
}
// Update every else property (if there are still some).
if (!empty($diff))
$this->fromSqlProperties($this->update($diff));
}
$this->getEventsManager()->fire(new AfterSave($this));
return true; // Entity saved.
}
/**
* Delete the entity.
* @return bool
* @throws MissingConditionValueException
* @throws NoPrimaryFieldException
* @throws UnknownDatabaseException
*/
public function delete(): bool
{
$this->getEventsManager()->fire(new BeforeDelete($this));
// Delete current entity.
$this->sql()->newDelete()->whereKeyOf($this)->execute();
// Set entity as new, in case of a save after deletion.
$this->isNew = true;
$this->getEventsManager()->fire(new AfterDelete($this));
return true; // Entity is deleted.
}
/**
* Insert current entity values.
* @return object Raw inserted object.
* @throws IncompatibleTypeException
* @throws UnknownDatabaseException
*/
private function insert(): object
{
$this->getEventsManager()->fire(new BeforeInsert($this));
// Return the first result (= the sole inserted row).
$result = array_first(
// Execute INSERT in RETURNING mode.
$this->sql()->newInsert()->values(
// Extract properties in an SQL row values array.
$this->toSqlProperties()
)->execute(true)
);
$this->getEventsManager()->fire(new AfterInsert($this));
return $result;
}
/**
* Update given entity values.
* @param array<string, mixed> $values Values to update.
* @return object Raw updated object.
* @throws MissingConditionValueException
* @throws IncompatibleTypeException
* @throws NoPrimaryFieldException
* @throws UnknownDatabaseException
*/
private function update(array $values): object
{
$this->getEventsManager()->fire(new BeforeUpdate($this));
// Return the first result (= the sole updated row).
$result = array_first(
// Execute UPDATE in RETURNING mode.
$this->sql()->newUpdate()->set($this->valuesToSql($values))
->whereKeyOf($this)
->execute(true)
);
$this->getEventsManager()->fire(new AfterUpdate($this));
return $result;
}
/**
* Get relation of the given name.
* @param string $relationName Name of the relation to get.
* @return EntityPropertyInstance The property instance.
* @throws UndefinedRelationException
*/
public function getRelation(string $relationName): EntityPropertyInstance
{
// Get defined properties for the entity.
$properties = $this->_getDefinition()->getProperties();
if (!empty($properties[$relationName]))
{ // A property with the relation name exists, trying to get its relation definition.
$definition = $properties[$relationName];
if (is_a($definition, EntityPropertyBlueprint::class))
// Initialize a property instance.
return new EntityPropertyInstance($definition);
else
{ // Not an entity blueprint, throw an exception.
throw new UndefinedRelationException(static::class, $relationName);
}
}
else
{ // No property with this name, throw an exception.
throw new UndefinedRelationException(static::class, $relationName);
}
}
/**
* Convert given values to SQL from matching properties types.
* @param array<string, mixed> $values Values to convert to SQL value.
* @return array<string, Raw|string|int|float|null> SQL values.
* @throws IncompatibleTypeException
*/
private function valuesToSql(array $values): array
{
// Get properties definition.
$propertiesDefinition = $this->_getDefinition()->getProperties();
foreach ($values as $propertyName => $value)
{ // For each value, try to convert its data.
// Keep the value (and convert it) if there is a matching property.
if (!empty($propertiesDefinition[$propertyName]))
{
// Get property type.
$propertyType = $propertiesDefinition[$propertyName]->getTypeInstance();
if (!$propertyType instanceof SqlType)
// Type is not an SQL type, throwing an exception.
throw new IncompatibleTypeException(get_class($propertyType), SqlType::class);
// Converting property value and put it in the SQL object.
$values[$propertyName] = $propertyType->toSql($value);
}
else
// No property matching the current value, unset it.
unset($values[$propertyName]);
}
return $values; // Return converted values.
}
/**
* Import properties from a raw SQL object.
* @param object $sqlProperties Raw SQL object where to extract properties.
* @param string $prefix Optional prefix of properties in SQL.
* @return void
* @throws IncompatibleTypeException
* @throws UnhandledPropertyTypeException
*/
public function fromSqlProperties(object $sqlProperties, string $prefix = ""): void
{
$this->isNew = false; // The entity already exists, as it's read from SQL properties.
foreach ($this->_getDefinition()->getProperties() as $propertyName => $propertyDefinition)
{ // For each defined property, try to read its data.
// Prepend property name by the prefix.
$propertyName = $prefix.$propertyName;
// Try to read only if a value is present.
if (isset($sqlProperties->$propertyName))
{ // Get property type.
if (!($propertyDefinition instanceof FieldBlueprint || $propertyDefinition instanceof ArrayBlueprint))
throw new UnhandledPropertyTypeException($propertyName, get_class($propertyDefinition));
// Get property type instance.
$propertyType = $propertyDefinition->getTypeInstance();
if (!$propertyType instanceof SqlType)
// Type is not an SQL type, throwing an exception.
throw new IncompatibleTypeException(get_class($propertyType), SqlType::class);
if ($propertyDefinition instanceof FieldBlueprint)
{ // Read direct value.
// Parsing property value and put it in the entity.
$value = $propertyType->fromSql($sqlProperties->$propertyName);
}
else
{ // Read JSON array value.
$value = json_decode($sqlProperties->$propertyName);
// Parsing array values.
foreach ($value as &$val) $val = $propertyType->fromSql($val);
}
// Set original property value.
$this->setOriginalProperty($propertyName, $value);
}
}
}
/**
* Export properties to a raw SQL object.
* @return array<string, Raw|string|int|float|null> Raw SQL array where properties are extracted.
* @param string $prefix Optional prefix of properties in SQL.
* @throws IncompatibleTypeException
*/
public function toSqlProperties(string $prefix = ""): array
{
// Initialize raw SQL array.
$object = [];
foreach ($this->_getDefinition()->getProperties() as $propertyName => $propertyDefinition)
{ // Get SQL value of each defined property.
// Prepend property name by the prefix.
$propertyName = $prefix.$propertyName;
if (isset($this->$propertyName))
{ // If property is set, converting it.
// Get property type.
$propertyType = $propertyDefinition->getTypeInstance();
if (!$propertyType instanceof SqlType)
// Type is not an SQL type, throwing an exception.
throw new IncompatibleTypeException(get_class($propertyType), SqlType::class);
// Converting property value and put it in the SQL object.
$object[$propertyName] = $propertyType->toSql($this->$propertyName);
}
}
return $object; // Return built raw SQL array.
}
/**
* Generate columns SELECT for the defined properties.
* @param string $prefix Prefix to add to the name of the properties.
* @return Raw[] Selected columns.
* @throws UnknownDatabaseException
* @throws MissingConditionValueException
*/
public function sqlSelectFields(string $prefix = ""): array
{
$select = [];
foreach ($this->_getDefinition()->getProperties() as $propertyName => $property)
{ // For each field, generate a column selection.
if ($property instanceof FieldBlueprint)
{ // It's a field property, generate a SELECT for it.
$select[] = new Raw("\"{$this->getTableName()}\".\"$propertyName\"".(!empty($prefix) ? " AS \"$prefix$propertyName\"" : ""));
}
elseif ($property instanceof ArrayBlueprint)
{ // It's an array field, generate a SELECT for it.
$select[] = (new ArrayInstance($property))->genSelect($this->query(), $prefix);
}
}
return $select;
}
}

155
src/EntityBlueprint.php Normal file
View file

@ -0,0 +1,155 @@
<?php
namespace Nest\Model;
use Nest\Types\Definitions\CarbonType;
use Nest\Types\Definitions\Integers\BigintType;
/**
* @template T
*/
class EntityBlueprint
{
/**
* Entity class.
* @var class-string<T>
*/
protected string $entityClass;
/**
* Database identifier of the entity.
* @var string|null
*/
protected ?string $database = null;
/**
* Database table name of the entity.
* @var string|null
*/
protected ?string $table = null;
/**
* Properties definition array.
* @var array<string, (ReadableFieldBlueprint|ReadableArrayBlueprint|ReadableEntityPropertyBlueprint)>
*/
protected array $properties = [];
/**
* @param class-string<T> $entityClass Entity class.
*/
public function __construct(string $entityClass)
{
$this->entityClass = $entityClass;
}
/**
* Set the database table name of the entity.
* @param string $table The database table name of the entity.
* @return $this
*/
public function setTable(string $table): static
{
$this->table = $table;
return $this;
}
/**
* Get the database table name of the entity.
* @return string|null The database table name.
*/
public function getTable(): ?string
{
return $this->table;
}
/**
* Set the database of the entity.
* @param string $databaseIdentifier The database identifier.
* @return $this
*/
public function setDatabase(string $databaseIdentifier): static
{
$this->database = $databaseIdentifier;
return $this;
}
/**
* Get the database of the entity.
* @return string|null The database identifier.
*/
public function getDatabase(): ?string
{
return $this->database ?? "default";
}
/**
* Get the defined properties.
* @return array<string, (ReadableFieldBlueprint|ReadableArrayBlueprint|ReadableEntityPropertyBlueprint)> The defined properties.
*/
public function getProperties(): array
{
return $this->properties;
}
public function field(string $name, string $type): FieldBlueprint
{
return $this->properties[$name] = (new ReadableFieldBlueprint())->type($type);
}
public function id(string $name = "id", string $type = BigintType::class): FieldBlueprint
{
return $this->foreignId($name, $type)->autoIncrement()->primary();
}
public function foreignId(string $name, string $type = BigintType::class): FieldBlueprint
{
return $this->field($name, $type)->unsigned()->index();
}
public function createdAt(string $name = "created_at", string $type = CarbonType::class): FieldBlueprint
{
return $this->field($name, $type)->required()->currentDateByDefault();
}
public function updatedAt(string $name = "updated_at", string $type = CarbonType::class): FieldBlueprint
{
return $this->field($name, $type)->required()->currentDateByDefault()->currentDateOnUpdate();
}
/**
* Define an array field.
* @param string $name The name of the field.
* @param string $type The type of the array values.
* @return ArrayBlueprint The array blueprint.
*/
public function array(string $name, string $type): ArrayBlueprint
{
return $this->properties[$name] = (new ReadableArrayBlueprint($this->entityClass, $name))->type($type);
}
/**
* Define an entity property.
* @param string $name The name of the property.
* @param string $class The class of the property.
* @param bool $multiple Whether the property is an array or not.
* @return EntityPropertyBlueprint The property blueprint.
*/
public function entity(string $name, string $class, bool $multiple = false): EntityPropertyBlueprint
{
return $this->properties[$name] = (new ReadableEntityPropertyBlueprint())->class($class)->multiple($multiple);
}
/**
* Define an entities array property.
* @param string $name The name of the property.
* @param string $class The class of the property.
* @return EntityPropertyBlueprint The property blueprint.
*/
public function entities(string $name, string $class): EntityPropertyBlueprint
{
return $this->entity($name, $class, true);
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace Nest\Model;
abstract class EntityPropertyBlueprint
{
protected string $class;
protected bool $multiple;
protected bool $allowInline = true;
protected EntityPropertyMode $mode;
protected ?string $localKey = null;
protected ?string $relatedKey = null;
protected ?string $pivotTable = null;
protected ?string $pivotLocalKey = null;
protected ?string $pivotRelatedKey = null;
protected bool $eagerLoad = false;
public function class(string $class): static
{
$this->class = $class;
return $this;
}
public function multiple(bool $multiple = true): static
{
$this->multiple = $multiple;
return $this;
}
/**
* Set if inline loading of the relation is allowed or not.
* @param bool $inline True to allow relation inline loading.
* @return $this
*/
public function allowInline(bool $inline = true): static
{
$this->allowInline = $inline;
return $this;
}
public function fromLocal(?string $localKey = null, ?string $relatedKey = null): static
{
$this->mode = EntityPropertyMode::LOCAL;
$this->localKey = $localKey;
$this->relatedKey = $relatedKey;
return $this;
}
public function fromRelated(?string $relatedKey = null, ?string $localKey = null): static
{
$this->mode = EntityPropertyMode::RELATED;
$this->localKey = $localKey;
$this->relatedKey = $relatedKey;
return $this;
}
public function localKey(string $localKey): static
{
$this->localKey = $localKey;
return $this;
}
public function relatedKey(string $relatedKey): static
{
$this->relatedKey = $relatedKey;
return $this;
}
public function fromPivot(string $pivotTable, ?string $pivotLocalKey = null, ?string $pivotRelatedKey = null): static
{
$this->mode = EntityPropertyMode::PIVOT;
$this->pivotLocalKey = $pivotLocalKey;
$this->pivotRelatedKey = $pivotRelatedKey;
return $this;
}
public function pivotLocalKey(string $pivotLocalKey): static
{
$this->pivotLocalKey = $pivotLocalKey;
return $this;
}
public function pivotRelatedKey(string $pivotRelatedKey): static
{
$this->pivotRelatedKey = $pivotRelatedKey;
return $this;
}
/**
* Set if the related field should be eager loaded or not.
* Only apply on related fields.
* @param bool $load True to eager load the field.
* @return $this
*/
public function eagerLoad(bool $load = true): static
{
$this->eagerLoad = $load;
return $this;
}
}

View file

@ -0,0 +1,200 @@
<?php
namespace Nest\Model;
use Nest\Database\Query\Raw;
use Nest\Database\Exceptions\UnknownDatabaseException;
use Nest\Model\Query\EntityQuery;
use function Nest\Utils\array_first;
use function Nest\Utils\array_first_or_val;
use function Nest\Utils\str_snake_singularize;
class EntityPropertyInstance extends ReadableEntityPropertyBlueprint
{
/**
* Goal entity instance.
* @var Entity
*/
private Entity $entity;
/**
* Create an entity property instance from an entity property definition.
* @param EntityPropertyBlueprint $entityProperty The entity property to copy from.
*/
public function __construct(EntityPropertyBlueprint $entityProperty)
{
// Copy all fields.
$this->class = $entityProperty->class;
$this->multiple = $entityProperty->multiple;
$this->allowInline = $entityProperty->allowInline;
$this->mode = $entityProperty->mode;
$this->localKey = $entityProperty->localKey;
$this->relatedKey = $entityProperty->relatedKey;
$this->pivotTable = $entityProperty->pivotTable;
$this->pivotLocalKey = $entityProperty->pivotLocalKey;
$this->pivotRelatedKey = $entityProperty->pivotRelatedKey;
$this->eagerLoad = $entityProperty->eagerLoad;
// Initialize goal entity.
$this->entity = new ($this->getClass())();
}
/**
* Create a new related entity.
* @return Entity A new related entity instance.
*/
public function newEntity(): Entity
{
return $this->entity->new();
}
/**
* Determine if the property can be inline loaded.
* @return bool True if the property can be inline loaded.
*/
public function canInlineLoad(): bool
{
return !$this->isMultiple() && $this->isInlineAllowed();
}
/**
* Build a query to retrieve relations for the given entities.
* @param Entity[] $entities Entities for which to retrieve relations.
* @return EntityQuery Built entities query.
* @throws UnknownDatabaseException
*/
public function queryFor(array $entities): EntityQuery
{
// Initialize query.
$query = $this->entity->query();
if (empty($entities))
// No entities, return a simple query without conditions.
return $query;
// Get a reference entity.
$referenceEntity = array_first($entities);
// Entity primary key.
$entityPrimaryKey = array_first_or_val($referenceEntity->getPrimaryFields());
// Get keys from entities list.
$entitiesKeys = array_column($entities, $entityPrimaryKey);
switch ($this->getMode())
{ // Build query depending on the relation mode.
case EntityPropertyMode::LOCAL:
$query
->select(new Raw("\"{$referenceEntity->getTableName()}\".\"$entityPrimaryKey\" AS \"__reference_key\""))
// Generate SELECT for the related entity.
->select(...$this->entity->sqlSelectFields())
->innerJoin($referenceEntity->getTableName())->on(
"{$this->entity->getTableName()}.".(
$this->getRelatedKey() ?? array_first_or_val($this->entity->getPrimaryFields())
),
"=",
"{$referenceEntity->getTableName()}.".(
$this->getLocalKey() ?? str_snake_singularize($this->entity->getTableName())."_id"
),
)->whereIn(
"{$referenceEntity->getTableName()}.$entityPrimaryKey", $entitiesKeys
);
break;
case EntityPropertyMode::RELATED:
$query
->select(new Raw("\"{$this->entity->getTableName()}\".\"".(
$this->getRelatedKey() ?? str_snake_singularize($referenceEntity->getTableName())."_id"
)."\" AS \"__reference_key\""))
// Generate SELECT for the related entity.
->select(...$this->entity->sqlSelectFields())
->whereIn(
"{$this->entity->getTableName()}.".(
$this->getRelatedKey() ?? str_snake_singularize($referenceEntity->getTableName())."_id"
)."",
$entitiesKeys
);
break;
case EntityPropertyMode::PIVOT:
$query
->select(new Raw("\"{$this->entity->getTableName()}\".\"".(
$this->getPivotLocalKey() ?? str_snake_singularize($referenceEntity->getTableName())."_id"
)."\" AS \"__reference_key\""))
// Generate SELECT for the related entity.
->select(...$this->entity->sqlSelectFields())
->innerJoin($this->getPivotTable())->on(
"{$query->getTableName()}.".(
$this->getRelatedKey() ?? $query->getPrimaryKeyName()
),
"=",
"{$this->getPivotTable()}.".(
$this->getPivotLocalKey() ?? str_snake_singularize($query->getTableName())."_id"
),
)
->whereIn(
"{$this->getPivotTable()}.".(
$this->getPivotLocalKey() ?? str_snake_singularize($referenceEntity->getTableName())."_id"
),
$entitiesKeys
);
break;
}
return $query;
}
/**
* Setup inline loading for the current property in an entity query.
* @param EntityQuery $query The entity query to alter.
* @return void
*/
public function setupInlineLoading(EntityQuery $query): void
{
switch ($this->getMode())
{ // Add join depending on the relation mode.
case EntityPropertyMode::LOCAL:
$query
->leftJoin($this->entity->getTableName())->on(
"{$query->getTableName()}.".(
$this->getLocalKey() ?? str_snake_singularize($this->entity->getTableName())."_id"
),
"=",
"{$this->entity->getTableName()}.".(
$this->getRelatedKey() ?? array_first_or_val($this->entity->getPrimaryFields())
),
);
break;
case EntityPropertyMode::RELATED:
$query
->leftJoin($this->entity->getTableName())->on(
"{$query->getTableName()}.".(
$this->getLocalKey() ?? $query->getPrimaryKeyName()
),
"=",
"{$this->entity->getTableName()}.".(
$this->getRelatedKey() ?? str_snake_singularize($query->getTableName())."_id"
),
);
break;
case EntityPropertyMode::PIVOT:
$query
->leftJoin($this->getPivotTable())->on(
"{$query->getTableName()}.".(
$this->getLocalKey() ?? $query->getPrimaryKeyName()
),
"=",
"{$this->getPivotTable()}.".(
$this->getPivotLocalKey() ?? str_snake_singularize($query->getTableName())."_id"
),
)
->leftJoin($this->entity->getTableName())->on(
"{$this->getPivotTable()}.".(
$this->getPivotRelatedKey() ?? str_snake_singularize($this->entity->getTableName())."_id"
),
"=",
"{$this->entity->getTableName()}.".(
$this->getRelatedKey() ?? array_first_or_val($this->entity->getPrimaryFields())
),
);
break;
}
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model;
enum EntityPropertyMode: string
{
case LOCAL = "local";
case RELATED = "related";
case PIVOT = "pivot";
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired after entity delete.
*/
class AfterDelete extends EntityEvent
{
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired after entity insert.
*/
class AfterInsert extends EntityEvent
{
}

21
src/Events/AfterLoad.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace Nest\Model\Events;
use Nest\Model\Entity;
use Nest\Model\Query\EntityQuery;
/**
* Event fired after entity relations load.
*/
class AfterLoad extends EntityEvent
{
/**
* @param Entity $entity The entity on which the event is fired.
* @param array<string, callable(EntityQuery): void|true> $relations Loaded relations.
*/
public function __construct(Entity $entity, public readonly array $relations)
{
parent::__construct($entity);
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired after entity properties initialization.
*/
class AfterPropertiesInitialization extends EntityEvent
{
}

10
src/Events/AfterSave.php Normal file
View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired after entity save.
*/
class AfterSave extends EntityEvent
{
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired after entity update.
*/
class AfterUpdate extends EntityEvent
{
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired before entity delete.
*/
class BeforeDelete extends EntityEvent
{
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired before entity insert.
*/
class BeforeInsert extends EntityEvent
{
}

21
src/Events/BeforeLoad.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace Nest\Model\Events;
use Nest\Model\Entity;
use Nest\Model\Query\EntityQuery;
/**
* Event fired before entity relations load.
*/
class BeforeLoad extends EntityEvent
{
/**
* @param Entity $entity The entity on which the event is fired.
* @param array<string, callable(EntityQuery): void|true> $relations Relations to load.
*/
public function __construct(Entity $entity, public readonly array $relations)
{
parent::__construct($entity);
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired before entity properties initialization.
*/
class BeforePropertiesInitialization extends EntityEvent
{
}

10
src/Events/BeforeSave.php Normal file
View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired before entity save.
*/
class BeforeSave extends EntityEvent
{
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired before entity update.
*/
class BeforeUpdate extends EntityEvent
{
}

View file

@ -0,0 +1,10 @@
<?php
namespace Nest\Model\Events;
/**
* Event fired when the entity definition function has been called.
*/
class EntityDefinitionEvent extends EntityEvent
{
}

View file

@ -0,0 +1,17 @@
<?php
namespace Nest\Model\Events;
use Nest\Model\Entity;
/**
* Any entity event.
*/
abstract class EntityEvent extends ModelEvent
{
/**
* @param Entity $entity The entity on which the event is fired.
*/
public function __construct(public readonly Entity $entity)
{}
}

12
src/Events/ModelEvent.php Normal file
View file

@ -0,0 +1,12 @@
<?php
namespace Nest\Model\Events;
use Nest\Events\Event;
/**
* Any model event.
*/
abstract class ModelEvent extends Event
{
}

View file

@ -0,0 +1,21 @@
<?php
namespace Nest\Model\Exceptions;
use Throwable;
/**
* Exception thrown when a required field of a model is missing.
*/
class MissingRequiredFieldException extends ModelException
{
/**
* @param string[] $missingFields Names of required fields that are missing.
* @param int $code [optional] The Exception code.
* @param null|Throwable $previous [optional] The previous throwable used for the exception chaining.
*/
public function __construct(public array $missingFields, int $code = 0, ?Throwable $previous = null)
{
parent::__construct("The following fields are required: ".implode(", ", $this->missingFields), $code, $previous);
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace Nest\Model\Exceptions;
use Nest\Exceptions\Exception;
class ModelException extends Exception
{
}

View file

@ -0,0 +1,22 @@
<?php
namespace Nest\Model\Exceptions;
use Throwable;
/**
* Exception thrown when a requested relation is undefined.
*/
class UndefinedRelationException extends ModelException
{
/**
* @param string $modelClass Class of the model where the relation is undefined.
* @param string $relationName Name of the undefined relation.
* @param int $code [optional] The Exception code.
* @param null|Throwable $previous [optional] The previous throwable used for the exception chaining.
*/
public function __construct(public readonly string $modelClass, public readonly string $relationName, int $code = 0, ?Throwable $previous = null)
{
parent::__construct("Undefined relation $this->relationName in $this->modelClass.", $code, $previous);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Nest\Model\Exceptions;
use Throwable;
/**
* Thrown when a property with an unhandled type has been provided.
*/
class UnhandledPropertyTypeException extends ModelException
{
/**
* @param string $propertyName Name of the provided property.
* @param class-string $propertyType Class name of the unhandled property type.
* @param int $code [optional] The Exception code.
* @param null|Throwable $previous [optional] The previous throwable used for the exception chaining.
*/
public function __construct(public readonly string $propertyName, public readonly string $propertyType, int $code = 0, ?Throwable $previous = null)
{
parent::__construct("Unhandled property $this->propertyName of type $this->propertyType.", $code, $previous);
}
}

77
src/FieldBlueprint.php Normal file
View file

@ -0,0 +1,77 @@
<?php
namespace Nest\Model;
abstract class FieldBlueprint
{
protected string $type;
protected bool $primary = false;
protected bool $index = false;
protected bool $unique = false;
protected bool $required = false;
protected bool $autoIncrement = false;
protected bool $unsigned = false;
protected bool $currentDateByDefault = false;
protected bool $currentDateOnUpdate = false;
public function type(string $type): static
{
$this->type = $type;
return $this;
}
public function primary(bool $primary = true): static
{
$this->primary = $primary;
return $this;
}
public function index(bool $index = true): static
{
$this->index = $index;
return $this;
}
public function unique(bool $unique = true): static
{
$this->unique = $unique;
return $this;
}
public function required(bool $required = true): static
{
$this->required = $required;
return $this;
}
public function autoIncrement(bool $autoIncrement = true): static
{
$this->autoIncrement = $autoIncrement;
return $this;
}
public function unsigned(bool $unsigned = true): static
{
$this->unsigned = $unsigned;
return $this;
}
public function currentDateByDefault(bool $currentDateByDefault = true): static
{
$this->currentDateByDefault = $currentDateByDefault;
return $this;
}
public function currentDateOnUpdate(bool $currentDateOnUpdate = true): static
{
$this->currentDateOnUpdate = $currentDateOnUpdate;
return $this;
}
}

344
src/Query/EntityQuery.php Normal file
View file

@ -0,0 +1,344 @@
<?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;
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace Nest\Model;
use Nest\Types\Type;
class ReadableArrayBlueprint extends ArrayBlueprint
{
public function getType(): string
{
return $this->type;
}
/**
* Get type instance of the given field.
* @return Type Type instance.
*/
public function getTypeInstance(): Type
{
return new $this->type();
}
public function getTable(): ?string
{
return $this->table;
}
public function getForeignKeyName(): ?string
{
return $this->foreignKeyName;
}
public function getForeignValueName(): ?string
{
return $this->foreignValueName;
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace Nest\Model;
class ReadableEntityPropertyBlueprint extends EntityPropertyBlueprint
{
public function getClass(): string
{
return $this->class;
}
public function isMultiple(): bool
{
return $this->multiple;
}
public function isInlineAllowed(): bool
{
return $this->allowInline;
}
public function getMode(): EntityPropertyMode
{
return $this->mode;
}
public function getLocalKey(): ?string
{
return $this->localKey;
}
public function getRelatedKey(): ?string
{
return $this->relatedKey;
}
public function getPivotTable(): ?string
{
return $this->pivotTable;
}
public function getPivotLocalKey(): ?string
{
return $this->pivotLocalKey;
}
public function getPivotRelatedKey(): ?string
{
return $this->pivotRelatedKey;
}
/**
* @return bool True if the field should be eager loaded.
*/
public function doEagerLoad(): bool
{
return $this->eagerLoad;
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace Nest\Model;
use Nest\Types\Type;
class ReadableFieldBlueprint extends FieldBlueprint
{
public function getType(): string
{
return $this->type;
}
/**
* Get type instance of the given field.
* @return Type Type instance.
*/
public function getTypeInstance(): Type
{
return new $this->type();
}
public function isPrimary(): bool
{
return $this->primary;
}
public function isIndexed(): bool
{
return $this->index;
}
public function isUnique(): bool
{
return $this->unique;
}
public function isRequired(): bool
{
return $this->required;
}
public function isAutoIncrementing(): bool
{
return $this->autoIncrement;
}
public function isUnsigned(): bool
{
return $this->unsigned;
}
public function hasCurrentDateByDefault(): bool
{
return $this->currentDateByDefault;
}
public function hasCurrentDateOnUpdate(): bool
{
return $this->currentDateOnUpdate;
}
}