Semaphor
Semantic Domains

Agent Guide

Build semantic domain templates programmatically with complete schema reference and validation rules

This guide is for AI agents and automation scripts that generate domain templates as JSON. It provides the complete schema specification, construction rules, and validation checklist needed to produce valid templates programmatically.

Approach

Domain templates can be applied in two ways:

  1. Template tab: Generate JSON, paste it into the Domain Editor's Template tab, and save
  2. Public API: PUT /api/v1/domains/{domainId}/template with the template in the request body (see Domain Templates API)

Both paths accept the same template format and run the same validation. Generate templates with schemaVersion: "2.1".

Templates saved in the 2.0 shape

Domains saved before version 2.1 keep working: they read as 2.1, and they are stored as 2.1 the next time they are saved. GET on the template API always returns 2.1. The API still accepts 2.0 bodies (calculatedMetrics, calculatedDimensions, datasets with type or fields.metrics, relationships with sourceDataset) and upgrades them on save, but refuses a body that mixes 2.0 and 2.1 keys (mixed_schema_versions). The relationship endpoints accept only from/to.


Template JSON Schema

Top-Level Structure

{
  "schemaVersion": "2.1",
  "aiContext": { "instructions": "..." },
  "datasets": [],
  "relationships": [],
  "metrics": [],
  "composedDimensions": [],
  "grainMappings": []
}
FieldTypeRequiredDefaultDescription
schemaVersion"2.1"YesSchema version
aiContextAiContextNoContext for the whole domain
datasetsSemanticDataset[]No[]Dataset definitions
relationshipsRelationship[]No[]Join definitions
metricsMetric[]No[]Every metric: DatasetMetric and ComposedMetric
composedDimensionsComposedDimension[]No[]Domain-level row expressions over inputs
grainMappingsGrainMapping[]NoCross-dataset dimension alignment

SemanticDataset

{
  "name": "orders",
  "label": "Orders",
  "description": "Customer order transactions",
  "aiContext": "One row per order. Exclude status = 'cancelled' from revenue.",
  "analyticsRole": "fact",
  "source": {
    "kind": "table",
    "connectionId": "conn_abc123",
    "connectionType": "PostgreSQL",
    "schema": "public",
    "table": "orders"
  },
  "primaryKey": ["order_id"],
  "uniqueKeys": [["order_number"]],
  "primaryDateField": "order_date",
  "fields": {
    "identifiers": [],
    "facts": [],
    "dimensions": []
  }
}
FieldTypeRequiredConstraints
namestringYesUnique within domain
labelstringYesDisplay name
descriptionstringNo
aiContextAiContextNo
analyticsRoleenumNo"fact", "dimension", "event", "snapshot", "lookup", "bridge", or "unknown"
sourceDatasetSourceYesWhere rows come from
primaryKeystring[]NoField names that identify one row. Defaults to []. Set it for fan-out detection
uniqueKeysstring[][]NoOther field-name lists that identify one row
primaryDateFieldstringNoName of a date or datetime dimension
fieldsobjectYesContains identifiers, facts, dimensions

analyticsRole is semantic planning metadata. It helps governed query execution and MCP diagnostics understand whether a dataset is a fact table, dimension, snapshot, lookup, bridge, or event table. It does not create relationships or grant access by itself.

DatasetSource

{ "kind": "table", "connectionId": "conn_abc123", "connectionType": "PostgreSQL", "database": "analytics", "schema": "public", "table": "orders" }
{ "kind": "sql", "connectionId": "conn_abc123", "connectionType": "PostgreSQL", "sql": "SELECT ... FROM public.orders" }
FieldTypeRequiredConstraints
kindenumYes"table" or "sql"
connectionIdstringYesMust match an existing connection
connectionTypeenumYesSee connection types below
databasestringNoDatabase, catalog, or project (BigQuery, Snowflake)
schemastringNoDatabase schema
tablestringNoTable or view name for kind: "table". Defaults to the dataset name
sqlstringFor kind: "sql"The query that returns the rows. Fully qualify tables; no trailing semicolon

Connection types: "PostgreSQL", "MySQL", "MSSQL", "BigQuery", "Redshift", "Snowflake", "clickhouse", "S3", "GoogleSheets", "FileUpload", "API", "none"

