Использование загрузки файлов CSV в Filament Laravel для массового импорта данных
Загрузка больших объёмов данных в файлах CSV для массового импорта в Filament Laravel

Как использовать CSV для загрузки больших объемов данных в Filament Laravel



Использование CSV для загрузки больших объемов данных в Filament

Каждое приложение Laravel, которое создается, имеет дело с данными в том или ином масштабе. Будь то несколько файлов с разметкой в репозитории GitHub или миллионы строк в многотерабайтной базе данных. Пользователи, которые ежедневно взаимодействуют с нашими приложениями на Laravel, делают это ради того, чтобы видеть эти данные и манипулировать ими.

Когда приложения Laravel небольшие по размеру и (обычно) относительно новые, ввод данных очень похож на то, как работают формы в Filament. Если требуется добавить новые данные в систему, нужно перейти к соответствующей форме, заполнить поля и отправить. Если же вы нужно добавить больше, этот процесс повторяется. По своей сути в этом нет ничего плохого! Для большинства случаев ввода данных это отличное решение! Однако что происходит, когда необходимо добавить сразу много данных, не тратя время на сотни кликов мышью в форме? Много времени уходит на просмотр одной и той же формы, вот что. Но есть лучший способ! Начните с загрузки данных в формате CSV.

Использование CSV для загрузки больших объемов данных — это метод, который используют всевозможные приложения на Laravel для того, чтобы предоставить пользователям простой способ загрузки большого количества данных. Их легко генерировать из электронных таблиц, таких как Excel, и добавлять в них данные очень просто, поэтому они так нравятся пользователям! Проблема в том, что, хотя с CSV легко работать, написание кода для импорта определенных CSV-файлов в определенные таблицы базы данных может быть слишком трудоемким и повторяющимся. К счастью, Filament теперь делает этот процесс быстрым и легким с помощью нового "Действия импорта" (Import Action).

Что для этого понадобится

  1. Приложение Laravel с установленным Filament.
  2. Ресурс Filament и соответствующая ему модель, которая будет создана из CSV.
  3. Класс Importer (подробнее об этом позже).

Настройка приложения

Получение контекста

Давайте начнем с небольшой предыстории приложения Laravel, которое будет рассматриваться в этой статье.

Сейчас разрабатывается приложение Laravel, которое позволяет пользователям заходить в панель и сохранять все книги, которые есть в их коллекции. У некоторых пользователей есть всего несколько книг, но у других есть целые библиотеки, которые они хотят добавить в наше приложение, поэтому требуется создать систему, которая позволит этим пользователям загружать всю свою коллекцию сразу.

Предположим, что приложение имеет следующую модель Book и что у нас уже есть ресурс BookResource, созданный с помощью метода form() и table():

  • Book
    • ID
    • User ID
    • Title
    • Author

Настройка предварительных условий для импорта CSV

Прежде чем приступать к написанию кода для реализации нашего импортера, необходимо выполнить несколько предварительных действий. Под капотом Filament его система импорта CSV использует две базовые системы Laravel: пакеты заданий (job batches) и сообщения базы данных (database notifications). Кроме того, она также использует новую таблицу, предоставленную Filament, для управления и хранения информации о самом импорте. Это можно настроить с помощью четырех простых команд:

php artisan queue:batches-table
php artisan notifications:table
php artisan vendor:publish --tag=filament-actions-migrations
 
php artisan migrate

После успешного выполнения этих команд и настройки всех базовых систем все готово к созданию нашего импорта!

Репозиторий, о котором идет речь в статье, можно найти здесь, если вы хотите поработать над статьей или увидеть конечный продукт. Каждый коммит в репозитории соответствует одному из следующих разделов статьи. Если вы хотите найти код для конкретного раздела, в каждом сообщении о коммите будет указано название раздела, которому он соответствует.

Импорт CSV-файлов

Добавление ImportAction

