metabase-data-app-semantic-layer

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Metabase Data App Semantic Layer

Metabase 数据应用语义层

Core Rules

核心规则

Keep the semantic layer and presentation layer separate.
  • All Metabase context must come from the generated schema file, usually
    src/metabase.data.ts
    or
    src/*.metabase.data.ts
    .
  • Do not discover data through MCP tools, create Metabase content, create tables, or edit the semantic layer while building the React UI.
  • Import data app query helpers from
    @metabase/embedding-sdk-react/data-app
    .
  • Every query is a
    defineQuery(...)
    named export in the root-level
    queries/
    directory, and every action a
    defineAction(...)
    named export in the root-level
    actions/
    directory, both beside
    package.json
    . Create both directories before writing the first hook call; the template ships them, each with a README. The hooks enforce this at compile time:
    useMetabaseQuery
    ,
    useMetabaseQueryObject
    , and
    useAction
    reject an inline object, a
    satisfies MetabaseQueryOptions
    object, and a spread copy of a definition. The error reads
    Property 'definedWithDefineQuery' is missing
    (or
    'definedWithDefineAction'
    ); the fix is always to move the object into
    queries/
    or
    actions/
    as a definition and import it, never a cast.
  • Never remove, edit, or copy a generated
    savedQuestionSourceId
    or
    copiedActionId
    , even if it appears unused. Preserve it during refactors; use
    npm run sync-resources
    to repair or replace generated IDs.
  • Prefer generated schema objects over raw IDs or strings. Extract local constants for top-level table objects.
  • Never hand-write
    DatasetQuery
    /MBQL objects in app code. Do not pass inline query objects like
    { type: "query", query: { "source-table": table.id } }
    , raw
    source-table
    clauses, raw field IDs, bare table IDs, or metric IDs to SDK components,
    useMetabaseQuery
    , or
    useMetabaseQueryObject
    . Prefer generated table and metric schema objects; for simple table-source queries, an explicit source reference like
    { type: "table", id: table.id }
    is also valid.
  • Build queries with
    source: schema.tables.<name>
    , generated
    fields
    , generated
    segments
    , generated
    measures
    , generated metrics in
    aggregations
    , generated metric
    dimensions
    ,
    filter(...)
    ,
    breakout(...)
    ,
    orderBy(...)
    , and
    aggregations
    helpers such as
    aggregations.count()
    and
    aggregations.sum(...)
    . Do not use
    source: schema.metrics.<name>
    ; metrics are aggregation expressions, not query sources.
  • Do not use existing saved questions as
    useMetabaseQuery
    or
    useMetabaseQueryObject
    sources. Typed schemas do not expose
    schema.questions
    or support
    question-collections
    while data-app reconciliation cannot copy existing saved questions into the app collection.
  • Prefer semantically rich table queries over shallow table dumps. Use curated table measures, segments, filters, and breakouts when they make the generated app more useful.
  • Prefer semantic-layer definitions over React-side inference. If the schema has a segment or measure for a concept, use it instead of recreating the concept from raw rows. Both belong to the static query only — the dynamic second argument cannot take them, see Static and dynamic query parts.
  • Filter UI must default to showing data. Empty controls, "All" options, and incomplete custom ranges should produce no filter instead of blocking queries or showing a blank dashboard.
  • Do not hardcode categorical filter option values. A generated schema field only proves the field exists, not which values exist; query options from Metabase at runtime using the same generated schema field that the filter applies.
  • Dashboard-level filters should visibly affect every compatible card, table, KPI, and trend. If a filter can only apply to one query, make that scope obvious in the UI; do not show duplicate or no-op date controls.
  • Entity filters, where the stored value is an id/key and the UI shows a label, must use a single searchable combobox. Click/focus must open the option list immediately, before typing. Query options at runtime, search labels, and store the raw value. Never render entity filters as
    <select>
    ; plain selects are only for short closed enums explicitly provided by the user.
  • Use
    DateRangePopover
    from
    @metabase/embedding-sdk-react/data-app
    for custom date ranges: it wraps the app's own trigger element and opens a Metabase-styled range calendar under it. The trigger stays the app's — style it like the other filter controls — and
    useDateFormatter()
    from the same entry produces its label. It ships with the SDK and needs no dependency and no CSS import.
    DateRangeCalendar
    is the same calendar inline, for when the app already has a container. Do not install a date picker library for a range — not
    react-datepicker
    ,
    react-day-picker
    ,
    flatpickr
    , or a UI suite's picker (
    @mui/x-date-pickers
    ,
    antd
    ,
    rsuite
    , …). Do not use native
    <input type="date">
    either: its placeholder and calendar popover are browser-controlled, often show
    mm/dd/yyyy
    , and cannot be reliably themed.
  • Never build a date label with
    new Date("YYYY-MM-DD")
    — a date-only string parses as UTC and shows the previous day west of Greenwich. Use
    formatDateRange
    /
    formatDate
    from
    useDateFormatter()
    , which parse in local time and format in the instance's locale.
  • Reach for a third-party date picker only for what the SDK calendar does not cover, such as single-date or date-time selection;
    react-datepicker
    is the default pick. Then import its stylesheet (
    react-datepicker/dist/react-datepicker.css
    ), add small CSS overrides for the app's visual style if needed, and pass
    Date | null
    — never
    new Date("")
    or another invalid date for incomplete ranges; type strict callback parameters explicitly, such as
    onChange={(date: Date | null) => ...}
    .
  • Date bars must include Custom last by default: duration presets, All time, then Custom. Omit Custom only when the user explicitly asks for fixed presets only or no date range control.
  • Never invent aggregation or measure objects such as
    { name: "count" }
    or
    { name: "sum", field: ... }
    . Use generated table measures or exported aggregation helpers.
  • Only render values returned by Metabase or deterministic transforms of returned values. Do not invent KPI values, trends, labels, statuses, ratings, timestamps, rankings, insights, segments, or chart series.
  • Do not custom-render ambiguous business fields such as
    margin
    ,
    rate
    ,
    score
    ,
    percent
    ,
    health
    ,
    risk
    , or
    efficiency
    . Do not add
    %
    , multiply by 100, color-code, or render stars unless semantic-layer units explicitly support it; use an SDK table/chart, omit the field, or ask for curation.
  • Visualization data must come from Metabase through
    useMetabaseQuery
    or
    useMetabaseQueryObject
    with
    InteractiveQuestion
    /
    StaticQuestion
    . Do not hardcode chart-ready arrays, sample data, demo values, or schema-shaped mock values.
  • Render charts with
    InteractiveQuestion
    /
    StaticQuestion
    . When a custom visualization is allowed instead, and which of the two to use, is decided by the setup skill's Rendering a chart: Metabase first.
  • useMetabaseQueryObject(...)
    returns
    { query, error, isLoading }
    . Pass only the
    query
    property as
    card={{ query }}
    to
    InteractiveQuestion
    or
    StaticQuestion
    ; never pass the whole hook result as
    card.query
    .
  • useMetabaseQuery().rows
    are keyed objects, not tuple arrays. Never read
    row[0]
    /
    row[1]
    , and never silence this with
    as unknown as [string, number][]
    ,
    DisplayRow
    , or another tuple cast. If TypeScript says property
    0
    does not exist, it is catching a real bug. For typed
    data.rows
    , use literal keys such as
    row.count
    or generated field names such as
    row[ordersTable.fields.createdAt.name]
    . Use
    data.columns
    with
    rawRows
    or after explicitly narrowing a key; do not index typed rows with arbitrary
    string
    values from
    data.columns
    .
  • Do not cast query objects to
    Parameters<typeof useMetabaseQuery>[0]
    or to
    DefinedQuery
    . That erases the generated table/metric validation and the definition contract. Validate table ownership at the definition with
    defineQuery<typeof table>(...)
    ; the hooks take the export with no generics.
  • Do not build shared filter arrays with
    ReturnType<typeof filter>[]
    or
    push(...)
    ; this can collapse overload inference. Pass raw filter state between components and build each query's
    filters: [...]
    inline with spreads.
  • Keep runtime state out of the base query in
    queries/
    . A clause whose value comes from a control — a selected plan, a date range, a search box — belongs in the second argument to
    useMetabaseQuery
    /
    useMetabaseQueryObject
    , not in the query. See "Static and dynamic query parts".
  • Do not include
    fields
    in queries with
    aggregations
    and
    breakouts
    ; breakouts determine grouped result columns. Use
    fields
    only for row-selection queries.
  • Before rendering a field, verify it exists in the generated schema object and is returned by the query. Do not guess table keys, field keys, or column names from the Metabase API, business intuition, or old mock data; only use entries actually emitted in
    src/metabase.data.ts
    .
  • Avoid unsupported freshness or operational claims such as "real-time", "live", "understaffed", or "risk" unless the returned data or curated semantic-layer definition supports them.
  • Before claiming the work is done or preparing a final handoff, run a TypeScript type-only check and report the command/result. If the check fails, fix the type errors before any final summary.
保持语义层与表现层分离。
  • 所有Metabase上下文必须来自生成的schema文件,通常为
    src/metabase.data.ts
    或
    src/*.metabase.data.ts
    。
  • 构建React UI时,不得通过MCP工具发现数据、创建Metabase内容、创建表或编辑语义层。
  • 从
    @metabase/embedding-sdk-react/data-app
    导入数据应用查询辅助工具。
  • 每个查询都是根级
    queries/
    目录下的
    defineQuery(...)
    命名导出,每个操作都是根级
    actions/
    目录下的
    defineAction(...)
    命名导出,这两个目录均与
    package.json
    同级。在编写第一个钩子调用前先创建这两个目录;模板已自带这两个目录,每个目录下都有README。钩子会在编译时强制执行此规则:
    useMetabaseQuery
    、
    useMetabaseQueryObject
    和
    useAction
    会拒绝内联对象、
    satisfies MetabaseQueryOptions
    类型的对象以及定义的展开拷贝。报错信息为
    Property 'definedWithDefineQuery' is missing
    (或
    'definedWithDefineAction'
    );修复方法始终是将该对象作为定义移至
    queries/
    或
    actions/
    目录中并导入,绝不要使用类型断言。
  • 切勿删除、编辑或复制生成的
    savedQuestionSourceId
    或
    copiedActionId
    ,即使它们看起来未被使用。重构时也要保留;使用
    npm run sync-resources
    来修复或替换生成的ID。
  • 优先使用生成的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
    辅助函数(如
    aggregations.count()
    和
    aggregations.sum(...)
    )来构建查询。不得使用
    source: schema.metrics.<name>
    ;指标是聚合表达式,而非查询源。
  • 不得将现有已保存问题用作
    useMetabaseQuery
    或
    useMetabaseQueryObject
    的源。类型化schema不会暴露
    schema.questions
    ,也不支持
    question-collections
    ,因为数据应用对账无法将现有已保存问题复制到应用集合中。
  • 优先使用语义丰富的表查询,而非浅层的表数据导出。当精心设计的表度量、分段、过滤器和分组能让生成的应用更实用时,应优先使用它们。
  • 优先使用语义层定义,而非React端的推断。如果schema中已有某个概念的分段或度量,请直接使用,而不要从原始行数据重新创建该概念。两者都仅属于静态查询——动态的第二个参数无法接收它们,参见查询的静态与动态部分。
  • 过滤器UI默认必须展示数据。空控件、“全部”选项以及不完整的自定义范围应等同于无过滤器,而非阻塞查询或显示空白仪表盘。
  • 不得硬编码分类过滤器的选项值。生成的schema字段仅能证明字段存在,无法证明存在哪些值;请在运行时使用过滤器所应用的同一生成schema字段从Metabase查询选项。
  • 仪表盘级别的过滤器应能明显影响所有兼容的卡片、表格、KPI和趋势图。如果某个过滤器仅能应用于一个查询,请在UI中明确说明其作用范围;不要显示重复或无效的日期控件。
  • 实体过滤器(存储值为ID/键,UI显示标签)必须使用单个可搜索的combobox。点击/聚焦时必须立即打开选项列表,无需先输入。运行时查询选项、搜索标签,并存储原始值。切勿将实体过滤器渲染为
    <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
    ,它们会按本地时间解析,并按实例的区域设置格式化。
  • 仅当SDK日历无法满足需求时(如单日选择或日期时间选择),才使用第三方日期选择器;默认选择
    react-datepicker
    。然后导入其样式表(
    react-datepicker/dist/react-datepicker.css
    ),如需可添加少量CSS覆盖以适配应用的视觉风格,并传递
    Date | null
    类型的值——绝不要为不完整的范围传入
    new Date("")
    或其他无效日期;请显式设置严格的回调参数类型,例如
    onChange={(date: Date | null) => ...}
    。
  • 日期选项栏默认必须包含“自定义”且置于最后:时长预设、全部时间,然后是自定义。仅当用户明确要求仅使用固定预设或不需要日期范围控件时,才省略自定义选项。
  • 切勿发明聚合或度量对象,例如
    { name: "count" }
    或
    { name: "sum", field: ... }
    。请使用生成的表度量或导出的聚合辅助函数。
  • 仅渲染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
    ;绝不要将整个钩子结果作为
    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>(...)
    验证表所有权;钩子接收导出时无需泛型。
  • 不要使用
    ReturnType<typeof filter>[]
    或
    push(...)
    构建共享过滤器数组;这会导致重载推断失效。请在组件之间传递原始过滤器状态,并通过展开运算符内联构建每个查询的
    filters: [...]
    。
  • 不要将运行时状态放入
    queries/
    的基础查询中。值来自控件的子句(所选计划、日期范围、搜索框)应放在
    useMetabaseQuery
    /
    useMetabaseQueryObject
    的第二个参数中,而非查询定义内。参见“查询的静态与动态部分”。
  • 带有
    aggregations
    和
    breakouts
    的查询中不要包含
    fields
    ;分组决定了结果列的分组方式。
    fields
    仅用于行选择查询。
  • 渲染字段前,请验证该字段存在于生成的schema对象中,且由查询返回。不要根据Metabase API、业务直觉或旧的模拟数据猜测表键、字段键或列名;仅使用
    src/metabase.data.ts
    中实际生成的条目。
  • 避免做出无依据的新鲜度或运营性声明,例如“实时”、“在线”、“人员不足”或“风险”,除非返回的数据或精心整理的语义层定义支持这些说法。
  • 在声称工作完成或准备最终交付前,请运行TypeScript纯类型检查并报告命令/结果。如果检查失败,请先修复类型错误,再给出最终总结。

Generate Schema

生成Schema

If the schema file already exists, use it. If it is missing or stale, treat schema generation as semantic-layer curation for this data app, not a mechanical export.
Choose the export scope before generating:
  1. Honor an explicit scope. Otherwise infer the required content types from the app's purpose: tables, curated metrics, or actions. For example, "show orders" needs tables; row counts and sums can also use table aggregations.
  2. Choose the narrowest supported scope that covers those needs, using collection IDs and database names or IDs from the request or project context. When the library type is clear but a narrower collection is unknown, use that library's whole tree.
  3. Ask only for context needed to select a scope, such as which database to use when the request requires a database scope but does not identify one. Once the scope is determined, state it briefly and generate without waiting for confirmation.
Scope parameters:
  • include-data-library=true
    for the whole
    Library / Data
    tree.
  • include-metric-library=true
    for the whole
    Library / metrics
    tree.
  • library-collections=<id-or-entity-id>[,<id-or-entity-id>]
    for specific Data or metrics library subcollections.
  • include-models=true
    for readable models that have actions. When combined with
    database=<name-or-id>
    , it includes models with actions for that database only.
  • database=<name-or-id>
    when the app should use tables from one database. Use it separately from library scopes; the API rejects that combination.
Combine library scopes when the app needs both tables and curated metrics.
Use
include-models=true
when the app needs any saved action under
schema.models.<model>.actions
; it includes all readable models with executable actions, unless
database
scopes them to one database. Models without executable actions are omitted to keep generated schemas compact. It can be combined with
library-collections
,
include-data-library
, or
include-metric-library
so one schema can include selected tables/metrics plus all readable actions.
If the user asks for any mutation-like flow, such as creating, updating, deleting, submitting, approving, executing an action, or running a write operation, include
include-models=true
in the typed-schema URL. Do this even when the user names one specific model/action, because actions are only discoverable through generated model entries.
The Metabase URL and API key live in the repo-root
.env.local
as
DATA_APP_MB_URL
and
DATA_APP_MB_API_KEY
(one file per repo, usually two levels up from the app dir, not in the app dir). The command below
source
s that file so the shell substitutes the values straight into
curl
— you never read, extract, or handle the credentials yourself.
Never ask the user to paste the API key into the chat, and never
cat
/
echo
.env.local
— it's git-ignored and may hold other secrets, so its contents must stay out of the conversation.
source
it so the shell uses the values without exposing them. If
$DATA_APP_MB_API_KEY
or
$DATA_APP_MB_URL
is empty or still set to the default
mb_replace_me
placeholder after sourcing, ask the user to add real values themselves, then continue.
Source the credentials from the repo-root
.env.local
and generate the scoped schema. The example below exports table data from the Data library; replace its query parameters with the scope chosen above:
bash
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"
)
After a successful export, verify that the schema contains every entity needed for the requested app. If any are missing, revise the scope using available context or ask for the missing context before building the UI.
If schema generation fails while building a selected model or model action, do not hide, paraphrase away, or retry past the error. Surface the typed-schema error to the user, including the failing
card-id
/
card-name
/
card-type
,
model-id
/
model-name
, dropped action ids, and message when present.
如果schema文件已存在,请直接使用。如果文件缺失或过时,请将schema生成视为该数据应用的语义层整理工作,而非机械导出。
生成前先选择导出范围:
  1. 遵循明确指定的范围。否则根据应用用途推断所需的内容类型:表、精心整理的指标或操作。例如,“展示订单”需要表;行计数和求和也可以使用表聚合。
  2. 使用请求或项目上下文中的集合ID和数据库名称或ID,选择能覆盖这些需求的最窄支持范围。当库类型明确但未知更窄的集合时,使用该库的整个树。
  3. 仅询问选择范围所需的上下文,例如当请求需要数据库范围但未指定数据库时,询问使用哪个数据库。确定范围后,简要说明并直接生成,无需等待确认。
范围参数:
  • include-data-library=true
    :包含整个
    库 / 数据
    树。
  • include-metric-library=true
    :包含整个
    库 / 指标
    树。
  • library-collections=<id-or-entity-id>[,<id-or-entity-id>]
    :包含特定的数据或指标库子集合。
  • include-models=true
    :包含具有操作的可读模型。与
    database=<name-or-id>
    结合使用时,仅包含该数据库的带操作模型。
  • database=<name-or-id>
    :当应用应使用单个数据库中的表时使用。请与库范围分开使用;API会拒绝该组合。
当应用同时需要表和精心整理的指标时,请组合使用库范围。
当应用需要
schema.models.<model>.actions
下的任何已保存操作时,请使用
include-models=true
;它会包含所有具有可执行操作的可读模型,除非
database
将其范围限定为单个数据库。为保持生成的schema紧凑,不包含无可执行操作的模型。它可以与
library-collections
、
include-data-library
或
include-metric-library
结合使用,这样一个schema可以包含选定的表/指标以及所有可读操作。
如果用户要求任何类似变更的流程,例如创建、更新、删除、提交、审批、执行操作或运行写入操作,请在类型化schema的URL中加入
include-models=true
。即使用户指定了一个特定的模型/操作也要这么做,因为操作只能通过生成的模型条目发现。
Metabase URL和API密钥存储在仓库根目录的
.env.local
中,变量名为
DATA_APP_MB_URL
和
DATA_APP_MB_API_KEY
(每个仓库一个文件,通常在应用目录的上两级,而非应用目录内)。下面的命令会
source
该文件,以便shell将值直接替换到
curl
中——你永远不需要读取、提取或处理凭据本身。
切勿要求用户将API密钥粘贴到聊天中,也切勿使用
cat
/
echo
查看
.env.local
—— 该文件已被git忽略,且可能包含其他密钥,因此其内容绝不能出现在对话中。请
source
该文件,让shell在不暴露值的情况下使用它们。如果执行source后,
$DATA_APP_MB_API_KEY
或
$DATA_APP_MB_URL
为空,或仍为默认的
mb_replace_me
占位符,请让用户自行添加真实值后再继续。
从仓库根目录的
.env.local
中加载凭据,并生成限定范围的schema。以下示例从数据库导出表数据;请将其查询参数替换为上面选择的范围:
bash
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"
)
导出成功后,请验证schema包含请求的应用所需的所有实体。如果有缺失,请使用可用上下文修改范围,或在构建UI前请求缺失的上下文。
如果在构建选定的模型或模型操作时schema生成失败,不要隐藏、淡化错误或重试跳过错误。请向用户展示类型化schema错误,包括失败的
card-id
/
card-name
/
card-type
、
model-id
/
model-name
、被丢弃的操作ID(如有)以及错误消息。

Synchronize every query and action

同步所有查询与操作

Everything an end-to-end prototype runs is permission-bound: it runs against a copy in the app's own collection; read access to that collection lets viewers run the app's cards, but they see the data only if they already have access to the underlying tables — the app grants the collection, not the tables. Declare each one as a named export in a root-level directory beside
package.json
—
queries/
for
defineQuery(...)
,
actions/
for
defineAction(...)
.
npm run sync-resources
scans only those two directories, so a definition under
src/queries/
,
src/actions/
, or any other source directory is silently never synchronized. Discovery covers
.js
,
.jsx
,
.ts
,
.tsx
,
.cjs
,
.cts
,
.mjs
, and
.mts
.
ts
import { defineAction, defineQuery } from "@metabase/embedding-sdk-react/data-app";
import schema from "../src/metabase.data";

// queries/revenue.query.ts
export const RevenueQuery = defineQuery({ source: schema.tables.orders });

// actions/orders.action.ts
export const CreateOrder = defineAction({
  action: schema.models.orders.actions.create,
});
One
sync-resources
run reconciles both. For a query it materializes the authored table query as a saved question and injects
savedQuestionSourceId
. For an action it copies the action's parent model into the app collection, copies the action onto that copy, and injects
copiedActionId
; a model is copied once no matter how many of its actions the app declares, siblings reuse that copy, and it disappears with the last declaration. Never copy a model into the app collection by hand.
Pass the definition itself to the hook and let the SDK resolve what runs — a production build runs the copy, while the dev preview runs the authored table or action, so an app works before its first synchronization:
ts
const { data } = useMetabaseQuery(RevenueQuery, {
  filters: [filter(RevenueQuery.source.fields.status, "=", selectedStatus)],
});

const { execute, isExecuting, error } = useAction(CreateOrder);
Never pass an inline table-source query (not even a read-only, filter-option, or helper query), a raw action id,
savedQuestionSourceId
,
copiedActionId
, or a hand-built
{ source: { type: "card", id } }
, and never spread a definition into a new object. Each defeats the swap; the authored ids also bypass the permission boundary. TypeScript rejects most of these: the hooks accept only what
defineQuery
/
defineAction
returned, so an inline object, a
satisfies
-typed object, a spread copy, and
schema.models.<model>.actions.<action>
all fail to compile. When
tsc
reports
Property 'definedWithDefineQuery' is missing
or
Property 'definedWithDefineAction' is missing
, the argument is not a definition: move it into
queries/
or
actions/
and import the export. Do not silence it with a cast or by wrapping the inline object in
defineQuery(...)
at the call site, which compiles but leaves the query unsynchronized. Keep fixed permission-boundary filters, aggregations, and breakouts inside
defineQuery
— synchronization bakes them into the saved question, so don't apply them again outside it, and put runtime clauses in the hook's second argument (see Static and dynamic query parts).
useAction
needs no generics: the definition types
execute
's parameters and
result
.
Wire
package.json
with
"sync-resources": "embedding-sdk-react data-apps sync-resources"
and
"build": "npm run sync-resources && vite build"
, then run
npm run build
after adding, changing, renaming, or removing any definition; run
sync-resources
directly only to inspect generated state before a build. It reads
DATA_APP_MB_URL
and
DATA_APP_MB_API_KEY
from the repo-root
.env.local
.
Inline generated IDs and
resources_metadata.json
are generated state: never delete or hand-edit either. A missing ID is restored automatically when the definition still identifies its resource — a query by its table and authored hash matching one unclaimed lockfile entry, an action by naming the same action — while a duplicated ID fails the run. Do not test or hand off the app until
npm run build
succeeds, every live definition carries a positive generated ID, and
resources_metadata.json
holds its matching entry. Commit every generated change. The build stops before bundling when synchronization fails.
If synchronization fails, surface the exact error and stop. Fix local shape, serialization, duplicate-ID, or lockfile errors before retrying. A confirmed
404
is recovered automatically; authentication, permission, network, server, collection-ownership, and Card-type failures must not trigger manual Card creation, deletion, ID replacement, or lockfile editing. Treat a successful run that discovers nothing as a failure when the app has queries or actions. Synchronization copies actions but never creates them, so an action the app needs must already exist in Metabase and be picked up by a regenerated schema; if the run reports that actions are not enabled for the database, stop and tell the user to enable them rather than working around it.
端到端原型运行的所有内容都受权限约束:它针对应用自身集合中的副本运行;对该集合的读取权限让查看者可以运行应用的卡片,但他们只有在已有底层表的访问权限时才能看到数据——应用授予的是集合权限,而非表权限。请在
package.json
旁的根级目录中将每个项声明为命名导出——
queries/
用于
defineQuery(...)
,
actions/
用于
defineAction(...)
。
npm run sync-resources
仅扫描这两个目录,因此
src/queries/
、
src/actions/
或任何其他源目录下的定义会被静默忽略,永远不会同步。发现范围覆盖
.js
、
.jsx
、
.ts
、
.tsx
、
.cjs
、
.cts
、
.mjs
和
.mts
文件。
ts
import { defineAction, defineQuery } from "@metabase/embedding-sdk-react/data-app";
import schema from "../src/metabase.data";

// queries/revenue.query.ts
export const RevenueQuery = defineQuery({ source: schema.tables.orders });

// actions/orders.action.ts
export const CreateOrder = defineAction({
  action: schema.models.orders.actions.create,
});
一次
sync-resources
运行会同时对账两者。对于查询,它会将编写的表查询具体化为已保存问题,并注入
savedQuestionSourceId
。对于操作,它会将操作的父模型复制到应用集合中,将操作复制到该副本上,并注入
copiedActionId
;无论应用声明了一个模型的多少个操作,该模型只会被复制一次,同级操作会复用该副本,当最后一个声明被移除时副本也会随之删除。切勿手动将模型复制到应用集合中。
将定义本身传递给钩子,让SDK解析运行的内容——生产构建运行副本,而开发预览运行编写的表或操作,因此应用在首次同步前即可工作:
ts
const { data } = useMetabaseQuery(RevenueQuery, {
  filters: [filter(RevenueQuery.source.fields.status, "=", selectedStatus)],
});

const { execute, isExecuting, error } = useAction(CreateOrder);
切勿传递内联表源查询(即使是只读、过滤器选项或辅助查询也不行)、原始操作ID、
savedQuestionSourceId
、
copiedActionId
,或手动构建的
{ source: { type: "card", id } }
,也绝不要将定义展开到新对象中。每一种做法都会破坏切换机制;编写的ID还会绕过权限边界。TypeScript会拒绝其中大多数情况:钩子仅接收
defineQuery
/
defineAction
返回的内容,因此内联对象、
satisfies
类型的对象、展开拷贝以及
schema.models.<model>.actions.<action>
都会编译失败。当
tsc
报告
Property 'definedWithDefineQuery' is missing
或
Property 'definedWithDefineAction' is missing
时,说明参数不是定义:将其移至
queries/
或
actions/
并导入导出项。不要用类型断言或在调用处用
defineQuery(...)
包裹内联对象来屏蔽错误,这样虽然能编译,但查询不会被同步。将固定的权限边界过滤器、聚合和分组放在
defineQuery
内部——同步会将它们固化到已保存问题中,因此不要在外部再次应用它们,并将运行时子句放在钩子的第二个参数中(参见查询的静态与动态部分)。
useAction
不需要泛型:定义会为
execute
的参数和
result
提供类型。
在
package.json
中配置
"sync-resources": "embedding-sdk-react data-apps sync-resources"
和
"build": "npm run sync-resources && vite build"
,然后在添加、更改、重命名或删除任何定义后运行
npm run build
;仅在构建前检查生成状态时才直接运行
sync-resources
。它会从仓库根目录的
.env.local
中读取
DATA_APP_MB_URL
和
DATA_APP_MB_API_KEY
。
内联的生成ID和
resources_metadata.json
是生成的状态:切勿删除或手动编辑其中任何一个。如果定义仍能识别其资源——查询通过其表和编写的哈希匹配一个未认领的锁文件条目,操作通过命名相同的操作——缺失的ID会自动恢复,而重复的ID会导致运行失败。在
npm run build
成功、每个生效的定义都带有有效的生成ID、且
resources_metadata.json
包含其匹配条目之前,不要测试或交付应用。提交所有生成的更改。同步失败时,构建会在打包前停止。
如果同步失败,请展示确切的错误并停止。重试前请修复本地结构、序列化、重复ID或锁文件错误。确认的
404
会自动恢复;身份验证、权限、网络、服务器、集合所有权或Card类型的失败绝不能触发手动Card创建、删除、ID替换或锁文件编辑。当应用有查询或操作时,如果一次成功的运行未发现任何内容,应视为失败。同步会复制操作但绝不会创建操作,因此应用需要的操作必须已存在于Metabase中,并被重新生成的schema拾取;如果运行报告数据库未启用操作,请停止并告知用户启用它们,而不要绕过该限制。

Standard pattern

标准模式

Two files per query: the definition in
queries/
, the hook call in the component.
ts
// 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,
});
tsx
// 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(...)
infers typed row data from the definition, so write no generics on the hook. To check table ownership of fields, segments, and measures, put the generic on the definition:
defineQuery<typeof ordersTable>({ ... })
. Leave it off selected-field queries when you need precise row keys from
data.rows
. The recipes below show the object passed to
defineQuery
; each one is an export in
queries/
, never an argument written at the hook.
Call each schema entry at most once per render tree. Multiple
useMetabaseQuery
calls on the same
questionId
(or same
tableId
+ identical filters/measures/breakouts) mount independent subscriptions, fire duplicate queries, and let consumers disagree mid-load. Lift the call to the highest component that needs the data; pass
data
/
isLoading
/
error
down as props. Different ids — or the same id with different filters / breakouts — are different data sources; call them separately.
Use keyed schema objects:
  • Tables:
    source: schema.tables.<table>
  • metrics:
    schema.metrics.<metric>
    inside
    aggregations
  • Fields:
    schema.tables.<table>.fields.<field>
  • Segments:
    schema.tables.<table>.segments.<segment>
  • Measures:
    schema.tables.<table>.measures.<measure>
  • metric dimensions:
    schema.metrics.<metric>.dimensions.<group>.<dimension>
Do not pass raw dimension strings like
"created_at"
or
"segment"
.
每个查询对应两个文件:
queries/
中的定义,以及组件中的钩子调用。
ts
// 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,
});
tsx
// 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(...)
会从定义推断类型化的行数据,因此不要在钩子上写泛型。要检查字段、分段和度量的表所有权,请在定义上使用泛型:
defineQuery<typeof ordersTable>({ ... })
。当你需要从
data.rows
获取精确的行键时,对于选定字段的查询请省略泛型。下面的示例展示了传递给
defineQuery
的对象;每个都是
queries/
中的导出项,绝不是写在钩子处的参数。
每个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"
。

Static and dynamic query parts

查询的静态与动态部分

Both query hooks take an optional second argument: the clauses that change while the app runs.
ts
// 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)],
});
Split them this way even when nothing appears to depend on it: the first argument must be identical on every render, and only the second may vary with runtime state.
The dynamic clauses run as their own stage, so they see the result columns of the static query, not its source table. That is why
plan
is a breakout above: a control that filters on a source column only works if that column survives into the result. If it does not, add it as a breakout, or leave the static query unaggregated. Likewise, filter an aggregated static query on
count
/
sum
, not on the fields behind them.
Segments and measures belong to the static part only. They are defined against a table, and the dynamic stage has no table — so
filters: [orders.segments.completed]
and
aggregations: [orders.measures.revenue]
are rejected, at compile time and again at runtime. This is the one place the usual "prefer the curated definition" rule does not apply.
Put the curated definition in the static query where it resolves, and let the dynamic clause work on what came out:
ts
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)],
});
If a control must switch a segment on and off, that is a choice between static queries, not a dynamic clause: define one query per state and pick the query, or express the same condition as a filter on a result column.
Do not remove or hand-edit
savedQuestionSourceId
if you find it on a query object, or
copiedActionId
on an action definition. Both are generated synchronization state — see Synchronize every query and action.
两个查询钩子都接受可选的第二个参数:应用运行时会变化的子句。
ts
// revenue.query.ts — 静态的,每次渲染都完全相同
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),
  ],
});

// 组件仅提供UI会更改的部分
const { data } = useMetabaseQuery(RevenueQuery, {
  filters: plan === null ? [] : [filter(orders.fields.plan, "=", plan)],
});
即使看起来没有依赖,也要按这种方式拆分:第一个参数必须在每次渲染时都完全相同,只有第二个参数可以随运行时状态变化。
动态子句作为独立的阶段运行,因此它们看到的是静态查询的结果列,而非其源表。这就是为什么上面的
plan
是一个分组:对源列进行过滤的控件只有在该列保留到结果中时才能工作。如果没有,请将其添加为分组,或让静态查询不进行聚合。同样,对聚合后的静态查询进行过滤时,应基于
count
/
sum
,而非它们背后的字段。
分段和度量仅属于静态部分。 它们是针对表定义的,而动态阶段没有表——因此
filters: [orders.segments.completed]
和
aggregations: [orders.measures.revenue]
会在编译时和运行时被拒绝。这是通常的“优先使用精心设计的定义”规则不适用的唯一情况。
将精心设计的定义放在静态查询中使其生效,让动态子句作用于输出结果:
ts
export const CompletedOrders = defineQuery({
  source: orders,
  filters: [orders.segments.completed], // 分段在此处生效
  aggregations: [orders.measures.revenue],
  breakouts: [breakout(orders.fields.plan)],
});

const { data } = useMetabaseQuery(CompletedOrders, {
  // 结果列,而非分段或度量
  filters: plan === null ? [] : [filter({ type: "column", name: "PLAN" }, "=", plan)],
});
如果控件必须切换某个分段的开关,这是静态查询之间的选择,而非动态子句:为每个状态定义一个查询并选择查询,或将相同条件表达为对结果列的过滤。
如果在查询对象上发现
savedQuestionSourceId
,或在操作定义上发现
copiedActionId
,请勿删除或手动编辑。两者都是生成的同步状态——参见同步所有查询与操作。

Table query recipes

表查询示例

For a table query, pass the generated table object as
source
:
ts
// queries/records.query.ts
const recordsTable = schema.tables.records;

export const RecordStatuses = defineQuery({
  source: recordsTable,
  fields: [recordsTable.fields.id, recordsTable.fields.status],
});
For grouped table summaries, include at least one aggregation:
ts
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" })],
});
For basic aggregations without a curated measure, use the
aggregations
helpers:
ts
export const AmountByCategory = defineQuery({
  source: recordsTable,
  aggregations: [
    aggregations.count(),
    aggregations.sum(recordsTable.fields.amount),
  ],
  breakouts: [breakout(recordsTable.fields.category)],
});
When using the same helper more than once, Metabase may return numbered runtime keys such as
sum
,
sum_2
, and
sum_3
. TypeScript only models the base helper key today. For custom KPI code that intentionally uses repeated same-kind aggregations, read through
data.columns
or cast the row to
Record<string, unknown>
before accessing numbered keys. Prefer curated measures or separate queries when that is clearer.
Table fields, segments, measures, filters, breakouts, and orderBys must come from the queried table. Use
defineQuery<RecordsTable>({ ... })
when you want TypeScript to validate that ownership at the definition.
对于表查询,将生成的表对象作为
source
传递:
ts
// queries/records.query.ts
const recordsTable = schema.tables.records;

export const RecordStatuses = defineQuery({
  source: recordsTable,
  fields: [recordsTable.fields.id, recordsTable.fields.status],
});
对于分组的表汇总,至少包含一个聚合:
ts
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
辅助函数:
ts
export const AmountByCategory = defineQuery({
  source: recordsTable,
  aggregations: [
    aggregations.count(),
    aggregations.sum(recordsTable.fields.amount),
  ],
  breakouts: [breakout(recordsTable.fields.category)],
});
当多次使用同一个辅助函数时,Metabase可能会返回带编号的运行时键,例如
sum
、
sum_2
和
sum_3
。目前TypeScript仅建模基础辅助函数键。对于有意使用重复同类聚合的自定义KPI代码,请通过
data.columns
读取,或将行断言为
Record<string, unknown>
后再访问带编号的键。如果更清晰的话,优先使用精心设计的度量或单独的查询。
表字段、分段、度量、过滤器、分组和排序必须来自被查询的表。当你希望TypeScript在定义处验证所有权时,请使用
defineQuery<RecordsTable>({ ... })
。

metric aggregation recipes

指标聚合示例

For a metric-backed query, pass the generated table object as
source
and the generated metric object in
aggregations
:
ts
// queries/revenue.query.ts
const ordersTable = schema.tables.orders;
const revenueMetric = schema.metrics.revenue;

export const Revenue = defineQuery({
  source: ordersTable,
  aggregations: [revenueMetric],
});
Use generated metric dimensions for filters and breakouts in queries that aggregate the owning metric. Dimensions from the metric's source table work directly. Dimensions from related tables also work when the generated field includes
sourceFieldId
; prefer those related-table dimensions for readable labels instead of grouping by raw foreign key IDs:
ts
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);
Queries backed by metrics can include helper aggregations over generated metric dimensions. They can also use compatible saved Segments and Measures from the table source when the generated schema exposes them:
ts
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)],
});
A metric aggregation must belong to the table source. Do not use source-card metrics in table-source queries. Generated metric dimensions are scoped to their owning metric: if a query uses
revenueMetric.dimensions.*
in filters, helper aggregations, breakouts, or orderBys, it must also include
revenueMetric
in
aggregations
. Do not use metric dimensions as standalone table fields for unrelated
count()
or table-measure queries. Generated metric dimensions must also resolve to the table source. Use
defineQuery<typeof ordersTable>({ ... })
when you want TypeScript to validate that at the definition.
对于指标支持的查询,将生成的表对象作为
source
传递,并将生成的指标对象放在
aggregations
中:
ts
// queries/revenue.query.ts
const ordersTable = schema.tables.orders;
const revenueMetric = schema.metrics.revenue;

export const Revenue = defineQuery({
  source: ordersTable,
  aggregations: [revenueMetric],
});
在聚合所属指标的查询中,使用生成的指标维度进行过滤和分组。指标源表的维度可直接使用。当生成的字段包含
sourceFieldId
时,相关表的维度也可使用;优先使用这些相关表维度以获得可读的标签,而非按原始外键ID分组:
ts
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",
    }),
  ],
});

// 优先使用可读的相关表维度(如果有)。
breakout(revenueMetric.dimensions.franchises.name);

// 当存在相关表维度时,避免使用原始FK ID。
breakout(revenueMetric.dimensions.orders.franchiseId);
指标支持的查询可以包含基于生成的指标维度的辅助聚合。当生成的schema暴露了表源中的兼容已保存分段和度量时,也可以使用它们:
ts
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)],
});
指标聚合必须属于表源。不得在表源查询中使用源卡片指标。生成的指标维度的作用域限定为其所属指标:如果查询在过滤器、辅助聚合、分组或排序中使用
revenueMetric.dimensions.*
,则它还必须在
aggregations
中包含
revenueMetric
。不得将指标维度用作独立表字段,用于不相关的
count()
或表度量查询。生成的指标维度还必须解析到表源。当你希望TypeScript在定义处验证这一点时,请使用
defineQuery<typeof ordersTable>({ ... })
。

SDK-rendered views

SDK渲染视图

Table fields, segments, measure aggregations, and metric aggregations must come from the queried table. Generated metric dimensions used in filters, helper aggregations, breakouts, and orderBys must resolve to the queried table and belong to a metric included in the same query's
aggregations
. When table queries use
fields
,
segments
,
aggregations
,
breakouts
, or
orderBys
, let
defineQuery
infer the shape, or write
defineQuery<typeof recordsTable>
when ownership validation matters more than precise result-row keys.
表字段、分段、度量聚合和指标聚合必须来自被查询的表。在过滤器、辅助聚合、分组和排序中使用的生成指标维度必须解析到被查询的表,且属于同一查询
aggregations
中包含的某个指标。 当表查询使用
fields
、
segments
、
aggregations
、
breakouts
或
orderBys
时,让
defineQuery
推断结构,或者当所有权验证比精确的结果行键更重要时,编写
defineQuery<typeof recordsTable>
。

Interactive Metabase Views

交互式Metabase视图

Whether an element is an SDK question at all — and whether it is
StaticQuestion
or
InteractiveQuestion
— is decided in the data-app setup skill (Rendering a chart: Metabase first). Once it is: declare the query in
queries/
, resolve it with
useMetabaseQueryObject(TheQuery)
, then pass the result through the SDK question component's
card
prop.
useMetabaseQueryObject
supports generated table queries, including metric aggregations. Use
useMetabaseQuery
when custom React needs direct row data; use
useMetabaseQueryObject
when Metabase should render or manage the visualization. Do not pass generics to
useMetabaseQueryObject
; it returns
{ query, error, isLoading }
, not query result rows.
The examples under Rendering With SDK Components use
return null
for minimal loading and error handling. In a real app, render the app's existing loading or error UI there. Passing
card={{ query }}
is safe while
query
is
null
; do not pass the full
{ query, error, isLoading }
hook result as
card.query
.
When wrapping
useMetabaseQueryObject
in a reusable chart/card component, destructure and render
error
; do not read only
{ query }
, because query-construction failures otherwise look like endless loading. Calling the hook inside that child component is valid React. Do not call hooks directly inside loops, conditions, or callbacks in the parent component.
Wrong/right pattern:
tsx
const trendQuery = useMetabaseQueryObject(TrendQuery);
<InteractiveQuestion card={{ query: trendQuery }} />; // wrong

const { query: trendQuery } = useMetabaseQueryObject(TrendQuery);
<InteractiveQuestion card={{ query: trendQuery }} />; // right
Hook typing:
  • Both hooks take a
    defineQuery
    export imported from
    queries/
    and nothing else; an inline object is a compile error. Write no generics on the hooks.
  • useMetabaseQuery(...)
    infers typed row data from the definition. Put
    defineQuery<typeof table>
    on the definition when ownership validation matters.
  • useMetabaseQueryObject(...)
    returns
    { query, error, isLoading }
    . Pass the
    query
    property to
    card={{ query }}
    .
  • Do not use
    as Parameters<typeof useMetabaseQuery>[0]
    or
    as DefinedQuery
    to quiet query typing errors. The first hides invalid table fields, metric aggregations, and breakouts; the second hides an unsynchronized query that fails in production.
The basic prop contract is:
  • Generated table query, including metric aggregations:
    <StaticQuestion card={{ query }} />
  • Full interactive question:
    <InteractiveQuestion card={{ query }} />
When you need the set of SDK-supported question displays, do not copy a local list. In generated apps, search
node_modules/@metabase/embedding-sdk-react/dist/index.d.ts
for the exact declaration
declare const cardDisplayTypes: readonly [...]
and use that tuple as the source of truth. Do not read the whole declaration file into context.
Always pass SDK-rendered ad hoc questions with a
card
object. Start with
card={{ query }}
when the user has not asked for a specific chart type and Metabase defaults can infer a reasonable display from the query. Use
card={{ query, visualization }}
when the user request or design calls for a specific chart type, such as a pie chart for a distribution, but does not ask for setting-level customization. Add
visualizationSettings
only when the user explicitly asks for a setting-level presentation change, such as hiding or renaming an axis label, showing value labels, stacking bars, adding a goal line, ordering table columns, showing pie totals/labels, or controlling series/slice order. Search
node_modules/@metabase/embedding-sdk-react/dist/index.d.ts
for
export declare type MetabaseCard
, the relevant
*VisualizationSettings
type, and any setting key you plan to use. Read the JSDoc comments attached to those declarations, then use the TypeScript declarations as the source of truth for legal
visualization
and
visualizationSettings
combinations. Build the query with
useMetabaseQueryObject
; do not call internal query resolution helpers, cast through
any
, or hardcode settings from memory.
For lightweight descriptions of the exposed settings and when to use them, read references/visualization-settings.md. Treat that file as guidance only; the installed SDK declaration decides what is legal.
Before writing a
card
, check
node_modules/@metabase/embedding-sdk-react/dist/data-app.d.ts
for the
useMetabaseQueryObject
return type. Destructure the returned
query
and use that value in
card.query
; for configured cards, type the object with
satisfies MetabaseCard
. If TypeScript reports duplicate opaque
DatasetQuery
symbols, do not force a cast; update the SDK package before using
card
.
Do not invent alternate prop names for generated queries or visualization settings. If the SDK type says a prop does not exist, believe it and use the documented
card
prop shape.
When
useMetabaseQuery
is needed, map typed rows into an explicit local view model using named properties before rendering:
ts
const orderedAtKey = ordersTable.fields.orderedAt.name;

const chartRows = (data?.rows ?? []).map((row) => ({
  label: String(row[orderedAtKey] ?? "Unknown"),
  value: row.count,
}));
If the result key only comes from
data.columns
at runtime, use
rawRows
with the matching column position, or narrow the key to a literal before indexing
data.rows
.
一个元素是否为SDK问题——以及它是
StaticQuestion
还是
InteractiveQuestion
——由数据应用设置技能(渲染图表:优先使用Metabase)决定。确定后:在
queries/
中声明查询,用
useMetabaseQueryObject(TheQuery)
解析它,然后通过SDK问题组件的
card
属性传递结果。
useMetabaseQueryObject
支持生成的表查询,包括指标聚合。当自定义React需要直接的行数据时使用
useMetabaseQuery
;当应由Metabase渲染或管理可视化时使用
useMetabaseQueryObject
。不要向
useMetabaseQueryObject
传递泛型;它返回
{ query, error, isLoading }
,而非查询结果行。
使用SDK组件渲染下的示例使用
return null
进行最小化的加载和错误处理。在实际应用中,请在此处渲染应用现有的加载或错误UI。当
query
为
null
时传递
card={{ query }}
是安全的;不要将完整的
{ query, error, isLoading }
钩子结果作为
card.query
传递。
当将
useMetabaseQueryObject
包装在可复用的图表/卡片组件中时,请解构并渲染
error
;不要只读取
{ query }
,否则查询构建失败看起来会像无限加载。在子组件内部调用钩子是有效的React做法。不要在父组件的循环、条件或回调中直接调用钩子。
错误/正确模式:
tsx
const trendQuery = useMetabaseQueryObject(TrendQuery);
<InteractiveQuestion card={{ query: trendQuery }} />; // 错误

const { query: trendQuery } = useMetabaseQueryObject(TrendQuery);
<InteractiveQuestion card={{ query: trendQuery }} />; // 正确
钩子类型:
  • 两个钩子都接收从
    queries/
    导入的
    defineQuery
    导出项,不接受其他内容;内联对象会导致编译错误。不要在钩子上写泛型。
  • useMetabaseQuery(...)
    会从定义推断类型化的行数据。当所有权验证很重要时,请在定义上使用
    defineQuery<typeof table>
    。
  • useMetabaseQueryObject(...)
    返回
    { query, error, isLoading }
    。将
    query
    属性传递给
    card={{ query }}
    。
  • 不要使用
    as Parameters<typeof useMetabaseQuery>[0]
    或
    as DefinedQuery
    来消除查询类型错误。前者会隐藏无效的表字段、指标聚合和分组;后者会隐藏未同步的查询,这类查询在生产环境中会失败。
基本的属性约定是:
  • 生成的表查询,包括指标聚合:
    <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
中使用该值;对于已配置的卡片,请使用
satisfies MetabaseCard
为对象添加类型。如果TypeScript报告重复的不透明
DatasetQuery
符号,不要强制断言;使用
card
前请更新SDK包。
不要为生成的查询或可视化设置发明替代属性名。如果SDK类型表示某个属性不存在,请相信它,并使用已记录的
card
属性结构。
当需要使用
useMetabaseQuery
时,在渲染前将类型化的行映射到具有命名属性的显式本地视图模型:
ts
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
前将键缩小为字面量。

Rendering With SDK Components

使用SDK组件渲染

Chart only, without the toolbar:
tsx
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>
);
Configured SDK visualization:
tsx
// 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>
);
仅图表,不带工具栏:
tsx
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可视化:
tsx
// queries/events.query.ts
export const TotalAmountByMonth = defineQuery({
  source: eventsTable,
  aggregations: [eventsTable.measures.totalAmount],
  breakouts: [breakout(eventsTable.fields.occurredAt, { unit: "month" })],
});

// 组件
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>
);

Filters And Breakouts

过滤器与分组

Use helpers because they give better autocomplete and shorter errors.
ts
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", "num-bins": 10 },
});
breakout(ordersTable.fields.state);

orderBy(ordersTable.fields.createdAt, "desc", { unit: "month" });
Do not hand-write
orderBys
object literals such as
{ field, direction }
or
{ fieldId, direction }
; use
orderBy(...)
. When ordering the same date field used by a date breakout, pass the same
unit
to both
breakout(...)
and
orderBy(...)
.
For top-N grouped summaries, order by the aggregation result, not the raw source field. Store the aggregation helper in a local constant and pass that same constant to both
aggregations
and
orderBy(...)
:
ts
// 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,
});
For user-selectable sorting, the sort is runtime state, so it belongs in the hook's second argument. Build a typed map of allowed generated fields instead of indexing the whole
fields
object:
ts
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
is the unaggregated
defineQuery({ source: scorecardTable })
export in
queries/
; its source fields survive into the result, so the dynamic stage can order by them.
For metric queries, pass generated metric dimensions to
filter(...)
and
breakout(...)
:
ts
filter(revenueMetric.dimensions.orders.status, "=", "paid");
breakout(revenueMetric.dimensions.orders.createdAt, { unit: "month" });
Filter operator rules:
  • 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
Only date dimensions can use
unit
. Non-date dimensions can be used as breakouts without
unit
; numeric dimensions can use
binning
.
Segments are already filters:
ts
filters: [
  schema.tables.records.segments.activeRecords,
  filter(schema.tables.records.fields.amount, ">", 100),
];
Use curated segments first when they exactly match the product intent. Use
filter(...)
when the UI needs a threshold, category, date range, text match, boolean condition, or other narrowing that is not already represented by a curated segment.
请使用辅助函数,因为它们提供更好的自动补全和更简短的错误信息。
ts
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", "num-bins": 10 },
});
breakout(ordersTable.fields.state);

orderBy(ordersTable.fields.createdAt, "desc", { unit: "month" });
不要手写
orderBys
对象字面量,例如
{ field, direction }
或
{ fieldId, direction }
;请使用
orderBy(...)
。对日期分组使用的同一日期字段排序时,请向
breakout(...)
和
orderBy(...)
传递相同的
unit
。
对于前N名的分组汇总,请按聚合结果排序,而非原始源字段。将聚合辅助函数存储在局部常量中,并将同一个常量同时传递给
aggregations
和
orderBy(...)
:
ts
// 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,
});
对于用户可选的排序,排序是运行时状态,因此它属于钩子的第二个参数。构建一个允许的生成字段的类型化映射,而非索引整个
fields
对象:
ts
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(...)
:
ts
filter(revenueMetric.dimensions.orders.status, "=", "paid");
breakout(revenueMetric.dimensions.orders.createdAt, { unit: "month" });
过滤器运算符规则:
  • 字符串:
    =
    、
    !=
    、
    contains
    、
    does-not-contain
    、
    starts-with
    、
    ends-with
    、
    is-empty
    、
    not-empty
    、
    is-null
    、
    not-null
  • 数字:
    =
    、
    !=
    、
    >
    、
    >=
    、
    <
    、
    <=
    、
    between
    、
    is-null
    、
    not-null
  • 日期:
    =
    、
    !=
    、
    >
    、
    >=
    、
    <
    、
    <=
    、
    between
    、
    time-interval
    、
    is-null
    、
    not-null
  • 布尔:
    =
    、
    is-null
    、
    not-null
仅日期维度可以使用
unit
。非日期维度可以不带
unit
用作分组;数字维度可以使用
binning
。
分段本身就是过滤器:
ts
filters: [
  schema.tables.records.segments.activeRecords,
  filter(schema.tables.records.fields.amount, ">", 100),
];
当精心设计的分段与产品意图完全匹配时,优先使用它们。当UI需要阈值、类别、日期范围、文本匹配、布尔条件或其他尚未由精心设计的分段表示的缩小条件时,请使用
filter(...)
。

Filter UI Patterns

过滤器UI模式

When the user asks for custom filters, build normal React controls that feed semantic query filters.
Before implementing filters, create a filter contract for the visible dashboard. At minimum, identify:
  • For each filter, name the runtime query that provides its options.
  • For each filter, name the raw value used in
    filter(...)
    .
  • For each card, table, KPI, and trend, name the generated table field or metric dimension that can receive that filter.
  • If a filter only applies to one section, keep it section-scoped or omit it from the global filter bar.
  • If a page needs a different date field such as
    snapshotDate
    , use one visible date control for that page.
  • KPI/detail pairs that describe the same concept should use the same relevant filters.
Use the detailed checklist in
references/filter-ui-patterns.md
for filter state rules, runtime categorical options, stale option reset, searchable controls, and custom date-picker implementation.
For the common memoized date/category filter shape:
tsx
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
above is what
<DateRangePopover value={customRange} onChange={setCustomRange}>
stores, so there is nothing to convert. When a fallback picker's state uses
Date | null
, convert selected dates with a local
YYYY-MM-DD
formatter before passing them to
filter(..., "between", range)
. Do not use
date.toISOString().split("T")[0]
for local date filters.
当用户要求自定义过滤器时,构建普通的React控件来为语义查询过滤器提供输入。
实现过滤器之前,为可见的仪表盘创建一份过滤器契约。至少要明确:
  • 每个过滤器,命名提供其选项的运行时查询。
  • 每个过滤器,命名在
    filter(...)
    中使用的原始值。
  • 每个卡片、表格、KPI和趋势图,命名可以接收该过滤器的生成表字段或指标维度。
  • 如果某个过滤器仅应用于一个部分,请将其保持在部分范围内,或从全局过滤器栏中省略。
  • 如果页面需要不同的日期字段(如
    snapshotDate
    ),为该页面使用一个可见的日期控件。
  • 描述同一概念的KPI/详情对应使用相同的相关过滤器。
有关过滤器状态规则、运行时分类选项、过期选项重置、可搜索控件和自定义日期选择器实现,请使用
references/filter-ui-patterns.md
中的详细清单。
对于常见的带记忆的日期/分类过滤器结构:
tsx
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]
处理本地日期过滤器。

Result Shape And Charts

结果结构与图表

  • Prefer keyed
    data.rows
    .
  • Never treat
    data.rows
    as positional arrays. Do not use
    row[0]
    ,
    row[1]
    ,
    DisplayRow
    , or tuple casts for
    useMetabaseQuery
    row objects.
  • Inspect
    data.columns
    before mapping low-level
    rawRows
    , but do not use arbitrary
    data.columns[].name
    strings to index typed
    data.rows
    .
  • Runtime row objects are keyed by returned Metabase column names, usually
    column.name
    such as
    total_amount
    or
    average_score
    . Do not assume generated schema keys like
    totalAmount
    or
    averageScore
    are runtime row keys.
  • For generated field references, the React property path and runtime result key can differ:
    schema.tables.orders.fields.orderedAt.name
    might be
    ordered_at
    . Use
    row[ordersTable.fields.orderedAt.name]
    ,
    row.count
    , or
    data.columns
    metadata instead of guessing
    row.orderedAt
    .
  • Treat row values as nullable. Guard before calling number/string methods such as
    toFixed
    ,
    toLocaleString
    , or string transforms.
  • Use
    rawRows
    only for known positional shapes.
  • Aggregation columns may be named
    count
    ,
    sum
    , or
    avg
    ; match metadata when needed.
  • If a custom visualization needs several helper aggregations with the same output name, such as multiple
    aggregations.sum(...)
    calls, prefer separate single-aggregation queries so each typed row has the known
    sum
    key. If one multi-aggregation query is necessary, read
    rawRows
    by column position after checking
    data.columns
    ; do not depend on generated names like
    sum_2
    unless they are explicitly typed or narrowed in the app code.
  • Grouped queries can include a
    null
    breakout bucket. Render it as
    "Unknown"
    or filter it out deliberately.
  • Time-series charts need multiple ordered buckets. Do not fake sparklines for scalar or one-point results.
  • Multi-series charts with different units or magnitudes need separate axes or normalization.
  • Format user-facing values: currency to at most 2 decimals, counts as whole numbers, dates as readable labels.
  • Do not render ambiguous derived business values unless the semantic layer description or inspected sample values make the meaning and units obvious.
  • Empty results are distinct from loading. After
    isLoading
    is false, render a clear empty state instead of leaving a skeleton or blank KPI.
  • 优先使用键控的
    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(...)
    调用,优先使用单独的单聚合查询,这样每个类型化的行都有已知的
    sum
    键。如果必须使用一个多聚合查询,请在检查
    data.columns
    后按列位置读取
    rawRows
    ;不要依赖生成的名称(如
    sum_2
    ),除非它们在应用代码中被显式类型化或缩小范围。
  • 分组查询可能包含
    null
    分组桶。将其渲染为
    "Unknown"
    或有意过滤掉。
  • 时间序列图表需要多个有序的桶。不要为标量或单点结果伪造迷你图。
  • 具有不同单位或量级的多序列图表需要单独的轴或归一化。
  • 格式化面向用户的值:货币最多保留2位小数,计数为整数,日期为可读标签。
  • 不要渲染含义模糊的派生业务值,除非语义层描述或检查过的示例值使其含义和单位明确无误。
  • 空结果与加载不同。当
    isLoading
    为false后,渲染清晰的空状态,而非保留骨架屏或空白KPI。

Presentation Guidance

呈现层指南

Prefer Metabase-rendered panels for chart-shaped and table-shaped data. The React app may group, sort, format, and derive display-only values from
data.rows
when a custom panel is justified, but do not make custom panels the default.
Good transforms:
  • Group rows for summaries.
  • Sort and slice rows for ranked lists only when a custom list is clearly better than a Metabase row/bar/table visualization.
  • Pick chart types from actual data shape, and prefer Metabase
    bar
    ,
    line
    ,
    area
    ,
    row
    ,
    combo
    ,
    pivot
    , and
    table
    displays before writing custom chart code.
  • Show loading, error, and empty states.
  • Bound dense result displays. Tables, alert lists, logs, and ranked lists should use a top-N slice, grouping, pagination, or a fixed/max-height scroll area so a large result set cannot stretch the entire page.
When a page feels like a raw table browser, look for schema-backed ways to enrich it:
  • Use segments for curated subsets like active, completed, overdue, high-priority, or needs-attention records.
  • Use measures for curated aggregations instead of recalculating everything ad hoc in React.
  • Use metrics when the schema exposes a curated metric aggregation for the page's core business question.
  • Use filters to focus the query on the UI's intent.
  • Use breakouts to create trends, category comparisons, and grouped summaries.
  • If the enriched result is still a sortable/drillable table, render it with SDK visualization components instead of rebuilding table behavior in React.
Avoid manual classification when the semantic layer already has the concept. Prefer curated segments, fields, or measures over string matching, threshold heuristics, or category reconstruction in React.
If no curated schema entry supports the intended UI, leave the section out or ask for semantic-layer curation. Do not keep mock data or placeholder analytics in the finished app.
对于图表类和表格类数据,优先使用Metabase渲染的面板。当有充分理由使用自定义面板时,React应用可以对
data.rows
进行分组、排序、格式化和派生仅用于显示的值,但不要将自定义面板作为默认选项。
合适的转换:
  • 为汇总对行进行分组。
  • 仅当自定义列表明显优于Metabase的行/条形/表格可视化时,才对行进行排序和切片以生成排名列表。
  • 根据实际数据形状选择图表类型,在编写自定义图表代码之前,优先使用Metabase的
    bar
    、
    line
    、
    area
    、
    row
    、
    combo
    、
    pivot
    和
    table
    显示方式。
  • 显示加载、错误和空状态。
  • 限制密集结果的显示范围。表格、警报列表、日志和排名列表应使用前N切片、分组、分页或固定/最大高度滚动区域,这样大型结果集不会拉伸整个页面。
当页面感觉像原始表浏览器时,寻找基于schema的方法来丰富它:
  • 使用分段来表示精心设计的子集,例如活跃、已完成、逾期、高优先级或需要关注的记录。
  • 使用精心设计的聚合度量,而非在React中临时重新计算所有内容。
  • 当schema为页面的核心业务问题暴露了精心设计的指标聚合时,使用指标。
  • 使用过滤器将查询聚焦于UI的意图。
  • 使用分组来创建趋势、类别比较和分组汇总。
  • 如果丰富后的结果仍然是可排序/可下钻的表格,请使用SDK可视化组件渲染它,而非在React中重建表格行为。
当语义层已有该概念时,避免手动分类。优先使用精心设计的分段、字段或度量,而非在React中进行字符串匹配、阈值启发式或类别重建。
如果没有精心设计的schema条目支持预期的UI,请省略该部分或请求进行语义层整理。不要在完成的应用中保留模拟数据或占位分析。

Final Checks

最终检查

  • Run
    npm run typecheck
    .
    Property 'definedWithDefineQuery' is missing
    or
    Property 'definedWithDefineAction' is missing
    means a hook received something other than a
    queries/
    or
    actions/
    export; move the object there and import it.
  • Search touched files for
    useMetabaseQuery(
    ,
    useMetabaseQueryObject(
    , and
    useAction(
    . The first argument must be an identifier imported from
    queries/
    or
    actions/
    ; a
    {
    , a
    defineQuery(
    , a
    defineAction(
    , or a spread there is wrong even when it compiles. The second argument of the query hooks is the dynamic object and is written inline.
  • Confirm
    queries/
    and
    actions/
    sit beside
    package.json
    , not under
    src/
    , and that every definition the app renders lives there.
  • Run
    npm run build
    ; it synchronizes queries before producing the bundle.
  • Keep TypeScript diagnostics compact in the chat or handoff. Use the full output locally to fix the app, but report grouped root causes and only a few representative diagnostics instead of pasting the entire
    tsc
    output.
  • Verify every rendered value can be traced to a returned row property, schema field, measure, or deterministic transform.
  • Search touched files for
    row[0]
    ,
    row[1]
    ,
    row.orderedAt
    ,
    row.orderDate
    ,
    as unknown as
    ,
    DisplayRow
    ,
    <select
    ,
    margin
    ,
    rate
    ,
    score
    ,
    percent
    ,
    %
    ,
    * 100
    , and
    .toFixed
    ; fix positional rows, result-key guesses, entity
    <select>
    filters, and unsupported business-field interpretations.
  • Verify every date preset bar includes Custom last unless explicitly omitted, every visible date filter affects the current page, and no page shows duplicate date filters for one scope.
  • Verify
    data_app.yaml
    points at the built bundle path and that the bundle path is tracked by git.
  • For every visible filter, verify "All" maps to no filter, selected values come from runtime query results, and each non-All option changes every card it claims to affect.
  • 运行
    npm run typecheck
    。
    Property 'definedWithDefineQuery' is missing
    或
    Property 'definedWithDefineAction' is missing
    表示钩子接收到的不是
    queries/
    或
    actions/
    的导出项;将对象移至该目录并导入。
  • 在修改过的文件中搜索
    useMetabaseQuery(
    、
    useMetabaseQueryObject(
    和
    useAction(
    。第一个参数必须是从
    queries/
    或
    actions/
    导入的标识符;即使能编译,那里出现
    {
    、
    defineQuery(
    、
    defineAction(
    或展开运算符也是错误的。查询钩子的第二个参数是动态对象,应内联编写。
  • 确认
    queries/
    和
    actions/
    位于
    package.json
    旁,而非
    src/
    下,且应用渲染的每个定义都位于那里。
  • 运行
    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>
    过滤器以及不受支持的业务字段解释。
  • 验证每个日期预设栏都将“自定义”置于最后(除非明确省略),每个可见的日期过滤器都影响当前页面,且没有页面为一个作用域显示重复的日期过滤器。
  • 验证
    data_app.yaml
    指向构建后的包路径,且该包路径由git跟踪。
  • 对于每个可见的过滤器,验证“全部”映射为无过滤器,选定的值来自运行时查询结果,且每个非“全部”选项都会改变它声称影响的所有卡片。

Common Mistakes

常见错误

  • Creating or searching for Metabase content during app building.
  • Writing the query object at the hook call instead of exporting it from
    queries/
    with
    defineQuery
    , or the action at
    useAction
    instead of from
    actions/
    with
    defineAction
    . Both are compile errors now; the fix is the directory, not a cast.
  • Wrapping the inline object in
    defineQuery(...)
    or
    defineAction(...)
    at the call site. It compiles, but
    sync-resources
    never sees it, so it is refused in production.
  • Putting definitions under
    src/queries/
    or
    src/actions/
    , where
    sync-resources
    never looks.
  • Importing older hooks instead of
    useMetabaseQuery
    .
  • Copying raw numeric IDs into constants instead of using generated schema objects.
  • Inventing ad hoc measure objects such as
    { name: "count" }
    or
    { name: "sum", field: fieldId }
    .
  • Passing raw strings for table fields.
  • Adding lookup helpers instead of using keyed generated schema objects.
  • Inventing SDK component prop names instead of using
    query
    for generated table queries.
  • Mixing fields, segments, or measures from unrelated tables.
  • Passing a segment or measure to the dynamic second argument, where only result columns resolve.
  • Adding a filter UI that sends empty values instead of omitting the filter.
  • Hardcoding categorical filter values instead of querying the runtime values from Metabase.
  • Displaying entity names but filtering by those names when a stable ID is available.
  • Applying a dashboard-level filter to only one KPI while related charts and tables ignore it.
  • Showing a global Date Range plus a page-specific Snapshot Date where one date filter has no effect.
  • Letting a KPI and its detail table use different date or category filters without explaining the difference.
  • Rendering
    Margin
    /
    Rate
    /
    Score
    /
    Health
    with invented
    %
    , stars, colors, or thresholds.
  • Shipping a date preset bar with no Custom range option, or Custom before All time.
  • Charting opaque IDs such as
    franchise_id
    when a user-facing name is available.
  • Rendering an entity filter in a plain
    <select>
    , even if the current runtime option list is short.
  • Reaching for native
    <input type="date">
    or any date picker dependency (
    react-datepicker
    ,
    react-day-picker
    , a UI suite's picker) for a date range instead of
    DateRangePopover
    , and shipping browser-controlled
    mm/dd/yyyy
    placeholders or unthemed calendar popovers.
  • Labelling a date trigger with
    new Date("YYYY-MM-DD").toLocaleDateString()
    instead of
    useDateFormatter()
    , so the label is a day early for users west of Greenwich.
  • Assuming
    filter(...)
    fully validates value types.
  • Letting a
    null
    bucket become the latest time-series point.
  • Hardcoding business values, labels, timestamps, or rankings.
  • Creating chart-ready arrays by hand instead of deriving them from queried
    data.rows
    .
  • Casting typed SDK rows to generic tuple rows such as
    [string, number]
    .
  • Reading generated object property names such as
    row.orderedAt
    when Metabase returns column names such as
    ordered_at
    .
  • Rendering fields that are not present in the schema or returned query result.
  • Rendering
    No data
    while the SDK is still authenticating or loading.
  • Creating nested
    MetabaseProvider
    instances instead of sharing one provider at the app boundary.
  • 在应用构建过程中创建或搜索Metabase内容。
  • 在钩子调用处编写查询对象,而非用
    defineQuery
    从
    queries/
    导出,或在
    useAction
    处编写操作,而非用
    defineAction
    从
    actions/
    导出。两者现在都是编译错误;修复方法是移动到对应目录,而非类型断言。
  • 在调用处用
    defineQuery(...)
    或
    defineAction(...)
    包裹内联对象。它能编译,但
    sync-resources
    永远看不到它,因此在生产环境中会被拒绝。
  • 将定义放在
    src/queries/
    或
    src/actions/
    下,而
    sync-resources
    永远不会查看那里。
  • 导入旧的钩子而非
    useMetabaseQuery
    。
  • 将原始数字ID复制到常量中,而非使用生成的schema对象。
  • 发明临时度量对象,例如
    { name: "count" }
    或
    { name: "sum", field: fieldId }
    。
  • 为表字段传递原始字符串。
  • 添加查找辅助函数,而非使用键控的生成schema对象。
  • 发明SDK组件属性名,而非对生成的表查询使用
    query
    。
  • 混合来自不相关表的字段、分段或度量。
  • 将分段或度量传递给动态的第二个参数,而那里仅解析结果列。
  • 添加发送空值的过滤器UI,而非省略过滤器。
  • 硬编码分类过滤器值,而非从Metabase查询运行时值。
  • 显示实体名称但按这些名称过滤,而此时有可用的稳定ID。
  • 仅将仪表盘级过滤器应用于一个KPI,而相关图表和表格忽略它。
  • 显示全局日期范围加上页面特定的快照日期,其中一个日期过滤器无效。
  • 让KPI及其详情表格使用不同的日期或分类过滤器,而不解释差异。
  • 用发明的
    %
    、星级、颜色或阈值渲染
    Margin
    /
    Rate
    /
    Score
    /
    Health
    。
  • 交付没有自定义范围选项的日期预设栏,或自定义在“全部时间”之前。
  • 当有面向用户的名称可用时,仍绘制不透明ID(如
    franchise_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仍在进行身份验证或加载时渲染“无数据”。
  • 创建嵌套的
    MetabaseProvider
    实例,而非在应用边界共享一个提供器。