Common Field Properties

Identifiers, facts, and dimensions share these properties:

FieldTypeRequiredConstraints
namestringYesUnique within the dataset, across all field lists and the dataset's metrics
labelstringNoDisplay name
descriptionstringNo
aiContextAiContextNo
dataTypeenumYesLogical type: "string", "number", "boolean", "date", "datetime", "json", "geo", "unknown"
nativeDataTypestringNoThe database's exact type, e.g. "numeric(12,2)" or "timestamp with time zone"
sourceFieldstringSee belowPhysical column name
hiddenbooleanNo
tagsstring[]No

Put the logical type in dataType and the database type in nativeDataType. A 2.1 template must use a logical type in dataType; a database type there is refused with a SCHEMA error. A template in the 2.0 shape, and the inline metric and dimension endpoints, still accept a database type in dataType: Semaphor stores the logical type there and moves the original value to nativeDataType.

IdentifierField

{
  "name": "order_id",
  "label": "Order ID",
  "description": "Unique order identifier",
  "dataType": "number",
  "nativeDataType": "bigint",
  "sourceField": "order_id",
  "type": "primary"
}
FieldTypeRequiredConstraints
typeenumYes"primary" (a column of the dataset's key) or "foreign"

Declare the key itself with the dataset's primaryKey. Join planning reads primaryKey, not the identifier type.

DimensionField

{
  "name": "order_date",
  "label": "Order Date",
  "dataType": "date",
  "sourceField": "order_date",
  "granularity": "month",
  "format": {
    "type": "temporal_bucket",
    "presentation": {
      "mode": "pattern",
      "pattern": "MMM YYYY"
    }
  },
  "isDisplayField": false,
  "hidden": false,
  "tags": []
}
FieldTypeRequiredConstraints
sourceFieldstringConditionalPhysical column name. Required when there is no expression
expressionstringConditionalRow-level SQL expression. Required when there is no sourceField. See Inline Expressions
granularityenumNo"second", "minute", "hour", "day", "week", "month", "quarter", or "year"; valid only for date/datetime, and date-only fields reject sub-day grains
formatobjectNoStructured temporal_bucket presentation. Requires granularity and must be compatible with that grain. Omit it, or use mode: "auto", to use resolved viewer preferences.
isDisplayFieldbooleanNoDefault display label for dataset

Every dimension needs sourceField or expression. A dimension expression is row-level: aggregate functions are refused (aggregate_in_row_field). It can reference the dataset's identifiers and dimensions, plus the column in its own sourceField.

format.presentation is one of:

{ "mode": "auto" }
{ "mode": "preset", "preset": "quarter_short" }
{ "mode": "pattern", "pattern": "MMM YYYY" }

Supported presets are week_of_short, week_of_long, quarter_short, quarter_long, fiscal_quarter_short, and fiscal_year. Patterns use the bounded Semaphor token vocabulary (YYYY, YY, MMMM, MMM, MM, DD, HH, mm, and ss) rather than arbitrary date-library tokens. Presets and patterns are grain-specific and invalid combinations are rejected when the domain is validated.

An explicit card format overrides the semantic format. If neither is explicit, Semaphor uses the viewer's Display formats preferences.

FactField

{
  "name": "amount",
  "label": "Revenue",
  "dataType": "number",
  "nativeDataType": "numeric(12,2)",
  "sourceField": "amount",
  "defaultAggregate": "SUM",
  "unit": "USD",
  "isDefault": false,
  "hidden": false,
  "tags": []
}
FieldTypeRequiredConstraints
sourceFieldstringConditionalPhysical column. Required when there is no expression
expressionstringConditionalRow-level SQL expression. Required when there is no sourceField
defaultAggregateenumRecommendedDefault rollup: "SUM", "COUNT", "AVG", "MIN", "MAX", "MEDIAN", or "DISTINCT"
formatstring | objectNoDisplay format hint
unitstringNoUnit label
isDefaultbooleanNoMark as the dataset's default measure
filtersarrayNoRow conditions applied whenever the fact is aggregated. Column facts only
comparisonPolarityenumNo"higher_is_better", "lower_is_better", "neutral"

A fact is a row-level quantity. Its expression never aggregates: SUM(...) in a fact is refused (aggregate_in_row_field) and belongs in a DatasetMetric.

defaultAggregate is the semantic default used by dashboards, MCP semaphor_analyze, and other governed query paths. New generated templates should include it; a fact without one is treated as SUM.

Common choices:

Fact meaningAggregate
Additive amount, quantity, value, cost, revenue, margin, duration, or weight"SUM"
Count or countable indicator"COUNT"
Rate, ratio, percentage, score, price, or per-unit measurement"AVG"
Highest or lowest observed value"MAX" or "MIN"
Median-sensitive distribution"MEDIAN"
Unique-value count"DISTINCT"

Metric

Every metric lives in the top-level metrics list in one of two forms. A metric with both dataset and inputs, or neither, is refused.

DatasetMetric aggregates one dataset's rows:

{
  "name": "average_order_value",
  "dataset": "orders",
  "label": "Average Order Value",
  "description": "Revenue per order",
  "expression": "SUM(amount) / NULLIF(COUNT(DISTINCT order_id), 0)",
  "dataType": "number",
  "format": { "type": "currency", "decimals": 2, "currency": "USD" },
  "aiContext": { "synonyms": ["AOV"] },
  "tags": ["kpi"]
}
FieldTypeRequiredConstraints
namestringYesUnique within its dataset (across fields and metrics)
datasetstringYesThe dataset whose rows it aggregates
expressionstringYesAggregate expression using that dataset's field and metric names
dataTypeenumYesLogical type, usually "number"
nativeDataTypestringNo
label, description, aiContextNo
format, unit, comparisonPolarity, isDefault, hidden, tagsNo

ComposedMetric combines named inputs:

{
  "name": "net_revenue",
  "label": "Net Revenue",
  "expression": "{sales} - COALESCE({returned}, 0)",
  "inputs": {
    "sales": { "metric": "revenue", "dataset": "orders" },
    "returned": { "dataset": "returns", "field": "amount", "aggregate": "SUM" }
  },
  "metricType": "derived",
  "format": { "type": "currency", "decimals": 2, "currency": "USD" }
}
FieldTypeRequiredConstraints
namestringYesUnique among composed metrics
expressionstringYesMin 1 character. Uses {input_name} tokens
inputsobjectYesMap of token name to an input (below)
defaultAggregateenumConditionalRequired when the expression works row by row
metricTypeenumNo"base", "derived" (default), "calculated"
aggregationStrategyenumNo"default", "symmetric_aggregate", "aggregate_then_join", "weighted"
filtersarrayNoEach { "dataset", "field", "operator", "value" }
label, description, aiContext, format, comparisonPolarity, hidden, tagsNo

Input types:

Input TypeShapeUse Case
Field reference{ "dataset": "...", "field": "...", "aggregate": "SUM" }A field (identifier, fact, or dimension) of a dataset
Dataset metric reference{ "metric": "...", "dataset": "..." }A DatasetMetric
Composed metric reference{ "metric": "..." }Another ComposedMetric

Aggregate values: "SUM", "COUNT", "AVG", "MIN", "MAX", "MEDIAN", "DISTINCT"

ComposedDimension

{
  "name": "order_month",
  "label": "Order Month",
  "description": "Monthly time grouping",
  "expression": "DATE_TRUNC('month', {order_date})",
  "inputs": {
    "order_date": { "dataset": "orders", "field": "order_date" }
  },
  "dataType": "date"
}
FieldTypeRequiredConstraints
namestringYesUnique among composed dimensions
labelstringYesDisplay name
descriptionstringNo
aiContextAiContextNo
expressionstringYesSQL expression with {input_name} tokens
inputsobjectYesMap of name to field reference or { "dimension": "..." }
dataTypeenumNo"string", "number", "boolean", "date", "datetime"
grainMappingobjectNoMap of dataset name to column name

Generate new row-level groupings as dimensions with an expression on their dataset. Use composed dimensions when a grouping needs a grain mapping across datasets.

Relationship

{
  "id": "rel_001",
  "name": "orders_to_customers",
  "from": { "dataset": "orders", "fields": ["customer_id"] },
  "to": { "dataset": "customers", "fields": ["id"] },
  "cardinality": "many_to_one",
  "defaultJoinType": "LEFT",
  "isAutoJoin": true,
  "joinPriority": 1,
  "discoveredBy": "user",
  "confidence": "high",
  "isActive": true,
  "description": "Orders belong to customers"
}
FieldTypeRequiredConstraints
idstringNoAuto-generated if omitted
namestringYesUnique name
from{ "dataset", "fields" }YesThe many side. Dataset must exist in datasets; min 1 field
to{ "dataset", "fields" }YesThe side whose fields identify one row. Same number of fields as from
cardinalityenumYes"many_to_one", "one_to_one", "many_to_many"
defaultJoinTypeenumYes"INNER", "LEFT", "RIGHT", "FULL"
isAutoJoinbooleanYesMust be true for automatic join resolution
joinPrioritynumberNoHigher = preferred when multiple paths exist
discoveredByenumYes"user", "auto", "fk_constraint", "ai", "agent_model_repair"
confidenceenumNo"high", "medium", "low"
isActivebooleanYesSet false to disable
descriptionstringNo
aiContextAiContextNo

AiContext

Context for agents and natural-language questions. Allowed on the domain, datasets, fields, metrics, composed dimensions, and relationships. A plain string is shorthand for { "instructions": "..." }.

{
  "instructions": "Use for questions about recurring revenue. Excludes one-time fees.",
  "synonyms": ["MRR", "monthly recurring revenue"],
  "examples": ["What was MRR by plan last month?"]
}
FieldTypeLimit
instructionsstring2,000 characters
synonymsstring[]50 entries, 100 characters each
examplesstring[]10 entries, 300 characters each

Over a limit, the save is refused with ai_context_too_long or ai_context_too_many. Empty strings are dropped and synonyms are de-duplicated (case-insensitive).

FormatSpec

{
  "type": "currency",
  "decimals": 2,
  "currency": "USD",
  "prefix": "$",
  "suffix": "",
  "thousandsSeparator": true
}
FieldTypeRequiredConstraints
typeenumYes"number", "currency", "percentage", "date", "string"
decimalsintegerNo0-10
currencystringNoCurrency code (e.g., "USD")
prefixstringNo
suffixstringNo
thousandsSeparatorbooleanNoDefault true

GrainMapping

{
  "sourceDimension": "order_month",
  "targetDataset": "returns",
  "targetColumn": "return_date"
}
FieldTypeRequiredDescription
sourceDimensionstringYesName of a composed dimension
targetDatasetstringYesDataset to map the dimension to
targetColumnstringYesColumn in the target dataset

Construction Rules

Follow these rules to produce valid templates:

Naming

  • Dataset name: unique within the domain, use snake_case
  • Field and dataset metric name: unique within its dataset, across identifiers, facts, dimensions, and that dataset's metrics
  • Composed metric and composed dimension name: unique within their list, use snake_case
  • Relationship name: descriptive (e.g., orders_to_customers)

Where Each Calculation Goes

  • Row-level quantity to aggregate (amount_cents / 100): a fact with expression and defaultAggregate
  • Row-level attribute to group by (DATE_TRUNC('month', order_date)): a dimension with expression
  • Aggregate over one dataset (SUM(amount) / COUNT(DISTINCT order_id)): a DatasetMetric
  • Combines datasets, or builds on composed metrics: a ComposedMetric

Semaphor checks this on save. A misplaced calculation is refused with aggregate_in_row_field, row_expression_in_dataset_metric, or dataset_metric_references_other_dataset.

Required Field Combinations

  • source.kind: "sql" datasets must have sql
  • Facts and dimensions must have sourceField or expression (at least one)
  • New facts should include defaultAggregate
  • Dataset metrics must have dataset, expression, and dataType
  • Composed metrics must have expression and inputs
  • Identifiers must have type set to "primary" or "foreign"
  • Relationships must have name, from, to, cardinality, defaultJoinType, isAutoJoin, discoveredBy, and isActive

Token Rules (composed metrics and dimensions)

  • Token names in expressions ({name}) must exactly match keys in the inputs map
  • Token names are case-sensitive
  • Every token in the expression must have a corresponding input
  • Every input should be referenced by at least one token (unused inputs produce warning E006)
  • No braces {} inside SQL string literals (parser limitation)
  • No escape sequences like {{ or }}

Aggregation Rules

  • A DatasetMetric expression must aggregate (SUM(amount), or another dataset metric by name)
  • A fact or dimension expression must not aggregate
  • In a composed metric, if the expression wraps a token in an aggregate (SUM({amount})), the input does not need aggregate
  • If a composed metric uses a bare field token ({total_revenue} / {order_count}), each field input must specify aggregate
  • Metric inputs ({ "metric": "..." }) never need aggregate: the referenced metric provides its own

Reference Rules

  • A fact expression can reference fields of its dataset, plus its own sourceField column
  • A dimension expression can reference identifiers and dimensions of its dataset, plus its own sourceField column
  • A dataset metric can reference fields and other dataset metrics of its own dataset only
  • Composed metrics can reference fields, dataset metrics ({ "metric", "dataset" }), and other composed metrics ({ "metric" })
  • Composed dimensions can reference fields and other composed dimensions; they cannot reference metrics (error E007)
  • Maximum nesting depth: 10 levels
  • No circular references (error E001)

Relationship Rules

  • from.dataset and to.dataset must exist in datasets, and their fields must name fields of those datasets
  • from.fields and to.fields must have the same length
  • Put the many side in from; there is no one_to_many
  • For many_to_one, to.fields should equal the to dataset's primaryKey or one of its uniqueKeys (otherwise: warning relationship_keys_unmatched)
  • Set isAutoJoin: true for fields to be auto-joinable in the explorer
  • Set isActive: true for the relationship to be used

Validation Checklist

Before submitting a template, verify:

  • schemaVersion is "2.1"
  • Every dataset has a unique name and a label
  • Every dataset has source with kind, connectionId, and connectionType
  • kind: "table" sources have table; kind: "sql" sources have sql
  • Every dataset with a key has primaryKey
  • Every fact and dimension has sourceField or expression
  • Every new fact has the intended defaultAggregate
  • Fact and dimension expressions don't aggregate; dataset metric expressions do
  • Every dataset metric names an existing dataset and uses only that dataset's fields
  • Every identifier has type: "primary" or type: "foreign"
  • All composed metric expressions use valid {token} syntax
  • All tokens in expressions have matching input keys, and all input keys are used
  • Field inputs point to existing datasets and fields
  • Metric inputs point to existing metrics (with dataset for a dataset metric)
  • Dimension inputs point to existing composed dimensions
  • No circular metric or dimension references
  • Relationship from.dataset and to.dataset exist in datasets
  • Relationship from.fields and to.fields have the same length
  • Relationship to.fields match a key of the to dataset
  • Context stays within its limits

Validation Error Codes

CodeSeverityCause
SCHEMAErrorThe template doesn't match the schema; the message names the path
mixed_schema_versionsErrorThe body mixes 2.0 and 2.1 keys
duplicate_nameError (warning when it already existed)A name is used twice in a dataset or list
aggregate_in_row_fieldErrorA fact or dimension expression aggregates
row_expression_in_dataset_metricErrorA dataset metric expression works row by row
dataset_metric_references_other_datasetErrorA dataset metric uses another dataset's fields
row_composed_metric_without_default_aggregateErrorA row-level composed metric has no defaultAggregate
metric_input_not_foundErrorA metric input names a metric that doesn't exist
column_input_not_foundErrorA field input names a field that doesn't exist
unknown_dataset, unknown_fieldErrorA dataset metric or relationship refers to a missing dataset or field
relationship_ends_unequalErrorfrom and to list a different number of fields
ai_context_too_long, ai_context_too_manyErrorContext is over a limit
invalid_data_typeErrordataType isn't a logical type
unknown_source_kindErrorsource.kind isn't table or sql
relationship_keys_unmatchedWarningA relationship's key-side fields don't match a key; joins may repeat rows
native_type_needs_syncWarningA field's binding changed, so its stored database type was cleared
E001ErrorCircular dependency in metric/dimension references
E003ErrorComposed dimension input references unknown field
E004ErrorInput references unknown dataset
E005ErrorToken in expression not found in inputs
E006WarningInput defined but not used in expression
E007ErrorComposed dimension references a metric
E009ErrorInvalid expression (braces in string literals)
E010ErrorInput requires aggregate but none specified
E011ErrorInput references unknown composed dimension
E012ErrorUnknown name in a fact or dataset metric expression
E013ErrorInvalid fact or dataset metric expression, or fact filter
E014ErrorUnknown field in a fact filter
E015ErrorUnsupported filter path (crosses unsafe relationship)
E016ErrorUnknown field in a dimension expression
E017ErrorInvalid dimension expression (AS aliases or syntax)
E018ErrorprimaryDateField names an unknown dimension
E019ErrorprimaryDateField names a dimension that isn't a date or datetime
E022ErrorA metric input wraps an aggregate metric in another aggregate

Generating Templates from Database Metadata

Here's a step-by-step algorithm for AI agents generating a domain from a database schema:

Step 1: Map Tables to Datasets

For each table in the target schema:

{
  "name": "<table_name_snake_case>",
  "label": "<Human Readable Name>",
  "analyticsRole": "<fact|dimension|event|snapshot|lookup|bridge|unknown>",
  "source": {
    "kind": "table",
    "connectionId": "<connection_id>",
    "connectionType": "<database_type>",
    "schema": "<schema_name>",
    "table": "<table_name>"
  },
  "primaryKey": ["<primary_key_column>"],
  "fields": { "identifiers": [], "facts": [], "dimensions": [] }
}

Step 2: Classify Columns into Fields

For each column in each table, set sourceField to the column name, dataType to its logical type, and nativeDataType to the database type:

Column TypeClassificationRule
Primary keyIdentifier (type: "primary"), and listed in primaryKeyAlways
Foreign keyIdentifier (type: "foreign")Always
Numeric, non-keyFactChoose defaultAggregate
Date/DateTimeDimensionSet granularity: "day" for dates
String/BooleanDimensionDefault classification

When choosing defaultAggregate, use SUM for additive measures, AVG for rates/prices/scores/per-unit measures, and COUNT only for explicit counts. If the automation cannot confidently classify the aggregate, prefer SUM and let a domain author adjust it later.

Step 3: Detect Relationships

For each foreign key constraint, put the table holding the foreign key in from:

{
  "name": "<fk_table>_to_<pk_table>",
  "from": { "dataset": "<fk_table>", "fields": ["<fk_column>"] },
  "to": { "dataset": "<pk_table>", "fields": ["<pk_column>"] },
  "cardinality": "many_to_one",
  "defaultJoinType": "LEFT",
  "isAutoJoin": true,
  "discoveredBy": "fk_constraint",
  "isActive": true
}

Step 4: Generate Common KPIs

Based on the facts, create dataset metrics for standard KPIs:

{
  "name": "total_<fact_name>",
  "dataset": "<dataset>",
  "label": "Total <Fact Label>",
  "expression": "SUM(<fact_name>)",
  "dataType": "number"
}

Step 5: Add Context

Add aiContext where names are ambiguous or business rules matter: instructions for how to use a dataset or metric, synonyms people use, and a few example questions.

Step 6: Validate

Save the template and fix any errors it reports. Review warnings, especially relationship_keys_unmatched.


Complete JSON Example

A production-ready template for an e-commerce analytics domain:

{
  "schemaVersion": "2.1",
  "aiContext": {
    "instructions": "Revenue is net of discounts. Use order_date for time questions unless the question is about returns.",
    "synonyms": ["sales", "ecommerce"]
  },
  "datasets": [
    {
      "name": "orders",
      "label": "Orders",
      "analyticsRole": "fact",
      "source": {
        "kind": "table",
        "connectionId": "conn_1",
        "connectionType": "PostgreSQL",
        "schema": "public",
        "table": "orders"
      },
      "primaryKey": ["order_id"],
      "uniqueKeys": [["order_number"]],
      "primaryDateField": "order_date",
      "fields": {
        "identifiers": [
          {"name": "order_id", "dataType": "number", "sourceField": "order_id", "type": "primary"},
          {"name": "customer_id", "dataType": "number", "sourceField": "customer_id", "type": "foreign"}
        ],
        "dimensions": [
          {
            "name": "order_date",
            "label": "Order Date",
            "dataType": "date",
            "sourceField": "order_date",
            "granularity": "day"
          },
          {
            "name": "order_number",
            "label": "Order Number",
            "dataType": "string",
            "sourceField": "order_number"
          },
          {"name": "status", "label": "Status", "dataType": "string", "sourceField": "status"},
          {
            "name": "order_quarter",
            "label": "Order Quarter",
            "dataType": "date",
            "expression": "DATE_TRUNC('quarter', order_date)"
          }
        ],
        "facts": [
          {
            "name": "amount",
            "label": "Amount",
            "dataType": "number",
            "nativeDataType": "numeric(12,2)",
            "sourceField": "amount",
            "defaultAggregate": "SUM"
          },
          {
            "name": "cost",
            "label": "Cost",
            "dataType": "number",
            "sourceField": "cost",
            "defaultAggregate": "SUM"
          }
        ]
      }
    },
    {
      "name": "customers",
      "label": "Customers",
      "analyticsRole": "dimension",
      "source": {
        "kind": "table",
        "connectionId": "conn_1",
        "connectionType": "PostgreSQL",
        "schema": "public",
        "table": "customers"
      },
      "primaryKey": ["id"],
      "fields": {
        "identifiers": [{"name": "id", "dataType": "number", "sourceField": "id", "type": "primary"}],
        "dimensions": [
          {
            "name": "segment",
            "label": "Customer Segment",
            "dataType": "string",
            "sourceField": "segment",
            "aiContext": {"synonyms": ["tier", "customer type"]}
          },
          {"name": "region", "label": "Region", "dataType": "string", "sourceField": "region"}
        ]
      }
    },
    {
      "name": "returns",
      "label": "Returns",
      "analyticsRole": "fact",
      "source": {
        "kind": "table",
        "connectionId": "conn_1",
        "connectionType": "PostgreSQL",
        "schema": "public",
        "table": "returns"
      },
      "fields": {
        "dimensions": [{"name": "return_date", "label": "Return Date", "dataType": "date", "sourceField": "return_date"}],
        "facts": [
          {
            "name": "amount",
            "label": "Return Amount",
            "dataType": "number",
            "sourceField": "amount",
            "defaultAggregate": "SUM"
          }
        ]
      }
    }
  ],
  "relationships": [
    {
      "name": "orders_to_customers",
      "from": {"dataset": "orders", "fields": ["customer_id"]},
      "to": {"dataset": "customers", "fields": ["id"]},
      "cardinality": "many_to_one",
      "defaultJoinType": "LEFT",
      "isAutoJoin": true,
      "discoveredBy": "user",
      "isActive": true
    }
  ],
  "metrics": [
    {
      "name": "revenue",
      "dataset": "orders",
      "label": "Revenue",
      "expression": "SUM(amount)",
      "dataType": "number",
      "aiContext": "Gross order amount after discounts, before returns."
    },
    {
      "name": "total_cost",
      "dataset": "orders",
      "label": "Total Cost",
      "expression": "SUM(cost)",
      "dataType": "number"
    },
    {
      "name": "profit",
      "dataset": "orders",
      "label": "Profit",
      "expression": "revenue - total_cost",
      "dataType": "number"
    },
    {
      "name": "profit_margin",
      "dataset": "orders",
      "label": "Profit Margin %",
      "expression": "profit / NULLIF(revenue, 0) * 100",
      "dataType": "number",
      "format": {"type": "percent", "decimals": 1}
    },
    {
      "name": "net_revenue",
      "label": "Net Revenue",
      "description": "Revenue minus returns",
      "expression": "{sales} - COALESCE({returned}, 0)",
      "inputs": {
        "sales": {"metric": "revenue", "dataset": "orders"},
        "returned": {"dataset": "returns", "field": "amount", "aggregate": "SUM"}
      }
    }
  ],
  "composedDimensions": [
    {
      "name": "order_month",
      "label": "Order Month",
      "expression": "DATE_TRUNC('month', {order_date})",
      "inputs": {"order_date": {"dataset": "orders", "field": "order_date"}},
      "dataType": "date"
    }
  ],
  "grainMappings": [{"sourceDimension": "order_month", "targetDataset": "returns", "targetColumn": "return_date"}]
}

Next Steps

On this page