Skip to main content

Semantic Model Reference

Complete technical reference for the Semantic Model Specification language used to define semantic layers in Coginiti.

Overview​

The Semantic Model Specification (SMDL) is a domain-specific language for defining semantic layers within Coginiti projects. SMDL uses HCL-like syntax, which provides a balance between flexibility, readability, and features.

Semantic layers are defined in files with the .smdl extension (Semantic Model Definition Language) and stored within a project directory. Each entity can be defined in a separate file or multiple entities can be defined in a single file.

File structure​

Directory organization​

Semantic model files should be organized in a dedicated directory within your project:

retail_foundation/              # Project folder
├── base/ # CoginitiScript packages
├── semantic/ # Semantic layer folder
│ ├── dim_persona.smdl
│ ├── dim_offering_item.smdl
│ ├── fact_sales_header.smdl
│ └── fact_sales_detail.smdl
├── project.toml
└── linter.toml

For large projects, organize by subject area:

retail_foundation/
└── semantic/
├── sales/ # Subject area
│ ├── fact_sales_header.smdl
│ └── fact_sales_detail.smdl
├── customer/ # Subject area
│ └── dim_persona.smdl
└── offering/ # Subject area
└── dim_offering_item.smdl

File extension​

All semantic model files must use the .smdl extension.

Project configuration​

Semantic layers are registered in the project.toml file using the [semantic] section.

Semantic section structure​

[semantic]
default = "all"

[semantic.all]
name = "Full Data"
path = "semantic"
environment = "prod"

[semantic.sales]
name = "Sales Domain"
path = "semantic"
environment = "prod"

Configuration parameters​

ParameterRequiredDescription
defaultYesThe default semantic layer to use
nameYesDisplay name for the semantic layer
pathYesPath to the directory containing .smdl files
environmentYesProject environment to use for this semantic layer

Entity​

An entity represents a dataset within the semantic layer, conceptually similar to a table or view in SQL.

Syntax​

entity "entity_name" {
label = "Display Name"
description = "Entity description"
schema_name = "schema"
table_name = "table"
# OR
query = <<-EOF
SELECT ...
EOF
# OR
csl_package = "package/path"
csl_block = "BlockName"

dimension "dim_name" { ... }
measure "measure_name" { ... }
relationship "relationship_name" { ... }
}

Parameters​

ParameterTypeRequiredDescription
labelstringNoUser-friendly name displayed in the UI
descriptionstringNoEntity description. Supports Markdown formatting
schema_namestringNoSchema containing the physical table. Valid only alongside table_name; combining it with query is an error. See Schema-qualified tables
table_namestringConditionalPhysical table name, without a schema qualifier. Required if query and csl_block are not specified
querystringConditionalSQL query defining the entity. Required if table_name and csl_block are not specified. Use heredoc syntax (<<-EOF) for multi-line queries
csl_packagestringConditionalPackage of the CoginitiScript block that publishes the source table or view. Resolved like a CoginitiScript #+import path. Required with csl_block, when table_name and query are not specified
csl_blockstringConditionalName of the CoginitiScript block that publishes the source table or view. Required with csl_package, when table_name and query are not specified
dimensionblockNoDimension definitions (can have multiple)
measureblockNoMeasure definitions (can have multiple)
relationshipblockNoRelationship definitions (can have multiple)

Schema-qualified tables​

When the physical table lives in a named schema, declare the schema with the dedicated schema_name property and the table with a bare table_name. Coginiti quotes each identifier part according to the rules of the target platform (for example, "base"."fct_sales_header").

Rules:

  • schema_name is valid only with table_name. Declaring it without table_name, or together with query, is a validation error.
  • Do not put a dotted name in table_name. A value such as table_name = "base.fct_sales_header" is not split on the dot — it is treated as a single table identifier whose name contains a dot.

Table-based entity example​

entity "sales_header" {
label = "Sales Header Fact"
description = "Contains sales header data"
schema_name = "base"
table_name = "fct_sales_header"

dimension "sales_header_key" {
label = "Sales Header Key"
type = "integer"
expr = "sales_header_key"
}
}

Query-based entity example​

entity "sales_detail" {
label = "Sales Detail Fact"
description = "Contains sales detail data with transformations"
query = <<-EOF
SELECT
id AS sales_detail_key,
header_key AS sales_header_key,
product_key,
unit_quantity,
unit_price_amount
FROM
fact_sales_detail
WHERE
is_deleted = FALSE
EOF
}

CoginitiScript block-based entity example​

The entity reads the table or the view that the block publishes. Coginiti does not run the block: the entity shows the data of the last publication run.

entity "orders_enriched" {
label = "Enriched Orders"
description = "Orders built by the OrdersEnriched block"
csl_package = "reporting/blocks"
csl_block = "OrdersEnriched"

dimension "order_id" {
label = "Order ID"
type = "integer"
expr = "order_id"
}
}

The referenced block must be public, of type sql or llm, and publish a table or a view with a plain text :name and, if present, :schema. An llm block can only publish a table:

#+src sql OrdersEnriched()
#+meta {
:publication {
:type "table",
:name "orders_enriched",
:schema "marts"
}
}
#+begin
SELECT ...
#+end

