# DB Schema扩展js

DB Schema 的json文件无法编程,可编程扩展的js将大大增强schema的控制能力。

过去clientDB里使用action来处理schema.json不足的地方。但action云函数有个安全缺陷,无法禁止客户端发起指定action的调用。

从 HBuilderX 3.6.11+,uniCloud提供了可编程schema,每个${表名}.schema.json旁边都可以配置一个${表名}.schema.ext.js

  • 在HBuilderX项目下,在目录 uniCloud/database/ 下可以创建${表名}.schema.ext.js
  • 在uniCloud web控制台的数据库表管理界面,在schema.json旁边也有${表名}.schema.ext.js的在线管理。

schema扩展js在规划中可以实现很多事情,目前仅上线数据库触发器功能。

推荐开发者使用JQL数据库触发器来替代action云函数。

# 数据库触发器

JQL的数据库触发器,用于在执行一段JQL数据库指令(增删改查等)的同时触发相应的操作。

仅限使用JQL来操作数据库,客户端和云端均可以执行JQL。但使用传统MongoDB写法不支持数据库触发器。

可以使用触发器方便的实现很多功能,例如:

  1. 更新数据时自动将更新时间修改为当前时间
  2. 读取文章详情后阅读量加1
  3. 发布一篇文章后自动给文章作者列表文章数量加1

由于数据库触发器是在云端执行的,所以clientDB操作数据库时很多不宜写在前端的代码,就可以挪到数据库触发器中实现。

如果把数据库的schema定义好,包括json和ext.js,那么各个业务模块就可以随便安心的调用数据库了,数据一致性逻辑和安全保障将被统一管理,不担心不良业务代码的破坏、不担心哪次调用会漏掉更新时间字段。

# 触发器配置

在项目的uniCloud/database目录下创建${表名}.schema.ext.js,内容如下。

module.exports = {
  trigger: {
	// 注册数据表的read前事件
    beforeRead: async function (
	// 确定要监听的什么样的JQL指令
	{
      collection,
      operation,
      where,
      field
    } = {}) {
		// 当上述jql指令被触发时,将执行这里的代码。这里就是普通的uniCloud代码,可以调用uniCloud的各种api。
		console.log("这个触发器被触发了")
    },
    afterRead: async function ({
      collection,
      operation,
      where,
      field
    } = {}) {

    }
  }
}

如上配置会在使用jql读取此表内容时触发beforeReadafterRead。 除这两个时机外还有beforeCountafterCountbeforeCreateafterCreatebeforeUpdateafterUpdatebeforeDeleteafterDelete这些触发时机,后续章节会详细说明

ext.js里引入公共模块的机制:

  • 在通过clientDB访问时触发器内可以使用包含在clientDB内的公共模块,如何将公共模块引入clientDB请参考:jql依赖公共模块
  • 在通过云函数/云对象使用jql访问时,触发器可以使用云函数/云对象依赖的公共模块。

# 触发器入参

所有触发器均在数据校验、权限校验之后执行,beforeXxx会在实际执行数据库指令之前执行,afterXxx会在实际执行数据库指令之后执行。

触发器的入参有以下几个,不同时机的触发器参数略有不同

参数名 类型 默认值 是否必备 说明
collection string - 当前表名
operation string - 当前操作类型:createupdatedeletereadcount
where object - 当前请求使用的查询条件(见下方说明)
field array<string> - read必备 当前请求访问的字段列表(见下方说明)
addDataList array<object> - create必备 新增操作传入的数据列表(见下方说明)
updateData object - update必备 更新操作传入的数据(见下方说明)
clientInfo object - 客户端信息,包括设备信息、用户token等,详见:clientInfo
userInfo object - 用户信息
result object - afterXxx内必备 本次请求结果
isEqualToJql function - 用于判断当前执行的jql语句和执行语句是否相等
triggerContext object - 用于在before和after内共享数据,新增于3.6.16
subCollection array - 请使用secondaryCollection替代此参数,此参数仍可访问只是会给出警告
secondaryCollection array - 获取联表查询的副表列表,新增于3.7.1
rawWhere object|string - 未经转化的原始查询条件,新增于3.7.0
rawGeoNear object - 未经转化的原始geoNear参数,新增于3.7.0
skip number - 跳过记录条数,新增于3.7.0
limit number - 返回的结果集(文档数量)的限制,新增于3.7.0
sample object - sample(随机选取)方法的参数,新增于3.7.0
docId string - doc方法的参数,数据库记录的_id,新增于3.7.0
isGetTempLookup boolean - 联表触发时必备 联表查询时用于标识,本次查询是否使用了getTemp,新增于3.7.1
primaryCollection string - 副表read必备 联表查询时主表的表名,新增于3.7.13

