查询和操作对象
编译
在编写或更新了Entity定义后,都需要执行命令来编译完整的对象数据字典:
npm run make:domain
编译出来的数据字典声明在src/oak-app-domain目录下,同时也会编译出来一个数据的存储格式供框架引用,可以在代码中像这样去引用它们:
// EntityDict是数据字典声明,StorageSchema是存储格式定义
import { EntityDict, StorageSchema } from '@project/oak-app-domain';
数据字典和存储格式是整个Oak框架最核心的内容,贯穿于使用框架的各个层面,因此需要深刻理解。本章节将使用上小节的Address和Area对象,介绍一些查询和操作的核心概念。
编译后的对象结构
编译后的对象原生结构称为OpSchema,其结构仅仅在用户定义的属性上增加了一些通用的属性类型,以及将引用对象转化成了外键。
每个Entity的OpSchema可以在编译后的oak-app-domain/${Entity}/Schema.ts中查看,本章下面的大多数数据结构都是如此
对象上增加的通用属性包括:
| 属性 | 类型 | 含义 |
|---|---|---|
| id | string<36> | 主键,uuid |
| $$createAt$$ | number | 创建时间戳(Date.now()) |
| $$updateAt$$ | number | 更新时间戳 |
| $$deleteAt$$ | number | 删除时间戳 |
| $$seq$$ | int | 递增序列 |
查询(Select)
Oak 里常用的查询入口其实有三类:
context.select(...)/store.select(...):查行数据;context.count(...)/store.count(...):只查数量;context.aggregate(...)/store.aggregate(...):分组聚合。
其中 select 使用的核心结构是 Selection<'select', ...>,定义在 oak-domain/src/types/Entity.ts 中:
{
data: Projection;
filter?: Filter;
sorter?: Sorter;
indexFrom?: number;
count?: number;
randomRange?: number;
total?: number;
distinct?: true;
}
这些字段的作用可以先记成下面这几类:
| 字段 | 含义 | 说明 |
|---|---|---|
data | 要取哪些字段 | 必填 |
filter | 过滤条件 | 可级联到父对象、子对象、JSON 字段 |
sorter | 排序条件 | 支持多个排序项,也支持按父对象字段排序 |
indexFrom + count | 分页 | oak-db/test/testcase/base.ts 已覆盖 |
randomRange | 随机取样范围 | 由 oak-domain/src/store/CascadeStore.ts 在框架层先取一批行再随机筛出 count 条 |
total | 额外附带总数上限 | 列表页常用;如果只想要数量,更推荐直接用 context.count |
distinct | 去重 | 类型和 SQL translator 已支持,当前主要有 translator 级测试 |
如果你是在后端手写查询,推荐先把这三种接口的职责分开:
const rows = await context.select('house', {
data: { id: 1, district: 1, size: 1 },
filter: { district: '杭州' },
sorter: [{ $attr: { size: 1 }, $direction: 'desc' }],
}, {});
const total = await context.count('house', {
filter: { district: '杭州' },
count: 1000,
}, {});
const aggr = await context.aggregate('house', {
data: {
'#aggr': { district: 1 },
'#count-1': { id: 1 },
},
filter: { district: { $in: ['杭州', '上海'] } },
}, {});
如果你要做去重查询,也是直接在 select 上声明 distinct:
const rows = await context.select('token', {
data: {
id: 1,
userId: 1,
$$createAt$$: 1,
},
distinct: true,
}, {});
这里也要按源码现状理解:distinct 在 Selection 类型和 SQL translator 里已经通了,oak-db/test/testSqlTranslator.ts 里也有 translator 级用例;但它目前还不是 oak-db/test/testcase 那种覆盖完整运行链路的“重回归项”,所以项目接入前最好自己补一条数据库实测。
Projection
当查询对象时,通过 Projection 可以定义要查询对象的哪些属性(projection 的命名本身就借鉴了数据库中的“投影”概念)。例如,对上一节中所定义的 Address 对象,查询时可以指定投影为:
{
detail: 1,
name: 1,
phone: 1,
}
可以根据对象之间的关系将 projection 扩展到多对一的父对象上,实现级联查询:
{
detail: 1,
name: 1,
phone: 1,
area: {
id: 1,
name: 1,
parent: {
id: 1,
name: 1,
},
},
}
也可以扩展到一对多的子对象上。例如我们查询 Area 对象时,可以把它关联的 Address 一并查出来:
{
id: 1,
name: 1,
address$area: {
$entity: 'address',
data: {
id: 1,
name: 1,
phone: 1,
},
},
}
Oak 还允许在 projection 里直接挂表达式字段。oak-domain/src/types/Demand.ts 中定义了 $expr 到 $expr20 共 21 个表达式列名,oak-db/test/testcase/projection.ts 已覆盖了 $expr、$expr1、$expr2、$expr3 的实际查询。
例如,如果想返回一个 name + phone 拼出来的展示字段,可以这样写:
{
id: 1,
name: 1,
phone: 1,
$expr: {
$concat: [
'姓名:',
{ '#attr': 'name' },
' 手机号:',
{ '#attr': 'phone' },
],
},
}
Projection 中可用的表达式
下面这些表达式已经能从类型定义和 oak-db/test/testcase 里对应上:
| 类别 | 已确认语法 | 参考来源 |
|---|---|---|
| 比较 | $gt、$gte、$lt、$lte、$eq、$ne | compare.ts |
| 字符串 | $startsWith、$endsWith、$includes、$concat | string.ts |
| 布尔/逻辑 | $true、$false、$and、$or、$not | bool.ts |
| 数学 | $add、$subtract、$multiply、$divide、$abs、$round、$floor、$ceil、$pow、$mod | math.ts、complax.ts |
| 日期 | $year、$month、$weekday、$weekOfYear、$day、$dayOfMonth、$dayOfWeek、$dayOfYear、$dateDiff、$dateFloor、$dateCeil | date.ts |
| 引用节点 | #attr、#id、#refId、#refAttr | Demand.ts、base.ts |
其中:
#attr表示“当前结点上的某个属性”;#id用来给当前 filter/projection 结点命名;#refId + #refAttr用来在一个表达式里引用另一个已命名结点上的字段。
例如,在父子结点之间做比较时,oak-db/test/testcase/base.ts 已经覆盖了这种跨结点写法:
{
'#id': 'node-1',
application$system: {
'#id': 'node-2',
$expr: {
$eq: [
{ '#attr': 'name' },
{ '#refId': 'node-1', '#refAttr': 'name' },
],
},
},
}
Schema
Schema 是对对象进行 Select 查询后得到的数据结果格式。此时返回的对象除了自身属性之外,还可能级联了父对象与子对象的数据。
例如,上面的 Address 查询结果中可能包含其父对象 Area:
{
id: 'xxx',
name: 'xxxxx',
phone: '139xxxxxxxx',
areaId: 'xxxx',
area: {
id: '310100',
name: '杭州市',
parentId: '330000',
parent: {
id: '330000',
name: '浙江省',
},
},
}
而查询到的 Area 数据结果则会包含其子对象 Address 的数组:
{
id: '310100',
name: '杭州市',
address$area: [
{
id: 'xxx',
name: 'xxxxx',
phone: '139xxxxxxxx',
},
{
id: 'yyy',
name: 'zzzzz',
phone: '138xxxxxxxx',
}
]
}
Filter
Filter 代表查询某个对象的条件。例如,我们要查询 Area 为杭州市、手机号以 139 开头的 Address,就可以这样写:
{
areaId: '310100',
phone: {
$startsWith: '139',
},
}
如果不知道杭州市的 areaId,也可以把 filter 扩展到父对象上:
{
area: {
name: '杭州市',
},
phone: {
$startsWith: '139',
},
}
同样的,Filter 也可以扩展到子对象上。比方说我们查询 Area,条件是“该 Area 上至少有一条相关的 Address,其手机号以 139 开头”:
{
address$area: {
phone: {
$startsWith: '139',
},
},
}
常用过滤算子
oak-domain/src/types/Demand.ts 中定义的常用过滤语法如下:
| 算子 | 参数类型 | 作用 |
|---|---|---|
$gt | number | string | 大于 |
$gte | number | string | 大于等于 |
$lt | number | string | 小于 |
$lte | number | string | 小于等于 |
$eq | number | string | boolean | 等于 |
$ne | number | string | boolean | 不等于 |
$in | (number | string)[] | 在……中 |
$nin | (number | string)[] | 不在……中 |
$between | [number, number] | 在……之间(含边界) |
$mod | [number, number] | 取模 |
$startsWith | string | 以……开头 |
$endsWith | string | 以……结尾 |
$includes | string | 包含…… |
$exists | boolean | 字段是否存在/是否为空 |
$and | Filter[] | 与 |
$or | Filter[] | 或 |
$not | Filter | 非 |
$text | { $search, $language?, $ts? } | 全文检索 |
此外还要注意几种“不是算子,但很常用”的写法:
- 数字、字符串、布尔、枚举都可以直接写字面量,表示等值比较;
- 枚举还支持
$in、$nin、$ne; - 日期比较沿用数字比较语法;
- 过滤条件里同样可以使用
$expr表达式。
其中 $gt、$gte、$lt、$lte、$eq、$ne、$in、$nin、$startsWith、$endsWith、$includes、$exists、$and、$or、$not 这些是 oak-db/test/testcase 已经反复覆盖的主路径;$between 则当前主要能从 Demand.ts 和 oak-db/src/sqlTranslator.ts 对上,项目里如果要大量使用,建议补一条自己的数据库回归用例。
例如,oak-db/test/testcase/base.ts 已覆盖了“在 filter 中直接写表达式”的形式:
{
id: {
$in: [id1, id2],
},
$expr: {
$eq: [
{ '#attr': 'name' },
{ '#attr': 'nickname' },
],
},
}
子查询与 #sqp
当 filter 被扩展到子对象时,Oak 默认的语义是“存在至少一条关联记录满足条件”。这个行为可以用 #sqp 改写。oak-db/test/testcase/base.ts 已经把四种语义测得很清楚:
#sqp 取值 | 含义 | 子表为空时的结果 |
|---|---|---|
不写 / 'in' | 存在至少一条满足条件 | false |
'not in' | 不存在任何满足条件的记录 | true |
'all' | 所有关联记录都满足条件 | true |
'not all' | 不是所有关联记录都满足条件 | false |
例如,我们查询“不能有任何一条手机号以 139 开头的 Address”的 Area:
{
address$area: {
phone: {
$startsWith: '139',
},
'#sqp': 'not in',
},
}
如果你要判断“所有子对象都满足某条件”,就把 #sqp 换成 'all':
{
application$system: {
type: 'web',
'#sqp': 'all',
},
}
JSON 字段过滤
oak-domain/src/types/Demand.ts 中还定义了 JsonFilter。oak-db/test/testcase/json.ts 已覆盖的能力包括:
- 嵌套对象按层级继续写 filter;
- JSON 数组支持
$contains、$overlaps; - JSON 内部的字符串字段仍然可以用
$includes、$startsWith等; - JSON 字段同样支持
$exists。
例如:
{
config: {
tags: {
$contains: ['oak'],
},
scores: {
$overlaps: [200, 500],
},
profile: {
nickname: {
$includes: 'xc',
},
},
age: {
$exists: true,
},
},
}
$or、$and 与关联查询
以前很多同学会担心:$or 里一旦混进父对象/子对象条件,SQL 会不会变坏。oak-db/test/testcase/or_search.ts 这部分已经专门补了大量用例:
$or中可以混合普通字段条件、父对象条件、多态关联条件;$and与$or可以嵌套;$not也可以和关联条件一起使用;- 空
$or: []会匹配不到任何记录; - 单项
$or也可以正常工作。
所以在真实项目里,下面这种写法是框架已经覆盖过的:
{
$or: [
{ player: { name: 'Alice' } },
{ value: 'token2' },
],
}
全文检索
全文检索的类型定义在 oak-domain/src/types/Demand.ts 中,MySQL 和 PostgreSQL translator 也都已经实现了翻译。但这里有两个前提必须同时满足:
- 对象上必须声明全文索引;
- 当前数据库实现要真的支持对应的全文检索语法。
一个典型写法如下:
{
$text: {
$search: '张三 杭州',
$language: 'zh_CN',
$ts: 'simple',
},
}
这里要特别说明:当前 oak-db/test/testcase 里还没有单独的全文检索回归用例。也就是说,这项能力在“类型定义 + translator 实现”这一层已经打通,但项目接入前最好自己补一条数据库实测。
Geo 查询
oak-domain/src/types/Expression.ts 与 MySQL/PostgreSQL translator 中已经定义了 $distance、$contains 这类 Geo 表达式;但当前:
oak-db/test/testcase没有对应回归测试;Expression.ts的本地执行分支里,$contains仍直接抛出“未实现”。
因此 Geo 能力更适合写成“按当前数据库项目验证后使用”,不建议在新手项目里把它当成已经稳定可依赖的通用语法。
Sorter
Sorter 表示查询时的排序。Oak 的 Sorter 结构定义在 oak-domain/src/types/Entity.ts 中:
[
{
$attr: {
phone: 1,
},
$direction: 'asc',
},
]
例如,我们查询 Address 时要求结果按手机号升序排序:
[
{
$attr: {
phone: 1,
},
$direction: 'asc',
},
]
写成数组意味着我们可以按序支持多个 sort 条件,同时也支持将排序属性扩展到多对一的父对象上:
[
{
$attr: {
phone: 1,
},
$direction: 'asc',
},
{
$attr: {
area: {
name: 1,
},
},
$direction: 'desc',
}
]
oak-db/test/testcase/base.ts 已覆盖:
- 按当前对象普通字段排序;
- 配合
indexFrom + count做分页; - 在更复杂的 filter 环境里使用排序。
如果查询中不指定任何排序条件,框架通常会自动补一个 $$createAt$$ 的降序排序条件。因此列表页如果要稳定分页,最好明确把 sorter 写出来。
聚合(Aggregate)
聚合查询使用的是 context.aggregate(...) / store.aggregate(...),其结构本质上和 Selection 很像,只是 data 变成了聚合数据结构:
{
data: {
'#aggr': {
district: 1,
},
'#count-1': {
id: 1,
},
},
filter: {
areaId,
},
}
这里有三个核心概念:
| 字段 | 作用 |
|---|---|
#aggr | 按哪些字段分组 |
#count-n | 聚合计数结果 |
#data | 聚合结果里真正的分组键值 |
例如,oak-db/test/testcase/aggr.ts 已覆盖了“按 district 分组并计数”的结果:
const result = await context.aggregate('house', {
data: {
'#aggr': {
district: 1,
},
'#count-1': {
id: 1,
},
},
filter: {
areaId,
},
}, {});
返回结果中的每一行大致会长成:
{
'#data': {
district: '杭州',
},
'#count-1': 3,
}
Oak 还支持把聚合挂到普通 select 的子查询 projection 上。oak-db/test/testcase/base.ts 已覆盖了这种关系聚合写法:
{
id: 1,
name: 1,
application$system$$aggr: {
$entity: 'application',
data: {
'#aggr': {
systemId: 1,
},
'#count-1': {
id: 1,
},
},
},
}
当前聚合能力的真实状态
这里必须按源码现状写,不要想当然:
#count-n:oak-db/test/testcase/aggr.ts已有实际运行用例,可以放心按当前语法使用;#sum-n、#max-n、#min-n、#avg-n:类型里已经定义,translator 命名也已预留,但aggr.ts里相关用例当前仍然被注释并标了“暂不支持”;distinct:类型与 translator 已支持,适合在你自己补过数据库测试后使用。
因此,对新手项目来说,当前最稳妥的聚合写法是:先以 #count-n 为主,其它聚合函数在项目仓库里补过实测后再上线。
不过为了避免你看源码时对不上,这几类聚合函数在类型里的真实写法大致如下:
{
data: {
'#aggr': {
district: 1,
},
'#sum-1': {
$$sum: {
'#attr': 'size',
},
},
'#max-1': {
$$max: {
'#attr': 'size',
},
},
'#min-1': {
$$min: {
'#attr': 'size',
},
},
'#avg-1': {
$$avg: {
'#attr': 'size',
},
},
},
}
这段写法可以在 oak-domain/src/types/Expression.ts 和 oak-db/test/testcase/aggr.ts 的注释用例里对上,但请注意:目前文档给出它,是为了让你读源码时能认出来,不是为了把它描述成“已经完整稳定支持”。
计数(Count)
如果你只是想知道“满足当前条件的有多少条”,不要总是先 select 再自己数。Oak 已经单独提供了 count 接口:
const count = await context.count('house', {
filter: {
district: '杭州',
},
count: 1000,
}, {});
这里的 count 参数不是“再查几条数据”,而是“数量上限”。它的用途和列表页里的 total 很接近,都是为了避免满足条件的数据特别多时,把数据库拖进一次很重的精确计数。
查询选项(SelectOption)
除了 Selection 本身,oak-domain/src/types/Entity.ts 还定义了 SelectOption:
{
dontCollect?: boolean;
blockTrigger?: true;
forUpdate?: true | 'skip locked' | 'nowait';
includedDeleted?: true;
ignoreAttrMiss?: true;
}
这些选项里最常用的是下面几个:
| 选项 | 作用 | 当前状态 |
|---|---|---|
forUpdate | 查询时加锁 | oak-db/test/testcase/base.ts 已覆盖 true |
includedDeleted | 查询时包含软删除行 | base.ts 已覆盖 |
dontCollect | 不把查询结果收集进 opRecords / 前端 cache 同步链路 | 更偏框架层能力 |
blockTrigger | 跳过 select trigger / checker 链路 | 更偏框架层能力 |
ignoreAttrMiss | cache 场景下允许属性缺失 | 前端缓存相关 |
例如:
const rows = await context.select('house', {
data: { id: 1, size: 1 },
filter: { id: houseId },
}, {
forUpdate: true,
});
forUpdate 在类型上还支持 'skip locked' 和 'nowait':
await context.select('house', {
data: { id: 1, size: 1 },
filter: { district: '杭州' },
}, {
forUpdate: 'skip locked',
});
MySQL / PostgreSQL translator 当前都会把这类字符串直接拼到 FOR UPDATE 后面;但 oak-db/test/testcase/base.ts 目前真正跑过回归的还是 forUpdate: true,因此如果项目要依赖 skip locked、nowait 这类数据库方言,仍建议自己补集成测试。
软删除数据如果要重新查出来,则要显式带上:
const rows = await context.select('house', {
data: { id: 1 },
filter: { id: houseId },
}, {
includedDeleted: true,
});
一个总的建议
查询语法这块,新同学最容易犯的错,就是把 Oak 当成“只有 Mongo 风格 filter 的 ORM”。实际上 Oak 当前已经同时支持:
- 关系级联 projection;
- 子查询谓词
#sqp; $expr表达式;- JSON 嵌套过滤;
- 聚合查询;
- 软删除可见性控制;
- 加锁查询;
- 列表页额外 total 与随机抽样。
因此遇到“这个语法到底能不能写”的问题,正确的核对顺序应该是:
- 先看生成后的 Entity 类型;
- 再看
oak-domain/src/types/Demand.ts和Expression.ts; - 最后看
oak-db/test/testcase有没有对应实测。
操作(Operate)
当要操作一个对象时,传入的数据结构如下:
{
id: Uuid;
action: Action;
data: Data;
filter: Filter;
}
Uuid
Operate的操作需要唯一编号,其作用是为了记录日志和在分布式环境下实现操作同步。可以使用下面两个函数来产生Uuid。
import { generateNewId, generateNewIdAsync } from '@oak-domain/utils/uuid';
一般来说,如果不是在有些前端环境中受到同步的限制,应优先使用异步产生函数。
Action
声明Operate的类型。有三种Action是公共的:create/update/remove,除此之外,用户在定义Entity时,所声明的Action也是有效的Action。
所有用户定义的Action从广义上来说都是update。如果用户在定义Action时,也定义了相应的状态转换矩阵(见编写对象),则在执行该动作时,会自动进行对象相应属性的状态检查以及更新其状态。
一般来说,对一个对象的操作如果有业务层面上的语义,推荐尽量细化成不同的Action,而不直接使用update。这样一来可以使对对象的操作历史更加清晰,二来也便于后续进行细粒度的权限控制。
Data
声明更新的数据。更新的数据可以是两种:
- 自身的数据属性
例如要更新地址的phone和name:
{
phone: '138xxxxxxxx',
name: '张小三',
}
create操作必须传入id以及有效的所有声明非空属性,update只需要传入至少一个属性,而remove操作无需传入自身的属性。
update 还支持表达式更新。需要用表达式计算字段值时,应显式写成 $expr:
await context.operate('account', {
id: await generateNewIdAsync(),
action: 'update',
data: {
balance: {
$expr: {
$add: [
{ '#attr': 'balance' },
100,
],
},
},
},
filter: {
id: accountId,
},
}, {});
MySQL / PostgreSQL translator 都已经支持这类 update expression。需要注意的是,JSON / JSONB 字段里的普通对象会按字面量处理,不会被自动当成表达式;如果要表达式语义,必须显式使用 $expr。
- 级联数据属性 同Filter/Projection一样,更新的Data也支持级联更新,可以通过一次Operate请求,更新当前对象以及其级联对象上的属性。 例如,我们可以在更新Address时,同时去更新其相关联的父对象Area的数据(不考虑这个请求是否有意义):
{
phone: '138xxxxxxxx',
name: '张小三',
area: {
id: '{uuid}',
action: 'update',
data: {
name: '苏杭市',
}
},
}
同样的,我们也可以在更新父对象Area时,更新其相关联的子对象Address的数据:
{
name: '苏杭市',
address$area: {
id: '{uuid}',
action: 'update',
data: {
phone: '138xxxxxxxx',
},
filter: {
name: '张小三',
}
}
}
这条Operate在更新Area数据的同时,还会将“指向它的且『name为张小三』的所有的Address”数据的phone属性更新成138xxxxxxxx。
我们还可以在更新父对象的同时,插入一条子对象,像下面这样:
{
name: '苏杭市',
address$area: {
id: '{uuid}',
action: 'create',
data: {
id: '{uuid}',
phone: '138xxxxxxxx',
name: '张小四',
....
},
}
}
新插入的Address会自动和当前Area关联。
下面列出了框架所支持的级联更新的情况:
- 子对象级联父对象
| 子对象 | 父对象 | 效果 |
|---|---|---|
| create | create | 父子对象和关联关系一起创建 |
| update | create | 更新子对象、创建父对象及关联关系(如果原来子对象上有关联的父对象关系会丢失) |
| update | update | 更新子对象,同时更新关联的父对象 |
| update | remove | 更新子对象,同时删除关联的父对象及关联关系 |
| remove | update | 移除子对象及关联关系,同时更新关联的父对象 |
| remove | remove | 同时移除父子对象,以及关联关系 |
- 父对象级联子对象
| 父对象 | 子对象 | 效果 |
|---|---|---|
| create | create | 创建父子对象,并创建关联关系 |
| update | update | 更新父对象,并更新关联的子对象 |
| update | remove | 更新父对象,同时删除关联的子对象 |