mcpField
M
metabase-data-app-semantic-layer

metabase-data-app-semantic-layer

github.com/metabase数据与数据库49.9k816 次安装今天更新
代码仓库

关于 metabase-data-app-semantic-layer

面向 Metabase Data App 构建者的语义层开发规范,指导使用生成的 Schema、查询与操作定义及 SDK 组件构建 React 界面。它强调将查询逻辑与呈现分离,并避免臆造字段、指标和可视化数据。

主要功能

  1. 01以生成的 Schema 作为数据上下文
  2. 02通过 defineQuery 和 defineAction 定义查询与操作
  3. 03使用语义层对象和 SDK 查询辅助函数
  4. 04将运行时筛选状态与基础查询分开
  5. 05用 Metabase 查询结果驱动可视化

适用场景

  1. 01基于语义层构建 Data App 界面
  2. 02为筛选控件定义动态查询
  3. 03使用 Metabase 数据呈现图表和指标

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/metabase/metabase --skill metabase-data-app-semantic-layer

安装后在 Claude Code 中输入/metabase-data-app-semantic-layer确认已加载。

附带文件references/filter-ui-patterns.mdreferences/visualization-settings.md

详细介绍

Metabase 数据应用语义层

核心规则

保持语义层与展示层分离。

  • 所有 Metabase 上下文都必须来自生成的 schema 文件,通常是 src/metabase.data.ts 或 src/*.metabase.data.ts(其运行时对象,以及当某个条目有上下文要提供时开头的 /* metadata: {...} */ 块),而应用集合中各文件的写入依据必须来自 npm run print-resources。绝不来自仓库中的 Metabase YAML,也绝不来自你自己的 API 调用。
  • 在构建 React UI 时,不要通过 MCP 工具发现数据、创建 Metabase 内容、创建表或编辑语义层。
  • 从 @metabase/embedding-sdk-react/data-app 导入数据应用查询辅助函数。
  • 每个查询都是根级 queries/ 目录中的 defineQuery(...) 具名导出,每个操作都是根级 actions/ 目录中的 defineAction(...) 具名导出,二者都与 package.json 并列。在编写第一个 hook 调用之前创建这两个目录;模板已自带它们,每个目录中都有一个 README。这些 hook 会在编译时强制执行此规则:useMetabaseQuery、useMetabaseQueryObject 和 useAction 会拒绝内联对象、satisfies MetabaseQueryOptions 对象以及定义的展开副本。错误信息为 Property 'definedWithDefineQuery' is missing(或 'definedWithDefineAction');修复方法始终是将该对象作为定义移入 queries/ 或 actions/ 并导入它,绝不要用类型断言。
  • 每个 savedQuestionEntityId 和 copiedActionEntityId 都指向应用集合中的一个文件(参见 将每个查询和操作写入应用的集合)。在重构和重命名过程中保留它,绝不要将它复制到另一个定义,并且在其文件存在时绝不要移除它。
  • 优先使用生成的 schema 对象,而不是原始 ID 或字符串。为顶层表对象提取本地常量。
  • 绝不要在应用代码中手写 DatasetQuery/MBQL 对象。不要将内联查询对象(如 { type: "query", query: { "source-table": table.id } })、原始 source-table 子句、原始字段 ID、裸表 ID 或指标 ID 传递给 SDK 组件、useMetabaseQuery 或 useMetabaseQueryObject。优先使用生成的表和指标 schema 对象;对于简单的表源查询,显式的源引用(如 { type: "table", id: table.id })也是有效的。
  • 使用 source: schema.tables.<name>、生成的 fields、生成的 segments、生成的 measures、aggregations 中生成的指标、生成的指标 dimensions、filter(...)、breakout(...)、orderBy(...),以及诸如 aggregations.count() 和 aggregations.sum(...) 的 aggregations 辅助函数来构建查询。不要使用 source: schema.metrics.<name>;指标是聚合表达式,不是查询源。
  • 不要将现有的已保存问题用作 useMetabaseQuery 或 useMetabaseQueryObject 的源。类型化 schema 不会暴露 schema.questions,也不支持 question-collections,因为应用的资源无法复制现有的已保存问题。
  • 优先使用语义丰富的表查询,而不是浅层的表转储。当经过整理的表的 measures、segments、filters 和 breakouts 能让生成的应用更有用时,请使用它们。
  • 优先使用语义层定义,而不是 React 侧的推断。如果 schema 中有某个概念的 segment 或 measure,请使用它,而不是从原始行重新创建该概念。二者都只属于静态查询——动态的第二个参数不能接收它们,参见 静态和动态查询部分。
  • 筛选 UI 必须默认显示数据。空控件、"All" 选项和不完整的自定义范围应产生无筛选,而不是阻止查询或显示空白仪表板。
  • 不要硬编码分类筛选选项值。生成的 schema 字段只能证明该字段存在,不能证明存在哪些值;在运行时使用筛选所应用的同一个生成 schema 字段,从 Metabase 查询选项。
  • 仪表板级筛选应明显影响每个兼容的卡片、表格、KPI 和趋势。如果某个筛选只能应用于一个查询,请在 UI 中使该范围显而易见;不要显示重复或无效的日期控件。
  • 实体筛选(其中存储的值是 id/key,而 UI 显示标签)必须使用单个可搜索的组合框。单击/聚焦必须在输入之前立即打开选项列表。在运行时查询选项、搜索标签,并存储原始值。绝不要将实体筛选渲染为 <select>;普通 select 仅用于用户明确提供的简短封闭枚举。
  • 使用来自 @metabase/embedding-sdk-react/data-app 的 DateRangePopover 来实现自定义日期范围:它包装应用自己的触发元素,并在其下方打开一个 Metabase 风格的日期范围日历。触发器仍属于应用——像其他筛选控件一样为其设置样式——而来自同一条目的 useDateFormatter() 生成其标签。它随 SDK 一起提供,不需要依赖,也不需要 CSS 导入。DateRangeCalendar 是同一个内联日历,适用于应用已有容器的情况。不要为日期范围安装日期选择器库——不要 react-datepicker、react-day-picker、flatpickr,也不要 UI 套件的选择器(@mui/x-date-pickers、antd、rsuite……)。也不要使用原生 <input type="date">:其占位符和日历弹出框由浏览器控制,通常显示 mm/dd/yyyy,并且无法可靠地设置主题。
  • 绝不要用 new Date("YYYY-MM-DD") 构建日期标签——仅日期字符串会按 UTC 解析,在格林尼治以西会显示前一天。使用 useDateFormatter() 中的 formatDateRange / formatDate,它们按本地时间解析并按实例的 locale 格式化。
  • 仅在 SDK 日历未覆盖的情况下(例如单个日期或日期时间选择)才使用第三方日期选择器;react-datepicker 是默认选择。然后导入其样式表(react-datepicker/dist/react-datepicker.css),如有需要,为应用的视觉风格添加少量 CSS 覆盖,并传递 Date | null——对于不完整的范围,绝不要传 new Date("") 或其他无效日期;显式地类型化严格回调参数,例如 onChange={(date: Date | null) => ...}。
  • 日期栏默认必须包含 Custom,且放在最后:时长预设、All time,然后是 Custom。仅当用户明确要求只用固定预设或不要日期范围控件时,才省略 Custom。
  • 绝不要发明聚合或 measure 对象,例如 { name: "count" } 或 { name: "sum", field: ... }。使用生成的表 measures 或导出的聚合辅助函数。
  • 只渲染 Metabase 返回的值或对返回值进行的确定性转换。不要发明 KPI 值、趋势、标签、状态、评级、时间戳、排名、洞察、分段或图表系列。
  • 不要自定义渲染含糊的业务字段,例如 margin、rate、score、percent、health、risk 或 efficiency。除非语义层单位明确支持,否则不要添加 %、乘以 100、用颜色编码或渲染星级;请使用 SDK 表格/图表、省略该字段,或请求整理。
  • 可视化数据必须通过 useMetabaseQuery 或 useMetabaseQueryObject 搭配 InteractiveQuestion/StaticQuestion 从 Metabase 获取。不要硬编码可直接用于图表的数组、示例数据、演示值或符合 schema 形状的模拟值。
  • 使用 InteractiveQuestion/StaticQuestion 渲染图表。何时允许改用自定义可视化,以及在两者中使用哪一个,由设置技能的 渲染图表:Metabase 优先 决定。
  • useMetabaseQueryObject(...) 返回 { query, error, isLoading }。只将 query 属性作为 card={{ query }} 传递给 InteractiveQuestion 或 StaticQuestion;绝不要将整个 hook 结果作为 card.query 传递。
  • useMetabaseQuery().rows 是以键为索引的对象,不是元组数组。绝不要读取 row[0] / row[1],也绝不要用 as unknown as [string, number][]、DisplayRow 或其他元组类型断言来掩盖这一点。如果 TypeScript 提示属性 0 不存在,那是在捕获一个真实的 bug。对于类型化的 data.rows,使用字面量键,例如 row.count 或生成的字段名,例如 row[ordersTable.fields.createdAt.name]。将 data.columns 与 rawRows 搭配使用,或在显式收窄键之后使用;不要用 data.columns 中任意的 string 值来索引类型化的行。
  • 不要将查询对象类型断言为 Parameters<typeof useMetabaseQuery>[0] 或 DefinedQuery。那会抹去生成的表/指标验证和定义契约。在定义处使用 defineQuery<typeof table>(...) 验证表归属;hook 接收该导出,不使用泛型。
  • 不要用 ReturnType<typeof filter>[] 或 push(...) 构建共享筛选数组;这可能使重载推断失效。在组件之间传递原始筛选状态,并使用展开内联构建每个查询的 filters: [...]。
  • 将运行时状态排除在 queries/ 中的基础查询之外。值来自控件的子句——选中的计划、日期范围、搜索框——属于 useMetabaseQuery/useMetabaseQueryObject 的第二个参数,而不属于查询。参见 “静态和动态查询部分”。
  • 不要在带有 aggregations 和 breakouts 的查询中包含 fields;breakouts 决定分组结果列。仅对行选择查询使用 fields。
  • 在渲染字段之前,验证它存在于生成的 schema 对象中并且由查询返回。不要根据 Metabase API、业务直觉或旧模拟数据猜测表键、字段键或列名;只使用实际在 src/metabase.data.ts 中输出的条目。
  • 避免不受支持的时效性或运营性声明,例如 "real-time"、"live"、"understaffed" 或 "risk",除非返回的数据或经过整理的语义层定义支持它们。
  • 在声称工作完成或准备最终交接之前,运行 TypeScript 仅类型检查并报告命令/结果。如果检查失败,在任何最终总结之前修复类型错误。

