Generate models from your warehouse¶
Every model in these tutorials was written by hand, from a view whose shape you
already knew. That does not scale past a few fields, and it goes stale silently
when someone changes the view. In this tutorial you will stop writing them: the
semolina command reads a semantic view, or probes a query, and prints the
Python class.
You will generate two classes. semolina codegen writes the
SemanticView model that describes the whole view.
semolina codegen-dto writes the Pydantic response class for one query’s
result, typed for the warehouse it asked.
Prerequisites: Test queries without a warehouse, and its reports.py,
conftest.py and test_reports.py. The commands below run against the same
tutorial.db. The semolina command comes with the cli extra, so add it to the
environment you set up there:
pip install "semolina[cli]"
Tip
Formatted output
Both commands print valid but unformatted Python by default. Install the
codegen-lint extra and the generated source goes through ruff first, with
its imports sorted:
pip install "semolina[codegen-lint]"
1. Read the view¶
semolina codegen takes the view names you want and a --backend, and
writes Python to stdout:
semolina codegen analytics.sales --backend snowflake
semolina codegen analytics.sales --backend databricks
semolina codegen sales --backend duckdb --database tutorial.db
Snowflake and Databricks find the view through the credentials in your
.semolina.toml or your environment. DuckDB has no credentials, so it takes
a --database path instead: --backend duckdb requires either that flag or
a DUCKDB_DATABASE environment variable.
Run the DuckDB command and the class arrives on stdout:
from semolina import Dimension, Fact, Metric, SemanticView
class Sales(SemanticView, view="sales"):
country = Dimension[str]()
region = Dimension[str]()
revenue = Metric[int | None]()
cost = Metric[int | None]()
That is the model you wrote by hand in Your first query, with two differences. The fields are ordered as the warehouse reports them rather than as you happened to type them, and each one carries the type its column arrives as, which you had no way to know without asking.
The | None on both metrics is not hedging. An aggregate over a group whose
inputs are all NULL returns NULL, so SUM can produce one whatever the column
underneath allows.
Fact is imported and unused here because this view declares
no facts. Codegen emits one import line for every field type it can produce, so the
import block is the same whichever view you generate.
2. Keep it in a file¶
Redirect stdout to write the module. Everything the command has to say for itself goes to stderr, so the file captures only Python:
semolina codegen sales --backend duckdb --database tutorial.db > models.py
Now query with it. The generated Sales behaves exactly like the hand-written
one, so this is the first-query script with one import changed. Save it as
check_models.py:
from adbc_poolhouse import DuckDBConfig
from models import Sales
from semolina import create_engine
create_engine(
DuckDBConfig(database="tutorial.db"), register=True
)
cursor = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.order_by(Sales.revenue.desc())
.execute()
)
for row in cursor.fetchall_rows():
print(row.country, row.revenue)
$ python check_models.py
CA 2000
US 1500
The typed fields do more than document. Metric[int | None] is what lets your
editor tell you the type of row.revenue before you run anything, and what
lets a type checker catch a comparison against the wrong kind of value.
3. Generate the response DTO¶
semolina codegen describes a view. It cannot describe a result, because a
result belongs to one query: it carries the subset of fields that query
projected, under the column names that query produced, with the types that
query’s aggregations returned. So the DTO comes from a query instead.
semolina codegen-dto will take a dotted path to a query object you already
wrote. The query in reports.py is built inside a function, so there is
nothing importable to point at, and the second route fits better: name the view
and the fields and let codegen build the query itself.
This is the projection the dashboard endpoint runs – the revenue metric by
the country dimension:
semolina codegen-dto --backend snowflake \
--view analytics.sales \
--metrics revenue --dimensions country \
--name RevenueByCountry --output dtos.py
semolina codegen-dto --backend databricks \
--view analytics.sales \
--metrics revenue --dimensions country \
--name RevenueByCountry --output dtos.py
semolina codegen-dto --backend duckdb --database tutorial.db \
--view sales \
--metrics revenue --dimensions country \
--name RevenueByCountry --output dtos.py
--output writes the file directly. Without --name, the class would be
called Sales after the view’s last segment.
Run the DuckDB command:
$ semolina codegen-dto --backend duckdb --database tutorial.db \
--view sales --metrics revenue --dimensions country \
--name RevenueByCountry --output dtos.py
Wrote: dtos.py (1 class)
"""
Generated result DTOs. Do not edit.
Backend: duckdb
Fields bind to result columns by name. Every backend names a column after its field,
so a field carries an alias only where its column is spelled differently. The
annotations reflect this backend's own aggregation result typing: regenerate against
another warehouse where its driver types differ.
Classes:
RevenueByCountry -- view 'sales' metrics=[revenue] dimensions=[country] (dialect: DuckDBDialect, probe route: execute-schema)
"""
from __future__ import annotations
import pydantic
class RevenueByCountry(pydantic.BaseModel):
"""Result DTO for view 'sales' metrics=[revenue] dimensions=[country] (probe route: execute-schema)."""
revenue: int | None
country: str
No row of your data was fetched. Codegen asked DuckDB to type the query rather
than run it, which is what probe route: execute-schema in the header
records.
The field names are the query’s field names, which is what every backend calls
the result columns, so the class needs no aliases. The annotations are the part
that was measured. Regenerate against Snowflake and the same file comes back
with revenue: decimal.Decimal | None, and the field names in the JSON your
endpoint returns do not change.
Warning
A generated DTO’s annotations belong to one backend
Each annotation is the type the probed backend reported, so a file probed against DuckDB can be wrong for Snowflake wherever the two drivers type a column differently. The header names the backend that answered. Regenerate against the warehouse you deploy to, not the one you develop against.
4. Use both¶
reports.py now has nothing left to declare. Delete the hand-written model
and DTO from it and import the generated ones:
"""Query code the dashboard endpoint calls."""
from dtos import RevenueByCountry
from models import Sales
def revenue_by_country(
country: str | None = None,
) -> list[RevenueByCountry]:
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.where(
Sales.country == country if country else None
)
.order_by(Sales.revenue.desc())
)
with query.execute() as cursor:
return cursor.into(RevenueByCountry)
The test suite from the last tutorial is what tells you the swap worked. Run it unchanged:
pytest
============================= test session starts ==============================
platform darwin -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/semolina-tutorial
plugins: anyio-4.14.2
collected 3 items
test_reports.py ... [100%]
============================== 3 passed in 0.18s ===============================
Two generated files, one hand-written query function, and the same three
assertions passing. The projection is the one thing that still has to agree by
hand: --metrics revenue --dimensions country has to match what the query
selects, or the DTO’s fields will not find their columns.
Next steps¶
That is the last of the six tutorials. Your application’s model, response class and test suite now all come from the warehouse rather than from memory.
You have generated files that were correct at the moment you generated them.
Both commands take a --check flag that re-probes the warehouse and exits
non-zero when a committed file no longer matches, which is the thing to put in
CI. See Check a committed model for drift and How to generate a DTO class with semolina codegen-dto.
Once you have more than a couple of DTOs, declare them in a [tool.semolina.dto]
section of pyproject.toml and regenerate the set with a bare
semolina codegen-dto. How to generate a DTO class with semolina codegen-dto has the format.
Pointing either command at Snowflake or Databricks needs credentials, and
Semolina reads them from .semolina.toml, then the environment, then a
.env file. How to configure codegen credentials covers the chain.
See also¶
Multiple views at once, facts, unsupported column types, and what introspection reads on each backend.
The dotted-path route, pyproject.toml config, Any annotations, and
why every metric is optional.
.semolina.toml, environment variables, and .env files.
Running --check in CI so a changed view fails the build.
What the generated field types mean, and the source= escape hatch for
a warehouse name Python cannot spell.
Why the warehouse decides each annotation, and what a generated one promises.