Привет, Хабр! Одной из важных функций в аналитическом языке DAX является SUMMARIZECOLUMNS
, т.к. она готовит данные для дашбордов за счет декартова произведения полей группировки, если поля группировки из разных таблиц. Понятно, что на любом языке программирования можно реализовать логику, в чем-то аналогичную SUMMARIZECOLUMNS
из DAX. Интересующимся DAX-style логикой для C# из NuGet пакета DaxSharp для функцииSUMMARIZECOLUMNS
— добро пожаловать под кат :)
Вообще говоря, функция SUMMARIZECOLUMNS
из Power BI до сих пор развивается и дорабатывается Microsoft. Так, в 2024 году добавлено использование SUMMARIZECOLUMNS
в мерах в Power BI. Синтаксис SUMMARIZECOLUMNS
выглядит следующим образом:


Назначение параметров для группировки, фильтрации и выражений в общем и целом интуитивно понятно, и специфика SUMMARIZECOLUMNS
заключается в декартовом произведении полей группировки.
В связи с этим можно выделить две особенности SUMMARIZECOLUMNS
в Power BI:
декартово произведение для группировки по разным таблицам и меры «с константой»;
выполнение по частям с алгоритмической точки зрения, т.е. для декартова произведения 1 млн x 1 млн не будет 1 000 000 000 000 «неделимых строк» в результате, а будет выполнение по частям, например, в простейшем случае — первая 1000 записей.
Аналог такого поведения SUMMARIZECOLUMNS
из DAX для Power BI имеется в NuGet пакете DaxSharp. Этот пакет не обеспечивает полную поддержку DAX из Power BI, но позволяет писать DAX-style логику из SUMMARIZECOLUMNS
из Power BI с учетом декартова произведения и выполнения SUMMARIZECOLUMNS
по частям — например, получить первую 1000 записей при размерности декартова произведения 1 млн x 1 млн.
Получение декартова произведения явно задается разработчиком через метод расширения SummarizeColumnsCartesian
(если декартово произведение не нужно, то используется метод SummarizeColumns
с той же сигнатурой):
IEnumerable<(TGrouped grouped, TExpressions expressions)>
SummarizeColumnsCartesian<T, TGrouped, TExpressions>(
this IEnumerable<T> items,
Func<T, TGrouped> groupByBuilder,
Func<T?, TGrouped?, bool> filter,
Func<IEnumerable<T>, TGrouped?, TExpressions?> expressions,
int maxCount = int.MaxValue)
where TGrouped : notnull
Соответственно, в пакете DaxSharp есть только DAX-style логика SUMMARIZECOLUMNS
с учетом декартова произведения и выполнения запроса по частям, и разработчик сам отвечает за учет «схемы данных» (насколько это вообще применимо к C#), «включение или отключение» декартова произведения, фильтров и т.д. В итоге, несмотря на отсутствие DAX функционала, DaxSharp условно считать «надмножеством» функции SUMMARIZECOLUMNS
из DAX в Power BI, т.е. DAX-style логика, которая контролируется C# разработчиком, и он отвечает за обработку метаданных и построение запроса.
Конечно, удобнее всего ознакомиться с DaxSharp на примерах. Рассмотрим схему данных с таблицей продаж Sales
и справочниками Products
и Categories
.

Таблица Sales
является денормализованной, т.е. в ней хранятся значения из справочников, например, имя продукта дублируется в Sales[Product]
и соответствует Product[Product]
, такое дублирование только для целей иллюстрации. Предполагаем, что условно все связи и данные валидны — один ко многим между справочниками и таблицей фактов, чтобы не рассматривать частные случаи.
Работа SUMMARIZECOLUMNS без декартова произведения — для полей группировки из одной таблицы
Рассмотрим пример, в котором не будет декартова произведения, т.е. поля группировки из одной и той же таблицы, т.е. следующий DAX:
EVALUATE
SUMMARIZECOLUMNS(
Sales[Product],
Sales[Category],
FILTER(
Categories,
Categories[IsActive] = TRUE && Categories[Category] <> "Category1"
),
"Sum", IF(
ISBLANK(SUM(Sales[Amount])),
2,
SUM(Sales[Amount])
)
)
Результаты Power BI:

Исходные данные и соответствующий код DaxSharp:
using DaxSharp;
var data = new[]
{
(Product: "Product1", Category: "Category1", IsActive: true, Amount: 10, Quantity: 2),
(Product: "Product1", Category: "Category2", IsActive: true, Amount: 20, Quantity: 3),
(Product: "Product2", Category: "Category1", IsActive: true, Amount: 5, Quantity: 1),
(Product: "Product3", Category: "Category3", IsActive: true, Amount: 15, Quantity: 2)
}.ToList();
var results = data.SummarizeColumns(
item => new { item.Product, item.Category },
(_, _) => true,
(items, g) =>
items.Where(x => x.IsActive && x.Category != "Category1").ToArray() is { Length: > 0 } array
? array.Sum(x => x.Amount)
: 2
).ToList();
Результаты DaxSharp:
Product1, Category1, 2
Product1, Category2, 20
Product2, Category1, 2
Product3, Category3, 15
Как видно, результаты DaxSharp совпадают с Power BI (за исключением порядка) и декартова произведения нет при использовании метода SummarizeColumns
.
Работа SUMMARIZECOLUMNS с декартовым произведением — для полей группировки из разных таблиц
Рассмотрим пример, в котором будет декартово произведение, т.е. таблицы в полях группировки разные, а также для выражения из SUMMARIZECOLUMNS
даже при пустой группе будет результат (число 2) — при этом будет использован метод SummarizeColumnsCartesian
, соответствующий DAX:
EVALUATE
SUMMARIZECOLUMNS(
Products[Product],
Categories[Category],
FILTER(
Categories,
Categories[IsActive] = TRUE && Categories[Category] <> "Category1"
),
"Sum", IF(
ISBLANK(SUM(Sales[Amount])),
2,
SUM(Sales[Amount])
)
)
Результаты Power BI:

Исходные данные и код DaxSharp:
using DaxSharp;
var data = new[]
{
(Product: "Product1", Category: "Category1", IsActive: true, Amount: 10, Quantity: 2),
(Product: "Product1", Category: "Category2", IsActive: true, Amount: 20, Quantity: 3),
(Product: "Product2", Category: "Category1", IsActive: true, Amount: 5, Quantity: 1),
(Product: "Product3", Category: "Category3", IsActive: true, Amount: 15, Quantity: 2)
}.ToList();
var results = data.SummarizeColumnsCartesian(
item => new { item.Product, item.Category },
(item, g) => item is { IsActive: true, Category: not "Category1" } || g is { Category: not "Category1" },
(items, g) =>
items.ToArray() is { Length: > 0 } array
? array.Sum(x => x.Amount)
: 2
).ToList();
Результаты DaxSharp:
Product1, Category2, 20
Product2, Category2, 2
Product3, Category2, 2
Product1, Category3, 2
Product2, Category3, 2
Product3, Category3, 15
Как видно, получаем декартово произведение для Product
и Category
с учетом фильтра по категориям Categories[Category] <> "Category1"
, и результаты совпадают с Power BI, в данном случае совпадает и порядок записей.
Работа SUMMARIZECOLUMNS с размерностью 1 млн x 1 млн — первые 1000 записей
Как уже было отмечено, вторая важная особенность SUMMARIZECOLUMNS — это способность выполнения запроса по частям для декартова произведения большой размерности, например, 1 млн x 1 млн. Для этого рассмотрим пример со 100 миллионами строк в Sales
и группировкой 1 млн x 1 млн с декартовым произведением, выберем первые 1000 записей.
var stopwatch = new Stopwatch();
stopwatch.Start();
var sales = Enumerable.Range(0, 100000000)
.Select(i => (productId: i % 1000000, customerId: i % 1000000, amount: i % 100))
.ToArray();
stopwatch.Stop();
output.WriteLine($"Creation: {stopwatch.Elapsed}");
stopwatch.Restart();
var result = sales.SummarizeColumnsCartesian(
x => new {x.productId, x.customerId},
(x, g) => x is { amount: > 1 } || g is { productId: > 0 },
(x, g) => x.ToArray() is { Length: > 0 } array
? array.Max(y => y.amount) + array.Sum(y => y.amount)
: 0,
1000
).ToList();
stopwatch.Stop();
output.WriteLine($"Elapsed: {stopwatch.Elapsed}");
Соответствующий DAX:
EVALUATE
TOPN(
1000,
SUMMARIZECOLUMNS(
Products[ProductId],
Categories[CategoryId],
"Sum", IF(
ISBLANK(SUM(Sales[Amount])),
2,
SUM(Sales[Amount])
)
)
)
Запрос выполняется примерно 26 секунд.
Creation: 00:00:00.9013289
Elapsed: 00:00:25.9113662
В общих чертах видно, что пакет DaxSharp способен работать с размерностью декартова произведения полей группировки 1 млн x 1 млн для 100 млн записей, что говорит о его «условной алгоритмической корректности» для кейса 1 млн x 1 млн, или по крайней мере, об отсутствии явных проблем.
Таким образом, при использовании пакета DaxSharp нельзя сказать, что для каждого случая очевидно, как реализовать в точности такую же логику, как в DAX, но это выполнимо. Т.е. можно получить имплементацию на C#, которая по результатам совпадает с DAX из Power BI, и работает «более‑менее прилично» с алгоритмической точки зрения, что видно из примера для 100 млн и декартова произведения 1 млн x 1 млн. Безусловно, для DaxSharp напрашиваются многочисленные улучшения, например, поддержка сортировки, но в целом DaxSharp выглядит работоспособно.
Надеюсь, описанный подход может быть интересен разработчикам, успехов в обработке данных, BI и дашбордах :)
Alex-ok
Интересный подход, спасибо за разбор! Но возникает закономерный вопрос — зачем реализовывать такую логику на C#, если даже базовые SQL-движки справляются с задачами декартова произведения и агрегаций на порядок быстрее?
Например, аналогичная операция в SQL Server (декартово произведение 1 млн × 1 млн и выборка первых 1 млн строк):
У меня этот запрос выполняется за ~4 секунды.
А вот эквивалент для ClickHouse (1 млн × 1 млн, первые 1 млн. строк):
На ClickHouse — ~0.12 сек.
Понятно, что у DaxSharp немного иная цель — эмулировать семантику DAX и SUMMARIZECOLUMNS в .NET-приложениях. Но если говорить исключительно о производительности и объёмах, SQL-инструменты всё ещё выглядят значительно предпочтительнее.