For a walkthrough see How to build a semantic entity from a CoginitiScript block.

Dimension​

A dimension is an entity attribute that provides descriptive context for measures. Dimensions are used for filtering, grouping, and organizing data.

Syntax​

dimension "dimension_name" {
label = "Display Name"
type = "text"
description = "Dimension description"
hidden = false
expr = "column_name"
}

Parameters​

ParameterTypeRequiredDescription
labelstringNoUser-friendly name displayed in the UI
typestringYesData type. Valid values: text, number, date, datetime, bool
descriptionstringNoDescription of the dimension's semantic meaning
hiddenbooleanNoWhen true, hides the dimension from the UI. Default: false
exprstringYesSQL expression referencing physical column names. Maps physical data to the semantic dimension

Simple dimension example​

dimension "first_name" {
label = "First Name"
type = "text"
expr = "first_name"
}

Calculated dimension example​

dimension "full_name" {
label = "Full Name"
type = "text"
description = "Customer's full name (first and last)"
expr = "CONCAT(first_name, ' ', last_name)"
}
important

When a dimension expr contains a compound expression, each physical column used as an operand must have its own dimension defined within the same entity. This mapping is required so the system can determine the data type of each operand. Use hidden = true for helper dimensions that should not be exposed to end users.

// Helper dimensions — required so the system knows the data types of the
// physical columns used in the full_name expression
dimension "first_name" {
label = "First Name"
type = "text"
expr = "first_name"
}

dimension "last_name" {
label = "Last Name"
type = "text"
expr = "last_name"
hidden = true
}

dimension "full_name" {
label = "Full Name"
type = "text"
description = "Customer's full name (first and last)"
expr = "CONCAT(first_name, ' ', last_name)"
}

Date dimension example​

dimension "transaction_date" {
label = "Transaction Date"
type = "date"
expr = "DATE(transaction_datetime)"
}

Measure​

A measure represents quantitative data that you want to analyze. Measures are aggregated when analyzed across dimensions.

SMDL has two kinds of measures, and the kind is inferred from the fields you provide:

  • A simple measure has an aggregation_type and aggregates a column or expression directly — for example, SUM(amount).
  • A derived measure has no aggregation_type; its expr combines other measures using arithmetic — for example, profit = revenue - cost.

Syntax​

measure "measure_name" {
label = "Display Name"
type = "number"
description = "Measure description"
hidden = false
aggregation_type = "sum"
expr = "column_name"
filter = "boolean_expression"
}

Parameters​

ParameterTypeRequiredDescription
labelstringNoUser-friendly name displayed in the UI
typestringYesData type. Valid values: number, date, datetime
descriptionstringNoDescription of the measure's semantic meaning
hiddenbooleanNoWhen true, hides the measure from the UI. Default: false
aggregation_typestringNoAggregation function. Valid values: count, count_distinct, sum, avg, max, min, median, custom
exprstringYesSQL expression for the measure. Identifiers are resolved using identifier resolution — they may reference physical columns, other measures and dimensions, or columns from related entities
filterstringNoSQL boolean expression that restricts which rows feed the aggregation. Valid only on simple measures. See Filtered measures

Aggregation types​

Aggregation TypeSQL FunctionAdditiveDescription
countCOUNT(*)YesCount of rows
count_distinctCOUNT(DISTINCT ...)NoCount of unique values
sumSUM(...)YesSum of values
avgAVG(...)NoAverage of values
maxMAX(...)NoMaximum value
minMIN(...)NoMinimum value
medianMEDIAN(...)NoMedian value
custom(expression)VariesCustom aggregation expression
note

The median aggregation requires native median support on the target platform: it is available on Snowflake and Amazon Redshift. On PostgreSQL, BigQuery, Yellowbrick, and SQL Server, a query that references a median measure fails with an error naming the measure and platform.

Simple measure example​

measure "unit_quantity" {
label = "Unit Quantity"
type = "number"
expr = "unit_quantity"
aggregation_type = "sum"
}

Measure with a compound expression​

measure "sales_amount" {
label = "Sales Amount"
type = "decimal"
description = "Total sales amount (quantity × price)"
expr = "unit_quantity * unit_price_amount"
aggregation_type = "sum"
}
important

When a measure expr contains a compound expression, each physical column used as an operand must have a dimension defined within the same entity. This mapping is required so the system can determine the data type of each operand. Use hidden = true for helper dimensions that should not be exposed to end users.

// Hidden dimensions — required so the system knows the data types of the
// physical columns used in the sales_amount expression
dimension "unit_quantity" {
label = "Unit Quantity"
type = "integer"
expr = "unit_quantity"
hidden = true
}

dimension "unit_price_amount" {
label = "Unit Price Amount"
type = "decimal"
expr = "unit_price_amount"
hidden = true
}

measure "sales_amount" {
label = "Sales Amount"
type = "decimal"
description = "Total sales amount (quantity × price)"
expr = "unit_quantity * unit_price_amount"
aggregation_type = "sum"
}

Custom aggregation example​

