后台页面已经可以创建单词书了。
但点击"保存"以后,真正的问题才刚开始:
这本单词书到底存在哪里?
这会一路把 PostgreSQL、Supabase、ORM、Drizzle、schema、migration、外键、级联删除和 RLS 全部串起来。
一、先试试最简单的方法:放进数组里
假设管理员创建:
text
四级词汇
最简单可以写:
ts
const books = []
books.push({
id: 1,
name: "四级词汇",
})
页面确实可以显示:
text
四级词汇
但是刷新页面、服务器重启以后怎么办?
如果数据只存在运行时内存里:
text
程序结束
↓
数据丢失
而真实系统需要长期保存:
text
单词书
单词
管理员
用户
学习记录
所以必须引入:
数据库。
二、为什么选择 PostgreSQL
danci 使用 PostgreSQL。
PostgreSQL 是一种关系型数据库。
关系型数据库最基本的数据结构就是:
text
Table 表
Row 行
Column 列
例如:
text
books
可以是一张表:
| id | name | description |
|---|---|---|
| 1 | 四级词汇 | CET-4 核心词汇 |
| 2 | 考研词汇 | 考研英语词汇 |
这里:
text
books
是一张表。
text
id
name
description
是字段。
而:
text
1 | 四级词汇 | CET-4 核心词汇
是一行记录。
于是创建单词书这件事就变成:
text
管理员提交表单
↓
向 books 表插入一行数据
三、数据库有了,难道还要自己部署 PostgreSQL?
当然可以。
但如果自己部署数据库,就需要处理:
text
服务器
PostgreSQL 安装
连接配置
网络
账号权限
备份
安全
升级
运维
对于一个正在快速开发的应用来说,这些事情会带来额外成本。
于是 danci 使用:
text
Supabase
四、Supabase 到底是什么
Supabase 经常被称为:
text
BaaS
Backend as a Service
后端即服务
它提供的核心能力之一就是:
text
托管 PostgreSQL 数据库
除此之外还有:
text
Auth
Storage
Realtime
Data API
RLS
Postgres Extensions
所以常说:
text
使用 Supabase 数据库
没有问题。
但更准确的技术关系是:
text
Supabase
└── PostgreSQL
真正负责关系型数据存储的仍然是 PostgreSQL。
Supabase 解决的是:
不必从零搭建整套数据库基础设施,就可以直接获得一个可用的云端 PostgreSQL,以及围绕它的一系列后端能力。
五、那"支持向量数据库"是什么意思
Supabase 的 PostgreSQL 可以启用 pgvector 扩展。
它可以在 PostgreSQL 中存储:
text
embedding
向量
并进行:
text
向量相似度搜索
例如以后想做:
text
根据语义寻找相似单词
根据单词解释做相似检索
RAG / AI 检索
就可能使用向量能力。
但这里需要分清:
danci 当前最核心的数据仍然是普通关系型数据。
例如:
text
books
words
users
user_word_records
pgvector 是 PostgreSQL 的扩展能力,不是做普通单词 CRUD 的前提。
六、Supabase 数据库已经在云端,Next.js 怎么连接它?
现在数据库地址在云端。
Next.js 项目需要知道:
text
数据库在哪里?
使用什么账号连接?
连接哪个数据库?
这类连接信息通常通过:
text
DATABASE_URL
保存。
例如:
env
DATABASE_URL=postgresql://...
一般放进:
text
.env
于是:
text
Next.js
↓
DATABASE_URL
↓
Supabase PostgreSQL
连接起来。
注意:
DATABASE_URL包含敏感的数据库连接信息,不应该暴露到浏览器端。
七、现在已经能连数据库了,直接写 SQL 不行吗?
当然可以。
查询:
sql
SELECT * FROM books;
插入:
sql
INSERT INTO books (name)
VALUES ('四级词汇');
修改:
sql
UPDATE books
SET name = '大学英语四级'
WHERE id = 1;
删除:
sql
DELETE FROM books
WHERE id = 1;
SQL 完全没有问题。
所以 ORM 出现的原因绝不是:
"SQL 不能用。"
真正的问题是:
text
项目主要写 TypeScript
↓
数据库使用 SQL
↓
两边需要组织在一起
八、ORM 为什么叫"对象关系映射"
ORM 全称:
text
Object Relational Mapping
对象关系映射
先看两个世界。
JavaScript / TypeScript 中:
ts
const user = {
id: 1,
name: "张三",
}
这是一个:
text
Object
对象
数据库里:
text
users 表
id | name
---|-----
1 | 张三
这是关系型数据。
实际上两边表达的是同一份业务数据:
text
程序世界 数据库世界
User 对象 ↔ users 表的一行
user.id ↔ id 字段
user.name ↔ name 字段
ORM 的核心思想就是:
让程序中的数据结构和关系型数据库中的表、字段、记录建立对应关系,并提供一层数据库操作接口。
九、为什么很多 ORM 示例会使用 user.save()
经典 ORM 中经常可以看到:
ts
const user = new User()
user.name = "张三"
await user.save()
程序员操作的是:
text
User 对象
而 ORM 最终会转成类似:
sql
INSERT INTO users ...
的数据库操作。
因此可以用一张图理解:
text
User Object
↓
user.save()
↓
ORM
↓
SQL
↓
PostgreSQL users 表
这非常适合理解"对象关系映射"的思想。
但要注意:
ORM 并不等于一定存在
user.save()。
不同 ORM 的 API 风格完全可以不同。
十、Drizzle 为什么看起来和传统 ORM 不太一样
danci 使用:
text
Drizzle ORM
Drizzle 的设计非常接近 SQL。
例如:
ts
db.select().from(books)
一眼就能想到:
sql
SELECT * FROM books;
插入:
ts
db.insert(books).values({
name: "四级词汇",
})
对应:
sql
INSERT INTO books ...
所以 Drizzle 并没有试图把 SQL 思维全部藏掉。
它更接近:
text
SQL 思维
+
TypeScript API
+
类型安全
因此,"ORM 就是不用懂 SQL"这个理解是不准确的。
更合理的是:
ORM 可以减少重复的底层数据库操作,并把数据库结构更好地纳入应用代码;但表关系、索引、约束、事务、SQL 思维仍然值得理解。
十一、那 Drizzle 怎么知道数据库里有 books 表?
现在 Supabase 已经存在:
text
books
但本地 TypeScript 项目怎么知道:
text
books 有哪些字段?
id 是什么类型?
name 能不能为空?
哪个字段是主键?
这就需要:
text
schema.ts
例如:
ts
export const books = pgTable("books", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
})
这段代码是在 TypeScript 中描述:
text
books 表
├── id
└── name
于是:
text
Drizzle schema PostgreSQL
books ↔ books 表
books.id ↔ id
books.name ↔ name
建立起联系。
十二、schema.ts 是"建表文件"还是"映射文件"?
这个问题非常关键。
因为实际项目有两种常见路线。
路线一:Code First
先在 TypeScript 中写:
text
schema.ts
再让 Drizzle 根据 schema:
text
生成迁移
↓
修改数据库
这时:
text
代码里的 schema
更接近数据库结构的 source of truth。
路线二:Database First
也可能是:
text
Supabase 中已经存在数据库表
↓
再让本地项目获得它的结构
Drizzle Kit 提供 pull,可以从现有数据库 introspect 出 Drizzle schema。
这时候:
text
真实数据库
↓
Drizzle schema
数据库更接近结构来源。
所以不能简单记成:
text
写一个 schema.ts
=
数据库自动已经有这张表
更准确的是:
schema.ts 是 Drizzle 在代码侧对数据库结构的描述;它究竟是先于数据库还是从数据库同步而来,取决于项目采用 Code First 还是 Database First。
十三、db/index.ts 又做什么?
常见项目可能有:
text
db/
├── index.ts
└── schema.ts
职责可以简单分成:
text
schema.ts
↓
数据库长什么样
index.ts
↓
怎么连接数据库
例如 index.ts 会:
text
读取 DATABASE_URL
↓
建立 PostgreSQL 连接
↓
初始化 Drizzle
↓
得到 db
业务代码最后使用:
ts
db.select()
db.insert()
db.update()
db.delete()
十四、数据库字段变了怎么办?
假设最开始:
text
books
id
name
后来需求变化,希望增加:
text
description
只修改 schema.ts 还不够。
因为真正的 PostgreSQL 表也必须增加:
text
description
于是出现:
text
Database Migration
数据库迁移
它解决的问题就是:
代码里的数据库结构发生变化以后,真实数据库如何安全地跟着变化。
十五、generate、migrate、push 到底是什么关系
generate
当 schema.ts 发生变化:
text
schema.ts
↓
drizzle-kit generate
↓
生成 SQL migration
它不是"再生成一个 schema 文件"。
它主要根据 schema 的差异生成:
text
migration.sql
以及 Drizzle 用来追踪结构变化的快照信息。
migrate
接下来:
text
migration.sql
↓
drizzle-kit migrate
↓
执行到数据库
也就是:
把已经生成的迁移真正应用到 PostgreSQL。
所以:
text
generate
↓
生成"要怎么改数据库"
migrate
↓
真正执行这些修改
push
还有一种更直接的方式:
text
schema.ts
↓
drizzle-kit push
↓
比较 schema 和数据库
↓
直接应用变化
也就是说:
push可以省略显式生成 migration 文件的步骤,直接把 schema 的变化推到数据库。
开发阶段可能很方便,但团队协作或生产环境通常更重视可追踪的迁移历史。
studio
drizzle-kit studio 用来打开数据库可视化界面。
可以更方便地查看:
text
表
字段
数据
十六、一本单词书到底怎么包含很多单词?
数据库连接和 ORM 解决以后,还有更实际的问题:
text
四级词汇
里面可能有:
text
apple
ability
abandon
...
但是:
text
apple
又可能同时属于:
text
四级词汇
考研词汇
雅思词汇
因此:
text
一本 book 有很多 word
一个 word 也可以属于很多 book
这就是:
text
多对多关系
十七、为什么需要 book_words 中间表
多对多关系通常使用中间表:
text
books
│
book_words
│
words
例如:
| book_id | word_id |
|---|---|
| 1 | 10 |
| 1 | 11 |
| 2 | 10 |
意思是:
text
book 1 包含 word 10、11
book 2 也包含 word 10
这样:
text
apple
只需要在 words 中保存一次。
它属于哪些单词书,则通过:
text
book_words
记录关系。
十八、外键在这里解决什么问题?
例如:
text
book_words.book_id
应该指向:
text
books.id
数据库可以通过:
text
Foreign Key
外键
保证:
text
book_words 里的 book_id
不能随便指向一个不存在的 book。
否则就可能出现:
text
book_id = 999
但:
text
books
里根本没有 id = 999。
外键就是关系型数据库维护数据完整性的重要机制之一。
十九、删除一本书后,book_words 怎么办?
假设删除:
text
books.id = 1
但 book_words 里还存在:
text
book_id = 1
的关系记录。
这些关系已经没有意义了。
于是可以在外键上设置:
text
ON DELETE CASCADE
表示:
text
删除 book
↓
自动删除依赖它的 book_words 关系
这就是:
text
cascade
级联删除
但它不能无脑使用。
正确判断应该是:
父记录消失以后,这些子记录在业务上是不是也应该必然消失?
如果答案是"是",才适合考虑 cascade。
二十、用户开始背单词以后,又出现了一个安全问题
words 记录:
text
apple
苹果
音标
例句
这些数据大家都一样。
但:
text
用户 A:apple 已掌握
用户 B:apple 学习中
却不是公共数据。
因此用户学习记录应该单独设计,例如:
text
user_word_records
user_id
word_id
status
review_count
这时就出现了:
用户 A 能不能读取用户 B 的学习记录?
当然不能。
这就是 RLS 出现的原因。
二十一、RLS 到底是什么?
RLS:
text
Row Level Security
行级安全
重点在:
text
Row
行
它不是简单控制:
text
能不能访问这张表
而是进一步控制:
text
当前用户可以访问这张表里的哪些行
例如:
| user_id | word_id | status |
|---|---|---|
| 1 | 10 | mastered |
| 1 | 11 | learning |
| 2 | 10 | learning |
用户 1 登录后,只能访问:
text
user_id = 1
的行。
用户 2:
text
user_id = 2
这就实现了:
text
同一张表
↓
不同用户看到不同数据
二十二、words 是公共表,所以"不需要开 RLS"吗?
这个说法容易产生误解。
真正要区分的是两个问题:
text
这张表的数据是不是公开的?
和:
text
这张表是否启用了 RLS?
它们不是同一个概念。
如果 words 通过 Supabase Data API 暴露给客户端,更稳妥的做法是:
text
开启 RLS
↓
再明确配置访问策略
例如可以配置成:
text
所有用户允许 SELECT words
这代表:
text
公开读取
而不是:
text
完全关闭行级安全
对于:
text
user_word_records
则可以配置:
text
只能访问 user_id = 当前登录用户
因此可以理解成:
text
words
↓
公开数据
↓
策略允许读取
user_word_records
↓
私有数据
↓
策略只允许访问自己的行
真正重要的是:
根据业务决定访问策略,而不是简单用"公共表 / 私有表"判断要不要安全机制。
二十三、Authentication 和 RLS 解决的也不是一个问题
Authentication 解决:
text
当前用户是谁?
例如:
text
当前登录用户 id = 123
RLS 解决:
text
既然你是 123
↓
数据库里的哪些行允许你访问?
所以:
text
Authentication
↓
身份认证
RLS
↓
数据授权
这两个概念要分开。
二十四、把第二篇完整串起来
现在整个数据层终于能连成一条线:
text
管理员创建一本单词书
↓
数据必须长期保存
↓
PostgreSQL
↓
不想从零部署数据库基础设施
↓
Supabase
↓
Next.js 要连接数据库
↓
DATABASE_URL
↓
业务代码要操作 PostgreSQL
↓
Drizzle ORM
↓
本地需要描述表结构
↓
schema.ts
↓
结构变化需要同步数据库
↓
migration
再往业务里面走:
text
books 和 words 多对多
↓
book_words
↓
Foreign Key
↓
ON DELETE CASCADE
用户开始学习后:
text
产生用户私有记录
↓
RLS
这一整条链路就是 danci 数据层真正的骨架。
二十五、但是数据库现在还是空的
到这里:
text
books 表有了
words 表也设计好了
可新的问题又出现了:
难道几千个单词要在后台一个一个手工录入?
当然不可能。
所以接下来就进入这个项目里非常有价值的一块:
text
GitHub 开源数据
↓
JSON
↓
数据清洗
↓
scripts
↓
AI Coding
这就是第三篇。