Aller au contenu

Avancé : cubes virtuels, parent-enfant, membres calculés

Cette page couvre cinq patterns de modélisation avancés qui vont au-delà du cube en étoile standard : combiner plusieurs tables de faits dans un seul cube, les hiérarchies parent-enfant, les membres calculés définis dans le schema, les ensembles nommés réutilisables, et les dimensions pont (plusieurs-à-plusieurs).

Cubes multi-faits (la réponse Mondrian 4 aux « cubes virtuels »)

Mondrian 3 livrait un élément <VirtualCube> qui assemblait deux ou plusieurs cubes réguliers. Mondrian 4 n’a pas d’élément <VirtualCube>. La fonctionnalité a été absorbée dans le modèle <Cube> régulier : un seul cube peut contenir plusieurs éléments <MeasureGroup>, chacun pointant sur une table de faits différente. Cela réalise tout ce qu’un cube virtuel faisait, avec moins de cérémonie et une meilleure planification de requête.

Vous utilisez un cube multi-groupes-de-mesures lorsque vous avez :

  • Des tables de faits à différentes granularités — par exemple une au niveau du jour et une autre au niveau du mois.
  • Des tables de faits avec différentes dimensionnalités — par exemple une couvrant Product, Time et Customer, une autre couvrant Product, Time et Warehouse.
  • Une table d’agrégat que vous voulez enregistrer à côté de la table de faits de base (un pattern courant — les groupes de mesures d’agrégat utilisent type="aggregate").

Exemple : Sales et Warehouse dans un seul cube

Warehouse and Sales:
dimensions:
- source: "Time"
- source: "Product"
- source: "Store"
- source: "Customer"
- source: "Warehouse"
measure_groups:
- name: "Sales"
table: "sales_fact_1997"
measures:
- name: "Unit Sales"
column: "unit_sales"
aggregator: "sum"
format_string: "Standard"
- name: "Store Sales"
column: "store_sales"
aggregator: "sum"
format_string: "#,###.00"
- name: "Store Cost"
column: "store_cost"
aggregator: "sum"
format_string: "#,###.00"
dimension_links:
- type: "foreign_key"
dimension: "Time"
foreign_key_column: "time_id"
- type: "foreign_key"
dimension: "Product"
foreign_key_column: "product_id"
- type: "foreign_key"
dimension: "Store"
foreign_key_column: "store_id"
- type: "foreign_key"
dimension: "Customer"
foreign_key_column: "customer_id"
- type: "no_link"
dimension: "Warehouse"
- name: "Warehouse"
table: "inventory_fact_1997"
measures:
- name: "Units Ordered"
column: "units_ordered"
aggregator: "sum"
- name: "Warehouse Sales"
column: "warehouse_sales"
aggregator: "sum"
- name: "Warehouse Cost"
column: "warehouse_cost"
aggregator: "sum"
dimension_links:
- type: "foreign_key"
dimension: "Time"
foreign_key_column: "time_id"
- type: "foreign_key"
dimension: "Product"
foreign_key_column: "product_id"
- type: "foreign_key"
dimension: "Store"
foreign_key_column: "store_id"
- type: "foreign_key"
dimension: "Warehouse"
foreign_key_column: "warehouse_id"
- type: "no_link"
dimension: "Customer"
calculated_members:
- name: "Profit Per Unit Shipped"
dimension: "Measures"
formula: "([Measures].[Store Sales] - [Measures].[Store Cost]) / [Measures].[Units\
\ Ordered]"

Comment Mondrian gère les dimensions non conformes

Les dimensions partagées par les deux groupes de mesures — Time et Product dans l’exemple ci-dessus — sont des dimensions conformes. Mondrian synchronise automatiquement le contexte entre les groupes de mesures pour celles-ci. Si le contexte actuel est [Time].[1997].[Q2] et [Product].[Beer], les mesures des deux groupes se résolvent correctement.

Les dimensions qui n’appartiennent qu’à un seul groupe de mesures sont non conformes. Lorsque le contexte actuel inclut [Customer].[Jane Smith], une mesure du groupe Warehouse (qui a <NoLink dimension="Customer"/>) renvoie NULL plutôt qu’une agrégation incorrecte. C’est le comportement correct et attendu.

