Typing and Validation
Typing and Validation
Trilogy models describe both the shape of data and the promises that queries can rely on. Check those promises at three levels:
- Type checking checks declarations, expressions, and impossible literal comparisons while parsing and compiling.
- Unit tests exercise models with generated data using
trilogy unit. - Integration validation checks real datasource bindings, grain, declared value domains, completeness, and assertions using
validateortrilogy integration.
Parsing a model does not prove that its warehouse data satisfies the model. Run validation against that data as part of deployment and refresh workflows.
Type Checking
Every concept in a Trilogy model has a type. Beyond the standard primitives, type statements define custom types for concept declarations, enabling semantic typing and type aliases:
type usd float;
type email_address string;
key customer_id int;
property customer_id.email string::email_address;
property customer_id.balance float::usd;
Custom types are applied as traits with base_type::trait. They give tools semantic information while retaining the underlying data type. Add ? after a declaration's type when null is allowed.
There are standard types defined in the std. library imports included with Trilogy. These are suggested default integration targets, though users can create and extend their own.
Ranges, Enums, and Patterns
Constrain values directly in a declaration, or define a reusable constrained trait:
type percentage float[0..100];
key response_id int;
properties response_id (
score int[0..10],
completion float::percentage,
status enum<string>['new', 'active', 'closed'],
code string['[A-Z]{3}[0-9]{4}']?,
observed_on date['2020-01-01'..],
);
Ranges include both endpoints. int[0..] has no upper bound, and int[0..10, 20..30] allows either interval. Enums list the permitted string or integer values. String patterns match the entire non-null value; ? controls nullability separately. See Type Statements for supported forms and restrictions.
Trilogy rejects provably impossible literal predicates, such as comparing score = 20 for int[0..10] or status = 'deleted' for the enum above. During datasource validation, domain checks scan for actual out-of-range, out-of-enum, and pattern-violating values. Regex execution uses the database's regex dialect, so use patterns supported by your target engine.
Validation
Validate statements choose which model promises to check. With an executor connected to the warehouse, these checks read the underlying data:
import customer as customer;
import order as order;
# Validate the model against its bound data
validate all;
Use validate datasource customer.customers; to inspect one imported datasource, or validate concept customer.id; to check a concept's bindings across sources. validate all; includes both scopes.
Partial and Complete Data
A binding without ~ claims to cover the concept's full domain. Mark a binding partial when the table contains only some of those values. For example, orders may reference only some customers:
key customer_id int;
key order_id int;
datasource customers (customer_id)
grain (customer_id)
address customers;
datasource orders (order_id, customer_id: ~customer_id)
grain (order_id)
address orders;
validate concept customer_id;
Concept validation compares distinct counts across complete bindings and flags smaller populations that should be marked partial. For additive sum/count metrics it compares totals across stored aggregates. Matching counts alone do not prove that two sources contain identical sets of values. Partialness describes domain coverage; nullability (?) describes whether an individual value can be null.
Use partial datasource for a source whose population is a subset, and complete where to declare the slice it fully covers:
key event_id int;
property event_id.region string;
partial datasource eu_events (event_id, region)
grain (event_id)
complete where region = 'EU'
address eu_events;
validate datasource eu_events;
The planner can use this source for the declared EU scope. complete where is a promise, not a row filter: validation checks for rows outside the claim when its referenced columns are available. Add a datasource where clause when the underlying source needs filtering. An unfiltered query cannot use a partial-only source as if it covered every region.
Assertions on Published and Refreshed Data
Attach business rules to a datasource with assert:
key account_id int;
datasource accounts (account_id)
grain (account_id)
address accounts
assert count(account_id) >= 1
assert count(account_id) >= 0.75 * prior(count(account_id))
;
Refresh and persist evaluate assertions on the new build before publishing it. Here, the absolute check prevents an empty build, while prior(...) compares the new population to the previous asset. On a first build there is no baseline, so the prior-dependent check is skipped with a warning; the absolute check still applies.
validate datasource accounts; checks absolute assertions on the published asset. It reports prior-dependent assertions as not run because validation has no previous build to compare. See datasource assertions for grouped checks and reusable templates.
Unit Tests
The unit command runs unit tests on Trilogy scripts with mocked datasources. It always uses DuckDB internally, so no database connection or dialect argument is needed:
# Test single file
trilogy unit test_query.preql
# Test entire directory, in parallel
trilogy unit tests/ -p 4
This parses the script(s), validates datasources with mocked data, and reports validation results and any errors — a fast feedback loop for development and CI/CD.
Within a script, mock statements create temporary mock implementations of datasources or concepts for testing and development:
import customer as customer;
# Mock the imported customer datasource for testing
mock datasource customer.customers;
# Now queries will use mock data
select
customer.id,
customer.name
;
Generated data can exercise model structure, but cannot establish production data quality. Range and enum declarations guide generated values. Regex mocks support literals, character classes, alternatives, groups, and repetition. Unsupported constructs such as lookaround or backreferences raise an error; use explicit test data for those models.
Integration Tests
The integration command validates models with real database connections, including data-level checks. Ordinary integration validation reads data without rebuilding assets; the optional --refresh-derived failed recovery mode can refresh derived assets and retry.
# Integration test against Postgres
trilogy integration tests/ postgres host=localhost database=testdb username=app password=secret
# Integration test single file
trilogy integration query.preql duckdb
A Typical CI/CD Flow
Both test commands check datasource and concept scopes by default. --skip-type concepts narrows a run to datasource checks. For models used by agents, natural-language answer validation compares generated answers with authored queries. That additional test type requires --include-type agent, uses the configured provider, and consumes provider tokens.
- On every commit:
trilogy unit .— parse, type-check, and validate all scripts against mocked data. - After deployment:
trilogy integration . <dialect>— verify the deployed warehouse matches the model, including domains and completeness claims. - Before a managed refresh:
trilogy refresh . --dry-run— review the planned builds and their assertions, then run the refresh to evaluate them.