После выполнения всех предварительных шагов первым пунктом настройки импорта CSV в Filament является добавление ImportAction куда-нибудь в пользовательский интерфейс. Обычно эта кнопка размещается в разделе заголовка страницы или в заголовке таблицы. В этом примере кнопка ImportAction добавлена в заголовок страницы ListBooks, чтобы у пользователей была возможность загрузить свой CSV-файл в контексте раздела "Books" в панели Filament.

После добавления ImportAction файл ListBooks.php должен выглядеть следующим образом:

<?php
 
namespace App\Filament\Resources\BookResource\Pages;
 
use App\Filament\Imports\BookImporter;
use App\Filament\Resources\BookResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
 
class ListBooks extends ListRecords
{
    protected static string $resource = BookResource::class;
 
    protected function getActions(): array
    {
        return [
            Actions\ImportAction::make() 
                ->importer(),
            Actions\CreateAction::make(),
        ];
    }
}

Если вы следите за развитием событий и вводите приведенный выше код в выбранный вами редактор, то можете заметить, что метод ->importer() выдает ошибку "Expected 1 argument. Found 0". Это происходит потому, что, хотя для ImportAction был задан режим выполнения при нажатии на кнопку, пока что мы не объяснили ему, как импортировать данные. Это задача класса Importer.

Добавление Importer

Прежде всего, что такое Importer?

В Filament классы Importer содержат логику, которая указывает Filament, какие столбцы ожидать от загруженного CSV-файла и как их обрабатывать. Эти классы определяют свои требования к столбцам очень похоже на то, как классы Resource определяют столбцы таблиц, поэтому, если вы уже работали с классами Resource Filament, вы будете чувствовать себя здесь как дома.

Мы можем создать импортер, как и большинство других файлов в Filament, с помощью простой команды artisan:

php artisan make:filament-importer Book --generate

После выполнения команды класс Importer будет сгенерирован и помещен в каталог app/Filament/Imports. Если выполнить команду make:filament-importer (без флага --generate, для примера) в нашем проекте, у нас появится файл app/Filament/Imports/BookImporter.php.

Давайте быстро пройдемся по важным разделам этого файла:

<?php
 
namespace App\Filament\Imports;
 
use App\Models\Book;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class; 
 
    public static function getColumns(): array
    {
        return [
            //
        ];
    }
 
    public function resolveRecord(): ?Book
    {
        // return Book::firstOrNew([
        //     // Update existing records, matching them by `$this->data['column_name']`
        //     'email' => $this->data['email'],
        // ]);
 
        return new Book();
    }
 
    public static function getCompletedNotificationBody(Import $import): string
    {
        $body = 'Your book import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.';
 
        if ($failedRowsCount = $import->getFailedRowsCount()) {
            $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.';
        }
 
        return $body;
    }
}

Прежде всего, у нас есть свойство модели $model. Importer использует его, чтобы узнать, в какую модель сохранить загруженные CSV-данные! Это небольшая деталь, но она очень важна!

<?php
 
namespace App\Filament\Imports;
 
use App\Models\Book;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array 
    {
        return [
            //
        ];
    }
 
    public function resolveRecord(): ?Book
    {
        // return Book::firstOrNew([
        //     // Update existing records, matching them by `$this->data['column_name']`
        //     'email' => $this->data['email'],
        // ]);
 
        return new Book();
    }
 
    public static function getCompletedNotificationBody(Import $import): string
    {
        $body = 'Your book import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.';
 
        if ($failedRowsCount = $import->getFailedRowsCount()) {
            $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.';
        }
 
        return $body;
    }
}

Метод getColumns() — это то место, где вы будете проводить большую часть времени в классе Importer. Он имеет очень похожий API на метод form() и table() класса Resource, но вместо определения полей и столбцов, которые будут отображаться в интерфейсе Filament, он определяет столбцы, которые будут ожидаться из загруженного CSV-файла, и описывает, как обрабатывать данные в них. Подробнее об этом мы поговорим позже, а пока просто знайте, что любые данные, которые вы хотите импортировать из CSV, должны присутствовать в этом методе в той или иной форме.

