Skip to content

Data Modeling (Entities & Facts)

Provisa gives you two declarative primitives — entity and fact — that cover the building blocks every star schema and Data Vault is assembled from. Declare the spec; Provisa lowers it to exactly the materialized-view, bitemporal, and relationship definitions you would otherwise hand-write (REQ-1164). [tool-verified: modeling.py module docstring lines 11-28]

What entities and facts are

An entity is a keyed, deduplicated, optionally-historized projection of a source relation. You name it, point it at a source, declare the entity key and the attributes you want to carry, and choose a history mode. Provisa writes the view SQL and registers the MV. When history is enabled, the MV is bitemporal. [tool-verified: Entity dataclass, modeling.py lines 53-69; entity_registration function, modeling.py lines 105-120]

A fact is a join to entity keys, reduced to a declared grain, with aggregated measures. Provisa writes an aggregate-MV query (GROUP BY grain + FK columns) and registers a relationship for each declared dimension link. A fact with no measures is a pure key-set — the Data Vault link pattern. [tool-verified: Fact dataclass, modeling.py lines 91-102; fact_registration function, modeling.py lines 123-141; comment at line 130 "a measureless fact is a pure key-set (DV link)"]

Both constructs are IR. The generated definitions retarget across engines — materialized in Oracle, Databricks, or left virtual over an MPP engine — without remodeling. [tool-verified: modeling.py docstring lines 25-28]

History modes

Three modes are available on an entity [tool-verified: _HISTORY constant at modeling.py line 38, _HISTORY_MODE dict at modeling.py line 40]:

Mode Meaning Bitemporal mode
none Current-only. No history.
scd2 Track every change. Append only changed rows (delta) keyed on the entity key. delta
snapshot Track every refresh. Append the full result set each refresh, stamped with system time. snapshot

scd2 needs an entity key to compute the delta. snapshot works on any engine but storage grows by a full copy per refresh. Pick scd2 for large, slowly-changing sources; pick snapshot when you need full history and the source can't supply a key.

Facts have no history mode — temporal coverage comes from the underlying entity history.

Measures and aggregations

Measures are declared as column:agg pairs. Supported aggregations [tool-verified: _AGGS at modeling.py line 41]:

sum  avg  min  max  count

The default aggregation is sum [tool-verified: Measure.agg default at modeling.py line 75].

Worked example: Customer entity + Sales fact

The source tables

  • raw.customers — id, name, region, tier
  • raw.orders — order_id, customer_id, amount, quantity

Register the Customer entity

  1. Open Tables and click + Model.
  2. Choose Entity (dimension).
  3. Fill the form:
  4. Name: Customer
  5. Source relation: raw.customers
  6. Domain: (your domain)
  7. Entity key: id
  8. Attributes: name, region, tier
  9. History: SCD2 (track changes — delta bitemporal)
  10. Click Create.
mutation {
  registerEntity(input: {
    name: "Customer"
    source: "raw.customers"
    domainId: "sales"
    key: ["id"]
    attributes: ["name", "region", "tier"]
    history: "scd2"
  }) {
    success
    message
  }
}

Provisa generates and registers this bitemporal MV [tool-verified: entity_registration in modeling.py lines 105-120]:

SELECT "id", "name", "region", "tier" FROM "raw"."customers"
-- registered as a bitemporal delta MV, entity key: ["id"]

Register the Sales fact

  1. Click + Model again.
  2. Choose Fact.
  3. Fill the form:
  4. Name: Sales
  5. Source relation: raw.orders
  6. Domain: (your domain)
  7. Grain: order_id
  8. Measures: amount:sum, quantity:sum
  9. Dimensions: Customer:customer_id
  10. Click Create.
mutation {
  registerFact(input: {
    name: "Sales"
    source: "raw.orders"
    domainId: "sales"
    grain: ["order_id"]
    measures: [
      { column: "amount", agg: "sum" }
      { column: "quantity", agg: "sum" }
    ]
    dimensions: [
      { entity: "Customer", via: "customer_id" }
    ]
  }) {
    success
    message
  }
}

Provisa generates and registers [tool-verified: fact_registration in modeling.py lines 123-141]:

SELECT "order_id", "customer_id",
       SUM("amount") AS "amount",
       SUM("quantity") AS "quantity"
FROM   "raw"."orders"
GROUP BY "order_id", "customer_id"

Plus one registered relationship: Sales.customer_id → Customer (cardinality: many-to-one). [tool-verified: fact_table_input in modeling_register.py lines 89-98, cardinality at line 95]

The Model form (admin UI)

