使用 Drizzle 关系模拟外键约束
什么是 Drizzle?
Drizzle 是一个出色的 ORM(对象关系映射工具),在 TypeScript 开发人员中迅速受到欢迎。它在保持类型安全的同时,采用了与熟悉 SQL 的开发人员非常熟悉的语法。此外,Drizzle 团队还开发了一个 CLI 工具,可以基于项目中的架构定义生成 SQL 迁移脚本或直接对数据库应用架构更改。
在本文中,我们将讲解如何使用 Drizzle 的虚拟关系(Virtual Relationships),以及如何将这些更改应用到 PlanetScale 数据库上。以下表格架构用于演示,它是一个简单的 “Link in Bio” 服务示例,用户可以创建包含链接到其喜欢的网站或社交媒体页面的个人资料:
外键与外键约束
在设计数据库时,通常会有一个或多个表包含关联数据。例如,使用上述架构,“users” 表与 “blocks” 表存在一对多的关系,即单个用户记录可以引用多个块(blocks)。这一关系通过 users.id
列与 blocks.user_id
列之间的链接实现。在这种情况下,blocks.user_id
是 users.id
的外键。
外键允许在数据库中定义逻辑关系,可以通过添加约束使这些关系变得更加严格。当定义外键约束时,你是在告诉数据库这两个表的指定列存在关系,并希望数据库引擎在某些操作发生时自动对相关数据执行操作以维护表的完整性。
例如,如果我们在示例架构中创建 users.id
和 blocks.user_id
的外键约束,可以请求数据库在删除相关的 users
记录时自动删除 blocks
表中的记录:
ALTER TABLE blocks ADD CONSTRAINT fk_users_blocks FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
在没有外键约束的情况下查询关联数据
尽管外键约束是维护数据库完整性的传统方式,但 PlanetScale 的设计重点是扩展性和零停机架构更新,而外键约束会对这些目标造成干扰。幸运的是,ORM 中的虚拟关系能实现类似逻辑,但不依赖数据库引擎,而是通过代码来完成主要工作。
通常在 Drizzle 中,你可以使用 references
方法来定义字段与关联实体及字段的关系。这向 ORM 指明了实体间基于特定字段的关联性:
export const users = mysqlTable('users', { id: serial('id').primaryKey(), username: varchar('username', { length: 120 }), tagline: varchar('tagline', { length: 250 }), display_name: varchar('display_name', { length: 250 }), img_url: varchar('img_url', { length: 500 }) }) export const blocks = mysqlTable('blocks', { id: serial('id').primaryKey(), url: varchar('url', { length: 200 }), block_type: int('type'), // 👇 以下代码会创建外键约束 user_id: int('user_id').references(() => users.id), label: varchar('label', { length: 200 }) })
由于 Drizzle 兼容多种关系型数据库,使用这种方法会尝试在架构中自动添加外键约束。然而,当尝试使用 drizzle-kit
将该架构迁移到 PlanetScale 数据库时会遇到错误:
drizzle-kit push:mysql --schema functions/utils/db/schema.ts --connectionString='$DATABASE_URL' --driver mysql2 # 输出: # Error: VT10001: foreign key constraints are not allowed [...] # { # code: 'ER_UNKNOWN_ERROR', # errno: 1105, # sql: 'ALTER TABLE `blocks` ADD CONSTRAINT `blocks_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;', # sqlState: 'HY000', # sqlMessage: 'VT10001: foreign key constraints are not allowed' # }
使用虚拟关系解决问题
通过稍作修改,Drizzle 可以通过虚拟关系在子表中查询数据,而不需要使用外键约束。以下代码实现了与上述相同的效果,让你能够查询用户及其关联的 blocks
:
export const users = mysqlTable('users', { id: serial('id').primaryKey(), username: varchar('username', { length: 120 }), tagline: varchar('tagline', { length: 250 }), display_name: varchar('display_name', { length: 250 }), img_url: varchar('img_url', { length: 500 }) }) export const blocks = mysqlTable('blocks', { id: serial('id').primaryKey(), url: varchar('url', { length: 200 }), block_type: int('type'), user_id: int('user_id'), label: varchar('label', { length: 200 }) }) // 👇 以下代码告诉 Drizzle “users” 与 “blocks” 是关联的 export const usersRelations = relations(users, ({ many }) => ({ blocks: many(blocks) })) // 👇 以下代码定义两个表中哪些列是关联的 export const blocksRelations = relations(blocks, ({ one }) => ({ user: one(users, { fields: [blocks.user_id], references: [users.id] }) }))
使用与上文一致的命令就可以成功应用这些更改:
drizzle-kit push:mysql --schema functions/utils/db/schema.ts --connectionString='$DATABASE_URL' --driver mysql2 # 输出: # drizzle-kit: v0.19.12 # drizzle-orm: v0.27.2 # # Reading schema files: orbytal-ink/functions/utils/db/schema.ts # # [✓] Changes applied
最后,当需要返回某个用户及其关联的 blocks
时,可以使用以下代码:
const user = await db.query.users.findFirst({ where: eq(users.username, username), // 使用 `with` 指明希望返回关联数据 with: { blocks: true } }) // `user` 内容: // { // "id": 5, // "username": "brianmmdev", // "tagline": "Developer Educator @ PlanetScale", // "display_name": "Brian Morrison II", // "img_url": "https://img.clerk.com/...", // "blocks": [ // { "id": 9, "url": "brianmmdev", "block_type": 2, "user_id": 5, "label": null }, // { "id": 8, "url": "brianmmdev", "block_type": 4, "user_id": 5, "label": null }, // { "id": 7, "url": "brianmmdev", "block_type": 1, "user_id": 5, "label": null } // ] // }
那级联操作呢?
外键约束的一个常见副作用是支持级联操作(Cascading Actions)。由于 PlanetScale 不支持外键约束,无法通过设计数据库模式来直接指定这些操作。
幸运的是,解决方案非常简单:将级联操作的逻辑移至代码层。这是开发人员最熟悉的区域之一。例如,删除用户时删除其关联的 blocks
,可以通过以下代码实现:
// 删除用户 await db.delete(users).where(eq(users.id, userId)) // 删除关联的 blocks await db.delete(blocks).where(eq(blocks.user_id, userId))
如上所示,仅需额外一行代码即可在删除用户时删除其关联的 blocks
。虽然这是一个非常简单的示例,但你可能会问:在更复杂的场景中,这是否需要更多工作来完成同样的操作?
答案是是也不是。确实需要开发人员编写更多代码来维护数据库中的数据完整性。然而,在复杂的模式中可能会出现多个嵌套的父子关系表。如果删除顶部记录,数据库无法保证能够删除所有嵌套记录,因为数据库引擎必须考虑这些嵌套表的所有约束。此时,数据库可能会返回错误,开发人员需要处理这些错误,或者更糟的是,应用可能错误退出,从而导致糟糕的用户体验。将数据完整性任务移到代码层,可以从长期来看减少这些问题的出现。
结论
通过阅读本文,你应该能够掌握如何在不使用外键约束的情况下使用 Drizzle 建立数据库关系。
关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台
除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接