<?php
 
namespace App\Filament\Imports;
 
use App\Models\Book;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array
    {
        return [
            //
        ];
    }
 
    public function resolveRecord(): ?Book 
    {
        // return Book::firstOrNew([
        //     // Update existing records, matching them by `$this->data['column_name']`
        //     'email' => $this->data['email'],
        // ]);
 
        return new Book();
    }
 
    public static function getCompletedNotificationBody(Import $import): string
    {
        $body = 'Your book import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.';
 
        if ($failedRowsCount = $import->getFailedRowsCount()) {
            $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.';
        }
 
        return $body;
    }
}

Далее у нас есть метод resolveRecord(). Этот метод вызывается для каждой строки в CSV и отвечает за возврат экземпляра модели, который будет заполнен данными из CSV. По умолчанию он создает новую запись, но мы можем изменить логику в этом методе, чтобы вместо этого обновлять существующие записи. Быстрый и простой способ сделать это — раскомментировать блок Book::firstOrNew(), который будет искать существующую запись и обновлять ее, если она найдена. В противном случае будет создана новая запись из этой строки CSV.

<?php
 
namespace App\Filament\Imports;
 
use App\Models\Book;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array
    {
        return [
            //
        ];
    }
 
    public function resolveRecord(): ?Book
    {
        // return Book::firstOrNew([
        //     // Update existing records, matching them by `$this->data['column_name']`
        //     'email' => $this->data['email'],
        // ]);
 
        return new Book();
    }
 
    public static function getCompletedNotificationBody(Import $import): string 
    {
        $body = 'Your book import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.';
 
        if ($failedRowsCount = $import->getFailedRowsCount()) {
            $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.';
        }
 
        return $body;
    }
}

И наконец, у нас есть метод getCompletedNotificationBody(). Этот метод определяет, какой текст будет показан в теле уведомления Filament, когда импорт CSV будет завершен. Скорее всего, вам очень редко придется менять то, что здесь находится, разве что иногда корректировать название модели.

Теперь, когда мы добавили наш новый класс BookImporter, нам нужно вернуться и убедиться, что мы добавили его в наш ImportAction, созданный ранее. Мы можем просто обновить ImportAction следующим образом:

<?php
 
namespace App\Filament\Resources\BookResource\Pages;
 
use App\Filament\Imports\BookImporter;
use App\Filament\Resources\BookResource;
use App\Filament\Imports\BookImporter;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
 
class ListBooks extends ListRecords
{
    protected static string $resource = BookResource::class;
 
    protected function getActions(): array
    {
        return [
            Actions\ImportAction::make()
//                ->importer(), 
                ->importer(BookImporter::class), 
            Actions\CreateAction::make(),
        ];
    }
}

Определение столбцов у Importer.

На данный момент мы добавили кнопку Action для запуска импорта и определили BookImporter, который будет использоваться ImportAction, но мы еще не сказали Filament, какие типы данных ожидать от нашего CSV-файла. Для этого нам нужно вернуть массив объектов ImportColumn из нашего метода getColumns(). Мы предположим, что каждое свойство нашей модели Book (за исключением временных меток (timestamps)) будет иметь соответствующий столбец в CSV. Это означает, что нам нужен ImportColumn для user_id, title и author.

Начнем с того, что просто добавим объекты ImportColumn в метод getColumns(). В оставшейся части этого раздела я удалю методы и объявления пространства имен, которые не имеют отношения к делу, но все еще присутствуют в актуальном классе.

<?php
 
// Namespaces
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array
    {
        return [
            ImportColumn::make('user_id'), 
            ImportColumn::make('title'),
            ImportColumn::make('author'),
        ];
    }
 
    // Other methods
}