生成 Schema

如果 schema 文件已存在,直接使用它。如果缺失或已过期,请将 schema 生成视为针对此数据应用的语义层策划,而不是机械导出。

在生成之前选择导出范围:

  1. 遵循明确指定的范围。否则,根据应用的用途推断所需的内容类型:表、精选指标或操作。例如,“show orders” 需要表;行数和求和也可以使用表聚合。
  2. 选择能覆盖这些需求的最窄受支持范围,使用请求或项目上下文中的 collection ID 和数据库名称或 ID。当库类型明确但更窄的 collection 未知时,使用该库的整棵树。
  3. 只询问选择范围所需的上下文,例如当请求需要数据库范围但未指明时,询问使用哪个数据库。一旦确定范围,简要说明并直接生成,无需等待确认。

范围参数:

  • include-data-library=true 用于整个 Library / Data 树。
  • include-metric-library=true 用于整个 Library / metrics 树。
  • library-collections=<id-or-entity-id>[,<id-or-entity-id>] 用于特定的 Data 或 metrics 库子集合。
  • include-actions=true 用于应用可以运行的操作,位于 schema.actions 下。当与 database=<name-or-id> 组合使用时,仅包含该数据库的操作。
  • database=<name-or-id> 当应用应使用某个数据库中的表时。请与库范围分开使用;API 会拒绝该组合。

当应用同时需要表和精选指标时,组合使用库范围。

当应用需要 schema.actions 下的任何已保存操作时,使用 include-actions=true;它会包含所有可读操作,除非 database 将其限定为一个数据库。它可以与 library-collections、include-data-library 或 include-metric-library 组合使用,以便一个 schema 可以包含选定的表/指标以及所有可读操作。

如果用户要求任何类似变更的流程,例如创建、更新、删除、提交、审批、执行操作或运行写操作,请在 typed-schema URL 中包含 include-actions=true。即使用户指定了某一个具体操作,也要这样做,因为操作只能通过生成的 schema 被发现。

Metabase URL 和 API key 位于 repo-root 的 .env.local 中,分别为 DATA_APP_MB_URL 和 DATA_APP_MB_API_KEY(每个 repo 一个文件,通常位于 app 目录往上两级,而不是在 app 目录中)。该 key 必须属于 Administrators 组: typed schema 只会应答管理员,任何其他 key 都会得到 403。下面的命令会 source 该文件,因此 shell 会直接将值替换进 curl —— 你永远不需要自己读取、提取 或处理凭据。

绝不要要求用户将 API key 粘贴到聊天中,也绝不要 cat / echo .env.local —— 它已被 git 忽略,并且可能包含其他密钥,因此其 内容必须保持在对话之外。对它执行 source,以便 shell 使用这些 值而不暴露它们。如果 $DATA_APP_MB_API_KEY 或 $DATA_APP_MB_URL 在 source 后为空 或仍为默认的 mb_replace_me 占位符,请让 用户自行添加真实值,然后继续。

从 repo-root 的 .env.local 中 source 凭据并生成限定范围的 schema。下面的示例从 Data 库导出表数据;请将其 查询参数替换为上面选择的范围:

ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$ROOT" ]; then
  echo "Not inside the connected git repo — cd into it first." >&2
  exit 1
fi

(
  source "$ROOT/.env.local" 2>/dev/null
  # Fail early (before curl) if either var is missing or placeholder-only.
  if [ -z "$DATA_APP_MB_URL" ] || [ "$DATA_APP_MB_URL" = "mb_replace_me" ] ||
     [ -z "$DATA_APP_MB_API_KEY" ] || [ "$DATA_APP_MB_API_KEY" = "mb_replace_me" ]; then
    echo "Set real DATA_APP_MB_URL / DATA_APP_MB_API_KEY in repo-root .env.local" >&2
    exit 1
  fi
  curl \
    -o src/metabase.data.ts \
    -H "x-api-key: $DATA_APP_MB_API_KEY" \
    -H "Accept: text/typescript" \
    "$DATA_APP_MB_URL/api/typed-schemas/v1/typescript?include-data-library=true"
)

