查询和操作对象
编译
在编写或更新了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)
当查询一个对象时,传入的Select结构如下:
{
data: Projection;
filter: Filter;
sorter: Sorter;
indexFrom: number;
count: number;
}
Projection
当查询对象时,通过Projection可以定义要查询对象的哪些属性(projection的命名本身就借鉴了数据库中的“投影”概念)。例如,对上一节中所定义的Address对象,查询时可以指定投影为:
{
detail: 1,
name: 1,
phone: 1,
}
可以根据对象之间的关系将projection扩展到多对一的父对象上,实现级联查询:
{
detail: 1,
name: 1,
phone: 1,
area: { // 查询相关联的Area
id: 1,
name: 1,
parent: { // 查询Area所相关的更高层Area
id: 1,
name: 1,
},
},
}
也可以扩展到一对多的子对象上的级联查询,例如我们查询Area对象时,可以将之关联的Address一并查找出来:
{
id: 1,
name: 1,
address$area: { // 查询和area相关的所有address对象
$entity: 'area',
data: {
id: 1,
name: 1,
phone: 1,
},
},
}
通过这样的扩展,可以很方便的通过一次查询获得一个对象相关联的所有数据,查询结果会是一个复杂的数据对象(Schema)
Oak框架还支持表达式查询和函数计算,例如如果想返回一个name和phone连接的字符串,可以这样写:
{
id: 1,
$expr: {
$concat: [
"姓名:",
{
'#attr': "name",
},
"手机号:",
{
'#attr': "phone",
}
]
}
}
Schema
Schema是对对象进行Select查询获得的数据结果格式。此时返回的对象除了自身的属性之外,还可能级联了其父对象与子对象的数据。 例如,在上面的例子里,查询到的Address数据结果中包含了其父对象Address的数据:
{
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: 'xxx',
name: 'xxxxx',
phone: '139xxxxxxxx',
}
]
}
Filter
Filter代表查询某个对象的条件,例如,我们要查询Area为杭州市,手机号码以139开头的Address,就可以这样写:
{
areaId: '310100',
phone: {
$startsWith: '139',
},
}
可见Filter的算子语法比较接近MongoDB,Oak框架在此基础上做了大量的扩展,例如,如果不知道杭州市的areaId,我们也可以这样写:
{
area: { // 把filter扩展到Area父对象上
name: '杭州市
},
phone: {
$startsWith: '139',
},
}
同样的,Filter也可以被扩展到子对象上,比方说我们查询Area,条件是“该Area上至少有一条相关的Address,其手机号码以139开头”(不用考虑这个查询是否合理):
{
address$area: {
phone: {
$startsWith: '139',
},
}
}
甚至我们可以查询“该Area上不能有任何一条手机号以139开头的ddress”:
{
address$area: {
phone: {
'#sqp': 'not in',
$startsWith: '139',
},
}
}
具体的Filter算子的语法比较丰富,我们罗列在下方,部分复杂的语法用户可以查阅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)[] | 不在……中 |
| $mod | [number, number] | 取模 |
| $between | [number, number] | 在……之间(包括等于) |
| $startsWith | string | 以……开头 |
| $endsWith | string | 以……结尾 |
| $includes | string | 包含…… |
| $exists | boolean | 是否为空 |
| $search $language | string 'zh_CN'|'en_US' | 全文检索(对象上必须声明了全文索引) |
| $and | Filter[] | 与 |
| $or | Filter[] | 或 |
此外,Filter还能支持表达式计算和非结构化数据查询。todo
Sorter
Sorter表示查询时的排序。例如,我们查询Address时要求结果按手机号排序,可以这样写sorter:
[
{
$attr: {
phone: 1,
},
$direction: 'ASC',
},
]
写成数组的形式意味着我们可以按序支持多个sort条件,同时也支持将排序的属性扩展到多对一的父对象上:
[
// 先按phone升序排,再按area.name降序排
{
$attr: {
phone: 1,
},
$direction: 'ASC',
},
{
$attr: {
area: {
name: 1,
},
},
$direction: 'DESC',
}
]
在查询中,如果不指定任何排序条件,则框架会自动添加一个$$createAt$$属性上的降序排序条件。
操作(Operate)
当要操作一个对象时,传入的数据结构如下:
{
id: Uuid;
action: Action;
data: Data;
filter: Filter;
}
Uuid
Operate的操作需要唯一编号,其作用是为了记录日志和在分布式环境下实现操作同步。可以使用下面两个函数来产生Uuid。
import { generateUuid, generateUuidAsync } from 'oak-domain/lib/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操作无需传入自身的属性。
- 级联数据属性 同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 | 更新父对象,同时删除关联的子对象 |