commit 268a6937b123f7454b606a2caee9e5ecb0623b55 Author: Madeorsk Date: Fri Nov 8 17:12:46 2024 +0100 Initialize Nest model library. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dfd9ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# IDEA +.idea/ +*.iml + +# Composer +vendor/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f42a331 --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "version": "1.0", + "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" + } +} diff --git a/src/ArrayBlueprint.php b/src/ArrayBlueprint.php new file mode 100644 index 0000000..e786ae4 --- /dev/null +++ b/src/ArrayBlueprint.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/src/ArrayInstance.php b/src/ArrayInstance.php new file mode 100644 index 0000000..71c9f88 --- /dev/null +++ b/src/ArrayInstance.php @@ -0,0 +1,120 @@ +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(); + } +} diff --git a/src/Entities.php b/src/Entities.php new file mode 100644 index 0000000..487a8d0 --- /dev/null +++ b/src/Entities.php @@ -0,0 +1,125 @@ +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 $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; + } +} diff --git a/src/Entity.php b/src/Entity.php new file mode 100644 index 0000000..7e501c6 --- /dev/null +++ b/src/Entity.php @@ -0,0 +1,586 @@ +_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 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 $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 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 $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 $values Values to convert to SQL value. + * @return array 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 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; + } +} diff --git a/src/EntityBlueprint.php b/src/EntityBlueprint.php new file mode 100644 index 0000000..8a6fb46 --- /dev/null +++ b/src/EntityBlueprint.php @@ -0,0 +1,155 @@ + + */ + 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 + */ + protected array $properties = []; + + /** + * @param class-string $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 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); + } +} diff --git a/src/EntityPropertyBlueprint.php b/src/EntityPropertyBlueprint.php new file mode 100644 index 0000000..5328012 --- /dev/null +++ b/src/EntityPropertyBlueprint.php @@ -0,0 +1,106 @@ +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; + } +} diff --git a/src/EntityPropertyInstance.php b/src/EntityPropertyInstance.php new file mode 100644 index 0000000..909a420 --- /dev/null +++ b/src/EntityPropertyInstance.php @@ -0,0 +1,200 @@ +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; + } + } +} diff --git a/src/EntityPropertyMode.php b/src/EntityPropertyMode.php new file mode 100644 index 0000000..113e6ad --- /dev/null +++ b/src/EntityPropertyMode.php @@ -0,0 +1,10 @@ + $relations Loaded relations. + */ + public function __construct(Entity $entity, public readonly array $relations) + { + parent::__construct($entity); + } +} diff --git a/src/Events/AfterPropertiesInitialization.php b/src/Events/AfterPropertiesInitialization.php new file mode 100644 index 0000000..609abbf --- /dev/null +++ b/src/Events/AfterPropertiesInitialization.php @@ -0,0 +1,10 @@ + $relations Relations to load. + */ + public function __construct(Entity $entity, public readonly array $relations) + { + parent::__construct($entity); + } +} diff --git a/src/Events/BeforePropertiesInitialization.php b/src/Events/BeforePropertiesInitialization.php new file mode 100644 index 0000000..bffefda --- /dev/null +++ b/src/Events/BeforePropertiesInitialization.php @@ -0,0 +1,10 @@ +missingFields), $code, $previous); + } +} diff --git a/src/Exceptions/ModelException.php b/src/Exceptions/ModelException.php new file mode 100644 index 0000000..343d774 --- /dev/null +++ b/src/Exceptions/ModelException.php @@ -0,0 +1,9 @@ +relationName in $this->modelClass.", $code, $previous); + } +} diff --git a/src/Exceptions/UnhandledPropertyTypeException.php b/src/Exceptions/UnhandledPropertyTypeException.php new file mode 100644 index 0000000..cacae5c --- /dev/null +++ b/src/Exceptions/UnhandledPropertyTypeException.php @@ -0,0 +1,22 @@ +propertyName of type $this->propertyType.", $code, $previous); + } +} diff --git a/src/FieldBlueprint.php b/src/FieldBlueprint.php new file mode 100644 index 0000000..0061a09 --- /dev/null +++ b/src/FieldBlueprint.php @@ -0,0 +1,77 @@ +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; + } +} diff --git a/src/Query/EntityQuery.php b/src/Query/EntityQuery.php new file mode 100644 index 0000000..5f585ee --- /dev/null +++ b/src/Query/EntityQuery.php @@ -0,0 +1,344 @@ + $relations Relations to normalize. + * @return array 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 + */ + 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 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 $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 $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; + } +} diff --git a/src/ReadableArrayBlueprint.php b/src/ReadableArrayBlueprint.php new file mode 100644 index 0000000..0076005 --- /dev/null +++ b/src/ReadableArrayBlueprint.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/src/ReadableEntityPropertyBlueprint.php b/src/ReadableEntityPropertyBlueprint.php new file mode 100644 index 0000000..d29cb54 --- /dev/null +++ b/src/ReadableEntityPropertyBlueprint.php @@ -0,0 +1,59 @@ +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; + } +} diff --git a/src/ReadableFieldBlueprint.php b/src/ReadableFieldBlueprint.php new file mode 100644 index 0000000..0702583 --- /dev/null +++ b/src/ReadableFieldBlueprint.php @@ -0,0 +1,62 @@ +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; + } +}