生成文件中的某个条目在带有可提供的上下文时会先以一个 /* metadata: {...} */ JSON 块开头,再是其运行时属性,例如字段的显示名称、描述和语义类型,表的数据库、schema 和真实名称,以及指标的描述、过滤器和是否已验证。它是注释,因此永远不会进入 bundle,应用也永远不会导入它;请阅读它,并且绝不要编辑或重新格式化该文件。 导出成功后,请验证 schema 是否包含所请求应用所需的全部实体。如果缺少任何实体,请使用可用上下文修订范围,或在构建 UI 之前请求缺失的上下文。

如果 schema 生成失败,不要隐藏、转述掉该错误,也不要重试以绕过它。请将类型化 schema 错误呈现给用户,并在存在时包含失败的 id、名称和消息。

将每个查询和操作写入应用的集合

数据应用运行的每个查询和操作都会经过应用自身集合中的副本:对该集合的读访问权限让查看者可以运行应用的卡片,但他们只能看到自己已经可以读取的表中的数据,因为应用授予的是该集合的权限,而不是这些表的权限。请将每一个都声明为根级目录(与 package.json 同级)中的命名导出 —— defineQuery(...) 放入 queries/,defineAction(...) 放入 actions/。CLI 只扫描这两个目录,因此 src/queries/、src/actions/ 或任何其他源目录下的定义永远不会被发现。发现范围涵盖 .js、.jsx、.ts、.tsx、.cjs、.cts、.mjs 和 .mts。

import { defineAction, defineQuery } from "@metabase/embedding-sdk-react/data-app";
import schema from "../src/metabase.data";

// queries/revenue.query.ts
export const RevenueQuery = defineQuery({
  savedQuestionEntityId: "<entity ID of its saved question>",
  source: schema.tables.orders,
});

// actions/orders.action.ts
export const CreateOrder = defineAction({
  copiedActionEntityId: "<entity ID of its copied action>",
  action: schema.actions.createOrder,
});

每个定义都由序列化的 Metabase YAML 支撑,你需要将其写入仓库的 collections/data_apps/ 目录,也就是 data-apps 命名空间的集合所在的位置,由 Metabase 导出的形式。在拉取仓库之前,Metabase 中不会有任何变化;拉取会将这些文件作为序列化内容加载到应用的集合中,就像仓库中存放的其他所有内容一样,因此请写入这些文件,而不是在该集合中手动创建任何内容。从应用的目录 data_apps/<slug>/ 来看,该目录是 ../../collections/data_apps/。

data_apps/<slug>/data_app.yaml                     collection: <entity ID of the app's collection>
collections/data_apps/<collection>.yaml            the app's collection, a root collection of the data-apps namespace
collections/data_apps/<collection>/<name>.yaml     a saved question per query, copies of the metrics they use, and a copy of each action the app runs

<collection> 是集合经 Metabase slug 化后的名称:Data App: Sales 对应 data_app__sales。拉取和 CLI 通过内容而非路径来查找应用的文件:即 manifest 中指定了 entity_id 的集合,以及 collection_id 为该集合的卡片和操作。

请以 Metabase representation 格式编写 YAML。在写入或编辑 collections/data_apps/ 下的任何文件之前,请加载用于读写 Metabase representation YAML 的技能(使用技能发现),并对该目录下的每个文件 —— 集合、卡片和操作 —— 遵循该技能及其格式规范。如果没有此类技能,请使用 npx skills add metabase/agent-skills/skills --skill metabase-representation-format 安装;如果仍无法加载,请停止并告知用户,而不是写入这些文件。以下任何一项都不能替代它:来自早期应用的 YAML(包括 git 历史中的文件)、针对某个字段搜索规范,或 npm run check-resources 通过,因为其 schema 验证检查的是 schema,而不是格式的约定。每个卡片和副本都根据 npm run print-resources 打印的内容写入,如下所示;格式技能则是你编写它的方式。绝不要猜测字段的形状。使用 npx representations generate-entity-id(多个时使用 --count <n>)从应用自身的 dev 依赖中生成每个新实体 ID;绝不要凭空编造、复用来自另一个应用的 ID,或在其副本上保留源实体的 ID。

  1. 集合。 每个应用一次,并且在 collections/data_apps/ 下的任何其他内容之前,将 collections/data_apps/<collection>.yaml 写入为一个名为 Data App: <app name> 且带有 namespace: data-apps 的集合,并将其实体 ID 作为 collection: <id> 放入 data_app.yaml。脚手架通常随 manifest 一起写入它:当 data_app.yaml 已经指定了一个其文件存在的集合时,跳过此步骤。print-resources 在 manifest 指定该集合之前拒绝运行,因为它会将已保存的问题打印到其中。它必须是该命名空间的根集合:没有 parent_id、personal_owner_id、type、authority_level 或 archive_operation_id,并且不是 is_remote_synced、is_sample 或 archived。拉取会拒绝没有 collection: 的 manifest,以及其集合在仓库中没有文件的 manifest。

  2. 打印资源是根据什么写出来的。 每个 defineQuery 都带有其 savedQuestionEntityId,每个 defineAction 都带有其 copiedActionEntityId,在你打印之前,用 npx representations generate-entity-id 生成:保存的问题会连同定义的 ID 一起打印,因此 print-resources 会拒绝没有该 ID 的查询并指出其名称。然后 npm run print-resources(可选地带上一个文件,npm run print-resources -- queries/revenue.query.ts)会把应用的定义发送到 Metabase,使用仓库根目录 .env.local 中的 DATA_APP_MB_URL 和 DATA_APP_MB_API_KEY,并以 JSON 打印序列化所写出的所有内容,每个引用都按名称或实体 ID 而非数字 ID:

    • queries:每个定义的 export、file 和 savedQuestionEntityId,Metabase 为其写出的保存的问题,即 entity(以 export 命名,位于应用的 collection 中,带有该实体 ID,保存着 dev preview 运行的查询),以及它所聚合的 metrics 的实体 ID;
    • actions:每个定义的 export、file 和 copiedActionEntityId,以及源 action,即 entity;
    • metrics:查询所聚合的 metrics,每个都作为 entity。

    action 或 metric 旁边的数字 id 只表明它是哪个源;应用的 collection 中没有任何文件持有它。

    该 export 仅响应管理员,因此 API key 必须是 Administrators 组中的 key。使用任何其他 key 时,命令会以 403 失败:停下并告知用户,绝不要以其他方式写这些文件。

    无法构建或复制的条目会有一个 error 代替(例如查询中指名的不存在的字段、属于某个 model 的 action,或读取另一张 card 的 metric 或 action);停下并告知用户。任何定义变更后再次运行它,并更新它影响的文件。

  3. 每个查询一个保存的问题。 将打印出的 entity 原样写入 collections/data_apps/<collection>/<name>.yaml。它的名称、type、display、creator_id、collection_id、实体 ID 和 serdes/meta 都已设置好;不要添加任何内容。唯一的改动在其 dataset_query 中:每个 metric 引用([metric, {}, <entity ID>])指向该 metric 在应用中的副本(第 4 步),而不是源 metric 的实体 ID。你要亲自按照格式 skill 的布局(它如何排列 MBQL 子句)来写,保持打印出的键顺序,而不是用脚本序列化打印出的 JSON:脚本会跳过格式 skill,其输出也不会对照格式进行检查。该查询就是 dev preview 所运行的,因此其中任何差异都会改变生产环境运行的内容;写入文件后,将其解析回来并与打印出的 entity 比较,并修正除 metric 引用之外的每一处差异。

  4. 每个 metric 一个副本。 查询所聚合的每个 metric 也必须被复制,因为查看者只能读取应用的 collection。将 metric 打印出的 entity 作为一个 card 写入 collections/data_apps/<collection>/,带有新的实体 ID,并将 collection_id 设置为应用的 collection,然后从每个使用该 metric 的问题中引用该副本的实体 ID。一个副本可供它们全部使用。

  5. 每个 action 一个副本。 将 action 打印出的 entity 写入 collections/data_apps/<collection>/,带有新的实体 ID,设置为定义的 copiedActionEntityId,并将 collection_id 设置为应用的 collection。应用只运行不属于任何 model 的 query actions,因此副本永远不会有 model_id。 每张卡片和每份文案都要你亲自根据打印导出内容编写,遵循格式技能,绝不能依据 YAML(不是仓库导出的集合或其顶层 actions/,不是其他应用的集合,不是 git 历史),绝不能依据你自己的 API 调用,也绝不能用把打印出的 JSON 转储为 YAML 的脚本。它的引用已经是 YAML 使用的形式,因此照原样复制。副本精确保留其导出打印的内容,除了使其成为副本的部分:新的 entity_id(以及匹配的 serdes/meta id,其标签取自副本名称)和 collection_id。打印内容已经省略了序列化未设置或处于默认值的内容(description、archived、enable_embedding、public_uuid、表单字段的未设置设置等),正如格式会省略它们:绝不要添加打印内容中没有的键,绝不要保留值为 null 的键,并保持打印的键顺序。