measure "average_discount_rate" {
label = "Average Discount Rate"
type = "number"
description = "Weighted average discount rate"
aggregation_type = "custom"
expr = "SUM(discount_amount) / NULLIF(SUM(original_amount), 0)"
}

Filtered measures​

A filter restricts which rows feed a measure's aggregation. The filter is a SQL boolean expression — rows where it evaluates to false or null contribute nothing to the aggregation.

filter is valid only on simple measures (those with an aggregation_type or an aggregate function in expr). Applying filter to a derived measure is a validation error.

measure "completed_revenue" {
label = "Completed Revenue"
type = "decimal"
aggregation_type = "sum"
expr = "amount"
filter = "status = 'completed'"
}

The filter compiles into a CASE WHEN wrapped by the aggregation function:

SUM(CASE WHEN status = 'completed' THEN amount END) AS completed_revenue

Compound filters​

Combine conditions with AND and OR:

measure "large_completed_revenue" {
label = "Large Completed Revenue"
type = "decimal"
aggregation_type = "sum"
expr = "amount"
filter = "status = 'completed' AND amount > 100"
}

Filters referencing a dimension​

Identifiers in filter follow the same identifier resolution rule as expr. A filter can reference a boolean dimension by name instead of repeating its expression:

dimension "is_completed" {
type = "bool"
expr = "status = 'completed'"
}

measure "active_revenue" {
label = "Active Revenue"
type = "decimal"
aggregation_type = "sum"
expr = "amount"
filter = "is_completed"
}

is_completed resolves to the dimension's expression, producing the same SQL as the simple filter above.

info

A measure filter is part of the measure definition — it applies every time the measure is used. A query-time HAVING clause is independent and applied per query. The two mechanisms coexist.

Derived measures​

A derived measure has no aggregation_type. Its expr references other measures, which are each aggregated independently and then combined. The measure kind is inferred — a measure with no aggregation_type whose expr references other measures is derived.

measure "revenue" {
type = "decimal"
aggregation_type = "sum"
expr = "amount"
}

measure "cost" {
type = "decimal"
aggregation_type = "sum"
expr = "cost_amount"
}

measure "profit" {
label = "Profit"
type = "decimal"
expr = "revenue - cost"
}

profit references the revenue and cost measures. Each is aggregated on its own, then subtracted:

SUM(amount) - SUM(cost_amount) AS profit

This is post-aggregation: revenue - cost becomes SUM(amount) - SUM(cost_amount), not SUM(amount - cost_amount). For SUM both forms produce the same result; for AVG or COUNT they differ, so choose the form that matches the metric you mean.

Chained derivation​

A derived measure can reference another derived measure. The dependency chain is resolved automatically:

measure "margin" {
label = "Profit Margin"
type = "number"
expr = "profit / NULLIF(revenue, 0)"
}

margin expands through profit down to the base measures:

(SUM(amount) - SUM(cost_amount)) / NULLIF(SUM(amount), 0) AS margin

Ratios​

A ratio is a derived measure with division — no special syntax is required. Use NULLIF to guard against division by zero:

measure "completion_rate" {
label = "Completion Rate"
type = "number"
expr = "completed_revenue / NULLIF(total_revenue, 0)"
}

SQL functions such as ROUND, NULLIF, and COALESCE are passed through unchanged. Any identifier that does not resolve to a measure or dimension is treated as a physical column or a SQL function.

Validation rules​

  • A derived measure must reference at least one measure or dimension. A measure with no aggregation_type that references only physical columns is invalid.
  • filter is not allowed on a derived measure.
  • Circular references are an error — a cannot derive from b if b derives from a, directly or transitively.

Additive vs. non-additive measures​

Additive measures can be aggregated multiple times and still produce correct results:

  • count
  • sum

Non-additive measures cannot be rolled up multiple times as it produces incorrect results:

  • count_distinct
  • avg
  • median
  • Standard deviation and variance measures

This distinction is important for pre-aggregation optimization strategies.

Relationship​

Relationships define joins between entities, allowing the system to access attributes from related entities. The system uses LEFT JOIN by default. For INNER JOIN behavior, add an IS NOT NULL condition in the join expression.

Syntax​

relationship "relationship_name" {
label = "Display Name"
type = "many_to_one"
expr = "source_entity.dimension_name = target_entity.dimension_name"
}

Parameters​

ParameterTypeRequiredDescription
labelstringNoUser-friendly name displayed in the UI
typestringYesRelationship cardinality. Valid values: one_to_one, one_to_many, many_to_one
exprstringYesJoin condition referencing semantic identifiers: entity_name.dimension_name. Uses entity names and dimension names, not physical table or column names

Relationship types​

TypeDescriptionExample
one_to_oneEach record in the source entity corresponds to exactly one record in the target entityOrder to order details (for single-item orders)
one_to_manyEach record in the source entity corresponds to multiple records in the target entityCustomer to orders
many_to_oneMultiple records in the source entity correspond to one record in the target entityOrders to customer

Many-to-one relationship example​

entity "sales_header" {
table_name = "fact_sales_header"

relationship "persona_dimension" {
label = "Persona"
type = "many_to_one"
expr = "sales_header.persona_key = dim_persona.persona_key"
}

dimension "persona_key" {
label = "Persona Key"
type = "integer"
expr = "persona_key"
}
}

