在 Filament 中处理批量导入

最后更新于 作者:

Handling Bulk Imports in Filament image

每个构建的应用程序都会处理一定规模的数据。无论数据是 GitHub 存储库中的一些 Markdown 文件,还是多 TB 数据库系统中的数百万行,每天与我们的应用程序交互的用户都会这样做,以查看和操作这些数据。

当应用程序规模较小且(通常)相对较新时,数据输入看起来非常像 Filament 中表单的工作方式。如果您想向系统添加新数据,您将导航到相关表单,填写字段并提交。如果您想添加更多内容,请重复此过程。这本身没有什么错!对于大多数数据输入,这都是一个很好的解决方案!但是,当您想一次性添加大量数据,而无需花时间数百次点击表单时会发生什么?您花费大量时间点击同一个表单,就是这样。但是,有一个更好的方法!输入 CSV 上传。

使用 CSV 上传批量数据是各种应用程序使用的一种方法,可以让用户轻松上传大量数据。它们很容易从 Excel 等电子表格程序生成,并且向其中添加数据也很简单,因此用户喜欢它们!问题是,即使 CSV 在代码中易于处理,但编写用于将特定 CSV 文件导入特定数据库表的代码可能会很重复且耗时。值得庆幸的是,Filament 现在通过新的“导入操作”使该过程变得快速而轻松。

您需要什么

  1. 安装了 Filament 的 Laravel 应用程序
  2. 将从 CSV 创建的 Filament 资源及其相应的模型
  3. 一个 Importer 类(稍后将详细介绍)

设置应用程序

获取上下文

让我们从为将在本文中使用的应用程序提供一些背景信息开始。

我们正在开发一个应用程序,允许用户登录面板并记录他们收藏中的所有书籍。一些用户只有少量书籍,而另一些用户则拥有整个图书馆,他们希望将这些图书馆加入我们的应用程序,因此,我们被要求创建一个系统,允许这些批量用户一次性上传他们的整个收藏。

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

书籍

  • ID
  • 用户 ID
  • 标题
  • 作者

设置 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 页面的标题中,以便我们的用户可以在面板的“书籍”部分的上下文中选择上传他们的 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() 方法抛出“预期 1 个参数。找到 0 个”错误。这是因为,即使我们已将 ImportAction 设置为在按钮交互时运行,我们还没有告诉操作如何导入数据。这是 Importer 类的任务。

添加 Importer

首先,什么是 Importer

在 Filament 中,Importer 类包含告诉 Filament 预期从上传的 CSV 文件中获取哪些列以及如何处理这些列的逻辑。这些类以与 Resource 类定义表格列非常相似的方式定义其列期望值,因此,只要您以前使用过 Filament Resource 类,您就会在这里感到宾至如归。

我们可以使用一个简单的 artisan 命令来创建一个导入器,就像 Filament 中大多数其他文件一样

php artisan make:filament-importer Book

或者,如果我们想从现有的数据库架构中生成列(谁不想这样做!),我们可以添加 --generate 标志,如下所示

php artisan make:filament-importer Book --generate

运行后,这些命令将在 app/Filament/Imports 目录中生成并放置您的 Importer 类。如果我们在项目中运行 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;
}
}

首先,我们有模型属性。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` 列

到目前为止,我们已经添加了一个操作按钮来触发导入,并且定义了 `ImportAction` 将使用的 `BookImporter`,但我们尚未告诉 Filament 预期从 CSV 文件中获得哪些类型的数据。为此,我们需要从 `getColumns()` 方法中返回一个 `ImportColumn` 对象数组。我们将假设我们的 `Book` 模型上的每个属性(除了时间戳)在我们的 CSV 中都有一个相应的列。这意味着我们需要为 `user_id`、`title` 和 `author` 创建一个 `ImportColumn`。

让我们先将 `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 文件后,导航到 Filament 中的 `Book` 表视图,然后单击我们之前创建的 `导入书籍` 操作。您会看到一个 Filepond 文件上传器。CSV 文件在 Filepond 文件上传器中处理完成后,您将在模态框中看到一个名为“列”的字段集。这些选择字段是导入过程中的“映射器”。每个字段的标签对应于我们之前创建的 `ImportColumn` 对象之一。每个标签旁边的选择字段对应于上传的 CSV 的哪一列将把它的数据映射到 Filament 在导入过程中处理的每一行的模型中。如果您的用户上传的 CSV 中包含正确的数据,但列标题与您的预期不完全一致,这将特别有用。例如,如果一个用户上传了一个带有 `User` 而不是 `user_id` 的 CSV,他们仍然可以手动将该列映射到 `Book` 模型上的 `user_id` 属性。

