Глубокое погружение в отношения Фабрик

Источник: «Factory Relationships deep dive»
В Laravel фабрики играют важную роль в генерации данных для моделей.

Предположим, мы хотим создать пост с 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 завершён!

Дополнительные материалы

Предыдущая Статья

Все способы обработки null значений в PHP

Следующая Статья

For each циклы с LATERAL соединениями