每张卡片和每个操作都带有打印内容给出的 creator_id;Metabase 在加载应用时会将其替换为其内部用户。

如果应用需要的某个源不在 src/metabase.data.ts 中,或者其导出带有 error,请停止并告知用户;绝不要从头编写替代品。在 Metabase 中更改某个源后,再次运行 npm run print-resources 并再次编写其副本,保留副本的实体 ID。

运行 npm run check-resources,并修复它报告的每个文件。当定义与应用集合的文件不一致时,它会失败并列出每个问题:清单未命名任何集合,或命名了一个没有文件的集合,定义没有其实体 ID 或命名了一个没有文件持有的实体 ID,查询命名了不是问题的卡片,或者某个问题或操作没有定义命名它;然后它会根据格式的 schema 验证仓库中的每个 Metabase YAML 文件,包括应用集合文件。npm run build 会运行第一项检查。一旦没有东西使用某个指标副本,就自己删除它。这两个命令都不会调用 Metabase。下一次拉取会再次验证一切,并拒绝应用的资源,同时显示一条命名该文件的消息,展示在 Data apps 管理页面上。修复该文件并再次推送。

将定义本身传递给 hook,让 SDK 解析要运行的内容——生产构建运行副本,而开发预览运行编写的表或操作,因此应用在其资源存在之前即可工作:

const { data } = useMetabaseQuery(RevenueQuery, {
  filters: [filter(RevenueQuery.source.fields.status, "=", selectedStatus)],
});

const { execute, isExecuting, error } = useAction(CreateOrder);

绝不要传递内联的表源查询(即使是只读、筛选选项或辅助查询)、原始操作 id、savedQuestionEntityId、copiedActionEntityId 或手工构建的 { source: { type: "card", id } },也绝不要将定义展开到新对象中。每一种都会破坏替换;编写的 id 还会绕过权限边界。TypeScript 会拒绝其中大多数:hook 只接受 defineQuery/defineAction 返回的内容,因此内联对象、satisfies 类型的对象、展开副本和 schema.actions.<action> 都无法编译。当 tsc 报告 Property 'definedWithDefineQuery' is missing 或 Property 'definedWithDefineAction' is missing 时,该参数不是定义:将其移入 queries/ 或 actions/ 并导入该导出。不要用类型转换或在调用处将内联对象包装在 defineQuery(...) 中来消除它,那样可以编译,但永远不会由保存的问题支撑。将固定的权限边界筛选、聚合和分组保留在 defineQuery 内——它们已烘焙进保存的问题中,因此不要在其外部再次应用它们,并将运行时子句放入 hook 的第二个参数(参见 静态和动态查询部分)。useAction 不需要泛型:定义会为 execute 的参数和 result 设定类型。 在 package.json 中配置 "print-resources": "embedding-sdk-react data-apps print-resources"、"check-resources": "embedding-sdk-react data-apps check-resources && representations validate-schema --folder ../.."(并将 @metabase/representations 作为 dev 依赖),以及 "build": "vite build"。在添加、修改、重命名或删除任何定义后,更新应用集合中的文件,然后运行 npm run check-resources 和 npm run build。在 npm run build 成功之前,不要测试或交接该应用。将 data_app.yaml、应用在 collections/data_apps/ 下的文件以及定义一起提交;Metabase 拉取仓库时,应用会读取它们。

标准模式

每个查询对应两个文件:定义位于 queries/,Hook 调用位于组件中。为保持简洁,从这里开始的示例都省略了 savedQuestionEntityId;每个真实定义都会带上它(参见 将每个查询和操作写入应用的集合)。

// queries/orders.query.ts
import {
  aggregations,
  breakout,
  defineQuery,
  filter,
  orderBy,
} from "@metabase/embedding-sdk-react/data-app";
import schema from "../src/metabase.data";

const ordersTable = schema.tables.orders;

export const PaidRevenueByMonth = defineQuery({
  source: ordersTable,
  filters: [
    ordersTable.segments.completed,
    filter(ordersTable.fields.status, "=", "paid"),
  ],
  aggregations: [aggregations.sum(ordersTable.fields.amount)],
  breakouts: [breakout(ordersTable.fields.createdAt, { unit: "month" })],
  orderBys: [orderBy(ordersTable.fields.createdAt, "desc", { unit: "month" })],
  limit: 100,
});
// src/pages/Overview.tsx
import { useMetabaseQuery } from "@metabase/embedding-sdk-react/data-app";
import { PaidRevenueByMonth } from "../../queries/orders.query";

const { data, isLoading, error } = useMetabaseQuery(PaidRevenueByMonth);

useMetabaseQuery(...) 会从定义中推断出带类型的行数据,因此不要在 Hook 上写泛型。要检查字段、分段和度量所属的表,请将泛型放在定义上:defineQuery<typeof ordersTable>({ ... })。当你需要从 data.rows 获取精确的行键时,在选定字段查询上不要加它。下面的配方展示了传给 defineQuery 的对象;每一个都是 queries/ 中的导出,绝不是写在 Hook 处的参数。

在每个渲染树中,每个 schema 条目最多调用一次。 对同一个 questionId(或同一个 tableId 加上完全相同的过滤条件/度量/分组)多次调用 useMetabaseQuery 会挂载相互独立的订阅,触发重复查询,并让使用方在加载中途出现不一致。将调用提升到需要该数据的最高层组件;把 data / isLoading / error 作为 props 向下传递。不同的 id——或同一个 id 搭配不同的过滤条件/分组——是不同的数据源;请分别调用。

使用带键的 schema 对象:

  • 表:source: schema.tables.<table>
  • 度量:aggregations 中的 schema.metrics.<metric>
  • 字段:schema.tables.<table>.fields.<field>
  • 分段:schema.tables.<table>.segments.<segment>
  • 度量值:schema.tables.<table>.measures.<measure>
  • 度量维度:schema.metrics.<metric>.dimensions.<group>.<dimension>

不要传入诸如 "created_at" 或 "segment" 这样的原始维度字符串。

静态与动态查询部分