Groupes de mesures d’agrégat

Lorsque vous enregistrez une table d’agrégat à côté de sa table de faits de base, le groupe de mesures d’agrégat utilise type="aggregate" et <CopyLink> au lieu de <ForeignKeyLink> pour les dimensions rolled-up. Voir la section Schema physique — CopyLink pour les détails complets.

Hiérarchies parent-enfant

Une hiérarchie conventionnelle a un nombre fixe de niveaux et chaque membre à un niveau donné a son parent au niveau au-dessus. Une hiérarchie parent-enfant n’a qu’un seul vrai niveau (plus le membre All facultatif), mais les membres peuvent être parents d’autres membres au sein du même niveau. C’est le bon modèle pour les organigrammes, les arbres de produits, les comptes du grand livre, les régions géographiques à profondeur variable, et les structures récursives similaires.

Définir une hiérarchie parent-enfant dans Mondrian 4

Dans Mondrian 4, vous exprimez la structure parent-enfant sur un <Attribute> à l’intérieur d’une <Dimension>. Ajoutez l’attribut parentAttribute pointant vers l’attribut qui contient la clé parent de chaque membre. Dans la dimension FoodMart Employees, la colonne auto-référente est supervisor_id :

<Dimension name="Employee" table="employee" key="Employee Id">
<Attributes>
<Attribute name="Employee Id" keyColumn="employee_id" nameColumn="full_name"/>
<Attribute name="Manager Id" keyColumn="supervisor_id"/>
<!-- additional descriptive attributes -->
<Attribute name="Position Title" keyColumn="position_title" hasHierarchy="false"/>
<Attribute name="Gender" keyColumn="gender" hasHierarchy="false"/>
<Attribute name="Education Level" keyColumn="education_level" hasHierarchy="false"/>
</Attributes>
<Hierarchies>
<Hierarchy name="Employees" allMemberName="All Employees">
<Level attribute="Employee Id" parentAttribute="Manager Id" nullParentValue="0"/>
</Hierarchy>
</Hierarchies>
</Dimension>

Attributs clés sur <Level> :

  • parentAttribute — le nom de l’attribut qui fournit la clé parent de chaque membre. Cet attribut unique est le signal pour Mondrian que la hiérarchie est parent-enfant.
  • nullParentValue — la valeur qui indique « pas de parent » (c.-à-d. un membre racine). Par défaut null, mais de nombreux schemas utilisent 0 ou -1 à la place car certaines bases de données n’indexent pas les valeurs null.

Optimiser les hiérarchies parent-enfant

L’implémentation naïve du roll-up parent-enfant est coûteuse : Mondrian doit émettre une instruction SQL par nœud pour sommer tous les descendants. Pour les hiérarchies peu profondes avec quelques dizaines de membres, c’est acceptable. Pour les arbres plus profonds ou plus larges — des centaines ou des milliers de membres — vous remarquerez une dégradation de performance significative. Il y a aussi une seconde contrainte : vous ne pouvez pas définir une mesure distinct-count dans un cube qui contient une hiérarchie parent-enfant non optimisée, car Mondrian ne peut pas exprimer la dé-duplication requise en SQL standard.

La solution est une table de fermeture.

Tables de fermeture

Une table de fermeture est une table SQL plate qui pré-calcule chaque paire ancêtre-descendant à chaque profondeur. Pour la table employee, elle ressemble à ceci :

supervisor_idemployee_iddistance
110
121
132
141
153
162
220
231
252
261
330
351
440
550
660

Chaque ligne dit « l’employé X est un descendant du superviseur Y à la profondeur D ». Important : chaque employé apparaît comme son propre descendant à distance 0 (la fermeture réflexive). Avec cette table disponible, Mondrian peut calculer n’importe quel agrégat de sous-arbre avec une seule jointure SQL — pas d’itération requise.

Vous déclarez la table de fermeture sur l’élément <Level> à l’aide de l’enfant <Closure> :

<Hierarchy name="Employees" allMemberName="All Employees">
<Level attribute="Employee Id" parentAttribute="Manager Id" nullParentValue="0">
<Closure table="employee_closure"
parentColumn="supervisor_id"
childColumn="employee_id"/>
</Level>
</Hierarchy>

