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
| Parameter | Required | Description |
|---|---|---|
default | Yes | The default semantic layer to use |
name | Yes | Display name for the semantic layer |
path | Yes | Path to the directory containing .smdl files |
environment | Yes | Project 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
| Parameter | Type | Required | Description |
|---|---|---|---|
label | string | No | User-friendly name displayed in the UI |
description | string | No | Entity description. Supports Markdown formatting |
schema_name | string | No | Schema containing the physical table. Valid only alongside table_name; combining it with query is an error. See Schema-qualified tables |
table_name | string | Conditional | Physical table name, without a schema qualifier. Required if query and csl_block are not specified |
query | string | Conditional | SQL query defining the entity. Required if table_name and csl_block are not specified. Use heredoc syntax (<<-EOF) for multi-line queries |
csl_package | string | Conditional | Package 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_block | string | Conditional | Name of the CoginitiScript block that publishes the source table or view. Required with csl_package, when table_name and query are not specified |
dimension | block | No | Dimension definitions (can have multiple) |
measure | block | No | Measure definitions (can have multiple) |
relationship | block | No | Relationship 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_nameis valid only withtable_name. Declaring it withouttable_name, or together withquery, is a validation error.- Do not put a dotted name in
table_name. A value such astable_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
| Parameter | Type | Required | Description |
|---|---|---|---|
label | string | No | User-friendly name displayed in the UI |
type | string | Yes | Data type. Valid values: text, number, date, datetime, bool |
description | string | No | Description of the dimension's semantic meaning |
hidden | boolean | No | When true, hides the dimension from the UI. Default: false |
expr | string | Yes | SQL 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)"
}
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_typeand aggregates a column or expression directly — for example,SUM(amount). - A derived measure has no
aggregation_type; itsexprcombines 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
| Parameter | Type | Required | Description |
|---|---|---|---|
label | string | No | User-friendly name displayed in the UI |
type | string | Yes | Data type. Valid values: number, date, datetime |
description | string | No | Description of the measure's semantic meaning |
hidden | boolean | No | When true, hides the measure from the UI. Default: false |
aggregation_type | string | No | Aggregation function. Valid values: count, count_distinct, sum, avg, max, min, median, custom |
expr | string | Yes | SQL expression for the measure. Identifiers are resolved using identifier resolution — they may reference physical columns, other measures and dimensions, or columns from related entities |
filter | string | No | SQL boolean expression that restricts which rows feed the aggregation. Valid only on simple measures. See Filtered measures |
Aggregation types
| Aggregation Type | SQL Function | Additive | Description |
|---|---|---|---|
count | COUNT(*) | Yes | Count of rows |
count_distinct | COUNT(DISTINCT ...) | No | Count of unique values |
sum | SUM(...) | Yes | Sum of values |
avg | AVG(...) | No | Average of values |
max | MAX(...) | No | Maximum value |
min | MIN(...) | No | Minimum value |
median | MEDIAN(...) | No | Median value |
custom | (expression) | Varies | Custom aggregation expression |
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"
}
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.
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_typethat references only physical columns is invalid. filteris not allowed on a derived measure.- Circular references are an error —
acannot derive frombifbderives froma, directly or transitively.
Additive vs. non-additive measures
Additive measures can be aggregated multiple times and still produce correct results:
countsum
Non-additive measures cannot be rolled up multiple times as it produces incorrect results:
count_distinctavgmedian- 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
| Parameter | Type | Required | Description |
|---|---|---|---|
label | string | No | User-friendly name displayed in the UI |
type | string | Yes | Relationship cardinality. Valid values: one_to_one, one_to_many, many_to_one |
expr | string | Yes | Join condition referencing semantic identifiers: entity_name.dimension_name. Uses entity names and dimension names, not physical table or column names |
Relationship types
| Type | Description | Example |
|---|---|---|
one_to_one | Each record in the source entity corresponds to exactly one record in the target entity | Order to order details (for single-item orders) |
one_to_many | Each record in the source entity corresponds to multiple records in the target entity | Customer to orders |
many_to_one | Multiple records in the source entity correspond to one record in the target entity | Orders 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
| Parameter | Type | Required | Description |
|---|---|---|---|
row_filter | block | Yes | The isolation strategy. One per layer |
row_filter.field | string | Yes | The dimension that marks tenant ownership, on every entity that does not say otherwise |
row_filter.shared_entities | list | No | Entities holding reference data common to every tenant. They are never filtered |
row_filter.entity | block | No | Names 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:
- it declares the dimension named by
row_filter.field, or - it has an
entityoverride naming a different dimension it declares, or - 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
| Condition | Message |
|---|---|
tenancy without row_filter | tenancy policy declares no isolation: add a 'row_filter' block naming the field that marks tenant ownership |
row_filter without field | tenancy row_filter declares no 'field': name the dimension that marks tenant ownership |
| An entity declares neither the field nor an exemption | entity '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 exist | tenancy entity override names unknown entity 'X' |
| An override names a field the entity does not declare | tenancy entity override for 'X' names field 'Y', which is not a dimension on that entity |
shared_entities names an entity that does not exist | tenancy shared_entities names unknown entity 'X' |
| An entity is both shared and overridden | entity 'X' is declared shared and also given a tenant field override |
The tenant field is not text or integer | tenancy field 'Y' on entity 'X' is declared 'date'. A tenant key is an identifier, so it must be 'text' or 'integer' |
At query time:
| Condition | Message |
|---|---|
| No tenant on the connection | this semantic layer requires a tenant: add 'tenant=<name>' to the connection |
| A tenant given to a layer without a policy | tenant 'X' was supplied, but this semantic layer declares no tenancy policy |
| The tenant is unknown, disabled, or not granted | not 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
| Parameter | Type | Required | Description |
|---|---|---|---|
label | string | No | Display name for the domain |
description | string | No | Free text describing the domain |
entity | block | Yes | One per entity the domain lists. A domain must list at least one |
filter | block | No | A mandatory filter. May repeat |
template | string | No | Name of the domain_template this domain instantiates |
values | map | No | Values for that template's parameters |
The entity block:
| Parameter | Type | Required | Description |
|---|---|---|---|
include | list | No | The only fields that are queryable. [] makes the entity internal-only |
exclude | list | No | Every 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
expr | string | Yes | A boolean row-level expression over dimensions, with every field reference qualified |
propagation | string | No | How 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:
| Visibility | Meaning |
|---|---|
| Queryable | Listed in the domain schema and usable in SQL |
| Internal | Hidden from SQL, still available to the compiler |
| Removed | Neither 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:
| Expression | Why |
|---|---|
orders.revenue | Not boolean |
orders.revenue > 1000, where revenue is a measure | A filter cannot reference a measure |
SUM(orders.revenue) > 1000 | Aggregate expressions are not allowed |
ROW_NUMBER() OVER (ORDER BY orders.amount) > 0 | Window expressions are not allowed |
MEASURE(revenue) > 1000 | MEASURE(...) 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"
}
| Value | Status | Meaning |
|---|---|---|
preserve_grain | Supported, and the default | Travel through self, many_to_one and one_to_one paths only, so the scanned entity's row grain never changes |
exists | Reserved | Keep a parent row if at least one child row satisfies the expression |
not_exists | Reserved | Keep a parent row if no child row satisfies it |
all | Reserved | Keep 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:
| Condition | Message |
|---|---|
| A domain lists no entities | domain 'X' must list at least one entity |
A domain is named public | domain name 'public' is reserved for the unrestricted semantic model |
| A domain lists one entity twice | Duplicate domain entity 'Y' |
| A domain lists an entity the layer does not have | domain 'X' references unknown entity 'Y' |
An entity block sets both include and exclude | Domain entity cannot define both 'include' and 'exclude' |
propagation is a reserved value | domain 'X' filter 'P': propagation 'exists' is not supported in v1; only 'preserve_grain' is supported |
propagation is not a known value | domain 'X' filter 'P': unknown propagation 'Y' |
| A domain names a template that does not exist | domain 'X' references unknown domain_template 'T' |
| A templated domain also defines blocks | domain 'X' uses template 'T', so it cannot define entity or filter blocks |
values without a template | domain 'X' defines values but does not reference a template |
| A required value is missing | domain 'X' uses template 'T' but does not supply required value 'V' |
| A value names no parameter | domain 'X' supplies unknown value 'V' for template 'T' |
| A value is the wrong type | domain 'X' value 'V' must match template parameter type 'text' |
| A domain and a template share a name | domain 'X' conflicts with a domain_template of the same name |
| A template lists no entities | domain_template 'T' must list at least one entity |
| A placeholder has no matching value | domain_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:
| Condition | Message |
|---|---|
| A filter does not cover an exposed entity | domain '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 list | domain '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_many | domain '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 entity | domain 'X' predicate 'P' does not cover exposed entity 'Y': path to target entity 'Z' crosses query-backed entity |
| A filter covers nothing the domain exposes | domain 'X' predicate 'P' covers zero exposed entities |
| A filter references an entity the domain does not list | entity 'Y' is not listed in the domain |
| A filter references a measure | measure reference 'Y.m' is not allowed in domain filters |
| A filter is not boolean | expression must be boolean; found <type> |
| A filter aggregates | aggregate expressions are not allowed |
| A filter uses a window function | window expressions are not allowed |
A filter calls MEASURE(...) | MEASURE(...) calls are not allowed |
| A queryable field depends on an entity the domain does not list | domain '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:
| Condition | Message |
|---|---|
| A query joins two semantic schemas | queries cannot mix semantic scopes: A, B |
| A query names an ungranted or unknown schema | reported 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:
- Look it up in the entity's logical namespace — all dimensions and measures — excluding the field currently being defined.
- If found, it is a logical reference to that dimension or measure.
- 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 Type | Description | Example Values |
|---|---|---|
text | Text and character data | "John Doe", "Product A" |
integer | 64-bit signed integer | 42, 1000000 |
float | 64-bit floating-point number | 3.14159, 0.001 |
number | Synonym for float | 3.14159, 0.001 |
decimal | Fixed-precision decimal | 99.95, 1234.5678901234 |
bool | Boolean values | TRUE, FALSE |
date | Calendar dates | 2024-01-15 |
datetime | Timestamps with date and time (millisecond precision) | 2024-01-15 14:30:00 |
SQL to semantic type mapping
| SQL Data Type | Semantic Type |
|---|---|
VARCHAR, CHAR, TEXT, STRING | text |
INTEGER, BIGINT, SMALLINT, TINYINT | integer |
FLOAT, DOUBLE, REAL | float |
DECIMAL, NUMERIC | decimal |
BOOLEAN, BOOL | bool |
DATE | date |
TIMESTAMP, DATETIME, TIMESTAMPTZ | datetime |
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, notfct_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
Related documentation
- Semantic Layer Overview - Understanding the concepts behind semantic layers
- CoginitiScript Reference - Transform data before applying semantic models
- Coginiti API Reference - Provision the tenants a tenancy policy filters by, and control who may connect as each one