两个查询钩子都接受一个可选的第二参数:在应用运行期间会发生变化的条件子句。

// revenue.query.ts — static, and identical on every render
const orders = schema.tables.orders;

export const RevenueQuery = defineQuery({
  source: orders,
  aggregations: [aggregations.sum(orders.fields.total)],
  breakouts: [
    breakout(orders.fields.createdAt, { unit: "month" }),
    breakout(orders.fields.plan),
  ],
});

// the component supplies only what the UI changes
const { data } = useMetabaseQuery(RevenueQuery, {
  filters: plan === null ? [] : [filter(orders.fields.plan, "=", plan)],
});

即使看起来没有任何东西依赖于此,也要按这种方式拆分:第一个参数在每次渲染时都必须完全相同,只有第二个参数可以随运行时状态变化。

动态子句作为它们自己的阶段运行,因此它们看到的是静态查询的结果列,而不是其源表。这就是为什么上面的 plan 是一个拆分项:一个基于源列进行过滤的控件,只有当该列保留到结果中时才能起作用。如果没有保留,就把它添加为一个拆分项,或者让静态查询保持未聚合状态。同样,对聚合后的静态查询进行过滤时,要基于 count/sum,而不是它们背后的字段。

分段和度量只属于静态部分。 它们是针对表定义的,而动态阶段没有表——因此 filters: [orders.segments.completed] 和 aggregations: [orders.measures.revenue] 会被拒绝,在编译时如此,在运行时亦然。这是通常的"优先使用精选定义"规则不适用的唯一地方。

把精选定义放在它能被解析的静态查询中,让动态子句基于其产出结果工作:

export const CompletedOrders = defineQuery({
  source: orders,
  filters: [orders.segments.completed], // the segment resolves here
  aggregations: [orders.measures.revenue],
  breakouts: [breakout(orders.fields.plan)],
});

const { data } = useMetabaseQuery(CompletedOrders, {
  // a result column, not a segment or measure
  filters: plan === null ? [] : [filter({ type: "column", name: "PLAN" }, "=", plan)],
});

如果某个控件必须开启和关闭一个分段,那是静态查询之间的选择,而不是动态子句:为每个状态定义一个查询并选择该查询,或者把同样的条件表达为对结果列的过滤。

如果你在某个查询对象上看到 savedQuestionEntityId,或者在某个操作定义上看到 copiedActionEntityId,请不要移除它们。它们各自指明了该定义在应用集合中的文件——参见将每个查询和操作写入应用的集合。

表查询配方

对于表查询,将生成的表对象作为 source 传入:

// queries/records.query.ts
const recordsTable = schema.tables.records;

export const RecordStatuses = defineQuery({
  source: recordsTable,
  fields: [recordsTable.fields.id, recordsTable.fields.status],
});

对于分组表摘要,至少包含一个聚合:

export const ActiveAmountByMonth = defineQuery({
  source: recordsTable,
  filters: [
    recordsTable.segments.activeRecords,
    filter(recordsTable.fields.amount, ">", 100),
  ],
  aggregations: [recordsTable.measures.totalAmount],
  breakouts: [breakout(recordsTable.fields.createdAt, { unit: "month" })],
  orderBys: [orderBy(recordsTable.fields.createdAt, "desc", { unit: "month" })],
});

对于没有精选度量的基本聚合,使用 aggregations 辅助函数:

export const AmountByCategory = defineQuery({
  source: recordsTable,
  aggregations: [
    aggregations.count(),
    aggregations.sum(recordsTable.fields.amount),
  ],
  breakouts: [breakout(recordsTable.fields.category)],
});

当某个查询多次使用同一个辅助函数时,为每一个指定一个 name。该名称会成为结果列的名称和行键,它是有类型的,并且 orderBy(...) 和运行时子句正是通过它来引用该聚合。如果没有名称,列会以 sum、sum_2 等返回,按其中之一排序或过滤就会失败:

const totalAmount = aggregations.sum(recordsTable.fields.amount, {
  name: "total_amount",
});
const totalTax = aggregations.sum(recordsTable.fields.tax, {
  name: "total_tax",
});

export const TaxByCategory = defineQuery({
  source: recordsTable,
  aggregations: [totalAmount, totalTax],
  breakouts: [breakout(recordsTable.fields.category)],
  orderBys: [orderBy(totalTax, "desc")],
});
// Rows are keyed `total_amount` and `total_tax`.

表字段、分段、度量、过滤器、拆分项和 orderBy 都必须来自被查询的表。当你希望 TypeScript 在定义处验证这种归属关系时,使用 defineQuery<RecordsTable>({ ... })。

度量聚合配方

对于由度量支持的查询,将生成的表对象作为 source 传入,并将生成的度量对象放入 aggregations:

// queries/revenue.query.ts
const ordersTable = schema.tables.orders;
const revenueMetric = schema.metrics.revenue;

export const Revenue = defineQuery({
  source: ordersTable,
  aggregations: [revenueMetric],
});

在聚合所属度量的查询中,使用生成的度量维度进行过滤和拆分。来自度量源表的维度可以直接使用。当生成的字段包含 sourceFieldId 时,来自关联表的维度也可以使用;优先使用这些关联表维度以获得可读的标签,而不是按原始外键 ID 分组:

export const PaidRevenueByMonthAndFranchise = defineQuery({
  source: ordersTable,
  aggregations: [revenueMetric],
  filters: [filter(revenueMetric.dimensions.orders.status, "=", "paid")],
  breakouts: [
    breakout(revenueMetric.dimensions.orders.createdAt, { unit: "month" }),
    breakout(revenueMetric.dimensions.franchises.name),
  ],
  orderBys: [
    orderBy(revenueMetric.dimensions.orders.createdAt, "desc", {
      unit: "month",
    }),
  ],
});

// Prefer readable related-table dimensions when available.
breakout(revenueMetric.dimensions.franchises.name);

// Avoid raw FK IDs when the related-table dimension exists.
breakout(revenueMetric.dimensions.orders.franchiseId);

由度量支持的查询可以包含基于生成的度量维度的辅助聚合。当生成的 schema 暴露它们时,它们还可以使用表源中兼容的已保存分段和度量:

export const CompletedRevenueByStatus = defineQuery({
  source: ordersTable,
  filters: [schema.tables.orders.segments.completed],
  aggregations: [
    revenueMetric,
    schema.tables.orders.measures.totalRevenue,
    aggregations.sum(revenueMetric.dimensions.orders.amount),
  ],
  breakouts: [breakout(revenueMetric.dimensions.orders.status)],
});

度量聚合必须属于表源。不要在表源查询中使用源卡片度量。生成的度量维度以其所属度量为作用域:如果某个查询在过滤器、辅助聚合、拆分项或 orderBy 中使用了 revenueMetric.dimensions.*,那么它也必须在 aggregations 中包含 revenueMetric。不要将度量维度用作不相关的 count() 或表度量查询的独立表字段。生成的度量维度也必须解析到表源。当你希望 TypeScript 在定义处验证这一点时,使用 defineQuery<typeof ordersTable>({ ... })。

SDK 渲染的视图

表字段、segments、度量聚合和指标聚合必须来自所查询的表。过滤条件、辅助聚合、分组和 orderBy 中使用的生成指标维度必须解析到所查询的表,并且属于同一查询的 aggregations 中包含的某个指标。 当表查询使用 fields、segments、aggregations、breakouts 或 orderBys 时,让 defineQuery 推断结构,或者在所有权校验比精确的结果行键更重要时编写 defineQuery<typeof recordsTable>。

