Concepts¶
Varda models the parts of dimensional design that a general modeling language has no words for. LinkML already knows about classes, slots, ranges, cardinality, enumerations, inheritance and imports. It does not know about facts, dimensions, grain, additivity or slowly-changing dimensions, because those are dimensional-modeling concepts rather than general modeling concepts.
Three of them carry most of the weight: what one row of a fact is, how a measure may be aggregated, and what happens when a dimension value changes. Everything below builds to those.
Tables have a role¶
Every annotated class declares varda:role:
FACT, DIMENSION or BRIDGE. Nothing is inferred from the class name — a
table named FctSale is a fact because it says so, which is what lets one
organization write Fct, another Fact, and a third nothing at all.
Facts are measurement events. They have a declared grain, foreign keys to dimensions, and zero or more measures. Nothing points at them.
Dimensions are descriptive context. One surrogate key, at least one natural key, and attributes that are almost always textual or categorical.
Bridges resolve a many-to-many — a customer in three segments — and usually carry an allocation factor, because a customer counted once per segment is a customer counted three times.
Columns have a role too¶
varda:role on a slot turns a flat list of columns into a structure that
generators and validators can reason about.
| Role | On | What it is |
|---|---|---|
SURROGATE_KEY |
dimension | The meaningless key facts join to. Exactly one. |
NATURAL_KEY |
dimension | The business identity a loader matches on. |
FOREIGN_KEY |
fact, bridge, dimension | A reference to another table's surrogate key. |
DEGENERATE_DIMENSION |
fact | An identifier with no dimension table of its own. |
MEASURE |
fact, bridge | A numeric quantity that is aggregated. |
ATTRIBUTE |
any | Descriptive context, grouped and filtered on. |
VERSION_START, VERSION_END, IS_CURRENT, VERSION_NUMBER |
type-2 dimension | What marks one version of a row off from another. |
The natural key is what a loader matches on — what makes two source rows the same business entity. Without one, every load either creates duplicates or has the matching rule written somewhere the model cannot see. The surrogate key is the meaningless integer the facts actually join to.
A degenerate dimension is an identifier living on the fact with no dimension table of its own — an order number, a ticket reference. It groups the lines of one transaction and has no attributes worth a table.
The versioning roles belong to history, below.
Grain: what one row is¶
A fact declares its grain twice, in two forms that do different jobs.
varda:grain names the columns at which rows are unique — the set that
together identifies exactly one row. This is the grain proper: in every formal
treatment of dimensional modeling the grain is that set. Stating it makes
the claim testable. V203 checks the columns
exist, V204 checks each is a foreign key or a
degenerate dimension — a grain is what a row is, not what it records — and
the SQL generator turns the set into a UNIQUE constraint. Whether the
database polices that constraint on every write, or the claim is checked once
per load by the generated assertions instead, is what --constraints
chooses; either way the claim is stated somewhere a machine reads.
varda:grain_statement says the same thing in words, conventionally "one row
per …". It carries what a column list cannot: why those columns, and what a
row means to somebody reading the model rather than querying it. A grain that
cannot be stated in one sentence is a sign the table is doing two jobs, and
writing it is most of the value. V202 checks it
is a sentence and nothing further.
Nothing compares the sentence to the columns, deliberately — see Design notes.
Additivity is the expensive one¶
Every measure declares varda:additivity.
ADDITIVE |
Sums across every dimension. Sales amount, quantity, cost. |
SEMI_ADDITIVE |
Sums across some dimensions but not others — almost always not across time. Account balance, inventory level, headcount. |
NON_ADDITIVE |
Never sum. Ratios, percentages, unit prices, temperatures. Aggregate the components and recompute. |
A semi-additive measure must also declare varda:semi_additive_over, naming
the foreign key it may not be summed across, and
V703 checks that column actually exists on the
table. A constraint naming a dimension the fact does not have is a constraint
that silently never applies, and it looks exactly like one that does.
Why this family is error and not warning
Summing something that must not be summed produces a number that looks fine. Nobody's query breaks, no test fails, and the wrong figure reaches someone who acts on it. This is the one class of modeling error that is both easy to make and invisible afterwards.
History: what happens when a value changes¶
varda:scd declares how a dimension responds when a source attribute
changes: TYPE_0 retain the original, TYPE_1 overwrite it, TYPE_2 add a
row.
It sits on the table rather than per column, deliberately. A mixed dimension — some attributes overwritten, others versioned — is a genuinely harder object that this core does not model. Split it into two dimensions, or handle it in an extension.
V502 is a warning rather than an error, because a
great many dimensions are genuinely type 1 and saying so feels like ceremony.
It stays a rule because "we never decided" and "we decided overwrite" look
identical in the model and cost very differently two years later.
How a type-2 dimension versions¶
Type 2 keeps a row per change. It does not say how the current row is found, because the field does not agree on that, and three mechanisms are all in normal use:
| Strategy | Columns | The current row is |
|---|---|---|
| window | VERSION_START + VERSION_END |
the one whose end is null or far-future |
| flagged | VERSION_START + IS_CURRENT |
the flagged one; the end derived from the next row |
| counter | VERSION_NUMBER |
the highest per natural key |
Varda accepts all three, and insists that something able to discriminate be marked — a start or a counter. A dimension versioning by an undeclared mechanism cannot have its uniqueness generated. That constraint is the return on saying so: a type-2 dimension is unique on its natural key plus the discriminator, never the natural key alone, which would reject the second version of every row.
IS_CURRENT is not a discriminator. It is true of exactly one version, so a
key carrying it lets every superseded row repeat — which is why all three
strategies above pair the flag with a start rather than relying on it.
The period follows SQL:2011 — closed at the start, open at the end. A row is
in force from VERSION_START up to but not including VERSION_END, which is
what stops consecutive versions overlapping at their boundary.
Version time is usually not business time
VERSION_START is named for the version rather than for validity because
in practice it records when the warehouse noticed a change, not when the
change was true. dbt's dbt_valid_from and Data Vault's LOAD_DATE are
both this, whatever they are called. Keeping both would be bitemporality,
which this core does not model.
When one thing has two identities¶
varda:role says what part a column plays. LinkML's own unique_keys says
which combinations are unique. They are different claims — a surrogate key is
unique and is not a business key, and valid_from belongs in a type-2
dimension's unique key while being a version marker rather than an identity —
so Varda reads both.
Most dimensions need only the roles. Varda derives the constraint from them: a type-0 or type-1 dimension is unique on its natural key, because neither keeps a second row for one business entity, and a type-2 dimension is unique on that key plus whatever marks its versions apart.
A dimension that declares no varda:scd gets no derived constraint, because
the answer depends on which type it is and guessing wrong costs rows —
V502 reports the missing declaration.
That derivation needs one natural key, and stops at more than one. A dimension loaded from several sources often has more than one, because each source identifies the thing its own way — and two of them are ambiguous in a way a role cannot resolve. Either they are one compound identity, a store known by its chain code and its store number together, or they are two alternatives, and the two want opposite constraints. So the model says which:
DimProduct:
annotations:
varda:role: DIMENSION
varda:scd: TYPE_2
unique_keys:
by_barcode:
description: Sources that supply a barcode.
unique_key_slots: [gtin, valid_from]
by_supplier_part:
description: Sources identifying an item by supplier and part number.
unique_key_slots: [supplier_code, supplier_part_number, valid_from]
Two keys are two constraints, and that is the whole point. Merging them into
one is weaker than either alone, and inert as well: a row whose gtin is null
passes a merged key without being checked at all. Kept apart, each row is
caught by whichever key its own source populated.
Declaring them replaces the derived constraint rather than adding to it, so a
table states its uniqueness in one place or the other.
V306 requires it wherever there is more than one
natural key, as an error: the merged constraint was derived silently for a
long time and enforced neither identity, and deriving nothing instead would
leave a dimension whose identity the database does not hold.
V307 warns when a dimension's only natural key
is nullable, because SQL counts nulls as distinct and the uniqueness then
does not hold for exactly the rows a repeated load produces — where there are
several keys, being absent is the point and it says nothing.
V303 checks the columns exist, because LinkML
accepts a key over a misspelled slot without complaint.
V304 checks a business key on a type-2 dimension
carries a version marker — without one it claims the business key does not
repeat, which on a type-2 table is false.
V305 warns about a natural key none of them
cover.
Two sources, one thing, two rows
A unique key stops one source loading the same product twice. Nothing structural can tell you that a row from one source and a row from another are the same product — that is a matching decision the ETL makes, and Varda has no column for where it landed.
Hierarchies: how a dimension is drilled¶
varda:hierarchies declares the named paths a reader drills down. Levels run
from least to most granular, which is the direction every system that models
hierarchies uses:
DimStore:
annotations:
varda:role: DIMENSION
varda:hierarchies:
- name: geography
levels: [country, region, store_code]
When the coarser levels are their own tables — a snowflake — the only
geography columns on DimCity are the keys, and a path of keys reads as
integers. Reach through the key instead:
DimCity:
annotations:
varda:role: DIMENSION
varda:hierarchies:
- name: geography
levels: [country_key.country_name, state_key.state_name, city_name]
Both halves are checked: the near name must be a foreign key of this table,
and the far one a column of the dimension it points at. A level that names a
foreign key without reaching through it is an error, because 4718 is not
something anyone drills into.
A hierarchy makes two claims, and only one of them is checkable. The path — offer country, then region, then store — is a declaration, and Varda checks that the levels are real columns, distinct, at least two of them, on a dimension, and the kind of column that can be a level. The other claim is that each level rolls up into exactly one member of the level above it, and that is a statement about data. Nothing here can test it, the same way nothing tests the grain sentence.
Three things go wrong often enough to be worth naming.
Two members can share a name. A level answers two questions: what a
member is called, and which member it is. Usually one column does both.
When it does not — city_name holds "Springfield" for cities in three
states — nothing extra is needed, because the hierarchy has already said
what tells them apart:
A level's identity is its own key preceded by the key of every coarser level,
so city_name is identified by country, state and city together. Varda
derives that from the order; it is never written out.
Declare a key only when what names a level is not what tells its members
apart — a level showing product_name where two products share a name:
The key defaults to the foreign key for a level reached through one and to
the naming column otherwise. A surrogate key or a foreign key may be a key
and never a column, which is one rule from both sides: they identify well
and read badly.
V607 checks a declared key exists and is the
kind of column that can identify something. Whether members are actually
distinct is a claim about data, and nothing checks it.
Weeks do not roll up into months. [year, quarter, month, week, date]
looks like the obvious calendar hierarchy and is wrong: a week straddles month
boundaries and, under ISO 8601, year boundaries. Real calendars carry two
paths that share their finest level and diverge in the middle, which is why a
column may appear in more than one hierarchy:
varda:hierarchies:
- name: calendar
description: How the business reports externally.
levels: [calendar_year, calendar_month, calendar_date]
- name: iso_week
description: How operations plans its weeks.
levels: [iso_year, iso_week, calendar_date]
A description is optional and earns its place exactly here: when a
dimension carries several paths, the names alone rarely say which one a
reader wants.
A hierarchy in a type-1 dimension rewrites history. Districts get redrawn and products get recategorized, and when the dimension overwrites, last year's numbers move into this year's regions. That is often what people want; it is never what they expect. A dimension whose hierarchy has to stay put under historical reporting is type 2, and the reasoning is the same one in History — the choice is cheap to make now and expensive to discover later.
Only fixed-depth paths are modeled. A hierarchy whose branches end at
different depths, whose levels can be skipped, or where a child has more than
one parent is not a list of columns — it is a bridge table, and the many-to-many
it resolves is what BRIDGE is for.
What Varda does not model¶
Data quality — whether the rows are right — is a different layer with a different audience. Varda's rules answer "is this a legal dimensional model?", never "is this data correct" and never "is this a good model". The last is a review, and a rule that produces an argument gets switched off.