Laf database-ql 设计解析:查询继承体系与特殊字段的双向类型编码
发布时间:2026/9/16 17:22:31 作者:尧图编辑部 阅读量:1,286

Laf database-ql 设计解析查询继承体系与特殊字段的双向类型编码【免费下载链接】lafLaf is a vibrant cloud development platform that provides essential tools like cloud functions, databases, and storage solutions. It enables developers to quickly unleash their creativity and bring innovative ideas to life with ease.项目地址: https://gitcode.com/GitHub_Trending/la/laf本文基于 Laf 仓库中packages/database-ql/src/README.md的设计说明展开解析 Laf 云数据库 SDK 的核心设计集合模块为什么继承 Query 模块、地理位置与日期时间等特殊字段如何在后端存储格式和JavaScript 对象之间双向转换、get()/set()/update()的完整请求链路以及 limit/offset 等查询参数的默认值与上限。读完本文你将能够理解该 SDK 的模块职责划分并在客户端正确构造查询、处理GeoPoint与Date字段同时掌握在本地编译与测试该包的实际命令。一、database-ql 的定位database-ql是 Laf 的数据库接口包其 package.json 中声明描述为Database interface for laf包内通过bson依赖处理ObjectId、Binary等 BSON 类型的序列化EJSON。它的构建产物同时提供 CommonJS 与 ESM 两种入口main:dist/commonjs/index.jsmodule:dist/esm/index.js包的对外 API 从 入口文件 导出Query、CollectionReference、DocumentReference、RequestInterface以及Db类本身。Db类还挂载了Geo类型集合Point、LineString、Polygon、MultiPoint、MultiLineString、MultiPolygon见 geo 模块、逻辑命令command、主键名primaryKey默认_id并提供了collection()、generateId()、ObjectId()等工厂方法。值得注意的是Db构造函数强制要求传入request实例否则直接抛出DbConfig.request cannot be empty错误——这说明该包本身不关心网络细节HTTP 通信被完全抽象为 接口定义 中的RequestInterfaceexport interface RequestInterface { send(action: ActionType, data: QueryParam): PromiseResponseStruct }SDK 只需按ActionType枚举如database.queryDocument把参数交给请求实现即可具体是 HTTP、WebSocket 还是本地模拟都由外部注入决定。二、模块组成与常用命令2.1 文件说明原设计文档给出的文件结构如下引自 设计文档- collection.ts // 集合模块继承 Query 模块 - constant.ts // 常量模块 - db.ts // 数据库模块 - document.ts // 文档模块 - model.ts // 类型约束模块 - query.ts // 查询模块 - request.ts // 请求模块 - 临时模拟使用 - util.ts // 工具模块 - validate.ts // 校验模块对照当前仓库源码可以看出这份文件清单记录的是 SDK 的早期形态部分模块已经演进文档中的文件当前源码对应说明db.tssrc/index.tsDb类定义在入口文件中并通过DbConfig支持自定义primaryKeymodel.ts类型约束src/interface.ts、src/result-types.ts接口类型与结果类型拆分成了独立文件request.ts临时模拟请求src/interface.ts 中的RequestInterface临时模拟已被抽象为可注入的请求接口query.ts/collection.ts/document.ts/constant.ts/util.ts/validate.ts同名文件保留核心模块结构保持稳定此外当前源码还新增了文档未列出的模块例如 serializer 目录数据编码、commands 目录更新命令、aggregate.ts聚合管道等。从源码结构看包的职责边界从集合 查询扩展到了完整的 CRUD、索引与聚合能力。2.2 常用命令设计文档中记录的常用开发命令为# 编辑 typescript tnpm run tsc # 实时编译 typescript tnpm run tsc:w # 运行测试用例 tnpm run tstest需要说明的是当前 package.json 中的实际脚本已演变为{ watch: tsc -w, test: mocha tests/units/**/*.test.js, build: tsc -p tsconfig.json tsc -p tsconfig.esm.json, lint: eslint . --fix --ext .ts --ext .js, prepublishOnly: npm run build }即本地开发时用npm run watch实时编译跑单测用npm run testmocha 收集tests/units/**/*.test.js发包前会先执行双端构建CommonJS ESM。三、类型声明约定设计文档明确提出了一条工程约定类型声明写在.ts文件里这样可以及时发现问题。.d.ts不能及时暴露问题。其含义是如果把类型只放在.d.ts声明文件中编译器不会对其做完整的实现级检查类型与实现漂移的问题容易被掩盖而把接口直接写在参与编译的.ts源码里任何字段名、可选性的改动都会在编译期立即报错。当前包正是这样做的——RequestInterface、QueryParam、ProjectionType等全部定义在参与tsc编译的 interface.ts 中而不是单独的声明文件。四、核心设计集合模块继承 Query 模块设计文档给出的一条关键设计决策是集合模块继承 Query 模块为了更好使用查询条件。主要参考了 firebase - firestore 的设计。在 collection.ts 中可以看到这一继承关系export class CollectionReference extends Query { constructor(db: Db, coll: string) { super(db, coll) } // 读取集合名字 get name() { return this._coll } // 获取文档引用 doc(docID: string | number): DocumentReference { ... } // 添加一篇文档 add(data: object, options?: { multi: boolean }) { ... } // 聚合 aggregate(rawPipeline: object[] []) { ... } }这个继承带来的直接收益是集合引用本身就携带完整的查询链能力。Query类的每个链式方法where、orderBy、field、limit、skip、page都返回一个新的Query实例而不修改原对象不可变链式 API因此db.collection(todos)拿到后可以直接接上条件db.collection(todos) .where({ status: pending }) .orderBy(createdAt, desc) .limit(20) .get()4.1 Query 内部状态与参数拼装从 query.ts 的源码结构看Query持有五类内部状态_fieldFilters查询条件_fieldOrders排序条件数组{field, direction}_queryOptions查询选项limit/offset/projection/count_withs子查询with()一对多、withOne()一对一_request从Db实例注入的请求实现真正发送请求前buildQueryParam()会把上述状态拼装为统一的QueryParam结构定义见 interface.ts并在此处落实了两个关键边界if (this._queryOptions.limit) { param.limit this._queryOptions.limit 1000 ? this._queryOptions.limit : 1000 } else { param.limit 100 // 默认每页 100 条 }未显式设置limit时默认100 条显式设置时若超过1000会被钳制为 1000即单次请求最多取 1000 条。where()在入口就做了参数校验见 validate.ts 与 constant.ts 中的ErrorCode参数必须是对象且对象值不能全部为undefined否则分别抛出QueryParamTypeError查询参数必须为对象与QueryParamValueError查询参数对象值不能均为undefined。4.2 查询操作符的映射SDK 侧使用的比较运算符会被翻译成后端存储引擎MongoDB的操作符映射表定义在 constant.tsconst OperatorMap { [Operator.eq]: $eq, // [Operator.lt]: $lt, // [Operator.lte]: $lte, // [Operator.gt]: $gt, // [Operator.gte]: $gte, // }where()内部调用QuerySerializer.encode(query)serializer/query.ts完成这层转换。更新场景则支持另一组操作符白名单UpdateOperatorList$set、$inc、$mul、$unset、$push、$pop、$unshift、$shift、$currentDate、$each、$position。4.3 结果集合并with / withOneget()的源码实现区分了两条路径未设置with子查询时走internalGet()单次请求设置了子查询时走internalMerge()——先执行主查询再取主结果中localField的值集合对子查询追加$in条件执行第二查最后在客户端按localField - foreignField连接键把子结果合并进主文档withOne()对应一对一关系映射表值为单对象而非数组。这体现了设计文档把查询条件拼进请求的思路在复杂关联场景下的延伸。五、字段设计特殊类型字段的双向编码这是设计文档篇幅最集中的部分也是最容易踩坑的部分。原文档的表述是拉取文档列表后过滤一遍数据把特殊类型的字段格式化为相应的js对象。 发送请求新增或更新文档时过滤一遍数据把特殊字段编码为后端数据格式。也就是说 SDK 在读和写两个方向上各做一遍数据转换开发者全程只接触自然的 JavaScript 类型而不需要理解后端线的格式。5.1 读取方向后端格式 - JS 对象文档中每一个地理位置都是一个GeoPoint对象、每一个日期时间都是一个Date对象的承诺落地在 util.ts 的Util.formatResDocumentData()与formatField()中。get()返回前会调用它对res.data.list逐篇文档做递归处理核心逻辑节选switch (type) { case FieldType.GeoPoint: realValue new Point(item.coordinates[0], item.coordinates[1]) break case FieldType.GeoLineString: case FieldType.GeoPolygon: case FieldType.GeoMultiPoint: case FieldType.GeoMultiLineString: case FieldType.GeoMultiPolygon: realValue /* 递归构造对应的 Geo 对象 */ break case FieldType.Timestamp: realValue new Date(item.$timestamp * 1000) break case FieldType.Object: case FieldType.Array: realValue Util.formatField(item) // 递归深入嵌套结构 break case FieldType.ObjectId: case FieldType.Binary: realValue EJSON.deserialize(item) break default: realValue item }类型判别由whichType()完成先通过Object.prototype.toString拿到基础类型再结合instanceofPoint、Date、ServerDate、ObjectId、Binary和 EJSON 标记字段$timestamp、$date、$oid、$binary以及Point.validate(obj)等结构校验来识别。注意Object与Array两个分支会递归进入嵌套结构因此地理位置或时间戳无论嵌在文档多深的位置都会被还原为对应 JS 对象——这正是文档所说过滤一遍数据的完整实现。5.2 为什么不给 GeoPoint 增加转后端格式的公开方法设计文档里有一处 QA为什么不在类下面增加一个方法转换成后端数据格式这个开发者用不到所以没有必要暴露出来。这一点值得品味后端格式的编解码完全由 SDK 内部的serialize()/formatField()收口见 serializer/datatype.tsGeo 类只提供面向开发的几何语义 API。如果暴露出转后端格式方法开发者绕开 SDK 直连后端时反而会产生格式假设增加维护成本封装在内部则保证读写两条路径永远对称。5.3 写入方向JS 对象 - 后端格式编码逻辑同样收口在 serializer/datatype.ts 的serialize()中if (isInternalObject(val)) { switch (val._internalType) { case SYMBOL_GEO_POINT: return (val as Point).toJSON() case SYMBOL_SERVER_DATE: return (val as ServerDate).parse() case SYMBOL_REGEXP: return (val as RegExp).parse() default: return val.toJSON ? val.toJSON() : val } } else if (isDate(val) || isRegExp(val) || isObjectId(val) || isBinary(val)) { return EJSON.serialize(val) // 日期、正则、ObjectId、Binary 走 EJSON } else if (isArray(val)) { // 递归编码数组元素并检测循环引用 // 若发现循环结构抛出 Cannot convert circular structure to JSON } else if (isObject(val)) { // 递归编码对象属性 }可以看到三类内部类型内部标记SYMBOL_GEO_POINT/SYMBOL_SERVER_DATE/SYMBOL_REGEXP定义在 helper/symbol.ts各走各的toJSON()/parse()而原生Date、RegExp、ObjectId、Binary统一交给bson包的EJSON.serialize处理。序列化过程中还会跟踪visited数组检测循环引用遇到循环结构直接抛错避免把非法数据发往后端。文档中提到的serverDate服务器时间在 入口文件 上标注了deprecatednot implemented in server sidedb.RegExp也建议改用 JS 原生new RegExp()使用时需注意这两个废弃状态。六、整体设计get / set / update 的请求链路设计文档的整体设计一节给出了三条主线下面结合 document.ts 逐条对应到源码。6.1 读取get()使用document.get()获取数据时把where()、orderBy()、limit()、offset()、设置的数据拼接到请求里。DocumentReference.get()的实现是where({ [primaryKey]: this.id }).getOne()即按主键构造条件后取单条Query.getOne()本质是limit(1).get()后取第一元素查不到时返回{ok: true, data: null}。请求动作常量定义在 constant.ts 的ActionType中enum ActionType { add database.addDocument, query database.queryDocument, update database.updateDocument, count database.countDocument, remove database.deleteDocument, aggregate database.aggregateDocuments, createIndex database.createIndex, dropIndex database.dropIndex, listIndexes database.listIndexes, }对后台返回的数据进行格式化使其成为一个DocumentSnapshot对象对特殊类型的字段如地理位置、日期时间进行处理。设计文档以DocumentSnapshot命名这一结果对象从当前源码结构看该概念已经演化为更扁平的返回结构GetResT{ok, data, requestId, total?, limit?, offset?}、GetOneResT、CountRes、UpdateRes、RemoveRes等统一定义在 result-types.ts 中统一携带ok标志与requestId错误时附带error与code。特殊字段的处理仍由第五节所述的formatField()完成。6.2 写入set() 与 update() 的语义差异使用document.set()和document.update()时把数据进行编码尤其是特殊字段的处理编码成后端接口的数据格式。两者最终都走Query.update()但在DocumentReference中组装了不同的选项set(data)替换语义。内部固定merge false、multi false、upsert true即按主键不存在则创建、存在则整体替换通过返回的upsertId可判断是新建还是更新。同时它会递归检查data中是否混入了UpdateCommand如$inc命令对象一旦发现直接抛出data cannot contain operator——替换操作不允许携带更新操作符。update(data)合并语义。固定merge true、multi false、upsert false数据会经UpdateSerializer.encode()serializer/update.ts转换成带操作符$set等的更新结构。Query.update()层还有一道防护data不允许为空对象且不允许包含_id字段can not update the _id field防止误改主键。数据编码统一经过第五节的serialize()因此Point、Date、ServerDate等在写入路径上会被正确转换为后端格式。批量更新则直接通过Query.update(data, { multi, merge, upsert })发起条件仍由链式where()决定条件删除走Query.remove({ multi })其源码会对已设置的offset/limit/projection与orderBy打印告警这些选项在删除操作中不生效避免开发者误以为删除范围受分页约束。6.3 新增与主键CollectionReference.add(data)会创建一个docID为undefined的DocumentReference并调用create()create()发送ActionType.add请求返回{id, insertedCount, requestId, ok}其中id优先取响应的_id否则取db.primaryKey对应的字段——这解释了 入口文件 中DbConfig.primaryKey默认_id配置项的意义SDK 内所有按主键定位文档的操作doc()、set()、update()、remove()、get()都基于该字段构造条件。七、扩展说明文档中的时间戳字段设计文档的扩展说明一节建议开发者在每篇文档中自行记录创建时间与更新时间创建时间是一个时间对象更新时间是一个时间对象的数组结合第五节的字段设计这意味着这两个字段在 SDK 视角下就是普通字段写入时传new Date()serialize()会将其转为 EJSON 的$date形式读取时formatField()又会把它还原为Date对象。如果希望更新时间累积为数组可借助更新操作符$push它在 constant.ts 的UpdateOperatorList白名单中而每次写入自动刷新则对应$currentDate。SDK 不会替开发者自动维护这两个字段这一点属于应用层约定。八、设计验证测试用例该包的设计在 tests/units 目录 中有系统的用例覆盖按场景分子目录组织get/覆盖get()基础查询、where条件where.test.js、orderBy、field投影、limit与page分页doc/覆盖单文档create、get、update场景create.test.jsupdate/、remove/覆盖批量更新与删除aggregate/覆盖聚合管道中的match、lookup等阶段。运行方式为前文所述的npm run testmocha 收集tests/units/**/*.test.js。九、小结回到设计文档的核心脉络database-ql的设计可以归纳为三层继承体系CollectionReference extends Query让集合引用天生具备完整的链式查询能力DocumentReference再按主键包装单文档操作——这与文档所说参考 firebase - firestore 的设计一脉相承双向类型编码读取时formatField()把地理位置还原为Geo*对象、日期还原为Date写入时serialize()把它们编码为后端 EJSON 格式开发者只接触自然 JS 类型后端格式细节不对外暴露请求抽象所有操作最终收敛为RequestInterface.send(ActionType, QueryParam)参数拼装统一发生在buildQueryParam()中limit 默认 100、上限 1000 等边界也在这一处集中落实。理解这三层之后阅读 query.ts、document.ts、serializer/datatype.ts 三个核心文件的源码就能完整还原该 SDK 从开发者 API 到后端协议之间的全部转换逻辑。【免费下载链接】lafLaf is a vibrant cloud development platform that provides essential tools like cloud functions, databases, and storage solutions. It enables developers to quickly unleash their creativity and bring innovative ideas to life with ease.项目地址: https://gitcode.com/GitHub_Trending/la/laf创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考