交互式 Metabase 视图

某个元素究竟是不是 SDK 问题——以及它是 StaticQuestion 还是 InteractiveQuestion——由数据应用设置技能决定(渲染图表:Metabase 优先)。一旦确定:在 queries/ 中声明查询,用 useMetabaseQueryObject(TheQuery) 解析它,然后将结果通过 SDK 问题组件的 card prop 传入。

useMetabaseQueryObject 支持生成的表查询,包括指标聚合。当自定义 React 需要直接的行数据时使用 useMetabaseQuery;当应由 Metabase 渲染或管理可视化时使用 useMetabaseQueryObject。不要向 useMetabaseQueryObject 传入泛型;它返回 { query, error, isLoading },而非查询结果行。

使用 SDK 组件渲染下的示例使用 return null 进行最简的加载和错误处理。在真实应用中,请在此处渲染应用现有的加载或错误 UI。当 query 为 null 时,传入 card={{ query }} 是安全的;不要将完整的 { query, error, isLoading } hook 结果作为 card.query 传入。

在可复用的图表/卡片组件中包装 useMetabaseQueryObject 时,请解构并渲染 error;不要只读取 { query },否则查询构建失败会看起来像是无尽的加载。在该子组件内部调用 hook 是合法的 React。不要在父组件的循环、条件或回调中直接调用 hook。

错误/正确模式:

const trendQuery = useMetabaseQueryObject(TrendQuery);
<InteractiveQuestion card={{ query: trendQuery }} />; // wrong

const { query: trendQuery } = useMetabaseQueryObject(TrendQuery);
<InteractiveQuestion card={{ query: trendQuery }} />; // right

Hook 类型:

  • 两个 hook 都只接受从 queries/ 导入的 defineQuery 导出,不接受其他内容;内联对象会导致编译错误。不要在 hook 上编写泛型。
  • useMetabaseQuery(...) 从定义中推断带类型的行数据。当所有权校验很重要时,将 defineQuery<typeof table> 写在定义上。
  • useMetabaseQueryObject(...) 返回 { query, error, isLoading }。将 query 属性传给 card={{ query }}。
  • 不要使用 as Parameters<typeof useMetabaseQuery>[0] 或 as DefinedQuery 来平息查询类型错误。前者会隐藏无效的表字段、指标聚合和分组;后者会隐藏没有已保存问题的查询,这会在生产环境中失败。

基本 prop 约定为:

  • 生成的表查询,包括指标聚合:<StaticQuestion card={{ query }} />
  • 完整的交互式问题:<InteractiveQuestion card={{ query }} />

当你需要 SDK 支持的问题显示集合时,不要复制本地列表。在生成的应用中,搜索 node_modules/@metabase/embedding-sdk-react/dist/index.d.ts 找到确切的声明 declare const cardDisplayTypes: readonly [...],并将该元组作为事实来源。不要将整个声明文件读入上下文。 始终通过 card 对象传递 SDK 渲染的临时问题。当用户未要求特定图表类型,且 Metabase 默认值可以从查询中推断出合理的展示方式时,从 card={{ query }} 开始。当用户请求或设计需要特定图表类型(例如用于展示分布的饼图),但不要求设置级别的自定义时,使用 card={{ query, visualization }}。仅当用户明确要求设置级别的呈现更改(例如隐藏或重命名轴标签、显示值标签、堆叠条形、添加目标线、排列表格列、显示饼图总计/标签,或控制系列/切片顺序)时,才添加 visualizationSettings。在 node_modules/@metabase/embedding-sdk-react/dist/index.d.ts 中搜索 export declare type MetabaseCard、相关的 *VisualizationSettings 类型,以及你计划使用的任何设置键。阅读附加在这些声明上的 JSDoc 注释,然后将 TypeScript 声明作为合法 visualization 和 visualizationSettings 组合的唯一依据。使用 useMetabaseQueryObject 构建查询;不要调用内部查询解析辅助函数,不要通过 any 进行类型转换,也不要凭记忆硬编码设置。

有关所公开设置的简要说明及其使用时机,请阅读 references/visualization-settings.md。仅将该文件视为指导;已安装的 SDK 声明决定什么是合法的。

在编写 card 之前,请检查 node_modules/@metabase/embedding-sdk-react/dist/data-app.d.ts 中 useMetabaseQueryObject 的返回类型。解构返回的 query,并在 card.query 中使用该值;对于已配置的 card,使用 satisfies MetabaseCard 为该对象标注类型。如果 TypeScript 报告重复的不透明 DatasetQuery 符号,不要强制类型转换;在使用 card 之前先更新 SDK 包。

不要为生成的查询或可视化设置杜撰替代的 prop 名称。如果 SDK 类型表明某个 prop 不存在,请相信它,并使用文档中记录的 card prop 结构。

当需要使用 useMetabaseQuery 时,在渲染之前,使用命名属性将类型化行映射到显式的本地视图模型:

const orderedAtKey = ordersTable.fields.orderedAt.name;

const chartRows = (data?.rows ?? []).map((row) => ({
  label: String(row[orderedAtKey] ?? "Unknown"),
  value: row.count,
}));

如果结果键仅在运行时来自 data.columns,请使用 rawRows 并匹配相应的列位置,或者在索引 data.rows 之前将键收窄为字面量。

使用 SDK 组件渲染

仅图表,不含工具栏:

import {
  InteractiveQuestion,
  StaticQuestion,
  type MetabaseCard,
} from "@metabase/embedding-sdk-react";

import { useMetabaseQueryObject } from "@metabase/embedding-sdk-react/data-app";

import { AmountByMonth } from "../../queries/events.query";

const { query, isLoading, error } = useMetabaseQueryObject(AmountByMonth);

if (error) {
  return null;
}

if (isLoading || !query) {
  return null;
}

return (
  <InteractiveQuestion card={{ query }}>
    <InteractiveQuestion.QuestionVisualization height="500px" />
  </InteractiveQuestion>
);

已配置的 SDK 可视化:

// queries/events.query.ts
export const TotalAmountByMonth = defineQuery({
  source: eventsTable,
  aggregations: [eventsTable.measures.totalAmount],
  breakouts: [breakout(eventsTable.fields.occurredAt, { unit: "month" })],
});

// the component
const { query, isLoading, error } = useMetabaseQueryObject(TotalAmountByMonth);

if (error) {
  return null;
}

if (isLoading || !query) {
  return null;
}

const trendCard = {
  query,
  visualization: "bar",
  visualizationSettings: {
    "graph.show_values": true,
    "graph.y_axis.title_text": "Total amount",
  },
} satisfies MetabaseCard;

return (
  <InteractiveQuestion card={trendCard}>
    <InteractiveQuestion.QuestionVisualization height="500px" />
  </InteractiveQuestion>
);

过滤与分组

使用辅助函数,因为它们提供更好的自动补全和更简短的错误信息。

filter(ordersTable.fields.quantity, ">", 0);
filter(ordersTable.fields.status, "contains", "paid");
filter(ordersTable.fields.quantity, "between", [10, 20]);
filter(ordersTable.fields.status, "not-empty");

breakout(ordersTable.fields.createdAt, { unit: "month" });
breakout(ordersTable.fields.amount, {
  binning: { strategy: "num-bins", numBins: 10 },
});
breakout(ordersTable.fields.state);

orderBy(ordersTable.fields.createdAt, "desc", { unit: "month" });

