Drizzle ORM Join 实战:从扁平结果到类型安全的嵌套聚合
【免费下载链接】drizzle-ormORM项目地址: https://gitcode.com/gh_mirrors/dr/drizzle-orm
Drizzle ORM 的 join 语法在设计上追求"既像 SQL、又具备类型安全"的平衡——你既可以用链式 API 写出与原生 SQL 对应的left join/inner join/right join/full join/cross join,又可以让 TypeScript 编译器替你推导出每一列的可空性。本文以一对多关系建模为线索,完整讲解 Drizzle ORM join 的写法、结果类型的空值推导规则、嵌套分组技巧,并深入仓库源码揭示其类型系统底层的实现原理,读完后你将能写出类型安全、无需!断言、可自由聚合的 join 查询。
从一对多建模说起:表定义与原始 SQL
join 最常见的使用场景是查询一对多(one-to-many)关系。本文沿用官方文档中的经典例子:一个城市(city)下住着多个用户(user),用户表通过外键cityId引用城市表:
const users = pgTable('users', { id: serial('id').primaryKey(), firstName: text('first_name').notNull(), lastName: text('last_name'), cityId: int('city_id').references(() => cities.id), }); const cities = pgTable('cities', { id: serial('id').primaryKey(), name: text('name').notNull(), });这里使用pgTable定义 PostgreSQL 表结构。references(() => cities.id)声明外键,serial、text、int对应数据库列类型。上述定义位于 pg-core 模块体系下;Drizzle ORM 为 MySQL、SQLite、SingleStore 提供了同构的mysqlTable、sqliteTable、singlestoreTableAPI,join 用法完全一致。
"查询所有城市及其居民"的需求,用原生 SQL 写出来是这样的:
select cities.id as city_id, cities.name as city_name, users.id as user_id, users.first_name, users.last_name from cities left join users on users.city_id = cities.id注意left join的语义:即使某个城市没有居民,该城市行仍然会返回,只是users相关列为NULL。下面看 Drizzle ORM 如何以类型安全的方式表达同样的查询。
Drizzle 版 join:select 投影与自动空值化
同样的查询,在 Drizzle ORM 中写成链式调用:
const rows = await db .select({ cityId: cities.id, cityName: cities.name, userId: users.id, firstName: users.firstName, lastName: users.lastName, }) .from(cities) .leftJoin(users, eq(users.cityId, cities.id));rows的类型会被自动推导为:
{ cityId: number; cityName: string; userId: number | null; firstName: string | null; lastName: string | null; }[]关键点在于:所有来自被 join 表的列都被自动"空值化"了(userId: number | null、firstName: string | null),而主表cities的列保持原类型。这是因为left join在运行时可能匹配不到任何行,被 join 表的列在结果中可能为NULL。
如果你只想把 join 结果当作"单行"使用,这种扁平结构是够用的;但当一行结果中同时包含 city 和 user 两个实体时,逐字段判空(或更糟——在每个字段后加!让编译器"闭嘴")就非常痛苦。你真正想要的是:一次判空,整组字段全部可用。
嵌套对象分组:一次判空,整组解空
Drizzle ORM 允许你在.select()中把属于同一张表的字段放进一个嵌套对象:
const rows = await db .select({ cityId: cities.id, cityName: cities.name, user: { id: users.id, firstName: users.firstName, lastName: users.lastName, }, }) .from(cities) .leftJoin(users, eq(users.cityId, cities.id));此时 ORM 会在类型层面识别出"嵌套对象里的所有字段都属于同一张表",并把整组字段的判空逻辑合并成对嵌套对象的一次判空。rows的类型变为:
{ cityId: number; cityName: string; user: { id: number; firstName: string; lastName: string | null; } | null; }现在判空只需一行:if (row.user !== null),分支内所有 user 字段自动解除空值,且lastName自身定义时就是可空的(text('last_name')未加.notNull()),因此即使user非空,user.lastName仍保留string | null——这是符合列定义本身语义的正确推导。
分组规则:同一表分组才触发"整组判空"优化
你可以按任意方式组织嵌套对象,但单检查优化只对"全部字段属于同一张表"的嵌套对象生效。例如你也可以把城市字段同样分组:
.select({ city: { id: cities.id, name: cities.name, }, user: { id: users.id, firstName: users.firstName, lastName: users.lastName, }, })结果类型为:
{ city: { id: number; name: string; }; user: { id: number; firstName: string; lastName: string | null; } | null; }主表cities在left join中永远不会缺失(它驱动了查询),所以city对象本身不可空,只有user对象是| null。
混合分组:多表字段同处一个对象时逐个判空
如果同一个嵌套对象里混入了来自不同表的列,类型系统会退化为"组内每个字段各自判空"——这符合直觉,因为此时无法用一个布尔值代表整组的存在性:
.select({ id: cities.id, cityAndUser: { cityName: cities.name, userId: users.id, firstName: users.firstName, lastName: users.lastName, } })结果类型:
{ id: number; cityAndUser: { cityName: string; userId: number | null; firstName: string | null; lastName: string | null; }; }可以看到,cityAndUser对象本身不可空(因为它包含主表列),但来自users的列全部是| null。
不写 select 参数:一键投影全部表
当你需要所有参与查询的表的全部字段时,可以直接省略.select()的参数:
const rows = await db.select().from(cities).leftJoin(users, eq(users.cityId, cities.id));[!NOTE] 这种情况下,结果对象的键名直接使用 Drizzle 的表名 / 列名(即你在
pgTable('users', ...)中声明的名称)。
{ cities: { id: number; name: string; }; users: { id: number; firstName: string; lastName: string | null; cityId: number | null; } | null; }[]这一行为可以从源码得到印证:在 pg select.ts 的createJoin实现中,当查询不是"部分选择"(partial select,即用户显式提供了.select({...}))时,如果这是第一次 join,ORM 会把主表的字段"搬进"以主表名命名的嵌套对象(this.config.fields = { [baseTableName]: this.config.fields }),并把被 join 表的全部列以表名作为键追加进去。这正是db.select()无参形式能自动按表分组的原因。
传表简写:整表字段 + 部分自定义字段
某些场景下你希望"一张表的所有字段 + 另一张表的部分字段"。此时无需把该表的字段逐个列出,直接传入表本身即可:
.select({ cities, // 等价于 "cities: cities",键名可以任意 user: { firstName: users.firstName, }, })结果类型:
{ cities: { id: number; name: string; }; user: { firstName: string; } | null; }传表简写同样依赖嵌套分组规则:由于cities是主表,它的对象不可空;user来自被 join 表,对象整体为| null。注意cities这个键名只是别名,你可以写成任意键名(例如city: cities)。
深入源码:join 空值推导的类型系统原理
上述所有空值行为都不是魔法,而是 Drizzle ORM 在类型层面维护的一张"空值映射表"(nullability map)。理解它有助于你在复杂查询中预判推导结果。
运行时:joinsNotNullableMap 逐表记录可空性
在 pg select.ts 的createJoin工厂方法中,运行时维护joinsNotNullableMap来记录每张表在 join 之后是否可能缺失,规则为:
leftjoin:被 join 的表标记为可空(joinsNotNullableMap[tableName] = false);rightjoin:主表及此前所有表全部变为可空,被 join 表保持非空;cross/innerjoin:双方都必然存在,被 join 表标记为非空;fulljoin:双方都可能缺失,所有表都变为可空。
同时,createJoin会检查 join 别名冲突——如果同一查询中两张表被赋予相同别名,会直接抛出Alias "<name>" is already used in this query错误(select.ts)。
类型层:AppendToNullabilityMap 与 SelectPartialResult
类型层面的空值推导定义在 select.types.ts:
JoinNullability = 'nullable' | 'not-null'(第 11 行);AppendToNullabilityMap(第 137-147 行)根据 join 类型更新空值映射:left追加{ [name]: 'nullable' },right/full把已有表全部置为'nullable',inner/cross追加{ [name]: 'not-null' };SelectPartialResult(第 47-74 行)处理嵌套对象:当对象内所有字段属于同一张表(通过列上的tableName元数据推断)时,整组应用ApplyNullability;当字段来自多个表时,递归地逐字段判空——这正是上一节"混合分组逐个判空"的类型级实现。
每个 dialect 的 select 构建器(pg、mysql、sqlite、singlestore、gel)都基于同一套基础类型实现 join 方法,可以从 pg select.types.ts 与 query-builders 目录下的通用类型定义相互印证。
各 join 变体与 SQL 方言差异
除了文档主讲的leftJoin,pg select.ts 还提供了完整的 join 家族:innerJoin(L415)、rightJoin(L386)、fullJoin(L458)、crossJoin(L486),以及依赖方言能力的leftJoinLateral、innerJoinLateral、crossJoinLateral(用于子查询引用左侧表的场景)。rightJoin/fullJoin受数据库支持限制,例如 SQLite 在较老版本不支持right/full join,MySQL 不支持full join——具体以目标数据库文档为准;Drizzle 的 mysql、sqlite 等实现均位于各自*-core/query-builders/select.ts中,方法签名与 pg 一致。
聚合结果:把 city-user 对折叠成"城市 → 用户列表"
回到开篇场景:left join返回的是city-user?组合的扁平数组,而你真正想要的是"每个城市对应一份用户列表"。Drizzle ORM 对此刻意不做强制约束——结果如何聚合完全由你决定,官方文档提供了基于Array.reduce()的经典做法:
import { InferModel } from 'drizzle-orm'; type User = InferModel<typeof users>; type City = InferModel<typeof cities>; const rows = await db .select({ city: cities, user: users, }) .from(cities) .leftJoin(users, eq(users.cityId, cities.id)); const result = rows.reduce<Record<number, { city: City; users: User[] }>>( (acc, row) => { const city = row.city; const user = row.user; if (!acc[city.id]) { acc[city.id] = { city, users: [] }; } if (user) { acc[city.id].users.push(user); } return acc; }, {}, );这段代码有两个值得注意的实践要点:
巧用传表简写:
select({ city: cities, user: users })直接投出两张表的全部字段,且因为user整组来自同一张表,row.user被推导为User | null,if (user)一次判空即可安全 push。模型类型推导:文档示例使用
InferModel,它在 table.ts 中被标记为@deprecated,官方推荐使用更明确的两个替代:InferSelectModel(L197-L200)与InferInsertModel(L202-L205),或直接使用表实例上自带的$inferSelect/$inferInsert方法。因此现代写法推荐:type User = typeof users.$inferSelect; type City = typeof cities.$inferSelect;两种方式等价——它们都基于
InferModelFromColumns按列的'query'数据模式生成模型,且可通过dbColumnNames配置决定键名使用 TS 属性名还是数据库列名。
小结
Drizzle ORM 的 join 设计可以总结为三条核心规则:
- 自动空值推导:被 join 表的所有列自动变为
| null,推导规则与 join 类型严格对应(left只空被 join 表,right/full可能空掉主表,inner/cross双方非空); - 嵌套分组优化:把同一张表的字段放进嵌套对象,即可把"逐字段判空"压缩为"一次判空",混合分组的对象则退化为逐字段判空;
- 投影自由:
.select()支持扁平投影、嵌套分组、无参全字段、传表简写四种形式,聚合逻辑(如reduce成城市-用户列表)完全交由开发者掌控。
想要进一步验证这些行为,可以在仓库中查看 join 相关的集成测试(integration-tests/tests 目录下的pg、mysql、sqlite等用例)以及各 dialect 的select.ts/select.types.ts实现,源码即最佳注释。
【免费下载链接】drizzle-ormORM项目地址: https://gitcode.com/gh_mirrors/dr/drizzle-orm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考