让查询结果脱离 ItemsMap 直接渲染)
Lightdash Composer 可视化架构 Step 1用自描述的结果列ResultColumn让查询结果脱离 ItemsMap 直接渲染【免费下载链接】lightdashAgentic BI. Analytics at the speed of code ⚡️项目地址: https://gitcode.com/GitHub_Trending/li/lightdash本文以 Lightdash 仓库中的设计文档 docs/composer-viz-plan/01-design.md 为主体深入讲解Enriched Result Columns富化结果列设计的完整方案自描述的ResultColumn类型设计、格式表达式的保真度审计与 11 项转换器缺口G1–G11、按查询来源划分的列填充规则、消费者改造顺序、兼容性论证、PR 切分策略以及实施过程中对原设计假设的修正与 M1 收敛缺口。读完本文你可以理解 Lightdash 如何让泛型 v2 结果接口ReadyQueryResultsPagerows columns pivotDetails在不依赖ItemsMap、Explore或MetricQuery的前提下驱动正确格式化、正确标注的可视化输出并掌握行存原始值、列携带渲染配方这一单一格式化路径的架构决策。1. 设计背景与目标该设计是 docs/composer-viz-plan 五步计划的 Step 1其上游计划文档 01-enrich-result-columns.md 定义了目标让泛型结果形状自身携带足够的元数据label、format、provenance使得仅持有ReadyQueryResultsPage的消费者就能渲染出正确格式化、正确标注的输出无需ItemsMap、Explore或MetricQuery。这是让可视化栈与查询栈解耦的契约。设计文档的状态是designed研究完成可拆分为 PR由四轮研究支撑列构建清单盘点column-construction inventory、格式表达式保真度审计format expression fidelity audit、provenance 设计对比、消费者改造清单盘点。其研究动机直接来自仓库中的真实痛点ResultColumn原本只有{ reference, type: DimensionType }位于 packages/common/src/types/results.ts。类型定义处的注释早已预见到更丰富的列类型。格式化是服务端按结果页施加的S3 存储原始 JSONLAsyncQueryService.getAsyncQueryResults用查询持久化的fields: ItemsMap构建格式化闭包。指标路径的结果已被格式化但元数据没有以泛型形式出现在线上传输层。后端代码中留有这样一条 TODO当前仓库中位于 AsyncQueryService.tsWe should use the columns data instead of fields. We need to: add format expression to columns type and refactor csv service, etc to use columns instead of fields——随后是对 SQL 查询导出合成仅含 label 的Dimension的临时方案。泛型前端路径把格式化了AI artifact 表格把每个单元格解包为rawSQL runner 直接从/query/{uuid}/results流式传输原始 JSONL完全绕过格式化页面端点Agent 路径同样如此。DuckDB/compose 节点通过LIMIT 1探针派生列即使每一列都原样来自某个引用了完整query_history.fields的语义层节点也拿不到任何字段元数据。2. 核心类型ResultColumn 与 ResultColumnProvenance设计锁定的类型定义设计文档 §1如下其中format字段是关键创新// packages/common/src/types/results.ts export type ResultColumnProvenance { /** Key into the querys fields map (query_history.fields). */ fieldId: string; /** * Which query in a multi-source pipeline the field belongs to. Omitted * for single-query results. Two composer nodes can both expose * orders_status, so a bare fieldId is ambiguous — the MergeFieldOrigin * lesson (mergeQuery.ts:620-635). */ sourceQueryUuid?: string; }; export type ResultColumn { reference: string; type: DimensionType; /** Display label. Absent ⇒ consumers fall back to the reference. */ label?: string; /** * Lightdash format expression: ECMA-376 with in-repo extensions (IEC * bytes, tz-shift for date expressions). MUST be rendered with * formatValueWithExpression, never raw numfmt. */ format?: string; /** The expression cannot encode locale — carried beside it, mirroring * Field.separator / getFieldFormatOverrideProps (formatting.ts:1213). */ separator?: NumberSeparator; /** Escape hatch for the two non-expressible formats: Compact.AUTO and * negative round (magnitude rounding). */ formatOptions?: CustomFormat; /** Temporal grain. Required for QUARTER (no ECMA-376 token) and for * export paths (GSheets) that branch on grain. */ timeInterval?: TimeFrames; /** Resolved output of getFormatterTimezone: whether values shift into the * display timezone. */ shiftsTimezone?: boolean; /** Absent ⇒ no semantic field behind this column (computed DuckDB column, * raw SQL column, table calc, join key). Absence gates interaction * capabilities (drill, underlying data, URLs) off — by design. */ provenance?: ResultColumnProvenance; };当前仓库中 results.ts 的类型已包含这些字段另有一个numericKind字段标注 NUMBER 列在数据源处的表示形式说明设计已部分落地到代码库。研究锁定的四条关键设计决策Provenance 内联在列上、以列为主键而非以字段为主键的 sidecar。Pivot 扇出一个字段 → N 个{field}_{agg}_{groupValue}列在RecordFieldId, …中不可表达代码库中已有两处列主键模式的先例pivotValuesColumns[col].referenceField、MergeTypedColumn.origin。不为泛型结果合成完整ItemsMap。merge 路径是警示性证据合成的Field能通过所有类型守卫isField/isDimension然后静默地出错——URL 菜单解析错误的行键richText/image/colors/showUnderlyingValues未被察觉地丢失无效字段检测永远无法触发。返回undefined的句柄是诚实的降级伪造的字段是等着被发现的那个 bug。format是 Lightdash 表达式不是可移植的 ECMA-376。formatValueWithExpressionformatting.ts在 numfmt 之上叠加了 IEC 字节处理、时区重标注和已注册的 separator locale。column.format的每个消费者都必须调用它绝不裸调 numfmt。参数在列构建时服务端插值。参数值在一次查询执行的生命周期内是固定的修改参数会重新执行所以${ld.parameters.*}占位符在存入column.format之前已通过evaluateConditionalFormatExpression(item.format, usedParametersValues)解析。这使列自包含并删除了前端重新格式化 hackformatCellContent。未插值的模板保留在 chart config 中它本来就在。页面级增补ReadyQueryResultsPageapi.ts需要resolvedTimezoneexecute 响应已有页面原本缺失——没有时间区时间表达式就是错的当前仓库中该字段已出现在页面类型定义里以及fields投影provenance 解析映射来自query_history.fields。设计强调fields应以投影形式下发label、tableLabel、fieldType、type、urls、richText、image、colors、showUnderlyingValues、filters而非完整FieldField.sql是原始 dbt SQL目前并未在 SQL/composer 结果页暴露。这一增补是 Step 2交互能力的挂载点——句柄先落地没有fields时它是惰性的。formatValueWithExpression 的底层实现印证format之所以必须经由formatValueWithExpression渲染可以从源码印证。该函数formatting.ts依次处理BigInt 安全区间检查超出Number.MAX_SAFE_INTEGER的 bigint 直接抛错降级IEC 字节单位匹配表达式中的KiB、MiB等二进制后缀调用compactConfig.convertFn做数值换算后再格式化并处理悬挂小数分隔符stripDanglingDecimalSeparator日期格式带时区时moment.utc(value).tz(timezone).utc(true)把墙钟时间重标注为 UTC让 numfmt 的ignoreTimezone原样渲染无时区分支也必须按 UTC 解析——注释明确说明否则无 offset 的值会按观看者本地时区偏移在跨月边界时出错文本格式裸ID/文本格式直接原样输出绕过 numfmt 对大数字的 Excel 式科学计数法兜底任何异常都return \${value}保证渲染永不中断。3. 转换器缺口G1–G11 保真度审计getFormatExpression→convertCustomFormatToFormatExpressionformatting.ts是列填充的主力转换器审计出 11 项缺口。先修缺口再填充列否则列会与今天服务端格式化的值不一致缺口修复方案G1DEFAULT→ null输出#,##0.###G2ID→ null输出文本格式顺带修复 Excel 科学计数法 IDG3DATE/TIMESTAMP→ nulltimeInterval未读取按粒度输出yyyy/yyyy-mm/yyyy-mm-dd/yyyy-mm-dd, hh:mm:ssQUARTER 和(Z)后缀仍由渲染端经timeInterval处理G4 round 默认分歧PERCENT/BYTES表达式说 2 位小数结构化说 ≤3 尾零截断对齐并扩展目前覆盖不到它的 round-trip 测试formatting.test.ts — 无 bytes 夹具percent 值恰好落在 2 位小数G5 IEC 字节伪表达式KiB字符串匹配 hack可接受但把format文档化为 Lightdash 方言用户 CUSTOM 后缀含KiB时有误报风险G6 负数 round 丢失不可表达 →formatOptions逃生舱G7Compact.AUTO→ nullNUMBER/CURRENCY且被静默丢弃PERCENT/BYTES依赖数值 →formatOptions逃生舱修复静默丢弃分支G8 未转义的 prefix/suffix 引号转义G9YEAR_NUM对转换器不可见会输出#,##0.###→2,021填充时特判G10 货币符号位置在PERIOD_COMMA下的行为host-localeDEFAULT分隔符对齐或接受并文档化分歧G11 Excel COUNT 覆盖#,##0保留在导出路径——一个format装不下 UI 与 Excel 两种变体渲染端约定不放在表达式里null → ∅、undefined → -、布尔经formatBoolean以type BOOLEAN为键、坏时间戳为NaT、抛错时回退String(value)。现有的两个实现formatting.ts 与formatRowValueFromWarehouse已经一致提炼为一个共享 helper。测试种子把 round-trip 夹具扩展为对所有CustomFormatType×{round: undefined, 0, 2, -2}× 所有 separator ×{negative, zero, fractional}值断言applyCustomFormat(v, f) formatValueWithExpression(convert(f), v, locale(f.separator))。G1–G10 会自然作为失败项浮出。缺口修复的爆炸半径转换器不只是一个未来的填充主力——它今天就有活的消费者G1–G3 落地瞬间就会翻转它们的行为。全局修转换器仍然是对的只修列的变体反而会重新制造出 Step 1 要消灭的那种分歧但 PR 1 必须带着 golden/snapshot 覆盖故意地做出这些变更getFieldFormatOverridePropsformatting.ts——DEFAULT/ID/DATE/TIMESTAMP 的格式覆盖目前走结构化formatOptions分支因为转换器返回 nullG1–G3 之后它们改走表达式分支改变展开到查询结果字段上的内容。经getExcelFormatExpression的 Excel 导出——DEFAULT 格式字段将获得显式#,##0.###numFmt 而非 General。COUNT#,##0守卫G11已预见到 count 场景普通数字单元格会变化。在 ExcelService 测试中断言新 numFmt。convertCustomMetricsToYaml——writeback 开始在之前省略format键的位置输出format: #,##0.###YAML diff 抖动且与 §5 的VirtualViewCoder隐忧同类地构成幂等性隐患。用 snapshot 钉住新输出并验证 writeback 往返emit → parse → emit 稳定。fields.ts的自定义指标格式比较两侧都做转换构造上安全——无需动作。4. 按查询来源划分的填充规则列在写入时持久化进query_history.columns读取时原样读回——富化发生在写路径。各来源的规则来源规则位置指标查询itemsMap已是runQueryAndTransformRows的参数且未透视的列键就是字段 id。getUnpivotedColumns增加一个itemsMap参数当itemsMap[key]存在时 →label getItemLabel(item)format getFormatExpression(item)缺口修复后、参数插值后separator/formatOptions/timeInterval/shiftsTimezone取自 itemprovenance { fieldId: key }。这一条规则就是指标路径的全部算法。getUnpivotedColumns.ts 调用点透视值列把valuesColumnData.values()不是.keys()itemsMap传入getPivotedColumns。每个{field}_{agg}_{groupValue}列provenance.fieldId referenceFieldformat取自源指标label由指标 label pivotValues[].formatted组合已经过完整格式化计算。索引/直通列自动继承按引用拷贝。硬编码的type: NUMBER对 MAX-of-timestamp / boolean ANY 是错的——经convertItemTypeToDimensionType修复属于行为变更单独分期。getPivotedColumns.ts原始 SQL / SQL 图表虚拟视图 item map 到达与fieldsMap相同的接缝所以label friendlyName(reference)免费获得。无 provenance——虚拟视图维度不是语义字段标记它们会复活 fake-field 失败模式。format保持 undefined。SqlQueryComposer.ts, virtualView.tsDuckDB composecomposer 节点不做元数据推断2026-08-27 重新定范围PROD-10681。终节点为语义层查询的管道原样输出该查询自己的富化结果集。DuckDB 后处理节点是任意复杂 SQL其列只携带 DuckDB 诚实知道的东西——reference 探针类型——不从上游节点继承 label/format/provenance。契约是接口不是元数据每个节点的结果集都是同一ResultColumns形状走同一格式化管道。此前把探针列与引用列做名称/类型匹配的方案被放弃——匹配即伪造元数据SUM(revenue) AS revenue会误报正是本设计禁止的 fake-field 失败模式若将来需要显式继承必须是用户在管道上声明的映射绝不推断。用测试钉住单节点[semanticLayer]管道必须终结于指标查询自己的结果集而不是 DuckDBSELECT *包装。runDuckdbSqlQuery 探针PROD-10681Compose 合并最廉价的落地原型点在构建originalColumns的确切位置compiledMerge.itemsMap[reference]label/format和typedColumns[].originprovenance都在手边、目前被丢弃。仓库合并自动获得指标路径规则。AsyncQueryService.ts 合并构建点外部数据源仅 DuckDBDESCRIBE——裸列。二等数据源后续补类型。ExternalSourceService.ts静态自动补全结果完整 field 对象在作用域内——平凡。AsyncQueryService.ts两个生产者侧隐患缓存命中把旧列拷进新行。部署后的缓存 TTL 期间新行会服务未富化的列。选择接受所有消费者本来就必须容忍undefined而不是 bumpCACHE_VERSION会造成仓库负载尖峰。保留窗口 32 天。两种悬空状态无 provenance正常、静默与provenance 解析失败源查询已过期都必须静默降级——后者绝不复用field not found in dbt project警告路径。5. 消费者改造按序所有消费者改造在填充之前都是惰性的因此可以早于、晚于或交错于填充落地导出高价值、自包含。用columnsToItemsMap(columns)合成替换SQL_QUERY_MOCK_EXPLORER_NAME字符串比较分支携带label ?? friendlyName(reference)和format。因为formatItemValue先检查格式表达式、getExcelFormatExpression读取item.format——ExcelnumFmt就是ECMA-376零转换——CsvService/ExcelService/PivotTableService/GSheets 无需变更。先例SchedulerTask 中的buildItemMapFromColumns。命名阻塞项GoogleDriveClient.formatCell按timeInterval和 TIMESTAMP-vs-DATE 分支——所以列上要有timeInterval。chart config 的用户customLabels必须继续压过column.label测试断言。golden-file 测试。不要试图对四个导出服务做完整的 ItemsMap→columns 重写。Labels低。getAiArtifactTableConfig的label: column.label ?? column.reference一行注意它还喂给 saved-SQL-chart 创建——唯一的写路径副作用SqlChartResultsRunner/SqlRunnerResultsRunnerFrontend停止丢弃originalColumns元数据IResultsRunner增加getColumns(): ResultColumn[]TableDataModel.getResultOptions回退label ?? column.label ?? key。格式感知单元格渲染器中。扩展 TanStackColumnMeta加resultColumn?: ResultColumnuseVirtualTable/useTableDataModel设置它getValueCell在format存在时分支到formatValueWithExpressionformatRowValueFromWarehouse保留为回退。时区隐患页面无resolvedTimezone时客户端时间格式化会偏移到观看者的时区——页面级时区是时间列的前置条件。然后 AI artifact 表格保留raw并经此渲染design B——保留 JSON 单元格与 copy-raw。Agent 预览中、eval 敏感。CSV 值保持 raw模型会把值重新引号化进 SQLpercent 显示 ×100 会腐蚀推理。只用 label format 丰富columnSummary行让语义作为元数据到达模型。snapshot 测试钉住这个面。图表格式化器高、最后。新可视化栈今天只能表达 percent/SI/compactCartesianChartDataModel 两分支 switch、PieChartDataModel 硬编码默认、BigNumber 仅 compact。改走formatValueWithExpression优先级为display-config 压过column.format用户显式的 Percent 选择覆盖列。启用sqlRunnerPivotQueries.ts中四处一行改动Object.values(pivotResults.columns)取代裸 reference——自然时机是退役废弃的VizColumn别名。排在 (3) 之后落地保证同一查询的表格与图表一致。性能注记宽透视下每个 tooltip 回调做表达式格式化需要每列一个 memoized formatter。6. 兼容性论证已验证仅增量ResultColumn出现在所有响应中仅一个请求体VirtualViewAsCode.columns例外那里未知的可选属性被忽略tsoa 未开noImplicitAdditionalProperties。oasdiff breaking把新增可选属性归类为非破坏性release-safety marker 从 migrations/restApi/mcpApi/config 计算无一触发。无需 release-safety 声明按 CLAUDE.md声明了反而是错的。无需迁移——jsonb 列已存在。本地运行pnpm generate-api验证pre-commit hook 会取消暂存生成产物设计如此。旧的query_history行无需读时默认值可选字段读作undefined项目风格是消费者侧默认。若将来想要单一接缝convertDbQueryHistoryToQueryHistory覆盖所有读路径。VirtualViewCoder幂等性保持其transform()输出最小{reference, type}形状或在isEqual前归一化否则每个已提交的 virtual-view YAML 会永远报告 UPDATE。Payload 大小列随行出现在每个结果页且多两处持久化query_history、pre-aggregate materializations宽透视会倍增 label/format 字符串。需测量若有压力考虑对透视值列省略format改用源列 provenance。Snapshot 抖动ProjectService.mock.ts的expectedColumns在AsyncQueryService.test.ts中被断言 8 次——填充 PR 更新它们仅类型 PR 不更新。7. PR 切分与 M1 收敛缺口原始 PR 序列各自独立落地为绿1–2 对用户惰性类型 转换器缺口修复G1–G4、G8、G9 最低集 round-trip 测试网格。指标路径 透视路径填充持久化富化列 snapshot 更新列构建时的参数插值。导出修复columnsToItemsMap golden-file 测试 →首个用户可见收益格式化、带标签的 SQL/CSV/Excel 导出。Labels 穿过可视化栈消费者项 2。页面级resolvedTimezone 格式感知单元格渲染器 AI artifact 表格消费者项 3。DuckDB 传播composer 终节点继承元数据→composer 结果看起来像 Lightdash 数据——Step 1 退出标准。AgentcolumnSummary富化消费者项 4。 8.Step 2 边界结果页fields投影 provenance 消费者图表格式化器消费者项 5。线性映射Standardize 项目 M1PR1 ≈ PROD-9829/9830PR2 ≈ PROD-9831PR3 ≈ PROD-9833 PROD-9835PR6 关闭 M1 的 composer 半边PROD-9832诚实的 SQL 列元数据即 §4 的 SQL 路径规则。实施发现PR 1–2两条假设未能在代码中存活设计文档 §72026-08-27 修订首先给出对 §4 的两处修正参数插值需要先有迁移。原设计假设usedParametersValues在列构建时于作用域内。NATS 启用的队列路径上并非如此worker 完全从query_history行重建RunAsyncWarehouseQueryArgsbuildWarehouseQueryArgs而持久化的只有request_parameters调用方提供的值不含已解析的项目默认值——知道已解析值的 composer 已经不存在了。前置条件把 composer 的getUsedParameters()输出持久化到新的query_history.used_parametersjsonb创建时穿引到runQueryAndTransformRows在getResultColumnMetadataFromItem中插值。在此之前填充完全省略参数依赖的格式绝不存储未插值的占位符——它会在渲染时抛错并回退String(value)。SQL 路径得不到任何免费午餐。原设计声称label friendlyName(reference)免费来自指标路径变更。不成立SqlQueryComposer以虚拟视图字段 id${table}_${column}为键构建 fields map而原始 SQL 仓库列以裸列名为键items-map 查找永远落空SQL 列碰巧保持裸列状态。PROD-9832 必须把规则变成显式代码label friendlyName(reference)且虚拟视图维度绝不带 provenance守卫防止未来键匹配悄悄盖上 fake-field provenance。决策行是原始值列携带渲染配方今天存在两种行方言页面端点经服务端按页格式化器提供的格式化ResultValue{raw, formatted}以及 SQL runner 直接从/query/{uuid}/results流出的原始 JSONL完全绕过格式化。有了自描述列接口契约即行是原始值列携带渲染所需的一切format 表达式 separator formatOptions timeInterval只经formatValueWithExpression渲染加上页面级resolvedTimezone以及——一旦持久化——参数值。服务端格式化的{raw, formatted}形状和按页格式化闭包是遗留现有消费者继续工作但新消费者不得依赖服务端格式化的值M2共享格式化器PROD-9834收敛现有消费者。这是单一格式化路径决策不允许逐消费者反复重审。M1 缺口清单接口感觉对了标准一个引擎指标层、原始 SQL、composer在契约内当且仅当它的结果页携带的列能让消费者在不了解引擎的前提下 label、format、chart。剩余工作缺口位置工单已用参数值未持久化 → 队列路径上参数格式无法插值query_history迁移 QueryHistoryModel 创建点 getResultColumnMetadataFromItemPROD-10680Composer 管道必须说这个接口——Step 1 退出标准重新定范围为不推断钉住单节点管道原样服务语义结果集、DuckDB 节点服务诚实裸列PROD-10681结果页缺resolvedTimezone——仅凭页面无法完成时间渲染ReadyQueryResultsPageexecute 响应已携带PROD-10682合并结果丢弃手边已有元数据originalColumns构建点compiledMerge.itemsMaptypedColumns[].origin都在作用域内却被丢弃PROD-10683透视值列硬编码type: NUMBER——对 MAX-of-timestamp / boolean ANY 是谎言getPivotedColumns经convertItemTypeToDimensionType行为变更单独分期PROD-10690SQL 列碰巧是裸的规则从未显式化§4 的 SQL 路径规则变成故意代码PROD-9832修订后的排序填充不得先于参数前置条件再落地——列应自包含地出生而不是事后修补。M1 剩余工作的修订顺序(1)used_parameters持久化并入填充 PR让富化完整出厂(2) SQL 路径规则 合并路径 页面resolvedTimezone小、独立(3) composer 接口钉住PROD-10681重新定范围为不推断——小(4) 透视类型诚实化分期的行为变更然后 M2 的导出/格式化器翻转来证明解耦成立。8. 小结这套设计解决了什么把 01-design.md 放回 composer-viz-plan 计划索引 中看Step 1 承担的是接口一层的建设查询栈composer 管道→ 接口泛型 v2 结果形状→ 可视化栈。其核心贡献可以浓缩为三点自描述列类型label/format/separator/formatOptions/timeInterval/shiftsTimezone/provenance七个可选字段全部增量式加入ResultColumn配合消费者侧默认的项目风格实现零迁移、零破坏格式保真的工程化验证用applyCustomFormat(v, f) formatValueWithExpression(convert(f), v, locale)的 round-trip 测试网格把 11 项转换器缺口G1–G11转化为可执行的失败项并显式评估了修复对既有消费者的爆炸半径诚实降级的原则宁要返回undefined的句柄不要能通过类型守卫的伪造字段——这条原则同时约束了 provenance 形状列内联而非 sidecar、DuckDB 节点不推断、只探针和 SQL 路径显式规则 防 provenance 守卫。而 §7 的修订部分则展示了设计文档的另一种价值当假设参数作用域、SQL 路径免费继承在 PR 1–2 中被代码证伪时文档就地记录修正、锁定行原始、列携带配方的单一格式化路径决策并重新排出剩余工作的优先级——这使该文档成为 M1Standardize 项目收敛过程的活契约。【免费下载链接】lightdashAgentic BI. Analytics at the speed of code ⚡️项目地址: https://gitcode.com/GitHub_Trending/li/lightdash创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考