diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index 66b3b3a..be4274e 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -1,138 +1,48 @@ */ namespace Evas\Orm; -use Evas\Base\App; use Evas\Base\Help\HooksTrait; -use Evas\Db\Interfaces\DatabaseInterface; -use Evas\Db\Interfaces\QueryBuilderInterface; -use Evas\Db\Table; use Evas\Orm\Exceptions\LastInsertIdUndefinedException; +use Evas\Orm\Traits\ActiveRecordConvertTrait; +use Evas\Orm\Traits\ActiveRecordDatabaseTrait; +use Evas\Orm\Traits\ActiveRecordDataTrait; +use Evas\Orm\Traits\ActiveRecordRelatedsTrait; +use Evas\Orm\Traits\ActiveRecordRelationsTrait; +use Evas\Orm\Traits\ActiveRecordStateTrait; +use Evas\Orm\Traits\ActiveRecordTableTrait; +use Evas\Orm\Traits\ActiveRecordQueryTrait; -abstract class ActiveRecord -{ - // подключаем поддержку произвольных хуков в наследуемых классах - use HooksTrait; +use Evas\Orm\Traits\ActiveRecordIdentityTrait; - /** @var string имя соединения с базой данных*/ - public static $dbname; - /** @var string имя соединения с базой данных только для записи */ - public static $dbnameWrite; - /*** @var string кастомное имя таблицы */ - public static $tableName; - /** - * Получение соединения с базой данных. - * @param bool использовать ли соединение для записи - * @return DatabaseInterface - */ - // abstract public static function getDb(): DatabaseInterface; - public static function getDb(bool $write = false): DatabaseInterface - { - $dbname = static::getDbName($write); - return App::db($dbname); - } +class ActiveRecord implements \JsonSerializable +{ + // подключаем конвертацию + use ActiveRecordConvertTrait; - /** - * Получение имени соединения с базой данных. - * @param bool использовать ли соединение для записи - * @return string|null - */ - public static function getDbName(bool $write = false): ?string - { - return true === $write && !empty(static::$dbnameWrite) - ? static::$dbnameWrite : static::$dbname; - } + use ActiveRecordDatabaseTrait; - /** - * Генерация имени таблицы из имени класса. - * @return string - */ - public static function generateTableName(): string - { - $className = get_called_class(); - $lastSlash = strrpos($className, '\\'); - if ($lastSlash > 0) { - $className = substr($className, $lastSlash + 1); - } - $mapperPos = strrpos($className, 'Mapper'); - if (false !== $mapperPos) { - $className = substr($className, 0, $mapperPos); - } - return strtolower(preg_replace('/([a-z0-9]+)([A-Z]{1})/', '$1_$2', $className)) . 's'; - } + use ActiveRecordDataTrait; - /** - * Получение имени таблицы из маппинга моделей таблиц. - * @return string|null имя таблицы - */ - public static function tableNameFromMap(): ?string - { - return static::getDb()->modelTablesMap()->getModelTable(get_called_class()); - } + use ActiveRecordRelatedsTrait; - /** - * Получение имени таблицы. - * @return string - */ - public static function tableName(): string - { - if (empty(static::$tableName)) { - static::$tableName = static::generateTableName(); - } - return static::$tableName; - } + use ActiveRecordRelationsTrait; - /** - * Получение объекта таблицы. - * @return Table - */ - public static function table(): Table - { - return static::getDb()->table(static::tableName()); - } + use ActiveRecordStateTrait; - /** - * Получение первичного ключа. - * @param bool|null переполучить из схемы заново - * @return string - */ - public static function primaryKey(bool $reload = false): string - { - return static::table()->primaryKey($reload); - } + use ActiveRecordTableTrait; - /** - * Получение столбцов таблицы. - * @param bool|null переполучить из схемы заново - * @return array - */ - public static function columns(bool $reload = false): array - { - return static::table()->columns($reload); - } + use ActiveRecordQueryTrait; - /** - * Получение id последней записи. - * @return int|null - */ - public static function lastInsertId(): ?int - { - return static::table()->lastInsertId(); - } + // подключаем поддержку произвольных хуков в наследуемых классах + use HooksTrait; - /** - * Дефолтные значения новой записи. - * @return array - */ - public static function default(): array - { - return []; - } + use ActiveRecordIdentityTrait; /** * Конструктор. @@ -140,88 +50,17 @@ public static function default(): array */ public function __construct(array $props = null) { + $creating = empty($this->primaryValue()); + if (!$creating) $this->saveState(); $this->hook('beforeConstruct', $props); - - $pk = static::primaryKey(); - $creating = empty($props[$pk]); - - if ($creating) $this->hook('beforeCreate', $props); - else $this->hook('beforeGet', $props); + $this->hook($creating ? 'beforeCreate' : 'beforeGet', $props); if (!empty($props)) $this->fill($props); - if ($creating) $this->hook('afterCreate'); - else $this->hook('afterGet'); + $this->hook($creating ? 'afterCreate' : 'afterGet'); $this->hook('afterConstruct'); } - /** - * Заполнение модели свойствами. - * @param array свойства модели - * @return self - */ - public function fill(array $props): ActiveRecord - { - foreach ($props as $name => $value) { - $this->$name = $value; - } - return $this; - } - - - /** - * Получение маппинга данных записи для базы данных. - * @return array - */ - public function getRowProperties(): array - { - $props = []; - foreach (static::columns() as &$column) { - if (isset($this->$column)) { - $props[$column] = $this->$column; - } - } - return $props; - } - - /** - * Получение состояния сохраняемых полей. - * @return array - */ - public function getState(): array - { - $state = static::getDb()->identityMapGetState($this, static::primaryKey()); - foreach ($state as $name => &$value) { - if (!isset($value) || !in_array($name, static::columns())) { - unset($state[$name]); - } - } - return $state; - } - - /** - * Получение маппинга измененных свойств записи. - * @return array - */ - public function getUpdatedProperties(): array - { - $props = $this->getRowProperties(); - $pk = static::primaryKey(); - if (empty($this->$pk)) { - return $props; - } else { - // $state = static::getDb()->identityMapGetState($this, $pk); - $state = $this->getState(); - // return array_diff($props, $state ?? []); - - return array_merge( - array_fill_keys(array_keys(array_diff($state ?? [], $props)), null), - array_diff_assoc($props, $state ?? []) - ); - } - } - - /** * Сохранение записи. * @return self @@ -229,29 +68,53 @@ public function getUpdatedProperties(): array */ public function save() { - if (empty($this->getUpdatedProperties())) { + if (empty($this->getUpdatedProps())) { $this->hook('nothingSave'); return $this; } $this->hook('beforeSave'); $pk = static::primaryKey(); - if (empty($this->$pk)) { - $this->hook('beforeInsert'); - static::getDb(true)->insert(static::tableName(), $this->getUpdatedProperties()); - $this->$pk = static::lastInsertId(); - if (empty($this->$pk)) { - throw new LastInsertIdUndefinedException(); + if ($this->isStateHasPrimaryValue()) { + $this->hook('beforeUpdate'); + $props = $this->getUpdatedProps(); + if (empty($props)) { + $this->hook('nothingUpdate'); + return $this; + } else { + static::table(true)->where($pk, $this->$pk)->update($props); + $this->hook('afterUpdate', $props); } - $this->hook('afterInsert'); } else { - $this->hook('beforeUpdate'); - static::getDb(true)->update(static::tableName(), $this->getUpdatedProperties()) - ->where("$pk = ?", [$this->$pk])->one(); - $this->hook('afterUpdate'); + $this->hook('beforeInsert'); + $props = $this->getUpdatedProps(); + if (empty($props)) { + $this->hook('nothingInsert'); + return $this; + } else { + static::table(true)->insert($props); + $this->$pk = static::lastInsertId(); + if (empty($this->$pk)) { + throw new LastInsertIdUndefinedException(); + } + $this->hook('afterInsert', $props); + } + } + $this->hook('afterSave', $props); + // return static::getDb()->identityMapUpdate($this, $pk); + return $this->saveState(); + } + + /** + * Сохранение модели и её связей. + */ + public function push() + { + $this->save(); + foreach ($this->relatedCollections as $models) { + if (!is_array($models)) $models = [$models]; + foreach ($models as $model) $model->push(); } - $this->hook('afterSave'); - return static::getDb()->identityMapUpdate($this, $pk); } /** @@ -265,85 +128,24 @@ public function delete() return $this; } $this->hook('beforeDelete'); - $qr = static::getDb(true)->delete(static::tableName()) - ->where("$pk = ?", [$this->$pk])->one(); + $qr = static::table(true)->where($pk, $this->$pk)->limit(1)->delete(); $this->hook('afterDelete', $qr->rowCount()); if (0 < $qr->rowCount()) { - static::getDb()->identityMapUnset($this, $pk); - $this->id = null; + $this->identityMapRemove(); + $this->$pk = null; + $this->saveState(); } return $this; } /** - * Создание модели записи. - * @param array|null значения записи - * @return static - */ - public static function create(array $props = null): ActiveRecord - { - return new static($props); - } - - /** - * Создание модели записи с вставкой в базу. - * @param array|null значения записи - * @return static - */ - public static function insert(array $props = null): ActiveRecord - { - return static::create($props)->save(); - } - - /** - * Поиск записи через сборщик запроса. - * @param string|null столбцы - * @return QueryBuilderInterface - */ - public static function find(string $columns = null): QueryBuilderInterface - { - return static::getDb()->select(static::tableName(), $columns); - } - - /** - * Поиск по первичному ключу. - * @param int|string значение первичного ключа, перечисление - * @return static|array of static - */ - public static function findByPK(...$ids) - { - $pk = static::primaryKey(); - $qb = static::find(); - if (count($ids) > 1) { - return $qb->whereIn("`$pk`", $ids) - ->query(count($ids))->classObjectAll(static::class); - } else { - return $qb->where("`$pk` = ?", $ids) - ->one()->classObject(static::class); - } - } - - /** - * Поиск по id, алиас для findByPK. - * @param int id, перечисление - * @return static|array of static - */ - public static function findById(int ...$ids) - { - return static::findByPK(...$ids); - } - - /** - * Поиск по sql-запросу. - * @param string sql-запрос - * @param array|null значения запроса для экранирования - * @return static|static[] + * Обновление данных модели из базы. */ - public static function query(string $sql, array $values = null) + public function reload() { - $qr = static::getDb()->query($sql, $values); - return strstr($qr->stmt()->queryString, 'LIMIT 1') - ? $qr->classObject(static::class) - : $qr->classObjectAll(static::class); + return ($pv = $this->primaryValue()) + // ? static::find($pv) + ? $this->fill(static::find($pv)->toArray()) + : $this; } } diff --git a/src/IdentityMap.php b/src/IdentityMap.php new file mode 100644 index 0000000..4f0d732 --- /dev/null +++ b/src/IdentityMap.php @@ -0,0 +1,144 @@ + + */ +namespace Evas\Orm; + +use Evas\Orm\Identity\ModelIdentity; +use Evas\Orm\ActiveRecord; + +class IdentityMap +{ + /** @static self единственный экземляр IdentityMap */ + protected static $instance; + /** @var ActiveRecord[] модели */ + protected $models = []; + + /** + * Получение экземпляра IdentityMap. + * @return self + */ + public static function instance() + { + if (!static::$instance) static::$instance = new static; + return static::$instance; + } + + /** + * Конструктор. + */ + protected function __construct() + { + $this->resetModels(); + } + + /** + * Очистка моделей. + */ + public function resetModels() + { + $this->models = []; + return $this; + } + + /** + * Получение количества моделей в IdentityMap. + * @return int + */ + public static function count(): int + { + return count(static::instance()->models); + } + + /** + * Получение моделей IdentityMap. + * @return array + */ + public static function models(): array + { + return static::instance()->models; + } + + /** + * Проверка наличия модели в IdentityMap. + * @param ActiveRecord модель + * @return bool + */ + public static function has(ActiveRecord $model): bool + { + return isset(static::instance()->models[$model->identity()]); + } + + /** + * Добавление модели в IdentityMap. + * @param ActiveRecord модель + * @return self + */ + public static function set(ActiveRecord $model) + { + static::instance()->models[$model->identity()] = $model; + return static::instance(); + } + + /** + * Получение модели из IdentityMap или null. + * @param ActiveRecord модель + * @return ActiveRecord|null модель или null + */ + public static function get(ActiveRecord $model) + { + return static::instance()->models[$model->identity()] ?? null; + } + + /** + * Получение модели с установкой в случае отсутствия. + * @param ActiveRecord модель + * @return ActiveRecord модель + */ + public static function getWithSave(ActiveRecord $model) + { + if (!static::has($model)) { + static::set($model); + return $model; + } else { + $old = static::get($model); + // sync state + $props = $old->getUpdatedProps(); + $old->fill($model->getData()); + $old->saveState(); + $old->fill($props); + return $old; + } + } + + /** + * Удаление модели из IdentityMap. + * @param ActiveRecord модель + * @return self + */ + public static function unset(ActiveRecord $model) + { + unset(static::instance()->models[$model->identity()]); + return static::instance(); + } + + /** + * Удаление всех моделей из IdentityMap. + * @return self + */ + public static function unsetAll() + { + return static::instance()->resetModels(); + } + + /** + * Приведение IdentityMap к строке. + * @return string + */ + public function __toString() + { + return json_encode(['models_count' => static::count(), 'models' => $this->models]); + } +} diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php new file mode 100644 index 0000000..c13bf42 --- /dev/null +++ b/src/QueryBuilder.php @@ -0,0 +1,101 @@ + + */ +namespace Evas\Orm; + +use Evas\Db\Builders\QueryBuilder as DbQueryBuilder; +use Evas\Db\Interfaces\DatabaseInterface; +use Evas\Orm\Traits\QueryBuilderRelationsTrait; + +class QueryBuilder extends DbQueryBuilder +{ + use QueryBuilderRelationsTrait; + + /** @var string класс модели данных */ + protected $model; + + /** + * Расширяем конструктор передачей класса модели. + * @param DatabaseInterface + * @param string|null класс модели + */ + public function __construct(DatabaseInterface &$db, string $model = null) + { + parent::__construct($db); + if ($model) $this->fromModel($model); + } + + /** + * Установка модели и проброс таблицы модели. + * @param string модель + * @return self + * @throws \InvalidArgumentException + */ + public function fromModel(string $model) + { + if (!class_exists($model, true)) { + throw new \InvalidArgumentException(sprintf( + 'Class by name "%s" passed to 1 argument of %s() does not exist', + $model, __METHOD__ + )); + } + $this->model = $model; + $this->from($model::tableName()); + return $this; + } + + + /** + * Переопределяем получение первичного ключа таблицы. + * @return string + */ + protected function primaryKey(): string + { + return $this->model::primaryKey(); + } + + /** + * Выполнение select-запроса с получением нескольких записей. + * @param array|null столбцы для получения + * @return array найденные записи + */ + public function get($columns = null): array + { + $this->applyRelationsBefore(); + + if ($columns) $this->select(...func_get_args()); + $models = $this->query()->objectAll($this->model); + if (!$models) return $models; + $ids = []; + foreach ($models as &$model) { + $model = $model->identityMapSave(); + if (0 < count($this->withOne)) $this->parseWiths($model); + $ids[] = $model->primaryValue(); + } + // $models = array_unique($models); + $this->applyRelationsAfter($ids, $models); + return $models; + } + + /** + * Выполнение select-запроса с получением одной записи. + * @param array|null столбцы для получения + * @return array|null найденная запись + */ + public function one($columns = null) + { + $this->applyRelationsBefore(); + if ($columns) $this->select(...func_get_args()); + $model = $this->limit(1)->query()->object($this->model); + if ($model) { + if (0 < count($this->withOne)) $this->parseWiths($model); + $model = $model->identityMapSave(); + $this->applyRelationsAfter([$model->primaryValue(), [$model]]); + } + return $model; + // return is_null($model) ? $model : $model->identityMapSave(); + } +} diff --git a/src/RelatedCollection.php b/src/RelatedCollection.php new file mode 100644 index 0000000..25fe4a3 --- /dev/null +++ b/src/RelatedCollection.php @@ -0,0 +1,96 @@ + + */ +namespace Evas\Orm; + +use Evas\Base\Help\Collection; +use Evas\Orm\ActiveRecord; +use Evas\Orm\Relation; + +class RelatedCollection extends Collection +{ + /** @var ActiveRecord модель */ + protected $model; + /** @var Relation связь */ + protected $relation; + /** @var array значения первичных ключей моделей коллекции */ + protected $ids = []; + + /** + * Получение текущей модели коллекции. + * @return ActiveRecord|null модель + */ + public function current(): ?ActiveRecord + { + return parent::current(); + } + + /** + * Проверка наличия модели в коллекции. + * @param int|string значение первичного ключа + * @return bool + */ + public function has(int $id): bool + { + return in_array($id, $this->ids); + } + + /** + * Добавление элемента в коллекцию. + * @param mixed элемент + * @return self + */ + public function add($item) + { + if (!($item instanceof $this->relation->foreignModel)) { + if ($item instanceof ActiveRecord) $item = $item->toArray(); + $item = new $this->relation->foreignModel($item); + } + if (!($id = $item->primaryValue()) || !$this->has($id)) { + $this->ids[] = $id; + return parent::add($item); + } + return $this; + } + + /** + * Конструктор. + * @param ActiveRecord модель + * @param Relation связь + * @param array|Collection|null элементы для коллекции + */ + public function __construct(ActiveRecord $model, Relation $relation, $items = null) + { + $this->model = &$model; + $this->relation = $relation; + parent::__construct($items); + } + + /** + * Перезапрос связанных записей. + * @return self + */ + public function reload() + { + $this->items = $this->relation->foreignModel::where( + $this->relation->foreignKey, + $this->model->{$this->relation->localKey} + )->get(); + return $this; + } + + /** + * Сохраенение связанных записей. + * @return self + */ + public function save() + { + foreach ($this->items as &$item) { + $item->save(); + } + return $this; + } +} diff --git a/src/Relation.php b/src/Relation.php new file mode 100644 index 0000000..d13d4a2 --- /dev/null +++ b/src/Relation.php @@ -0,0 +1,199 @@ + + */ +namespace Evas\Orm; + +use Evas\Orm\ActiveRecord; +use Evas\Orm\QueryBuilder; + +class Relation +{ + /** @var string тип связи */ + public $type; + /** @var string класс локальной модели */ + public $localModel; + /** @var string локальный ключ */ + public $localKey; + /** @var string класс внешней модели */ + public $foreignModel; + /** @var string внешний ключ */ + public $foreignKey; + /** @var string имя связи */ + public $name; + + /** + * Конструктор. + * @param string тип связи + * @param string класс локальной модели + * @param string класс внешней модели + * @param string|null локальный ключ + * @param string|null внешний ключ + */ + public function __construct( + string $type, string $localModel, string $foreignModel, + string $foreignKey = null, string $localKey = null + ) { + if (!$foreignKey) { + // $foreignKey = static::generateForeignKey($localModel); + $foreignKey = ('belongsTo' === $type) + ? $foreignModel::primaryKey() + : static::generateForeignKey($localModel); + } + if (!$localKey) { + // $localKey = $localModel::primaryKey(); + $localKey = ('belongsTo' === $type) + ? static::generateForeignKey($foreignModel) + : $localModel::primaryKey(); + } + $this->type = $type; + $this->localModel = $localModel; + $this->foreignModel = $foreignModel; + + $this->localKey = $localKey; + $this->foreignKey = $foreignKey; + + $this->localTable = $localModel::tableName(); + $this->foreignTable = $foreignModel::tableName(); + $this->localFullKey = "$this->localTable.$this->localKey"; + $this->foreignFullKey = "$this->foreignTable.$this->foreignKey"; + $this->name = $this->foreignTable; + } + + /** + * Генерация внешнего ключа. + * @param string класс локальной модели + * @return string сгенерированный ключ + */ + protected static function generateForeignKey(string $localModel): string + { + $pk = $localModel::primaryKey(); + if ('id' === $pk) { + $pk = $localModel::tableName(); + if (($len = strlen($pk)) > 1 && strrpos($pk, 's') === $len - 1) { + $pk = substr($pk, 0, $len - 1); + } + $pk .= '_id'; + } + return $pk; + } + + /** + * Установка имени связей модели. + * @param string имя + * @return self + */ + public function setName(string $name) + { + $this->name = $name; + return $this; + } + + /** + * Получение внешнего столбца. + * @param string столбец + * @param bool|null исользовать ли имя связи вместо имение таблицы + * @return string + */ + public function foreignColumn(string $column, bool $useName = false): string + { + return ($useName ? $this->name : $this->foreignTable) .'.'. $column; + } + + /** + * Получение внешнего первичного ключа. + * @param bool|null исользовать ли имя связи вместо имение таблицы + * @return string + */ + public function foreignPrimary(bool $useName = false): string + { + return $this->foreignColumn($this->foreignModel::primaryKey(), $useName); + } + + /** + * Получение внешнего ключа связи. + * @param bool|null исользовать ли имя связи вместо имение таблицы + * @return string + */ + public function foreignKey(bool $useName = false): string + { + return $this->foreignColumn($this->foreignKey, $useName); + } + + /** + * Получение локального столбца. + * @param bool|null исользовать ли полный ключ + * @return string + */ + public function localColumn(string $column, bool $useFull = true): string + { + return ($useFull ? ($this->localTable.'.') : '') . $column; + } + + /** + * Получение локального ключа связи. + * @param bool|null исользовать ли полный ключ + * @return string + */ + public function localKey(bool $useFull = true): string + { + return $this->localColumn($this->localKey, $useFull); + } + + /** + * Получение аргументов для join. + * @param QueryBuilder + */ + public function joinArgs(QueryBuilder $query = null, bool $useName = false): array + { + return [ + $query ?? $this->foreignTable, $this->name, + $this->foreignKey($useName), $this->localKey(true) + ]; + } + + /** + * Добавление связи с моделью. + * @param ActiveRecord модель + * @param array данные внешней модели + * @return ActiveRecord модель + */ + public function addRelated(ActiveRecord $model, array $foreignData) + { + return $model->addRelated($this->name, new $this->foreignModel($foreignData)); + } + + /** + * Является ли связь связью к одному. + * @return bool + */ + public function isOne(): bool + { + return in_array($this->type, ['hasOne', 'belongsTo']); + } + + /** + * Является ли связь связью ко многим. + * @return bool + */ + public function isMany(): bool + { + return !$this->isOne(); + } + + + /** + * Приведение к строке. + * @return string + */ + public function __toString() + { + return json_encode([ + 'localFullKey' => $this->localFullKey, + 'foreignFullKey' => $this->foreignFullKey + ], JSON_UNESCAPED_UNICODE); + // return json_encode(get_object_vars($this), JSON_UNESCAPED_UNICODE); + } +} diff --git a/src/RelationsMap.php b/src/RelationsMap.php new file mode 100644 index 0000000..db996cc --- /dev/null +++ b/src/RelationsMap.php @@ -0,0 +1,131 @@ + + */ +namespace Evas\Orm; + +use Evas\Base\Help\PhpHelp; +use Evas\Orm\Exceptions\OrmException; +use Evas\Orm\Relation; + +class RelationsMap +{ + /** @static Relation[] by models */ + protected static $relations = []; + + /** + * Инициализция связей класса модели. + * @param string класс модели + * @throws OrmException + */ + public static function initRelations(string $modelClass) + { + $relations = $modelClass::relations(); + if ($relations) foreach ($relations as $name => &$relation) { + if (!$relation instanceof Relation) { + throw new OrmException(sprintf( + 'Relation for model %s must be instance of %s, %s given', + $modelClass, Relation::class, PhpHelp::getType($relation) + )); + } + $relation->setName($name); + } + static::$relations[$modelClass] = []; + static::setRelations($modelClass, $relations); + } + + /** + * Получение связей класса модели. + * @param string класс модели + * @return array|null + */ + public static function getRelations(string $modelClass): ?array + { + $relations = static::$relations[$modelClass] ?? null; + if (is_null($relations)) { + static::initRelations($modelClass); + } + return static::$relations[$modelClass] ?? null; + } + + /** + * Получение связи класса модели. + * @param string класс модели + * @param string имя связи + * @return array|null + */ + public static function getRelation(string $modelClass, string $name): ?Relation + { + return static::getRelations($modelClass)[$name] ?? null; + } + + /** + * Провеерка наличия связей класса модели. + * @param string класс модели + * @return bool + */ + public static function hasRelations(string $modelClass): bool + { + return !is_null(static::getRelations($modelClass)); + // return isset(static::$relations[$modelClass]); + } + + /** + * Провеерка наличия связи класса модели. + * @param string класс модели + * @param string имя связи + * @return bool + */ + public static function hasRelation(string $modelClass, string $name): bool + { + return !is_null(static::getRelation($modelClass, $name)); + } + + /** + * Установка связей класса модели. + * @param string класс модели + * @param Relation[] связи + */ + public static function setRelations(string $modelClass, array $relations) + { + foreach ($relations as $relation) { + static::setRelation($modelClass, $relation); + } + } + + /** + * Установка связи класса модели. + * @param string класс модели + * @param Relation связь + */ + public static function setRelation(string $modelClass, Relation $relation) + { + if (!static::hasRelations($modelClass)) { + static::$relations[$modelClass] = []; + } + static::$relations[$modelClass][$relation->name] = &$relation; + } + + /** + * Удаление связей класса модели или всех связей. + * @param string|null класс модели + */ + public static function unsetRelations(string $modelClass = null) + { + if (is_null($modelClass)) unset(static::$relations); + else unset(static::$relations[$modelClass]); + } + + /** + * Удаление связи класса модели. + * @param string класс модели + * @param string имя связи + */ + public static function unsetRelation(string $modelClass, string $name) + { + unset(static::$relations[$modelClass][$name]); + } +} diff --git a/src/Traits/ActiveRecordConvertTrait.php b/src/Traits/ActiveRecordConvertTrait.php new file mode 100644 index 0000000..4c563f9 --- /dev/null +++ b/src/Traits/ActiveRecordConvertTrait.php @@ -0,0 +1,48 @@ + + */ +namespace Evas\Orm\Traits; + +trait ActiveRecordConvertTrait +{ + /** + * Конвертация данных и связей модели в массив. + * @return array + */ + public function toArray(): array + { + return $this->getData(); + // return array_merge($this->getData(), $this->getRelatedCollections()); + } + + /** + * Конвертация модели в JSON сериализацию. + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Конвертация данных и связей модели в JSON. + * @param int|null опции для json_encode + * @return string + */ + public function toJson(int $options = JSON_UNESCAPED_UNICODE): string + { + return json_encode($this->jsonSerialize(), $options); + } + + /** + * Конвертация модели в строку. + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/src/Traits/ActiveRecordDataTrait.php b/src/Traits/ActiveRecordDataTrait.php new file mode 100644 index 0000000..6ccebe5 --- /dev/null +++ b/src/Traits/ActiveRecordDataTrait.php @@ -0,0 +1,96 @@ + + */ +namespace Evas\Orm\Traits; + +trait ActiveRecordDataTrait +{ + /** @var array данные модели */ + protected $modelData = []; + + /** + * Дефолтные значения новой записи. + * @return array + */ + public static function default(): array + { + return []; + } + + /** + * Получение значения первичного ключа модели. + * @return int|string|null + */ + public function primaryValue() + { + return $this->{static::primaryKey()}; + } + + /** + * Заполнение модели свойствами. + * @param array свойства модели + * @return self + */ + public function fill(array $props) + { + foreach ($props as $name => $value) { + $this->$name = $value; + } + return $this; + } + + /** + * Получение данных модели. + * @return array + */ + public function getData(): array + { + return $this->modelData; + } + + /** + * Установка свойства. + * @param string имя + * @param mixed значение + */ + public function __set(string $name, $value) + { + $this->modelData[$name] = $value; + } + + /** + * Получение свойства или связанных моделей. + * @param string имя + * @return mixed значение + */ + public function __get(string $name) + { + if (static::hasRelation($name)) { + return $this->getRelatedCollection($name); + } else { + return $this->modelData[$name] ?? null; + } + } + + /** + * Проверка наличия свойства. + * @param string имя свойства + * @return bool + */ + public function __isset(string $name): bool + { + return isset($this->modelData[$name]); + } + + /** + * Очистка свойства. + * @param string имя свойства + */ + public function __unset(string $name) + { + unset($this->modelData[$name]); + } +} diff --git a/src/Traits/ActiveRecordDatabaseTrait.php b/src/Traits/ActiveRecordDatabaseTrait.php new file mode 100644 index 0000000..59ef4b9 --- /dev/null +++ b/src/Traits/ActiveRecordDatabaseTrait.php @@ -0,0 +1,40 @@ + + */ +namespace Evas\Orm\Traits; + +use Evas\Base\App; +use Evas\Db\Interfaces\DatabaseInterface; + +trait ActiveRecordDatabaseTrait +{ + /** @var string имя соединения с базой данных */ + public static $dbname; + /** @var string имя соединения с базой данных только для записи */ + public static $dbnameWrite; + + /** + * Получение соединения с базой данных. + * @param bool использовать ли соединение для записи + * @return DatabaseInterface + */ + public static function db(bool $write = false): DatabaseInterface + { + $dbname = static::dbName($write); + return App::db($dbname); + } + + /** + * Получение имени соединения с базой данных. + * @param bool использовать ли соединение для записи + * @return string|null + */ + public static function dbName(bool $write = false): ?string + { + return true === $write && !empty(static::$dbnameWrite) + ? static::$dbnameWrite : static::$dbname; + } +} diff --git a/src/Traits/ActiveRecordIdentityTrait.php b/src/Traits/ActiveRecordIdentityTrait.php new file mode 100644 index 0000000..bf31929 --- /dev/null +++ b/src/Traits/ActiveRecordIdentityTrait.php @@ -0,0 +1,45 @@ + + */ +namespace Evas\Orm\Traits; + +use Evas\Orm\ActiveRecord; +use Evas\Orm\IdentityMap; + +trait ActiveRecordIdentityTrait +{ + /** @var string идентификатор модели для IdentityMap */ + protected $identity; + + /** + * Получение идентификатора модели для IdentityMap + * @return string идентификатор модели для IdentityMap + * */ + public function identity(): string + { + if (!$this->identity) { + $this->identity = implode(':', [static::class, $this->primaryValue(), static::dbName()]); + } + return $this->identity; + } + + /** + * Сохранение модели в IdentityMap. + * @return static + */ + public function identityMapSave(): ActiveRecord + { + return IdentityMap::getWithSave($this); + } + + /** + * Удаление модели из IdentityMap. + */ + public function identityMapRemove() + { + IdentityMap::unset($this); + } +} diff --git a/src/Traits/ActiveRecordQueryTrait.php b/src/Traits/ActiveRecordQueryTrait.php new file mode 100644 index 0000000..f963517 --- /dev/null +++ b/src/Traits/ActiveRecordQueryTrait.php @@ -0,0 +1,56 @@ + + */ +namespace Evas\Orm\Traits; + +use Evas\Orm\QueryBuilder; + +trait ActiveRecordQueryTrait +{ + /** + * Поиск по sql-запросу. + * @param string sql-запрос + * @param array|null значения запроса для экранирования + * @return static|static[] + */ + public static function query(string $sql, array $values = null) + { + $qr = static::db()->query($sql, $values); + return strstr($qr->stmt()->queryString, 'LIMIT 1') + ? $qr->object(static::class) + : $qr->objectAll(static::class); + } + + /** + * Проброс сборщика запросов через магический вызов статического метода. + * @param string имя метода + * @param array|null аргументы + * @return mixed результат выполнения метода + * @throws \BadMethodCallException + */ + public static function __callStatic(string $name, array $args = null) + { + if (method_exists(QueryBuilder::class, $name)) { + $db = static::db(); + return (new QueryBuilder($db, static::class))->$name(...$args); + } + throw new \BadMethodCallException(sprintf( + 'Call to undefined method %s::%s()', __CLASS__, $name + )); + } + + /** + * Поиск записи/записей. + * @param array|int столбец или столбцы + * @return static|static[] + */ + public static function find($ids) + { + $ids = func_num_args() > 1 ? func_get_args() : $ids; + $db = static::db(); + return (new QueryBuilder($db, static::class))->find($ids); + } +} diff --git a/src/Traits/ActiveRecordRelatedsTrait.php b/src/Traits/ActiveRecordRelatedsTrait.php new file mode 100644 index 0000000..dcc99d7 --- /dev/null +++ b/src/Traits/ActiveRecordRelatedsTrait.php @@ -0,0 +1,90 @@ + + */ +namespace Evas\Orm\Traits; + +use Evas\Orm\ActiveRecord; +use Evas\Orm\RelatedCollection; + +trait ActiveRecordRelatedsTrait +{ + /** @var array связанные коллекции моделей */ + protected $relatedCollections = []; + + /** + * Установка связанной коллекции модели. + * @param string имя связи + * @param array связанные модели + * @return self + */ + public function setRelatedCollection(string $name, array $relateds) + { + unset($this->relatedCollections[$name]); + foreach ($relateds as &$related) { + $this->addRelated($name, $related); + } + return $this; + } + + /** + * Добавление связанной модели в коллекцию. + * @param string имя связи + * @param ActiveRecord модель + * @return self + */ + public function addRelated(string $name, ActiveRecord $related) + { + $relation = static::getRelation($name); + $related = $related->identityMapSave(); + if (!$relation) return $this; + // if (false) { + if ($relation->isOne()) { + if (!isset($this->relatedCollections[$name]) && $related->primaryValue()) { + $this->relatedCollections[$name] = &$related; + } + return $this; + } + if (!isset($this->relatedCollections[$name])) { + $this->relatedCollections[$name] = new RelatedCollection($this, static::getRelation($name)); + } + if ($related->primaryValue()) { + $this->relatedCollections[$name]->add($related); + } + return $this; + } + + public function addRelatedData(string $name, array $data) + { + $relation = static::getRelation($name); + if (!$relation) return $this; + return $this->addRelated($name, new $relation->foreignModel($data)); + } + + /** + * Получение всех связанных записей модели. + * @return array + */ + public function getRelatedCollections(): array + { + return $this->relatedCollections; + } + + /** + * Получение конкретных связанных записей модели. + * @param string имя связи + * @return RelatedCollection|null + */ + public function getRelatedCollection(string $name)//: ?RelatedCollection + { + if (static::hasRelation($name)) { + if (!isset($this->relatedCollections[$name])) { + $this->loadRelated($name); + } + return $this->relatedCollections[$name] ?? null; + } + return null; + } +} diff --git a/src/Traits/ActiveRecordRelationsTrait.php b/src/Traits/ActiveRecordRelationsTrait.php new file mode 100644 index 0000000..ffbd620 --- /dev/null +++ b/src/Traits/ActiveRecordRelationsTrait.php @@ -0,0 +1,90 @@ + + */ +namespace Evas\Orm\Traits; + +use Evas\Base\Help\PhpHelp; +use Evas\Orm\Relation; +use Evas\Orm\RelationsMap; + +trait ActiveRecordRelationsTrait +{ + /** + * Описание связей модели. + * @return array + */ + public static function relations(): array + { + return []; + } + + + /** + * Установка множественной связи. + * @param string класс внешней модели + * @param string|null внешний ключ + * @param string|null локальный ключ + * @return Relation + */ + public static function hasMany( + string $foreignModel, string $foreignKey = null, string $localKey = null + ): Relation { + return new Relation( + 'hasMany', static::class, $foreignModel, $foreignKey, $localKey + ); + } + + /** + * Установка единичной связи. + * @param string класс внешней модели + * @param string|null внешний ключ + * @param string|null локальный ключ + * @return Relation + */ + public static function hasOne( + string $foreignModel, string $foreignKey = null, string $localKey = null + ): Relation { + return new Relation( + 'hasOne', static::class, $foreignModel, $foreignKey, $localKey + ); + } + + /** + * Установка единичной связи к родителю. + * @param string класс внешней модели + * @param string|null внешний ключ + * @param string|null локальный ключ + * @return Relation + */ + public static function belongsTo( + string $foreignModel, string $foreignKey = null, string $localKey = null + ): Relation { + return new Relation( + 'belongsTo', static::class, $foreignModel, $foreignKey, $localKey + ); + } + + + /** + * Получение связи по имени. + * @param string имя связи + * @return Relation|null + */ + public static function getRelation(string $name): ?Relation + { + return RelationsMap::getRelation(static::class, $name); + } + + /** + * Проверка наличия связи. + * @param string имя связи + * @return bool + */ + public static function hasRelation(string $name): bool + { + return RelationsMap::hasRelation(static::class, $name); + } +} diff --git a/src/Traits/ActiveRecordStateTrait.php b/src/Traits/ActiveRecordStateTrait.php new file mode 100644 index 0000000..d7abcb9 --- /dev/null +++ b/src/Traits/ActiveRecordStateTrait.php @@ -0,0 +1,85 @@ + + */ +namespace Evas\Orm\Traits; + +trait ActiveRecordStateTrait +{ + /** @var array состояние полей модели */ + protected $state = []; + + /** + * Сохранение состояния. + * @return self + */ + public function saveState() + { + $this->state = $this->getProps(); + return $this; + } + + /** + * Получение состояния сохраняемых полей. + * @return array + */ + public function getState(): array + { + return $this->state; + } + + /** + * Проверка наличия значения первичного ключа записи в состоянии. + * @return bool + */ + public function isStateHasPrimaryValue(): bool + { + return isset($this->state[static::primaryKey()]); + } + + /** + * Получение данных записи для базы данных. + * @return array + */ + public function getProps(): array + { + $props = []; + foreach (static::columns() as &$column) { + if (isset($this->$column)) $props[$column] = $this->$column; + } + return $props; + } + + /** + * Получение маппинга измененных свойств записи. + * @return array + */ + public function getUpdatedProps(): array + { + $props = $this->getProps(); + if (empty($this->primaryValue())) { + return $props; + } else { + $state = $this->getState(); + // // return array_diff($props, $state ?? []); + // // var_dump($state); + // // echo '
'; + // // var_dump($props); + // // echo '
'; + $updated = []; + foreach ($props as $name => $value) { + if (!isset($state[$name]) || $value !== $state[$name]) { + $updated[$name] = $value; + } + } + // var_dump($updated); echo '
'; + return $updated; + // return array_merge( + // array_fill_keys(array_keys(array_diff($state ?? [], $props)), null), + // array_diff_assoc($props, $state ?? []) + // ); + } + } +} diff --git a/src/Traits/ActiveRecordTableTrait.php b/src/Traits/ActiveRecordTableTrait.php new file mode 100644 index 0000000..631f60f --- /dev/null +++ b/src/Traits/ActiveRecordTableTrait.php @@ -0,0 +1,80 @@ + + */ +namespace Evas\Orm\Traits; + +use Evas\Db\Interfaces\TableInterface; + +trait ActiveRecordTableTrait +{ + /** @var string имя таблицы */ + public static $tableName; + + /** + * Генерация имени таблицы из имени класса. + * @return string + */ + public static function generateTableName(): string + { + $className = static::class; + $lastSlash = strrpos($className, '\\'); + if ($lastSlash > 0) { + $className = substr($className, $lastSlash + 1); + } + return strtolower(preg_replace('/([a-z0-9]+)([A-Z]{1})/', '$1_$2', $className)) . 's'; + } + + /** + * Получение имени таблицы. + * @return string + */ + public static function tableName(): string + { + if (empty(static::$tableName)) { + static::$tableName = static::generateTableName(); + } + return static::$tableName; + } + + /** + * Получение объекта таблицы. + * @param bool использовать ли соединение с БД для записи + * @return TableInterface + */ + public static function table(bool $write = false): TableInterface + { + return static::db($write)->table(static::tableName()); + } + + /** + * Получение первичного ключа. + * @param bool|null переполучить из схемы заново + * @return string + */ + public static function primaryKey(bool $reload = false): string + { + return static::table()->primaryKey($reload); + } + + /** + * Получение столбцов таблицы. + * @param bool|null переполучить из схемы заново + * @return array + */ + public static function columns(bool $reload = false): array + { + return static::table()->columns($reload); + } + + /** + * Получение id последней записи. + * @return int|null + */ + public static function lastInsertId(): ?int + { + return static::table()->lastInsertId(); + } +} diff --git a/src/Traits/QueryBuilderRelationsHasTrait.php b/src/Traits/QueryBuilderRelationsHasTrait.php new file mode 100644 index 0000000..c17955c --- /dev/null +++ b/src/Traits/QueryBuilderRelationsHasTrait.php @@ -0,0 +1,337 @@ +has[] = func_get_args(); + return $this; + } + + public function has(string $relationName, $operator = null, $value = null) + { + return $this->addHas(false, false, ...func_get_args()); + } + + public function orHas(string $relationName, $operator = null, $value = null) + { + return $this->addHas(true, false, ...func_get_args()); + } + + public function notHas(string $relationName, $operator = null, $value = null) + { + return $this->addHas(false, true, ...func_get_args()); + } + + public function orNotHas(string $relationName, $operator = null, $value = null) + { + return $this->addHas(true, true, ...func_get_args()); + } + + protected function applyHases() + { + if (1 > count($this->has)) return; + // $columns = static::prepareModelColumns($this->columns, $this->model); + // $this->select($columns); + foreach ($this->has as $has) { + $this->applyHas(...$has); + } + } + + protected function applyHas( + bool $isOr, bool $isNot, string $relationName, $operator = null, $value = null + ) { + $relationNames = explode('.', $relationName); + $model = $this->model; + // $relations = []; + // foreach ($relationNames as $name) { + // $relation = $model::getRelation($name); + // $model = $relation->foreignModel; + // $relations[] = $relation; + // } + // if (!count($relations)) return; + + $relationGroups = []; + $dbName = null; + $relations = []; + foreach ($relationNames as $name) { + $relation = $model::getRelation($name); + $model = $relation->foreignModel; + + if ($dbName !== $model::dbName()) { + if (!empty($relations)) { + $relationGroups[] = $relations; + } + $dbName = $model::dbName(); + $relations = []; + } + + $relations[] = $relation; + } + if (!empty($relations)) { + $relationGroups[] = $relations; + } + if (!count($relationGroups)) return; + // foreach ($relationGroups as $relations) { + // foreach ($relations as $relation) { + // echo dumpOrm($relation); + // } + // echo '
'; + // } + // exit(); + + + if (func_num_args() > 3) { + $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 4 + ); + $opval = [$operator, $value]; + } else { + $opval = null; + } + + + // exit(); + + // $relations = array_reverse($relations); + // $query = $this->realApplyHas($relations, $operator, $value, $isCount); + + $last = count($relationGroups) - 1; + if (0 === $last) { + $relations = array_reverse($relationGroups[0]); + $query = $this->realApplyHas($relations, $opval); + + if (count($relations) < 2 && $opval) { + $this->where($query, ...$opval); + echo dumpOrm($this->getSql()); + echo dumpOrm($this->getBindings()); + } else { + $this->whereExists($query); + } + } else { + $ids = null; + foreach ($relationGroups as $i => $relations) { + $ids = $this->applyGroupHas( + $relations, $ids, $last === $i ? $opval : null + ); + // $relations = array_reverse($relations); + // $query = $this->realApplyHas($relations, $last === $i ? $opval : null); + // var_dump($query); + // echo '
'; + } + $this->whereIn($this->primaryKey(), $ids); + } + + + // if (count($relations) < 2 && $opval) { + // $this->where($query, ...$opval); + // echo dumpOrm($this->getSql()); + // echo dumpOrm($this->getBindings()); + // } else { + // $this->whereExists($query); + // } + + + // if (in_array($operator, ['<', '<='])) { + // $query = $this->realApplyNotHas($relations, $operator); + // $this->orWhereNotExists($query); + // } + + // $this->resetSelect('*'); + // $this->whereRaw('EXISTS (SELECT COUNT(`id`) AS `count_id` FROM `company` WHERE `user`.`id` = `company`.`user_id` AND (SELECT COUNT(`id`) AS `count_id` FROM `user` WHERE `company`.`user_id` = `user`.`id`) > 100)'); + } + + protected function applyGroupHas($relations, array $ids = null, array $opval = null) + { + echo dumpOrm($relations); + $relations = array_reverse($relations); + $last = count($relations) - 1; + $q = clone $this; + $q->db = $relations[0]->foreignModel::db(); + $q->withs = []; + $q->has = []; + $q->resetFrom($relations[0]->localModel::tableName()); + if ($ids) { + $q->whereIn($relations[0]->localKey(true), $ids); + $ids = null; + // $q->resetSelect(['iid' => $relations[0]->foreignKey(true)]); + array_pop($relations); + } else { + // + } + $query = $q->realApplyHas($relations, $opval); + if (count($relations) < 2 && $opval) { + $q->where($query, ...$opval); + } else { + $q->whereExists($query); + } + echo '

SQL:

'; + echo dumpOrm($q->getSql()); + echo dumpOrm($q->getBindings()); + echo '
'; + $models = $q->get(); + echo dumpOrm($models); + $ids = []; + foreach ($models as $model) { + $ids[] = $model->id; + } + echo dumpOrm($ids); + return $ids; + } + + protected function realApplyHas($relations, array $opval = null) + { + $query = null; + foreach ($relations as $i => $relation) { + $query = function ($q) use ($relation, $query, $opval, $i) { + $q->from($relation->foreignTable); + $q->whereColumn($relation->localKey(true), $relation->foreignKey(false)); + if ($query) { + if (1 == $i && $opval) $q->where($query, ...$opval); + else $q->whereExists($query); + } else if (0 == $i && $opval) { + $q->count('id'); + } + }; + } + return $query; + } + + // protected function realApplyHas($relations, $operator, $value, bool $isCount = false) + // { + // $query = null; + // foreach ($relations as $i => $relation) { + // $query = function ($q) use ( + // $relation, $query, $value, $operator, $isCount, $i + // ) { + // $q->from($relation->foreignTable); + // $q->whereColumn($relation->localKey(true), $relation->foreignKey(false)); + // if ($query) { + // if (1 == $i && $isCount) { + // $q->where($query, $operator, $value); + // } else { + // $q->whereExists($query); + // } + // } else if (0 == $i && $isCount) { + // $q->count('id'); + // } + // }; + // } + // return $query; + // } + + protected function realApplyNotHas($relations, $operator, bool $isAnd = false) + { + $query = null; + foreach ($relations as $relation) { + $query = (function ($q) use ($relation, $query) { + $q->from($relation->foreignTable); + $q->whereColumn($relation->localKey(true), $relation->foreignKey(false)); + if ($query) $q->whereExists($query); + }); + } + return $query; + } + + + // @[$relationName, $columns] = explode('.', $relationName); + // $relation = $this->getRelation($relationName); + // if (!$relation) return; + // $hasOneWith = $this->withOne[$relationName] ?? null; + // // if ($hasOneWith) + // if ($columns) { + // $this->whereExists(function ($q) use ($relation, $columns) { + // $q->from($relation->foreignTable); + // foreach ($columns as $column) { + // $q->whereColumn( + // $relation->localColumn($column, true), + // $relation->foreignColumn($column, false) + // ); + // } + // }); + // } else { + // $this->whereExists(function ($q) use ($relation) { + // $q->from($relation->foreignTable); + // $q->whereColumn($relation->localKey(true), $relation->foreignKey(false)); + // }); + // } + // } + + + // protected function applyHas( + // bool $isNot, string $relationName, $operator = null, $value = null + // ) { + // @[$relationName, $columns] = explode(':', $relationName); + // $relation = $this->getRelation($relationName); + // if (!$relation) return; + + // $this->select(static::prepareModelColumns($this->columns, $this->model)); + // if ($columns) { + // $columns = explode(',', $columns); + // foreach ($columns as $column) { + // $this->whereNotNull($relation->foreignColumn($column, true)); + // } + // } + // if (func_num_args() > 3) { + // if ($this->isQueryable($operator)) { + // if ($operator instanceof \Closure) { + // call_user_func($operator, $operator = $this->newQuery()); + // } + // foreach ($operator->wheres as $where) { + // if (isset($where['columns'])) + // foreach ($where['columns'] as &$column) { + // $this->addColumnTablePrefix($column, $relationName); + // } + // if (isset($where['column'])) + // $where['column'] = $this->addColumnTablePrefix( + // $where['column'], $relationName + // ); + // $this->pushWhere($where['type'], $where); + // } + // } else { + // $this->prepareValueAndOperator( + // $value, $operator, func_num_args() === 4 + // ); + // $foreignPrimary = $relation->foreignPrimary(true); + // if ($columns) { + // // isHas + // foreach ($columns as $column) { + // $this->where( + // $relation->foreignColumn($column, true), + // $operator, $value + // ); + // } + // } else { + // $this->count($foreignPrimary); + // $this->havingAggregate( + // 'count', $foreignPrimary, $operator, $value + // ); + // if ($value === 0) $isNot = true; + // } + // } + // } + + // $args = $relation->joinArgs(null, false); + + // if ($isNot) { + // if (func_num_args() > 3) { + // $this->leftJoinSub(...$args); + // $this->groupBy($relation->localKey(true)); + // } else { + // $this->leftOuterJoinSub(...$args); + // $this->whereNull($relation->foreignKey()); + // } + // } else { + // $this->joinSub(...$args); + // $this->groupBy($relation->localKey(true)); + // } + // } +} diff --git a/src/Traits/QueryBuilderRelationsTrait.php b/src/Traits/QueryBuilderRelationsTrait.php new file mode 100644 index 0000000..07f866f --- /dev/null +++ b/src/Traits/QueryBuilderRelationsTrait.php @@ -0,0 +1,111 @@ + + */ +namespace Evas\Orm\Traits; + +use Evas\Orm\ActiveRecord; +use Evas\Orm\Relation; +use Evas\Orm\Traits\QueryBuilderRelationsHasTrait; +use Evas\Orm\Traits\QueryBuilderRelationsWithTrait; + +trait QueryBuilderRelationsTrait +{ + use QueryBuilderRelationsHasTrait; + use QueryBuilderRelationsWithTrait; + + /** @var string разделитель для полей связанных записей */ + public static $relationDataSeparator = '_-_'; + + protected function getRelation(string $name): ?Relation + { + return $this->model::getRelation($name); + } + + + protected static function prepareColumns($columns): ?array + { + // if (empty($columns) || '*' == $columns) { + // return null; + // } + // if (is_array($columns)) { + // if (in_array('*', $columns)) return null; + // else return $columns; + // } + + return (empty($columns) || '*' === $columns) ? null : ( + is_array($columns) ? (in_array('*', $columns) ? null : $columns) + : explode(',', str_replace(' ', '', $columns)) + ); + + // return (empty($columns) || '*' == $columns + // || (is_array($columns) && in_array('*', $columns))) + // ? null : explode(',', str_replace(' ', '', $columns)); + } + + protected static function prepareModelColumns( + $columns, string $model, string $asPrefix = null + ) { + $columns = static::prepareColumns($columns) ?? $model::columns(); + $table = $model::tableName(); + $keys = []; + foreach ($columns as &$column) { + if (!empty($asPrefix)) { + $as = $asPrefix . static::$relationDataSeparator . $column; + $col = "{$asPrefix}.{$column}"; + } else { + $as = $column; + $col = "{$table}.{$column}"; + } + $keys[$as] = $col; + } + return $keys; + } + + protected function addColumnTablePrefix(string $column, string $table) + { + $parts = explode('.', $column); + if (count($parts) < 2) { + array_unshift($parts, $table); + $column = implode('.', $parts); + } + return $column; + } + + protected function applyRelationsBefore() + { + // // if (1 > count($this->withOne) && 1 > count($this->has)) return; + // if (1 > count($this->withs) && 1 > count($this->has)) return; + // $columns = static::prepareModelColumns($this->columns, $this->model); + // $this->select($columns); + // $this->applyWiths(); + $this->applyHases(); + } + + protected function applyRelationsAfter(array $ids, array &$models) + { + if (1 > count($this->withs)) return; + $this->applyWiths($ids, $models); + // if (1 > count($this->withMany)) return; + // foreach ($this->withMany as [$relation, $columns, $query]) { + // $qb = (new static($this->db, $relation->foreignModel)); + // $subModels = $qb->whereIn($relation->foreignKey, $ids)->get(); + // if (!$subModels) continue; + // $ids = []; + // foreach ($subModels as $subModel) { + // $ids[] = $subModel->primaryValue(); + // foreach ($models as $model) { + // if ($model->{$relation->localKey} == $subModel->{$relation->foreignKey}) { + // $model->addRelated($relation->name, $subModel); + // break; + // } + // } + // } + // if (0 < count($ids)) { + // $this->applyRelationsAfter($ids, $subModels); + // } + // } + } +} diff --git a/src/Traits/QueryBuilderRelationsWithTrait.php b/src/Traits/QueryBuilderRelationsWithTrait.php new file mode 100644 index 0000000..e381a6f --- /dev/null +++ b/src/Traits/QueryBuilderRelationsWithTrait.php @@ -0,0 +1,220 @@ +getRelation($name); + // if ($relation) { + // if ($relation->isOne()) $list = &$this->withOne; + // else $list = &$this->withMany; + // $list[$relation->name] = [$relation, $columns, $query]; + // } + // // echo title('addWith: ' . $name, 3);// . dumpOrm($relation); + // // echo dumpOrm($this->model::$relations); + // return $this; + // } + + // public function old_with(...$props) + // { + // foreach ($props as &$prop) { + // if (is_string($prop)) { + // @[$name, $columns] = explode(':', $prop); + // $this->addWith($name, $columns); + + // } else if (is_array($prop)) { + // foreach ($prop as $name => &$sub) { + // $columns = null; + // $query = null; + + // if (is_string($name)) { + // if (is_string($sub) || is_array($sub)) { + // $columns = $sub; + // } else if (is_callable($sub)) { + // @[$name, $columns] = explode(':', $name); + // $relation = $this->getRelation($name); + // if (!$relation) return; + // $query = new static($this->db, $relation->foreignModel); + // $sub($query); + // } + + // } else if (is_string($sub)) { + // @[$name, $columns] = explode(':', $sub); + // } else { + // throw new \InvalidArgumentException(sprintf( + // 'Incorrect arguments in method %s()', + // __METHOD__ + // )); + // } + // $this->addWith($name, $columns, $query); + // } + // } + // } + // return $this; + // } + + protected $withs = []; + + // public function with(...$props) + // { + // $this->withs = array_merge($this->withs, $props); + // echo dumpOrm($this->withs); + // return $this; + // } + + public function with(...$props) + { + // echo dumpOrm($props); + $withs = []; + foreach ($props as &$prop) { + if (is_string($prop)) { + $withs[] = $this->levels(explode('.', $prop)); + } else if (is_array($prop)) { + $withs[] = $this->recursiveArrayWith($prop); + } + } + $this->withs = array_merge_recursive(...$withs); + // echo dumpOrm($this->withs); + return $this; + } + + protected function levels($levels, $value = []) + { + $levels = array_reverse($levels); + $with = $value; + foreach ($levels as $level) { + $with = [$level => $with]; + } + return $with; + } + + protected function recursiveArrayWith(array $props) + { + $withs = []; + foreach ($props as $i => $prop) { + $key = is_string($i) ? $i : $prop; + $val = is_string($i) ? $prop : []; + + if (is_array($val)) $val = $this->recursiveArrayWith($val); + else if (is_string($val)) $val = [$val => []]; + + // вложенность связей в ключе + if (is_string($key)) { + $levels = explode('.', $key); + if (count($levels) > 1) { + $key = array_shift($levels); + $val = $this->levels($levels, $val); + } + } + // слияние значений уже существующего ключа + if (isset($withs[$key]) && is_array($withs[$key])) { + if (is_numeric($val)) return; + if (is_string($val)) $val = [$val]; + $val = array_merge($withs[$key], $val); + } + $withs[$key] = $val; + } + return $withs; + } + + // protected function recursiveArrayWith(array $props) + // { + // $withs = []; + // foreach ($props as $i => $prop) { + // $names = is_string($i) ? explode('.', $i) : []; + // if (is_string($prop)) { + // $withs[] = array_merge($names, explode('.', $prop)); + // } else if (is_array($prop)) { + // $subs = $this->recursiveArrayWith($prop); + // foreach ($subs as $sub) { + // $withs[] = array_merge($names, $sub); + // } + // } + // } + // return $withs; + // } + + + protected function applyWiths(array $ids, array &$models) + { + // // $withOne = array_filter($this->withOne, fn($with) => $with->isOne()); + // if (1 > count($this->withOne)) return; + // // echo title('applyWiths', 3); + // // $columns = static::prepareModelColumns($this->columns, $this->model); + // // $this->select($columns); + // foreach ($this->withOne as [$relation, $columns, $query]) { + // $this->applyWith($relation, $columns, $query); + // } + // return; + if (1 > count($this->withs)) return; + $keys = array_keys($this->withs); + foreach ($keys as $key) { + $this->applyWith($key, $this->withs[$key], $ids, $models); + } + } + + protected function applyWith($key, $val, array $ids, array &$models) + { + $relation = $this->getRelation($key); + $fModel = $relation->foreignModel; + $db = $fModel::db(); + $qb = (new static($db, $fModel)); + $qb->whereIn($relation->foreignKey, $ids); + if (!empty($val)) $qb->with($val); + $subModels = $qb->get(); + if (!$subModels) return; + + // $idsLocal = []; + foreach ($subModels as $subModel) { + // $idsLocal[] = $subModel->primaryValue(); + foreach ($models as $model) { + if ($model->{$relation->localKey} == $subModel->{$relation->foreignKey}) { + $model->addRelated($relation->name, $subModel); + // break; + } + } + } + } + + // protected function applyWith( + // Relation $relation, $columns = null, self $query = null + // ) { + // $columns = static::prepareModelColumns( + // $columns, $relation->foreignModel, $relation->name + // ); + // $this->select($columns); + // // $this->leftJoinSub( + // // $query ?? $relation->foreignTable, + // // $relation->name, + // // "{$relation->name}.{$relation->foreignKey}", + // // $relation->localKey(true) + // // ); + // $this->leftJoinSub(...$relation->joinArgs($query, true)); + // return $this; + // } + + protected function parseWiths(ActiveRecord &$model) + { + if (1 > count($this->withOne)) return; + // echo title('parseWiths', 3) . dumpOrm($model); + $foreigns = []; + foreach ($model->toArray() as $key => $value) { + @[$fname, $fkey] = explode(static::$relationDataSeparator, $key, 2); + if ($fkey && in_array($fname, array_keys($this->withOne))) { + $foreigns[$fname][$fkey] = $value; + unset($model->$key); + } + } + foreach ($foreigns as $fname => $subs) { + // $this->withOne[$fname][0]->addRelated($result, $subs); + $model->addRelatedData($fname, $subs); + } + } +}