Define metrics¶
This guide walks through creating a semantic model, defining a metric, and publishing it so the AI assistant will use it.
See Semantic layer for what these definitions mean and why they are shaped this way.
Before you start¶
You need workspace writer to author definitions and owner to publish
them. You also need at least metadata access to every table the model binds —
the same access reading that table's schema needs.
1. Create a model¶
Semantic models → New model. A model is one subject area. Keep it focused: accuracy falls off noticeably past about ten tables, and the fix is to split it rather than to grow it.
The identifier is used in URLs and by the assistant. It can be renamed later without breaking anything — the model's identity is not its name.
2. Bind datasets¶
A dataset is a logical table pointing at a physical one.
curl -X POST "$DH/api/workspaces/$WS/semantic/models/sales/datasets" \
-H 'Content-Type: application/json' \
-d '{
"name": "orders",
"catalog": "warehouse",
"schema_name": "analytics",
"table_name": "orders",
"primary_key": ["id"]
}'
Declare primary_key on any dataset you intend to join to. Without it, that
dataset cannot be the unique side of a relationship — a join without a key
cannot promise one match per row.
3. Declare joins¶
curl -X POST "$DH/api/workspaces/$WS/semantic/models/sales/relationships" \
-H 'Content-Type: application/json' \
-d '{
"name": "orders_to_customers",
"left_dataset": "orders",
"right_dataset": "customers",
"join_columns": [{"left": "customer_id", "right": "id"}]
}'
left is the many side. Traversal always runs left → right, so put the fact
table on the left. one_to_many is not accepted: see
why joins only point one way.
4. Add dimensions¶
At least one time dimension, marked as the dataset's default:
curl -X POST "$DH/api/workspaces/$WS/semantic/models/sales/dimensions" \
-H 'Content-Type: application/json' \
-d '{
"name": "order_date",
"dataset": "orders",
"kind": "time",
"time_grains": ["day", "week", "month", "quarter", "year"],
"is_default_time": true
}'
And the categorical ones people slice by. Give them synonyms and a few sample values:
curl -X POST "$DH/api/workspaces/$WS/semantic/models/sales/dimensions" \
-H 'Content-Type: application/json' \
-d '{
"name": "country",
"dataset": "customers",
"display_name": "Country",
"synonyms": ["nation", "market"],
"sample_values": ["United States", "Canada"]
}'
Sample values are what let "customers in the US" find rows stored as
United States instead of returning an empty result with no explanation.
5. Define the metric¶
curl -X POST "$DH/api/workspaces/$WS/semantic/models/sales/metrics" \
-H 'Content-Type: application/json' \
-d '{
"name": "revenue",
"dataset": "orders",
"display_name": "Revenue",
"description": "Net booked revenue from placed orders.",
"agg": "sum",
"expr": "total_amount",
"filter": "status <> '\''test'\''",
"time_dimension": "order_date",
"synonyms": ["turnover", "gmv"],
"caveat": "Excludes internal test orders."
}'
Two fields do the most work here:
filterapplies every single time this metric is computed. It is where "excluding test orders" belongs, so it can never be forgotten.time_dimensiondecides which column a time filter uses. Without it, a question about "last month" may silently measure on the wrong date.
caveat is surfaced with every answer the metric produces, so a reader sees it
at the moment they see the number.
The shortcut: save one from a worksheet¶
Most metrics are first written as SQL in a worksheet. Rather than retyping the calculation here, highlight the expression and choose Save as metric… from the worksheet toolbar.
DuckHaven splits what it can prove: SUM(total_amount) AS revenue arrives as
agg: sum, expr: total_amount, named revenue. Anything it cannot split
faithfully — an arithmetic expression, two aggregates added together — arrives
whole in the expression field with sum as a starting point, for you to correct.
Both fields are on the form, so a wrong reading is visible before you save.
You still choose the model and the dataset, because a worksheet cannot know which subject area a calculation belongs to. The metric is created as a draft, like any other.
6. Validate¶
Validation resolves every binding against the live catalog: that the tables exist, that every column an expression names is still there, and that each declared join actually joins on a primary key.
In the UI, Validate does the same and lists whatever failed.
7. Check the SQL¶
Open the model and look at the Preview under each metric. It shows the SQL that definition compiles to, generated by the same endpoint the assistant uses — so what you read is what will run.
Compile without executing at any time:
curl -X POST "$DH/api/workspaces/$WS/semantic/compile?published_only=false" \
-H 'Content-Type: application/json' \
-d '{
"model": "sales",
"metrics": ["revenue"],
"dimensions": ["country"],
"grain": "month",
"time_range": {"kind": "last_complete", "grain": "month", "n": 3}
}'
8. Publish¶
Publishing needs workspace owner and validates first. Until it succeeds, the assistant does not see the model at all.
Importing instead of authoring¶
A whole model can be published from a YAML document, which is the right route when definitions live in version control:
version: 1
models:
- slug: sales
name: Sales
datasets:
- name: orders
catalog: warehouse
schema: analytics
table: orders
primary_key: [id]
- name: customers
catalog: warehouse
schema: analytics
table: customers
primary_key: [id]
relationships:
- name: orders_to_customers
left: orders
right: customers
join: [{ left: customer_id, right: id }]
dimensions:
- name: order_date
dataset: orders
kind: time
default_time: true
- name: country
dataset: customers
synonyms: [nation]
sample_values: ["United States", "Canada"]
metrics:
- name: revenue
dataset: orders
agg: sum
expr: total_amount
filter: "status <> 'test'"
measured_on: order_date
synonyms: [turnover]
caveat: Excludes internal test orders.
curl -X POST "$DH/api/workspaces/$WS/semantic/imports/duckhaven" \
-H 'Content-Type: text/plain' \
--data-binary @semantic.yaml
Notes on importing:
- By default the payload is treated as the complete set for that provider,
and models it no longer declares are retired. Pass
?reconcile=nonewhen publishing a subset. - A typo in one metric costs that metric, not the whole file. Anything unusable
comes back in
skippedwith a reason. - Imported models are read-only in the UI. A model has exactly one owner, so edit it at the source and import again.
- Imports arrive as drafts. Publishing stays a person's decision.
Removing a definition¶
Definitions are meant to be corrected, so removing one needs no more ceremony than adding it — workspace writer, and the table underneath is never touched:
Two removals have consequences worth knowing before you make them.
Deleting a dimension is refused while a metric is measured on it. Rebind the
metric to another time dimension, or remove it, and the delete succeeds. The
reason is worth stating: a metric whose time axis is merely absent looks
exactly like one that never had an axis, and the compiler answers that kind
using the dataset's default date. Clearing the binding would therefore start
measuring revenue on created_at instead of order_date — the same question,
a different number, and no error anywhere. The metric is never deleted as a side
effect either way.
Deleting a dataset is refused while anything still binds it, and the error names every dimension, metric and relationship in the way:
{
"error": "dataset_in_use",
"message": "'orders' still has dimension 'order_date', metric 'revenue'.",
"details": {
"dependents": ["dimension 'order_date'", "metric 'revenue'"]
}
}
The bindings cascade in the database, so allowing this would quietly destroy every definition on the dataset. Refusing costs one extra step and makes the blast radius something you chose rather than discovered.
Reusing a name is refused the same way: each of datasets, dimensions,
metrics and relationships is unique within a model, and a repeat comes back
as 409 naming the clash.
Imported models are removed at their source
DELETE on a definition in an imported model returns 409, like every
other edit. Deleting it here would only last until the next import.
Testing that it works¶
Ask the assistant a question the model covers:
What was revenue last month, by country?
The tool-call trail should show query_metric naming the model and metric, with
the compiled SQL. If it shows run_sql with hand-written aggregation instead,
the metric is probably not published, or its synonyms do not include the words
being used.