Data Cubes
A data cube is the simplest useful analytics shape: facts keyed by a handful of dimensions, carrying one or more measures. In MeshWeaver the whole cube is mesh content — the dimension types are NodeType nodes, the dimension members are mesh nodes, the facts are mesh nodes, and even the formulas are data on dimension nodes.
This page builds a complete one: the balance sheet of Helvetia Vorsorge, a fictional Swiss pension fund — Position × Year × Currency → Amount, with computed positions like Total Assets and the Funding Ratio modelled out of the atomic positions and evaluated by business-rules scopes. The working node set ships in samples/Graph/Data/PensionFund/ — including the scopes themselves as node-native Code nodes (BalanceSheet/Source/BalanceSheetScopes.cs). Business rules / scopes ship as a plugin, which carries the scope engine and its tests that pin every number below.
The representation — everything is a mesh node
The cube ships exactly like every domain in the samples: each dimension gets its own NodeType node, its members are mesh nodes of that type, the Source/*.cs files are Code nodes compiled at runtime, and the facts are mesh nodes too:
PensionFund/
├── Position.json ← the dimension's NodeType node
├── Position/
│ ├── Source/Position.cs ← Code node: the dimension record + formula model
│ ├── Cash.json … FreeFunds.json ← 15 atomic position MEMBERS — mesh nodes
│ └── TotalAssets.json … ← 6 COMPUTED positions — formulas as node content
├── Year.json · Year/2024.json … ← reporting years
├── Currency.json · Currency/CHF.json …
├── BalanceSheetEntry.json
├── BalanceSheetEntry/2024-Cash.json … ← 30 facts — one node per Position × Year
├── BalanceSheet.json ← "config => config.ConfigureBalanceSheet()"
│ └── BalanceSheet/Source/ ← scopes, data loader, layout areas
└── Statement.json ← the report INSTANCE (nodeType PensionFund/BalanceSheet)
There is no Id property anywhere — a mesh node's identity is its path. A fact references its dimensions by their paths, and a formula references its operand positions by path. The views attach to instances of the BalanceSheet type, not to the type definition itself: open PensionFund/Statement in a portal with the samples loaded and the views below are its layout areas.
1. Dimension types host their instances
Each dimension is declared by a NodeType node whose Source/ holds the content record. The Position dimension is the interesting one — it carries the formula model:
public enum BalanceSheetSide { Assets, Liabilities, Computed }
public enum PositionAggregation { Atomic, Sum, Ratio }
public record PositionComponent
{
[MeshNode("nodeType:PensionFund/Position")] // ← references another Position NODE
public string Position { get; init; } = string.Empty;
public double Weight { get; init; } = 1; // +1 adds, −1 subtracts
}
public record Position
{
public BalanceSheetSide Side { get; init; }
public PositionAggregation Aggregation { get; init; }
public PositionComponent[]? Components { get; init; } // Sum operands
[MeshNode("nodeType:PensionFund/Position")]
public string? Numerator { get; init; } // Ratio
[MeshNode("nodeType:PensionFund/Position")]
public string? Denominator { get; init; }
}
Note what the record does not carry: no Id, no Name, no Description, no Order — all of those already live on the mesh node itself (MeshNode.Name, MeshNode.Description, MeshNode.Order). The content holds only what the node doesn't have: the formula model.
The [MeshNode("query")] attribute does double duty: it documents that the property holds a node path, and the Edit form renders it as the searchable MeshNodePicker over exactly the nodes the query matches — here, the members of the Position dimension.
2. Formulas are data on dimension nodes
Computed positions are ordinary Position nodes whose content holds the formula. Pension Capital — the actuarial obligation — is the sum of three other positions:
{
"id": "PensionCapital",
"namespace": "PensionFund/Position",
"nodeType": "PensionFund/Position",
"content": {
"$type": "Position",
"side": "Computed",
"aggregation": "Sum",
"components": [
{ "position": "PensionFund/Position/ActiveMembersCapital", "weight": 1 },
{ "position": "PensionFund/Position/PensionersCapital", "weight": 1 },
{ "position": "PensionFund/Position/TechnicalProvisions", "weight": 1 }
]
}
}
Available Assets uses negative weights (total assets minus short-term obligations), and the Funding Ratio is a Ratio position dividing it by Pension Capital — the statutory solvency measure of a Swiss pension fund (BVV2 Art. 44). Editing a formula is editing a node: add a component in the GUI and every report recomputes.
3. The fact — no Id, dimension columns are node paths
public record BalanceSheetEntry
{
[MeshNode("nodeType:PensionFund/Position")]
public string Position { get; init; } = string.Empty; // a node PATH
[MeshNode("nodeType:PensionFund/Year")]
public string Year { get; init; } = string.Empty;
[MeshNode("nodeType:PensionFund/Currency")]
public string Currency { get; init; } = string.Empty;
[DisplayFormat(DataFormatString = "{0:N1}")]
public double Amount { get; init; } // CHF m
}
Thirty entries — 15 atomic positions × 2 years — make up the sample balance sheet, and both years balance by construction (2024: 1,060.0 · 2025: 1,142.0).
4. Business rules — scopes evaluate any position
The evaluation engine is one interface from the business-rules framework (MeshWeaver.BusinessRules): a scope per (position, year), composing other scopes for its operands. The scope generator emits the implementations at build time — for sample Code nodes, the NodeType compiler runs it during dynamic compilation:
public record PositionYear(string Position, string Year);
public interface PositionValue : IScope<PositionYear, BalanceSheetStorage>
{
Position Position => GetStorage().Positions[Identity.Position];
double Value => Position.Aggregation switch
{
PositionAggregation.Atomic =>
GetStorage().Amounts.TryGetValue((Identity.Position, Identity.Year), out var v) ? v : 0,
PositionAggregation.Sum =>
(Position.Components ?? [])
.Sum(c => c.Weight * GetScope<PositionValue>(new PositionYear(c.Position, Identity.Year)).Value),
PositionAggregation.Ratio =>
GetScope<PositionValue>(new PositionYear(Position.Denominator!, Identity.Year)).Value is var d && d != 0
? GetScope<PositionValue>(new PositionYear(Position.Numerator!, Identity.Year)).Value / d
: 0,
_ => 0,
};
}
Scope instances are cached per identity — Total Assets feeds both Available Assets and the balance check, yet is computed once. Registration is one line in the node's hub configuration:
config.WithServices(services => services.AddBusinessRules(typeof(PositionValue).Assembly))
and any view evaluates positions through the registry:
var registry = host.Hub.ServiceProvider.CreateScopeRegistry(storage);
var ratio = registry.GetScope<PositionValue>(
new PositionYear("PensionFund/Position/FundingRatio", "PensionFund/Year/2024")).Value; // ≈ 109.8%
The same recursion, runnable right here — atomic values and formulas exactly as in the sample nodes, folded the way the PositionValue scope does:
// The formula model: Sum positions fold weighted components, Ratio divides.
record Component(string Position, double Weight);
record Pos(string Agg, Component[]? Components = null, string? Num = null, string? Den = null);
var amounts = new Dictionary<string, double> // 2024 atomic facts, CHF m
{
["Cash"] = 50, ["Bonds"] = 400, ["Equities"] = 300, ["RealEstate"] = 200,
["Alternatives"] = 100, ["Receivables"] = 10,
["Payables"] = 15, ["AccruedLiabilities"] = 5, ["EmployerContributionReserve"] = 20,
["NonTechnicalProvisions"] = 10, ["ActiveMembersCapital"] = 600,
["PensionersCapital"] = 280, ["TechnicalProvisions"] = 40,
["ValueFluctuationReserve"] = 80, ["FreeFunds"] = 10,
};
Component[] Sum1(params string[] ps) => ps.Select(p => new Component(p, 1)).ToArray();
var positions = new Dictionary<string, Pos>
{
["TotalAssets"] = new("Sum", Sum1("Cash", "Bonds", "Equities", "RealEstate", "Alternatives", "Receivables")),
["PensionCapital"] = new("Sum", Sum1("ActiveMembersCapital", "PensionersCapital", "TechnicalProvisions")),
["AvailableAssets"] = new("Sum", new[] { new Component("TotalAssets", 1),
new Component("Payables", -1), new Component("AccruedLiabilities", -1),
new Component("EmployerContributionReserve", -1), new Component("NonTechnicalProvisions", -1) }),
["FundingRatio"] = new("Ratio", Num: "AvailableAssets", Den: "PensionCapital"),
};
double Value(string p) => positions.TryGetValue(p, out var pos)
? pos.Agg switch
{
"Sum" => pos.Components!.Sum(c => c.Weight * Value(c.Position)),
"Ratio" => Value(pos.Num!) / Value(pos.Den!),
_ => 0,
}
: amounts[p]; // Atomic: read the fact
Controls.Markdown($"""
| Computed position (2024) | Value |
|---|---:|
| Total Assets | {Value("TotalAssets"):N1} |
| Pension Capital | {Value("PensionCapital"):N1} |
| Available Assets | {Value("AvailableAssets"):N1} |
| **Funding Ratio** | **{Value("FundingRatio"):P1}** |
""")
5. Edit on the GUI — one call, no per-field code
host.Edit(instance) renders the whole form from the record's attributes. In the sample, the dimension fields carry [MeshNode] and render node pickers; here, live from the kernel with selects:
using System.ComponentModel.DataAnnotations;
using MeshWeaver.Layout;
record BalanceSheetEntryDraft
{
[UiControl<SelectControl>(Options = new[] { "Cash", "Bonds", "Equities", "RealEstate" })]
[Display(Name = "Position")]
public string Position { get; init; } = "Equities";
[UiControl<SelectControl>(Options = new[] { "2024", "2025" })]
public string Year { get; init; } = "2025";
[UiControl<SelectControl>(Options = new[] { "CHF", "EUR", "USD" })]
public string Currency { get; init; } = "CHF";
[DisplayFormat(DataFormatString = "{0:N1}")]
[Display(Name = "Amount (CHF m)")]
public double Amount { get; init; } = 340.0;
}
Mesh.Edit(new BalanceSheetEntryDraft(), "pensionDraft")
6. The picker in a dialog
Opening the same form as a modal dialog is one click action — build the dialog, write it to the dialog area. In the sample this is the NewEntryDialog view, whose draft uses the real [MeshNode] pickers over the Position / Year / Currency nodes:
Controls.Button("New balance sheet entry…")
.WithClickAction(click =>
{
var dialog = Controls.Dialog(
click.Host.Edit(new BalanceSheetEntryDraft(), "newEntry"),
"New Balance Sheet Entry")
.WithSize("M")
.WithClosable(true);
click.Host.UpdateArea(DialogControl.DialogArea, dialog); // open
return Task.CompletedTask;
});
// close from any action: ctx.Host.UpdateArea(DialogControl.DialogArea, null!);
Reactive dialog patterns (spinners, conditional sections, server round-trips): Reactive Dialogs.
7. Slice & dice — the pivot table
Rows by Position, columns by Year — with totals. Live:
using MeshWeaver.Layout.Pivot;
record Entry(string Position, string Year, double Amount);
var entries = new Dictionary<string, (double Y2024, double Y2025)>
{
["Cash"] = (50, 60), ["Bonds"] = (400, 410), ["Equities"] = (300, 340),
["RealEstate"] = (200, 210), ["Alternatives"] = (100, 110), ["Receivables"] = (10, 12),
}
.SelectMany(kvp => new[] { new Entry(kvp.Key, "2024", kvp.Value.Y2024), new Entry(kvp.Key, "2025", kvp.Value.Y2025) })
.ToArray();
entries.ToPivotGrid(pivot => pivot
.GroupRowsBy(e => e.Position)
.GroupColumnsBy(e => e.Year)
.Aggregate(e => e.Amount, agg => agg.WithFunction(AggregateFunction.Sum))
.WithRowTotals()
.WithColumnTotals())
Re-dice it yourself: the field picker on the grid lets you drag dimensions between rows and columns — the cube re-aggregates live.
8. Slice & dice — the charts
The asset side, stacked by position across the years:
using MeshWeaver.Layout.Chart;
record Entry(string Position, string Year, double Amount);
var entries = new Dictionary<string, (double Y2024, double Y2025)>
{
["Cash"] = (50, 60), ["Bonds"] = (400, 410), ["Equities"] = (300, 340),
["RealEstate"] = (200, 210), ["Alternatives"] = (100, 110), ["Receivables"] = (10, 12),
}
.SelectMany(kvp => new[] { new Entry(kvp.Key, "2024", kvp.Value.Y2024), new Entry(kvp.Key, "2025", kvp.Value.Y2025) })
.ToArray();
entries
.SliceBy(e => e.Year)
.SliceBy(e => e.Position)
.ToStackedColumnChart(g => g.Sum(e => e.Amount))
.WithTitle("Assets by Year and Position (CHF m)")
And the 2025 asset allocation as a pie — the same chart the sample's AssetAllocation view renders from the scopes:
using MeshWeaver.Layout.Chart;
record Entry(string Position, double Amount);
var assets2025 = new[]
{
new Entry("Bonds", 410.0), new Entry("Equities", 340.0), new Entry("RealEstate", 210.0),
new Entry("Alternatives", 110.0), new Entry("Cash", 60.0), new Entry("Receivables", 12.0),
};
assets2025
.SliceBy(e => e.Position)
.ToPieChart(g => g.Sum(e => e.Amount))
.WithTitle("Asset Allocation 2025 (CHF m)")
9. The numbers — pinned by tests
Every figure this page shows is asserted by the business-rules plugin's tests — evaluated through the real generated scopes, the same node-native engine the sample's Code nodes compile against:
| Figure | 2024 | 2025 |
|---|---|---|
| Total Assets = Balance Sheet Sum = Total Liabilities | 1,060.0 | 1,142.0 |
| Pension Capital | 920.0 | 964.0 |
| Available Assets | 1,010.0 | 1,084.0 |
| Funding Ratio | ≈ 109.8% | ≈ 112.4% |
See Also
- Data Modeling — typed dimensions,
[Dimension<T>], reference data - Property Attributes —
[MeshNode],[UiControl<T>], the full attribute catalogue - Editor Control — how
Editmaps records to forms - Reactive Dialogs — dialogs beyond a single form
- Creating Node Types — NodeType nodes + Source code nodes, end to end