不要手写 orderBys 对象字面量,例如 { field, direction } 或 { fieldId, direction };请使用 orderBy(...)。当对与日期分组使用相同的日期字段进行排序时,请向 breakout(...) 和 orderBy(...) 传递相同的 unit。

对于 top-N 分组汇总,请按聚合结果排序,而不是原始源字段。将聚合辅助函数存储在局部常量中,并将该同一常量同时传递给 aggregations 和 orderBy(...):

// queries/inventory.query.ts
const avgQuantity = aggregations.avg(inventoryTable.fields.quantityOnHand);

export const TopIngredientsByQuantity = defineQuery({
  source: inventoryTable,
  aggregations: [avgQuantity],
  breakouts: [breakout(inventoryTable.fields.ingredient)],
  orderBys: [orderBy(avgQuantity, "desc")],
  limit: 15,
});

对于用户可选择的排序,排序是运行时状态,因此它属于 hook 的第二个参数。构建允许的生成字段的类型化映射,而不是索引整个 fields 对象:

type SortKey = "revenue" | "orders";
type ScorecardTable = typeof scorecardTable;

type ScorecardField = ScorecardTable["fields"][keyof ScorecardTable["fields"]];

const sortFields = {
  revenue: scorecardTable.fields.netRevenue,
  orders: scorecardTable.fields.orders,
} satisfies Record<SortKey, ScorecardField>;

const { data } = useMetabaseQuery(Scorecard, {
  orderBys: [orderBy(sortFields[sortKey], "desc")],
});

Scorecard 是 queries/ 中未聚合的 defineQuery({ source: scorecardTable }) 导出;其源字段会保留到结果中,因此动态阶段可以按它们排序。

对于指标查询,请将生成的指标维度传递给 filter(...) 和 breakout(...):

filter(revenueMetric.dimensions.orders.status, "=", "paid");
breakout(revenueMetric.dimensions.orders.createdAt, { unit: "month" });

过滤运算符规则:

  • string:=、!=、contains、does-not-contain、starts-with、ends-with、is-empty、not-empty、is-null、not-null
  • number:=、!=、>、>=、<、<=、between、is-null、not-null
  • date:=、!=、>、>=、<、<=、between、time-interval、is-null、not-null
  • boolean:=、is-null、not-null

只有日期维度可以使用 unit。非日期维度可以在没有 unit 的情况下用作分组;数值维度可以使用 binning。

细分已经是过滤器:

filters: [
  schema.tables.records.segments.activeRecords,
  filter(schema.tables.records.fields.amount, ">", 100),
];

当精选细分与产品意图完全匹配时,请先使用它们。当 UI 需要阈值、类别、日期范围、文本匹配、布尔条件或其他尚未由精选细分表示的收窄条件时,请使用 filter(...)。

过滤 UI 模式

当用户要求自定义过滤器时,请构建正常的 React 控件,以向语义查询过滤器提供输入。

在实现过滤器之前,请为可见仪表板创建过滤器契约。至少需要确定:

  • 对于每个过滤器,命名提供其选项的运行时查询。
  • 对于每个过滤器,命名在 filter(...) 中使用的原始值。
  • 对于每个卡片、表格、KPI 和趋势,命名可以接收该过滤器的生成表字段或指标维度。
  • 如果某个过滤器仅适用于一个部分,请将其保持为部分范围,或从全局过滤器栏中省略。
  • 如果页面需要不同的日期字段,例如 snapshotDate,请为该页面使用一个可见的日期控件。
  • 描述同一概念的 KPI/详情对应使用相同的相关过滤器。

有关过滤器状态规则、运行时候选选项、过期选项重置、可搜索控件和自定义日期选择器实现,请使用 references/filter-ui-patterns.md 中的详细检查清单。

对于常见的带记忆的日期/类别过滤器形式:

type DatePreset = "30d" | "90d" | "custom" | "all";

const [datePreset, setDatePreset] = useState<DatePreset>("all");
const [customRange, setCustomRange] = useState<[string | null, string | null]>([
  null,
  null,
]);
const [status, setStatus] = useState("all");

const dateRange = useMemo((): readonly [string, string] | null => {
  if (datePreset === "all") {
    return null;
  }

  if (datePreset === "custom") {
    const [start, end] = customRange;
    return start && end ? [start, end] : null;
  }

  return getPresetDateRange(datePreset);
}, [datePreset, customRange]);

const orderFilters = useMemo(
  () => [
    ...(dateRange
      ? [filter(ordersTable.fields.createdAt, "between", dateRange)]
      : []),
    ...(status === "all"
      ? []
      : [filter(ordersTable.fields.status, "=", status)]),
  ],
  [dateRange, status],
);

上面的 customRange 就是 <DateRangePopover value={customRange} onChange={setCustomRange}> 所存储的内容,因此无需转换。当回退选择器的状态使用 Date | null 时,请使用本地 YYYY-MM-DD 格式化器转换所选日期,然后再将它们传递给 filter(..., "between", range)。不要将 date.toISOString().split("T")[0] 用于本地日期过滤器。

结果形态与图表

  • 优先使用带键的 data.rows。
  • 切勿把 data.rows 当作位置数组。不要对 useMetabaseQuery 的行对象使用 row[0]、row[1]、DisplayRow 或元组类型断言。
  • 在映射底层 rawRows 之前先检查 data.columns,但不要用任意的 data.columns[].name 字符串去索引有类型的 data.rows。
  • 运行时行对象以返回的 Metabase 列名为键,通常是 column.name,例如 total_amount 或 average_score。不要假定生成的 schema 键(如 totalAmount 或 averageScore)就是运行时行键。
  • 对于生成的字段引用,React 属性路径与运行时结果键可能不同:schema.tables.orders.fields.orderedAt.name 可能是 ordered_at。请使用 row[ordersTable.fields.orderedAt.name]、row.count 或 data.columns 元数据,而不是猜测 row.orderedAt。
  • 将行值视为可空。在调用 toFixed、toLocaleString 等数字/字符串方法或字符串变换之前先做防护。
  • 仅在已知位置形态时使用 rawRows。
  • 聚合列可能命名为 count、sum 或 avg;需要时对照元数据。
  • 如果查询有多个同类型的辅助聚合,例如多个 aggregations.sum(...) 调用,请为每一个命名({ name: "..." })并按这些名称读取行。切勿依赖像 sum_2 这样的生成名称。
  • 分组查询可能包含 null 分组桶。请将其渲染为 "Unknown" 或有意识地将其过滤掉。
  • 时间序列图表需要多个有序分组桶。不要为标量或单点结果伪造迷你趋势图。
  • 单位或量级不同的多序列图表需要单独的坐标轴或归一化。
  • 格式化面向用户的值:货币最多保留 2 位小数,计数使用整数,日期使用可读标签。
  • 除非语义层描述或检查过的样本值使含义和单位显而易见,否则不要渲染含义不明确的派生业务值。
  • 空结果与加载中不同。在 isLoading 为 false 后,渲染清晰的空状态,而不是留下骨架屏或空白 KPI。

呈现指引

对于图表形态和表格形态的数据,优先使用 Metabase 渲染的面板。当自定义面板确有必要时,React 应用可以对 data.rows 进行分组、排序、格式化并派生仅用于展示的值,但不要将自定义面板作为默认选择。