# secondaryCollection

仅read操作联表有此参数,新增于 3.7.1

联表查询副表组成的列表

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeRead: async function({
      secondaryCollection
    } = {}) {
      if(secondaryCollection && secondaryCollection.length > 1) {
        throw new Error('仅允许关联一个副表')
      }
    }
  }
}

# where

read、count、delete、update操作可能有此参数

触发器收到的where参数为转化后的查询条件,可以直接作为参数传给db.collection()和dbJql.collection()的where方法。jql语句使用doc方法时也会转成where,形如:{_id: 'xxx'}

# docId

read、delete、update操作可能有此参数,新增于 3.7.0

触发器收到的doc方法内传递的文档_id

示例

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeDelete: async function({
      docId
    } = {}) {
      if(!docId) {
        throw new Error('仅能指定文档id删除')
      }
    }
  }
}

# rawWhere

read、delete、update操作可能有此参数,新增于 3.7.0

where或match方法的原始参数,未经jql转化。如果是字符串或使用了数据库方法,则仅能传递给databaseForJQL返回的数据库实例使用,不能传递给database方法返回的数据库实例使用。

示例

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeDelete: async function({
      rawWhere
    } = {}) {
      if(rawWhere && typeof rawWhere !== string) {
        throw new Error('仅能使用字符串作为查询条件')
      }
    }
  }
}

# rawGeoNear

仅read操作有此参数,新增于 3.7.0

geoNear方法的原始参数,未经jql转化。如果其中query是字符串或使用了数据库方法,则仅能传递给databaseForJQL返回的数据库实例使用,不能传递给database方法返回的数据库实例使用。

# skip

仅read操作有此参数,新增于 3.7.0

跳过的文档数量

示例

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeRead: async function({
      skip
    } = {}) {
      if(skip && skip > 10000) {
        throw new Error('无法访问10000条以后的数据')
      }
    }
  }
}

# limit

仅read操作有此参数,新增于 3.7.0

返回的结果集(文档数量)的限制

示例

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeRead: async function({
      limit
    } = {}) {
      if(limit && limit > 100) {
        throw new Error('每次最多访问100条数据')
      }
    }
  }
}

# sample

仅read操作有此参数,新增于 3.7.0

随机筛选方法的参数

示例

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeRead: async function({
      sample
    } = {}) {
      if(sample && sample.size > 100) {
        throw new Error('每次最多随机筛选100条数据')
      }
    }
  }
}

# field

仅read操作有此参数

field为所有被访问的字段的组成的数组,嵌套的字段会被摊平。未传递field时会返回所有字段

# addDataList

仅create操作有此参数

数据库指令add方法的参数和schema内defaultValue、forceDefaultValue合并后的列表。注意为统一数据结构,add方法内只传递一个对象时此参数也是一个仅有一项的数组。

如果在给数据库插入数据前拦截并修改了addDataList的数据,那么插入数据库的就会是新修改的数据。

# updateData

仅update操作有此参数

数据库指令update方法的参数。

如果在给数据库修改数据前拦截并修改了updateData的数据,那么更新进数据库的就会是新修改的数据。

# userInfo

新增于 HBuilderX 3.6.14

用户信息包含以下字段

字段名 类型 说明
uid string|null 用户id,未能获取用户信息时为null
role array 角色列表,默认为空数组
permission array 权限列表,默认为空数组

# result

新增于 HBuilderX 3.6.14

本次数据库操作的结果,不同操作返回不同的结构。对result对象的修改会应用到最终返回的结果内

查询

{
	data: [] // 获取到的数据列表
}

查询带count

{
	data: [], // 获取到的数据列表
  count: 0 // 符合条件的数据条数
}

计数

{
  total: 0 // 符合条件的数据条数
}

新增单条

{
	id: '' // 新增数据的id
}

新增多条

{
	ids: [], // 新增数据的id列表
	inserted: 3 // 新增成功的条数
}

更新数据

{
	updated: 1 // 更新的条数,数据更新前后无变化则更新条数为0
}

# isEqualToJql

新增于 HBuilderX 3.6.14

用于判断触发器当前执行的jql语句和方法传入的语句是否等价的方法。

