数据库与模型
在本章中,你将创建 Post 与 Comment 资源的模型和迁移,在它们之间建立关联,使用工厂和种子文件生成虚拟数据,并通过 REPL 查询你的数据。
概述
本章介绍 Lucid——AdonisJS 的 SQL ORM。你无需编写原始 SQL 查询,而是使用称为模型的 JavaScript 类来代表你的数据库表。在本章以及教程的其余部分,你将只通过模型与数据库交互。
一个重要的区别:模型定义了你如何与数据交互,但它们不会修改数据库结构。那是迁移的工作——迁移负责创建和修改表。在构建 DevShow 的数据库结构时,你会同时使用这两者。
关于学习的一点说明: 本章一次性引入了多个数据库概念。如果你没有完全理解所有内容,不必担心——目标是通过实践学习并让东西跑起来。更深的理解会随着练习而来。
创建 Post 模型
我们的应用需要帖子,所以让我们创建一个 Post 模型及其对应的数据库迁移。在 AdonisJS 中,每个数据库表对应创建一个模型。Lucid 使用命名约定自动将模型连接到其表——Post 模型映射到 posts 表,User 模型映射到 users 表,依此类推。
-
生成模型与迁移
运行以下命令一次性创建这两个文件。
node ace make:model Post -m-m标志告诉 Ace 在创建模型的同时创建一个迁移文件。你会看到如下输出。DONE: create app/models/post.ts DONE: create database/migrations/1763866156451_create_posts_table.ts -
理解生成的模型
让我们看看模型文件中生成了什么。
app/models/post.tsimport { PostSchema } from '#database/schema' export default class Post extends PostSchema { }该模型继承自
PostSchema——一个由你的数据库迁移自动生成的类。你无需在模型文件中定义列。当你运行迁移时,AdonisJS 会扫描你的数据库表,并生成包含所有列定义、类型与装饰器的database/schema.ts文件。你的模型文件才是添加关联与业务逻辑的地方。 -
在迁移中定义表结构
让我们更新迁移文件来定义数据库表结构。这正是你添加列的地方——运行迁移后,模型会自动获取它们。
database/migrations/1763866156451_create_posts_table.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'posts' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') table.string('title').notNullable() table.string('url').notNullable() table.text('summary').notNullable() table.timestamp('created_at') table.timestamp('updated_at') }) } async down() { this.schema.dropTable(this.tableName) } }关于迁移有几点重要事项:
up方法在你执行迁移时运行并创建表。down方法在你回滚迁移时运行并删除表。- 注意数据库中的列名使用
snake_case(如created_at),而你的模型属性使用camelCase(如createdAt)。Lucid 会自动处理这种转换。
创建 Comment 模型
让我们按照与帖子相同的过程创建 Comment 模型。
-
生成模型与迁移
运行以下命令。
node ace make:model Comment -m你会看到显示已创建文件的输出。
DONE: create app/models/comment.ts DONE: create database/migrations/1763866347711_create_comments_table.ts -
在迁移中定义表结构
更新迁移,创建带有一个 content 列的 comments 表。
database/migrations/1763866347711_create_comments_table.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'comments' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') table.text('content').notNullable() table.timestamp('created_at') table.timestamp('updated_at') }) } async down() { this.schema.dropTable(this.tableName) } }
运行迁移
现在让我们通过运行迁移在数据库中创建这些表。
node ace migration:run
你会看到显示已执行迁移的输出。
❯ migrated database/migrations/1763866156451_create_posts_table
❯ migrated database/migrations/1763866347711_create_comments_table
你的数据库现在有了 posts 和 comments 表!你还会注意到 database/schema.ts 已经更新,包含了带有所有列定义的 PostSchema 和 CommentSchema 类。该文件每次运行迁移时都会自动生成——你永远不需要手动编辑它。
迁移会被记录在数据库中一个特殊的 adonis_schema 表中。一旦迁移成功运行,即使你多次执行 node ace migration:run,它也不会再次运行。
添加关联
目前我们的帖子和评论是独立存在的,但在我们的 DevShow Web 应用中,评论属于帖子,帖子属于用户。我们需要在数据库和模型中建立这些连接。
要创建这些关联,我们需要在表中添加外键列。外键是一个引用另一个表主键的列。例如,comments 表中的 post_id 列将引用 posts 表中的 id 列,从而将每条评论链接到其帖子。
由于我们的表已经存在,我们将创建一个新的迁移来添加这些外键列。
-
为外键创建迁移
以下命令将创建一个新的迁移文件,用于修改我们现有的表。
node ace make:migration add_foreign_keys_to_posts_and_comments -
添加外键列
更新迁移文件以添加外键列。
database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { async up() { /** * 向 posts 表添加 user_id */ this.schema.alterTable('posts', (table) => { table.integer('user_id').unsigned().notNullable() table.foreign('user_id').references('users.id').onDelete('CASCADE') }) /** * 向 comments 表添加 user_id 和 post_id */ this.schema.alterTable('comments', (table) => { table.integer('user_id').unsigned().notNullable() table.foreign('user_id').references('users.id').onDelete('CASCADE') table.integer('post_id').unsigned().notNullable() table.foreign('post_id').references('posts.id').onDelete('CASCADE') }) } async down() { this.schema.alterTable('posts', (table) => { table.dropForeign(['user_id']) table.dropColumn('user_id') }) this.schema.alterTable('comments', (table) => { table.dropForeign(['user_id']) table.dropForeign(['post_id']) table.dropColumn('user_id') table.dropColumn('post_id') }) } }关于这个迁移,有几点需要理解:
- 我们使用
alterTable而不是createTable,因为我们在修改已有表。 - 外键约束通过确保
user_id或post_id始终引用对应表中的有效记录,来帮助维护数据完整性。 onDelete('CASCADE')意味着如果用户或帖子被删除,它们的评论也会被自动删除。
- 我们使用
-
运行迁移
node ace migration:run❯ migrated database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments -
在 Post 模型中定义关联
既然数据库已经有了外键列,让我们更新模型来定义这些关联。
app/models/post.tsimport { PostSchema } from '#database/schema' import { hasMany, belongsTo } from '@adonisjs/lucid/orm' import type { HasMany, BelongsTo } from '@adonisjs/lucid/types/relations' import Comment from '#models/comment' import User from '#models/user' export default class Post extends PostSchema { /** * 一个帖子拥有多条评论 */ @hasMany(() => Comment) declare comments: HasMany<typeof Comment> /** * 一个帖子属于一个用户 */ @belongsTo(() => User) declare user: BelongsTo<typeof User> }请记住,像
title、url、summary和userId这样的列定义已由PostSchema(从你的迁移自动生成)处理。模型文件才是你添加关联与业务逻辑的地方。@hasMany装饰器定义了一对多关系——一个帖子拥有多条评论。@belongsTo装饰器定义了反向关系——一个帖子属于一个用户。 -
在 Comment 模型中定义关联
app/models/comment.tsimport { CommentSchema } from '#database/schema' import { belongsTo } from '@adonisjs/lucid/orm' import type { BelongsTo } from '@adonisjs/lucid/types/relations' import Post from '#models/post' import User from '#models/user' export default class Comment extends CommentSchema { /** * 一条评论属于一个帖子 */ @belongsTo(() => Post) declare post: BelongsTo<typeof Post> /** * 一条评论属于一个用户 */ @belongsTo(() => User) declare user: BelongsTo<typeof User> }
完美!我们的模型现在理解了它们之间的关联。当你加载一个帖子时,可以通过 post.comments 轻松访问它的评论;当你加载一条评论时,可以通过 comment.post 访问它的帖子,或通过 comment.user 访问它的用户。
我们没有在 User 模型上添加反向的 hasMany 关联(例如 user.posts 或 user.comments),因为本教程中不需要它们。如果你的应用需要从用户一侧查询帖子或评论,可以稍后添加。
创建工厂
既然模型和数据库表已就绪,我们就需要用虚拟数据来填充它们,以便开发和测试。工厂充当创建模型实例的蓝图,并填充逼真的假数据。你只需定义一次蓝图,然后用一行代码生成所需数量的实例。
AdonisJS 工厂使用名为 Faker 的库来生成像姓名、URL、文本段落等真实数据。这让你的虚拟数据看起来真实,而不是明显的测试占位符。
-
创建 Post 工厂
node ace make:factory PostDONE: create database/factories/post_factory.ts这会创建一个工厂文件,我们将在其中定义如何生成虚拟的 Post 数据。
-
定义 Post 工厂数据
打开工厂文件并配置为每个 Post 生成什么数据。
database/factories/post_factory.tsimport factory from '@adonisjs/lucid/factories' import Post from '#models/post' export const PostFactory = factory .define(Post, async ({ faker }) => { return { title: faker.helpers.arrayElement([ 'My First iOS Weather App', 'Personal Portfolio Website with Dark Mode', 'Real-time Chat Application', 'Expense Tracker Progressive Web App', 'Markdown Blog Engine', 'Recipe Finder with AI Recommendations', '2D Platformer Game in JavaScript', 'Task Management Dashboard', 'URL Shortener with Analytics', 'Fitness Tracking Mobile App', ]), url: faker.internet.url(), summary: faker.lorem.paragraphs(3), } }) .build()faker.helpers.arrayElement()从数组中随机选取一个标题。faker.internet.url()生成一个真实的 URL。faker.lorem.paragraphs(3)创建三段占位文本。
-
创建 Comment 工厂
现在让我们用同样的过程为评论创建一个工厂。
node ace make:factory CommentDONE: create database/factories/comment_factory.ts -
定义 Comment 工厂数据
打开 Comment 工厂并添加数据生成逻辑。
database/factories/comment_factory.tsimport factory from '@adonisjs/lucid/factories' import Comment from '#models/comment' export const CommentFactory = factory .define(Comment, async ({ faker }) => { return { content: faker.lorem.paragraph(), } }) .build()Comment 工厂更简单。它只需使用
faker.lorem.paragraph()生成content字段,创建一段文本。我们会在种子文件中处理userId和postId关联。
创建种子文件
工厂定义了如何创建假数据,但它们并不会自动实际创建数据。这正是种子文件的用武之地——它们是使用工厂用实际数据填充数据库的脚本。每次你重置数据库或队友克隆项目时,运行 node ace db:seed 都会用一致、真实的数据填充数据库。
-
创建种子文件
让我们创建一个将生成帖子和评论的种子文件。
node ace make:seeder PostSeederDONE: create database/seeders/post_seeder.ts -
实现填充逻辑
现在打开种子文件并添加创建带评论的帖子的逻辑。
database/seeders/post_seeder.tsimport User from '#models/user' import { BaseSeeder } from '@adonisjs/lucid/seeders' import { PostFactory } from '#database/factories/post_factory' import { CommentFactory } from '#database/factories/comment_factory' export default class extends BaseSeeder { async run() { const user = await User.findByOrFail('email', '[email protected]') const posts = await PostFactory.merge({ userId: user.id }).createMany(10) for (const post of posts) { await CommentFactory.merge({ postId: post.id, userId: user.id }).createMany( Math.floor(Math.random() * 3) + 3 ) } } }让我们拆解一下这个种子文件做了什么:
首先,我们获取邮箱为
[email protected]的用户。这是我们在上一章探索 CLI 和 REPL 时创建的用户。如果你跟着做了,这个用户应该存在于你的数据库中。findByOrFail方法会在用户不存在时抛出错误。接着,我们使用
PostFactory.merge({ userId: user.id }).createMany(10)创建 10 个帖子。merge()方法在这里很重要——它将额外数据与原厂生成的值合并。我们需要它来设置每个帖子的userId外键。没有它,外键约束就会失败,因为userId会是未定义的。然后,对于每篇帖子,我们使用类似的方法创建 3 到 5 条评论。公式
Math.floor(Math.random() * 3) + 3生成 3 到 5 之间的随机数。 -
运行种子文件
现在执行种子文件来填充你的数据库。
node ace db:seed❯ running PostSeeder你的数据库现在有了 10 篇帖子,每篇都带有若干评论!
理解我们使用过的工具
在继续查询数据之前,让我们花点时间理解一下我们构建了什么。AdonisJS 提供了用于处理数据库的专用工具,每个都有特定用途。
迁移(Migrations) 定义你的数据库结构。它们创建表、添加列并建立约束。可以把它们看作转换你数据库 schema 的指令。当你运行 node ace migration:run 时,这些指令会执行并修改你的数据库结构。它们还会自动生成包含模型列定义的 database/schema.ts 文件。
模型(Models) 是你访问数据库表的 JavaScript 接口。它们继承自包含列定义的自动生成 schema 类(如 PostSchema),因此你只需添加关联和业务逻辑。模型提供了一个干净、类型安全的 API 来查询和操作数据,而无需编写原始 SQL。
工厂(Factories) 为你的模型生成逼真的虚拟数据。你无需反复手动创建测试数据,只需定义一次蓝图,工厂就能生成任意数量的逼真实例。这在开发和测试期间非常宝贵。
种子文件(Seeders) 是用数据填充数据库的脚本。它们通常使用工厂生成数据,但也可以创建特定记录或从其他来源导入数据。运行 node ace db:seed 会执行你所有的种子文件,并给你一个一致的数据库状态。
这些工具协同工作:迁移塑造数据库,模型与之交互,工厂生成数据,种子文件填充数据。每个工具都专注于自己的特定职责,使你的数据库工作流井然有序且易于维护。
使用 REPL 查询数据
既然我们的数据库中有了数据,让我们用 AdonisJS 的 REPL(读取-求值-打印循环)来探索它。REPL 是一个交互式 shell,你可以在其中运行 JavaScript 代码并实时与模型交互。
启动 REPL 并加载模型
首先,启动 REPL。
node ace repl
REPL 启动后,加载你所有的模型。
await loadModels()
这会让你所有的模型在 models 对象下可用。
获取所有帖子
让我们从数据库中获取所有帖子。
await models.post.all()
你会看到一个包含所有 10 篇帖子及其数据的数组。
[
Post {
id: 1,
title: 'My First iOS Weather App',
url: 'https://example.com/fp',
summary: 'Lorem ipsum dolor sit amet...',
userId: 1,
createdAt: DateTime { ... },
updatedAt: DateTime { ... }
},
// ... 还有 9 篇帖子
]
每篇帖子都是一个 Post 模型实例,而非普通的 JavaScript 对象。
按标题搜索帖子
让我们使用 query() 方法搜索标题中包含 "Task Management" 的帖子。
await models.post.query().where('title', 'like', '%Task Management%')
query() 方法返回一个构建在 Knex 之上的可链式查询构造器,让你在保持 JavaScript 语法的同时获得强大的 SQL 查询能力。你会看到一个匹配帖子的数组,根据工厂生成的内容,可能只有一条或零条。
获取一个帖子并加载其评论
现在让我们演示如何处理关联。首先,按 ID 获取一个特定的帖子。
const post = await models.post.find(1)
帖子已加载,但它的评论尚未加载(关联默认是懒加载的)。使用 load 方法加载 comments 关联。
await post.load('comments')
现在你可以访问评论了。
post.comments
你会看到属于这篇帖子的所有评论。
使用 preload 高效加载关联
你也可以在最初获取帖子时一并加载关联。
const postWithComments = await models.post.query().preload('comments').first()
这会在单次操作中获取第一篇文章及其评论。preload 方法比分别加载关联更高效,因为它避免了 N+1 查询问题。它不是为帖子做一次查询、再为每条评论各做一次查询,而是总共只做两次查询。
退出 REPL
当你探索完毕,输入 .exit 离开 REPL 并返回终端。
你学到了什么
你现在知道如何:
- 使用 Ace CLI 创建模型和迁移
- 创建数据库表并用迁移修改它们
- 理解列定义是如何从你的迁移在
database/schema.ts中自动生成的 - 使用
hasMany和belongsTo定义模型之间的关联 - 使用工厂和种子文件生成虚拟数据
- 使用 REPL 和模型方法查询数据
- 使用查询构造器进行复杂查询
- 使用
load()和preload()加载关联