One-to-many relationship example​

entity "sales_header" {
table_name = "fact_sales_header"

relationship "sales_details" {
label = "Sales Details"
type = "one_to_many"
expr = "sales_header.sales_header_key = sales_detail.sales_header_key"
}
}

Inner join example​

relationship "persona_dimension" {
label = "Persona"
type = "many_to_one"
expr = "sales_header.persona_key = dim_persona.persona_key AND dim_persona.persona_key IS NOT NULL"
}

Tenancy​

Tenancy restricts every query in a semantic layer to the rows belonging to one tenant, so a single layer over shared tables can serve many customers without each of them being able to read the others' data.

The policy is declared once for the whole layer, in a tenancy block. It never names tenants: tenants are administered in Coginiti, not in the model. What the model declares is how isolation applies.

A layer that declares tenancy requires a tenant on every connection. Queries against it fail until one is bound.

How isolation works. In this release the only strategy is the row filter: every tenant's rows live in the same tables, and one column marks which tenant each row belongs to. The row_filter block is nested inside tenancy so that further strategies can be added beside it without changing models that already exist.

The filter is applied by the semantic layer. A client that reaches the warehouse another way — a direct database connection, or a BI tool with its own credentials — is not constrained by it, and needs warehouse-native policies if it has to be.

This section is the model half. For the whole path — declaring the policy, creating the tenants, granting them, and connecting as one — see Set up semantic tenants.

Syntax​

tenancy {
row_filter {
field = "tenant_id"

shared_entities = ["dim_calendar"]

entity "customers" {
field = "account_id"
}
}
}

Parameters​

ParameterTypeRequiredDescription
row_filterblockYesThe isolation strategy. One per layer
row_filter.fieldstringYesThe dimension that marks tenant ownership, on every entity that does not say otherwise
row_filter.shared_entitieslistNoEntities holding reference data common to every tenant. They are never filtered
row_filter.entityblockNoNames one entity that marks ownership with a different dimension

How it applies​

The tenant field is a dimension like any other, and each entity declares its own. Every entity in the layer must fall into one of three cases:

  1. it declares the dimension named by row_filter.field, or
  2. it has an entity override naming a different dimension it declares, or
  3. it is listed in shared_entities

A layer where any entity fits none of them fails to load, naming the entity. The check is exhaustive on purpose: an entity that quietly went unfiltered would look exactly like one that is safe.

An entity cannot be both shared and overridden. It is either exempt from filtering or filtered on a field, not both.

The tenant field must be declared text or integer. A tenant key is an identifier, and the other types parse but cannot behave like one.

Hiding the tenant field​

The field is usually hidden, since it identifies the tenant rather than describing the data:

dimension "tenant_id" {
type = "integer"
expr = "tenant_id"
hidden = true
}

Exposing it is safe rather than a leak. The filter applies either way, so a user only ever sees their own tenant's value in it.

Per-entity field example​

Entities often mark ownership with different columns — a fact table on an integer key, a customer table on an account code. Name the exception rather than renaming the column:

tenancy {
row_filter {
field = "tenant_id"

entity "customers" {
field = "account_id"
}
}
}

entity "orders" {
table_name = "orders"

dimension "tenant_id" {
type = "integer"
expr = "tenant_id"
hidden = true
}
}

entity "customers" {
table_name = "customers"

dimension "account_id" {
type = "text"
expr = "account_id"
hidden = true
}
}

An override applies to the entity, not the table. Two entities over the same table — a role-playing entity, say — each need their own.

Shared reference data​

A calendar, a currency table or a product catalogue is the same for every tenant and has no ownership column at all. Filtering it would match nothing and the join would come back empty, so name it as shared:

tenancy {
row_filter {
field = "tenant_id"
shared_entities = ["dim_calendar"]
}
}

entity "dim_calendar" {
table_name = "dim_calendar"

dimension "day_key" {
type = "integer"
expr = "day_key"
}
}

A tenant-owned entity may join a shared one freely. The predicate lands on the tenant-owned side.

Query-based entities​

A query-based entity is filtered on what it declares, not on anything the layer knows about the query's output:

entity "order_lines" {
query = <<-EOF
SELECT tenant_id, order_id, amount
FROM orders
EOF

dimension "tenant_id" {
type = "integer"
expr = "tenant_id"
hidden = true
}
}

The query must project the field the entity declares. Declaring a field the query does not return passes the load-time check — which can only read declarations — and fails on the first scan, naming the field. It can never come back unfiltered.

Errors​

ConditionMessage
tenancy without row_filtertenancy policy declares no isolation: add a 'row_filter' block naming the field that marks tenant ownership
row_filter without fieldtenancy row_filter declares no 'field': name the dimension that marks tenant ownership
An entity declares neither the field nor an exemptionentity 'X' has no dimension 'tenant_id', so tenancy cannot filter it. Declare that dimension on 'X', give the entity its own field with a tenancy entity override, or add it to shared_entities if it holds reference data common to every tenant
An override names an entity that does not existtenancy entity override names unknown entity 'X'
An override names a field the entity does not declaretenancy entity override for 'X' names field 'Y', which is not a dimension on that entity
shared_entities names an entity that does not existtenancy shared_entities names unknown entity 'X'
An entity is both shared and overriddenentity 'X' is declared shared and also given a tenant field override
The tenant field is not text or integertenancy field 'Y' on entity 'X' is declared 'date'. A tenant key is an identifier, so it must be 'text' or 'integer'

