Modélisation des données (Entities et Facts)¶
Provisa vous donne deux primitives déclaratives, entity et fact, qui couvrent les briques dont tout schéma en étoile et tout Data Vault sont faits. Déclarez la spécification, et Provisa la traduit exactement en définitions de vues matérialisées, bitemporelles et de relations que vous écririez sinon à la main.
Ce que sont les entities et les facts¶
Une entity est une projection à clé, dédupliquée et éventuellement historisée d'une relation
source. Vous lui donnez un nom, la pointez vers une source, déclarez la clé de l'entity et les
attributs à conserver, puis choisissez un mode d'historique. Provisa écrit le SQL de la vue et
enregistre la MV. Lorsque l'historique est activé, la MV est bitemporelle.
Elle déclare alors la clé de l'entity comme business_key avec les colonnes de temps de validité et de temps d'enregistrement que vous indiquez.
Un fait est une jointure vers des clés d'entité, réduite à un grain déclaré, avec des mesures agrégées. Provisa écrit une requête de MV d'agrégat (GROUP BY grain + colonnes FK) et enregistre une relation pour chaque lien de dimension déclaré. Un fait sans mesure est un pur ensemble de clés, le motif link de Data Vault.
Les deux constructions sont de l'IR. Les définitions générées passent d'un moteur à l'autre sans remodélisation : matérialisées dans Oracle ou Databricks, ou laissées virtuelles sur un moteur MPP.
Modes d'historique¶
Trois modes sont disponibles sur une entity :
| Mode | Signification |
|---|---|
none |
État courant uniquement. Pas d'historique. |
scd2 |
Suit chaque changement, avec pour clé la clé de l'entity. |
snapshot |
Suit chaque rafraîchissement, avec pour clé la clé de l'entity. |
scd2 et snapshot conservent tous deux chaque version, et Provisa choisit comment les stocker. Chacun demande une colonne de temps de validité et une colonne de temps d'enregistrement (validTime, recordedTime) : la date que votre activité donne à l'enregistrement, et le moment où cette version a été saisie ou modifiée. Vues avec historique explique les deux temps.
Les faits n'ont pas de mode d'historique propre ; leur couverture dans le temps vient de l'historique des entités sous-jacentes.
Mesures et agrégations¶
Les mesures sont déclarées sous forme de paires column:agg. Agrégations prises en charge
:
sum avg min max count
L'agrégation par défaut est sum.
Exemple pratique : entity Customer + fact Sales¶
Les tables source¶
raw.customers— id, name, region, tier, effective_date, updated_atraw.orders— order_id, customer_id, amount, quantity
Enregistrer l'entity Customer¶
- Ouvrez Tables et cliquez sur + Model.
- Choisissez Entity (dimension).
- Remplissez le formulaire :
- Name :
Customer - Source relation :
raw.customers - Domain : (votre domaine)
- Business key :
id - Attributes :
name, region, tier - Keep history :
Bitemporal - Valid time :
effective_date - Recorded time :
updated_at - Cliquez sur Create.
Provisa génère et enregistre cette MV bitemporelle :
SELECT "id", "name", "region", "tier", "effective_date", "updated_at" FROM "raw"."customers"
-- registered as a bitemporal MV: business_key ["id"], valid_time effective_date,
-- recorded_time updated_at
Enregistrer le fact Sales¶
- Cliquez à nouveau sur + Model.
- Choisissez Fact.
- Remplissez le formulaire :
- Name :
Sales - Source relation :
raw.orders - Domain : (votre domaine)
- Grain :
order_id - Measures :
amount:sum, quantity:sum - Dimensions :
Customer:customer_id - Cliquez sur Create.
Provisa génère et enregistre :
SELECT "order_id", "customer_id",
SUM("amount") AS "amount",
SUM("quantity") AS "quantity"
FROM "raw"."orders"
GROUP BY "order_id", "customer_id"
Plus une relation enregistrée : Sales.customer_id → Customer (cardinalité : many-to-one).
Le formulaire Model (interface d'administration)¶
Le bouton + Model apparaît sur la page Tables (infobulle : "Model an entity or fact (star schema / Data Vault)").
Un contrôle segmenté en haut de la fenêtre modale bascule entre Entity (dimension) et Fact.
Champs Entity¶
| Champ | Obligatoire | Remarques |
|---|---|---|
| Name | oui | Le nom de la MV dans le catalogue |
| Source relation | oui | Relation en notation pointée, p. ex. raw.customers |
| Domain | oui | Domaine auquel appartient la MV |
| Entity key | oui | Colonne(s) clé séparée(s) par des virgules, p. ex. id |
| Attributes | non | Colonnes d'attributs séparées par des virgules, p. ex. name, region, tier |
| History | non | none / scd2 / snapshot ; la valeur par défaut est none |
| Valid time | avec historique | La colonne contenant la date que votre activité donne à l'enregistrement |
| Recorded time | avec historique | La colonne contenant le moment où cette version a été saisie ou modifiée |
Champs Fact¶
| Champ | Obligatoire | Remarques |
|---|---|---|
| Name | oui | Le nom de la MV dans le catalogue |
| Source relation | oui | Relation en notation pointée, p. ex. raw.orders |
| Domain | oui | Domaine auquel appartient la MV |
| Grain | oui | Colonne(s) de grain séparée(s) par des virgules, p. ex. order_id |
| Measures | non | Paires col:agg séparées par des virgules, p. ex. amount:sum, quantity:sum |
| Dimensions | non | Paires Entity:fk_column séparées par des virgules, p. ex. Customer:customer_id |
Lorsque agg est omis dans une mesure (amount au lieu de amount:sum), la valeur par défaut
est sum.
L'API GraphQL¶
Les deux mutations résident dans le schéma d'administration.
registerEntity¶
Champs de EntityInput :
| Champ | Type | Par défaut | Description |
|---|---|---|---|
name |
String | — | Nom de catalogue pour la MV de l'entity |
source |
String | — | Relation source (schema.table ou entre guillemets) |
domainId |
String | — | Id du domaine |
key |
[String] | — | Colonne(s) clé de l'entity |
attributes |
[String] | [] |
Colonnes d'attributs à projeter |
history |
String | "none" |
"none" | "scd2" | "snapshot" |
validTime |
String | null |
Colonne du temps de validité ; obligatoire quand history ne vaut pas "none" |
recordedTime |
String | null |
Colonne du temps d'enregistrement ; obligatoire quand history ne vaut pas "none" |
visibleTo |
[String] | ["public"] |
Liste de visibilité par rôle |
description |
String | null |
Description de la table enregistrée |
registerFact¶
Champs de FactInput :
| Champ | Type | Par défaut | Description |
|---|---|---|---|
name |
String | — | Nom de catalogue pour la MV du fact |
source |
String | — | Relation source |
domainId |
String | — | Id du domaine |
grain |
[String] | — | Colonne(s) de grain pour le GROUP BY |
measures |
[MeasureInput] | [] |
Paires { column, agg } |
dimensions |
[DimRefInput] | [] |
Paires { entity, via } |
visibleTo |
[String] | ["public"] |
Liste de visibilité par rôle |
description |
String | null |
Description de la table enregistrée |
MeasureInput : { column: String, agg: String } ; agg vaut "sum" par défaut.
DimRefInput : { entity: String, via: String } ; entity est le nom de l'entity référencée ; via est la colonne FK sur la source du fact.
En cas de succès, registerFact renvoie un message de la forme :
Fact 'Sales' registered with 1 dimension link(s).
Star schema de Kimball et Data Vault¶
Aucun des deux modèles ne nécessite d'outillage distinct. Les deux mêmes primitives se composent pour former l'un comme l'autre.
Star schema de Kimball¶
Ce parcours construit une étoile à trois dimensions. Deux tables source sont nouvelles :
raw.products—product_id,name,category,list_priceraw.date_spine—date_key,year,quarter,month
raw.orders gagne ici aussi les colonnes product_id et order_date.
Choisir le type de SCD¶
Le mode d'historique est le seul paramètre qui distingue SCD Type 1 de Type 2 :
| Type SCD | Mode d'historique | Effet |
|---|---|---|
| Type 1 (état courant uniquement) | none |
La MV est reconstruite à chaque rafraîchissement ; aucun historique de lignes |
| Type 2 (versionné) | scd2 |
MV bitemporelle ; chaque changement ajoute une nouvelle version, avec pour clé la clé de l'entity |
Utilisez scd2 quand les requêtes en aval doivent joindre une dimension telle qu'elle était au moment de la transaction, comme le niveau d'un client au moment de l'achat plutôt que son niveau actuel. Utilisez none pour des tables de référence stables. Un axe de dates ne change jamais, et un catalogue de produits dont seul le prix actuel compte peut être reconstruit à chaque rafraîchissement.
Décision du grain¶
Le grain est le niveau de détail le plus fin auquel répond le fait. order_id donne une ligne par commande et garde la possibilité de compter les commandes distinctes et de joindre n'importe quelle dimension au niveau de la commande. Un grain plus grossier, par exemple ["customer_id", "order_date"], pré-agrège entre commandes et perd ce détail définitivement. Déclarez le grain le plus fin dont le métier a besoin ; les agrégats plus grossiers se dérivent facilement ensuite.
Enregistrer les dimensions¶
Customer (SCD Type 2 — les changements de tier doivent être conservés) :
mutation {
registerEntity(input: {
name: "Customer"
source: "raw.customers"
domainId: "sales"
key: ["id"]
attributes: ["name", "region", "tier"]
history: "scd2"
validTime: "effective_date"
recordedTime: "updated_at"
}) { success message }
}
Génère une MV bitemporelle avec pour clé id :
SELECT "id", "name", "region", "tier", "effective_date", "updated_at" FROM "raw"."customers"
-- bitemporal MV: business_key ["id"], valid_time effective_date, recorded_time updated_at
Product (SCD Type 1 — catalogue courant, aucun historique de versions nécessaire) :
mutation {
registerEntity(input: {
name: "Product"
source: "raw.products"
domainId: "sales"
key: ["product_id"]
attributes: ["name", "category", "list_price"]
history: "none"
}) { success message }
}
Génère une MV ordinaire reconstruite à chaque rafraîchissement :
DateDim (aucun historique — une date est immuable) :
mutation {
registerEntity(input: {
name: "DateDim"
source: "raw.date_spine"
domainId: "sales"
key: ["date_key"]
attributes: ["year", "quarter", "month"]
history: "none"
}) { success message }
}
Génère :
Enregistrer le fact Sales sur trois dimensions¶
Grain : order_id. Trois références de dimension, avec une colonne FK chacune. Les deux mesures sont des sommes additives.
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" }
{ entity: "Product", via: "product_id" }
{ entity: "DateDim", via: "order_date" }
]
}) { success message }
}
Provisa calcule group_cols = dedup([grain] + [dim FKs])
= ["order_id", "customer_id", "product_id", "order_date"] et génère
:
SELECT "order_id", "customer_id", "product_id", "order_date",
SUM("amount") AS "amount",
SUM("quantity") AS "quantity"
FROM "raw"."orders"
GROUP BY "order_id", "customer_id", "product_id", "order_date"
Trois relations sont enregistrées automatiquement :
| Relation | Cardinalité |
|---|---|
Sales.customer_id → Customer |
many-to-one |
Sales.product_id → Product |
many-to-one |
Sales.order_date → DateDim |
many-to-one |
Dimensions conformes¶
Une dimension conforme est enregistrée une seule fois et référencée par nom depuis un nombre
quelconque de facts. Supposons que raw.returns contienne return_id, customer_id,
product_id et amount. Le fact Returns réutilise Customer et Product sans les réenregistrer :
mutation {
registerFact(input: {
name: "Returns"
source: "raw.returns"
domainId: "sales"
grain: ["return_id"]
measures: [{ column: "amount", agg: "sum" }]
dimensions: [
{ entity: "Customer", via: "customer_id" }
{ entity: "Product", via: "product_id" }
]
}) { success message }
}
Sales et Returns pointent tous deux vers les mêmes entities Customer et Product. Les
chemins de jointure de Provisa garantissent que les requêtes passant par l'un ou l'autre fact
traversent la même définition de dimension.
Data Vault¶
Les mêmes primitives se mappent directement sur le vocabulaire Data Vault :
| Artefact DV | Primitive | Historique |
|---|---|---|
| Hub | entity |
none — clés d'entity uniquement |
| Satellite | entity |
scd2 ou snapshot — historique des attributs aux côtés de la clé du hub |
| Link | fact sans mesure |
— |
| Bridge / link agrégé | fact avec mesures |
— |
L'exemple construit un vault minimal sur raw.customers et raw.orders.
Hubs¶
Un hub ne contient que la clé de l'entity, rien d'autre. attributes: [] avec
history: "none" produit un ensemble de clés courantes dédupliqué ; l'historique des attributs
vit entièrement dans le satellite.
mutation {
registerEntity(input: {
name: "CustomerHub"
source: "raw.customers"
domainId: "vault"
key: ["id"]
attributes: []
history: "none"
}) { success message }
}
Génère :
mutation {
registerEntity(input: {
name: "OrderHub"
source: "raw.orders"
domainId: "vault"
key: ["order_id"]
attributes: []
history: "none"
}) { success message }
}
Génère :
Satellite¶
Le satellite se place aux côtés de la clé du hub et porte l'historique complet des attributs. Utilisez scd2 pour suivre chaque changement, ou snapshot pour suivre chaque rafraîchissement.
mutation {
registerEntity(input: {
name: "CustomerSat"
source: "raw.customers"
domainId: "vault"
key: ["id"]
attributes: ["name", "region", "tier"]
history: "scd2"
validTime: "effective_date"
recordedTime: "updated_at"
}) { success message }
}
Génère :
SELECT "id", "name", "region", "tier", "effective_date", "updated_at" FROM "raw"."customers"
-- bitemporal MV: business_key ["id"], valid_time effective_date, recorded_time updated_at
CustomerSat et CustomerHub ont tous deux pour clé id. Le hub est la cible de jointure
stable ; le satellite fournit un accès aux attributs à un instant donné via la couche
bitemporelle.
Link (fact sans mesure)¶
Un link enregistre quelles clés de hub sont apparues ensemble. Il ne contient que des clés, sans mesure, et Provisa omet le GROUP BY quand measures est vide.
mutation {
registerFact(input: {
name: "OrderCustomerLink"
source: "raw.orders"
domainId: "vault"
grain: ["order_id"]
measures: []
dimensions: [
{ entity: "CustomerHub", via: "customer_id" }
{ entity: "OrderHub", via: "order_id" }
]
}) { success message }
}
group_cols = dedup(["order_id"] + ["customer_id", "order_id"]) = ["order_id", "customer_id"].
Aucune mesure, donc pas de GROUP BY. Génère :
Deux relations sont enregistrées : OrderCustomerLink.customer_id → CustomerHub et
OrderCustomerLink.order_id → OrderHub, toutes deux many-to-one
.
Bridge / link agrégé¶
Ajoutez des mesures au link et Provisa émet le GROUP BY, produisant un bridge préagrégé. Au
grain order_id, avec un client par commande, le résultat est une ligne agrégée par commande :
mutation {
registerFact(input: {
name: "OrderSummary"
source: "raw.orders"
domainId: "vault"
grain: ["order_id"]
measures: [{ column: "amount", agg: "sum" }]
dimensions: [
{ entity: "CustomerHub", via: "customer_id" }
{ entity: "OrderHub", via: "order_id" }
]
}) { success message }
}
group_cols = dedup(["order_id"] + ["customer_id", "order_id"]) = ["order_id", "customer_id"]
(le order_id dupliqué de la liste de dimensions est supprimé par _dedup).
Génère :
SELECT "order_id", "customer_id", SUM("amount") AS "amount"
FROM "raw"."orders"
GROUP BY "order_id", "customer_id"
Le modèle ne décide pas de la méthodologie. Le grain, la conformité, le choix du SCD et la répartition hub/satellite restent des décisions du modélisateur. Provisa les exécute.
Métriques¶
Une métrique est une définition d'agrégat nommée et gouvernée, sans grain propre. Le grain, c'est-à-dire les dimensions selon lesquelles l'agrégat est ventilé, est fixé par l'appelant au moment de la requête et non à la définition. C'est ce qui distingue une métrique d'une vue : une vue fige le grain à sa création, une métrique reste ouverte jusqu'à ce qu'on l'interroge.
L'objet Metric¶
| Champ | Obligatoire | Remarques |
|---|---|---|
name |
oui | snake_case, p. ex. net_revenue. Validé : [a-z][a-z0-9_]* |
expression |
oui | ANSI-SQL agrégatif ; doit inclure au moins une fonction d'agrégation |
datatype |
non | Indication du type de résultat, p. ex. number, integer |
description |
non | Définition métier lisible par un humain |
ai_context |
non | Texte destiné aux consommateurs IA — projeté vers les outils MCP, pg_description, la documentation GraphQL et l'export Ossie |
visible_to |
non | Liste de rôles ; la valeur par défaut est ["*"] (tous les rôles) |
from_fact |
— | Défini automatiquement lorsque la métrique a été générée à partir d'une mesure de fact |
Les références de colonnes dans l'expression doivent être qualifiées par leur table (orders.amount, pas seulement amount). Une colonne non qualifiée est une erreur bloquante à l'expansion de la métrique, et rien ne la laisse passer avec un simple avertissement.
Le référentiel de métriques valide l'expression à chaque écriture. Une expression qui ne s'analyse pas correctement ou qui ne contient aucune fonction d'agrégation est rejetée ; elle n'est jamais stockée.
Exemple d'entrée de configuration :
metrics:
- name: net_revenue
expression: "SUM(orders.amount) - SUM(orders.refunds)"
datatype: number
description: "Order revenue after refunds"
ai_context: "Net revenue: total order amounts minus approved refunds. Use for P&L."
Interroger une métrique¶
Le compilateur réserve le schéma metrics, et chaque métrique y est adressable comme relation virtuelle. Interrogez-la comme une table : les colonnes sélectionnées deviennent l'ensemble de dimensions et le GROUP BY :
-- Scalar total (no dimension)
SELECT value FROM metrics.net_revenue;
-- Broken out by region and month
SELECT region, month, value FROM metrics.net_revenue GROUP BY region, month;
Le compilateur réécrit ceci en un véritable agrégat groupé sur les tables sémantiques sous-jacentes avant l'exécution de la gouvernance, de sorte que la RLS et le masquage s'appliquent aux colonnes réelles.
SELECT * sur une relation de métrique est refusé. Nommez explicitement les colonnes de dimension et value.
Lorsque l'expression d'une métrique s'étend sur plusieurs tables, le compilateur les joint via les relations enregistrées. Une dimension qui est une colonne d'une table directement référencée se résout vers cette table. Une dimension à un saut de relation est jointe automatiquement. Deux sauts, ou une dimension ambiguë, constituent une erreur bloquante qui nomme le responsable.
Métriques issues des spécifications de fact¶
Lors de l'enregistrement d'un fact, chaque mesure déclarée enregistre automatiquement un objet
Metric correspondant. Le champ from_fact de la métrique enregistre le nom de la table de
fact source, et les dimensions de regroupement valides sont les attributs d'entity accessibles
via les relations FK du fact.
Les métriques enregistrées automatiquement apparaissent sur la page Metrics avec un badge fact. Elles peuvent être modifiées comme n'importe quelle autre métrique.
Vues composées de métriques (view_metrics)¶
Une vue view_metrics fixe le grain d'une métrique au moment de la définition. Déclarez les
noms des métriques, les colonnes de dimension et les filtres optionnels ; le compilateur
génère le SELECT.
tables:
- source_id: pg1
domain_id: sales
schema: public
table: monthly_revenue
view_metrics:
metrics: [net_revenue]
dimensions: [region, month]
filters: ["orders.status = 'completed'"]
Le compilateur génère (pour cet exemple) :
SELECT orders.region AS region, orders.month AS month,
SUM(orders.amount) - SUM(orders.refunds) AS net_revenue
FROM orders
WHERE orders.status = 'completed'
GROUP BY orders.region, orders.month
view_metrics et view_sql s'excluent mutuellement sur une même table.
Régénération automatique lors du changement d'une métrique. Lorsque l'expression d'une
métrique est mise à jour, chaque vue view_metrics qui la référence est recompilée et le
nouveau SQL est persisté immédiatement. La vue ne peut pas dériver de la définition de la
métrique, par construction.
Appels metric() en ligne dans du SQL de vue écrit à la main. Un view_sql écrit à la
main peut aussi référencer des métriques via metric('name'). Le compilateur remplace chaque
appel par l'expression de la métrique et enregistre un lien de traçabilité. Cela donne aux
vues écrites à la main la même propriété de recompilation automatique lorsqu'elles référencent
une métrique plutôt que d'en copier la formule.
Remarque : les vues du fichier de configuration qui utilisent des appels metric() en ligne se régénèrent au rechargement de la configuration, et un upsert de la métrique ne les régénère pas.
La page d'administration Metrics¶
Ouvrez l'élément de navigation Metrics pour gérer les métriques gouvernées. Cliquez sur une ligne pour développer un panneau de détail en lecture seule ; cliquez sur Edit à l'intérieur pour passer en édition en ligne (sans fenêtre modale). New Metric ouvre une carte de création en ligne au-dessus du tableau. La confirmation de suppression est la seule fenêtre modale de la page.
Le formulaire de création/modification propose un constructeur à trois sélecteurs pour les
métriques issues de facts : choisissez la table de fact source (filtrée sur
modelingRole=fact), une colonne de mesure et une fonction d'agrégation (SUM, AVG,
COUNT, MIN, MAX). Le datatype est dérivé automatiquement : COUNT → bigint,
AVG → numeric, SUM/MIN/MAX → the measure column's type (le type de la colonne de mesure). La zone de texte
d'expression reste l'échappatoire pour les expressions arbitraires.
L'apport de l'IR¶
Chaque appel d'enregistrement suit le même chemin qu'une MV écrite à la main. La spécification entity/fact est une représentation intermédiaire ; ce n'est ni un modèle ni une macro. L'entrepôt cible est une propriété du déploiement, et le modèle ne le nomme pas. Changez le moteur cible, et les mêmes déclarations entity / fact se matérialisent là-bas, car le SQL généré et l'historique bitemporel est neutre vis-à-vis du moteur par conception.