开发者除了使用field、where等分解后的对象,也可以使用isEqualToJql来判断当前执行的JQL语句是什么。

如果单纯使用字符串比较,开发者会遇到单双引号、换行等原因造成比较失败。所以提供了isEqualToJql方法。

用法

isEqualToJql(string JQLString)

返回值

此方法返回一个bool值,true表示当前执行的jql语句和传入的语句相等,否则为不等

示例

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeCount: async function({
      isEqualToJql
    } = {}) {
      if(isEqualToJql('db.collection("article").count()')) {
        console.log('成功匹配了JQL命令:对article表进行count计数且未带条件')
      } else {
        throw new Error('禁止执行带条件的count')
      }
    }
  }
}

# triggerContext

新增于 HBuilderX 3.6.16

此参数为一个空对象,仅用于在before内挂载数据并在after内获取使用

示例

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeUpdate: async function({
      triggerContext
    } = {}) {
      triggerContext.shareVar = 1
    },
    afterUpdate: async function(){
      if (triggerContext.shareVar === 1) {
        console.log('获取到的triggerContext.shareVar为1')
      }
    }
  }
}

# 触发时机

触发时机 说明
beforeRead 读取前触发
afterRead 读取后触发
beforeCount 计数前触发
afterCount 计数后触发
beforeCreate 新增前触发
afterCreate 新增后触发
beforeUpdate 更新前触发
afterUpdate 更新后触发
beforeDelete 删除前触发
afterDelete 删除后触发
beforeReadAsSecondaryCollection 集合作为副表被读取前触发,仅使用了getTemp的联表查询才会触发
afterReadAsSecondaryCollection 集合作为副表被读取后触发,仅使用了getTemp的联表查询才会触发

注意

  • count有两种触发情况一种是在数据库指令使用了count方法,另一种是在get方法内传getCount参数。HBuilderX 3.6.16版本之前,get方法内传getCount参数不会触发count触发器,HBuilderX 3.6.16及后续版本已修复此问题。

# 示例

以下article表为例。

为了不增加示例的复杂度,所有权限均设置为true,实际项目中切勿模仿。

// article.schema.ext.js
{
  "bsonType": "object",
  "required": ["title", "content"],
  "permission": {
    "read": true,
    "create": true,
    "update": true,
    "delete": true
  },
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "title": {
      "bsonType": "string"
    },
    "summary": {
      "bsonType": "string"
    },
    "content": {
      "bsonType": "string"
    },
    "author": {
      "bsonType": "string"
    },
    "view_count": {
      "bsonType": "int"
    },
    "create_date": {
      "bsonType": "timestamp"
    },
    "update_date": {
      "bsonType": "timestamp"
    }
  }
}

# 修改文章更新时间

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeUpdate: async function({
      collection,
      operation,
      docId,
      updateData,
      clientInfo
    } = {}) {
      if(typeof docId === 'string' && (updateData.title || updateData.content)) { //如果字段较多,也可以不列举字段,删掉后半个判断
        if(updateData.content) {
          // updateData.summary = 'xxxx' // 根据content生成summary
        }
        updateData.update_date = Date.now() // 更新数据的update_date字段赋值为当前服务器时间
      }
    }
  }
}

# 读取后触发实现阅读量加1

// article.schema.ext.js
module.exports = {
  trigger: {
    afterRead: async function({
      collection,
      operation,
      docId,
      field,
      clientInfo
    } = {}) {
      const db = uniCloud.database()
      // clientInfo.uniIdToken可以解出客户端用户信息,再进行判断是否应该加1。为了让示例简单清晰,此处省略相关逻辑
      if(typeof docId === 'string' && field.includes('content')) {
        // 读取了content字段后view_count加1
        await db.collection('article').doc(docId).update({
          view_count: db.command.inc(1)
        })
      }
    }
  }
}

# 删除前备份

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeDelete: async function({
      collection,
      operation,
      docId,
      clientInfo
    } = {}) {
      const db = uniCloud.database()
      if(typeof docId !== 'string') { // 此处也可以加入管理员可以批量删除的逻辑
        throw new Error('禁止批量删除')
      }
      const res = await db.collection('article').doc(docId).get()
      const record = res.data[0]
      if(record) {
        await db.collection('article-archived').add(record)
      }
    }
  }
}

# 新增文章时自动添加摘要