At query time:

ConditionMessage
No tenant on the connectionthis semantic layer requires a tenant: add 'tenant=<name>' to the connection
A tenant given to a layer without a policytenant 'X' was supplied, but this semantic layer declares no tenancy policy
The tenant is unknown, disabled, or not grantednot authorized to connect as tenant 'X': ask an administrator to grant you access

The last message is deliberately the same for all three. Distinguishing them would let a connection dialog enumerate tenant names, which are the customer's own customer names.

Domains​

A domain is a governed schema over one semantic layer. It exposes a chosen set of entities and fields, and applies mandatory filters to every query that runs through it — so one model can serve a regional view, a subject-area view and an unrestricted view without being copied three times.

Each domain appears beside the full model as a schema of its own:

public.sales_detail
cust001_sales.sales_detail

public is the full model: every entity and field, no mandatory filter. It is not a domain, and no domain may be named public.

Domains say what a governed schema contains. They do not say who may use one — that is granted in Coginiti administration, per semantic layer.

Syntax​

domain "cust001_sales" {
label = "CUST001 Sales"
description = "Sales restricted to one customer"

entity "sales_detail" {
include = ["sales_detail_key", "product_key", "unit_quantity"]
}

entity "sales_header" {
include = []
}

filter "cust001_only" {
expr = "sales_header.persona_key = 'CUST001'"
}
}

Parameters​

ParameterTypeRequiredDescription
labelstringNoDisplay name for the domain
descriptionstringNoFree text describing the domain
entityblockYesOne per entity the domain lists. A domain must list at least one
filterblockNoA mandatory filter. May repeat
templatestringNoName of the domain_template this domain instantiates
valuesmapNoValues for that template's parameters

The entity block:

ParameterTypeRequiredDescription
includelistNoThe only fields that are queryable. [] makes the entity internal-only
excludelistNoEvery field except these is queryable

An entity block sets include or exclude, never both. An entity block with neither exposes every field the entity has.

The filter block:

ParameterTypeRequiredDescription
exprstringYesA boolean row-level expression over dimensions, with every field reference qualified
propagationstringNoHow the filter travels along relationships. preserve_grain is the default and the only supported value

Field visibility​

A field in a domain is in one of three states:

VisibilityMeaning
QueryableListed in the domain schema and usable in SQL
InternalHidden from SQL, still available to the compiler
RemovedNeither visible nor available in this domain

Internal is not a leftover. A queryable measure may depend on dimensions the domain does not expose, a relationship needs its join keys, fanout protection needs primary keys, and a filter needs the field it compares. Those fields are retained and hidden rather than dropped:

entity "sales_detail" {
table_name = "fact_sales_detail"
primary_key = ["sales_detail_key"]

dimension "sales_detail_key" {
type = "integer"
expr = "sales_detail_key"
}

dimension "unit_quantity" {
type = "integer"
expr = "unit_quantity"
}

dimension "unit_price" {
type = "decimal"
expr = "unit_price"
}

measure "sales_amount" {
type = "decimal"
aggregation_type = "sum"
expr = "unit_quantity * unit_price"
}
}

domain "sales_summary" {
entity "sales_detail" {
include = ["sales_amount"]
}
}

In sales_summary.sales_detail, sales_amount is queryable. unit_quantity and unit_price are internal: the compiler still multiplies them, and neither appears in the schema.

Internal-only entities​

include = [] lists an entity without exposing anything on it:

entity "sales_header" {
include = []
}

The entity can then carry a relationship hop or be the target of a filter, but it is not a table in the domain schema and none of its fields can be selected. Use it for the entities a domain needs in order to be enforceable rather than the ones it is about.

Mandatory filters​

A filter applies to every query against the domain, including one that selects a measure with no dimensions, and it applies before aggregation. A filter in the user's own WHERE narrows the result further; it cannot widen it.

domain "cust001_sales" {
entity "sales_detail" {}
entity "sales_header" { include = [] }

filter "cust001_only" {
expr = "sales_header.persona_key = 'CUST001'"
}
}

If sales_detail reaches sales_header through a relationship, then this query:

SELECT MEASURE(unit_quantity)
FROM cust001_sales.sales_detail

counts only the rows whose header belongs to CUST001.

References must be qualified​

A domain filter has no home entity, so every field reference names one:

filter "cust001_only" {
expr = "sales_header.persona_key = 'CUST001'" // valid
}

filter "cust001_only" {
expr = "persona_key = 'CUST001'" // invalid: no home entity
}

What an expression may contain​

A filter is a boolean row-level expression over dimensions, because it is enforced inside the entity scan, before any measure is calculated.

These are valid:

filter "region_only" {
expr = "countries.region = 'EMEA'"
}

filter "completed_only" {
expr = "orders.status IN ('completed', 'shipped')"
}

