TSts-grm

公式与计算属性

公式(Formula) 让计算值表现得像普通属性:能放进视图、参与取形与排序。区别只在"值从哪来"。

SqlFormula:在 SQL 层计算

写在 SQL 里的表达式或子查询。示例数据中已有 author 表与 book_author_mapping,下面这个例子可以直接运行:

// ① 导入:model/prop/dsl/dto 建模型与公式,z 声明值类型;sqlClient 是示例提供的客户端
import { model, prop, dsl, dto, SqlFormula } from '@ts-grm/core'
import { z } from 'zod'
import { sqlClient } from './infra/sql-client'

// ② 作者模型:books 是 m2m 反向(映射表 book_author_mapping)
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")
})

// ③ 书模型:authors 是 m2m 拥有侧(中间表配置与示例数据一致)
const Book = model("Book", "id", class {
    id = prop.i64()
    name = prop.str(50)
    edition = prop.i32()
    price = prop.num(10, 2)
    authors = prop.m2m(Author).joinTable({
        name: "book_author_mapping",
        joinThisColumns: ["book_id"],
        joinTargetColumns: ["author_id"],
    })
    // 公式:作者数量(SQL 子查询 count)——声明后像普通属性一样用
    authorCount = prop.formula.sql(BOOK_AUTHOR_COUNT_FORMULA)
})

// ④ 定义 SQL 公式:对每本书,数它关联表里的作者行
const BOOK_AUTHOR_COUNT_FORMULA: SqlFormula<number> =
    SqlFormula.of({
        valueType: z.number(),               // StandardSchemaV1:类型来源
        sourceModel: () => Book,             // 挂在哪个模型上
        fn: book => dsl.subQuery(
            dsl.associationModel(Book, "authors"),     // 走 Book.authors 关联
            (q, assoc) => {
                q.where(assoc.sourceId.eq(book.id))    // 关联到当前行
                return q.select(dsl.count())
            }
        ),
    })

// ⑤ 查询:公式列出现在结果里(authorCount 由 SQL 计算)
const rows = await sqlClient.createQuery(Book, (q, book) => {
    return q.select(book.fetch(dto.view(Book, c => [
        c.id,
        c.name,
        c.authorCount,
    ])))
}).fetchList()

console.log(rows)  // 每本书带 authorCount(1~2)

注意常量和模型的声明顺序:公式定义引用了 Book,但 Book 也在公式定义之后才完整——和模型一样,顺序无关(惰性解析)。

TsFormula:在内存计算

读入依赖的数据后在 TS 里计算——适合拼接、加工、聚合后处理:

// 书店的书名清单:依赖 books 集合,fn 纯函数加工
const BOOK_NAMES_FORMULA: TsFormula<ReadonlyArray<string>> =
    TsFormula.of({
        valueType: z.array(z.string()),
        dependency: () => dto.view(BookStore, c => [        // 声明要读哪些数据
            c.books.with(c => [c.name, c.edition]),
        ]),
        fn: data => data.books.map(b => `${b.name}(${b.edition})`),  // 内存计算
    })

const BookStore = model("BookStore", "id", class {
    id = prop.i64()
    name = prop.str(100)
    bookNames = prop.formula.ts(BOOK_NAMES_FORMULA)
})

Calculator:集合型计算属性

公式算"单行一个值";Calculator 算"一组行的目标集合"(如"每本书店的最新书"):

const NEWEST_BOOKS = Calculator.targetOf({
    sourceModel: () => BookStore,   // 从哪个模型出发
    targetModel: () => Book,        // 算出什么模型
    fn: ctx => ctx.sqlClient.createQuery(Book, (q, book) => {
        // ctx.keys 是当前批次的所有书店 id——自动批量化,避免 N+1
        q.where(book.storeId.in(...ctx.keys))
        ...
    }).fetchList(),
})

newestBooks = prop.calculated.collection(NEWEST_BOOKS)   // 集合属性

另有带参数的 Calculator.parameterizedTargetOf(参数经 zod 校验)。

何时用哪个

  • 能写进 SQL 的(count、表达式)SqlFormula:还能排序/过滤;
  • 必须 JS 加工的TsFormula:声明依赖,框架自动补读;
  • 跨行的聚合到目标集合Calculator:天然批量化。

下一步

模型体系至此完整:列、关联、继承、复合值、公式。下一节是最重要的一页——DTO 哲学:为什么整个框架围绕"形状"而不是"实体"运转。