// article.schema.ext.js
module.exports = {
  trigger: {
    beforeCreate: async function({
      collection,
      operation,
      addDataList,
      clientInfo
    } = {}) {
      for(let i = 0; i < addDataList.length; i++) {
        const addDataItem = addDataList[i]
        if(!addDataItem.content) {
          throw new Error('缺少文章内容')
        }
        addDataItem.summary = addDataItem.content.slice(0, 100)
      }
    }
  }
}

# 在触发器内使用jql语法

jql触发器内可以使用jql语法操作数据库。

注意:在触发器内再使用jql语法操作数据库还会执行触发器,很容易引发死循环!

为此,uniCloud.databaseForJQL方法增加了参数skipTrigger,用于指定本次数据库操作跳过触发器的执行。

skipTrigger是一个bool值,true跳过执行触发器,false则继续执行触发器。默认为false。

该参数客户端不生效,仅云端生效。

示例如下:

uniCloud.databaseForJQL({
  clientInfo,
  skipTrigger: true // true跳过执行触发器,false则继续执行触发器。默认为false
})

我们现在增加一个阅读记录表,schema如下

为了不增加示例的复杂度,所有权限均设置为true,实际项目中切勿模仿。

// article.schema.ext.js
{
  "bsonType": "object",
  "required": ["title", "content"],
  "permission": {
    "read": true,
    "create": true,
    "update": true,
    "delete": true
  },
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "article_id": {
      "bsonType": "string",
      "foreignKey": "article._id"
    },
    "reader_id": {
      "bsonType": "string",
      "foreignKey": "uni-id-users._id",
      "forceDefaultValue": {
        "$env": "uid"
      }
    }
  }
}

使用jql语法可以自动验证客户端身份,仍以上述article表为例,在afterRead触发器内插入阅读记录。此时会将读者id插入到reader_id字段

module.exports = {
  trigger: {
    afterRead: async function ({
      docId,
      field,
      clientInfo
    } = {}) {
      if(typeof docId !== 'string' || !field.includes('content')) {
        return
      }
      const dbJQL = uniCloud.databaseForJQL({
        clientInfo,
        skipTrigger: true
      })
      await dbJQL.collection('article-view-log')
        .add({
          article_id: docId,
          reader_id: dbJQL.getCloudEnv('$cloudEnv_uid')
        })
    }
}

# 在触发器内使用扩展库和公共模块

schema扩展依赖的公共模块和扩展库同样可以被action、validateFunction使用。

内置依赖:目前schema扩展依赖了uni-iduni-id-common,uni-id 3.0.7及以上版本又依赖了uni-config-center,这两个公共模块是可以在触发器内直接使用的。如果所在服务空间开通了redis,schema扩展内可直接使用redis扩展。

HBuilderX 3.7.0起,可以在项目的uniCloud/database目录上右键管理schema扩展依赖的公共模块和扩展库。同样在此目录右键选择上传schema扩展Js的配置将配置的依赖同步到云端。

HBuilderX 3.2.7HBuilderX 3.7.0之间的版本,可通过在要使用的公共模块的package.json内配置"includeInClientDB":true,可以将公共模块和schema扩展关联,HBuilderX 3.7.0及之后的版本不推荐使用此用法

一个在JQL内使用的公共模块的package.json示例如下。

{
  "name": "test-common",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "keywords": [],
  "author": "",
  "license": "ISC",
  "includeInClientDB": true
}

通过上述步骤建立起关联关系后,可正常在数据库触发器或action云函数中使用公共模块。

注意

  • 尽量不要依赖体积过大的公共模块,会延长冷启动时间
  • 仅能依赖公共模块,不能添加npm包依赖

# 注意事项

  • 非getTemp联表查询(不推荐的用法)在触发器内获取的where为null、field为当前表的所有字段。
  • 联表查询时只会触发主表触发器,不会触发副表触发器
  • getTemp联表时主表所在的getTemp内的where和field会传递给触发器,虚拟联表的where和field不会传给触发器
  • 通过jql的redis缓存读取的内容不会触发读触发器
  • HBuilderX内使用jql数据管理功能执行jql语句时不会触发任何触发器

# 和action云函数的关系

  • 数据库触发器比action云函数更安全,不会被前端错误指定。
  • 数据库触发器支持JQL语法。action云函数只支持使用传统MongoDB方式。
  • 数据库触发器能实现很多常见的action云函数功能,并且无需修改schema和数据库指令。
  • 如果同时使用数据库触发器和action云函数,注意触发器的before会在所有action的before执行之前再执行,after会在所有action的after执行之后再执行。action无法捕获触发器抛出的错误。