После того как вы добавили эти три объекта ImportColumn в свой метод getColumns(), вы готовы импортировать свой первый CSV! Любым удобным для вас способом создайте небольшой CSV-файл, чтобы протестировать загрузку. Я рекомендую иметь одну строку с данными, которая выглядит примерно так:

user_id title author
1 test John Doe

Когда CSV-файл будет создан, перейдите к представлению таблицы Book в Filament и нажмите на действие Import Book, которое мы создали ранее. Вас встретит загрузчик файлов Filepond. После обработки CSV-файла в загрузчике файла Filepond в модальном окне появится набор полей "Columns".

Эти выбранные поля являются "картографами" для процесса импорта. Метка каждого поля соответствует одному из объектов ImportColumn, которые мы создали ранее. Поле выбора, расположенное рядом с каждой меткой поле, соответствует тому, данные какого из столбцов загруженного CSV будут отображены в модели для каждой строки, которую Filament обрабатывает во время импорта.

Это может быть особенно полезно, если ваши пользователи будут загружать CSV с правильными данными, указанными под заголовками, которые не совсем соответствуют вашим ожиданиям. Например, если пользователь загрузил CSV с User вместо user_id, он все равно сможет вручную сопоставить этот столбец со свойством user_id в модели Book.

Пример использования CSV для загрузки больших объемов данных в Filament

Если ваши CSV-заголовки были идентичны моим, вы увидите, что поля выбора уже заполнены соответствующим значением CSV-столбца. Это происходит потому, что Filament по умолчанию пытается автоматически определить, какой столбец ImportColumn подходит к заголовку CSV по имени.

На этом этапе, когда все сопоставления столбцов выбраны, можно нажать кнопку "Import". Если в вашем проекте все было настроено правильно, вы увидите, как ваша новая книга Book заполнит таблицу в Filament!

Поздравляем! Вы успешно импортировали данные из CSV! Но мы еще не закончили, есть еще несколько способов улучшить то, что мы уже создали.

Добавьте немного полезностей

Несмотря на то, что мы успешно импортировали данные из CSV, есть еще несколько важных функций массового импорта, о которых вы должны знать, прежде чем выпустить эту функцию в открытый доступ.

Требуемые соотношения

В настоящее время при том, как был настроен массив ImportColumn, ни один из используемых столбцов не должен быть сопоставлен со столбцом в CSV. Это означает, что можно оставить любые сопоставления пустыми, что приведет к ошибкам, когда Laravel попытается сохранить модель Book без трех обязательных параметров: user_id, title и author.

К счастью, в Filament есть простой способ исправить это. Добавив метод requiredMapping() к каждому из наших объектов ImportColumn, Filament не позволит пользователю начать импорт, пока каждый столбец не будет привязан к данным из загруженного CSV-файла.

<?php
 
// Namespaces
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array
    {
        return [
//            ImportColumn::make('user_id'), 
            ImportColumn::make('user_id') 
                ->requiredMapping(),
//            ImportColumn::make('title'), 
            ImportColumn::make('title') 
                ->requiredMapping(),
//            ImportColumn::make('author'), 
            ImportColumn::make('author') 
                ->requiredMapping(),
        ];
    }
 
    // Other methods
}

Валидация столбцов

Наряду с отсутствием необходимых сопоставлений, наше текущее решение для импорта не имеет никакой валидации. Подобно тому, как входящие запросы всегда должны проверяться, чтобы убедиться, что злоумышленник не пытается сломать нашу систему, мы также должны проверять поля массового импорта.

Для этого мы можем добавить метод rules() для каждого ImportColumn в метод getColumns() и передать в него те же правила валидации, которые мы знаем и любим из класса FormRequest в Laravel. Например, вот несколько правил валидации, которые можно добавить к нашим существующим объектам ImportColumn:

<?php
 
