Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

查询和操作对象

编译

在编写或更新了Entity定义后,都需要执行命令来编译完整的对象数据字典

npm run make:domain

编译出来的数据字典声明在src/oak-app-domain目录下,同时也会编译出来一个数据的存储格式供框架引用,可以在代码中像这样去引用它们:

// EntityDict是数据字典声明,StorageSchema是存储格式定义
import { EntityDict, StorageSchema } from '@project/oak-app-domain';

数据字典和存储格式是整个Oak框架最核心的内容,贯穿于使用框架的各个层面,因此需要深刻理解。本章节将使用上小节的AddressArea对象,介绍一些查询和操作的核心概念。

编译后的对象结构

编译后的对象原生结构称为OpSchema,其结构仅仅在用户定义的属性上增加了一些通用的属性类型,以及将引用对象转化成了外键。

每个Entity的OpSchema可以在编译后的oak-app-domain/${Entity}/Schema.ts中查看,本章下面的大多数数据结构都是如此

对象上增加的通用属性包括:

属性类型含义
idstring<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,
}, {});

这里也要按源码现状理解:distinctSelection 类型和 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$necompare.ts
字符串$startsWith$endsWith$includes$concatstring.ts
布尔/逻辑$true$false$and$or$notbool.ts
数学$add$subtract$multiply$divide$abs$round$floor$ceil$pow$modmath.tscomplax.ts
日期$year$month$weekday$weekOfYear$day$dayOfMonth$dayOfWeek$dayOfYear$dateDiff$dateFloor$dateCeildate.ts
引用节点#attr#id#refId#refAttrDemand.tsbase.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 中定义的常用过滤语法如下:

算子参数类型作用
$gtnumber | string大于
$gtenumber | string大于等于
$ltnumber | string小于
$ltenumber | string小于等于
$eqnumber | string | boolean等于
$nenumber | string | boolean不等于
$in(number | string)[]在……中
$nin(number | string)[]不在……中
$between[number, number]在……之间(含边界)
$mod[number, number]取模
$startsWithstring以……开头
$endsWithstring以……结尾
$includesstring包含……
$existsboolean字段是否存在/是否为空
$andFilter[]
$orFilter[]
$notFilter
$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.tsoak-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 中还定义了 JsonFilteroak-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 也都已经实现了翻译。但这里有两个前提必须同时满足:

  1. 对象上必须声明全文索引;
  2. 当前数据库实现要真的支持对应的全文检索语法。

一个典型写法如下:

{
    $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-noak-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.tsoak-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 链路更偏框架层能力
ignoreAttrMisscache 场景下允许属性缺失前端缓存相关

例如:

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 lockednowait 这类数据库方言,仍建议自己补集成测试。

软删除数据如果要重新查出来,则要显式带上:

const rows = await context.select('house', {
    data: { id: 1 },
    filter: { id: houseId },
}, {
    includedDeleted: true,
});

一个总的建议

查询语法这块,新同学最容易犯的错,就是把 Oak 当成“只有 Mongo 风格 filter 的 ORM”。实际上 Oak 当前已经同时支持:

  • 关系级联 projection;
  • 子查询谓词 #sqp
  • $expr 表达式;
  • JSON 嵌套过滤;
  • 聚合查询;
  • 软删除可见性控制;
  • 加锁查询;
  • 列表页额外 total 与随机抽样。

因此遇到“这个语法到底能不能写”的问题,正确的核对顺序应该是:

  1. 先看生成后的 Entity 类型;
  2. 再看 oak-domain/src/types/Demand.tsExpression.ts
  3. 最后看 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

声明更新的数据。更新的数据可以是两种:

  1. 自身的数据属性
    例如要更新地址的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

  1. 级联数据属性 同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关联。

下面列出了框架所支持的级联更新的情况:

  • 子对象级联父对象
子对象父对象效果
createcreate父子对象和关联关系一起创建
updatecreate更新子对象、创建父对象及关联关系(如果原来子对象上有关联的父对象关系会丢失)
updateupdate更新子对象,同时更新关联的父对象
updateremove更新子对象,同时删除关联的父对象及关联关系
removeupdate移除子对象及关联关系,同时更新关联的父对象
removeremove同时移除父子对象,以及关联关系
  • 父对象级联子对象
父对象子对象效果
createcreate创建父子对象,并创建关联关系
updateupdate更新父对象,并更新关联的子对象
updateremove更新父对象,同时删除关联的子对象