Привет, Хабр! Одной из важных функций в аналитическом языке 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 и дашбордах :)

Комментарии (1)


  1. Alex-ok
    27.07.2025 10:42

    Интересный подход, спасибо за разбор! Но возникает закономерный вопрос — зачем реализовывать такую логику на C#, если даже базовые SQL-движки справляются с задачами декартова произведения и агрегаций на порядок быстрее?

    Например, аналогичная операция в SQL Server (декартово произведение 1 млн × 1 млн и выборка первых 1 млн строк):

    WITH T1 AS (
        SELECT TOP (1000000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS a
        FROM sys.all_objects AS a
        CROSS JOIN sys.all_objects AS b
    ),
    T2 AS (
        SELECT TOP (1000000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS b
        FROM sys.all_objects AS a
        CROSS JOIN sys.all_objects AS b
    )
    SELECT TOP (1000000) *
    FROM T1
    CROSS JOIN T2;
    

    У меня этот запрос выполняется за ~4 секунды.

    А вот эквивалент для ClickHouse (1 млн × 1 млн, первые 1 млн. строк):

    SELECT *
    FROM
    (
        SELECT number AS a
        FROM numbers(1000000)
    ) AS t1
    JOIN
    (
        SELECT number AS b
        FROM numbers(1000000)
    ) AS t2
    ON 1 = 1
    LIMIT 1000000;
    

    На ClickHouse — ~0.12 сек.

    Понятно, что у DaxSharp немного иная цель — эмулировать семантику DAX и SUMMARIZECOLUMNS в .NET-приложениях. Но если говорить исключительно о производительности и объёмах, SQL-инструменты всё ещё выглядят значительно предпочтительнее.