编程

在 Filament 中处理批量导入

764 2024-02-03 08:20:00

每个应用都处理一定规模的数据。无论数据是 GitHub 仓库中的几个 markdown 文件,还是数万亿字节数据库系统中的数百万行,每天与我们的应用交互的用户都是为了查看和操作这些数据。

当应用规模较小且(通常)相对较新时,数据输入看起来与 Filament 中表单的工作方式非常相似。如果要向系统中添加新数据,请转到相关表单,填写字段,然后提交。如果你想添加更多,你可以重复这个过程。这并没有本质上的错误!对于大多数数据输入,这是一个很好的解决方案!然而,当你想一次添加大量数据,而不必花时间在表单上点击数百次时,会发生什么?你花了很多时间点击同一个表单,就是这样。但还有更好的方法!使用 CSV 上传。

使用 CSV 上传批量数据是各种应用使用的一种方法,为用户提供了上传大量数据的简单方法。它们很容易从 Excel 这样的电子表格程序中生成,向其中添加数据也很简单,所以用户喜欢它们!问题是,尽管 CSV 很容易在代码中处理,但编写代码将特定的 CSV 文件导入特定的数据库表可能是重复和耗时的。值得庆幸的是,Filament 现在通过新的 “Import Action” 使这一过程变得快速而简单。

我们所需

  1. 安装 Filament 的  Laravel 应用
  2. Filament 资源及其对应模型
  3. 一个 Importer

安装应用

获得上下文

让我们先为我们将在本文中使用的应用提供一些背景知识。

我们正在开发一个应用,允许用户登录到面板并记录他们收藏的所有书籍。一些用户只有少量书籍,但另一些用户有完整的图书库,他们想将其加载到我们的应用中,因此我们的任务是创建一个系统,允许这些批量用户一次上传他们的全部收藏。

让我们假设应用具有以下 Book 模型,并且我们已经带有 form()table() 方法创建了 BookResource

Book

  • ID
  • User ID
  • Title
  • Author

设置 CSV 导入器的先决条件

在我们开始编写代码来实现导入程序之前,我们需要设置一些先决条件。在底层下,Filament 的 CSV 导入系统使用两个底层的 Laravel 系统:作业批处理和数据库通知。此外,它还使用 Filament 提供的新表来管理和存储有关导入本身的信息。我们可以通过四个简单的命令进行设置:

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

一旦这些命令成功运行,并且每个底层系统都已设置好,我们就可以开始构建导入了!

如果想跟着本文一起操作或查看最终产品,我们在本文中讨论的代码库可以在此处找到。存储库中的每个提交都对应于本文的以下部分之一。如果您想查找特定章节的代码,则每条提交消息都将包括它所对应的章节的名称。

导入 CSV

添加 ImportAction

处理好前置要求后,在 Filament 中设置 CSV 导入的第一步是在接口的某处添加 ImportAction。通常,该按钮放在页面的头部区域或者表格头部。比如,我们将 ImportAction 添加到 ListBooks 页面的头部,这一当用户在面板的 “Books” 区域的上下文中就可以上传它们的 CSV。

添加完 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 类定义表格列字段的方式非常相似,所以只要你以前使用过 Filament Resource 类,你就会在这里如鱼得水。

与 Filament 中的大多数其他文件一样,我们可以使用简单的 artisan 命令创建 importer:

php artisan make:filament-importer Book

或者,你想从现有的数据库 schema 中生成列,可以添加 `--generate` 标记:

php artisan make:filament-importer Book --generate

一旦运行,这些命令将生成 Importer 类并将其放置在 app/Fament/Inports 目录中。如果我们在项目中运行 make:filament-importer 命令(为了示例起见,没有 --generate 标志),我们现在将有一个名为 app/Filent/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 类中花费大部分时间的地方。它有一个与 Resource 类上的 form()table() 方法非常相似的API,但它不是定义要在 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() 方法。此方法用来确定 CSV 导入完成时 Filament 通知正文中显示的文本。除了稍微调整模型名称之外,可你能很少需要更改此处的内容。

现在我们已经添加了新的 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 按钮来触发我们的导入,并且我们已经定义了一个由 ImportAction 使用的 BookImporter,但我们还没有告诉 Filament 我们的 CSV 文件需要什么类型的数据。为此,我们需要从 getColumns() 方法中返回一个 ImportColumn 对象数组。我们将继续假设 Book 模型上的每个属性(时间戳除外)在 CSV 中都有相应的列。这意味着我们需要一个带有 user_idtitleauthorImportColumn

让我们从将 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_idtitleauthor
1testJohn Doe

