Глубокое погружение в отношения Фабрик
Предположим, мы хотим создать пост с 20 комментариями. Обычно для этого требуется две строки кода: одна — для создания поста, другая — для создания комментариев, как показано ниже:
Здесь представлены модели Post
и Comment
:
class Post extends Model
{
use HasFactory;
protected $fillable = [
'title',
'body',
'views',
];
public function comments()
{
return $this->hasMany(Comment::class);
}
}
class Comment extends Model
{
use HasFactory;
}
$post = Post::factory()->create();
Comment::factory(20)->create([
'post_id' => $post->id,
]);
Однако Laravel предлагает более эффективный и оптимизированный способ достижения того же результата, что значительно упрощает процесс.
Post::factory()
->hasComments(20)
->create();
Этот подход будет работать без сбоев: создаётся пост, а затем к нему прикрепляются 20 комментариев.
Однако если проанализировать класс PostFactory
, то можно заметить, что метод hasComments
в явном виде не упоминается.
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => $this->faker->sentence,
'content' => $this->faker->paragraph,
'user_id' => User::factory(),
];
}
}
В связи с этим закономерно возникает вопрос: как в действительности функционирует этот процесс?
Как работают все has{relation}()
Прежде чем перейти к рассмотрению того, как осуществляются динамические вызовы в Laravel, было бы полезно понять, как они происходят в самом PHP. (Если вы уже знакомы с магическим методом __call
в PHP, то можете пропустить этот раздел).
Магический метод __call
В php любой класс может определить магический метод __call
.
Метод __call
автоматически вызывается при вызове несуществующего или недоступного метода. Например:
class Person {
public function __call() {
return 'Person class';
}
}
$person = new Person();
$person->badMethod() // returns: 'Person class'
Более подробный пример
Вместо того чтобы использовать простую иллюстрацию, давайте для лучшего понимания спроектируем объект ValueObject
. Основное назначение объекта ValueObject
, как следует из названия, — хранение значений. Он работает, получая массив данных и используя функцию get{key}
для извлечения определённого значения из массива.
$vo = new ValueObject(['name' => 'Ahmed', 'language' => 'php']);
echo $vo->getName(); // Ahmed
echo $vo->getLanguage(); // php
echo $vo->getAge(); // null
Как же реализовать это с помощью магического метода __call
? Давайте посмотрим.
class ValueObject {
private array $data = [];
public function __construct(array $data) {
$this->data = $data;
}
public function __call($name, $args) {
if (str_starts_with($name, 'get')) {
$key = lcfirst(substr($name, 3));
return $this->data[$key] ?? null;
}
throw new \InvalidArgumentException("Method $name does not exist");
}
}
Внутри метода __call
мы проверяем, начинается ли $name
с get
. Например, что-то вроде getName
будет соответствовать. Если нет, то мы выбрасываем исключение "Метод не найден".
Аналогичным образом Laravel использует эту технику для выявления любых вызовов has{relation}
.
Глубокое погружение в класс Factory
При изучении любой из фабрик Laravel можно заметить, что они расширяют класс Illuminate\Database\Eloquent\Factories\Factory
. Давайте заглянем в этот файл и поищем метод __call
.
public function __call($method, $parameters)
{
// .... some code ....
if (! Str::startsWith($method, ['for', 'has'])) {
static::throwBadMethodCallException($method);
}
$relationship = Str::camel(Str::substr($method, 3));
$relatedModel = get_class($this->newModel()->{$relationship}()->getRelated());
if (method_exists($relatedModel, 'newFactory')) {
$factory = $relatedModel::newFactory() ?? static::factoryForModel($relatedModel);
} else {
$factory = static::factoryForModel($relatedModel);
}
if (str_starts_with($method, 'for')) {
return $this->for($factory->state($parameters[0] ?? []), $relationship);
} elseif (str_starts_with($method, 'has')) {
return $this->has(
$factory
->count(is_numeric($parameters[0] ?? null) ? $parameters[0] : 1)
->state((is_callable($parameters[0] ?? null) || is_array($parameters[0] ?? null)) ? $parameters[0] : ($parameters[1] ?? [])),
$relationship
);
}
}
Рассмотрев первые несколько строк метода __call
, можно понять, как Laravel идентифицирует отношение и создаёт на его основе фабрику, которая в нашем случае является фабрикой CommentFactory
. Далее, если посмотреть на оператор elseif
в строке 21, то Laravel проверяет, начинается ли строка с has
. Если да, то инициируется создание нового объекта Factory
из PostFactory
, содержащего коллекцию has
, которая ссылается на CommentFactory
.
public function has(self $factory, $relationship = null)
{
return $this->newInstance([
'has' => $this->has->concat([new Relationship(
$factory, $relationship ?? $this->guessRelationship($factory->modelName())
)]),
]);
}
Что происходит дальше
Теперь, когда мы поняли, что делает функция has{relation}()
(она инициирует новый Factory
с правильно определённым отношением, основанным на вызове has{relation}
), вернёмся к исходной цепочке вызовов, о которой мы говорили ранее.
Post::factory()
->hasComments(20)
->create();
Функция hasComments
возвращает PostFactory
с заданным отношением comments
, после чего вызывается метод create
.
Рассмотрим подробнее метод create
.
public function create($attributes = [], ?Model $parent = null)
{
// .... some code ....
if ($results instanceof Model) {
$this->store(collect([$results]));
$this->callAfterCreating(collect([$results]), $parent);
} else {
$this->store($results);
$this->callAfterCreating($results, $parent);
}
return $results;
}
Ключевым элементом, который нас интересует, является метод store
. Давайте исследуем, что он делает.
protected function store(Collection $results)
{
$results->each(function ($model) {
if (! isset($this->connection)) {
$model->setConnection($model->newQueryWithoutScopes()->getConnection()->getName());
}
$model->save();
foreach ($model->getRelations() as $name => $items) {
if ($items instanceof Enumerable && $items->isEmpty()) {
$model->unsetRelation($name);
}
}
$this->createChildren($model);
});
}
Последняя строка этого метода является ключом к разгадке нашего вопроса. Метод createChildren
отвечает за создание всех определённых отношений has
.
Рассмотрим этот метод более подробно.
protected function createChildren(Model $model)
{
Model::unguarded(function () use ($model) {
$this->has->each(function ($has) use ($model) {
$has->recycle($this->recycle)->createFor($model);
});
});
}
Хорошо, здесь есть несколько сложных вызовов, в частности, отсутствуют подсказки типов, которые бы подсказали нам, к чему относятся $has
и $has->recycle
. Пока оставим это в стороне и сосредоточимся на createFor($model)
. Поясним, что $model
здесь — это экземпляр класса Post
. Это означает, что $has
— это, несомненно, экземпляр CommentFactory
.
Переменная $has
является экземпляром класса Illuminate\Database\Eloquent\Factories\Relationship
, который оборачивает нашу фабрику CommentFactory
. Если мы рассмотрим метод createFor
в классе Relationship
, то увидим следующее:
public function createFor(Model $parent)
{
$relationship = $parent->{$this->relationship}();
if ($relationship instanceof MorphOneOrMany) {
$this->factory->state([
$relationship->getMorphType() => $relationship->getMorphClass(),
$relationship->getForeignKeyName() => $relationship->getParentKey(),
])->create([], $parent);
} elseif ($relationship instanceof HasOneOrMany) {
$this->factory->state([
$relationship->getForeignKeyName() => $relationship->getParentKey(),
])->create([], $parent);
} elseif ($relationship instanceof BelongsToMany) {
$relationship->attach($this->factory->create([], $parent));
}
}
В нашем конкретном случае нас интересует в первую очередь первое условие else if
. Это связано с тем, что пост имеет множество комментариев, что в конечном итоге приводит к следующему:
Comment::factory()->create(['post_id' => $post->id]);
На этом исчерпывающий обзор функционирования отношений Factory в Laravel завершён!