ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

teable v2 queries 读模型架构:Query DTO、Handler 注册与记录查询管线全解析

teable v2 queries 读模型架构:Query DTO、Handler 注册与记录查询管线全解析 teable v2 queries 读模型架构Query DTO、Handler 注册与记录查询管线全解析【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable导读本文以 packages/v2/core/src/queries/ARCHITECTURE.md 为骨架深入剖析 teable 新一代 v2 核心包中应用层读模型Query的定义与执行机制包括 Query DTO 如何把原始输入转换为值对象、Handler 如何通过QueryHandler装饰器注册进查询总线、以及ListTableRecordsHandler这条最复杂的记录查询管线如何把过滤器、排序、分组、搜索与视图默认值合并成可执行查询。读完本文你将掌握 v2 core 中从HTTP 原始入参 → 类型化 Query → 领域 Spec → 仓库查询 → Result的完整调用链并能直接对照源码继续深入。一、queries 文件夹的职责边界queries目录承载的是应用层的读模型Read Model与写模型Commands严格分离。根据 ARCHITECTURE.md 的声明该层有三项核心职责定义应用读模型Query与其 Handler——即查询什么与怎么查把原始输入转换为值对象 / 规格Spec/ 排序 / 分页——所有 DTO 都通过 Zod Schema 校验再进一步构造领域层值对象查询仓储Repository并返回Result——Handler 内只依赖 Port 层定义的仓储接口不直接触碰数据库。这一设计与 v2 整体的端口-适配器Ports Adapters架构一脉相承queries属于应用层application layer它依赖 ports 中抽象的ITableRepository、ITableRecordQueryRepository、IExecutionContext等接口而 PostgreSQL 等具体实现则位于packages/v2/adapter-*各适配器包中。从文件清单可以看到该目录目前包含 5 组查询对Query Handler、1 个注册器、1 组记录过滤 DTO 与其映射器以及配套的单元测试文件角色职责QueryHandler.tsHandler 接口 注册表让查询总线Query Bus按 Query 类型解析对应 HandlerListBasesQuery.ts/ListBasesHandler.ts查询 DTO / Handler分页列出 baseGetTableByIdQuery.ts/GetTableByIdHandler.ts查询 DTO / Handler按 baseId tableId 查找单张表ListTablesQuery.ts/ListTablesHandler.ts查询 DTO / Handler按名称过滤 排序 分页列出表GetRecordByIdQuery.ts/GetRecordByIdHandler.ts查询 DTO / Handler按 tableId recordId 读取单条记录ListTableRecordsQuery.ts/ListTableRecordsHandler.ts查询 DTO / Handler带过滤、排序、分组、搜索、投影的记录列表查询GetComputeActivityQuery.ts/GetComputeActivityHandler.ts查询 DTO / Handler读取表的计算活动快照computed activityRecordFilterDto.tsDTO Schema定义记录查询的过滤组/条件输入结构RecordFilterMapper.ts映射器把过滤 DTO 转换为记录条件 SpecRecordSearch.ts值对象V1 兼容的记录搜索输入解析与可见行过滤*.spec.ts系列查询测试验证 DTO 校验、排序、分页、Handler 与总线协作说明ARCHITECTURE.md 顶部有一段维护声明若本文件夹的结构发生变化请同步更新此文档尤其是核心领域概念并为抽象概念补充示例或示例文件路径。这也是阅读时值得留意的一条项目约定。二、Handler 注册机制QueryHandler装饰器与查询总线2.1 Handler 接口与注册表QueryHandler.ts 是整个查询层的枢纽它同时定义了三件事IQueryHandlerTQuery, TResult接口handle(context: IExecutionContext, query: TQuery): PromiseResultTResult, DomainError。所有 Handler 必须实现该方法且统一返回neverthrow的Result类型错误则收敛为领域错误DomainError而非抛裸异常。QueryTypeTQuery类型用于在运行时取得 Query 类的构造函数prototypename作为注册表的键。模块级注册表queryHandlerRegistry一个MapQueryType, QueryHandlerClass在模块加载期填充。QueryHandler装饰器接收 Query 类作为参数把被装饰的 Handler 类写入注册表const queryHandlerRegistry new MapQueryTypeunknown, QueryHandlerClassunknown, unknown(); export const QueryHandler TQuery(query: QueryTypeTQuery) (target: QueryHandlerClassTQuery, unknown): void { const descriptor Object.getOwnPropertyDescriptor(target.prototype, handle); if (descriptor typeof descriptor.value function !isTraceSpanWrapped(descriptor.value)) { TraceSpan()(target.prototype, handle, descriptor); Object.defineProperty(target.prototype, handle, descriptor); } queryHandlerRegistry.set(query, target as QueryHandlerClassunknown, unknown); };2.2 装饰器附带的两项能力从实现可以看到QueryHandler并不只是注册还做了两件额外的事自动埋点若handle方法尚未被TraceSpan包装则先通过TraceSpan()来自 ports/TraceSpan包裹handle让每次查询自动产生一个追踪 Span与 teable 的 OpenTelemetry 观测体系打通。注册表绑定将Query → Handler 类的关系写入模块级 MapgetQueryHandlerToken(query)可按 Query 构造函数反查 Handler 类 token。2.3 Query Bus 如何消费注册表查询总线 MemoryQueryBus.ts 的execute流程如下const queryType (handlerQuery as { constructor: QueryTypeTQuery }).constructor; const handlerToken getQueryHandlerToken(queryType as QueryTypeunknown); if (!handlerToken) { return err(domainError.validation({ message: Missing query handler for ${queryType.name} })); } const handler this.handlerResolver.resolve(handlerToken as IClassTokenIQueryHandlerTQuery, TResult); return await handler.handle(handlerContext, handlerQuery);即总线通过query.constructor反查注册表拿到 Handler 类再交由 DI 容器IHandlerResolver解析出实例执行找不到 Handler 时返回Missing query handler校验错误。同时MemoryQueryBus支持中间件IQueryBusMiddleware管道通过reduceRight把中间件串成洋葱模型包裹真正的 handler 执行。单元测试 GetTableByIdHandler.command-query-bus.spec.ts 演示了完整闭环先CreateTableCommand建表再queryBus.execute(GetTableByIdQuery.create({ baseId, tableId }))查到该表且断言字段的cellValueType正确而查询不存在的表时result._unsafeUnwrapErr().message恰为Table not found。三、Query DTO从原始输入到值对象的类型化转换v2 所有查询 DTO 都遵循同一模式对外暴露一个 Zod*InputSchema与静态工厂create(raw: unknown): ResultQuery, DomainError内部构造器私有。这保证查询对象一旦创建即为不可变、已校验的类型化对象原始输入在边界处就被关在外面。3.1ListTablesQuery输入校验 默认排序 可选分页ListTablesQuery.ts 的输入 Schemaexport const listTablesInputSchema z.object({ baseId: z.string(), q: z.string().trim().min(1).max(255).optional(), // 表名关键字会 trim sortBy: tableSortKeySchema.optional(), // 排序键 sortDirection: sortDirectionSchema.optional(), // asc | desc limit: z.coerce.number().int().positive().optional(), // 支持字符串数字强转 offset: z.coerce.number().int().nonnegative().optional(), });create中的组合逻辑值得注意它体现了若干显式校验规则若传了sortDirection却没传sortBy返回Sort direction requires sortBy的 unexpected 错误若传了offset却没传limit返回Pagination offset requires limit默认排序什么都不传时按createdTime desc排TableSortKey.default()SortDirection.desc()只传sortBy时方向默认ascq会被 trim 后构造TableName值对象作为名称模糊查询条件。ListTablesQuery.spec.ts 验证了这三条默认排序为createdTime/desc且无分页传sortBy: id, sortDirection: desc, limit: 10, offset: 5后正确生成排序与OffsetPagination(10, 5)q: Alpha 会被规范化为Alpha。3.2GetTableByIdQuery与GetRecordByIdQuery最小化标识查询GetTableByIdQuery.ts 只接收baseIdtableId然后分别构造BaseId与TableId值对象GetRecordByIdQuery.ts 接收tableIdrecordId构造TableId与RecordId。这类按标识精确查询的 DTO 用andThen串起两次值对象创建任何一步失败即短路返回校验错误。3.3ListBasesQuery带默认值的内置分页ListBasesQuery.ts 是分页的另一种写法limit默认20、上限100offset默认0在 Zod Schema 层用.default()兜底之后直接映射为OffsetPagination。3.4ListTableRecordsQuery记录查询的完整输入面ListTableRecordsQuery.ts 是输入面最丰富的 DTO其 Schema 几乎覆盖了记录读模型的所有维度。关键设计点/** Default page size for records */ export const DEFAULT_RECORDS_LIMIT 100; /** Maximum page size for records */ export const MAX_RECORDS_LIMIT 1000; const parseJsonInput TSchema extends z.ZodTypeAny(schema: TSchema) z.preprocess((value) { if (typeof value ! string) return value; try { return JSON.parse(value); } catch { return value; } }, schema);parseJsonInput预处理filter、sort、groupBy、search、projection、filterLinkCellSelected/Candidate等复杂结构允许直接传对象也允许传 JSON 字符串HTTP query 场景常用统一在边界解析分页上限limit显式max(MAX_RECORDS_LIMIT)未传时默认DEFAULT_RECORDS_LIMIT 100offset未传默认0同样保留offset 需要 limit的校验互斥约束superRefine强制filterLinkCellSelected与filterLinkCellCandidate不能同时设置fieldKeyType必填决定后续用字段 ID、字段名还是数据库列名作为字段键。此外ListTableRecordsQuery.create(raw, options?)还接受IListTableRecordsQueryOptions可注入recordReadQuerySource如权限裁剪后的可用字段集合与recordSearchAccessPath如生成列generated_tsvector的搜索路径这些选项一路透传给 Handler 与仓储。四、Handler 实战解析三条典型查询链路4.1GetTableByIdHandler规格查询 表操作插件守卫GetTableByIdHandler.ts 的执行链路展示了读侧的标准姿态构造Table.specs(baseId).byId(tableId)规格交给tableRepository.findOne(context, spec)若仓储返回notFound统一翻译为domainError.notFound({ code: table.not_found, message: Table not found })读操作也走插件守卫通过注入的TableOperationPluginRunner.prepare({ kind: TableOperationKind.read, ... })准备插件执行再调用guard()校验——这意味着即便只读查询也可以被表操作插件拦截/增强成功后包装为GetTableByIdResult.create(table)。Handler 通过inject(v2CoreTokens.tableRepository)、inject(v2CoreTokens.logger)等 token 由 DI 注入依赖并用logger.scope(query, { name: GetTableByIdHandler.name })建立带上下文的日志。4.2ListTablesHandler动态规格构建ListTablesHandler.ts 采用safeTry生成器 yield*的错误传播写法const specBuilder TableAggregate.specs(query.baseId); if (query.nameQuery) { specBuilder.byNameLike(query.nameQuery); } const spec yield* specBuilder.build(); const table yield* await handler.tableRepository.find(context, spec, { sort: query.sort, pagination: query.pagination, }); return ok(ListTablesResult.create(table));byNameLike名称模糊规格只在传入q时追加排序与分页直接透传给仓储。这种Query 中组装好的值对象 → Handler 中组装 Spec → 仓储消费的链路正是 ARCHITECTURE.md 职责声明的具体落地。4.3GetComputeActivityHandler读侧快照GetComputeActivityHandler.ts 展示了读取计算活动快照的路径同样先按 baseIdtableId 加载表并通过插件守卫再调用IComputedActivityReader.getByTableId读取快照当活动行为空时会回填baseId以保证快照自洽最终返回GetComputeActivityResult。4.4GetRecordByIdHandler与ListBasesHandler精简直查GetRecordByIdHandler.ts 用TableByIdSpec加载表后调用tableRecordQueryRepository.findOne(context, table, query.recordId, { mode: stored })并把record.not_found归一化。源码注释特别强调记录读取一律使用存储值mode: stored绝不要改成computed——这是与写模型保持一致性的关键约定。ListBasesHandler.ts 则只依赖baseRepository.find(context, query.pagination)把结果包装为ListBasesResult(bases, total, limit, offset)。五、记录过滤体系RecordFilterDto→RecordFilterMapper→ Spec5.1 DTO 结构条件、分组与取反RecordFilterDto.ts 用递归 Schema 定义了过滤树的三种节点条件Condition{ fieldId, operator, value }分组Group{ conjunction: and | or, items: [...RecordFilterNode] }且items至少 1 项取反Not{ not: RecordFilterNode }。value是四选一的可空联合类型recordFilterValueSchema字面量string/number/boolean、字面量数组、日期值对象、或字段引用{ type: field, fieldId, tableId? }用于跨字段比较。日期值对象按模式区分exactDate / exactDateTime / exactFormatDate必须带exactDate与daysAgo / daysFromNow / pastNumberOfDays / nextNumberOfDays必须带numberOfDays并由superRefine兜底校验。操作符全集定义在 RecordConditionOperators.ts共 22 个is / isNot / contains / doesNotContain / isEmpty / isNotEmpty / isGreater / isGreaterEqual / isLess / isLessEqual / isAnyOf / isNoneOf / hasAnyOf / hasAllOf / isNotExactly / hasNoneOf / isExactly / isWithIn / isBefore / isAfter / isOnOrBefore / isOnOrAfter并进一步按字段类型细分text、number、boolean、date、单选、多选、user、link、attachment 各有其合法子集。DTO 层有两个贴心设计一元操作符归一化isEmpty / isNotEmpty等操作符要求value: null若调用方漏传valuenormalizeUnaryOperatorValue预处理会自动补null数组操作符约束isAnyOf / isNoneOf / hasAnyOf / hasAllOf / isNotExactly / hasNoneOf / isExactly要求数组值或字段引用其余操作符不允许数组值。RecordFilterDto.spec.ts 完整覆盖了这些规则例如isEmpty nope校验失败、isAnyOf [a,b]通过、hasAnyOf { type:field, fieldId }通过而标量失败。5.2 Mapper过滤树 → 记录条件 SpecRecordFilterMapper.ts 提供三个核心导出buildRecordConditionSpec(table, filter)递归遍历过滤树条件节点通过field.spec().create({ operator, value })生成领域规格组节点用RecordConditionSpecBuilder按 and/or 组合取反节点用notSpec()包裹sanitizeRecordFilter(table, filter)对过滤树做净化——引用不存在的字段、非法值或非法操作符的节点会被整支剪除返回null而不是报错中断保证脏输入不至于炸掉整个查询replaceCurrentUserTagInFilter(table, filter, actorId)把用户/创建人/修改人字段过滤值中的Me占位符替换为当前actorId实现过滤条件中的我动态解析。5.3RecordSearchV1 兼容搜索输入RecordSearch.ts 承载 V1 兼容的记录搜索输入语义如下[value]在所有可见字段中搜索仅高亮[value, fieldKeys]在指定的逗号分隔字段键中搜索仅高亮[value, fieldKeys, hideNotMatchRow]同上并额外过滤掉不匹配的可见行。RecordSearch.buildHideNotMatchFilter会把搜索词转换为跨字段的contains条件or组合并自动跳过 Button 字段与全字段搜索下的日期字段等不支持场景matchesFieldKey同时支持字段 ID、字段名与数据库列名三种匹配方式。六、ListTableRecordsHandler最复杂的记录查询管线ListTableRecordsHandler.ts 是整个查询层最核心也最长的 Handler约 1066 行其handle方法是一个清晰的多阶段管线值得逐步拆解。6.1 管线总览加载主表TableByIdSpec.create(query.tableId)→tableRepository.findOnenotFound归一化为table.not_found解析查询形状resolve_shape把视图默认值、权限可见字段与请求参数合并成有效过滤/排序/分组解析可见行搜索visible-row search决定搜索范围全部可见字段或指定字段与搜索访问路径default_ilike/generated_tsvector/fallback查询记录tableRecordQueryRepository.find携带分页、orderBy、search、projectionFieldIds、includeTotal等且强制mode: stored转换响应字段键当fieldKeyType ! FieldKeyType.Id时用FieldKeyResolverService.transformResponseKeys把返回字段键换算为名称或列名。全程还通过context.tracer.startSpan(...)打点如teable.table.query.list_records、teable.table.query.records.find并在 finally 中把可观测事件recordRequest/recordError/recordSearchFallback上报给ITableQueryObservability。6.2 视图默认值与请求参数的合并这一步是记录查询与视图语义绑定的关键mergeFilterWithViewDefaults/mergeSortWithViewDefaults视图默认过滤与请求过滤同时存在时用and组合两者请求排序缺失时回退视图默认排序manualSort开启且请求无排序时返回空排序表示手动顺序query.viewId优先作为有效视图若为链接字段候选筛选filterLinkCellCandidate还会回退使用该链接字段配置的filterByViewId。此外enabledFieldIds来自recordReadQuerySource会被用于裁剪过滤、排序、投影中的字段——不在可见/权限范围内的字段引用被静默剔除sanitizeFilterByEnabledFieldIds、resolveSortValues、resolveProjectionFieldIds。6.3 链接字段相关的入站筛选buildQueryPlan会按需叠加四类条件 Spec常规resolvedFilter转换出的条件filterLinkCellSelected来自其他表对当前表的链接已被选中通过IncomingLinkSelectedSpec表达——当前表列非空currentColumnNotNull或宿主表存在外键引用hostReferenceExists若指定了宿主记录 ID则直接取出该记录已关联的 recordIds 用RecordByIdsSpec限定filterLinkCellCandidate链接候选行仅对 OneMany / OneOne 关系生效通过IncomingLinkCandidateSpec区分junctionReferenceAvailable / currentColumnAvailable / hostReferenceAvailable三种模式并叠加链接字段自身配置的过滤等价于 V1 的getFormLinkRecordsselectedRecordIds按 ID 白名单过滤若与候选筛选并存则取反排除已选行。buildLinkCandidatePlan还会识别isJunctionTable数据库表名含junction段以决定候选用中间表引用还是当前列判空实现。6.4 响应结构查询成功后返回ListTableRecordsResult.create(transformedRecords, queryResult.total, offset, limit)其中total仅在includeTotal为真时由仓储统计records是TableRecordReadModel[]只读数组。结果对象同样保持私有构造 静态工厂的模式。七、测试矩阵如何验证查询层queries 目录的测试分三个层次共同守护了上述行为测试文件覆盖层次验证重点ListTablesQuery.spec.tsDTO默认排序、显式排序/分页、名称 trim 规范化ListTableRecordsQuery.spec.tsDTO记录查询输入的 JSON 解析、分页默认值/上限、互斥约束GetTableByIdQuery.spec.ts / GetTableByIdQuery.input.spec.tsDTOID 值对象转换与非法输入拒绝RecordFilterDto.spec.tsDTO操作符/值合法性、一元操作符归一化、节点形状判别RecordFilterMapper.spec.ts映射器过滤树 → Spec 的递归转换RecordSearch.spec.ts值对象搜索输入三元组语义与可见行过滤GetTableByIdHandler.spec.tsHandler查存在/不存在的表GetTableByIdHandler.command-query-bus.spec.ts总线集成Command → Query 全链路验证公式字段值类型ListTablesHandler.spec.ts / ListTablesHandler.command-query-bus.spec.tsHandler/总线排序分页与名称过滤的实际执行ListTableRecordsHandler.spec.tsHandler记录管线各阶段的组装GetRecordByIdHandler.spec.tsHandler单记录读取与 not_found 归一化GetComputeActivityHandler.spec.tsHandler计算活动快照读取ListBasesHandler.spec.tsHandlerbase 分页列表这些测试大多通过getV2NodeUnitTestContainer()见 testkit装配内存版仓储与总线不依赖真实数据库即可跑通应用层逻辑与 PostgreSQL 的真实集成则由packages/v2/e2e与packages/v2/adapter-*各包覆盖。八、扩展阅读路径领域层 Spec 体系domain/table/specs 与 domain/table/records/specs含RecordConditionSpecBuilder、IncomingLinkSelectedSpec、IncomingLinkCandidateSpec等端口定义ports/TableRepository、ports/TableRecordQueryRepository、ports/QueryBus查询总线实现ports/memory/MemoryQueryBus.ts记录查询适配层packages/v2/adapter-table-query-ops-postgres与packages/v2/adapter-table-repository-postgresPostgreSQL 下的具体 SQL 生成与执行写模型对照packages/v2/core/src/commands下的CreateTableCommand等与 queries 形成 CQRS 对称结构。整体来看teable v2 的 queries 层把读模型做成了一个可独立测试、可观测、可扩展的管道输入在 DTO 边界被 Zod 严格校验并值对象化Handler 通过装饰器注册进总线并自动获得追踪查询逻辑通过 Port 抽象与存储实现彻底解耦而最复杂的记录查询则把视图语义、权限裁剪、链接筛选与搜索有机融合为一条可追溯的管线。理解这一层就等于掌握了 v2 核心所有读路径的地基。【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表