Le schema physique a également besoin d’un <Link> pour que Mondrian puisse joindre de employee à employee_closure :

<Table name="employee" keyColumn="employee_id"/>
<Table name="employee_closure"/>
<Link source="employee_closure" target="employee" foreignKeyColumn="employee_id"/>

Pour de meilleures performances, ajoutez les index suivants :

CREATE UNIQUE INDEX employee_closure_pk
ON employee_closure (supervisor_id, employee_id);
CREATE INDEX employee_closure_emp
ON employee_closure (employee_id);

Déclarez à la fois supervisor_id et employee_id comme NOT NULL — certains optimiseurs de base de données gèrent significativement mieux les colonnes indexées non-null.

Peupler les tables de fermeture

Mondrian ne peuple pas la table de fermeture — c’est le travail de la couche ETL. La table doit être rafraîchie chaque fois que la hiérarchie change. Si vous utilisez Pentaho Data Integration (Kettle), il existe une étape Closure Generator intégrée qui gère cela automatiquement dans le cadre de votre pipeline de chargement.

Si vous n’utilisez pas Kettle, vous pouvez peupler la table avec une procédure stockée. Voici un exemple MySQL qui amorce les paires avec soi-même puis itère vers l’extérieur un niveau de profondeur à la fois :

DELIMITER //
CREATE PROCEDURE populate_employee_closure()
BEGIN
DECLARE distance INT;
TRUNCATE TABLE employee_closure;
SET distance = 0;
-- seed with self-pairs (distance 0)
INSERT INTO employee_closure (supervisor_id, employee_id, distance)
SELECT employee_id, employee_id, distance
FROM employee;
-- for each (root, leaf) in the closure add (root, leaf->child)
REPEAT
SET distance = distance + 1;
INSERT INTO employee_closure (supervisor_id, employee_id, distance)
SELECT ec.supervisor_id, e.employee_id, distance
FROM employee_closure ec
JOIN employee e ON ec.employee_id = e.supervisor_id
WHERE ec.distance = distance - 1;
UNTIL (ROW_COUNT() = 0)
END REPEAT;
END //
DELIMITER ;

Exécutez cette procédure après chaque chargement qui modifie la table employee.

Membres calculés

Un membre calculé est un membre de cube dont la valeur provient d’une formule MDX plutôt que d’une colonne de la table de faits. Vous pouvez définir des membres calculés sur la dimension Measures (créant des mesures calculées) ou sur toute autre dimension du cube.

Plutôt que de répéter la formule dans chaque requête MDX avec une clause WITH MEMBER, vous la définissez une fois dans le schema et elle est automatiquement disponible dans toutes les requêtes contre ce cube :

calculated_members:
- name: "Profit"
dimension: "Measures"
formula: "[Measures].[Store Sales] - [Measures].[Store Cost]"
properties:
- name: "FORMAT_STRING"
value: "$#,##0.00"

Vous pouvez aussi écrire la formule comme attribut XML si vous préférez la brièveté :

calculated_members:
- name: "Profit"
dimension: "Measures"
formula: "[Measures].[Store Sales] - [Measures].[Store Cost]"
properties:
- name: "FORMAT_STRING"
value: "$#,##0.00"

Les deux formes produisent des résultats identiques — et parce que le convertisseur fond l’enfant <Formula> et l’attribut formula dans la même clé YAML formula:, les deux orthographes XML donnent le YAML identique ci-dessus.

CalculatedMemberProperty

<CalculatedMemberProperty> définit les propriétés de solve-order MDX, les chaînes de format et les informations de type sur le membre calculé. Les propriétés les plus couramment utilisées sont :

PropriétéButExemple
FORMAT_STRINGContrôle comment la valeur est rendue dans les clients"$#,##0.00"
DATATYPEIndique aux clients XMLA le type de retour"Numeric", "Integer", "String"
SOLVE_ORDERPriorité lorsque plusieurs membres calculés sont en portée"2000"

FORMAT_STRING peut contenir une expression conditionnelle plutôt qu’un littéral :

calculated_members:
- name: "Conditional Profit"
dimension: "Measures"
formula: "[Measures].[Store Sales] - [Measures].[Store Cost]"
properties:
- name: "FORMAT_STRING"
expression: "Iif(Value < 0, '|($#,##0.00)|style=red', '|$#,##0.00|style=green')"