如果您的 CSV 标题与我的标题相同,您会发现选择字段已经用适当的 CSV 列填充。这是因为 Filament 会尝试通过名称自动确定哪个 `ImportColumn` 与哪个 CSV 标题相匹配,作为默认行为。

此时,一旦所有列映射都已选择,您就可以单击“导入”。如果您的项目中一切都设置正确,您将看到新的 `Book` 在 Filament 中的表格中显示!

恭喜!您已成功从 CSV 中导入大量数据!但我们还没有完成,我们还有几种方法可以改进我们已经构建的内容。

增加一些细节

虽然我们已经成功从 CSV 中导入数据,但在将此功能发布到实际环境中之前,您还需要了解一些必要的批量导入功能。

必需的映射

目前,根据我们设置 `ImportColumn` 数组的方式,我们的列都不需要映射到 CSV 中的列。这意味着我们可以将任何映射留空,这会导致 Laravel 在尝试保存没有三个必需参数(`user_id`、`title` 和 `author`)的 `Book` 模型时抛出错误。

幸运的是,Filament 提供了一种简单的解决方法。通过在每个 `ImportColumn` 对象上添加 `requiredMapping()` 方法,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
}

列验证

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

为此,我们可以将 `rules()` 方法添加到 `getColumns()` 方法中的每个 `ImportColumn` 中,并将我们从 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(),
];
}
 
// Other methods
}

处理关系

我们的 `getColumns()` 方法开始看起来好多了,但我们还可以采取一个简单的步骤来利用 Laravel 的模型并清理此代码。在 Filament 中的多个地方,我们能够利用我们已经在模型上定义的 Eloquent 关系来填充选择下拉菜单、访问相关数据和保存相关模型。现在,我们也可以使用它们来清理我们的批量导入逻辑!

以 `user_id` 列为例。我对它有两个主要不满。首先,我不喜欢我们明确保存 `user_id` - 我更希望告诉 Laravel 使用我已有的关系逻辑来为我保存用户。其次,我不喜欢我必须指定规则,这些规则基本上是 Laravel 本身已经可以完成的关系检查的重复。

谢天谢地,我们可以通过用 `relationship()` 替换 `user_id` `ImportColumn` 上的 `rules()` 方法来解决这两个问题。

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

在上面的代码差异中,除了用 `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 中最喜欢的功能之一,我们希望它也能很快成为您最喜欢的功能之一!

Alex Six photo

Zillow 的高级软件开发工程师。@ Filament 的开发者关系负责人。导师。热爱 Laravel、Filament、JS 和 Tailwind。Vim 用户。有一只可爱的柯基犬。在 https://alexandersix.com 上发布帖子

Cube

Laravel 新闻

加入 40,000 多名其他开发者,永不错过新的提示、教程等等。

Laravel Forge logo

Laravel Forge

轻松创建和管理您的服务器,并在几秒钟内部署您的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动完成和对本地和生产环境的即时反馈进行 Tinker。

Tinkerwell
No Compromises logo

无妥协

Joel 和 Aaron,来自 No Compromises 播客的两位经验丰富的开发者,现在可以为您的 Laravel 项目聘用。⬧ 固定费用为 7500 美元/月。⬧ 没有冗长的销售流程。⬧ 没有合同。⬧ 100% 返款保证。

无妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,确保您的 Web 应用程序取得成功。

Kirschbaum
Shift logo

Shift

使用旧版 Laravel 版本?即时、自动化的 Laravel 升级和代码现代化,让您的应用程序保持新鲜。

Shift
Bacancy logo

Bacancy

为您的项目配备经验丰富的 Laravel 开发人员,拥有 4-6 年的经验,每月只需 2500 美元。获得 160 小时的专业知识,享受 15 天的免费试用。立即预约通话!

Bacancy
Lucky Media logo

Lucky Media

立即获得好运 - Laravel 开发的理想选择,拥有十多年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel 电子商务

Laravel 的电子商务。一个开源包,将现代无头电子商务功能的强大功能带到 Laravel。

Lunar: Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 招聘网站

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS 启动套件

SaaSykit 是一个 Laravel SaaS 启动套件,包含运行现代 SaaS 所需的所有功能。支付、漂亮的结账、管理面板、用户仪表板、身份验证、现成组件、统计数据、博客、文档等等。

SaaSykit: Laravel SaaS 启动套件
Rector logo

Rector

您无缝升级 Laravel、降低成本和加速创新的合作伙伴,助力企业取得成功

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。了解灵活、现代的 MongoDB 数据库如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的零依赖输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

在您的 Laravel 应用程序中添加 Swagger UI

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel Prompts 构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的网站搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 包为您的 Laravel 应用程序添加评论

阅读文章