TSts-grm

Input DTO 与「按需给形」

ts-grm 的目录哲学是「类型即形状、形状即契约」(见「核心概念 → DTO 哲学」):查询端用 dto.view 按需形,保存端则用 dto.input 按需形——客户端想提交什么形状的数据,就声明什么形状的 Input。这就是框架主页上那句「Like the inversed GraphQL」:GraphQL 的查询按需取、ts-grm 的保存按需给。

⚠️ 设计预览dto.inputsqlClient.save@ts-grm/core 0.0.10 尚未发布(API 设计已定型), 本节示例为形态预览,随正式实现落地。API 名称以当前版本为准。

View 与 Input:一张 DTO 的两个方向

// ① 模型(与全书一致的沙盒种子模型)
import { model, prop, dto } from '@ts-grm/core'

const BookStore = model("BookStore", "id", class {
    id = prop.i64()
    name = prop.str(100)
    books = prop.o2m(Book).mappedBy("store").orderBy("name", "edition")
})

const Book = model("Book", "id", class {
    id = prop.i64()
    name = prop.str(50)
    edition = prop.i32()
    price = prop.num(10, 2)
    store = prop.m2o(BookStore).joinColumns({ cascade: "DELETE" }).nullable()
    authors = prop.m2m(Author).joinTable({
        name: "book_author_mapping",
        joinThisColumns: ["book_id"],
        joinTargetColumns: ["author_id"],
    })
})

const Author = model("Author", "id", class {
    id = prop.i64()
    name = prop.embedded({
        firstName: prop.str(50),
        lastName: prop.str(50),
    })
    books = prop.m2m(Book).mappedBy("authors")
})

「书 + 书店名 + 作者们」这个形状,查询端和保存端各有一份声明:

// ② 查询视图:从数据库把形状取出来
const BookView = dto.view(Book, c => [
    c.id,
    c.name,
    c.store.with(c => [c.id, c.name]),
    c.authors.with(c => [c.id, c.name]),
])

// ③ 输入 DTO:把形状交回数据库
const BookInput = dto.input(Book, c => [
    c.id.key(),          // ← key():声明「保存键」——UPSERT/UPDATE 用它定位行
    c.name,
    c.price,
    c.store.$ref("store", c => [c.id]),      // 仅引用:给外键,不展开对象
    c.authors.$ref("authors", c => [c.id]),  // 仅引用:给中间表另一侧的 id 集合
])

同一个 c 上下文,.with() 表示内嵌整段载荷(保存时逐层展开写),$ref() 表示只引用键(图里只出现外键/关联 id,不写目标实体本身的字段)。这就是「按需给形」——要控制到什么粒度,由 Input 的声明决定。

Input 的取形语法

语法语义
c.name标量字段进入输入载荷
c.name.key()同上,并标记为保存键(主键、唯一键等定位依据)
c.store.$ref("store", c => [c.id])m2o 引用:仅携带目标键
c.store.with(c => [c.id, c.name])m2o 引用:内嵌目标对象的字段载荷
c.authors.with(c => [c.id, c.name])m2m 集合:内嵌集合载荷(o2m 同理)
c.$instanceOf(Derived, c => […])多态:按判别列切分子类的输入载荷
c.embeddedProp.mapInput(schema, fn)输入校验 + 值映射(见「输入映射与校验」)

m2o 里 .with()$ref() 的差别在保存时可感知:内嵌对象时,保存逻辑会「看到」目标实体要写的字段(例如 with(c => [c.name]) 表示新建/更新书店;只给 $ref 时目标实体本身不产生写操作,只落外键。

TypeOf:Input 的载荷类型

TypeOf<Input>TypeOf<View> 一样可以从 DTO 定义中推导出形状,只是方向相反——View 推导的是「查询返回什么」,Input 推导的是「保存接受什么」:

// ④ 载荷类型推导:TypeOf<BookInput> ≈
// {
//   id: bigint
//   name: string
//   price: number
//   store?: { id: bigint } | null
//   authors: { id: bigint }[]
// }
import type { TypeOf } from '@ts-grm/core'

type BookPayload = TypeOf<BookInput>   // 客户端即可推导,无需运行

约定俗成:业务入口参数声明为 TypeOf<BookInput>,天然和 Input DTO 保持同步——改 Input 声明,接口参数类型跟着变,不需要额外维护一份「请求体类型」。

InputAssociationMembers:可被「模式选项」寻址的关联

保存选项(associated / dissocation)会按关联路径寻址(下一页展开)。InputAssociationMembers<TInput> 把 Input 声明里的内嵌关联(.with() / $ref())收集成「路径 → 关联类型」的映射,作为选项的键集合约束——写错路径或写成非关联成员,类型层面直接报错。

// ⑤ Input 内嵌的关联路径会成为选项的合法键
// (设计形态:路径用 "." 连接嵌套层级,例如 "authors"、"store")
const INPUT = BookInput   // 关联成员含 store(m2o)、authors(m2m)
// save(INPUT, payload, {
//   associated: {
//     "authors": "APPEND_IF_ABSENT",   // 合法:authors 是关联成员
//     // "name": "…"                   // ❌ 标量不是关联成员,类型报错
//   },
// })

小结

  • dto.view的形状,dto.input的形状,语法同源(同一个 c 上下文);
  • .key() 声明保存键,是 UPSERT/UPDATE 定位行的依据;
  • m2o/m2m/o2m 要么内嵌(.with())要么仅引用($ref()),粒度由声明决定;
  • TypeOf 让载荷类型跟着 Input 声明走,量体裁衣、一处修改处处生效。