filter "active_only" {
expr = "customers.is_active"
}

The last one is valid when customers.is_active is a bool dimension.

These are not:

ExpressionWhy
orders.revenueNot boolean
orders.revenue > 1000, where revenue is a measureA filter cannot reference a measure
SUM(orders.revenue) > 1000Aggregate expressions are not allowed
ROW_NUMBER() OVER (ORDER BY orders.amount) > 0Window expressions are not allowed
MEASURE(revenue) > 1000MEASURE(...) calls are not allowed

Coverage​

Every filter must cover every entity the domain exposes. A domain where one does not is blocked: the layer still loads and the schema is still listed, but every query against it is refused, naming the filter, the entity and the reason.

semantic scope 'emea_sales' has blocking diagnostics: domain 'emea_sales' predicate
'emea_only' does not cover exposed entity 'sales_detail': no path to target entity 'countries'

Refusing is the point. A domain that exposed an uncovered table would list it beside the covered ones, and nothing in the result would say it had been left unconstrained.

An internal-only entity that a filter does not cover is a warning, not a block. Nothing queryable depends on it, so it cannot carry unconstrained rows into a result.

An entity is covered when the filter's target can be reached from it through relationships that do not change its row grain — itself, many_to_one, or one_to_one. Given:

sales_detail -> sales_header -> countries

this domain loads:

domain "emea_sales" {
entity "sales_detail" {}
entity "sales_header" { include = [] }
entity "countries" { include = [] }

filter "emea_only" {
expr = "countries.region = 'EMEA'"
}
}

and this one is blocked, because the path runs through sales_header, which the domain does not list:

domain "emea_sales" {
entity "sales_detail" {}
entity "countries" { include = [] }

filter "emea_only" {
expr = "countries.region = 'EMEA'"
}
}

Listing sales_header with include = [] fixes it. An entity needed only as a hop still has to be named.

One-to-many paths​

A filter does not travel from the "one" side to the "many" side. Given a one_to_many relationship from customer to orders:

domain "completed_orders" {
entity "customer" {}
entity "orders" {}

filter "completed_only" {
expr = "orders.status = 'completed'"
}
}

orders is covered, because it is the filter's own entity. customer is not, and because customer is exposed the domain is blocked.

This is a refusal to guess rather than a missing feature. A filter on orders.status could mean customers with at least one completed order, or customers whose orders are all completed, and those are different result sets. Expose orders alone, or model the intended meaning as its own entity.

Propagation​

propagation states the rule explicitly:

filter "emea_only" {
expr = "countries.region = 'EMEA'"
propagation = "preserve_grain"
}
ValueStatusMeaning
preserve_grainSupported, and the defaultTravel through self, many_to_one and one_to_one paths only, so the scanned entity's row grain never changes
existsReservedKeep a parent row if at least one child row satisfies the expression
not_existsReservedKeep a parent row if no child row satisfies it
allReservedKeep a parent row only if every child row satisfies it

The reserved values parse and are then refused, so a model that asks for one is told it is unsupported rather than given preserve_grain quietly.

Domain templates​

When domains differ only by a literal, a domain_template holds the shape and each domain supplies the values:

domain_template "product_family_sales" {
parameter "family" {
type = "text"
}

entity "sales_detail" {
include = ["sales_detail_key", "product_key", "unit_quantity"]
}

entity "dim_product" {
include = ["product_key", "product_name"]
}

filter "product_family_only" {
expr = "dim_product.product_name LIKE ${family}"
}
}

domain "widget_sales" {
template = "product_family_sales"
label = "Widget Sales"

values = {
family = "Widget%"
}
}

domain "gadget_sales" {
template = "product_family_sales"
label = "Gadget Sales"

values = {
family = "Gadget%"
}
}

Each instance is still a schema in its own right — widget_sales.sales_detail, gadget_sales.sales_detail.

A parameter is declared text, number or bool, and a value must match its parameter's type. Placeholders are written ${name} and substituted before the filter is validated, so dim_product.product_name LIKE ${family} is checked as dim_product.product_name LIKE 'Widget%'.

A templated domain is values only. It may set template, label, description and values, and nothing else; adding an entity or filter block to it fails to load. That keeps a template's filters from being weakened one instance at a time, and keeps every validation error attributable to either the template or the values. A domain that needs a different shape is a standalone domain, or a second template.

With a tenancy policy​

A layer may declare both. The two are independent and both apply: the tenant filter restricts the rows to one tenant, the domain's filters restrict them further, and a user's own WHERE narrows what is left.

SELECT MEASURE(amount)
FROM high_credit.orders

On a connection bound to one tenant, this returns that tenant's rows, minus the rows the high_credit domain excludes. Neither filter can widen the other, and the order they compose in does not change the result.

A domain does not have to mention the tenant field. Tenancy is enforced on every entity in the layer, including the ones a domain exposes, so a domain is written as though it were the only policy.

Querying a domain​

Address a domain by schema:

SELECT MEASURE(unit_quantity)
FROM cust001_sales.sales_detail

If the session's default schema is the domain, the name can be left off.

One query uses one schema. Joining across two of them is refused:

SELECT cust001_sales.sales_detail.product_key, sales_header.persona_key
FROM cust001_sales.sales_detail
JOIN sales_header
queries cannot mix semantic scopes

What a user sees​

A client browsing the catalog sees only the schemas granted to them, and inside a domain schema only its exposed entities and their queryable fields. An internal-only entity is absent, as is an internal field.

A schema that has not been granted is absent rather than refused: naming it fails the way naming a schema that does not exist fails. An error that distinguished the two would confirm which domains a layer has.

Errors​

Domain problems land in two places, and the difference matters when one is reported.

The model does not load. Nothing in the layer is usable until it is fixed:

ConditionMessage
A domain lists no entitiesdomain 'X' must list at least one entity
A domain is named publicdomain name 'public' is reserved for the unrestricted semantic model
A domain lists one entity twiceDuplicate domain entity 'Y'
A domain lists an entity the layer does not havedomain 'X' references unknown entity 'Y'
An entity block sets both include and excludeDomain entity cannot define both 'include' and 'exclude'
propagation is a reserved valuedomain 'X' filter 'P': propagation 'exists' is not supported in v1; only 'preserve_grain' is supported
propagation is not a known valuedomain 'X' filter 'P': unknown propagation 'Y'
A domain names a template that does not existdomain 'X' references unknown domain_template 'T'
A templated domain also defines blocksdomain 'X' uses template 'T', so it cannot define entity or filter blocks
values without a templatedomain 'X' defines values but does not reference a template
A required value is missingdomain 'X' uses template 'T' but does not supply required value 'V'
A value names no parameterdomain 'X' supplies unknown value 'V' for template 'T'
A value is the wrong typedomain 'X' value 'V' must match template parameter type 'text'
A domain and a template share a namedomain 'X' conflicts with a domain_template of the same name
A template lists no entitiesdomain_template 'T' must list at least one entity
A placeholder has no matching valuedomain_template 'T' filter 'P': unresolved template placeholder remains after substitution

The domain is blocked. The layer loads and every other domain keeps working. The blocked schema stays listed, so a client can still see it, but any query against it is refused with semantic scope 'X' has blocking diagnostics: followed by the messages below:

ConditionMessage
A filter does not cover an exposed entitydomain 'X' predicate 'P' does not cover exposed entity 'Y': no path to target entity 'Z'
The only path needs an entity the domain does not listdomain 'X' predicate 'P' does not cover exposed entity 'Y': path to target entity 'Z' requires removed path entity: A -> B
The only path is one_to_manydomain 'X' predicate 'P' does not cover exposed entity 'Y': path to target entity 'Z' requires one_to_many propagation
The path crosses a query-based entitydomain 'X' predicate 'P' does not cover exposed entity 'Y': path to target entity 'Z' crosses query-backed entity
A filter covers nothing the domain exposesdomain 'X' predicate 'P' covers zero exposed entities
A filter references an entity the domain does not listentity 'Y' is not listed in the domain
A filter references a measuremeasure reference 'Y.m' is not allowed in domain filters
A filter is not booleanexpression must be boolean; found <type>
A filter aggregatesaggregate expressions are not allowed
A filter uses a window functionwindow expressions are not allowed
A filter calls MEASURE(...)MEASURE(...) calls are not allowed
A queryable field depends on an entity the domain does not listdomain 'X' … requires field 'Z.f' from unlisted entity 'Z'

These messages say predicate where the model says filter. They are the same thing.

Two more are reported when a query runs:

ConditionMessage
A query joins two semantic schemasqueries cannot mix semantic scopes: A, B
A query names an ungranted or unknown schemareported as not found, the same as a schema that does not exist

Identifier resolution​

Every identifier in an expr or filter is resolved with one rule, applied consistently across dimensions, measures, and relationships.

Resolution rule​

For an unqualified identifier:

  1. Look it up in the entity's logical namespace — all dimensions and measures — excluding the field currently being defined.
  2. If found, it is a logical reference to that dimension or measure.
  3. If not found, it is a physical column reference.

Self-reference​

A dimension or measure cannot reference itself. When an identifier matches the name of the field being defined, it is excluded from the lookup and resolves to a physical column:

// "amount" matches the measure being defined → excluded → resolves to the
// physical column "amount"
measure "revenue" {
type = "decimal"
aggregation_type = "sum"
expr = "amount"
}

// "revenue" does not match the measure being defined → resolves to the
// measure above
measure "profit" {
type = "decimal"
expr = "revenue - cost"
}

Name shadowing​

If a logical name (a dimension or measure) and a physical column share a name, the logical name wins. The semantic layer favors logical abstractions over physical data.

Qualified identifiers and cross-entity references​

A qualified identifier uses dot notation — entity_name.field_name — to reference a field on another entity.

Relationship expr always uses qualified identifiers on both sides of the join condition:

relationship "product" {
type = "many_to_one"
expr = "sales_detail.product_key = dim_product.product_key"
}

Dimension expr and measure expr and filter can also use qualified identifiers to reference a column from a related entity. A relationship path between the two entities must exist, or it is a validation error.

