尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Drizzle ORM Join 实战:从扁平结果到类型安全的嵌套聚合

Drizzle ORM Join 实战:从扁平结果到类型安全的嵌套聚合 Drizzle ORM Join 实战从扁平结果到类型安全的嵌套聚合【免费下载链接】drizzle-ormORM项目地址: https://gitcode.com/gh_mirrors/dr/drizzle-ormDrizzle ORM 的 join 语法在设计上追求既像 SQL、又具备类型安全的平衡——你既可以用链式 API 写出与原生 SQL 对应的left join/inner join/right join/full join/cross join又可以让 TypeScript 编译器替你推导出每一列的可空性。本文以一对多关系建模为线索完整讲解 Drizzle ORM join 的写法、结果类型的空值推导规则、嵌套分组技巧并深入仓库源码揭示其类型系统底层的实现原理读完后你将能写出类型安全、无需!断言、可自由聚合的 join 查询。从一对多建模说起表定义与原始 SQLjoin 最常见的使用场景是查询一对多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、singlestoreTableAPIjoin 用法完全一致。查询所有城市及其居民的需求用原生 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 版 joinselect 投影与自动空值化同样的查询在 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({...})时如果这是第一次 joinORM 会把主表的字段搬进以主表名命名的嵌套对象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] falserightjoin主表及此前所有表全部变为可空被 join 表保持非空cross/innerjoin双方都必然存在被 join 表标记为非空fulljoin双方都可能缺失所有表都变为可空。同时createJoin会检查 join 别名冲突——如果同一查询中两张表被赋予相同别名会直接抛出Alias name is already used in this query错误select.ts。类型层AppendToNullabilityMap 与 SelectPartialResult类型层面的空值推导定义在 select.types.tsJoinNullability nullable | not-null第 11 行AppendToNullabilityMap第 137-147 行根据 join 类型更新空值映射left追加{ [name]: nullable }right/full把已有表全部置为nullableinner/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 方言差异除了文档主讲的leftJoinpg select.ts 还提供了完整的 join 家族innerJoinL415、rightJoinL386、fullJoinL458、crossJoinL486以及依赖方言能力的leftJoinLateral、innerJoinLateral、crossJoinLateral用于子查询引用左侧表的场景。rightJoin/fullJoin受数据库支持限制例如 SQLite 在较老版本不支持right/full joinMySQL 不支持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 InferModeltypeof users; type City InferModeltypeof cities; const rows await db .select({ city: cities, user: users, }) .from(cities) .leftJoin(users, eq(users.cityId, cities.id)); const result rows.reduceRecordnumber, { 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 | nullif (user)一次判空即可安全 push。模型类型推导文档示例使用InferModel它在 table.ts 中被标记为deprecated官方推荐使用更明确的两个替代InferSelectModelL197-L200与InferInsertModelL202-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),仅供参考
返回列表