Lorsque Mondrian rend une cellule, il évalue d’abord l’expression pour obtenir une chaîne de format, puis applique cette chaîne de format à la valeur de la cellule.

Visibilité

Définissez visible="false" sur un <CalculatedMember> (ou sur une <Measure>) pour le masquer des navigateurs de membres des outils clients. C’est utile lorsque vous construisez un résultat par étapes intermédiaires qui ne devraient pas être exposées directement :

<Measure name="Store Cost" column="store_cost" aggregator="sum"
formatString="#,###.00" visible="false"/>
<CalculatedMember name="Margin" dimension="Measures" visible="false">
<Formula>([Measures].[Store Sales] - [Measures].[Store Cost]) / [Measures].[Store Cost]</Formula>
</CalculatedMember>
<CalculatedMember name="Store Sqft" dimension="Measures" visible="false">
<Formula>[Store].Properties("Sqft")</Formula>
</CalculatedMember>
<CalculatedMember name="Margin per Sqft" dimension="Measures" visible="true">
<Formula>[Measures].[Margin] / [Measures].[Store Cost]</Formula>
<CalculatedMemberProperty name="FORMAT_STRING" value="$#,##0.00"/>
</CalculatedMember>

Seul « Margin per Sqft » apparaît dans le client ; les autres sont des helpers.

Membres calculés dans les cubes multi-faits

<CalculatedMembers> se trouve au niveau <Cube> et peut référencer des mesures de l’un quelconque des groupes de mesures du cube. C’est l’emplacement naturel pour les KPI inter-tables-de-faits :

calculated_members:
- name: "Profit Growth"
dimension: "Measures"
visible: true
formula: "([Measures].[Profit] - [Measures].[Profit last Period]) / [Measures].[Profit\
\ last Period]"
properties:
- name: "FORMAT_STRING"
value: "0.0%"

Ensembles nommés

Un ensemble nommé est une expression d’ensemble MDX réutilisable définie dans le schema. C’est l’analogue dans le schema d’une clause WITH SET dans une requête MDX. Une fois défini, l’ensemble est implicitement disponible dans chaque requête contre le cube (ou schema) où il est déclaré.

Ensembles nommés au niveau du cube

Déclarez un ensemble nommé à l’intérieur d’un <Cube> pour le rendre disponible pour toutes les requêtes contre ce cube :

named_sets:
- name: "Top Sellers"
formula: "TopCount([Warehouse].[Warehouse Name].MEMBERS, 5, [Measures].[Warehouse\
\ Sales])"

Vous pouvez ensuite utiliser [Top Sellers] directement en MDX :

SELECT
{[Measures].[Warehouse Sales]} ON COLUMNS,
{[Top Sellers]} ON ROWS
FROM [Warehouse]
WHERE [Time].[Year].[1997]

Ce qui pourrait renvoyer :

WarehouseWarehouse Sales
Treehouse Distribution31 116,37
Jorge Garcia, Inc.30 743,77
Artesia Warehousing, Inc.29 207,96
Jorgensen Service Storage22 869,79
Destination, Inc.22 187,42

Ensembles nommés au niveau du schema

Vous pouvez également déclarer des ensembles nommés au niveau du schema, en dehors de tout cube. Les ensembles nommés au niveau du schema sont disponibles dans tous les cubes du schema, mais ils ne sont valides que dans les cubes qui contiennent les dimensions que la formule référence :

<Schema name="FoodMart">
<Cube name="Sales" .../>
<Cube name="Warehouse" .../>
<NamedSets>
<NamedSet name="CA Cities"
formula="{[Store].[USA].[CA].Children}"/>
<NamedSet name="Top CA Cities">
<Formula>TopCount([CA Cities], 2, [Measures].[Unit Sales])</Formula>
</NamedSet>
</NamedSets>
</Schema>

[CA Cities] est valide dans tout cube qui a une dimension [Store]. L’utiliser dans un cube sans cette dimension lève une erreur au moment de la requête, pas au moment du chargement du schema.

L’attribut formula et l’élément enfant <Formula> sont équivalents. Utilisez la forme attribut pour les expressions courtes, l’élément enfant pour la lisibilité sur les plus longues.

