Генерування фейкових даних в Laravel

Генерування фейкових даних в Laravel

У процесі розробки ми часто, а точніше, трохи частіше ніж завжди, використовуємо фейкові дані для тестування функціоналу. Тому пропоную подивитися на прикладах, як генерувати такі дані в Laravel 5.6 за допомогою Faker.

Задача 1

Найпростіша - згенерувати N користувачів.

Рішення

Для таких тривіальних речей простіше і дешевше використовувати tinker. Оскільки фабрика для створення користувачів в Laravel вже є, даємо команду в консолі:

php artisan tinker

 

 ...і створюємо, скажімо, десяток користувачів:

>>> factory(App\User::class, 10)->create()

Питання вирішене.

 

Задача 2

Трохи ускладнимо: залити в базу N постів, кожен з яких прив'язаний до користувача. Припустимо, що міграція постів виглядає наступним чином:

Schema::create('posts', function (Blueprint $table) {
      $table->increments('id');
      $table->integer('user_id')->unsigned();
      $table->string('title');
      $table->text('body');
      $table->boolean('live')->default(false);
      $table->timestamps();

      $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
 
Рішення

Насамперед реалізуємо фабрику для постів. Команда в консолі:

php artisan make:factory PostFactory --model=Post

 

Після чого йдемо в файл фабрики (project/database/factories/PostFactory.php) та дописуємо наступне:

$factory->define(App\Post::class, function (Faker $faker) {
    return [
        'user_id' => function() {
            return factory(App\User::class)->create()->id;
        },
        'title' => $faker->sentence(5),
        'body' => $faker->text(300),
        'live' => $faker->boolean
    ];
});

 

Тобто, при генерації поста ми в колбеку будемо відразу ж генерувати користувача і "смикати" його id для поля user_id. Знову викликаємо tinker і на цей раз просимо створити десяток постів:

>>> factory(App\Post::class, 10)->create()

 

І, до речі, оскільки вище ніде не згадуються відносини користувач - пости, код спрацює, навіть якщо Ви забули визначити відносини між даними сутностями в моделях. Але, як то кажуть - то таке. Ми ж йдемо далі. Вважаю, не варто пояснювати, що ситуація, коли у кожного користувача лише один пост, в реальному житті малоймовірна, тому не будемо настільки академічними.

 

Задача 3

Створити по 10 постів на кожного з 5 створюваних користувачів. Таблиця користувачів ідентична таблиці з попереднього прикладу.

Рішення

От не даремно так поставив умову - може здатися, що слід згенерувати 10 постів і якимось чином "всунути" їм потрібний user_id. Але в даному випадку слід подумати і перевернути умову: згенерувати 5 користувачів з десятьма постами кожен - так задача вирішується простіше. Підемо в фабрику користувачів і взагалі викинемо з неї поле user_id:

$factory->define(App\Post::class, function (Faker $faker) {
    return [
        'title' => $faker->sentence(5),
        'body' => $faker->text(300),
        'live' => $faker->boolean
    ];
});

 

Цього разу не будемо використовувати tinker, доберемося нарешті до seeder-а. Відкриваємо DatabaseSeeder (project/database/seeds/DatabaseSeeder.php) та додаємо в метод run() усього пару рядків:

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        factory(App\User::class, 5)->create()->each(function($user) {
            $user->posts()->saveMany(factory(App\Post::class, 10)->make());
        });
    }
}

 

Поясню, що відбувається: після створення користувачів, ми "проходимося" по кожному з них і створюємо по десятку постів. Звернемо увагу:

  • ми повинні використовувати не метод save(), а saveMany(), оскільки передаємо не один пост, а колекцію постів
  • щоб отримати цю саму колекцію постів нам потрібен метод make(). На відміну від create() він не зберігає екземпляр в базі, а лише створює його

Крім того, ми виходили з міркувань, що відносини "один до багатьох" визначено, тобто для того, щоб все це щастя запрацювало, слід в моделі користувача прописати метод posts():

...
use App\Post;

class User extends Authenticatable
{
    ...

    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

 

Ось тепер сміливо йдемо в консоль і говоримо:

php artisan db:seed

 

Результат - 5 користувачів та 50 постів - по десятку на користувача.

 

Задача 4

Давайте додамо до постів категорії. Іншими словами, потрібно згенерувати пости і прив'язати до кожного посту будь-якого користувача і категорію. Міграція категорій:

Schema::create('categories', function (Blueprint $table) {
    $table->increments('id');
    $table->string('title');
    $table->string('slug');
    $table->boolean('live')->default(false);
    $table->timestamps();
});

 

Міграція постів тепер така:

Schema::create('posts', function (Blueprint $table) {
    $table->increments('id');
    $table->integer('user_id')->unsigned();
    $table->integer('category_id')->unsigned()->nullable();
    $table->string('title');
    $table->text('body');
    $table->boolean('live')->default(false);
    $table->timestamps();

    $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
    $table->foreign('category_id')->references('id')->on('categories');
});

 

Рішення

Зробимо фабрику категорій:

php artisan make:factory CategoryFactory --model=Category

 

Та відредагуємо відповідний файл (project/database/factories/CategoryFactory.php):

<?php

use Faker\Generator as Faker;

$factory->define(App\Category::class, function (Faker $faker) {
    $title = $faker->sentence(2);

    return [
        'title' => $title,
        'slug' => str_slug($title),
        'live' => $faker->boolean,
        'sort_order' => rand(1, 50)
    ];
});

 

Тепер знову подумаємо. По-перше, і тут хочеться і можна обійтися малою кров'ю - без окремих класів-сідерів. По-друге, є два варіанти вирішення:

  • знову згенерувати по 5 користувачів, кожен з 10 постами, але при цьому не забути вставити id категорії в кожен пост
  • генерувати пости, кожен з яких пов'язувати з якимось випадковим користувачем і випадкової категорією

Я не суперечу тому, що говорив в завданні 3, тобто перевернути умову і т.д., фактично я говорю про те, що в деяких ситуаціях одне рішення виглядає краще ніж інше. В даному випадку, розглянемо обидва. Отже, перше. По суті ми повторимо те, що було в попередньому варіанті, але доповнимо його ще одним колбеком, в якому в модельку поста будемо вставляти id випадкової категорії. Зрозуміло, що спочатку потрібно їх створити. Всю роботу знову зробимо в DatabaseSeeder, метод run() буде виглядати так:

public function run()
{
    $categoryIds = factory(App\Category::class, 5)->create()->pluck('id')->toArray();

    factory(App\User::class, 5)->create()->each(function($user) use ($categoryIds) {
        $user->posts()->saveMany(
            factory(App\Post::class, 10)->make()->each(function($post) use ($categoryIds) {
                $post->category_id = rand(1, count($categoryIds)
            );
        }));
    });
}

 

Необхідність вказувати use ($categoryIds) колбеках трохи напружує, але PHP це не JavaScript, тому доводиться мириться. Пояснив все вище, але повторюся - насамперед ми створили категорії, і не відходячи від каси витягли їх id в масив. Потім після створення користувачів, перебираємо їх, генеруючи для кожного по 10 постів. При цьому пости не зберігаємо в базу даних, а просто генеруємо. У свою чергу, після того як моделі постів готові, проходимо по них і присвоюємо властивості category_id випадкове значення з масиву ідентифікаторів категорій. Візьмемо на замітку, що тут маємо вкладений цикл, тобто складність алгоритму N^2.

Тепер перейдемо до другого варіанту. Цього разу ми попередньо створимо і користувачів, і категорії, також знову "смикнемо" їх id у відповідні масиви. Потім створимо моделі постів, перебираючи їх, вставимо в в user_id і category_id випадкові значення з масивів $userIds і $categoryId. І ще один важливий момент - ми не хочемо звертатися до бази даних при створенні кожного посту - не кошерно це. Тому колекцію постів перетворимо в масив і відправимо все одним запитом. Ось повний код класу:

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $userIds = factory(App\User::class, 3)->create()->pluck('id')->toArray();
        $categoryIds = factory(App\Category::class, 5)->create()->pluck('id')->toArray();

        $posts = factory(App\Post::class, 25)->make()->each(function($post) use ($userIds, $categoryIds) {
            $post->user_id = array_random($userIds);
            $post->category_id = array_random($categoryIds);
            // $post->save();
        })->toArray();

        App\Post::insert($posts);
    }
}

 

Результат - все тих же 50 постів. Але на цей раз ніякого комунізму - у когось більше постів, у когось менше, що виглядає набагато природніше. Що приємно - вкладеного циклу більше немає, тобто складність алгоритму N, а це значить, що другий варіант буде працювати швидше. Як би там не було, обираємо ту версію, яка більше подобається і виконуємо команду:

php artisan db:seed

 

Задача 5

Останнє завдання візьмемо серйозніше: створити користувачів, категорії і пости - зв'язки як і раніше один до багатьох (користувач - пости, категорія - пости); створити ще якийсь контент, наприклад, відео; створити теги, зв'язок з постами і відео - поліморфний, багато до багатьох.

Рішення

Вирішувати також будемо трохи інакше - для кожної сутності, а також для проміжної таблиці taggables будемо створювати свій клас-seeder. відео і тегами особливо заморочуватися не будемо, ось методи up() з відповідних міграцій. Відео:

public function up()
{
    Schema::create('videos', function (Blueprint $table) {
        $table->increments('id');
        $table->string('title');
        $table->string('url');
        $table->timestamps();
    });
}

 

Теги:

public function up()
{
    Schema::create('tags', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
    });
}

 

Також нам знадобиться проміжна таблиця taggables. Команда для міграції:

php artisan make:migration create_taggables_table --create=taggables

 

Та метод up() даної міграції:

public function up()
{
    Schema::create('taggables', function (Blueprint $table) {
        $table->integer('tag_id')->unsigned();
        $table->integer('taggable_id')->unsigned();
        $table->string('taggable_type');

        $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
    });
}

 

Далі створимо фабрики:

php artisan make:factory VideoFactory --model=Video
...
php artisan make:factory TagFactory --model=Tag

 

І відредагуємо їх. Відео:

<?php

use Faker\Generator as Faker;

$factory->define(App\Video::class, function (Faker $faker) {
    return [
        'title' => $faker->sentence(4),
        'url' => $faker->url
    ];
});

 

...та теги:

<?php

use Faker\Generator as Faker;

$factory->define(App\Tag::class, function (Faker $faker) {
    return [
        'name' => $faker->word
    ];
});

 

Наступний крок - класи-сідери, в тому числі і для taggables. Всі команди в консолі аналогічні, тому приведу команду тільки для сідера користувачів:

php artisan make:seeder UsersTableSeeder

 

На простих класах seeder-ах зупинятися не буду, лише наведу їх код. 

UsersTableSeeder:

<?php

use App\User;
use Illuminate\Database\Seeder;

class UsersTableSeeder extends Seeder
{
    public function run()
    {
        factory(User::class, 5)->create();
    }
}

 

CategoriesTableSeeder:

<?php

use App\Category;
use Illuminate\Database\Seeder;

class CategoriesTableSeeder extends Seeder
{
    public function run()
    {
        factory(Category::class, 5)->create();
    }
}

 

VideosTableSeeder:

<?php

use App\Video;
use Illuminate\Database\Seeder;

class VideosTableSeeder extends Seeder
{
    public function run()
    {
        factory(Video::class, 20)->create();
    }
}

 

TagsTableSeeder:

<?php

use App\Tag;
use Illuminate\Database\Seeder;

class TagsTableSeeder extends Seeder
{
    public function run()
    {
        factory(Tag::class, 10)->create();
    }
}

 

PostsTableSeeder - фактично ми робимо те ж, що і раніше. Відмінність в тому, що тепер не потрібно створювати користувачів і категорії - за це відповідають відповідні класи. Але нам все так же потрібні їх масиви id. Після ми знову сгенеруємо моделі постів, і поля user_id, category_id присвоїмо випадкові значення з вищезазначених масивів. Щоб не напружувати базу даних купою запитів, конвертуємо наші моделі в масиви і вставимо в БД все одним махом, скориставшись методом insert():

<?php

use App\{Category, Post, User};
use Illuminate\Database\Seeder;

class PostsTableSeeder extends Seeder
{
    public function run()
    {
        $userIds = User::pluck('id')->toArray();
        $categoryIds = Category::pluck('id')->toArray();

        $posts = factory(Post::class, 20)->make()->each(function($post) use($userIds, $categoryIds) {
            $post->user_id = array_random($userIds);
            $post->category_id = array_random($categoryIds);
        })->toArray();

        Post::insert($posts);
    }
}

 

TaggablesTableSeeder - тут ще докладніше. Потрібно враховувати, що у нас немає моделі, є тільки таблиця, тобто фабрика нам не підійде. Ну і добре. Ми хочемо, щоб теги прив'язувалися випадковим чином до знову ж таки обраним випадково постами або відео. Для цього нам знову знадобляться масиви з ідентифікаторами (тегів, постів та відео). Далі зробимо так: згенерируємо або 0, або 1. Якщо буде одиниця - прив'яжемо тег до якогось посту, якщо нуль - до якого-небудь відео. Після чого, запишемо масив в базу (сформуємо 30 записів):

<?php

use App\{Post, Tag, Video};
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class TaggablesTableSeeder extends Seeder
{
    public function run()
    {
        $tagIds = Tag::pluck('id')->toArray();
        $postIds = Post::pluck('id')->toArray();
        $videoIds = Video::pluck('id')->toArray();

        $taggables = [];
        for ($i = 0; $i < 30; $i++) {
            $taggable['tag_id'] = array_random($tagIds);

            $random = rand(0, 1);
            if ($random) {
                $taggable['taggable_id'] = array_random($postIds);
                $taggable['taggable_type'] = Post::class;
            } else {
                $taggable['taggable_id'] = array_random($videoIds);
                $taggable['taggable_type'] = Video::class;
            }

            $taggables[] = $taggable;
        }

        DB::table('taggables')->insert($taggables);
    }
}

 

І остання команда:

php artisan db:seed

 

Ось і все - це завдання також вирішене. 

Висновок

Може здатися, що роботи чимало, але насправді написання всього цього коду цілком і повністю себе виправдовує, оскільки істотно заощаджує час в подальшому. Ще раз повторимо: для чогось простого використовуємо фабрики і tinker, ля речей трохи складніше можна обійтися фабриками і одним класом DatabaseSeeder, ну а якщо чимало різних зв'язків і потрібно гарненько наповнити базу - користуємося фабриками і пишемо клас-сидер для кожної таблиці. Сподіваюся, цей матеріал стане в нагоді.