创建 CSV 文件后,请转到 Filament 中的 Book 表视图,然后单击我们之前创建的 Import Book 操作。迎接你的将是一个 Filepond 文件上传器。一旦在 Filepond 文件上传器中处理了 CSV 文件,你将在模态框中看到一个字段集 “Columns”。这些选择字段是导入过程的“映射器”。每个字段的标签都对应于我们前面创建的 ImportColumn 对象之一。每个标签旁边的选择字段对应于上传的 CSV 的哪些列将其数据映射到 Filament 在导入过程中处理的每一行的模型中。如果用户上传的 CSV 的标题下列出了与你预期不完全相同的正确数据,这将特别有用。例如,如果用户上传了一个带有 User 而不是 user_id 的 CSV,他们仍然可以手动将该列映射到 Book 模型上的 user_id 属性。

如果你的 CSV 标题与我的相同,你将看到选择字段已经用相应的 CSV 列填充。这是因为默认情况下,Filament 将尝试通过名称自动确定哪个 ImportColumn 匹配哪个 CSV标题。

此时,一旦选择了所有列映射,就可以单击“导入”。如果你的项目中一切都设置正确,你会看到你的 Book 在 Filament 中填充表格!

你已成功从 CSV 导入批量数据!但还没有完成,我们仍有一些方法可以改进。

添加一些色泽

尽管我们已经成功地从 CSV 导入了数据,但在将此功能发布之前,你仍然应该了解一些重要的批量导入功能。

必需映射

目前,按照我们设置 ImportColumn 数组的方式,我们的任何列都不需要映射到 CSV 中的列。这意味着我们可以将任何映射留空,从而导致当 Laravel 试图在没有三个必需参数(user_idtitleauthor)的情况下保存 Book 模型时抛出错误。

幸运的是,Filament 有一个简单的方法来解决这个问题。通过将 requiredMapping() 方法添加到我们的每个 ImportColumn 对象上,Filament 将不允许用户启动导入,直到每个列字段都映射好为止。

<?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
}

列字段验证

除了没有任何必需的映射之外,我们当前的导入解决方案也没有任何验证。就像应该始终验证传入请求以确保恶意用户不会试图破坏我们的系统一样,我们也应该始终验证批量导入字段。

要做到这一点,我们可以在 getColumns() 方法中向每个 ImportColumn 添加 rules() ,并传递我们从 Laravel 的 FormRequest 类中只需和喜爱的相同验证规则。例如,以下是我要添加到现有 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(),
        ];
    }

处理关联

我们的 getColumns() 方法开始看起来好多了,但我们可以采取一个更简单的步骤来利用 Laravel 的模型并清洁这些代码。在 Filament 的多个不同位置,我们能够利用我们已经在模型上定义的 Eloquent 关联来填充选择下拉列表、访问相关数据和保存相关模型。现在,我们还可以使用它们来清洁我们的批量导入逻辑!

user_id 为例。我对此有两个主要的不满。首先,我不喜欢我们专门保存 user_id — 我更愿意让 Laravel 使用我已经存在的关联逻辑为我保存 user。其次,我不希望我必须指定本质上与 Laravel 底层已经实现的关联检查进行重复的规则。

值得庆幸的是,我们可以通过用 relationship() 替换 user_id ImportColumn 上的 rules() 方法来解决这两个问题。

<?php
 
// Namespaces
 
class BookImporter extends Importer
{
    protected static ?string $model = Book::class;
 
    public static function getColumns(): array
    {
        return [
            //- mportColumn::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
}

在上面的代码不同中,除了用 relationship() 替换 rules() 方法外,我们还更改了 ImportColumn 的名称,以符合我们在 Book 模型上定义的关联(在本例中,它是 user)。

这好多了,但我仍然能想到一个我们应该解决的小问题。用户当前仍然需要在 CSV 中输入每一行的用户 ID。在许多系统中,用户很难知道自己(以及彼此)的用户ID。相反,我们应该使用更容易知道并且仍然唯一的东西,比如电子邮件地址!

现在,如果我们尝试这样做,我们会从 Laravel 得到一个错误,因为 users 表中的 ID 看起来并不像电子邮件地址。然而,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 链接的主键!

探索更多

有了这些,你现在只需几行代码就成功地实现了整个批量导入系统!但我们只触及了表面。在批量导入系统中还有很多东西要探索:提供示例 CSV 数据、自定义导入作业、强制转换状态等等!有关如何自定义自己的导入操作的更多详细信息,请参阅导入操作文档

一如既往,我们很想听听大家的看法!这是 v3.1 中我最喜欢的功能之一,我希望它也能很快成为你的功能之一!