Dimensions pont (plusieurs-à-plusieurs)

Une dimension normale a une relation un-à-plusieurs avec le fait : chaque ligne de fait appartient exactement à un client, un produit, un jour. Une relation plusieurs-à-plusieurs (ou pont) est différente — une seule ligne de fait peut appartenir à plusieurs membres de dimension à la fois.

L’exemple classique est un compte bancaire conjoint. Un compte a un seul solde, mais il peut être co-détenu par deux clients ou plus :

ACCOUNTS (fact) OWNERSHIP (bridge)
acct year balance acct customer weight
1 2024 1000 1 Alice 0.50
2 2024 500 1 Bob 0.50
3 2025 300 2 Bob 1.00
3 Alice 0.25
total balance = 1800 3 Carol 0.75

Le compte 1 est détenu par Alice et Bob. Il n’y a pas de colonne unique customer_id que vous pouvez placer sur la table de faits, donc un simple <ForeignKeyLink> ne peut pas modéliser cela. À la place, la relation vit dans une table pont séparée (OWNERSHIP) qui mappe les comptes aux clients, éventuellement avec un poids de propriété.

Le problème de fan-out

La façon naïve de répondre à « solde par client » est de joindre fait → pont → client et SUM(balance). Mais cette jointure fait du fan-out : la seule ligne de 1000 $ du compte 1 devient deux lignes (une pour Alice, une pour Bob). Sommez naïvement à travers tous les clients et vous obtenez 3100, pas le vrai 1800 — le compte 1 compté deux fois, le compte 3 compté deux fois. Ce double-comptage est le danger central de la modélisation plusieurs-à-plusieurs.

Le Mondrian de Saiku gère le fan-out correctement avec un <BridgeLink>.

Déclarer un lien pont

Un <BridgeLink> remplace le <ForeignKeyLink> pour la dimension plusieurs-à-plusieurs à l’intérieur des <DimensionLinks> du groupe de mesures :

measure_groups:
- name: "Balances"
table: "account_fact"
measures:
- name: "Balance"
column: "balance"
aggregator: "sum"
dimension_links:
- type: "foreign_key"
dimension: "Date"
foreign_key_column: "date_key"
- type: "bridge"
dimension: "Customer"
bridge_table: "account_owner"
fact_foreign_key_column: "account_id"
bridge_fact_key_column: "account_id"
bridge_dimension_key_column: "customer_id"
AttributRequisDescription
dimensionouiLa dimension plusieurs-à-plusieurs que ce lien résout.
bridgeTableouiLa table physique contenant le mapping fait↔dimension. Doit être déclarée dans <PhysicalSchema>.
factForeignKeyColumnouiColonne sur la table de faits vers laquelle le pont rejoint (la clé de grain de fait).
bridgeFactKeyColumnouiColonne sur le pont qui correspond à factForeignKeyColumn.
bridgeDimensionKeyColumnouiColonne sur le pont qui correspond à la clé de la dimension.
aggregationnonfullCount (par défaut) ou weighted. Voir ci-dessous.
weightColumnnonColonne sur le pont contenant le poids d’allocation. Requise quand aggregation="weighted".

La dimension pont nécessite également que le grain de fait soit déclaré, afin que Saiku puisse dédupliquer le fan-out. Déclarez-le comme la <Key> de la table de faits dans le schema physique :

tables:
- name: "account_fact"
key:
- "account_id"

Allocation : full-count vs pondéré

Il y a deux façons honnêtes d’attribuer une valeur de fait partagée entre ses propriétaires.

fullCount (par défaut) crédite la totalité de la valeur à chaque propriétaire. Chaque client voit le solde complet de chaque compte sur lequel il est :

Balance by Customer (fullCount):
Alice 1300 (acct1 1000 + acct3 300)
Bob 1500 (acct1 1000 + acct2 500)
Carol 300 (acct3 300)

Les chiffres par client se chevauchent délibérément — Alice et Bob voient tous deux les 1000 $ complets du compte 1 — ce qui est ce que vous voulez pour « quel solde ce client a-t-il la signature autorisée dessus ? ». Chaque fois que les totaux full-count sont roulés vers le haut entre propriétaires — le grand total (All Customers), ou tout niveau intermédiaire (voir Dimensions pont multi-niveaux ci-dessous) — Saiku applique un agrégat symétrique : il déduplique en revenant au grain de fait avant de sommer, donc le grand total est le vrai 1800, pas le 3100 fanned-out.