合适的转换:

  • 对行进行分组以生成摘要。
  • 仅在自定义列表明显优于 Metabase 行/条形/表格可视化时,才对行进行排序和截取以生成排名列表。
  • 根据实际数据形态选择图表类型,在编写自定义图表代码之前优先使用 Metabase 的 bar、line、area、row、combo、pivot 和 table 显示。
  • 显示加载、错误和空状态。
  • 限制密集结果的显示。表格、告警列表、日志和排名列表应使用 top-N 截取、分组、分页或固定/最大高度滚动区域,以免大量结果集撑满整个页面。

当页面感觉像原始表格浏览器时,寻找基于 schema 的方式来丰富它:

  • 使用 segment 获取精选子集,例如活跃、已完成、已逾期、高优先级或需要关注的记录。
  • 使用 measure 获取精选聚合,而不是在 React 中临时重新计算一切。
  • 当 schema 为页面的核心业务问题暴露了精选的 metric 聚合时,使用 metric。
  • 使用 filter 将查询聚焦到 UI 的意图上。
  • 使用 breakout 创建趋势、类别对比和分组摘要。
  • 如果丰富后的结果仍是可排序/可下钻的表格,请使用 SDK 可视化组件渲染它,而不是在 React 中重新实现表格行为。

当语义层已有相应概念时,避免手动分类。优先使用精选的 segment、字段或 measure,而不是在 React 中进行字符串匹配、阈值启发式判断或类别重建。

如果没有精选的 schema 条目支持预期的 UI,请省略该部分或请求进行语义层精选。不要在完成的 app 中保留模拟数据或占位分析。

最终检查

  • 运行 npm run typecheck。出现 Property 'definedWithDefineQuery' is missing 或 Property 'definedWithDefineAction' is missing 表示某个 hook 接收到的不是 queries/ 或 actions/ 导出;将该对象移到对应目录并导入。
  • 在改动过的文件中搜索 useMetabaseQuery(、useMetabaseQueryObject( 和 useAction(。第一个参数必须是从 queries/ 或 actions/ 导入的标识符;即使能编译通过,此处出现 {、defineQuery(、defineAction( 或展开运算符也是错误的。查询 hook 的第二个参数是动态对象,需内联书写。
  • 确认 queries/ 和 actions/ 与 package.json 同级,而不是位于 src/ 下,并且应用渲染的每个定义都存放在那里。
  • 确认你加载了表示格式技能,并依据它检查了应用集合中的每个文件;在交接说明中写明这一点,或说明你没有做。
  • 确认每个定义都有其实体 ID 及其在应用集合中的文件,并且每个已保存问题都与其定义匹配。运行 npm run check-resources,然后运行 npm run build;当应用集合文件不再支撑这些定义时,两者都会失败。
  • 在聊天或交接说明中保持 TypeScript 诊断信息简洁。在本地使用完整输出修复应用,但报告分组的根本原因和少量代表性诊断,而不是粘贴整个 tsc 输出。
  • 验证每个渲染值都能追溯到返回的行属性、schema 字段、度量或确定性转换。
  • 在改动过的文件中搜索 row[0]、row[1]、row.orderedAt、row.orderDate、as unknown as、DisplayRow、<select、margin、rate、score、percent、%、* 100 和 .toFixed;修复位置式行访问、结果键猜测、实体 <select> 筛选器以及不受支持的业务字段解读。
  • 验证每个日期预设栏都包含 Custom 作为最后一项,除非明确省略;每个可见日期筛选器都影响当前页面;并且没有页面对同一范围显示重复的日期筛选器。
  • 验证 data_app.yaml 指向构建产物的路径,并且该构建产物路径已纳入 git 跟踪。
  • 对于每个可见筛选器,验证 "All" 映射为无筛选,选中值来自运行时查询结果,并且每个非 All 选项都会改变其声称影响的每张卡片。

常见错误

  • 在构建应用期间创建或搜索 Metabase 内容。应用集合中的文件通过 npm run print-resources 写入,绝不在 Metabase 中创建。
  • 从仓库中的 Metabase YAML(其导出内容、其顶层 actions/ 或 git 历史)或从自己的 API 调用中复制指标或 action,而不是从 npm run print-resources 中获取。
  • 自行编写已保存问题、添加打印结果中不存在的键,或更改打印实体中的任何内容(将指标引用改为应用副本除外)。
  • 在 hook 调用处编写查询对象,而不是使用 defineQuery 从 queries/ 中导出;或者在 useAction 处编写 action,而不是使用 defineAction 从 actions/ 中导出。这两者现在都是编译错误;修复方法是改到正确的目录,而不是用强制类型转换。
  • 在调用处将内联对象包裹在 defineQuery(...) 或 defineAction(...) 中。这样能编译,但它从不基于已保存问题,因此在生产环境中会被拒绝。
  • 将定义放在 src/queries/ 或 src/actions/ 下,而 CLI 从不查找这些位置。
  • 导入旧版 hook,而不是 useMetabaseQuery。
  • 将原始数字 ID 复制到常量中,而不是使用生成的 schema 对象。
  • 自行编造临时度量对象,例如 { name: "count" } 或 { name: "sum", field: fieldId }。
  • 为表字段传递原始字符串。
  • 添加查找辅助函数,而不是使用带键的生成 schema 对象。
  • 自行编造 SDK 组件 prop 名称,而不是为生成的表查询使用 query。
  • 混用来自不相关表的字段、分段或度量。
  • 将分段或度量传递给动态的第二个参数,而该参数只能解析结果列。
  • 添加会发送空值的筛选器 UI,而不是省略该筛选器。
  • 硬编码分类筛选值,而不是从 Metabase 查询运行时值。
  • 在有稳定 ID 可用时,显示实体名称却按这些名称进行筛选。
  • 将仪表板级筛选器仅应用于一个 KPI,而相关图表和表格忽略它。
  • 同时显示全局 Date Range 和页面特定的 Snapshot Date,导致其中一个日期筛选器无效。
  • 让 KPI 及其明细表使用不同的日期或类别筛选器,却不解释差异。
  • 使用自行编造的 %、星级、颜色或阈值来渲染 Margin/Rate/Score/Health。
  • 发布没有 Custom range 选项的日期预设栏,或将 Custom 放在 All time 之前。
  • 在有面向用户的名称可用时,却用 franchise_id 这类不透明 ID 绘制图表。
  • 在普通 <select> 中渲染实体筛选器,即使当前运行时选项列表很短。
  • 为日期范围使用原生 <input type="date"> 或任何日期选择器依赖项(react-datepicker、react-day-picker、某个 UI 套件的选择器),而不是 DateRangePopover,并发布由浏览器控制的 mm/dd/yyyy 占位符或未套用主题的日历弹层。
  • 使用 new Date("YYYY-MM-DD").toLocaleDateString() 来标注日期触发器,而不是 useDateFormatter(),导致格林尼治以西的用户看到的标签早一天。
  • 假设 filter(...) 会完全验证值类型。
  • 让 null 分桶成为最新的时间序列数据点。
  • 硬编码业务值、标签、时间戳或排名。
  • 手工创建可直接用于图表的数组,而不是从查询到的 data.rows 中推导。
  • 将带类型的 SDK 行强制转换为通用元组行,例如 [string, number]。
  • 在 Metabase 返回 ordered_at 这类列名时,却读取 row.orderedAt 这类生成的对象属性名。
  • 渲染 schema 或返回的查询结果中不存在的字段。
  • 在 SDK 仍在进行身份验证或加载时渲染 No data。
  • 创建嵌套的 MetabaseProvider 实例,而不是在应用边界共享一个 provider。

评论

支持 Markdown