diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index 66b3b3a..fa46055 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -6,25 +6,34 @@ */ namespace Evas\Orm; +use \JsonSerializable; 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\RelatedCollection; +use Evas\Orm\Traits\RelationsTrait; +use Evas\Orm\QueryBuilder; -abstract class ActiveRecord +abstract class ActiveRecord implements JsonSerializable { // подключаем поддержку произвольных хуков в наследуемых классах use HooksTrait; - /** @var string имя соединения с базой данных*/ + // подключаем поддержку связей + use RelationsTrait; + + /** @var string имя соединения с базой данных */ public static $dbname; /** @var string имя соединения с базой данных только для записи */ public static $dbnameWrite; - /*** @var string кастомное имя таблицы */ + /** @var string кастомное имя таблицы */ public static $tableName; + /** @var array данные модели */ + protected $modelData = []; + /** * Получение соединения с базой данных. * @param bool использовать ли соединение для записи @@ -54,15 +63,11 @@ public static function getDbName(bool $write = false): ?string */ public static function generateTableName(): string { - $className = get_called_class(); + $className = static::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'; } @@ -168,6 +173,15 @@ public function fill(array $props): ActiveRecord return $this; } + /** + * Получение значения первичного ключа модели. + * @return int|string|null + */ + public function primaryValue() + { + return $this->{static::primaryKey()}; + } + /** * Получение маппинга данных записи для базы данных. @@ -191,12 +205,21 @@ public function getRowProperties(): array public function getState(): array { $state = static::getDb()->identityMapGetState($this, static::primaryKey()); - foreach ($state as $name => &$value) { + if ($state) foreach ($state as $name => &$value) { if (!isset($value) || !in_array($name, static::columns())) { unset($state[$name]); } } - return $state; + return $state ?? []; + } + + /** + * Проверка наличия значения первичного ключа записи в состоянии. + * @return bool + */ + protected function hasPrimaryValueInState(): bool + { + return isset($this->getState()[static::primaryKey()]); } /** @@ -206,8 +229,7 @@ public function getState(): array public function getUpdatedProperties(): array { $props = $this->getRowProperties(); - $pk = static::primaryKey(); - if (empty($this->$pk)) { + if (empty($this->primaryValue())) { return $props; } else { // $state = static::getDb()->identityMapGetState($this, $pk); @@ -236,7 +258,12 @@ public function save() $this->hook('beforeSave'); $pk = static::primaryKey(); - if (empty($this->$pk)) { + if ($this->hasPrimaryValueInState()) { + $this->hook('beforeUpdate'); + static::getDb(true)->table(static::tableName()) + ->where($pk, $this->$pk)->update($this->getUpdatedProperties()); + $this->hook('afterUpdate'); + } else { $this->hook('beforeInsert'); static::getDb(true)->insert(static::tableName(), $this->getUpdatedProperties()); $this->$pk = static::lastInsertId(); @@ -244,16 +271,25 @@ public function save() throw new LastInsertIdUndefinedException(); } $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('afterSave'); return static::getDb()->identityMapUpdate($this, $pk); } + /** + * Сохранение модели и её связей. + */ + public function push() + { + $this->save(); + foreach ($this->relatedCollections as $models) { + if (!is_array($models)) $models = [$models]; + foreach ($models as $model) { + $model->push(); + } + } + } + /** * Удаление записи. */ @@ -265,16 +301,26 @@ public function delete() return $this; } $this->hook('beforeDelete'); - $qr = static::getDb(true)->delete(static::tableName()) - ->where("$pk = ?", [$this->$pk])->one(); + $qr = static::getDb(true)->table(static::tableName()) + ->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->$pk = null; } return $this; } + /** + * Обновление данных модели из базы. + */ + public function reload() + { + $pv = $this->primaryValue(); + if (!$pv) return; + $this->fill((array) static::find($pv)); + } + /** * Создание модели записи. * @param array|null значения записи @@ -296,54 +342,277 @@ public static function insert(array $props = null): ActiveRecord } /** - * Поиск записи через сборщик запроса. - * @param string|null столбцы - * @return QueryBuilderInterface + * Поиск записи/записей. + * @param array|int столбец или столбцы + * @return static|static[] */ - public static function find(string $columns = null): QueryBuilderInterface + public static function find($ids) { - return static::getDb()->select(static::tableName(), $columns); + $ids = func_num_args() > 1 ? func_get_args() : $ids; + $db = static::getDb(); + return (new QueryBuilder($db, static::class))->find($ids); } /** - * Поиск по первичному ключу. - * @param int|string значение первичного ключа, перечисление - * @return static|array of static + * Поиск по sql-запросу. + * @param string sql-запрос + * @param array|null значения запроса для экранирования + * @return static|static[] */ - public static function findByPK(...$ids) + public static function query(string $sql, array $values = null) { - $pk = static::primaryKey(); - $qb = static::find(); - if (count($ids) > 1) { - return $qb->whereIn("`$pk`", $ids) - ->query(count($ids))->classObjectAll(static::class); + $qr = static::getDb()->query($sql, $values); + return strstr($qr->stmt()->queryString, 'LIMIT 1') + ? $qr->object(static::class) + : $qr->objectAll(static::class); + } + + /** + * Получение или создание новой записи. + * @param array свойства поиска + * @param array свойства вставки + * @return static + */ + public static function firstOrCreate(array $findProps, array $createProps = null) + { + $keys = array_keys($findProps); + $vals = array_values($findProps); + foreach (static::columns() as $i => $key) { + if (!in_array($key, $keys)) { + unset($keys[$i]); + unset($vals[$i]); + } + } + $record = static::whereRowValues($keys, $vals)->one(); + if ($record) return $record; + return static::create(array_merge($findProps, $createProps)); + } + + /** + * Проброс сборщика запросов через магический вызов статического метода. + * @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::getDb(); + return (new QueryBuilder($db, static::class))->$name(...$args); + } + throw new \BadMethodCallException(sprintf( + 'Call to undefined method %s::%s()', __CLASS__, $name + )); + } + + /** + * Описание связей модели. + * @return array + */ + protected static function relations(): array + { + return []; + } + + /** + * Подгрузка связанных записей. + * @param string имя связи + * @param array|null столбцы + * @return self + */ + public function loadRelated(string $name, array $columns = null, QueryBuilder $query = null) + { + $relation = static::getRelation($name); + if ($query) { + $qb = $query->where($relation->foreignKey, $this->{$relation->localKey}); } else { - return $qb->where("`$pk` = ?", $ids) - ->one()->classObject(static::class); + $qb = $relation->foreignModel::where($relation->foreignKey, $this->{$relation->localKey}); + } + if ($relation->type === 'hasOne') $qb->limit(1); + $models = $qb->get($columns); + $this->setRelatedCollection($relation->name, $models); + return $this; + } + + public function load(...$props) + { + foreach ($props as &$prop) { + if (is_string($prop)) { + @list($name, $columns) = explode(':', $prop); + $this->loadRelated($name, $columns); + } else if (is_array($prop)) { + foreach ($prop as $name => &$sub) { + $columns = null; + $query = null; + if (is_string($name)) { + if (is_string($sub)) { + $columns = $sub; + } else if (is_callable($sub)) { + @list($name, $columns) = explode(':', $name); + $relation = $this->getRelation($name); + if (!$relation) continue; + $db = static::getDb(); + $query = new QueryBuilder($db, $relation->foreignModel); + $sub($query); + } + } else if (is_string($sub)) { + @list($name, $columns) = explode(':', $sub); + } else { + throw new \InvalidArgumentException('Incorrect load syntax'); + } + $this->loadRelated($name, $columns, $query); + } + } } } /** - * Поиск по id, алиас для findByPK. - * @param int id, перечисление - * @return static|array of static + * Получение данных модели. + * @return array */ - public static function findById(int ...$ids) - { - return static::findByPK(...$ids); - } + public function getData(): array + { + return $this->modelData; + } /** - * Поиск по sql-запросу. - * @param string sql-запрос - * @param array|null значения запроса для экранирования - * @return static|static[] + * Получение всех связанных записей модели. + * @return array */ - public static function query(string $sql, array $values = null) + public function getRelatedCollections(): array { - $qr = static::getDb()->query($sql, $values); - return strstr($qr->stmt()->queryString, 'LIMIT 1') - ? $qr->classObject(static::class) - : $qr->classObjectAll(static::class); + 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; + } + + /** + * Установка свойства. + * @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]); + } + + // Конвертация + + /** + * Конвертация данных и связей модели в массив. + * @return array + */ + public function toArray(): array + { + 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(); } + + // protected static $list = []; + // protected static $map = []; + + // public static function list(): array + // { + // return static::$list; + // } + + // public static function map(): array + // { + // return static::$map; + // } + + // public static function size(): int + // { + // return count(static::$map); + // } + + // public static function getCached($id = null) + // { + // if (null === $id) { + // return static::list(); + // } else if (is_array($id)) { + // $result = []; + // foreach ($id as $sub) { + // $result[] = static::getCached($sub); + // } + // return $result; + // } else { + // return static::$map[$id]; + // } + // } } diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php new file mode 100644 index 0000000..0d352e3 --- /dev/null +++ b/src/QueryBuilder.php @@ -0,0 +1,375 @@ + + */ +namespace Evas\Orm; + +use Evas\Db\Builders\QueryBuilder as DbQueryBuilder; +use Evas\Db\Interfaces\DatabaseInterface; +// use Evas\Orm\Model; +use Evas\Orm\ActiveRecord; +use Evas\Orm\Relation; + +class QueryBuilder extends DbQueryBuilder +{ + /** @var string разделитель для полей связанных записей */ + public static $relationDataSeparator = '_-_'; + + /** @var string класс модели данных */ + protected $model; + + /** @var array обработанный результат запроса в виде моделей */ + protected $result = []; + + + /** + * Расширяем конструктор передачей класса модели. + * @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 + */ + public function fromModel(string $model) + { + $this->model = $model; + $this->from($model::tableName()); + return $this; + } + + /** + * Преобразование полученых записей. + * @param array|null записи в виде массива + * @return array записи в виде моделей + */ + private function prepareGetResult(array $rows): array + { + foreach ($rows as $row) { + $this->prepareOneResult($row); + } + return array_values($this->result); + } + + /** + * Преобразование полученой записи. + * @param array|null запись в виде массива + * @return ActiveRecord запись в виде модели + */ + private function prepareOneResult(array $row = null) + { + if (!$row) return $row; + $foreigns = []; + if (count($this->withs) > 0) { + foreach ($row as $key => $value) { + @[$fname, $fkey] = explode(static::$relationDataSeparator, $key, 2); + if ($fkey && in_array($fname, array_keys($this->withs))) { + $foreigns[$fname][$fkey] = $value; + unset($row[$key]); + } + } + } + $id = $row[$this->model::primaryKey()]; + if (!isset($this->result[$id])) { + $this->result[$id] = new $this->model($row); + } + $result = $this->result[$id]; + foreach ($foreigns as $fname => $subs) { + $this->withs[$fname][0]->addRelated($result, $subs); + } + return $result; + } + + /** + * Выполнение select-запроса с получением результирующих строк в виде массива моделей. + * @param array|null столбцы + * @return array модели + */ + public function get($columns = null): ?array + { + $this->result = []; + $this->applyWiths(); + $rows = parent::get($columns); + return ( + count($this->aggregates) < 1 && count($this->groups) < 1 + && count($this->withs) >= count($this->joins) + ) + ? $this->prepareGetResult($rows) + : $rows; + } + + /** + * Выполнение select-запроса с получением результирующей строки в виде модели. + * @param array|null столбцы + * @return ActiveRecord|null + */ + public function one($columns = null): ?ActiveRecord + { + $this->result = []; + if (count($this->withs)) { + $rows = $this->limit(1)->get($columns); + return count($rows) ? $rows[0] : null; + } + $row = parent::one($columns); + return $this->prepareOneResult($row); + } + + protected function applyWiths() + { + if (count($this->withs)) { + if ($this->limit) { + // $this->fromSub(function ($query) { + // $query->from($this->from)->limit($this->limit); + // }, $this->from); + // $this->fromSub($this->getSql(), $this->from); + $this->fromSub($this, $this->from); + $this->bindings['where'] = []; + $this->wheres = []; + $this->limit(null); + } + $columns = static::prepareModelColumns($this->columns, $this->model); + $this->addSelect($columns); + foreach ($this->withs as [$relation, $columns, $query]) { + $this->applyWith($relation, $columns, $query); + } + } + } + + protected function applyWith(Relation $relation, array $columns = null, self $query = null) + { + $columns = static::prepareModelColumns($columns, $relation->foreignModel, $relation->name); + $this->addSelect($columns); + $this->leftJoinSub($query ?? $relation->foreignTable, $relation->name, $relation->foreignFullKey, $relation->localFullKey); + return $this; + } + + protected $withs = []; + + protected function addWith(string $name, array $columns = null, self $query = null) + { + $relation = $this->getRelation($name); + if ($relation) { + if (!isset($this->withs[$relation->name])) { + $this->withs[$relation->name] = [$relation, $columns, $query]; + } + } + return $this; + } + + public function with(...$props) + { + foreach ($props as &$prop) { + if (is_string($prop)) { + @list($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)) { + $columns = $sub; + } else if (is_callable($sub)) { + @list($name, $columns) = explode(':', $name); + $relation = $this->getRelation($name); + if (!$relation) continue; + $query = new static($this->db, $relation->foreignModel); + $sub($query); + } + } else if (is_string($sub)) { + @list($name, $columns) = explode(':', $sub); + } else { + throw new \InvalidArgumentException('Incorrect with syntax'); + } + $this->addWith($name, $columns, $query); + } + } + } + return $this; + } + + protected function addHas( + bool $isNot, bool $isWith, string $relationName, $operator = null, $value = null + ) { + @[$relationName, $columns] = explode(':', $relationName); + $relation = $this->getRelation($relationName); + if ($relation) { + $selfColumns = static::prepareModelColumns($this->columns, $this->model); + $this->select($selfColumns); + if ($isNot) { + $this->leftOuterJoinSub($relation->foreignTable, $relation->name, $relation->foreignFullKey, $relation->localFullKey); + $this->whereNull($relation->foreignFullKey); + } else { + if (func_num_args() > 3) { + if ($this->isQueryable($operator)) { + if ($operator instanceof \Closure) { + $cb = $operator; + $cb($operator = $this->forSubQuery()); + } + foreach ($operator->wheres as $where) { + if (isset($where['columns'])) foreach($where['columns'] as &$column) { + $parts = explode('.', $column); + if (count($parts) < 2 || $parts[0] != $relationName) { + array_unshift($parts, $relation); + $column = implode('.', $parts); + } + } + $this->pushWhere($where['type'], $where); + } + $this->addBindings('where', $operator->getBindings('where')); + } else { + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 4 + ); + } + if ($columns) { + $columns = explode(',', $columns); + foreach ($columns as $column) { + $column = $relation->name . '.' . $column; + // $this->count($column); + // $this->havingAggregate('count', $column, $operator, $value); + $this->where($column, $operator, $value); + // $this->whereNotNull($column); + // $this->where($column, '!=', 0); + } + } else { + $foreignFullPrimary = $relation->name . '.' . $relation->foreignModel::primaryKey(); + $this->count($foreignFullPrimary); + $this->havingAggregate('count', $foreignFullPrimary, $operator, $value); + } + } else if ($columns) { + $columns = explode(',', $columns); + foreach ($columns as $column) { + $column = $relation->name . '.' . $column; + // $this->count($column); + // $this->havingAggregate('count', $column, $operator, $value); + // $this->where($column, $operator, $value); + $this->whereNotNull($column); + // $this->where($column, '!=', 0); + } + // $columns = static::prepareModelColumns($columns, $relation->foreignModel, $relation->name); + } + $this->joinSub($relation->foreignTable, $relation->name, $relation->foreignFullKey, $relation->localFullKey); + $this->groupBy($relation->localFullKey); + } + } + return $this; + } + + public function has(string $relationName, $operator = null, $value = null) + { + return $this->addHas(false, false, ...func_get_args()); + } + + public function notHas(string $relationName, $operator = null, $value = null) + { + return $this->addHas(true, false, ...func_get_args()); + } + + public function withHas(string $relationName, $operator = null, $value = null) + { + return $this->addHas(false, true, ...func_get_args()); + } + + + protected function getRelation(string $name): ?Relation + { + return $this->model::getRelation($name); + } + + // protected function parseTableWithColumns(string $value) + // { + // @list($table, $columns) = explode(':', $value); + // return [$table, $columns]; + // } + + protected static function prepareModelColumns($columns, string $model, string $asPrefix = null) + { + $columns = static::prepareColumns($columns); + if (!$columns) $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 static function prepareColumns($columns): ?array + { + return (empty($columns) || $columns = '*' || (is_array($columns) && in_array('*', $columns))) + ? null + : explode(',', str_replace(' ', '', $columns)); + } + + // public function with(...$props) + // { + // // $relations = []; + // foreach ($props as &$prop) { + // if (is_string($prop)) { + // @list($name, $columns) = explode(':', $prop); + // $columns = $this->prepareColumns($columns) + // $relation = $this->model->$name(); + // // $this->base->leftJoin($relation->name) + // // $sql = 'SELECT * FROM `user`' + // // . ' LEFT JOIN `group` ON `group`.`user_id` = `user`.`id`'; + // // $relation = $this->getRelation($name); + // } else if (is_array($prop)) { + // foreach ($prop as $name => &$sub) { + // $columns = null; + // if (is_string($name)) { + // if (is_string($sub)) { + // $columns = $sub; + // } else if (is_callable($sub)) { + // @list($name, $columns) = $name; + // $relation = $this->model->$name(); + // $sub(new static($relation)); + // } + // } else if (is_string($sub)) { + // @list($name, $columns) = explode(':', $sub); + // } else { + // throw new \Exception('Incorrect!'); + // } + // $columns = $this->prepareColumns($columns); + // $relation = $this->model->$name(); + // // $relation = $this->getRelation($name); + // } + // } + // } + // } + + public function withCount(...$props) + {} + + public function withSum(...$props) + {} + + public function withMax(...$props) + {} + + public function withMin(...$props) + {} + + public function withAvg(...$props) + {} + + public function withExists(...$props) + {} +} diff --git a/src/RelatedCollection.php b/src/RelatedCollection.php new file mode 100644 index 0000000..10e89fb --- /dev/null +++ b/src/RelatedCollection.php @@ -0,0 +1,93 @@ + + */ +namespace Evas\Orm; + +use Evas\Base\Help\Collection; +use Evas\Base\Help\PhpHelp; +use Evas\Orm\ActiveRecord; +use Evas\Orm\Relation; + +class RelatedCollection extends Collection +{ + /** @var ActiveRecord модель */ + protected $model; + /** @var string связь */ + 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)) { + $item = new $this->relation->foreignModel($item); + } + if (!($id = $item->primaryValue()) || !$this->has($id)) { + $this->ids[] = $id; + return parent::add($item); + } + return $this; + } + + /** + * Конструктор. + * @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..f0b91be --- /dev/null +++ b/src/Relation.php @@ -0,0 +1,92 @@ + + */ +namespace Evas\Orm; + +use Evas\Orm\ActiveRecord; + +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); + if (!$localKey) $localKey = $localModel::primaryKey(); + $this->type = $type; + $this->localModel = $localModel; + $this->localKey = $localKey; + $this->foreignModel = $foreignModel; + $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 (strlen($pk) > 1 && strrpos($pk, 's') == strlen($pk) - 1) { + $pk = substr($pk, 0, strlen($pk) - 1); + } + $pk .= '_id'; + } + return $pk; + } + + /** + * Установка имени связей модели. + * @param string имя + * @return self + */ + public function setName(string $name) + { + $this->name = $name; + $this->foreignFullKey = "$this->name.$this->foreignKey"; + return $this; + } + + /** + * Добавление связи с моделью. + * @param ActiveRecord модель + * @param array данные внешней модели + * @return ActiveRecord модель + */ + public function addRelated(ActiveRecord $model, array $foreignData) + { + return $model->addRelated($this->name, new $this->foreignModel($foreignData)); + } +} diff --git a/src/Traits/RelationsTrait.php b/src/Traits/RelationsTrait.php new file mode 100644 index 0000000..69e6d7f --- /dev/null +++ b/src/Traits/RelationsTrait.php @@ -0,0 +1,131 @@ + + */ +namespace Evas\Orm\Traits; + +use Evas\Base\Help\PhpHelp; +use Evas\Orm\ActiveRecord; +use Evas\Orm\OrmException; +use Evas\Orm\RelatedCollection; +use Evas\Orm\Relation; + +trait RelationsTrait +{ + /** @static array связи */ + protected static $relations; + /** @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); + if ($relation->type === 'hasOne') { + 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; + } + + /** + * Инициализация связей. + * @throws \InvalidArgumentException + */ + protected static function initRelations() + { + $relations = static::relations(); + if ($relations) foreach ($relations as $name => &$relation) { + if (!$relation instanceof Relation) { + throw new \InvalidArgumentException(sprintf( + 'Relation must be instance of %s, %s given', + Relation::class, PhpHelp::getType($relation) + )); + } + $relation->setName($name); + } + static::$relations = &$relations; + } + + /** + * Получение связи по имени. + * @param string имя связи + * @return Relation|null + */ + public static function getRelation(string $name): ?Relation + { + if (is_null(static::$relations)) { + static::initRelations(); + } + return static::$relations[$name] ?? null; + } + + /** + * Проверка наличия связи. + * @param string имя связи + * @return bool + */ + public static function hasRelation(string $name): bool + { + if (is_null(static::$relations)) { + static::initRelations(); + } + return isset(static::$relations[$name]); + } + + /** + * Установка множественной связи. + * @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); + } +}