weighted divise chaque valeur par la colonne de poids du pont, afin que les parties somment au tout :

dimension_links:
- type: "bridge"
dimension: "Customer"
bridge_table: "account_owner"
fact_foreign_key_column: "account_id"
bridge_fact_key_column: "account_id"
bridge_dimension_key_column: "customer_id"
aggregation: "weighted"
weight_column: "weight"
Balance by Customer (weighted):
Alice 575 (1000×0.50 + 300×0.25)
Bob 1000 (1000×0.50 + 500×1.00)
Carol 225 (300×0.75)
total 1800 (reconciles exactly)

Utilisez weighted pour « quelle est la part économique de ce client ? » et lorsque les poids somment à 1 par ligne de fait, chaque niveau — y compris le grand total — se réconcilie automatiquement au total de fait.

Dimensions pont multi-niveaux

Une dimension pont n’est pas limitée à un seul niveau — elle peut avoir une hiérarchie, et les roll-ups full-count restent corrects à chaque niveau. Disons que chaque client appartient à un segment (Alice et Bob sont Premium, Carol est Standard) et que vous roulez le pont vers le segment :

shared_dimensions:
Customer:
table: "dim_customer"
key: "Customer"
attributes:
- name: "Segment"
key_column: "segment"
- name: "Customer"
key_column: "customer_id"
name_column: "customer_name"
hierarchies:
- name: "By Segment"
all_member_name: "All Customers"
levels:
- "Segment"
- "Customer"
Balance by Segment (fullCount):
Premium 1800 (owns acct1, acct2, acct3 — de-duplicated)
Standard 300 (owns acct3)

Le compte 1 est détenu par Alice et Bob, tous deux Premium — mais il est compté une fois dans le total Premium, pas deux. C’est l’agrégat symétrique qui fait son travail à un niveau intermédiaire : sans cela, Premium afficherait le 2800 fanned-out. Le compte 3 apparaît dans les deux segments (Alice est Premium, Carol est Standard) — c’est le chevauchement full-count voulu entre segments, distinct du double-comptage au sein d’un segment que la déduplication retire.

Les roll-ups pondérés n’ont pas besoin de déduplication — la part pondérée de chaque propriétaire s’additionne proprement, donc Premium = 1575, Standard = 225, se réconciliant toujours à 1800.

Ce qui fonctionne

Une dimension pont se comporte comme n’importe quelle autre dimension une fois déclarée. Tout cela fonctionne nativement :

  • la dimension pont sur les lignes, colonnes ou dans le slicer (WHERE) ;
  • croisée avec des dimensions à clé étrangère normales (par ex. Customer × Region), sur des axes différents ou en crossjoin sur le même axe ;
  • les hiérarchies pont multi-niveaux — les totaux full-count se dédupliquent correctement à chaque niveau, pas seulement à la feuille et au grand total ;
  • plusieurs mesures dans une requête, y compris les mesures à colonne calculée (un CASE, ou de l’arithmétique comme revenue - cost), qui se dédupliquent comme des mesures simples ;
  • NON EMPTY (les clients sans comptes sont supprimés) ;
  • les ensembles de membres explicites et .Members.

Exigences et limitations

  • La table de faits doit déclarer son grain comme <Key> pour que fullCount puisse dédupliquer. (C’est ce qui rend les roll-ups multi-niveaux corrects.)
  • weighted nécessite une weightColumn ; fullCount ignore tout poids.
  • Les deux allocations couvrent les mesures à colonne réelle simples et les mesures à colonne calculée (un CASE, ou de l’arithmétique comme revenue - cost) : fullCount déduplique l’expression de calcul sur le grain de fait, et weighted l’échelonne par le poids (SUM(expression × weight)).
  • La jointure pont est mono-colonne à chaque saut. Les clés de jointure composites sont une limitation générale du chemin de jointure Calcite (pas spécifique aux ponts) — si vos clés de pont sont multi-colonnes, modélisez une seule clé de grain de substitution à la place.