The + Model button appears on the Tables page (tooltip: "Model an entity or fact (star schema / Data Vault)"). [tool-verified: tablesPage.json line 13; TablesPage.tsx lines 441-450]

A segmented control at the top of the modal switches between Entity (dimension) and Fact. [tool-verified: ModelingForm.tsx lines 102-110]

Entity fields

[tool-verified: ModelingForm.tsx lines 141-171; modelingForm.json]

Field Required Notes
Name yes The MV name in the catalog
Source relation yes Dotted relation, e.g. raw.customers
Domain yes Domain the MV belongs to
Entity key yes Comma-separated key column(s), e.g. id
Attributes no Comma-separated attribute columns, e.g. name, region, tier
History no none / scd2 / snapshot; default is none

Fact fields

[tool-verified: ModelingForm.tsx lines 172-196; modelingForm.json]

Field Required Notes
Name yes The MV name in the catalog
Source relation yes Dotted relation, e.g. raw.orders
Domain yes Domain the MV belongs to
Grain yes Comma-separated grain column(s), e.g. order_id
Measures no col:agg pairs, comma-separated, e.g. amount:sum, quantity:sum
Dimensions no Entity:fk_column pairs, comma-separated, e.g. Customer:customer_id

When agg is omitted in a measure (amount instead of amount:sum), it defaults to sum. [tool-verified: ModelingForm.tsx line 73 agg: agg || "sum"]

The GraphQL API

Both mutations live in the admin schema. [tool-verified: schema_mutation.py lines 449-472]

registerEntity

mutation RegisterEntity($input: EntityInput!) {
  registerEntity(input: $input) {
    success
    message
  }
}

EntityInput fields [tool-verified: types.py lines 449-456]:

Field Type Default Description
name String Catalog name for the entity MV
source String Source relation (schema.table or quoted)
domainId String Domain id
key [String] Entity key column(s)
attributes [String] [] Attribute columns to project
history String "none" "none" | "scd2" | "snapshot"
visibleTo [String] ["public"] Role visibility list

registerFact

mutation RegisterFact($input: FactInput!) {
  registerFact(input: $input) {
    success
    message
  }
}

FactInput fields [tool-verified: types.py lines 472-479]:

Field Type Default Description
name String Catalog name for the fact MV
source String Source relation
domainId String Domain id
grain [String] Grain column(s) for the GROUP BY
measures [MeasureInput] [] { column, agg } pairs
dimensions [DimRefInput] [] { entity, via } pairs
visibleTo [String] ["public"] Role visibility list

MeasureInput: { column: String, agg: String } — agg defaults to "sum". [tool-verified: types.py lines 460-462]

DimRefInput: { entity: String, via: String }entity is the referenced entity name; via is the FK column on the fact source. [tool-verified: types.py lines 465-468]

On success, registerFact returns a message of the form: Fact 'Sales' registered with 1 dimension link(s). [tool-verified: schema_mutation.py line 471]

Kimball star schema and Data Vault

Neither pattern requires separate tooling. The same two primitives compose into both.

Kimball star schema

  • A dimension is an entity with the attributes you want to conformed-select. Choose none for SCD Type 1 (current only, rebuilt on refresh). Choose scd2 for SCD Type 2 (row-versioned, full history in the bitemporal MV). [tool-verified: modeling.py docstring line 18-20]
  • A fact table is a fact. Declare the grain, the additive measures, and one dimension reference per FK. Provisa writes the GROUP BY and registers the join paths. [tool-verified: modeling.py docstring lines 21-23]

Data Vault

  • A hub is an entity with history: "none" — the deduplicated business-key set.
  • A satellite is an entity with history: "scd2" or history: "snapshot" — the time-stamped attribute record beside the hub key. [tool-verified: modeling.py docstring line 19]
  • A link is a fact with no measures — a pure key-set joining two or more entities. Add measures to turn it into a Point-in-Time or bridge aggregate. [tool-verified: modeling.py line 130 comment]

The model does not decide the methodology. Grain, conformance, SCD choice, and the hub/satellite split remain the modeler's decisions. Provisa executes them. [tool-verified: modeling.py docstring lines 25-26]

The IR payoff

Every registration call goes through the same path as a hand-written MV. The entity/fact spec is an intermediate representation — not a template, not a macro. The warehouse it targets is a property of the deployment, not of the model. Change the target engine and the same entity / fact declarations materialize there, because the generated SQL and bitemporal modes are engine-neutral by construction. [tool-verified: modeling.py docstring lines 25-28; modeling_register.py lines 56-66, 80-88]