// Namespaces
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array
    {
        return [
            ImportColumn::make('user_id')
                ->rules(['required', 'exists:users,id']) 
                ->requiredMapping(),
            ImportColumn::make('title')
                ->rules(['required', 'max:255']) 
                ->requiredMapping(),
            ImportColumn::make('author')
                ->rules(['required', 'max:255']) 
                ->requiredMapping(),
        ];
    }
 
    // Other methods
}

Работа с отношениями

Наш метод getColumns() стал выглядеть гораздо лучше, но есть еще один простой шаг, который можно сделать, чтобы использовать модели Laravel и очистить этот код. В разных местах Filament мы можем использовать связи Eloquent, которые мы уже определили в наших моделях, для заполнения выпадающих списков выбора, доступа к связанным данным и сохранения связанных моделей. Теперь мы также можем использовать их для оптимизации логики массового импорта!

Возьмем, к примеру, столбец user_id. Здесь есть две основные претензии

  • Во-первых, не логично, что мы специально сохраняем user_id - лучше сказать Laravel, чтобы он использовал уже существующую логику отношений для сохранения User автоматически.
  • Во-вторых, для сохранения User не хватает правил, которые, по сути, дублируют проверку отношений, которую Laravel уже умеет делать под капотом.

К счастью, обе эти проблемы можно решить, заменив наш метод rules() для колонки импорта user_id на relationship().

<?php
 
// Namespaces
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array
    {
        return [
//            ImportColumn::make('user_id') 
//                ->rules(['required', 'exists:users'])
            ImportColumn::make('user') 
                ->relationship()
                ->requiredMapping()
            ImportColumn::make('title')
                ->rules(['required', 'max:255'])
                ->requiredMapping(),
            ImportColumn::make('author')
                ->rules(['required', 'max:255'])
                ->requiredMapping(),
        ];
    }
 
    // Other methods
}

В приведенном выше коде, наряду с заменой метода rules() на relationship(), также изменено имя колонки ImportColumn, чтобы оно соответствовало отношению, которое мы определили в нашей модели Book (в данном случае это user).

Это гораздо лучше, но есть одна небольшая проблема, которую нужно решить. В настоящее время пользователям все еще необходимо вводить идентификатор пользователя в CSV для каждой строки. Во многих системах пользователям сложно знать свои собственные (и друг друга) идентификаторы пользователей. Вместо этого следует использовать что-то более легко узнаваемое и по-прежнему уникальное, например адрес электронной почты!

Сейчас, если бы мы попробовали это сделать, то получили бы ошибку от Laravel, потому что ни один ID в таблице users не похож на адрес электронной почты. Однако Filament предоставляет способ решить эту проблему!

<?php
 
// Namespaces
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array
    {
        return [
            ImportColumn::make('user')
//                ->relationship() 
                ->relationship(resolveUsing: 'email') 
                ->requiredMapping()
            ImportColumn::make('title')
                ->rules(['required', 'max:255'])
                ->requiredMapping(),
            ImportColumn::make('author')
                ->rules(['required', 'max:255'])
                ->requiredMapping(),
        ];
    }
 
    // Other methods
}

Теперь, когда Filament пытается импортировать каждую строку, вместо того чтобы искать первичный ключ, связывающий Book с ее User, он будет искать адрес электронной почты User!

И напоследок ещё много интересного...

Таким образом, с помощью всего нескольких строк кода была успешно реализована целая система массового импорта! Но мы только начали. В системе массового импорта есть еще много интересного: предоставление образцов CSV-данных, настройка задания импорта, состояние выборки и многое другое! Ознакомьтесь с документацией по действиям импорта, чтобы узнать больше о том, как настроить собственные действия импорта.

Перевод с английского:
www.laravel-news.com

Заберите ссылку на статью к себе, чтобы потом легко её найти!
Раз уж досюда дочитали, то может может есть желание рассказать об этом месте своим друзьям, знакомым и просто мимо проходящим?
Не надо себя сдерживать! ;)

Старт! Горячий старт на просторы интернета
Старт! Горячий старт на просторы интернета
Старт! Меню