Avançado: cubos virtuais, parent-child, calculated members
Esta página cobre cinco padrões avançados de modelagem que vão além do cubo estrela padrão: combinar múltiplas tabelas de fato em um único cubo, hierarquias parent-child, calculated members definidos no schema, named sets reutilizáveis e dimensões bridge (many-to-many).
Cubos multi-fato (a resposta do Mondrian 4 para “cubos virtuais”)
O Mondrian 3 trouxe um elemento <VirtualCube> que costurava dois ou mais cubos regulares. O Mondrian 4 não tem elemento <VirtualCube>. O recurso foi absorvido no modelo regular de <Cube>: um único cubo pode conter múltiplos elementos <MeasureGroup>, cada um apontando para uma tabela de fato diferente. Isso atinge tudo o que um cubo virtual fazia, com menos cerimônia e melhor planejamento de consulta.
Você usa um cubo com múltiplos measure groups quando tem:
- Tabelas de fato em granularidades diferentes — digamos uma no nível do dia e outra no nível do mês.
- Tabelas de fato com dimensionalidades diferentes — digamos uma cobrindo Product, Time e Customer, outra cobrindo Product, Time e Warehouse.
- Uma tabela agregada que você quer registrar ao lado do fato base (um padrão comum — measure groups agregados usam
type="aggregate").
Exemplo: Sales e Warehouse em um cubo
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]"<Cube name="Warehouse and Sales">
<Dimensions> <Dimension source="Time"/> <Dimension source="Product"/> <Dimension source="Store"/> <Dimension source="Customer"/> <Dimension source="Warehouse"/> </Dimensions>
<MeasureGroups>
<!-- Fact table 1: sales transactions --> <MeasureGroup name="Sales" table="sales_fact_1997"> <Measures> <Measure name="Unit Sales" column="unit_sales" aggregator="sum" formatString="Standard"/> <Measure name="Store Sales" column="store_sales" aggregator="sum" formatString="#,###.00"/> <Measure name="Store Cost" column="store_cost" aggregator="sum" formatString="#,###.00"/> </Measures> <DimensionLinks> <ForeignKeyLink dimension="Time" foreignKeyColumn="time_id"/> <ForeignKeyLink dimension="Product" foreignKeyColumn="product_id"/> <ForeignKeyLink dimension="Store" foreignKeyColumn="store_id"/> <ForeignKeyLink dimension="Customer" foreignKeyColumn="customer_id"/> <NoLink dimension="Warehouse"/> </DimensionLinks> </MeasureGroup>
<!-- Fact table 2: warehouse stock movements --> <MeasureGroup name="Warehouse" table="inventory_fact_1997"> <Measures> <Measure name="Units Ordered" column="units_ordered" aggregator="sum"/> <Measure name="Warehouse Sales" column="warehouse_sales" aggregator="sum"/> <Measure name="Warehouse Cost" column="warehouse_cost" aggregator="sum"/> </Measures> <DimensionLinks> <ForeignKeyLink dimension="Time" foreignKeyColumn="time_id"/> <ForeignKeyLink dimension="Product" foreignKeyColumn="product_id"/> <ForeignKeyLink dimension="Store" foreignKeyColumn="store_id"/> <ForeignKeyLink dimension="Warehouse" foreignKeyColumn="warehouse_id"/> <NoLink dimension="Customer"/> </DimensionLinks> </MeasureGroup>
</MeasureGroups>
<CalculatedMembers> <CalculatedMember name="Profit Per Unit Shipped" dimension="Measures"> <Formula>([Measures].[Store Sales] - [Measures].[Store Cost]) / [Measures].[Units Ordered]</Formula> </CalculatedMember> </CalculatedMembers>
</Cube>Como o Mondrian trata dimensões não conformantes
Dimensões compartilhadas por ambos os measure groups — Time e Product no exemplo acima — são dimensões conformantes. O Mondrian sincroniza automaticamente o contexto entre measure groups para essas. Se o contexto atual é [Time].[1997].[Q2] e [Product].[Beer], medidas de ambos os grupos resolvem corretamente.
Dimensões que pertencem a apenas um measure group são não conformantes. Quando o contexto atual inclui [Customer].[Jane Smith], uma medida do grupo Warehouse (que tem <NoLink dimension="Customer"/>) retorna NULL em vez de um agregado incorreto. Este é o comportamento correto e esperado.
Measure groups agregados
Quando você registra uma tabela agregada ao lado de sua tabela de fato base, o measure group agregado usa type="aggregate" e <CopyLink> em vez de <ForeignKeyLink> para as dimensões com rollup. Veja a seção Schema físico — CopyLink para detalhes completos.
Hierarquias parent-child
Uma hierarquia convencional tem um número fixo de levels e cada membro em um dado level tem seu pai no level acima. Uma hierarquia parent-child tem só um level real (mais o opcional All), mas membros podem ter outros membros como pais dentro do mesmo level. Este é o modelo certo para organogramas, árvores de produto, contas do livro razão, regiões geográficas com profundidade variável e estruturas recursivas similares.
Definir uma hierarquia parent-child no Mondrian 4
No Mondrian 4 você expressa a estrutura parent-child em um <Attribute> dentro de uma <Dimension>. Adicione o atributo parentAttribute apontando para o atributo que segura a chave do pai de cada membro. Na dimensão FoodMart Employees, a coluna auto-referencial é 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>Atributos chave em <Level>:
parentAttribute— o nome do atributo que fornece a chave do pai de cada membro. Este único atributo é o sinal para o Mondrian de que a hierarquia é parent-child.nullParentValue— o valor que indica “sem pai” (ou seja, um membro raiz). Defaultnull, mas muitos schemas usam0ou-1porque alguns bancos não indexam valores nulos.
Otimizar hierarquias parent-child
A implementação ingênua de rollup parent-child é cara: o Mondrian precisa emitir um statement SQL por nó para somar todos os descendentes. Para hierarquias rasas com algumas dezenas de membros, isso é aceitável. Para árvores mais profundas ou largas — centenas ou milhares de membros — você notará degradação significativa de performance. Há também uma segunda restrição: você não pode definir uma medida distinct-count em nenhum cubo que contenha uma hierarquia parent-child não otimizada, porque o Mondrian não consegue expressar a deduplicação necessária em SQL padrão.
A solução é uma closure table.
Closure tables
Uma closure table é uma tabela SQL plana que pré-computa cada par ancestral-descendente em cada profundidade. Para a tabela employee ela se parece com isso:
| supervisor_id | employee_id | distance |
|---|---|---|
| 1 | 1 | 0 |
| 1 | 2 | 1 |
| 1 | 3 | 2 |
| 1 | 4 | 1 |
| 1 | 5 | 3 |
| 1 | 6 | 2 |
| 2 | 2 | 0 |
| 2 | 3 | 1 |
| 2 | 5 | 2 |
| 2 | 6 | 1 |
| 3 | 3 | 0 |
| 3 | 5 | 1 |
| 4 | 4 | 0 |
| 5 | 5 | 0 |
| 6 | 6 | 0 |
Cada linha diz “employee X é um descendente do supervisor Y na profundidade D”. Crucialmente, cada employee aparece como seu próprio descendente na distância 0 (o fechamento reflexivo). Com essa tabela disponível, o Mondrian pode computar qualquer agregado de subárvore com um único join SQL — sem iteração.
Você declara a closure table no elemento <Level> usando o filho <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>O schema físico também precisa de um <Link> para que o Mondrian possa juntar de employee a employee_closure:
<Table name="employee" keyColumn="employee_id"/><Table name="employee_closure"/><Link source="employee_closure" target="employee" foreignKeyColumn="employee_id"/>Para melhor performance, adicione os seguintes índices:
CREATE UNIQUE INDEX employee_closure_pk ON employee_closure (supervisor_id, employee_id);
CREATE INDEX employee_closure_emp ON employee_closure (employee_id);Declare ambos supervisor_id e employee_id como NOT NULL — alguns otimizadores de banco lidam significativamente melhor com colunas indexadas não-nulas.
Popular closure tables
O Mondrian não popula a closure table — esse é o trabalho da camada de ETL. A tabela deve ser refrescada sempre que a hierarquia muda. Se você usa Pentaho Data Integration (Kettle), há uma etapa built-in Closure Generator que lida com isso automaticamente como parte do seu pipeline de carga.
Se você não está usando Kettle, pode popular a tabela com uma stored procedure. Aqui está um exemplo MySQL que semeia auto-pares e itera para fora um level de profundidade por vez:
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 ;Rode esta procedure depois de cada carga que modifica a tabela employee.
Calculated members
Um calculated member é um membro de cubo cujo valor vem de uma fórmula MDX em vez de uma coluna da tabela de fato. Você pode definir calculated members na dimensão Measures (criando medidas computadas) ou em qualquer outra dimensão no cubo.
Em vez de repetir a fórmula em cada consulta MDX com uma cláusula WITH MEMBER, você a define uma vez no schema e ela fica automaticamente disponível em todas as consultas contra aquele cubo:
calculated_members:- name: "Profit" dimension: "Measures" formula: "[Measures].[Store Sales] - [Measures].[Store Cost]" properties: - name: "FORMAT_STRING" value: "$#,##0.00"<CalculatedMembers> <CalculatedMember name="Profit" dimension="Measures"> <Formula>[Measures].[Store Sales] - [Measures].[Store Cost]</Formula> <CalculatedMemberProperty name="FORMAT_STRING" value="$#,##0.00"/> </CalculatedMember></CalculatedMembers>Você também pode escrever a fórmula como atributo XML se preferir brevidade:
calculated_members:- name: "Profit" dimension: "Measures" formula: "[Measures].[Store Sales] - [Measures].[Store Cost]" properties: - name: "FORMAT_STRING" value: "$#,##0.00"<CalculatedMember name="Profit" dimension="Measures" formula="[Measures].[Store Sales] - [Measures].[Store Cost]"> <CalculatedMemberProperty name="FORMAT_STRING" value="$#,##0.00"/></CalculatedMember>Ambas as formas produzem resultados idênticos — e como o conversor mescla o filho
<Formula> e o atributo formula na mesma chave YAML formula:,
as duas grafias XML produzem o YAML idêntico mostrado acima.
CalculatedMemberProperty
<CalculatedMemberProperty> define propriedades de solve-order do MDX, format strings e informação de tipo no calculated member. As propriedades mais usadas são:
| Propriedade | Propósito | Valor de exemplo |
|---|---|---|
FORMAT_STRING | Controla como o valor é renderizado nos clientes | "$#,##0.00" |
DATATYPE | Diz aos clientes XMLA o tipo de retorno | "Numeric", "Integer", "String" |
SOLVE_ORDER | Prioridade quando múltiplos calculated members estão em escopo | "2000" |
FORMAT_STRING pode conter uma expressão condicional em vez de um literal:
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')"<CalculatedMember name="Conditional Profit" dimension="Measures"> <Formula>[Measures].[Store Sales] - [Measures].[Store Cost]</Formula> <CalculatedMemberProperty name="FORMAT_STRING" expression="Iif(Value < 0, '|($#,##0.00)|style=red', '|$#,##0.00|style=green')"/></CalculatedMember>Quando o Mondrian renderiza uma célula, ele primeiro avalia a expressão para obter uma format string, depois aplica essa format string ao valor da célula.
Visibilidade
Defina visible="false" em um <CalculatedMember> (ou em uma <Measure>) para escondê-lo dos navegadores de membro das ferramentas cliente. Isso é útil quando você constrói um resultado através de passos intermediários que não devem ser expostos diretamente:
<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>Apenas “Margin per Sqft” aparece no cliente; os outros são helpers.
Calculated members em cubos multi-fato
<CalculatedMembers> fica no nível do <Cube> e pode referenciar medidas de qualquer um dos measure groups do cubo. Esta é a casa natural para KPIs cross-fact-table:
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%"<CalculatedMember name="Profit Growth" dimension="Measures" formula="([Measures].[Profit] - [Measures].[Profit last Period]) / [Measures].[Profit last Period]" visible="true"> <CalculatedMemberProperty name="FORMAT_STRING" value="0.0%"/></CalculatedMember>Named sets
Um named set é uma expressão de set MDX reutilizável definida no schema. É o análogo de schema de uma cláusula WITH SET em uma consulta MDX. Uma vez definido, o set fica implicitamente disponível em cada consulta contra o cubo (ou schema) onde é declarado.
Named sets no nível de cubo
Declare um named set dentro de um <Cube> para torná-lo disponível em todas as consultas contra aquele cubo:
named_sets:- name: "Top Sellers" formula: "TopCount([Warehouse].[Warehouse Name].MEMBERS, 5, [Measures].[Warehouse\ \ Sales])"<Cube name="Warehouse"> <!-- ... dimensions and measure groups ... -->
<NamedSets> <NamedSet name="Top Sellers"> <Formula> TopCount([Warehouse].[Warehouse Name].MEMBERS, 5, [Measures].[Warehouse Sales]) </Formula> </NamedSet> </NamedSets></Cube>Você pode então usar [Top Sellers] diretamente em MDX:
SELECT {[Measures].[Warehouse Sales]} ON COLUMNS, {[Top Sellers]} ON ROWSFROM [Warehouse]WHERE [Time].[Year].[1997]Que pode retornar:
| Warehouse | Warehouse Sales |
|---|---|
| Treehouse Distribution | 31,116.37 |
| Jorge Garcia, Inc. | 30,743.77 |
| Artesia Warehousing, Inc. | 29,207.96 |
| Jorgensen Service Storage | 22,869.79 |
| Destination, Inc. | 22,187.42 |
Named sets no nível de schema
Você também pode declarar named sets no nível do schema, fora de qualquer cubo. Named sets no nível de schema ficam disponíveis em todos os cubos do schema, mas só são válidos em cubos que contêm as dimensões que a fórmula referencia:
<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] é válido em qualquer cubo que tenha uma dimensão [Store]. Usá-lo em um cubo sem essa dimensão dispara um erro no momento da consulta, não no momento da carga do schema.
O atributo formula e o elemento filho <Formula> são equivalentes. Use a forma de atributo para expressões curtas, o elemento filho para legibilidade em mais longas.
Dimensões bridge (many-to-many)
Uma dimensão normal tem uma relação um-para-muitos com o fato: cada linha de fato pertence a exatamente um cliente, um produto, um dia. Uma relação many-to-many (ou bridge) é diferente — uma única linha de fato pode pertencer a vários membros de dimensão ao mesmo tempo.
O exemplo clássico é uma conta bancária conjunta. Uma conta tem um saldo, mas pode ser co-detida por dois ou mais clientes:
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.25total balance = 1800 3 Carol 0.75A conta 1 é detida por Alice e Bob. Não há uma única coluna customer_id que você possa colocar na tabela de fato, então um simples <ForeignKeyLink> não pode modelar isso. Em vez disso, a relação vive em uma tabela bridge separada (OWNERSHIP) que mapeia contas a clientes, opcionalmente com um peso de propriedade.
O problema do fan-out
A forma ingênua de responder “saldo por cliente” é juntar fato → bridge → customer e SUM(balance). Mas esse join se abre em leque: a única linha de $1000 da conta 1 vira duas linhas (uma para Alice, uma para Bob). Some ingenuamente entre todos os clientes e você ganha 3100, não os verdadeiros 1800 — conta 1 contada duas vezes, conta 3 contada duas vezes. Esse double-counting é o perigo central da modelagem many-to-many.
O Mondrian do Saiku trata o fan-out corretamente com um <BridgeLink>.
Declarar um bridge link
Um <BridgeLink> substitui o <ForeignKeyLink> para a dimensão many-to-many dentro dos <DimensionLinks> do measure group:
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"<MeasureGroup name="Balances" table="account_fact"> <Measures> <Measure name="Balance" column="balance" aggregator="sum"/> </Measures> <DimensionLinks> <ForeignKeyLink dimension="Date" foreignKeyColumn="date_key"/> <BridgeLink dimension="Customer" bridgeTable="account_owner" factForeignKeyColumn="account_id" bridgeFactKeyColumn="account_id" bridgeDimensionKeyColumn="customer_id"/> </DimensionLinks></MeasureGroup>| Atributo | Obrigatório | Descrição |
|---|---|---|
dimension | sim | A dimensão many-to-many que este link resolve. |
bridgeTable | sim | A tabela física que segura o mapeamento fato↔dimensão. Deve ser declarada em <PhysicalSchema>. |
factForeignKeyColumn | sim | Coluna na tabela de fato com a qual o bridge se une de volta (a chave do grão de fato). |
bridgeFactKeyColumn | sim | Coluna no bridge que corresponde a factForeignKeyColumn. |
bridgeDimensionKeyColumn | sim | Coluna no bridge que corresponde à chave da dimensão. |
aggregation | não | fullCount (default) ou weighted. Veja abaixo. |
weightColumn | não | Coluna no bridge segurando o peso de alocação. Obrigatório quando aggregation="weighted". |
A dimensão bridge também exige que o grão do fato seja declarado, para que o Saiku possa deduplicar o fan-out. Declare-o como a <Key> da tabela de fato no schema físico:
tables:- name: "account_fact" key: - "account_id"<Table name="account_fact"> <Key><Column name="account_id"/></Key></Table>Alocação: full-count vs weighted
Há duas formas honestas de atribuir um valor de fato compartilhado entre seus donos.
fullCount (o default) credita o valor inteiro a cada dono. Cada cliente vê o saldo completo de cada conta na qual está:
Balance by Customer (fullCount): Alice 1300 (acct1 1000 + acct3 300) Bob 1500 (acct1 1000 + acct2 500) Carol 300 (acct3 300)Os números por cliente deliberadamente se sobrepõem — Alice e Bob ambos veem todos os $1000 da conta 1 — que é o que você quer para “quanto saldo este cliente tem autoridade de assinatura?”. Sempre que totais full-count são roladas entre donos — o grand total (All Customers), ou qualquer level intermediário (veja Dimensões bridge multi-level abaixo) — o Saiku aplica um agregado simétrico: ele deduplica de volta ao grão do fato antes de somar, para que o grand total seja o verdadeiro 1800, não os 3100 fanned-out.
weighted divide cada valor pela coluna de peso do bridge, para que as partes somem ao total:
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"<BridgeLink dimension="Customer" bridgeTable="account_owner" factForeignKeyColumn="account_id" bridgeFactKeyColumn="account_id" bridgeDimensionKeyColumn="customer_id" aggregation="weighted" weightColumn="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)Use weighted para “qual é a parte econômica deste cliente?” e quando pesos somam a 1 por linha de fato, cada level — incluindo o grand total — reconcilia ao total do fato automaticamente.
Dimensões bridge multi-level
Uma dimensão bridge não é limitada a um único level — ela pode ter uma hierarquia, e rollups full-count permanecem corretos em cada level. Digamos que cada cliente pertence a um segmento (Alice e Bob são Premium, Carol é Standard) e você rola o bridge até segmento:
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"<Dimension name="Customer" table="dim_customer" key="Customer"> <Attributes> <Attribute name="Segment" keyColumn="segment"/> <Attribute name="Customer" keyColumn="customer_id" nameColumn="customer_name"/> </Attributes> <Hierarchies> <Hierarchy name="By Segment" allMemberName="All Customers"> <Level attribute="Segment"/> <Level attribute="Customer"/> </Hierarchy> </Hierarchies></Dimension>Balance by Segment (fullCount): Premium 1800 (owns acct1, acct2, acct3 — de-duplicated) Standard 300 (owns acct3)A conta 1 é detida por Alice e Bob, ambos Premium — mas é contada uma vez no total Premium, não duas. Este é o agregado simétrico fazendo seu trabalho em um level intermediário: sem ele, Premium leria os 2800 fanned-out. A conta 3 aparece em ambos os segmentos (Alice é Premium, Carol é Standard) — isso é a sobreposição full-count desejada entre segmentos, distinta do double-count dentro de um segmento que a deduplicação remove.
Rollups weighted não precisam de deduplicação — a parcela ponderada de cada dono soma limpa, então Premium = 1575, Standard = 225, ainda reconciliando a 1800.
O que funciona
Uma dimensão bridge se comporta como qualquer outra dimensão uma vez declarada. Tudo isso funciona nativamente:
- a dimensão bridge em linhas, colunas ou no slicer (
WHERE); - cruzada com dimensões normais de foreign key (ex.: Customer × Region), em eixos diferentes ou como um crossjoin no mesmo eixo;
- hierarquias bridge multi-level — totais full-count deduplicam corretamente em cada level, não apenas folha e grand total;
- múltiplas medidas em uma consulta, incluindo medidas de coluna calculada (um CASE ou aritmética como
revenue - cost), que deduplicam igual a medidas planas; NON EMPTY(clientes sem contas são suprimidos);- sets explícitos de membros e
.Members.
Requisitos e limitações
- A tabela de fato deve declarar seu grão como uma
<Key>para quefullCountpossa deduplicar. (É isso que torna rollups multi-level corretos.) weightedexige umaweightColumn;fullCountignora qualquer peso.- Ambas as alocações cobrem medidas planas de coluna real e medidas de coluna calculada (um CASE ou aritmética como
revenue - cost):fullCountdeduplica a expressão calc no grão do fato, eweighteda escala pelo peso (SUM(expression × weight)). - O join do bridge é single-column em cada hop. Chaves de join compostas são uma limitação geral do caminho de join do Calcite (não específica de bridges) — se suas chaves de bridge são multi-coluna, modele uma chave de grão substituta única.
Um exemplo completo, carregável e trabalhado — schema, dados de seed e MDX de exemplo com números esperados — vem na cube library sob many-to-many.
Grão distinto no nível da medida (distinctKeyColumn)
Um bridge resolve fan-out causado por um join. Mas fan-out também pode vir do grão da própria tabela de fato: uma linha de fato que deveria ser contada uma vez é armazenada fisicamente como várias linhas (um pedido com várias line items, um evento registrado por toque), e um simples SUM sobre a coluna conta duplo. Quando a duplicação é chaveada por uma coluna na própria tabela de fato da medida, você não precisa de um bridge — você pode fixar o grão de deduplicação diretamente na medida com 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)Declare a chave de deduplicação na medida. Ela deve resolver para uma coluna na própria tabela de fato da medida, e é permitida apenas para aggregator="sum" e aggregator="avg":
measures:- name: "Order Amount" column: "amount" aggregator: "sum" distinct_key_column: "order_id"<Measure name="Order Amount" column="amount" aggregator="sum" distinctKeyColumn="order_id"/>Semântica
A medida agrega sobre SELECT DISTINCT (group keys, distinctKeyColumn, operand) — cada chave distinta contribui seu valor uma vez, mesmo quando o grão do fato repete a linha. Reutiliza o mesmo maquinário de agregado simétrico que o fan-out do bridge (a deduplicação é dirigida pela declaração da medida em vez da topologia do join):
aggregator="sum"→SUMsobre as chaves distintas (o formatosum_distinctdo LookML).aggregator="avg"→AVGsobre as chaves distintas (LookMLaverage_distinct).
Rollups permanecem corretos em cada level: By Region sobre o exemplo lê North = 400, South = 50 — os valores distintos, nunca os 850 fanned-out.
Compõe com row security
O grão distinto é aplicado após o filtro de row-security, não antes. <PredicateGrant> e member grants de bridge filtram as linhas de fato dentro da subquery DISTINCT, então a deduplicação sempre opera exatamente sobre as linhas que o chamador tem permissão de ver — não há caminho que deduplica um valor que a role não pode ver e depois vaza o total. O grão distinto é uma propriedade fixa de schema (não depende de role), então não adiciona nova dimensão de cache, e o cache de segmento continua a isolar roles: um valor aquecido para os grants de uma role nunca é servido a outra. (Isso é coberto pelos testes de composição de row-security, incluindo um teste de no-cross-role-cache-bleed.)
Requisitos e limitações
distinctKeyColumndeve resolver para uma coluna real na própria tabela de fato da medida. Uma chave cross-table ou irresolvível é rejeitada no momento da carga (fail-closed) — nunca uma deduplicação silenciosamente errada.- Permitido apenas para
aggregator="sum"eaggregator="avg". - Uma carga de segmento que mistura uma medida com grão distinto com uma medida plana, ou duas medidas com chaves distintas diferentes, é dividida em segmentos por medida homogêneos automaticamente (o
DISTINCTem escopo da requisição não pode servir dois grãos de uma vez). - Quando a chave distinta iguala a própria chave de grão da tabela de fato (ex.: sua primary key), a deduplicação é um no-op e a medida se comporta como um
sum/avgplano. Este é o alvo natural parasum_distinct/average_distinctdo LookML — veja Migrar do Looker.
Adaptado do guia de schema do projeto Mondrian (EPL v1.0). Dimensões bridge (many-to-many) e grão distinto no nível da medida são extensões do Saiku ao Mondrian 4.