Un exemple complet et chargeable — schema, données de seed, et MDX d’exemple avec les nombres attendus — est livré dans la bibliothèque de cubes sous many-to-many.

Grain distinct au niveau de la mesure (distinctKeyColumn)

Un pont résout le fan-out causé par une jointure. Mais le fan-out peut aussi venir du grain de la table de faits elle-même : une ligne de fait qui devrait être comptée une fois est physiquement stockée comme plusieurs lignes (une commande avec plusieurs lignes d’articles, un événement journalisé par contact), et un simple SUM sur la colonne double-compte. Lorsque la duplication est clavée par une colonne de la propre table de faits de la mesure, vous n’avez pas besoin d’un pont — vous pouvez épingler le grain de déduplication directement sur la mesure avec distinctKeyColumn.

ORDER_LINES (fact)
order_id line region amount
1 a North 100
1 b North 100 ← amount repeats per line of order 1
2 a South 50
3 a North 300
3 b North 300 ← amount repeats per line of order 3
SUM(amount) = 100+100+50+300+300 = 850 (WRONG — double-counts)
SUM(amount) DISTINCT over order_id = 100 + 50 + 300 = 450 (correct)

Déclarez la clé de déduplication sur la mesure. Elle doit résoudre vers une colonne sur la propre table de faits de la mesure, et n’est autorisée que pour aggregator="sum" et aggregator="avg" :

measures:
- name: "Order Amount"
column: "amount"
aggregator: "sum"
distinct_key_column: "order_id"

Sémantique

La mesure agrège sur SELECT DISTINCT (group keys, distinctKeyColumn, operand) — chaque clé distincte contribue sa valeur une seule fois, même quand le grain de fait répète la ligne. Elle réutilise la même machinerie d’agrégat symétrique que le fan-out de pont (la déduplication est pilotée par la déclaration de mesure plutôt que par la topologie de jointure) :

  • aggregator="sum"SUM sur les clés distinctes (la forme LookML sum_distinct).
  • aggregator="avg"AVG sur les clés distinctes (LookML average_distinct).

Les roll-ups restent corrects à chaque niveau : By Region sur l’exemple lit North = 400, South = 50 — les valeurs distinctes, jamais le 850 fanned-out.

Compose avec la sécurité de lignes

Le grain distinct est appliqué après le filtrage de sécurité de lignes, pas avant. <PredicateGrant> et les autorisations de membres de pont filtrent les lignes de fait à l’intérieur de la sous-requête DISTINCT, donc la déduplication opère toujours sur exactement les lignes que l’appelant a le droit de voir — il n’y a pas de chemin qui dédupliquerait une valeur que le rôle ne peut pas voir puis ferait fuiter le total. Le grain distinct est une propriété fixe du schema (non dépendante du rôle), donc il n’ajoute pas de nouvelle dimension de cache, et le cache de segments continue d’isoler les rôles : une valeur réchauffée pour les autorisations d’un rôle n’est jamais servie à un autre. (Ceci est couvert par les tests de composition de sécurité de lignes, y compris un test no-cross-role-cache-bleed.)

Exigences et limitations

  • distinctKeyColumn doit résoudre vers une vraie colonne sur la propre table de faits de la mesure. Une clé inter-tables ou non résoluble est rejetée au chargement (fail-closed) — jamais une déduplication silencieusement fausse.
  • Autorisée seulement pour aggregator="sum" et aggregator="avg".
  • Un chargement de segment qui mélange une mesure à grain distinct avec une mesure simple, ou deux mesures avec des clés distinctes différentes, est divisé en segments homogènes par mesure automatiquement (le DISTINCT à l’échelle de la requête ne peut pas servir deux grains à la fois).
  • Lorsque la clé distincte est égale à la propre clé de grain de la table de faits (par ex. sa clé primaire), la déduplication est un no-op et la mesure se comporte comme un simple sum/avg. C’est la cible naturelle pour LookML sum_distinct/average_distinct — voir Migration depuis Looker.

Adapté du guide de schema du projet Mondrian (EPL v1.0). Les dimensions pont (plusieurs-à-plusieurs) et le grain distinct au niveau de la mesure sont des extensions Saiku à Mondrian 4.