entity "sales_detail" {
schema_name = "base"
table_name = "fct_sales_detail"

// References product_name from the related dim_product entity
dimension "product_name" {
label = "Product Name"
type = "text"
expr = "dim_product.product_name"
}

relationship "product" {
label = "Product"
type = "many_to_one"
expr = "sales_detail.product_key = dim_product.product_key"
}
}

The system joins dim_product automatically whenever a query uses product_name. Cross-entity references work the same way in a measure filter — a measure on sales_detail can filter on a dim_product column as long as a relationship connects the two entities.

Multi-hop references are resolved transitively. If sales_detail relates to sales_header, and sales_header relates to dim_persona, an expr on sales_detail can reference dim_persona.persona_key, and the intermediate join through sales_header is added automatically.

Data types​

The semantic model defines its own set of data types that map to SQL data types.

Semantic data types​

Semantic TypeDescriptionExample Values
textText and character data"John Doe", "Product A"
integer64-bit signed integer42, 1000000
float64-bit floating-point number3.14159, 0.001
numberSynonym for float3.14159, 0.001
decimalFixed-precision decimal99.95, 1234.5678901234
boolBoolean valuesTRUE, FALSE
dateCalendar dates2024-01-15
datetimeTimestamps with date and time (millisecond precision)2024-01-15 14:30:00

SQL to semantic type mapping​

SQL Data TypeSemantic Type
VARCHAR, CHAR, TEXT, STRINGtext
INTEGER, BIGINT, SMALLINT, TINYINTinteger
FLOAT, DOUBLE, REALfloat
DECIMAL, NUMERICdecimal
BOOLEAN, BOOLbool
DATEdate
TIMESTAMP, DATETIME, TIMESTAMPTZdatetime

Complete example​

Here's a complete example showing multiple entities with dimensions, measures, and relationships:

// Customer dimension entity
entity "dim_persona" {
table_name = "dim_persona"
label = "Persona Dimension"
description = "Customer dimension containing persona information"

dimension "persona_key" {
label = "Persona Key"
type = "integer"
expr = "persona_key"
}

dimension "first_name" {
label = "First Name"
type = "text"
expr = "first_name"
}

dimension "last_name" {
label = "Last Name"
type = "text"
expr = "last_name"
}

dimension "full_name" {
label = "Full Name"
type = "text"
expr = "CONCAT(first_name, ' ', last_name)"
}
}

// Product dimension entity
entity "dim_product" {
table_name = "dim_product"
label = "Product Dimension"
description = "Product dimension containing offering information"

dimension "product_key" {
label = "Product Key"
type = "integer"
expr = "product_key"
}

dimension "product_name" {
label = "Product Name"
type = "text"
expr = "product_name"
}
}

// Sales fact entity
entity "sales_detail" {
schema_name = "base"
table_name = "fct_sales_detail"
label = "Sales Detail Fact"
description = "Contains detailed sales transaction data"

// Dimensions
dimension "sales_detail_key" {
label = "Sales Detail Key"
type = "integer"
expr = "sales_detail_key"
}

dimension "sales_header_key" {
label = "Sales Header Key"
type = "integer"
expr = "sales_header_key"
}

dimension "product_key" {
label = "Product Key"
type = "integer"
expr = "product_key"
}

// Hidden dimensions for compound measure operands
dimension "unit_quantity" {
label = "Unit Quantity"
type = "integer"
expr = "unit_quantity"
hidden = true
}

dimension "unit_price_amount" {
label = "Unit Price Amount"
type = "decimal"
expr = "unit_price_amount"
hidden = true
}

// Measures
measure "unit_quantity" {
label = "Unit Quantity"
type = "number"
description = "Number of units sold"
expr = "unit_quantity"
aggregation_type = "sum"
}

measure "unit_price_amount" {
label = "Unit Price Amount"
type = "decimal"
description = "Price per unit"
expr = "unit_price_amount"
aggregation_type = "avg"
}

measure "sales_amount" {
label = "Sales Amount"
type = "decimal"
description = "Total sales amount (quantity × price)"
expr = "unit_quantity * unit_price_amount"
aggregation_type = "sum"
}

// Relationships
relationship "sales_header" {
label = "Sales Header"
type = "many_to_one"
expr = "sales_detail.sales_header_key = sales_header.sales_header_key"
}

relationship "dim_products" {
label = "Product"
type = "many_to_one"
expr = "sales_detail.product_key = dim_products.product_key"
}
}

Best practices​

File organization​

  • One entity per file: Makes navigation and maintenance easier
  • Use subject area folders: For large projects, organize by business domain
  • Consistent naming: Use lowercase with underscores for file names (e.g., dim_customer.smdl)

Naming conventions​

  • Entity names: Use descriptive business names (e.g., sales_detail, not fct_sls_dtl)
  • Dimension/measure names: Use clear, business-friendly labels
  • Relationship names: Describe the target entity or the relationship purpose

Performance considerations​

  • Use table_name when possible: Query-based entities add overhead
  • Mark calculated measures as non-additive: Helps with pre-aggregation optimization
  • Keep expressions simple: Complex calculations can impact query performance

Documentation​

  • Add descriptions: Use the description field to explain business meaning
  • Document calculated fields: Explain the logic in calculated dimensions/measures