How to define models¶
The Sales class in Your first query is about the smallest model that works.
A SemanticView subclass can declare rather more: three kinds
of field, a type subscript your IDE reads, and the descriptors the query builder sees.
Create a model¶
Subclass SemanticView and pass the warehouse view name via view=:
from semolina import SemanticView, Metric, Dimension, Fact
class Sales(SemanticView, view="sales"):
revenue = Metric[float]()
cost = Metric[float]()
country = Dimension[str]()
region = Dimension[str]()
unit_price = Fact[float]()
The type parameter ([float], [str], etc.) is covered under Type the subscript.
Note
float is used for brevity here; codegen writes Decimal | None
The examples on this page annotate money metrics Metric[float]() to keep the
focus on model structure. A real DECIMAL column arrives as a
decimal.Decimal, and semolina codegen writes
Metric[decimal.Decimal | None]() for it: Decimal because that is the value
you get, and | None because a metric over an empty group is null.
This matters if you run semolina codegen --check. That command compares the type
your committed annotation names with the one the result schema implies, so a
hand-written Metric[float]() on a decimal column is reported as drift. Prefer
the annotation codegen would write for models you intend to check.
.into(DTO) is a separate question and behaves differently: a hand-written
float there is honoured as a deliberate narrowing under validate=True. See
How to map results into Pydantic models with .into().
The view= parameter is required – it identifies the semantic view in your warehouse.
Omitting it raises a TypeError at class creation time. The one exception is a base class
that only shares fields; see Share fields between models.
Name a view in another schema¶
If your view lives in a specific schema, pass the schema-qualified name:
class Sales(
SemanticView,
view="analytics.sales",
):
revenue = Metric[float]()
For Databricks Unity Catalog, use three-part names:
class Sales(
SemanticView,
view="catalog.schema.sales",
):
revenue = Metric[float]()
Note
How the view name is quoted
Semolina folds the view name to your warehouse’s default identifier case –
the same folding it applies to field names: uppercase on Snowflake,
lowercase on Databricks and DuckDB. So view="sales" resolves against a
view created the ordinary (unquoted) way, whatever case you write here.
Schema-qualified names are split on . and quoted per part, so on Snowflake
view="analytics.sales" becomes "ANALYTICS"."SALES".
If a view was created with a quoted, case-sensitive name, quote that segment yourself to keep it verbatim:
class Sales(
SemanticView,
view='analytics."Sales"', # -> "ANALYTICS"."Sales"
):
revenue = Metric[float]()
Semolina escapes the name between the quotes for you, so a quote inside it can be
written once, view='analytics."My"View"', or doubled the standard SQL way,
view='analytics."My""View"'. Both name the view My"View on Snowflake.
On Databricks, quote the segment with backticks instead:
view="analytics.`My View`". A backtick inside it is handled the same way, written
once or doubled. On DuckDB, Semolina passes the view name to semantic_view()
exactly as you write it, and DuckDB matches names case-insensitively anyway, so
none of this quoting applies there.
Choose field types¶
Each field type corresponds to a role in your semantic view query:
Field |
Use for |
Accepted by |
|---|---|---|
Aggregated measures: revenue totals, counts, averages |
|
|
Categorical grouping: country, product category, date |
|
|
Raw event-level numeric columns ( |
|
Metric fields¶
A Metric represents an aggregatable measure. In generated SQL, metrics
are wrapped in the backend’s aggregation function:
class Orders(SemanticView, view="orders"):
total_revenue = Metric[float]()
order_count = Metric[int]()
SELECT AGG("TOTAL_REVENUE") AS "total_revenue", AGG("ORDER_COUNT") AS "order_count"
FROM "ORDERS"
SELECT MEASURE(`total_revenue`) AS `total_revenue`, MEASURE(`order_count`) AS `order_count`
FROM `orders`
SELECT "total_revenue" AS "total_revenue", "order_count" AS "order_count"
FROM semantic_view('orders', metrics := ['total_revenue', 'order_count'])
Passing a Metric to .dimensions() raises a TypeError. Pass it to .metrics() instead.
Dimension fields¶
A Dimension represents a categorical attribute used for grouping:
class Orders(SemanticView, view="orders"):
country = Dimension[str]()
product_category = Dimension[str]()
SELECT "COUNTRY" AS "country", "PRODUCT_CATEGORY" AS "product_category"
FROM "ORDERS"
GROUP BY ALL
SELECT `country` AS `country`, `product_category` AS `product_category`
FROM `orders`
GROUP BY ALL
SELECT "country" AS "country", "product_category" AS "product_category"
FROM semantic_view('orders', dimensions := ['country', 'product_category'])
Fact fields¶
A Fact represents a raw numeric value that has not been pre-aggregated.
Snowflake users: a column declared in the FACTS clause of CREATE SEMANTIC VIEW
reports kind=FACT when you introspect with SHOW COLUMNS IN VIEW, and
semolina codegen emits Fact() for it automatically. For hand-written models,
use Fact for raw numeric columns you want to distinguish semantically from categorical
dimensions.
Databricks users: Databricks metric views have no native fact concept. Every
non-aggregate field is a dimension: in the metric view YAML. Use Fact in your Semolina
model for raw numeric columns you want to distinguish semantically from categorical grouping
attributes – the warehouse does not enforce the distinction, but your teammates and future
readers will see the intent.
On Snowflake and Databricks, Fact and Dimension produce identical SQL
(SELECT "COL" AS "col" FROM ... GROUP BY ALL), so the distinction is semantic rather
than a difference in execution. DuckDB’s semantic_view() takes facts in their own
facts := argument and returns them row by row, so Semolina adds SELECT DISTINCT to
a fact query there. You get the same rows on all three: one per distinct combination of
values. DuckDB can’t select facts and metrics in the same query, so Semolina raises
ValueError if you pass a Fact to .dimensions() alongside .metrics() on a
DuckDB engine.
Default to Dimension. Use Fact as an intentional opt-in for two situations:
Semantic precision – the column is a raw event-level numeric (
unit_price,quantity,line_amount) you want to distinguish from categorical attributes likecountryorproduct_category.Snowflake alignment –
semolina codegenintrospected the column askind=FACTfrom your warehouse. Match that designation in Semolina.
class Orders(SemanticView, view="orders"):
# raw price column, not aggregated
unit_price = Fact[float]()
quantity = Fact[int]()
# categorical grouping attribute
country = Dimension[str]()
product_category = Dimension[str]()
SELECT "UNIT_PRICE" AS "unit_price", "QUANTITY" AS "quantity"
FROM "ORDERS"
GROUP BY ALL
SELECT `unit_price` AS `unit_price`, `quantity` AS `quantity`
FROM `orders`
GROUP BY ALL
SELECT DISTINCT "unit_price" AS "unit_price", "quantity" AS "quantity"
FROM semantic_view('orders', facts := ['unit_price', 'quantity'])
Type the subscript¶
Annotate each field with the Python type of the underlying warehouse column. The subscript is optional but recommended – it lets your IDE infer the type when you access query results:
revenue = Metric[float]()
order_id = Metric[int]()
country = Dimension[str]()
# date column; requires: import datetime
created_at = Dimension[datetime.date]()
When the column type has no clean Python equivalent (GEOGRAPHY, ARRAY, STRUCT), use
Metric[Any]() and import Any from typing. The semolina codegen command emits a
# TODO: comment for these cases. A VARIANT column takes
JsonValue; see How to generate Semolina model classes from warehouse views.
You can also omit the subscript entirely. Metric() is shorthand for Metric[Any]() –
valid Python, just without type narrowing.
Put it together¶
A complete model with all three field types:
from semolina import SemanticView, Metric, Dimension, Fact
class Orders(SemanticView, view="orders"):
"""Order-level metrics and dimensions."""
total_revenue = Metric[float]()
order_count = Metric[int]()
country = Dimension[str]()
product_category = Dimension[str]()
unit_price = Fact[float]() # raw price, not aggregated
Access field descriptors¶
Fields use Python’s descriptor protocol.
Accessing Orders.total_revenue at the class level returns the
Metric descriptor itself – the same object you pass into
query methods:
# Class-level access: returns the descriptor
field = Orders.total_revenue
print(type(field))
# <class 'semolina.fields.Metric'>
# Pass directly into query methods
query = Orders.query().metrics(
Orders.total_revenue,
Orders.order_count,
)
Accessing a field on an instance raises AttributeError. Semolina models are never
instantiated – the class is the query target.
Model immutability¶
Models are frozen immediately after the class body executes. Attempting to modify a
model attribute after class creation raises AttributeError:
class Sales(SemanticView, view="sales"):
revenue = Metric[float]()
# AttributeError: Cannot modify after creation
Sales.revenue = Metric[float]()
A model therefore cannot change shape while a query built from it is in flight.
Carry a field description from your warehouse¶
Where your warehouse records a comment on a column, semolina codegen writes it as
a docstring under the generated field:
class Orders(SemanticView, view="orders"):
total_revenue = Metric[decimal.Decimal | None]()
"""Sum of revenue, tax excluded."""
The description travels one way. All three backends read the column comment out of the catalogue during introspection, and codegen renders it into the model it writes. Nothing reads a docstring you attach to a field yourself, so a description you want in a regenerated model belongs in the warehouse rather than in your Python.
See How to generate Semolina model classes from warehouse views for the rest of the generated output.
See also¶
Your first query – the minimal model this page expands on
How to build queries – use your model to build and execute queries
How to filter queries – filter queries with field operators
How to generate Semolina model classes from warehouse views – generate models from existing warehouse views
Check a committed model for drift – check a committed model for annotation drift
Warehouse types and what lands in your row – which Python type each warehouse column arrives as