Author: Руссо Марко Феррари Альберто
Tags: программирование на эвм компьютерные программы вычислительная техника микропроцессоры базы данных анализ данных компьютерные технологии
ISBN: 978-5-97060-859-3
Year: 2021
Подробное руководство
по DAX: бизнес-аналитика
с Microsoft Power Bl,
SQL Server Analysis Services
и Excel
Марко Руссо и Альберто Феррари
Москва, 2021
УДК 004.42DAX
ББК 32.97
Р89
Руссо М., Феррари А.
Р89 Подробное руководство по DAX: бизнес-аналитика с Microsoft Power BI,
SQL Server Analysis Services и Excel / пер. с англ. А. Ю. Гинько. - M.: ДМК
Пресс, 2021. - 776 с.: ил.
ISBN 978-5-97060-859-3
Расширенная и дополненная с учетом современных требований и техник, эта
книга представляет собой наиболее полное руководство по языку DAX, применя-
емому в области бизнес-аналитики, моделирования данных и анализа. Эксперты
Microsoft BI Марко Руссо и Альберто Феррари излагают как основы, так и отдельные
нюансы работы с DAX: от простых табличных функций до продвинутых техник
программирования и оптимизации моделей. Вы узнаете, что происходит под
капотом движка DAX при запуске выражений; полученные знания пригодятся
при написании быстрого и надежного кода.
В книге используются примеры, которые можно запустить в бесплатной версии
Power BI Desktop и разобраться во всех тонкостях синтаксиса создания переменных
(VAR) в Power BI, Excel или Analysis Services.
Издание предназначено для опытных пользователей и профессионалов в сфере
бизнес-аналитики, использующих в своей работе DAX и аналитические инстру-
менты от Microsoft.
УДК 004.42DAX
ББК 32.97
Authorized Translation from the English language edition, entitled DEFINITIVE GUIDE TO
DAX, THE: BUSINESS INTELLIGENCE FOR MICROSOFT POWER BI, SQL SERVER ANALYSIS
SERVICES, AND EXCEL, 2nd Edition by MARCO RUSSO; ALBERTO FERRARI, published by Pearson
Education, Inc, publishing as Microsoft Press. Russian-language edition copyright © 2021 by DMK
Press. All rights reserved.
No part of this book may be reproduced or transmitted in any form or by any means, electronic
or mechanical, including photocopying, recording or by any information storage retrieval system,
without permission from Pearson Education, Inc.
Electronic RUSSIAN language edition publiched by DMK PRESS PUBLISHING LTD. Copyright
© 2021.
Все права защищены. Любая часть этой книги не может быть воспроизведена в ка-
кой бы то ни было форме и какими бы то ни было средствами без письменного разрешения
владельцев авторских прав.
ISBN 978-1-5093-0697-8 (англ.)
ISBN 978-5-97060-859-3 (рус.)
Copyright © 2020 by Alberto Ferrari
and Marco Russo
© Оформление, издание, перевод,
ДМК Пресс, 2021
Содержание
Рецензия.............................................14
Об авторах...........................................15
От команды разработчиков.............................16
Благодарности........................................17
От издательства......................................19
Предисловие ко второму изданию.......................20
Предисловие к первому изданию........................21
Глава 1 Что такое DAX?.........................................27
Введение в модель данных.............................27
Введение в направление связи......................29
DAX для пользователей Excel..........................31
Ячейки против таблиц..............................32
Excel и DAX: два функциональных языка.............34
Итерационные функции в DAX........................34
DAX требует изучения теории.......................35
DAX для разработчиков SQL............................35
Работа со связями.................................35
DAX как функциональный язык.......................36
DAX как язык программирования и язык запросов.....37
Подзапросы и условия в DAX и SQL..................37
DAX для разработчиков MDX............................38
Многомерность против табличное™...................39
DAX как язык программирования и язык запросов.....39
Иерархии..........................................40
Вычисления на конечном уровне.....................41
DAX для пользователей Power BI.......................41
Глава 2 Знакомство с DAX.......................................43
Введение в вычисления DAX............................43
Типы данных DAX...................................45
Операторы DAX.....................................48
Конструкторы таблиц...............................49
Условные операторы................................50
Введение в вычисляемые столбцы и меры................51
Вычисляемые столбцы...............................51
Меры..............................................52
Введение в переменные................................56
Обработка ошибок в выражениях DAX....................57
Ошибки преобразования.............................57
Ошибки арифметических операций....................58
Содержание 5
Перехват ошибок...................................61
Генерирование ошибок..............................64
Форматирование кода на DAX..........................65
Введение в агрегаторы и итераторы...................68
Использование распространенных функций DAX..........71
Функции агрегирования.............................71
Логические функции................................73
Информационные функции............................74
Математические функции............................75
Тригонометрические функции........................76
Текстовые функции.................................76
Функции преобразования............................77
Функции для работы с датой и временем.............78
Функции отношений.................................79
Заключение..........................................81
Глава 3 Использование основных табличных функций..............83
Введение в табличные функции........................83
Введение в синтаксис EVALUATE.......................86
Введение в функцию FILTER...........................87
Введение в функции ALL и ALLEXCEPT..................90
Введение в функции VALUES, DISTINCT и пустые строки.94
Использование таблиц в качестве скалярных значений.100
Введение в функцию ALLSELECTED.....................102
Заключение.........................................104
Глава 4 Введение в контексты вычисления.......................юз
Введение в контексты вычисления....................106
Знакомство с контекстом фильтра..................106
Знакомство с контекстом строки...................112
Тест на понимание контекстов вычисления............114
Использование функции SUM в вычисляемых столбцах.114
Использование ссылок на столбцы в мерах..........115
Использование контекста строки с итераторами.......116
Вложенные контексты строки в разных таблицах.....117
Вложенные контексты строки в одной таблице.......119
Использование функции EARLIER....................123
Функции FILTER, ALL и взаимодействие между контекстами.125
Работа с несколькими таблицами.....................128
Контексты строки и связи.........................129
Контекст фильтра и связи.........................132
Использование функций DISTINCT и SUMMARIZE
в контекстах фильтра...............................136
Заключение.........................................140
Глава 5 Функции CALCULATE и CALCULATETABLE...................142
Введение в функции CALCULATE и CALCULATETABLE......142
Создание контекста фильтра.......................143
6 Содержание
Знакомство с функцией CALCULATE..................147
Использование функции CALCULATE для расчета
процентов........................................152
Введение в функцию KEEPFILTERS...................163
Фильтрация по одному столбцу.....................167
Фильтрация по сложным условиям...................168
Порядок вычислений в функции CALCULATE...........172
Преобразование контекста...........................177
Повторение темы контекста строки и контекста фильтра....177
Введение в преобразование контекста..............179
Преобразование контекста в вычисляемых столбцах..183
Преобразование контекста в мерах.................186
Циклические зависимости............................190
Модификаторы функции CALCULATE.....................194
Модификатор USERELATIONSHIP......................195
Модификатор CROSSFILTER..........................198
Модификатор KEEPFILTERS..........................199
Использование модификатора ALL в функции CALCULATE ...200
Использование ALL и ALLSELECTED без параметров...202
Правила вычисления в функции CALCULATE.............203
Глава 6 Переменные...........................................206
Введение в синтаксис переменных VAR................206
Переменные - это константы.........................208
Области видимости переменных.......................209
Использование табличных переменных.................212
Отложенное вычисление переменных...................214
Распространенные шаблоны использования переменных..215
Заключение.........................................217
Глава 7 Работа с итераторами и функцией CALCULATE............219
Использование итерационных функций.................219
Кратность итератора..............................220
Использование преобразования контекста в итераторах.....223
Использование функции CONCATENATEX...............226
Итераторы, возвращающие таблицы .................228
Решение распространенных сценариев при помощи
итераторов.........................................232
Расчет среднего и скользящего среднего...........232
Использование функции RANKX......................235
Изменение гранулярности вычисления...............243
Заключение.........................................247
Глава 8 Логика операций со временем..........................249
Введение в логику операций со временем.............249
Автоматические дата и время в Power BI...........250
Автоматические столбцы с датами в Power Pivot для Excel.251
Содержание 7
Шаблон таблицы дат в Power Pivot для Excel.........251
Создание таблицы дат.................................253
Использование функций CALENDAR и CALENDARAUTO......254
Работа со множественными датами....................257
Поддержка множественных связей с таблицей дат......257
Поддержка нескольких таблиц дат....................259
Знакомство с базовыми вычислениями в работе со временем ...260
Пометка календарей как таблиц дат..................265
Знакомство с базовыми функциями логики операций
со временем..........................................266
Нарастающие итоги с начала года, квартала, месяца..268
Сравнение временных интервалов.....................270
Сочетание функций логики операций со временем......273
Расчет разницы по сравнению с предыдущим периодом..275
Расчет скользящей годовой суммы....................276
Выбор порядка вложенности функций логики операций
со временем........................................278
Знакомство с полуаддитивными вычислениями............280
Использование функций LASTDATE и LASTNONBLANK......282
Работа с остатками на начало и конец периода.......288
Усовершенствованные методы работы с датой и временем.292
Вычисления нарастающим итогом......................293
Функция DATEADD....................................296
Функции FIRSTDATE, LASTDATE, FIRSTNONBLANK
и LASTNONBLANK.....................................303
Использование детализации с функциями логики
операций со временем...............................305
Работа с пользовательскими календарями...............306
Работа с неделями..................................307
Пользовательские вычисления нарастающим итогом.....309
Заключение...........................................312
Глава 9 Группы вычислений......................................313
Знакомство с группами вычислений.....................313
Создание групп вычислений............................316
Знакомство с группами вычислений.....................322
Применение элемента вычисления.....................325
Очередность применения групп вычислений............334
Включение и исключение мер из элементов вычисления.339
Косвенная рекурсия...................................341
Два основных правила.................................346
Заключение...........................................347
Глава 10 Работа с контекстом фильтра...........................348
Использование функций HASONEVALUE и SELECTEDVALUE....349
Использование функций ISFILTERED и ISCROSSFILTERED...354
Понимание разницы между функциями VALUES и FILTERS...357
8 Содержание
Понимание разницы между ALLEXCEPT и ALL/VALUES....359
Использование функции ALL для предотвращения
преобразования контекста..........................364
Использование функции ISEMPTY.....................366
Привязка данных и функция TREATAS.................368
Фильтры произвольной формы........................372
Заключение........................................379
Глава 11 Работа с иерархиями................................381
Вычисление процентов внутри иерархии..............381
Работа с иерархиями типа родитель/потомок.........386
Заключение........................................398
Глава 12 Работа с таблицами.................................399
Функция CALCULATETABLE............................399
Манипулирование таблицами.........................402
Функция ADDCOLUMNS..............................402
Функция SUMMARIZE...............................405
Функция CROSSJOIN...............................409
Функция UNION...................................411
Функция INTERSECT...............................415
Функция EXCEPT..................................417
Использование таблиц в качестве фильтров..........418
Применение условных конструкций OR..............419
Ограничение расчетов постоянными покупателями
с первого года..................................422
Вычисление новых покупателей....................423
Повторное использование табличных выражений
при помощи функции DETAILROWS...................425
Создание вычисляемых таблиц.......................427
Функция SELECTCOLUMNS...........................427
Создание статических таблиц при помощи функции ROW ....429
Создание статических таблиц при помощи функции
DATATABLE.......................................430
Функция GENERATESERIES..........................431
Заключение........................................432
Глава 13 Создание запросов..................................433
Знакомство с DAX Studio...........................433
Инструкция EVALUATE...............................434
Введение в синтаксис EVALUATE...................434
Использование VAR внутри DEFINE.................435
Использование MEASURE внутри DEFINE.............437
Реализация распространенных шаблонов запросов в DAX.438
Использование функции ROW для проверки мер......439
Функция SUMMARIZE...............................440
Функция SUMMARIZECOLUMNS........................442
Содержание 9
Функция TOPN....................................448
Функции GENERATE и GENERATE ALL.................454
Функция ISONORAFTER.............................457
Функция ADDMISSINGITEMS.........................460
Функция TOPNSKIP................................461
Функция GROUPBY.................................461
Функции NATURALINNERJOIN и NATURALLEFTOUTERJOIN...464
Функция SUBSTITUTEWITHINDEX.....................466
Функция SAMPLE..................................468
Автоматическая проверка существования данных
в запросах DAX.....................................469
Заключение........................................476
Глава 14 Продвинутые концепции языка DAX....................478
Знакомство с расширенными таблицами...............478
Функция RELATED.................................483
Использование функции RELATED в вычисляемых
столбцах........................................484
Разница между фильтрами по таблице и фильтрами
по столбцу.........................................486
Использование табличных фильтров в мерах........489
Введение в активные связи.......................492
Разница между расширением таблиц и фильтрацией..495
Преобразование контекста в расширенных таблицах.497
Функция ALLSELECTED и неявные контексты фильтра...498
Знакомство с неявными контекстами фильтра.......499
ALLSELECTED возвращает строки из итераций.......503
Применение функции ALLSELECTED без параметров...506
Функции группы ALL*...............................506
Функция ALL.....................................508
Функция ALLEXCEPT...............................509
Функция ALLNOBLANKROW...........................509
Функция ALLSELECTED.............................509
Функция ALLCROSSFILTERED........................509
Использование привязки данных.....................510
Заключение........................................512
Глава 15 Углубленное изучение связей........................514
Реализация вычисляемых физических связей..........514
Создание связей по нескольким столбцам..........514
Реализация связей на основе диапазонов..........517
Циклические зависимости в вычисляемых физических
связях..........................................520
Реализация виртуальных связей.....................523
Распространение фильтров в DAX..................524
Распространение фильтра с использованием функции
TREATAS.........................................526
10 Содержание
Распространение фильтра с использованием функции
INTERSECT.........................................527
Распространение фильтра с использованием функции
FILTER............................................528
Динамическая сегментация с использованием
виртуальных связей.................................529
Реализация физических связей в DAX...................533
Использование двунаправленной кросс-фильтрации.......536
Связи типа «один ко многим»..........................538
Связи типа «один к одному»...........................539
Связи типа «многие ко многим»........................540
Реализация связи «многие ко многим» через таблицу-мост ..540
Реализация связи «многие ко многим» через общее
измерение..........................................546
Реализация связи «многие ко многим» через слабые связи ...551
Выбор правильного типа для связи.....................553
Управление гранулярностью............................555
Возникновение неоднозначностей в связях..............559
Появление неоднозначностей в активных связях......561
Устранение неоднозначностей в неактивных связях...563
Заключение...........................................565
Глава 16 Вычисления повышенной сложности в DAX.................567
Подсчет количества рабочих дней между двумя датами...567
Данные о продажах и бюджетировании в одном отчете....575
Расчет сопоставимых продаж по магазинам..............578
Нумерация последовательности событий.................585
Вычисление продаж по предыдущему году до определенной
даты............................................588
Заключение...........................................593
Глава 17 Движки DAX............................................594
Знакомство с архитектурой движков DAX................594
Введение в движок формул..........................596
Введение в движок хранилища данных................596
Движок хранилища данных VertiPaq..................597
Движок хранилища данных DirectQuery...............598
Процедура обновления данных.......................599
Принципы работы движка хранилища данных VertiPaq.....600
Введение в столбчатые базы данных.................600
Сжатие данных движком VertiPaq....................603
Сегментация и секционирование.....................613
Использование представлений динамического
управления.........................................614
Использование связей в движке VertiPaq...............617
Материализация.......................................620
Агрегирование........................................623
Содержание 11
Выбор аппаратного обеспечения для VertiPaq.........625
Возможность выбора аппаратного обеспечения.......626
Приоритеты при выборе аппаратного обеспечения....626
Модель центрального процессора...................627
Быстродействие памяти............................628
Количество ядер процессора.......................628
Объем памяти.....................................629
Дисковый ввод/вывод и постраничная подкачка......630
Заключение.........................................630
Глава 18 Оптимизация движка VertiPaq.........................632
Сбор информации о модели данных....................632
Денормализация.....................................637
Кратность столбцов.................................645
Работа с датой и временем..........................646
Вычисляемые столбцы................................649
Оптимизация сложных фильтров при помощи булевых
вычисляемых столбцов.............................652
Обработка вычисляемых столбцов...................653
Выбор столбцов для хранения........................654
Оптимизация хранения столбцов......................657
Оптимизация при помощи разделения столбцов.......657
Оптимизация столбцов с высокой кратностью........658
Отключение иерархий атрибутов....................659
Оптимизация атрибутов детализации................659
Управление агрегированием VertiPaq.................660
Заключение.........................................663
Глава 19 Анализ планов выполнения запросов DAX...............664
Перехват запросов DAX..............................664
Введение в планы выполнения запросов...............667
Создание плана выполнения запроса................668
Логический план выполнения запроса...............669
Физический план выполнения запроса...............670
Запросы движка хранилища данных..................671
Сбор информации для оптимизации....................672
Использование DAX Studio.........................673
Использование SQL Server Profiler................676
Чтение запросов движка хранилища VertiPaq..........680
Введение в синтаксис xmSQL.......................681
Время сканирования...............................689
Внутренние события DISTINCTCOUNT.................691
Параллелизм и кеш данных.........................692
Кеш движка VertiPaq..............................694
Функция обратного вызова CallbackDatalD..........696
Чтение запросов движка хранилища DirectQuery.......702
Анализ составных моделей данных..................703
12 Содержание
Использование агрегатов в модели данных.........704
Чтение планов выполнения запросов.................706
Заключение........................................713
Глава 20 Оптимизация в DAX..................................715
Выбор стратегии оптимизации.......................716
Выделение выражения DAX для оптимизации.........716
Создание проверочного запроса...................719
Анализ времени выполнения запроса и информации
из плана........................................723
Поиск узких мест в движке формул и движке хранилища
данных..........................................726
Внесение изменений и повторные запуски тестовых
запросов........................................727
Оптимизация узких мест в выражениях DAX...........727
Оптимизация условий фильтрации..................728
Оптимизация преобразования контекста............732
Оптимизация условных выражений IF...............739
Снижение влияния функции CallbackDatalD
на производительность...........................751
Оптимизация вложенных итераторов................754
Отказ от использования табличных фильтров с функцией
DISTINCTCOUNT..............................761
Уход от множественных вычислений путем
использования переменных........................766
Заключение........................................771
Предметный указатель........................................772
Рецензия
Эту книгу можно смело назвать «Библией DAX». На сегодняшний день это са-
мое подробное и глубокое описание практически всех имеющихся в языке DAX
функций и нюансов их применения.
Авторы данного шедевра - Альберто Феррари и Марко Руссо - одни из са-
мых (если не самые) уважаемые и признанные эксперты в этой теме. Их сайт
www.sqlbi.com - это кладезь информации для любого аналитика, а без их про-
грамм (DAX Studio, Power Pivot Utilities и др.) я уже не могу представить себе
полноценную работу с данными в реальных бизнес-задачах.
Со всей ответственностью могу утверждать, что эта книга - однозначный
must have для любого аналитика, работающего с Power BI, или продвинутого
пользователя Microsoft Excel.
У меня, признаюсь, эта книга в англоязычном варианте с Amazon (еще пер-
вое издание!) уже несколько лет «живет» на полке рядом с рабочим столом и не
раз выручала меня в работе и подготовке тренингов.
Очень рад, что рядом с ней теперь будет стоять ее русскоязычный брат-
близнец.
Николай Павлов,
Microsoft Certified Trainer, Microsoft Most Valuable Professional (MVP),
автор проекта «Планета Эксел» (www.pLanetaexceL.ru)
Об авторах
Марко Руссо и Альберто Феррари являются основа-
телями сайта sqLbi.com, на котором регулярно публику-
ют статьи по Microsoft Power BI, Power Pivot, DAX и SQL
Server Analysis Services. Они работают c DAX с момента
появления первой бета-версии Power Pivot в 2009 году,
и за это время сайт sqLbi.com стал одним из главных по-
ставщиков статей и обучающих материалов по DAX. Их
семинары, как очные, так и в удаленном режиме, явля-
ются основным источником вдохновения и обучения
для энтузиастов DAX.
Марко и Альберто проводят консультации и обучение
в области бизнес-аналитики (BI) с использованием тех-
нологий от Microsoft. За время своей практики они на-
писали несколько книг и статей по Power BI, DAX и Ana-
lysis Services. Также они обеспечивают сообщество DAX
постоянной поддержкой в виде новых материалов для
сайтов daxpatterns.com, daxformatter.com и dax.guide.
Кроме того, Марко и Альберто регулярно выступают на крупнейших меж-
дународных конференциях, включая Microsoft Ignite, PASS Summit и SQLBits.
Связаться с Марко можно по электронной почте marco.russo@sqLbi.com, а с Аль-
берто - aLberto.ferrari@sqLbi.com.
От команды разработчиков
Вы можете не знать наших имен. Мы проводим дни за написанием кода для
программ, которые вы ежедневно используете в своей работе. Мы - часть
команды разработчиков Power BI, SQL Server Analysis Services и... да, мы при-
ложили руку к созданию языка DAX и движка VertiPaq.
Язык, который вы собираетесь изучать, читая эту книгу, является нашим де-
тищем. Мы провели не один год, работая над ним, улучшая движок и находя
способы для ускорения оптимизатора в попытке превратить DAX в простой
и лаконичный язык, призванный значительно облегчить жизнь и повысить
эффективность труда аналитиков данных.
Но позвольте, это ведь предисловие к книге, так что больше ни слова о нас!
Почему же мы пишем вводное слово к изданию Марко и Альберто - парней из
SQLBI? Хотя бы потому, что при поиске информации по DAX в сети новички
постоянно выходят на их статьи. Они начинают читать их, увлекаются языком
и в конечном счете, мы надеемся, проникаются уважением к результатам на-
шего тяжелого труда. Мы познакомились с Марко и Альберто довольно дав-
но и сразу отметили их глубочайшие познания в области SQL Server Analysis
Services. И они были в числе первопроходцев нового языка DAX, изучали его
и старались применить на практике.
Их статьи, заметки и посты в блогах стали источником познания для многих
тысяч людей. Мы пишем код, но не так много времени уделяем обучению раз-
работчиков тому, как им пользоваться. А Марко и Альберто как раз из числа
тех, кто распространяет знания о DAX по миру.
Книги этих парней являются мировыми бестселлерами в данной области,
а написание подробного руководства по DAX ознаменовало собой историче-
скую веху в популяризации языка, который мы сотворили и к которому питаем
самые нежные чувства. Мы пишем код, они пишут книги, а вы изучаете DAX,
привнося в свой бизнес невиданную аналитическую мощь. Вместе же мы дела-
ем общее дело - извлекаем максимум аналитической информации из данных.
И это здорово!
Мариус Думитру (Marius Dumitru),
руководитель отдела разработки Power BI
Кристиан Петкулеску (Cristian Petculescu),
главный разработчик Power BI
Джеффри Ванг (Jeffrey Wang),
управляющий отдела разработки ПО
Кристиан Уэйд (Christian Wade),
старший руководитель проекта
Предисловие
ко второму изданию
Когда мы задумались о том, что пришло время обновить книгу, мы посчита-
ли, что сделать это будет легко: в конце концов, в языке DAX за это время
произошло не так много изменений, а теоретическая ценность первого изда-
ния не была утрачена. Мы полагали, что ограничимся лишь заменой рисунков
с Excel на Power BI и добавим что-то по мелочи тут и там. Как же мы ошибались!
Приступив к обновлению первой главы, мы очень быстро поняли, что хотим
переписать в ней почти все. И так на протяжении всей книги. Так что вы дер-
жите в руках не просто второе издание, а совершенно новую книгу.
И причина таких серьезных обновлений отнюдь не в том, что за это время
как-то кардинально изменился язык или описываемые в книге инструменты.
Скорее, мы как авторы и преподаватели изменились - надеемся, в лучшую сто-
рону. Мы научили языку DAX тысячи людей по всему миру, неустанно работа-
ли со своими студентами и старались максимально доходчиво объяснять им
самые сложные темы. В конечном счете мы нашли совершенно новый способ
донесения до читателя информации о любимом нами языке.
Мы расширили количество примеров в этом издании, чтобы показать, как
работает на практике то, что вы сначала изучаете в теории. При этом мы стара-
лись максимально упростить примеры без ущерба для полноты описываемой
ситуации. Мы боролись с редактором за возможность увеличить количество
страниц в книге, чтобы она могла вместить все темы, которые мы собирались
осветить. Но мы не изменили главный посыл книги, состоящий в том, что вам
не нужно владеть языком DAX, чтобы ее читать, хотя она и не предназначена
для тех, кому просто нужно решить пару задачек на DAX. Скорее, эта книга для
людей, желающих в полной мере овладеть искусством программирования на
DAX и познать весь его потенциал и сложность.
Если вы действительно хотите использовать всю мощь языка DAX, то долж-
ны приготовиться к длительному путешествию с чтением этой книги от корки
до корки и возвращением к ней с целью отыскать то, что ускользнуло от вас
при первом прочтении.
Предисловие
к первому изданию
В нашем авторском активе немало материалов, посвященных языку DAX.
Это и книги по Power Pivot и табличной модели SQL Server Analysis Services
(SSAS Tabular), и посты в блогах, и статьи, и экспертные доклады, и, наконец,
книга, посвященная шаблонам (patterns) в DAX. Так зачем нам было писать
(а вам, надеемся, читать) еще одну книгу по DAX? Неужели об этом языке так
много можно узнать? Мы, разумеется, считаем, что да.
Первое, что редактор стремится выведать у переводчика в момент начала
работы над новой книгой, - это предполагаемое количество страниц. И это не
праздный интерес - на объем книги завязана и цена, и весь производственный
процесс, включая распределение ресурсов издательства, и прочее. Практиче-
ски все, что связано с книгой, так или иначе зависит от количества страниц
в ней. Нас как авторов это немало расстраивает. Всякий раз, когда мы садились
писать книгу, мы должны были выделять приличное место для описания про-
граммных продуктов, будь то Power Pivot для Microsoft Excel или SSAS Tabular,
и только затем переходить к самому языку DAX. И каждый раз мы оставались
недовольны тем, что нам вновь не удалось рассказать о DAX в объеме, в кото-
ром планировали. В конце концов, не писать же книгу по Power Pivot объемом
в тысячу страниц - такая книга на полке магазина напугает кого угодно.
Так что нам приходилось раз за разом писать о SSAS Tabular и Power Pivot,
а проект книги по DAX продолжал пылиться в ящике стола. Но однажды мы
открыли этот ящик и решили не думать о том, что включать в новую книгу, -
она должна была быть посвящена DAX целиком и полностью. Результат этого
решения вы держите в руках.
Здесь вы не прочитаете о том, как создать вычисляемый столбец или ка-
кое диалоговое окно использовать для установки того или иного свойства.
Эта книга - не пошаговое руководство по Microsoft Visual Studio, Power BI или
Power Pivot для Excel. В ней вы сможете с головой погрузиться в мир DAX - на-
чиная с самых основ и заканчивая техническими нюансами, позволяющими
оптимизировать код и модель.
В процессе написания мы полюбили каждую страницу нашей книги. Мы
столько раз ее перечитывали, что буквально выучили наизусть. При этом мы
добавляли новый контент всякий раз, когда считали это уместным, не боясь
превысить лимит на объем книги, и ничего не сокращали только для того, что-
бы остаться в рамках дозволенного. Одновременно мы все больше узнавали
о DAX и наслаждались своими открытиями.
Но есть еще один вопрос: зачем вам вообще читать руководство по DAX?
Признайтесь, вы подумали так, впервые попробовав поработать в Power Pi-
vot или Power BI! И вы не одиноки. В свой первый раз мы подумали точно так
Предисловие к первому изданию 21
же. DAX предельно прост! Он очень похож на Excel! Более того, обладая опы-
том работы с одним языком программирования или запросов, вы наверняка
привыкли изучать другие языки, просто глядя на примеры и сопоставляя его
синтаксис с уже знакомыми вам шаблонами. Мы сами допустили эту ошибку
и не хотим, чтобы через это прошли и вы.
DAX - очень мощный язык, который используется во все большем количест-
ве аналитических инструментов. Потенциал его велик, но некоторые его кон-
цепции непросто понять, идя в своих рассуждениях от частного к общему.
Например, изучение контекста вычисления в DAX требует обратного подхо-
да - от общего к частному. Вы начинаете с теории, а после этого обращаетесь
к соответствующим практическим примерам. Именно такой подход, именуе-
мый дедукцией, характерен для этой книги. Мы понимаем, что многим не по
душе подобный метод обучения - они предпочитают идти от практики к тео-
рии, сначала разобравшись с конкретной задачей, а затем подводя под нее
определенные теоретические выводы. Если вы сторонник такого подхода, эта
книга не для вас. Мы уже писали практические книги по DAX, полные при-
меров и без описания того, как работает та или иная формула и почему тот
или иной подход к коду будет более оптимальным. Их вполне можно исполь-
зовать как справочник функций DAX. Цель написания данной книги была со-
вершенно иной. Мы хотели, чтобы вы в полной мере овладели языком DAX. Все
примеры в этой книге демонстрируют определенное поведение, а не решают
конкретные проблемы. Если вы сможете воспользоваться формулами из этой
книги в своей модели, что ж, отлично. Но помните, что это лишь приятное до-
полнение, но никак не основная цель написания примеров. И всегда читайте
описание к примерам, чтобы не угодить в ловушку. С целью обучения мы часто
приводим в них не самые оптимальные способы решения задач.
Мы искренне надеемся, что вам придется по душе наше совместное путе-
шествие в мир DAX и во время чтения книги вы получите не меньшее удоволь-
ствие, чем мы - во время ее написания.
Для кого предназначена эта книга?
Если вы лишь время от времени используете DAX, эта книга, скорее всего, не
для вас. Есть множество книг с простым введением в инструменты, исполь-
зующие DAX, и в сам язык - начиная с самых основ и заканчивая базовыми
понятиями программирования. Мы хорошо осведомлены об этом, поскольку
и сами писали такие книги.
Если же вы настроены на освоение DAX очень серьезно и с далеко идущими
намерениями, эта книга - ваш выбор! При этом вы можете ничего не знать об
этом языке. В этом случае, правда, не надейтесь на усвоение сложных концеп-
ций с первого раза. Мы советуем прочитать книгу от корки до корки, а затем,
по мере приобретения опыта, возвращаться к наиболее сложным главам для
повторного прочтения. Вполне вероятно, что описанные в них техники откро-
ются для вас по-новому.
Язык DAX может быть полезен для людей, занятых в самых разных областях:
пользователям Power BI может понадобиться написать формулы на DAX в своих
22 Предисловие к первому изданию
моделях данных, специалистам по работе в Excel язык DAX может пригодить-
ся в совместном использовании с надстройкой Power Pivot, а профессионалы
в области бизнес-аналитики (business intelligence - BI) могут применять код на
DAX в своих решениях вне зависимости от их масштаба. В этой книге мы попы-
тались представить информацию, которая может оказаться полезной для всех
перечисленных категорий специалистов. При этом некоторые главы (в особен-
ности касающиеся оптимизации работы DAX) могут быть предназначены для
профессионалов в области бизнес-аналитики, поскольку содержат сложную
техническую информацию. Но мы считаем, что пользователям Power BI и Excel
также может быть полезно узнать возможности оптимизации выражений DAX
для достижения максимальной эффективности функционирования модели.
И наконец, мы хотели написать книгу не только для чтения, но и для обуче-
ния. Поначалу мы будем стараться все объяснять максимально простым язы-
ком - с самого нуля. Но с усложнением концепций мы будем постепенно
уходить от простоты и приближаться к реальности. DAX - простой язык, но
использовать его не так легко. Нам потребовалось несколько лет, чтобы в пол-
ной мере освоить все его премудрости. Не ожидайте, что вы все это усвоите за
несколько дней беззаботного чтения. Эта книга потребует от вас максималь-
ной концентрации внимания. Взамен мы предлагаем вам шанс освоить всю
глубину DAX и стать настоящим экспертом в этой области.
Как мы представляем себе нашего читателя?
Мы предполагаем, что наш читатель обладает базовыми знаниями в области
Power BI и имеет представление об анализе данных. Если у вас есть опыт ис-
пользования языка DAX, тем лучше для вас - быстрее прочитаете первые главы.
Но в целом для чтения книги навыки работы с этим языком не обязательны.
В книге встречаются фрагменты кода на MDX и SQL, но вам не нужно знать
эти языки - они приводятся здесь лишь для сравнения способов написания
выражений. Если вы не поймете, что написано в этих фрагментах кода, ничего
страшного. Значит, вам это не нужно.
В наиболее сложных главах книги мы затронем вопросы параллелизма, до-
ступа к памяти, использования центрального процессора и другие сложные
темы, с которыми далеко не все должны быть знакомы. Опытные разработ-
чики почувствуют себя в этих главах в своей тарелке, а пользователи Power BI
и Excel могут быть немного напуганы. Но без этих технических нюансов просто
не обойтись при описании темы оптимизации кода на DAX. И хотя эти сложные
главы больше предназначены для опытных разработчиков в области бизнес-
аналитики, чем для пользователей Power BI и Excel, мы уверены, что пользу от
их чтения получат все без исключения.
Структура книги
Эта книга построена так, что темы в ней располагаются по нарастающей - от
простых к сложным. В каждой следующей главе предполагается, что вы полно-
Предисловие к первому изданию 23
стью усвоили материал предыдущей - мы старались практически не повторять
то, о чем уже писали ранее. Именно поэтому мы настоятельно советуем читать
книгу от начала до конца, не прыгая от главы к главе.
Будучи прочитанной, книга может превратиться в полезный справочник по
DAX. Например, если вы захотите вспомнить, как работает функция ALLSEL-
ECTED, то можете открыть конкретный раздел и освежить память. Но обра-
щаться к главам без их предварительного чтения мы не советуем - вы просто
рискуете не до конца понять описываемую концепцию.
Представляем вам описание глав этой книги:
глава 1 содержит краткое введение в DAX с несколькими разделами,
предназначенными для тех, кто уже знаком с другими языками, такими
как SQL, MDX или язык формул Excel. В этой главе мы не представляем
какие-то новые концепции, а описываем базовые отличия между DAX
и другими языками программирования, которые может знать читатель;
в главе 2 мы познакомим вас с языком DAX. Мы пройдемся по основным
терминам вроде вычисляемых столбцов и мер, а также расскажем о функ-
циях для перехвата ошибок в выражениях. Кроме того, здесь будут пере-
числены все основные функции языка;
глава 3 будет посвящена основным табличным функциям. Многие функ-
ции DAX работают с таблицами и возвращают таблицы в качестве резуль-
тата. Здесь мы опишем работу большинства табличных функций, а в гла-
вах 12 и 13 расскажем о более сложных функциях для работы с таблицами;
в главе 4 мы впервые затронем тему контекстов вычисления. Данная кон-
цепция является основополагающей в DAX, так что эта глава и следую-
щая, возможно, являются наиболее значимыми в этой книге;
в главе 5 мы ограничимся всего двумя функциями - CALCULATE и CALCU-
LATETABLE. Это наиболее важные функции в DAX, и к их изучению можно
приступать только после усвоения концепции контекстов вычисления;
глава 6 будет посвящена переменным. Мы используем переменные
в примерах на протяжении всей книги, но именно в этой главе позна-
комим вас с их синтаксисом и объясним назначение. Вы сможете воз-
вращаться к этой части книги, когда будете встречаться с переменными
в последующих главах;
в главе 7 мы обсудим сладкую парочку из итерационных функций и функ-
ции CALCULATE, союз которых поистине был заключен на небесах. Ис-
пользование итерационных функций совместно с техникой преобразо-
вания контекста позволит вам извлечь максимум пользы из языка DAX.
В этой главе мы продемонстрируем несколько примеров, позволяющих
реализовать весь потенциал данной связки;
в главе 8 мы подробно остановимся на функциях логики операций со вре-
менем. Нарастающие итоги с начала года и месяца, показатели преды-
дущих лет, недельные интервалы и нестандартные календари - все это
будет рассмотрено в этой части книги;
глава 9 будет посвящена относительно новой особенности языка DAX -
группам вычислений. Это очень мощный инструмент моделирования
данных. В данной главе мы рассмотрим создание и использование групп
24 Предисловие к первому изданию
вычислений, познакомим вас с базовыми концепциями и представим не-
сколько примеров;
в главе 10 мы более подробно поговорим об особенностях использования
контекста фильтра, привязке данных и других полезных средствах для
расчета сложных формул;
в главе 11 вы научитесь проводить вычисления над иерархиями и рабо-
тать со структурами родитель/потомок в DAX;
главы 12 и 13 посвящены продвинутым табличным функциям, полезным
как при написании запросов, так и при проведении сложных вычислений;
прочитав главу 14, вы продвинетесь на шаг вперед в понимании контекс-
тов вычисления, а заодно узнаете об использовании сложных функций
ALLSELECTED и KEEPFILTERS и концепции расширенных таблиц (expand-
ed tables). Это глава для опытных пользователей, раскрывающая секреты
сложных выражений DAX;
глава 15 посвящена управлению связями в DAX. Благодаря этому языку
в модели данных можно создавать связи всех возможных типов. Здесь
мы также приведем описание всех типов связей, которые допустимо ис-
пользовать в моделях;
в главе 16 мы приведем несколько примеров сложных расчетов с исполь-
зованием DAX. Это будет последняя глава, посвященная непосредственно
языку, и в ней мы расскажем о разных решениях и новых идеях;
в главе 17 мы приведем детальное описание движка (engine) VertiPaq, яв-
ляющегося самым распространенным движком хранилища данных (stor-
age engine) в моделях с использованием DAX. Понимание особенностей
движка позволит вам извлекать максимум потенциала из языка;
в главе 18 мы воспользуемся знаниями, полученными в предыдущей
главе, чтобы продемонстрировать возможные способы оптимизации на
уровне модели данных. Вы узнаете, как снизить количество уникальных
значений в столбце, выбрать столбцы для импорта и повысить эффек-
тивность системы за счет выбора правильных типов связей и снижения
количества используемой памяти в DAX;
в главе 19 вы научитесь читать планы выполнения запросов (query plan)
и замерять производительность выражений на DAX при помощи DAX
Studio и SQL Server Profiler;
в главе 20 мы покажем вам несколько техник по оптимизации модели
с использованием знаний, полученных в предыдущих главах. Мы про-
демонстрируем разные выражения на DAX, проанализируем их произво-
дительность, а затем представим оптимизированные варианты формул.
Условные обозначения
В этой книге приняты следующие условные обозначения:
жирным помечен текст, который вводите вы;
курсив используется для обозначения новых терминов, а также названия
мер, вычисляемых столбцов, таблиц и баз данных;
Предисловие к первому изданию 25
первые буквы в названиях диалоговых окон, их элементов, а также ко-
манд - прописные. Например, в диалоговом окне Save As... (Сохранить
как...);
названия вкладок на ленте даются ПРОПИСНЫМИ БУКВАМИ;
комбинации нажимаемых клавиш на клавиатуре обозначаются знаком
плюс (+) между названиями клавиш. Например, Ctrl+Alt+Delete означа-
ет, что вы должны одновременно нажать клавиши Ctrl, Alt и Delete.
Сопутствующий контент
Для развития ваших навыков и подкрепления их практикой мы снабдили кни-
гу сопутствующим контентом, который можно скачать по ссылке: Microsoft-
PressStore.com/DefinitiveGuideDAX/downLoads.
Представленный архив содержит:
бэкап базы данных Contoso Retail DW в формате SQL Server, который
вы можете использовать для самостоятельной проверки примеров. Это
стандартная демонстрационная база от Microsoft, которую мы расшири-
ли путем добавления нескольких представлений (view) для облегчения
создания на ее основе модели данных;
файлы в формате Power BI Desktop для всех примеров из этой книги. Каж-
дому рисунку соответствует отдельный файл. Модель данных при этом
практически не меняется, но вы можете использовать эти файлы для са-
мостоятельного выполнения всех шагов, описанных в книге.
ГЛАВА 1
Что такое DAX?
DAX, или выражения анализа данных (Data Analysis expressions), - это язык про-
граммирования в средах Microsoft Power BI, Microsoft Analysis Services и Mic-
rosoft Power Pivot для Excel. Он был создан в 2010 году - с первым выходом
надстройки PowerPivot для Microsoft Excel 2010. Да, тогда название PowerPivot
писалось слитно, а пробел появился лишь через три года. С тех пор язык DAX
постоянно набирал популярность как в среде пользователей Excel, применяю-
щих его для создания моделей данных в Power Pivot, так и в сообществе бизнес-
аналитики, где этот язык используется для проектирования моделей в Power
BI и Analysis Services. DAX присутствует во многих инструментах, которые объ-
единяет один табличный движок (Tabular). Именно поэтому мы будем часто
говорить просто о табличных моделях, подразумевая все инструменты сразу.
DAX - простой язык. При этом он существенно отличается от других языков
программирования, так что на освоение его новых концепций у вас может уйти
немало времени. По опыту преподавания DAX тысячам студентов мы можем
заметить, что с основами языка проблем обычно не возникает - можно при-
ступить к его использованию уже через несколько часов после начала обучения.
Что касается продвинутых тем вроде контекста вычисления, итерационных
функций и преобразования контекста, они могут вызвать серьезные затрудне-
ния. Но не сдавайтесь! Наберитесь терпения. Когда вы вникнете в эти концеп-
ции, вы поймете всю простоту языка DAX. К нему нужно просто привыкнуть.
В начале первой главы мы расскажем о том, что представляет из себя модель
данных с таблицами и связями. Мы советуем прочитать эти страницы всем, не-
зависимо от опыта, чтобы понять, какую терминологию мы будем использовать
на протяжении всей книги, описывая таблицы, модели и разные типы связей.
В следующих разделах мы дадим полезные советы читателям, имеющим
определенные навыки работы с другими языками, такими как SQL, MDX и язык
формул Microsoft Excel. Каждому из этих языков мы отведем отдельный раздел,
чтобы читатели могли сравнить их с DAX. Если вам это поможет, попробуйте
смотреть на DAX через призму этих языков. Прочитав заключительный раздел
«DAX для пользователей Power В1», переходите к следующей главе, с которой,
по сути, и начинается наше путешествие в мир DAX.
Введение в модель данных
Язык DAX предназначен для расчета бизнес-показателей посредством формул
в модели данных. Некоторые читатели могут знать, что из себя представляет
модель данных. Для остальных мы сделаем разъяснение.
ГЛАВА 1 Что такое DАХ? 27
Модель данных (data model) - это набор таблиц, объединенных связями.
Все мы знаем, что такое таблица. Это перечисление строк, содержащих ин-
формацию, при этом каждая строка поделена на столбцы. Столбец, в свою
очередь, характеризуется определенным типом данных и содержит единый
фрагмент информации. Обычно мы называем строку в таблице записью. Таб-
личный способ хранения информации очень удобен в плане организации дан-
ных. По сути, таблица сама по себе является моделью данных, пусть и в своей
простейшей форме. А значит, когда мы вводим на лист Excel текст и цифры, мы
создаем модель данных.
Если модель состоит из нескольких таблиц, вполне вероятно, что вам захо-
чется связать их. Связь (relationship) представляет собой объединение двух таб-
лиц. Такие таблицы мы называем связанными (related). Графически связь двух
таблиц обозначается линией между ними. На рис. 1.1 показан пример модели
данных.
ffl Date
а Calendar ¥еаг
3 Calendar Year N'onth
Я Calendar Year Month Mu...
3 Calend ar Yea/ Number
Calendar Year Quarter
3 Calendar Year Quarter Nu...
C Date
31 DateKey
H Day of Wees
И Day of Weei Number
3 Europe Season
ffl Sales
E7 CustomerKey
3 De.ivery Date
El Net Price
3 Order D=rte
£3 Older L ne Number
Order Number
ET ProductKey
3 Quantity
Unit Cost
3 Unit Discount
3 Unit Price
Я Cus*unier
E" Company Name
a Continent
3 CourtyReg.on
И Customer Code
П Customer Type
H CustomeiKey
s Date First Purchase
П Education
И Gender
3 '-louse О vnersnip
3 Nlartal Status
Рис. 1.1 Модель данных, состоящая из шести таблиц
Далее перечислим важные аспекты связей между таблицами:
таблицы, объединенные связью, выполняют разные роли. Одна из них
представляет сторону «один», а вторая - «многие», которые помечены на
схеме данных символами «1» и «*» (звездочка) соответственно. Обратите
внимание на связь между таблицами Product (Товары) и Product Subcate-
gory (Подкатегории товаров) на рис. 1.1. Одной подкатегории может при-
надлежать несколько товаров, тогда как один товар может представлять
только одну подкатегорию. Таким образом, таблица Product Subcategory
являет собой сторону «один» в этой связи, a Product - сторону «многие»;
существуют особые виды связей. Это связи «один к одному» (1:1) и слабые
связи (weak relationships). В связи «один к одному» обе таблицы представ-
28 ГЛАВА 1 ЧтотакоеЭАХ?
ляют собой сторону «один», тогда как в слабых связях они могут нахо-
диться на стороне «многие». Такие особые виды связей не слишком рас-
пространены, и мы подробно обсудим их в главе 15;
столбцы, использующиеся для объединения таблиц и обычно имеющие
одинаковые имена, называются ключами (keys) связи. При этом в клю-
чевом столбце таблицы, представляющей сторону «один», должны на-
ходиться уникальные значения без пропусков. В то же время в таблице
«многие» значения в ключевом столбце могут повторяться, и чаще всего
это так и есть. Столбец, содержащий исключительно уникальные значе-
ния, называется ключом таблицы;
связи могут образовывать цепочки. Каждый товар принадлежит какой-
то подкатегории, которая, в свою очередь, представляет определенную
категорию товаров. Следовательно, каждый товар можно отнести к кон-
кретной категории. Но чтобы получить ее название, необходимо пройти
к ней от товаров через цепочку из двух связей. В модели данных, пред-
ставленной на рис. 1.1, присутствует цепочка связей, состоящая сразу из
трех звеньев, - от таблицы Sales к Product Category;
стрелкой посередине связи обозначается направление перекрестной фильт-
рации (cross filter direction). По рис. 1.1 видно, что связь между таблица-
ми Sales и Product отмечена стрелками в обоих направлениях, тогда как
остальные связи в модели - однонаправленные. Стрелкой обозначается
направление распространения фильтра по этой связи. Поскольку выбор
правильных направлений для фильтров является одним из важнейших
навыков в работе с моделью данных, мы подробно обсудим эту тему
в следующих главах. Обычно мы не советуем пользователям включать
двунаправленную фильтрацию (bidirectional filtering) в связях, как сказано
в главе 15. В этой модели такая связь присутствует исключительно в об-
разовательных целях.
Введение в направление связи
Каждая связь может характеризоваться однонаправленной или двунаправлен-
ной перекрестной фильтрацией (кросс-фильтрацией). Фильтр всегда распро-
страняется от стороны «один» к стороне «многие». Если же связь двунаправ-
ленная, то есть обозначена на схеме двумя разнонаправленными стрелками,
фильтр по ней может распространяться и в обратном направлении.
Приведем пример, который поможет вам лучше разобраться в этом. Если
построить отчет на основе модели данных, представленной на рис. 1.1, вынеся
годы (Calendar Year) на строки, а количество проданных товаров (Quantity) и ко-
личество наименований товаров (Count of Product Name) - в область значений,
мы увидим вывод, показанный на рис. 1.2.
Столбец Calendar Year принадлежит таблице дат (Date). А поскольку таблица
Date представляет сторону «один» в связи с продажами (Sales), движок отфильт-
рует таблицу Sales по годам. Именно поэтому количество проданных товаров
в отчете показано с разбивкой по годам.
С таблицей товаров (Products) дело обстоит несколько иначе. Фильтрация
в этом случае работает корректно, поскольку связь, объединяющая таблицы
ГЛ АВА 1 Что такое DAX? 29
Sales и Product, является двунаправленной. Выводя в отчет количество на-
именований товаров, мы фактически получаем ежегодно продаваемый ассор-
тимент посредством фильтра, распространенного от таблицы Sales к Product.
Если бы связь между Sales и Product была однонаправленной, результат был бы
иным, и мы расскажем об этом в следующих разделах.
Calendar Year Quantity Count of Product Name
CY 2007 44,310 1258
CY 2008 40,226 1478
CY 2009 55,644 1513
Total 140,180 2517
Рис. 1.2 Отчет демонстрирует эффект фильтрации по нескольким таблицам
Если модифицировать отчет, вынеся на строки цвет товаров (Color) и доба-
вив в область значений количество дат (Count of Date), результат также поменя-
ется. Вывод этого отчета можно видеть на рис. 1.3.
Color Quantity Count of Product Name Count of Date
Azure 546 14 2556
Black 33,618 602 2556
Blue 8,859 200 2556
Brown 2,570 77 2556
Gold 1,393 50 2556
Green 3,020 74 2556
Grey 11,900 283 2556
Orange 2,203 55 2556
Pink 4,921 84 2556
Purple 102 6 2556
Red 8,079 99 2556
Silver 27,551 417 2556
Silver Grey 959 14 2556
Transparent 1,251 1 2556
White 30,543 505 2556
Yellow 2,665 36 2556
Total 140,180 2517 2556
Рис. 1.3 В отчете показано, что в отсутствие двунаправленной связи
фильтрация таблиц не выполняется
Столбец Color, вынесенный на строки отчета, принадлежит таблице Product.
А поскольку Product представляет сторону «один» в связи с таблицей Sales, зна-
чения в столбце Quantity посчитались корректно. Поле Count of Product Name
правильно отфильтровалось, поскольку его источником является таблица
Product, вынесенная на строки. Неожиданные значения мы видим в столбце
30 ГЛАВА 1 Что такое DAX?
Count of Date. Здесь для всех строк указано одно и то же число, представляющее
общее количество строк в таблице Date.
Фильтр, идущий от столбца Color, не распространяется на Date, поскольку
связь между таблицами Date и Sales - однонаправленная. Таким образом, не-
смотря на то что фильтр в таблице Sales активен, он не может распространить-
ся на таблицу Date по причине однонаправленности связи.
Если сделать связь между таблицами Date и Sales двунаправленной, резуль-
тат будет иным, что видно по рис. 1.4.
Color Quantity Count of Product Name Count of Date
Azure 546 14 41
Black 33,618 602 811
Blue 8,859 200 408
Brown 2,570 77 169
Gold 1,393 50 106
Green 3,020 74 188
Grey 11,900 283 499
Orange 2,203 55 142
Pink 4,921 84 226
Purple 102 6 11
Red 8,079 99 286
Silver 27,551 417 722
Silver Grey 959 14 63
Transparent 1,251 1 14
White 30,543 505 750
Yellow 2,665 36 110
Total 140,180 2517 2556
Рис. 1.4 Если активировать двунаправленную фильтрацию,
таблица Date будет отфильтрована по столбцу Color
Теперь в столбце отображается количество дней, когда был продан как ми-
нимум один товар выбранного цвета. На первый взгляд кажется, что стоит все
связи в модели сделать двунаправленными, чтобы позволить фильтрам рас-
пространять свое действие во все стороны и доставать правильные данные.
Как вы узнаете из этой книги, такой подход почти никогда не будет оправдан.
Вы должны выбирать направление фильтрации для связей в зависимости от
модели, с которой работаете. Если вы последуете нашим советам, то откаже-
тесь от применения двунаправленной фильтрации там, где это возможно.
DAX для пользователей Excel
Велика вероятность, что вы знакомы с языком формул Excel, который немного
напоминает DAX. В конце концов, корни DAX лежат в Power Pivot для Excel,
ГЛАВА 1 Что такое DAX? 31
и разработчики сделали все, чтобы эти языки были похожими. Эти сходства об-
легчат вам переход на DAX. Но не стоит забывать и о различиях в этих языках.
Ячейки против таблиц
В Excel все вычисления производятся над ячейками, которые обладают коор-
динатами. Так что мы можем написать формулу вроде этой:
= (А1 * 1.25) - В2
В DAX концепция ячеек с координатами просто отсутствует. Этот язык ра-
ботает с таблицами и столбцами, а не с отдельными ячейками. Как следствие
выражения DAX обращаются именно к таблицам и столбцам, что сказывается
на синтаксисе языка. Однако концепция таблиц и столбцов не нова для Excel.
Если выделить диапазон и воспользоваться пунктом Format as Table (Форма-
тировать как таблицу), можно писать формулы в Excel, обращающиеся непо-
средственно к таблицам и столбцам. На рис. 1.5 в столбце SalesAmount вычис-
ляется выражение, ссылающееся на столбцы в той же таблице, а не на ячейки
в рабочей книге.
► : fx =[^ProddctQuantityI*[@ProajctPrice] 1
4 А LU О co F G
1 2
3 OrderDate Q ProductNam? Q ProductQuantityQ Prod uclPr ice |S SalesAmount В
4 07/01/01 Meuntain-100 Black 42 1 2,024.99 2.024.99
5 07/01/01 Rcad-450 Red, 52 1 874 79 874.79
6 07/01/CI Road-45C Red 52 3 874.79 2,624.33
7 07/01/01 Rcad-450 Red, 52 1 874.79 874.79
8 07/01/CI SoorMOC Helmet, Black 2 20.19 4C.37
9 C7/01/C1 Sport-IOG Helmet, Red 1 20.19 20.15
10 07/DI/CI Snort-lOCHe'met, Black 4 20,19 80 75
11 07/01/CI Ll Road Frame - Red. 44 2 183.94 367.38
12 07/01/01 Road-450 Red 52 2 874.79 1,749.59
’3 07/CI/CI Sport-lCC Helmet, Red 1 20.19 20.19
14 07/01/01 Rcad-450 Red. 52 1 874.79 874.79
15 07/01/CI LL Road Frame - Rod 44 1 183.94 18394
Ч 07/01/01 Road-450 Red, 52 8 374.79 _6 9ЭЕ.35
Рис. 1.5 В формулах Excel можно ссылаться на столбцы таблицы
В Excel можно обращаться к столбцам, используя следующий формат:
[@ColumnName]. Здесь ColumnName - название столбца, а символ @ говорит
о том, что необходимо взять значение из текущей строки. Синтаксис получил-
ся не самым интуитивно понятным, но мы обычно и не пишем такие выраже-
ния вручную. Они появляются автоматически при нажатии на ячейку: Excel
сам заботится о вставке нужного кода.
Таким образом, в Excel есть два разных вида вычислений. Можно использо-
вать стандартное обращение к ячейкам - в этом случае формула для ячейки
F4 будет выглядеть так: E4*D4. Или же применять ссылки на столбцы внутри
таблицы. Это позволит использовать одинаковые выражения во всех ячейках
32 ГЛАВА 1 Что такое DAX?
столбца, a Excel в своих расчетах будет брать значение из конкретной строки.
В отличие от Excel, DAX работает исключительно с таблицами. Все формулы
должны ссылаться на столбцы внутри таблиц. Например, в DAX предыдущая
формула будет выглядеть так:
Sales[SalesAmount] = Sales[ProductPrice] * Sales[ProductQuantity]
Как видите, каждое название столбца предваряется наименованием соот-
ветствующей таблицы. В Excel мы не указываем названия таблиц, поскольку
там формулы работают внутри одной таблицы. DAX же работает в модели дан-
ных, состоящей из нескольких таблиц. Как следствие мы просто обязаны кон-
кретно указывать таблицы, ведь в разных таблицах могут находиться столбцы
с одинаковыми названиями.
Многие функции DAX работают подобно аналогичным функциям в Excel.
К примеру, функция IF в обоих языках применяется одинаково:
Excel ЕСЛИ ( [@SalesAmount] > 10; 1; 0)
DAX IF ( Sales[SalesAmount] > 10; 1; 0)
Единственным существенным отличием между Excel и DAX является способ
обращения к целому столбцу. В Excel, как мы уже говорили, символ @ в выра-
жении [©ProductQuantity] означает, что необходимо взять значение из текущей
строки. В DAX нет необходимости указывать этот факт явно, поскольку такое
поведение является для языка обычным. В Excel мы можем обратиться ко всем
строкам в столбце, убрав из формулы символ @. Это можно видеть на рис. 1.6.
G4 ’ | : =SUM [SalesAmount] |
А Б С D E F G
7
а Order Date Q ?n*rfuLt1Uame 3 rrsdurt^ant^yH ProduitPriceQ SdlpsArnotirtt { AllSales {3
4 07/01/01 Mounta ^-1СЭ Black, 42 1 2,024 99 2,024.99 [ 47,993.65
5 C7/01/C. Road-450 Red 52 1 £.74.73 874.79 47,993 65
6 j7/01/01 -oad-45C ed 52 3 374.79 2,624 38 47,993 65
7 C7/01/01 Road-45C Red 52 1 S74.79 874 79 47,991 65
Я 07/01/01 Soort-lCC Helmet. В1аск 2 20 19 40 37 7,933 65
9 07/01/01 Sourt 10G Helmet Red 1 20.19 20.19 47,993 66
1С 07/01/01 Sport 10C Helmet B«ac ; 4 20 19 30 75 47, 993 66
и 07/01 ^01 LL Read Frame - Red„ 44 2 1B3.94 367 88 47,993 65
12 07/01/01 Road-45£ Red 52 2 374, 79 1,749.59 47,993 65
13 D7/01/C. Scort-lCC Helmet Red 1 2C.19 20 19 47,993 65
14 07/01/01 Foao-450=ed 52 1 374.79 874.79 47,993 66
15 07/01/C_ LL Read Frame - Red,, 44 1 1B3 94 133 94 4 7,993 66
16 07/01/01 R(iad-456 Red, 52 3 374.79 6,998.35 4 7,933 65
17 07/01'02 Spnrt-lOC Helmet Black 3 20.19 60 56 47,993 65
’0 07/01/0. Sport-ice Helmet, Red 4 20.19 80 75 47,993 65
19 ! OJ 01/01 LL Read Frame - Red, 43 2 -B3 94 367 88 47,993 65
Рис. 1.6 В Excel можно сослаться на весь столбец, опустив символ @ в формуле
Значение столбца AllSales одинаковое для всех строк и равно общему ито-
гу по столбцу SalesAmount. Иными словами, в Excel существует четкое синтак-
сическое разграничение между обращением к ячейке в конкретной строке
и к столбцу в целом.
ГЛАВА 1 Что такое DAX? 33
DAX ведет себя иначе. В этом языке для вычисления столбца AllSales из
рис. 1.6 можно было бы использовать следующую формулу:
AllSales := SUM ( Sales[SalesAmount] )
И здесь нет никаких отличий между извлечением значения из текущей стро-
ки или из всего столбца. DAX понимает, что мы хотим просуммировать все
значения из столбца, поскольку его название передается в качестве аргумента
в агрегирующую функцию (здесь это функция SUM). Таким образом, если Excel
требует явного указания, какие данные извлекать из столбца, DAX решает эту
неоднозначность автоматически. Такая разница в подходах к вычислениям
может приводить в замешательство - по крайней мере, поначалу.
Excel и DAX: два функциональных языка
В чем язык формул Excel и DAX похожи, так это в том, что оба они являются
функциональными языками программирования. Функциональные языки со-
стоят из выражений, в основе которых лежат вызовы функций. В Excel и DAX
не реализованы концепции операторов, циклов и переходов, характерные для
большинства языков программирования. В DAX буквально все является выра-
жениями. Это бывает непросто понять тем, кто приходит из других языков про-
граммирования, а для пользователей Excel, наоборот, должно быть привычно.
Итерационные функции в DAX
Концепция, которая может оказаться для вас в новинку, - это итерационные
функции, или просто итераторы (iterators). В Excel все расчеты выполняются
последовательно, по одному за раз. В предыдущем примере вы видели, что для
того, чтобы рассчитать итог по продажам, мы создали столбец, в котором цена
умножалась на количество. На втором шаге мы подсчитывали сумму по этой
колонке. Получившийся результат впоследствии можно использовать в качест-
ве знаменателя при подсчете, например, доли продаж по каждому товару.
В DAX все это можно сделать за один шаг с помощью итерационных функ-
ций. Итератор делает ровно то, что и должен, исходя из названия, - проходит
по таблице и производит вычисления в каждой строке, одновременно агреги-
руя запрошенное значение.
Таким образом, вычисления из предыдущего примера можно произвести
при помощи одной итерационной функции SUMX:
AllSales :=
SUMX (
Sales;
Sales[ProductQuantity] * Sales[ProductPrice]
)
Такой подход имеет как достоинства, так и недостатки. К достоинствам
можно отнести то, что мы можем производить множество вычислений за один
шаг, не беспокоясь о создании вспомогательных столбцов, функциональность
которых ограничивается лишь промежуточными формулами. Недостатком же
34 ГЛАВА 1 Что такое DAX?
является то, что программирование на DAX менее визуально по сравнению
с формулами Excel. Мы ведь даже не видим столбца с результатом умножения
цены на количество - он существует только во время вычисления.
Как вы узнаете позже, у вас есть возможность создания вычисляемых столб-
цов для хранения подобных промежуточных вычислений. Но делать это не
рекомендуется, поскольку тогда будут задействованы дополнительные ресур-
сы памяти и может пострадать производительность, если вы не используете
режим DirectQuery совместно с агрегациями, о чем мы поговорим в главе 18.
DAX требует изучения теории
Будем откровенны: DAX является не единственным языком программирова-
ния, для использования которого вам понадобится обширная теоретическая
база. Разница лишь в подходе. Признайтесь, вы ведь частенько ищете в интер-
нете сложные формулы и шаблоны, которые помогут вам в решении вашего
собственного сценария. И шансы на то, что вы найдете подходящую формулу
для Excel, достаточно высоки - вам останется лишь адаптировать ее под свои
нужды.
Но в DAX дела обстоят иначе. Вам придется досконально изучить этот язык
и понять, как работает контекст вычисления, чтобы написать работающий код.
Без должной теоретической базы вам может показаться, что DAX производит
свои вычисления каким-то магическим образом или что он выдает цифры, не
имеющие с реальностью ничего общего. Проблема не в DAX, а в том, что вы не
понимаете всех тонкостей его работы.
К счастью, теоретическая база языка DAX ограничивается всего нескольки-
ми концепциями, которые мы опишем в главе 4. Приготовьтесь много учиться.
После освоения этой главы DAX перестанет быть для вас тайной, а мастерство
его использования будет зависеть исключительно от приобретенного опыта.
Помните: знание - всего лишь полдела. И не пытайтесь двигаться дальше, пока
досконально не освоите концепцию контекстов вычисления.
DAX для разработчиков SQL
Если вы знакомы с языком SQL, значит, у вас уже есть опыт работы со мно-
жеством таблиц и связей. В этом плане вы почувствуете себя в DAX как дома.
По сути, вычисления здесь базируются на выполнении запросов к нескольким
таблицам, объединенным связями, и агрегировании значений.
Работа со связями
Первые отличия между SQL и DAX заметны в области организации связей в мо-
дели данных. В SQL можно настроить внешние ключи в таблицах для опреде-
ления связей, но движок никогда не будет использовать эти связи без явного
указания. Например, если у нас есть таблицы Customers и Sales, и столбец Cus-
tomerKey является первичным ключом в Customers и внешним - в Sales, можно
написать следующий запрос:
ГЛАВА 1 Что такое DAX? 35
SELECT
Customers.CustomerName,
SUM ( Sales.SalesAmount ) AS SumOfSales
FROM
Sales
INNER JOIN Customers
ON Sales.CustomerKey = Customers.CustomerKey
GROUP BY
Customers.CustomerName
Хотя мы определили в модели внешние ключи для осуществления связей,
нам все равно необходимо всякий раз явно указывать в запросе условия для
выполнения соединений. Это приводит к увеличению объема запросов, зато
можно каждый раз использовать разные условия для связей, что дает макси-
мум свободы в извлечении данных.
В DAX связи являются составной частью модели данных, и все они - LEFT
OUTER JOIN. А раз так, вам нет необходимости каждый раз указывать их в за-
просе, DAX автоматически будет использовать связи при задействовании объ-
единенных таблиц. Так что на DAX можно переписать предыдущий запрос SQL
следующим образом:
EVALUATE
SUMMARIZECOLUMNS (
Customers[CustomerName];
"SumOfSales", SUM ( Sales[SalesAmount] )
)
Поскольку движок знает о созданной связи между таблицами Sales и Cus-
tomers, объединение таблиц в запросе происходит автоматически. После этого
функции SUMMARIZECOLUMNS останется выполнить группировку по столб-
цу Customers[CustomerName], причем для этого нет определенного ключевого
слова: функция SUMMARIZECOLUMNS автоматически группирует данные по
выбранным столбцам.
DAX как функциональный язык
SQL - декларативный язык. Вы определяете набор данных, который желаете
извлечь, посредством оператора SELECT, при этом не беспокоясь о том, как
именно движок это сделает.
DAX, напротив, является функциональным языком. В нем каждое выраже-
ние является вызовом функции. При этом параметры функции, в свою очередь,
также могут быть вызовами функций. Анализ всех этих параметров приво-
дит к созданию сложного плана выполнения запроса, который и вычисляется
движком DAX с целью получить результат.
Например, если нам понадобится получить информацию о покупателях, жи-
вущих в Европе, мы можем написать следующий запрос на SQL:
SELECT
Customers.CustomerName,
SUM ( Sales.SalesAmount ) AS SumOfSales
36 ГЛАВА 1 Что такое DAX?
FROM
Sales
INNER JOIN Customers
ON Sales.CustomerKey = Customers.CustomerKey
WHERE
Customers.Continent = 'Europe'
GROUP BY
Customers.CustomerName
В языке DAX мы не объявляем условие в операторе WHERE. Вместо этого
мы используем специальную функцию FILTER для осуществления фильтрации,
как показано ниже:
EVALUATE
SUMMARIZECOLUMNS (
Customers[CustomerName];
FILTER (
Customers;
Customers[Continent] = "Europe11
);
"SumOfSales"; SUM ( Sales[SalesAmount] )
)
Вы видите, как работает функция FILTER: она возвращает только покупателей,
проживающих в Европе, как мы и хотели. Порядок, в котором мы встраиваем
функции в код, и виды функций, которые используем, очень важны как с точки
зрения получения результата, так и в плане производительности запросов. В язы-
ке SQL это тоже важно, хотя там мы больше надеемся на оптимизатор запросов
(query optimizer) при построении наилучшего плана выполнения. В DAX оптими-
затор также занят своими прямыми обязанностями, но на вас как на разработчи-
ке лежит большая ответственность за написание быстро работающего кода.
DAX как язык программирования и язык запросов
В SQL существует четкое разделение между языком запросов и языком про-
граммирования, то есть набором инструкций, используемых для создания
хранимых процедур (stored procedures), представлений (views) и других объек-
тов в базе данных. В каждом диалекте SQL присутствуют свои операторы, при-
званные обогатить язык. Но в DAX не делается четких разграничений между
языком запросов и языком программирования. Множество функций работают
с таблицами и возвращают таблицы в качестве результата. Функция FILTER из
предыдущего кода - лишь один из примеров.
В этом отношении DAX, пожалуй, проще SQL. Изучая его как язык програм-
мирования - а им он изначально и является, - вы узнаете все необходимое для
использования его и в качестве языка запросов.
Подзапросы и условия в DAX и SQL
Одной из мощнейших особенностей языка запросов SQL является возмож-
ность использования подзапросов. В DAX применяется похожая концепция, но
с учетом функциональной направленности языка.
ГЛАВА 1 Что такое DAX? 37
Например, чтобы извлечь информацию о покупателях, сделавших покупки
на сумму более $100, можно написать следующий запрос SQL:
SELECT
CustomerName,
SumOfSales
FROM (
SELECT
Customers.CustomerName,
SUM ( Sales.SalesAmount ) AS SumOfSales
FROM
Sales
INNER JOIN Customers
ON Sales.CustomerKey = Customers.CustomerKey
GROUP BY
Customers.CustomerName
) AS SubQuery
WHERE
SubQuery.SumOfSales > 100
В DAX можно добиться похожего эффекта с использованием вложенных
функций, как показано ниже:
EVALUATE
FILTER (
SUMMARIZECOLUMNS (
Customers[CustomerName];
"SumOfSales", SUM ( Sales[SalesAmount] )
);
[SumOfSales] > 100
)
В этом коде результаты подзапроса, извлекающего CustomerName и Sum-
OfSales, прогоняются через функцию FILTER, которая оставляет в результиру-
ющем наборе только строки со значениями SumOfSales, превышающими 100.
В данный момент вы можете не понимать, что делает этот код. Но, постепенно
постигая все премудрости DAX, вы обнаружите, что в этом языке использовать
подзапросы намного легче, чем в SQL, и код получается более естественным по
причине функциональной природы DAX.
DAX для разработчиков MDX
Многие специалисты в области бизнес-аналитики переключаются на DAX как
на новый язык табличного движка Tabular. В прошлом они использовали язык
MDX для построения и обращения к многомерным моделям данных (Multidimen-
sional models) Analysis Services. Если вы из их числа, приготовьтесь изучать аб-
солютно новый язык, поскольку у DAX и MDX не так много общего. Более того,
некоторые концепции в DAX будут сильно напоминать вам MDX, но смысл их
будет совершенно иным.
38 ГЛАВА 1 Что такое DAX?
По опыту можем сказать, что путь от MDX к DAX наиболее тернист. Чтобы
изучить DAX, вам придется забыть все, что вы знаете о MDX. Выкиньте из го-
ловы многомерные пространства (multidimensional spaces) и приготовьтесь
к приобретению новых знаний с нуля.
Многомерность против табличности
MDX работает в многомерном пространстве, определенном моделью данных.
Его форма зависит от измерений (dimensions) и иерархий (hierarchies), присут-
ствующих в модели, и в свою очередь определяет систему координат много-
мерного пространства. Пересечения наборов элементов в разных измерениях
определяют точки в многомерном пространстве. Может понадобиться немало
времени, чтобы понять, что элемент [АН] любой иерархии атрибута - это не
более чем точка в многомерном пространстве.
В DAX все намного проще. Тут нет измерений, элементов и точек в много-
мерном пространстве. Да и самого многомерного пространства тоже нет. Есть
иерархии, которые мы можем определять в модели данных, но они существен-
но отличаются от иерархий в MDX. Пространство DAX построено на таблицах,
столбцах и связях. Таблицы в модели Tabular не являются ни группами мер
(measure group), ни измерениями. Это просто таблицы, для проведения вы-
числений в которых вы можете сканировать их, фильтровать и суммировать
значения. Все базируется на двух основных концепциях: таблицах и связях.
Скоро вы узнаете, что с точки зрения моделирования данных табличный
движок предоставляет меньше возможностей по сравнению с многомерным.
Но в данном случае это не означает, что в вашем распоряжении будет меньший
аналитический потенциал, поскольку вы всегда можете использовать DAX в ка-
честве языка программирования, чтобы обогатить модель данных. Истинный
потенциал движка Tabular заключается в потрясающей скорости DAX. Обычно
разработчики стараются не злоупотреблять языком MDX без необходимости,
поскольку оптимизировать такие запросы бывает непросто. DAX, напротив,
славится своим впечатляющим быстродействием. Так что большинство слож-
ных вычислений вы будете производить не в модели данных, а в формулах DAX.
DAX как язык программирования и язык запросов
И DAX, и MDX являются одновременно и языками программирования, и язы-
ками запросов. В MDX это разделение обусловлено наличием скриптов, в ко-
торых помимо базового языка MDX можно использовать специальные опе-
раторы вроде SCOPE, применимые исключительно в скриптах. В запросах на
извлечение данных в MDX вы пользуетесь оператором SELECT. В DAX все не-
сколько иначе. Вы можете использовать его как язык программирования для
определения вычисляемых столбцов, вычисляемых таблиц и мер. И если кон-
цепция вычисляемых столбцов и таблиц является новинкой в DAX, то меры
очень напоминают вычисляемые элементы в MDX. Можно также использовать
DAX в качестве языка запросов - например, для извлечения информации из
модели Tabular при помощи Службы отчетов (Reporting Services). При этом
в функциях DAX нет четкого разграничения в плане использования - все они
ГЛАВА 1 Что такое DAX? 39
могут быть применены как в запросах, так и при вычислении выражений. Бо-
лее того, в модели Tabular можно также использовать запросы, написанные
на языке MDX. Таким образом, хотя MDX и может использоваться с табличной
моделью данных в качестве языка запросов, когда речь идет о программирова-
нии в среде Tabular, единственным вариантом является DAX.
Иерархии
Производя большинство вычислений с помощью языка MDX, вы полагаетесь
на иерархии. Если вам необходимо получить сумму продаж по предыдущему
году, вам придется извлечь PrevMember из CurrentMember иерархии Year и ис-
пользовать это выражение для переопределения фильтра в MDX. Например, вы
можете написать такую формулу для осуществления расчетов по предыдуще-
му году на MDX:
CREATE MEMBER CURRENTCUBE.[Measures].[SamePeriodPreviousYearSales] AS
(
[Measures].[Sales Amount],
ParallelPeriod (
[Date].[Calendar].[Calendar Year],
1,
[Date].[Calendar].CurrentMember
)
);
В мере используется функция ParallelPeriod, возвращающая соседний эле-
мент относительно CurrentMember на иерархии Calendar. Таким образом, это
вычисление базируется на иерархиях, определенных в модели. В DAX мы бы
для этого использовали контекст фильтра и стандартные функции для работы
с датой и временем, как показано ниже:
SamePeriodPreviousYearSales :=
CALCULATE (
SUM ( Sales[Sales Amount] );
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
Можно произвести это вычисление разными способами, в том числе при
помощи функции FILTER, но идея остается прежней: вместо использования
иерархий мы применяем фильтрацию таблиц. Это очень существенное раз-
личие, и вам, вероятно, будет не хватать иерархий в DAX, пока не привыкнете
к новой для себя концепции.
Еще одним весомым отличием между этими языками является то, что в MDX
вы ссылаетесь на [Measures].[Sales Amount], тогда как функция агрегации, ко-
торая вам нужна, уже определена в модели. В DAX предопределенные агрега-
ции не используются. Фактически, как вы заметили, вычисляемое выражение
в приведенном выше примере следующее: SUM(Sales[Sales Amount]). Никаких
предопределенных агрегаций в модели нет. Мы определяем их тогда, когда
нам нужно. Всегда можно создать меру, вычисляющую сумму продаж, но эта
тема выходит за рамки этого раздела и будет описана позже в данной книге.
40 ГЛАВА 1 ЧтотакоеЭАХ?
Более существенным отличием DAX от MDX является то, что в MDX очень ак-
тивно используется инструкция SCOPE для реализации бизнес-логики (опять
же с использованием иерархий), тогда как в DAX применяется совсем другой
подход. Вообще, работы с иерархиями не хватает этому языку.
Например, если нам нужно очистить меру на уровне Year, в MDX мы могли
бы написать следующее выражение:
SCOPE ( [Measures].[SamePeriodPreviousYearSales], [Date].[Month].[All] )
THIS = NULL;
END SCOPE;
В DAX нет функций, похожих на SCOPE, и для получения аналогичного ре-
зультата придется выполнить проверку контекста фильтра, как показано ниже:
SamePeriodPreviousYearSales :=
IF (
ISINSCOPE ( 'Date'[Month] );
CALCULATE (
SUM ( Sales[Sales Amount] );
SAMEPERIODLASTYEAR ( 'Date'[Date] )
);
BLANK ()
)
Из кода функции понятно, что она возвратит результат, только если пользо-
ватель находится в календарной иерархии на уровне месяца или ниже. В про-
тивном случае функция вернет пустое значение (BLANK). Позже вы узнаете,
как работает эта функция. Стоит отметить, что это выражение более уязвимо
к ошибкам, чем код на MDX. Да, честно говоря, языку DAX очень не хватает
функций для работы с иерархиями.
Вычисления на конечном уровне
Используя язык MDX, вы, возможно, привыкли избегать проведения расчетов
на конечном уровне (leaf-level) элементов. Это настолько медленная операция,
что всегда будет предпочтительнее предварительно рассчитывать значения
и использовать агрегацию для возврата результата. В DAX вычисления на ко-
нечном уровне работают невероятно быстро, а предварительные агрегации
служат другим целям и используются только в работе с большими наборами
данных. Вам придется несколько изменить подход к проектированию моделей
данных. В большинстве случаев модели, идеально подходящие для многомер-
ной среды SQL Server Analysis Services, будут не лучшим образом показывать
себя в движке Tabular, и наоборот.
DAX для пользователей Power Bl
Если вы пропустили предыдущие разделы и сразу оказались здесь, что ж, при-
ветствуем! Язык DAX является родным для Power BI. И если у вас нет опыта ра-
боты с Excel, SQL или MDX, Power BI станет для вас первой средой, в которой вы
ГЛАВА 1 Что такое DAX? 41
сможете изучать DAX. В отсутствие навыков построения моделей данных при
помощи других инструментов вам будет приятно узнать, что Power BI является
мощнейшим средством анализа и моделирования, a DAX во всем ему помогает.
Возможно, вы не так давно начали работать с Power BI, а сейчас хотите сде-
лать очередной качественный шаг вперед. Если это так, приготовьтесь к увле-
кательному путешествию в мир DAX.
Вот вам наш совет: не ожидайте, что уже через пару дней вы сможете писать
сложный код на DAX. Этот язык потребует от вас полной концентрации и вни-
мания, а на его освоение может уйти немало времени, включая практическую
работу. По опыту можем сказать, что после проведения первых простых вы-
числений на DAX вы будете просто восхищены. Но восхищение пропадет, когда
вы дойдете до изучения контекстов вычислений и функции CALCULATE - наи-
более сложных составляющих языка. В этот момент вам все покажется очень
сложным. Но не отчаивайтесь! Большинство разработчиков DAX проходили
через это. На этой стадии вы уже так много всего изучите, что бросать все бу-
дет просто жалко. Читайте и практикуйтесь снова и снова, и вы увидите, что
озарение придет раньше, чем вы ожидаете. И тогда вы очень быстро завершите
чтение этой книги - уже в статусе гуру по DAX.
Контексты вычислений - это сердце языка DAX. Освоение их может занять
много времени. Мы не знаем никого, кому удалось бы узнать все о DAX за пару
дней. Но, как и с любым сложным делом, со временем вы научитесь получать
наслаждение от мелочей. А когда решите, что знаете уже все, перечитайте кни-
гу заново. Уверяем, вы найдете для себя массу полезных нюансов, которые при
первом прочтении казались не такими важными, но с приобретением опыта
смогут заиграть новыми красками.
Насладитесь остатком этой книги!
ГЛАВА 2
Знакомство с DAX
В этой главе мы начнем говорить о DAX. Вы познакомитесь с синтаксисом язы-
ка, ключевыми различиями между вычисляемыми столбцами, мерами (кото-
рые в старых версиях Excel назывались вычисляемыми полями) и основными
функциями DAX.
Поскольку это лишь вводная глава, мы не будем слишком углубляться в ра-
боту функций, а оставим это на потом. Здесь же мы ограничимся их пере-
числением и знакомством с языком в целом. Описывая особенности моделей
данных в Power BI, Power Pivot или Analysis Services, мы будем использовать
термин Tabular, даже если та или иная особенность не присутствует во всех
этих продуктах. Например, говоря «DirectQuery в модели Tabular», мы будем
иметь в виду режим DirectQuery в Power BI и Analysis Services, поскольку в Excel
он не поддерживается.
Введение в вычисления DAX
Перед тем как приступать к сложным формулам, необходимо изучить основы
DAX. Сюда включается синтаксис языка, поддерживаемые типы данных, ос-
новные операторы и способы обращения к столбцам и таблицам. Именно этим
концепциям будут посвящены следующие несколько разделов.
Мы используем DAX для проведения вычислений в столбцах таблиц. Мы
можем агрегировать значения, производить подсчет или поиск нужных нам
чисел, но в конечном счете мы работаем с таблицами и столбцами. Так что для
начала нам надо понять, как правильно обращаться к столбцам.
Обычно принято писать название таблицы в одинарных кавычках, за кото-
рыми идет наименование столбца, заключенное в квадратные скобки, как по-
казано ниже:
'Sales'[Quantity]
При этом допустимо опускать кавычки, если название таблицы не начина-
ется с цифры, не содержит пробелов и не является зарезервированным словом
вроде Date или Sum.
Кроме того, название таблицы можно не указывать, если мы обращаемся
к столбцам или мерам внутри таблицы, где определена формула. Таким об-
разом, [Quantity] будет вполне допустимым выражением, если оно определено
в вычисляемом столбце или мере в таблице Sales. И все же мы очень не совету-
ем вам опускать названия таблиц в формулах. Сейчас мы не можем должным
ГЛАВА 2 Знакомство с DAX 43
образом аргументировать этот довод, но вы все поймете, когда прочитаете
главу 5 «Введение в CALCULATE и CALCULATETABLE». Стоит отметить, что при
чтении кода DAX очень важно уметь определять, где речь идет о мерах (ко-
торые мы обсудим позже), а где о столбцах. Существует негласное правило
всегда указывать названия таблиц в случае со столбцами и опускать их в ме-
рах. Чем раньше вы примете эту доктрину, тем легче вам будет жить в мире
DAX. Так что вам нужно поскорее привыкать к такому обращению к столбцам
и мерам:
Sales[Quantity] * 2
[Sales Amount] * 2
-- Это ссылка на столбец
-- Это ссылка на меру
Вы поймете, почему приняты такие правила, после знакомства с концепци-
ей преобразования контекста в главе 5. А пока просто доверьтесь нам и при-
мите такое соглашение.
Комментарии в DAX
В предыдущем коде вы впервые увидели строки с комментариями в DAX. Этот язык под-
держивает как однострочные, так и многострочные комментарии. Однострочные пред-
варяются знаками -- или //, при этом оставшаяся часть строки считается комментарием.
= Sales [Quantity ] * Sales[Net Price] -- Однострочный комментарий
= Sales [Quantity] * Sales [Unit Cost] // Еще один пример однострочного комментария
Многострочные комментарии начинаются символами /* и заканчиваются */. Анали-
затор DAX пропускает все содержимое между этими знаками, считая его закомментиро-
ванным.
= IF (
Sales[Quantity] > 1;
/* Первый пример многострочного комментария
Здесь может быть написано что угодно, и оно будет проигнорировано
анализатором DAX
*/
"Multi";
/* Типичным использованием многострочного комментария является
изолирование части кода, который не будет выполняться
Следующий оператор IF будет проигнорирован, поскольку он включен
в многострочный комментарий
IF (
Sales[Quantity] = 1;
"Single";
"Special note"
)
*/
"Single"
)
Лучше стараться избегать комментариев в конце выражений DAX в определениях мер,
вычисляемых столбцов и таблиц.Такие комментарии могут быть просто не видны. Кроме
того, они могут не поддерживаться вспомогательными инструментами вроде DAX For-
matter, о котором мы расскажем позже в этой главе.
44 ГЛАВА 2 Знакомство с DAX
Типы данных DAX
DAX может выполнять вычисления над данными семи разных типов. С тече-
нием времени Microsoft вводила разные названия для одних и тех же типов
данных (data type), что привело к некоторой неразберихе. В табл. 2.1 показаны
различные имена, под которыми можно встретить типы данных в DAX.
ТАБЛИЦА 2.1 Типы данных
Тип данных в DAX Тип данных в Power Bl Тип данных в Power Pivot и Analysis Services Соответствующий общепринятый тип данных (например, в SQL Server) Тип данных объектной модели Tabular (Tabular Object Model - TOM)
Integer Whole Number Whole Number 1 nteger/l NT int64
Decimal Decimal Number Decimal Number Floating point / DOUBLE double
Currency Fixed Decimal Number Currency Currency/MONEY Decimal
DateTime DateTime, Date, Time Date Date/DATETIME DateTime
Boolean True/False True/False Boolean/BIT Boolean
String Text Text String/NVARCHAR(MAX) String
Variant - - - Variant
Binary Binary Binary Blob/VARBINARY(MAX) binary
В этой книге мы будем использовать типы данных из первой колонки
табл. 2.1, следуя стандартам сообщества бизнес-аналитики. Например, в Power
BI столбец, содержащий TRUE или FALSE, может характеризоваться типом дан-
ных TRUE/FALSE, тогда как в SQL Server он может представлять тип BIT. В то
же время историческим и более употребимым типом для таких данных будет
Boolean.
В DAX используется очень мощная подсистема для работы с типами данных,
так что вам не стоит особенно беспокоиться о них. Результирующий тип дан-
ных в выражении DAX выводится из типов составляющих частей выражений.
Вам необходимо иметь это в виду, если выражение вернет значение не того
типа, который вы ожидали. В этом случае нужно проверить типы данных со-
ставных частей выражения.
Например, если одним из слагаемых в сумме является дата, результат также
будет иметь тип даты. Если же суммируются целые числа, результат окажет-
ся целочисленного типа, несмотря на использование того же оператора. Такое
поведение именуется перегрузкой операторов (operator overloading) и показано
на рис. 2.1, где в столбец OrderDatePlusOneWeek записывается результат при-
бавления к Order Date числа 7.
Sales[OrderDatePlusOneWeek] = Sales[Order Date] + 7
Типом данных результирующего столбца будет дата.
В дополнение к перегрузке операторов DAX автоматически конвертирует
строки в числовые значения и обратно, когда это необходимо. Например, если
ГЛАВА 2 Знакомство с DAX 45
использовать оператор &, предназначенный для конкатенации строк, DAX кон-
вертирует аргументы в строки. Следующее выражение вернет значение «54»
как строку:
= 5 &4
А если использовать оператор +, возвращенное значение окажется число-
вым:
= «5» + “4”
Order Date OrderDatePlusOneWeek
10/08/2008 10/15/2008
10/10/2008 10/17/2008
10/12/2008 10/19/2008
09/05/2008 09/12/2008
09/07/2008 09/14/2008
09/23/2008 09/30/2008
11/05/2008 11/12/2008
11/07/2008 11/14/2008
11/09/2008 11/16/2008
11/17/2008 11/24/2008
Рис. 2.1 Прибавление целого числа к дате приводит
к образованию новой даты, отстающей от начальной
на заданное количество дней
Таким образом, тип результата в DAX зависит от оператора, а не от исход-
ных столбцов, значения которых приводятся к нужному типу автоматически
согласно требованиям выбранного оператора. И хотя такое поведение ана-
лизатора внешне выглядит удобным, далее в этой главе вы увидите, к каким
ошибкам может приводить автоматическое приведение типов. Также стоит от-
метить, что не все операторы поддерживают подобное поведение. Например,
операторы сравнения не могут сравнивать строки с числами. Получается, что
складывать строки с числами можно, а сравнивать - нет. Более подробную ин-
формацию по типам данных можно получить по ссылке: https://docs.microsoft.
com/en-us/power-bi/desktop-data-types. С учетом сложности правил мы бы посо-
ветовали вам вовсе избегать автоматического приведения типов данных. Если
вам потребуется воспользоваться приведением типов, лучше контролировать
этот процесс и указывать все явно. Например, предыдущий пример можно
переписать так:
= VALUE ( "5" ) + VALUE ( "411 )
Тем, кто привык работать с Excel и другими языками, типы данных DAX могут
показаться знакомыми. При этом нюансы работы с разными типами зависят
от конкретного движка и могут различаться в Power BI, Power Pivot и Analysis
Services. Больше информации по типам данных в Analysis Services можно най-
ти по адресу: http://msdn.microsoft.com/en-us/Library/gg492146.aspx, а по Power BI:
https://docs.microsoft.com/en-us/power-bi/desktop-data-types. Здесь же мы кратко
расскажем о каждом типе данных.
46 ГЛАВА 2 Знакомство с DAX
Integer
В DAX есть только один целочисленный тип данных Integer, позволяющий хра-
нить 64-битные значения. Во всех внутренних расчетах движок также исполь-
зует 64-битные целые числа.
Decimal
Тип данных Decimal призван хранить числа с плавающей запятой в формате
двойной точности. Не путайте этот тип данных DAX с типами decimal и numeric
в Transact-SQL. Соответствующим типом данных для decimal в SQL является
Float.
Currency
Тип данных Currency, также известный в Power BI как десятичное число с фик-
сированной запятой (Fixed Decimal Number), представляет числа с четырь-
мя знаками после запятой, которые хранятся в виде 64-битных целых чисел,
деленных на 10 000. Суммирование и вычитание значений с участием типа
Currency игнорирует десятичные знаки в количестве больше четырех, а умно-
жение и деление дают на выходе число с плавающей запятой, тем самым уве-
личивая точность значения. В общем случае если необходимо использовать
больше четырех десятичных знаков, нужно использовать тип Decimal.
По умолчанию тип данных Currency включает в себя символ валюты. Но мож-
но использовать этот символ и с типами Integer и Decimal, так же, как приме-
нять тип Currency без указания валюты.
DateTime
В DAX даты хранятся в типе данных DateTime. Внутренне этот тип хранится
в виде чисел с плавающей запятой, целая часть которых равна количеству
дней, прошедших до означенной даты с 30 декабря 1899 года, а дробная от-
ражает долю последнего дня. Таким образом, часы, минуты и секунды пере-
водятся в долю прошедшего дня. Следующее выражение возвращает текущую
дату плюс один день (24 часа):
= TODAY ()+1
Результатом будет завтрашний день с сегодняшним временем выполнения
этой формулы. Если вам необходимо получить только часть даты из значения
типа DateTime, воспользуйтесь функцией TRUNC для отсечения дробной части.
В Power BI существуют два дополнительных типа представления дат: Date
и Time. Внутренне они фактически являются отражением частей типа Date-
Time. Типы Date и Time хранят только целую и дробную части DateTime соот-
ветственно.
Ошибка високосного года
В программу электронных таблиц Lotus 1-2-3, увидевшую свет в 1983 году, вкралась
ошибка хранения информации в типе данных DateTime. При расчетах разработчики по-
считали 1900 год как високосный, хотя он таким не являлся. Дело в том, что последний
год столетия может быть високосным только при условии его деления без остатка на 400.
ГЛАВА 2 Знакомство с DAX 47
Разработчики первой версии Excel умышленно сохранили эту ошибку, чтобы обеспечить
совместимость с Lotus 1-2-3. И с тех пор каждая новая версия Excel тянет за собой эту
застарелую ошибку из тех же соображений совместимости.
На момент издания книги, в 2019 году, эта ошибка сохраняется в DAX для обратной
совместимости с Excel. И присутствие этой ошибки (или уже просто особенности?) может
приводить к неточностям во временных интервалах раньше 1 марта 1900 года. Так что
первой официально поддерживаемой датой в DAX является 1 марта 1900 года. Вычис-
ления, производимые до этой даты, могут приводить к ошибочным результатам, и здесь
нужно проявлять большую осторожность.
Boolean
Тип данных Boolean предназначен для хранения логических выражений. На-
пример, тип вычисляемого столбца со следующей формулой будет установлен
в Boolean:
= Sales[Unit Price] > Sales[Unit Cost]
Также значения типа Boolean могут быть отражены как числа: TRUE как 1,
a FALSE как 0. Такая нотация иногда оказывается полезной - например, для
сортировки, поскольку TRUE > FALSE.
String
Все строки в DAX хранятся в кодировке Unicode, в которой каждый символ за-
нимает 16 бит. По умолчанию операция сравнения между строками в DAX не
чувствительна к регистру, так что строки «Power В1» и «POWER В1» будут счи-
таться идентичными.
Variant
Тип данных Variant используется для выражений, которые могут возвращать
значения разных типов в зависимости от внешних условий. Например, сле-
дующее выражение может вернуть как целое число, так и строку, так что тип
возвращаемого значения будет Variant:
IF ( [measure] > 0; 1; "N/A" )
Тип данных Variant не может использоваться для столбцов в обычных табли-
цах. В свою очередь, меры и выражения в DAX могут иметь тип Variant.
Binary
Тип данных Binary используется в модели данных для хранения изображений
и другой неструктурированной информации. В DAX этот тип недоступен. Он
главным образом использовался в Power View, но в других средствах вроде
Power BI не применялся.
Операторы DAX
Теперь, когда вы понимаете всю важность операторов для определения типов
данных результатов выражений, пришло время познакомиться с ними сами-
ми. В табл. 2.2 представлен список операторов, доступных в DAX.
48 ГЛАВА 2 Знакомство с DAX
ТАБЛИЦА 2.2 Операторы
Тип оператора Символ Использование Пример
Скобки 0 Порядок предшествования операций и группировка аргументов (5 + 2) * 3
Арифметические Сложение Вычитание Умножение Деление 4 + 2 5-3 4*2 4/2
Сравнение A II II II V A A V V Равно Не равно Больше Больше или равно Меньше Меньше или равно [CountryRegion] = "USA" [CountryRegion] <> "USA" [Quantity] > 0 [Quantity] >= 100 [Quantity] < 0 [Quantity] <= 100
Строковая конкатенация & Конкатенация строк "Value is" & [Amount]
Логические & & II IN NOT Логическое И между двумя булевыми выражениями Логическое ИЛИ между двумя булевыми выражениями Нахождение элемента в списке Логическое отрицание [CountryRegion] = "USA" && [0uantity]>0 [CountryRegion] = "USA" || [Quantity] > 0 [CountryRegion] IN {"USA", "Canada"} NOT [Quantity] > 0
Также логические операции доступны в DAX в качестве функций с синтакси-
сом, похожим на Excel. Например, можно написать такие выражения:
AND ( [CountryRegion] = "USA"; [Quantity] > 0 )
OR ( [CountryRegion] = "USA"; [Quantity] > 0 )
Эти выражения полностью эквивалентны приведенным ниже:
[CountryRegion] = "USA" && [Quantity] > 0
[CountryRegion] = "USA" || [Quantity] > 0
Использование функций вместо операторов при вычислении булевой логи-
ки помогает при написании сложных формул. Фактически когда дело касается
форматирования объемных фрагментов кода, функции использовать бывает
легче, и читаются они лучше операторов. Главным недостатком функций явля-
ется то, что они могут принимать только два аргумента. Так что для сравнения
более двух составляющих придется вкладывать одну функцию в другую.
Конструкторы таблиц
В DAX существует возможность создания анонимных таблиц (anonymous tables)
прямо в коде. Если в предполагаемой таблице должен быть только один стол-
бец, можно написать значения через точку с запятой, по одному для каждой
строки, и заключить список в фигурные скобки. Допустимо каждое значение
в списке заключать в круглые скобки, но для таблицы с одним столбцом это не
обязательно. Таким образом, следующие две строки кода эквивалентны:
ГЛАВА 2 Знакомство с DAX 49
{ "Red"; "Blue"; "White" }
{ ( "Red" ); ( "Blue" ); ( "White" ) }
Если в таблице будет несколько столбцов, внутренние скобки обязательны.
При этом значения в строках для одного и того же столбца должны быть одно-
го типа. В противном случае DAX приведет все значения к обобщенному типу
данных, подходящему для всех строк в столбце.
{
( "А"; 10; 1,5; DATE ( 2017; 1; 1 ); CURRENCY ( 199,99 ); TRUE );
( "В"; 20; 2,5; DATE ( 2017; 1; 2 ); CURRENCY ( 249,99 ); FALSE );
( "C"; 30; 3,5; DATE ( 2017; 1; 3 ); CURRENCY ( 299,99 ); FALSE )
}
Конструкторы таблиц часто используются с оператором IN. Например, сле-
дующий синтаксис вполне приемлем в выражениях DAX:
'Product'[Color] IN { "Red"; "Blue"; "White" }
( 'Date'[Year]; 'Date'[MonthNumber] ) IN { ( 2017; 12 ); ( 2018; 1 ) }
Вторая строка в приведенном выше примере демонстрирует синтаксис для
сравнения набора столбцов или кортежа (tuple) с использованием операто-
ра IN. Такой синтаксис не может быть использован с операторами сравнения.
Иными словами, следующее выражение в DAX недопустимо:
( 'Date'[Year]; 'Date'[MonthNumber] ) = ( 2007; 12 )
Но вы всегда можете переписать это выражение с применением оператора
IN с конструктором таблицы из одной строки, как в примере ниже:
( 'Date'[Year]; 'Date'[MonthNumber] ) IN { ( 2007; 12 ) }
Условные операторы
В DAX вы можете создавать условные выражения при помощи функции IF.
Например, можно написать выражение, возвращающее строку «MULTI» или
«SINGLE» в зависимости от того, превышает ли количество товаров единицу.
IF (
Sates[Quantity] > 1;
"MULTI";
"SINGLE"
)
У функции /Гтри параметра, при этом обязательными из них являются толь-
ко первые два. Третий опциональный и по умолчанию принимает значение
BLANK. Взгляните на следующий код:
IF (
Sales[Quantlty] > 1;
Sales[Quantity]
)
50 ГЛАВА 2 Знакомство c DAX
Он абсолютно равнозначен своей полной версии:
if (
Sales[Quantlty] > 1;
Sales[Quantlty];
BLANK ()
)
Введение в вычисляемые столбцы и меры
Теперь, когда вы знаете основы синтаксиса DAX, необходимо усвоить одну
очень важную концепцию языка, состоящую в отличиях между вычисляемыми
столбцами и мерами. Несмотря на свои внешние сходства и возможность про-
водить одни и те же вычисления, это совершенно разные вещи. Понимание
различий между вычисляемыми столбцами и мерами таит в себе ключ ко всей
мощи языка DAX.
Вычисляемые столбцы
В зависимости от используемого инструмента вы можете создавать вычисляе-
мые столбцы разными способами. При этом концепция не меняется: вычисля-
емый столбец (calculated column) представляет собой еще один столбец в моде-
ли данных, содержимое которого не загружается из источника, а вычисляется
посредством формулы DAX.
Вычисляемый столбец во многом похож на любой другой столбец в таблице,
и мы можем использовать его в строках, колонках, фильтрах и области значе-
ний матрицы (matrix) или любого другого отчета. Более того, можно даже стро-
ить связи на основании вычисляемых столбцов. Выражения DAX, определен-
ные для вычисляемого столбца, производят вычисления в контексте текущей
строки таблицы, которой принадлежит этот столбец. Любое обращение к этому
столбцу вернет его значение для текущей строки. У нас нет возможности на-
прямую обратиться к значениям в других строках.
Если вы используете режим импорта (Import Mode), установленный в Tabular
по умолчанию, а не DirectQuery, важно будет помнить, что вычисляемые столб-
цы рассчитываются во время загрузки информации из базы данных и затем
сохраняются в модели данных. Такое поведение может показаться странным,
если вы привыкли к вычисляемым колонкам в SQL, которые рассчитываются
в момент выполнения запроса и не занимают память. В моделях Tabular все
вычисляемые столбцы хранятся в памяти и рассчитываются в момент обра-
ботки таблицы.
Такое поведение полезно, когда мы имеем дело со сложными вычисляемы-
ми столбцами. В этом случае время на сложные расчеты будет расходоваться во
время загрузки данных в модель, а не во время запросов, что повысит быстро-
действие отчетов. Но не стоит забывать о том, что вычисляемые столбцы рас-
ходуют драгоценную оперативную память. Например, если у нас есть вычис-
ляемый столбец со сложной формулой, можно поддаться соблазну и разбить
расчеты на несколько промежуточных вычисляемых столбцов. Если в процессе
ГЛАВА 2 Знакомство с DAX 51
разработки проекта такое решение допустимо, то в финальном продукте луч-
ше избегать подобных методов, поскольку каждое промежуточное вычисление
сохраняется в оперативной памяти, расходуя тем самым драгоценные ресурсы.
В случае с моделями, основанными на DirectQuery, дело обстоит иначе.
В этом режиме расчеты в вычисляемых столбцах производятся «на лету», в мо-
мент обращения движка Tabular к источнику данных. Это может приводить
к образованию тяжелых запросов, выполняемых в источнике, что негативно
сказывается на быстродействии отчетов.
Расчет длительности выполнения заказа
Представьте, что у нас в таблице Sales хранятся дата заказа и дата поставки. Используя
эти два столбца, можно легко вычислить длительность выполнения заказа в днях. По-
скольку даты хранятся в виде количества дней, прошедших с 30 декабря 1899 года, мы
можем получить разницу в днях между двумя датами путем обычного вычитания:
Sales[DaysToDeliver] = Sales [Delivery Date] - Sales[Order Date]
Но из-за того, что оба столбца имеют тип даты, результат также окажется датой, а для
перевода его в целое число нужно будет воспользоваться функцией приведения типов:
Sales[DaysToDeliver] = INT ( Sales[Delivery Date] - Sales[Order Date] )
Результат вычисления показан на рис. 2.2.
Order Date Delivery Date DaysToDeliver
01/02/2007 01/08/2007 6
01/02/2007 01/09/2007 7
01/02/2007 01/10/2007 8
01/02/2007 01/11/2007 9
01/02/2007 01/12/2007 10
01/02/2007 01/13/2007 11
01/02/2007 01/14/2007 12
Рис. 2.2 Вычитание одной даты из другой с последующим преобразованием
типа позволило нам получить разницу между датами в днях
Меры
Вычисляемые столбцы - безусловно, очень удобный и полезный инструмент,
но производить вычисления в модели данных можно и другим способом. Если
вы не хотите рассчитывать значение для каждой строки, а вместо этого вам
может понадобиться агрегировать данные по нескольким строкам, ваш вы-
бор - мера (measure).
Например, вы можете определить несколько вычисляемых столбцов в таб-
лице Sales для расчета валовой прибыли (gross margin):
Sales[SalesAmount] = Sales[Quantity] * Sales[Net Price]
Sales[TotalCost] = Sales[Quantity] * Sales[Unit Cost]
Sales[GrossMargin] = Sales[SalesAmount] - Sales[TotalCost]
52 ГЛАВА 2 Знакомство c DAX
А что произойдет, если вы захотите увидеть валовую прибыль в процент-
ном отношении к сумме продаж? Вы могли бы создать для этого вычисляемый
столбец со следующей формулой:
Sales[GrossMarginPct] = Sales[GrossMargin] I Sales[SalesAmount]
Формула вычисляет правильные значения по строкам, что видно по рис. 2.3,
но при этом итог по столбцу будет неверным.
SalesKey ▲ SalesAmount TotalCost GrossMargin GrossMarginPct
20070104611301-0002 $72.19 $38.74 $33.45 46.34%
20070104611301-0003 $23.75 $11.50 $12.25 51.58%
20070104611320-0006 $216.57 $116.22 $100.35 46.34%
20070104611320-0007 $23.75 $11.50 $12.25 51.58%
20070104611506-0002 $72.19 $38.74 $33.45 46.34%
20070104611506-0003 $23.75 $11.50 $12.25 51.58%
20070104611914-0002 $64.59 $38.74 $25.85 40.02%
20070104611914-0003 $21.25 $11.50 $9.75 45.88%
20070104611952-0004 $64.59 $38.74 $25.85 40.02%
20070104611952-0005 $21.25 $11.50 $9.75 45.88%
20070104611998-0002 $64.59 $38.74 $25.85 40.02%
20070104611998-0003 $63.75 $34.50 $29.25 45.88%
Total $732.23 $401.92 $330.31 551.46%
Рис. 2.3 В столбце GrossMarginPct правильные значения в строках, но итог ошибочный
Итоговое значение рассчитывается как сумма процентов по всем строкам.
А значит, когда нам необходимо агрегировать значения в процентах, мы не
можем полагаться на содержимое вычисляемых столбцов. Вместо этого в сво-
их расчетах мы должны опираться на суммы по столбцам. Здесь, к примеру,
нам необходимо поделить сумму по столбцу GrossMargin на сумму по столб-
цу SalesAmount. Мы должны включать в расчеты агрегированные значения,
а агрегация по вычисляемым столбцам здесь не годится. Иными словами, нуж-
но вычислить отношение сумм, а не сумму отношений.
Было бы неправильно также просто изменить тип агрегации в столбце
GrossMarginPct на расчет среднего значения - в этом случае вычисление также
окажется неверным. Отчет с усредненными итогами показан на рис. 2.4, и вы
можете легко проверить, что результат вычисления (330,31 / 732,23) должен
быть не 45,96 %, как показано, а 45,11 %.
Правильным решением будет создать GrossMarginPct в виде меры:
GrossMarginPct := SUM ( Sales[GrossMargin] ) / SUM (Sales[SalesAmount] )
Как мы уже сказали, нужный нам результат не может быть достигнут здесь
при помощи вычисляемого столбца. Если вам нужно будет агрегировать зна-
чения, а не работать со строками, без меры не обойтись. Вы, наверное, замети-
ГЛАВА2 Знакомство с DAX 53
ли, что для создания меры мы использовали знак := вместо обычного =. Таким
стандартом мы будем пользоваться на протяжении всей книги, и это позволит
вам отличать в коде вычисляемые столбцы от мер.
SalesKey SalesAmount TotalCost GrossMargin Average of GrossMarginPct
ж _ _ _ _ — -
20070104611301-0002 $72.19 $38.74 $33.45 46.34%
20070104611301-0003 $23.75 $11.50 $12.25 51.58%
20070104611320-0006 $216.57 $116.22 $100.35 46.34%
20070104611320-0007 $23.75 $11.50 $12.25 51.58%
20070104611506-0002 $72.19 $38.74 $33.45 46.34%
20070104611506-0003 $23.75 $11.50 $12.25 51.58%
20070104611914-0002 $64.59 $38.74 $25.85 40.02%
20070104611914-0003 $21.25 $11.50 $9.75 45.88%
20070104611952-0004 $64.59 $38.74 $25.85 40.02%
20070104611952-0005 $21.25 $11.50 $9.75 45.88%
20070104611998-0002 $64.59 $38.74 $25.85 40.02%
20070104611998-0003 $63.75 $34.50 $29.25 45.88%
Total $732.23 $401.92 $330.31 45.96%
Рис. 2.4 Смена функции агрегирования на AVERAGE не дала нужного результата
После объявления GrossMarginPct в качестве меры вы можете построить
корректный отчет, показанный на рис. 2.5.
SalesKey ж SalesAmount TotalCost GrossMargin GrossMarginPct
20070104611301-0002 $72.19 $38.74 $33.45 46.34%
20070104611301-0003 $23.75 $11.50 $12.25 51.58%
20070104611320-0006 $216.57 $116.22 $100.35 46.34%
20070104611320-0007 $23.75 $11.50 $12.25 51.58%
20070104611506-0002 $72.19 $38.74 $33.45 46.34%
20070104611506-0003 $23.75 $11.50 $12.25 51.58%
20070104611914-0002 $64.59 $38.74 $25.85 40.02%
20070104611914-0003 $21.25 $11.50 $9.75 45.88%
20070104611952-0004 $64.59 $38.74 $25.85 40.02%
20070104611952-0005 $21.25 $11.50 $9.75 45.88%
20070104611998-0002 $64.59 $38.74 $25.85 40.02%
20070104611998-0003 $63.75 $34.50 $29.25 45.88%
Total $732.23 $401.92 $330.31 45.11%
Рис. 2.5 Мера GrossMarginPct дала правильный результат
И вычисляемые столбцы, и меры используют выражения DAX, разница
состоит в контексте вычисления. Мера рассчитывается в контексте видимо-
го элемента или в контексте запроса DAX, тогда как вычисляемый столбец -
54 ГЛАВА 2 Знакомство с DAX
в контексте строки таблицы, которой он принадлежит. Контекст видимого эле-
мента (позже вы узнаете, что его также называют контекстом фильтра) зависит
от выбора пользователя в отчете или формата запроса DAX. Таким образом,
используя для меры выражение SUM(Sales[SalesAmount]), мы указываем движку
провести агрегацию по всем видимым элементам. Если же в формуле вычисля-
емого столбца написать Sales[SalesAmount], будет вычисляться значение столб-
ца SalesAmount из этой таблицы в текущей строке.
Мера должна быть определена внутри таблицы. Это одно из требований
языка DAX. В то же время нельзя сказать, что мера принадлежит конкретной
таблице, поскольку мы можем при желании перемещать ее из таблицы в таб-
лицу без потери функциональности.
Различия между вычисляемыми столбцами и мерами
Несмотря на все свои сходства, между вычисляемыми столбцами и мерами есть одно
существенное различие. Значение в вычисляемом столбце рассчитывается в момент об-
новления данных, и в качестве контекста используется контекст строки. Результат в этом
случае не зависит от действий пользователя. Мера, в свою очередь, оперирует агреги-
рованными данными в текущем контексте. В матрице или сводной таблице, к примеру,
исходные таблицы отфильтрованы в соответствии с координатами ячеек, и данные агре-
гированы и рассчитаны с использованием этих фильтров. Иными словами, мера всегда
оперирует агрегированными данными в контексте вычисления, с которым мы познако-
мимся в главе 4.
Выбор между вычисляемыми столбцами и мерами
Теперь, когда вы знаете отличия между вычисляемыми столбцами и мерами,
поговорим о том, когда и что нужно использовать. Иногда это будет не прин-
ципиально, но в большинстве случаев особенности вычислений будут одно-
значно определять правильность выбора.
Как разработчик вы должны отдавать предпочтение вычисляемым столб-
цам, если хотите:
использовать вычисленные результаты в качестве срезов, размещать их
на строках или столбцах (в отличие от области значений) в матрице или
сводной таблице, а также применять их в качестве фильтров в запросах
DAX;
определить выражение, строго привязанное к конкретной строке. Напри-
мер, выражение Price * Quantity не может вычисляться на основании сред-
них значений или сумм исходных столбцов;
хранить категории. Это могут быть диапазоны значений для меры, диа-
пазоны возрастов покупателей (0-18,18-25 и т. д.). Такие категории часто
используются в качестве фильтров или срезов.
В то же время вам придется воспользоваться мерой, если вы хотите отобра-
жать показатели, реагирующие на выбор пользователя или выступающие в ка-
честве агрегируемых значений в отчетах, например:
для вывода процента прибыли по выбранным в отчете фильтрам;
для расчета отношения показателя по одному товару ко всему ассорти-
менту при сохранении фильтра по году и региону.
ГЛАВА 2 Знакомство с DAX 55
Многие расчеты можно произвести как с использованием вычисляемого
столбца, так и посредством меры, но при этом нужно использовать разные вы-
ражения DAX. Например, мы могли бы определить GrossMargin как вычисляе-
мый столбец со следующей формулой:
Sales[GrossMargin] = Sales[SalesAmount] - Sales[TotalProductCost]
А в качестве меры выражение было бы иным:
GrossMargin := SUM ( Sales[SalesAmount] ) - SUM ( Sales[TotalProductCost] )
В этом случае мы посоветовали бы использовать меру. Будучи вычисляемой
в момент запроса, она не потребует для хранения дополнительной памяти
и дискового пространства. Помните: если вы можете произвести вычисления
обоими способами, лучше отдавать предпочтение мере. Вычисляемыми столб-
цами нужно пользоваться только в особых случаях, когда это действительно
необходимо. Пользователи с опытом работы в Excel обычно предпочитают вы-
числяемые столбцы мерам, поскольку они очень напоминают им привычные
вычисления в своей родной стихии. И все же лучшим выбором для произведе-
ния расчетов в DAX является мера.
Разумеется, в своих расчетах мера может обращаться к одному или нескольким вы-
числяемым столбцам. Обратное также возможно, пусть и не столь очевидно.Действи-
тельно, в вычисляемом столбце можно ссылаться на меру. В этом случае мера будет
вычисляться в контексте текущей строки, а результат будет сохраняться в столбце
и не будет зависеть от выбора пользователя. Конечно, только определенные опера-
ции с мерами способны в этом случае дать значимые результаты, поскольку вычис-
ления в мерах обычно строго зависят от выбора пользователя. Кроме того, всякий
раз, когда вы используете меру внутри вычисляемого столбца, вы полагаетесь на
преобразование контекста, что является продвинутой техникой вычислений в DAX.
Перед этим мы настоятельно рекомендуем вам прочитать и досконально усвоить
материал четвертой главы, в которой подробно описываются контексты вычислений
и преобразование контекста.
Введение в переменные
При написании выражений DAX можно избежать включения в них повторного
кода и тем самым улучшить читаемость формул за счет использования пере-
менных. Взгляните на следующие выражения:
VAR TotalSales = SUM ( Sales[SalesAmount] )
VAR TotalCosts = SUM ( Sales[TotalProductCost] )
VAR GrossMargin = TotalSales - TotalCosts
RETURN
GrossMargin / TotalSales
Переменные (variables) в языке DAX определяются ключевым словом VAR.
После объявления переменных обязательным является включение секции,
56 ГЛАВА 2 Знакомство с DAX
начинающейся с ключевого слова RETURN, для определения результата выра-
жения. При этом вы можете объявить сразу несколько переменных, которые
будут храниться локально - в выражении, в котором определены.
К переменной, объявленной внутри выражения, нельзя обращаться извне.
В DAX не предусмотрены глобальные переменные. Это означает, что вы не мо-
жете объявить переменные, которые можно будет использовать во всей модели.
Применительно к переменным в DAX используется так называемое «лени-
вое» вычисление (lazy evaluation), также именуемое отложенным. Азначит, если
вы объявили переменную, которая не используется в коде, ее значение не бу-
дет вычислено. Когда переменная потребуется в дальнейших расчетах, она бу-
дет инициализирована, но только один раз, а при повторном обращении к ней
будет использовано уже рассчитанное ранее значение. Таким образом, при-
менение переменных позволяет оптимизировать код при наличии сложных
повторяющихся вычислений.
Переменные являются важным инструментом в DAX. Как вы узнаете из гла-
вы 4, использовать переменные бывает очень полезно, поскольку в них приме-
няется контекст вычисления (evaluation context) вместо контекста, в котором
используется переменная. В главе 6 мы подробно расскажем о переменных
и их использовании. Кроме того, мы будем пользоваться переменными на про-
тяжении всей книги.
Обработка ошибок в выражениях DAX
Вы уже усвоили основы синтаксиса DAX, а теперь пришло время узнать, как
в этом языке обрабатываются возникающие ошибки. Выражения DAX могут
содержать недопустимые вычисления из-за ссылки в формуле на ошибочные
данные. Например, в формуле может возникнуть ошибка деления на ноль или
попытка выполнить арифметическую операцию со столбцом, содержащим
нечисловые данные. Полезно узнать, как подобные ошибки обрабатываются
в DAX по умолчанию и как можно перехватывать их самостоятельно.
Перед началом обсуждения посмотрим, какие виды ошибок могут появлять-
ся в формулах DAX:
ошибки преобразования;
ошибки арифметических операций;
пустые или отсутствующие значения.
Ошибки преобразования
Первый вид ошибки - ошибка преобразования. Как мы уже видели ранее в этой
главе, DAX автоматически преобразует значения между строковыми и число-
выми типами, когда это необходимо. Так что все перечисленные выражения
являются допустимыми:
"10" + 32 = 42
"10" & 32 = "1032"
10 & 32 = "1032"
ГЛАВА 2 Знакомство с DAX 57
DATE (2010;3;25) = 3/25/2010
DATE (2010;3;25) + 14 = 4/8/2010
DATE (2010;3;25) & 14 = "3/25/201014"
Эти формулы корректны, поскольку оперируют константами. А как насчет
следующей формулы, при условии что в столбце VatCode хранятся текстовые
данные?
Sales[VatCode] + 100
Поскольку первым операндом суммирования является столбец с текстом,
вы как разработчик должны позаботиться о том, чтобы все значения из этого
столбца могли быть преобразованы в числа. Если DAX с этим не справится, воз-
никнет ошибка преобразования. Вот пара типичных ситуаций:
"1 + 1" + 0 = Не удается преобразовать значение "1+1" типа Text в тип Number.
DATEVALUE ("25/14/2010") = Не удается преобразовать значение "25/14/2010" типа Text
в тип Date.
Если вы хотите избежать возникновения подобных ошибок, необходимо
снабжать выражения DAX соответствующими перехватчиками ошибок. Мож-
но обрабатывать ошибки после их появления, а можно заранее проверять опе-
ранды вычислений на корректность. В любом случае желательно предпринять
превентивные меры, чем перехватывать ошибку после ее возникновения.
Ошибки арифметических операций
Второй тип ошибок - ошибки арифметических операций - возникает в резуль-
тате выполнения некорректных арифметических действий вроде деления на
ноль или извлечения квадратного корня из отрицательного числа. Это не ошиб-
ки преобразования, DAX генерирует их всякий раз, когда происходит попытка
вызова функции или использования оператора с недопустимыми значениями.
Ошибка деления на ноль требует особого подхода, поскольку ее возникно-
вение не очевидно (пожалуй, за исключением математиков). Когда происхо-
дит деление на ноль, DAX возвращает специальное значение Infinity (беско-
нечность). В особых случаях, когда ноль делится на ноль или Infinity на Infinity,
DAX возвращает другое специальное значение NaN (Not A Number - не число).
Поскольку тут мы имеем дело с неочевидным поведением, мы решили свести
результаты вычислений в табл. 2.3.
ТАБЛИЦА 2.3 Специальные значения результатов при делении на ноль
Выражение Результат
10/0 Infinity
7/0 Infinity
0/0 NaN
(10/0) / (7/0) NaN
Важно заметить, что значения Infinity и NaN не являются ошибками, это спе-
циальные значения в DAX. Фактически при делении числа на Infinity ошибка не
генерируется. Вместо этого возвращается ноль:
58 ГЛАВА 2 Знакомство с DAX
9954 / ( 7 / 0 ) = 0
За исключением этой особой ситуации, DAX будет возвращать арифмети-
ческую ошибку при вызове функции с недопустимым параметром, например
при попытке взять квадратный корень из отрицательного числа:
SQRT ( -1 ) = Аргумент или функция "SQRT" имеет неправильный тип данных, либо результат
имеет слишком большой или слишком маленький размер.
При обнаружении подобной ошибки DAX прекратит дальнейшее вычисле-
ние выражения и сгенерирует ошибку. Для проверки того, вернуло ли выраже-
ние ошибку, можно воспользоваться функцией ISERROR. Далее в этой главе мы
покажем такой сценарий.
Стоит отметить, что специальные значения вроде NuNb некоторых инстру-
ментах, например в Power BI, отображаются как обычные значения. В других
инструментах, таких как сводные таблицы в Excel, эти значения могут воспри-
ниматься как ошибки.
Пустые или отсутствующие значения
Третий тип ошибки, который мы рассмотрим, характеризуется не каким-то
ошибочным выполнением условия, а наличием пустых значений. В соседстве
с другими элементами присутствие пустых значений в выражениях может
приводить к непредсказуемым результатам или ошибкам в вычислении.
DAX обрабатывает пустые или отсутствующие значения, а также пустые
ячейки одинаково, заменяя их значением BLANK. BLANK само по себе являет-
ся даже не значением, а способом идентификации таких условий. В DAX полу-
чить значение BLANK можно путем вызова одноименной функции, результат
которой отличается от пустой строки. Например, следующее выражение всегда
будет возвращать значение BLANK, которое в разных клиентских инструмен-
тах может отображаться как пустая строка или «(blank)»:
= BLANK ()
Само по себе это выражение не несет никакой смысловой нагрузки - функ-
ция BLANK оказывается полезной тогда, когда нам необходимо вернуть пустое
значение. Допустим, вы хотите отобразить пустую строку вместо нуля. В сле-
дующем выражении рассчитаем размер скидки для продажи и вернем пустое
значение для нулевых результатов:
=if (
Sales[DiscountPerc] =0; -- Проверяем, есть ли скидка
BLANK (); -- Возвращаем пустое значение, если скидки нет
Sales[DiscountPerc] * Sales[Amount] -- Иначе рассчитываем скидку
)
BLANK, по существу, не является ошибкой, это просто пустое значение. Та-
ким образом, выражение, в котором содержится BLANK, может возвращать
значение или пустоту в зависимости от требований расчетов. Например, сле-
дующее выражение вернет BLANK всякий раз, когда Sales[Amount] будет являть-
ся BLANK:
ГЛАВА 2 Знакомство с DAX 59
= 10 * Sales[Amount]
Иными словами, результат арифметической операции будет BLANK, если
один или оба из ее операндов - BLANK. Это создает неразбериху, когда необ-
ходимо проверить выражение на пустоту. Из-за выполнения неявных преоб-
разований бывает невозможно понять, вернуло ли выражение при использо-
вании оператора сравнения ноль (или пустую строку) или BLANK. Следующие
выражения всегда будут возвращать TRUE:
BLANK ()=0 -- Всегда вернет TRUE
BLANK () = 1111 -- Всегда вернет TRUE
Таким образом, если в столбцах Sales[DiscountPerc] или Sales [Clerk] будут со-
держаться пустые значения, следующие условия вернут TRUE даже при срав-
нении с 0 и пустой строкой:
Sales[DiscountPerc] =0 -- Вернет TRUE, если DiscountPerc либо BLANK, либо 0
Sales[Clerk] = 1111 -- Вернет TRUE, если Clerk либо BLANK, либо 1111
В таких случаях можно использовать функцию ISBLANK для проверки зна-
чения на пустоту:
ISBLANK ( Sales[DiscountPerc] ) -- Вернет TRUE, только если DiscountPerc - BLANK
ISBLANK ( Sales[Clerk] ) -- Вернет TRUE, только если Clerk - BLANK
Действие функции BLANK в арифметических и логических операциях в вы-
ражениях DAX показано в следующих примерах:
BLANK () + BLANK () = BLANK ()
10 * BLANK () = BLANK ()
BLANK () / 3 = BLANK ()
BLANK () I BLANK () = BLANK ()
Однако функция BLANK оказывает влияние на итоговый результат не во всех
формулах в DAX. Некоторые вычисления игнорируют пустые значения. Вмес-
то этого возвращаемое значение зависит от других величин в формуле. При-
мерами таких операций могут быть сложение, вычитание, деление на BLANK
и логические операции с участием BLANK. Следующие примеры показывают
поведение некоторых операций с BLANK:
BLANK () - 10 = -10
18 + BLANK () = 18
4 / BLANK () = Infinity
0 / BLANK () = NaN
BLANK OH BLANK () = FALSE
BLANK () && BLANK () = FALSE
( BLANK () = BLANK () ) = TRUE
( BLANK () = TRUE ) = FALSE
( BLANK () = FALSE ) = TRUE
( BLANK () = 0 ) = TRUE
( BLANK () = 1111 ) = TRUE
ISBLANK ( BLANK() ) = TRUE
FALSE || BLANK () = FALSE
60 ГЛАВА 2 Знакомство c DAX
FALSE && BLANK () = FALSE
TRUE || BLANK () = TRUE
TRUE && BLANK () = FALSE
Пустые значения в Excel и SQL
В Excel иначе обрабатываются пустые значения. Все пустые значения там восприни-
маются как нулевые, если они участвуют в арифметических операциях сложения или
умножения, но при этом могут возвращать ошибку в операциях деления или логических
выражениях.
В SQL пустые значения (NULL) в выражениях обрабатываются иначе, чем в DAX - зна-
чения BLANK. Как вы видели ранее в этой главе, выражения DAX, в которых присутствуют
значения BLANK, далеко не всегда возвращают BLANK,тогда как наличие NULL в инструк-
ции SQL почти всегда означает итоговый NULL. Это отличие очень важно учитывать при
работе с реляционной базой данных в режиме DirectQuery, поскольку в подобном случае
одни вычисления будут производиться в SQL, другие - в DAX. В результате разница в под-
ходах к пустым значениям в двух движках может обернуться неожиданным поведением
запросов.
Понимание работы с пустыми или отсутствующими значениями в выраже-
ниях DAX и умелое использование функции BLANK для возврата пустых ячеек
в вычислениях очень важны для полного контроля над итоговыми результата-
ми выражений. Вы можете возвращать BLANK всякий раз, когда обнаруживае-
те недопустимые значения или другие ошибки в выражении, как мы покажем
в следующем разделе.
Перехват ошибок
Теперь, когда вы познакомились с видами ошибок, которые могут возникать
в DAX, самое время научиться перехватывать их, исправлять или, по крайней
мере, выводить понятное для пользователя сообщение. Появление ошибок в вы-
ражениях DAX зачастую связано со значениями в столбцах таблиц, к которым
обращается само выражение. Мы научим вас выявлять ошибки в выражениях
и возвращать сообщения о них. Общепринятой техникой обработки ошибок
в DAX является их обнаружение и возврат значения по умолчанию или сообще-
ния об ошибке. Для этого в языке используется сразу несколько функций.
Первой из них является функция IFERROR - очень похожая на IF, но вместо
оценки условия проверяющая выражение на ошибки. Типичные примеры ис-
пользования функции IFERROR приведены ниже:
= IFERROR ( Sales[Quantity] * Sales[Price]; BLANK () )
= IFERROR ( SQRT ( Test[Omega] ); BLANK () )
Если в любом из столбцов Sales[Quantity] или Sales[Price] в первом выраже-
нии находится строка, которую невозможно преобразовать в число, все выра-
жение в целом вернет пустое значение. Иначе результатом будет произведе-
ние значений двух этих столбцов.
Во втором выражении результатом будет пустое значение всякий раз, когда
в столбце Test [Omega ] будет оказываться отрицательное число.
ГЛАВА 2 Знакомство с DAX 61
Использование функции IFERROR в таком виде заменяет собой более много-
словный код с применением функции IFERROR совместно с IF:
= IF (
ISERROR ( Sales[Quantity] * Sales[Price] );
BLANK ();
Sales[Quantity] * Sales[Price]
)
= IF (
ISERROR ( SQRT ( Test[Omega] ) );
BLANK ();
SQRT ( Test[Omega] )
)
Первый вариант без функции IF более предпочтителен, и вы можете исполь-
зовать его всегда, когда возвращается то же значение, которое проверяется на
ошибки. К тому же в этом случае вам не придется два раза писать одно и то же
выражение, как в последнем примере, а значит, код станет более надежным
и понятным. Функцию IF следует использовать в случаях, когда из выражения
возвращается другое значение - не то, которое проверялось на ошибки.
Кто-то может вовсе не проверять выражение на ошибки, а вместо этого
тестировать параметры на допустимость значений перед их обработкой. На-
пример, в случае с функцией SORT, вычисляющей квадратный корень из вы-
ражения, можно было предварительно проверить, является ли ее параметр по-
ложительным числом:
= IF (
Test[Omega] >= 0;
SQRT ( Test[Omega] );
BLANK ()
)
А с учетом того, что третий аргумент функции IF по умолчанию равен BLANK,
можно записать это выражение более лаконично:
= IF (
Test[Omega] >= 0;
SQRT ( Test[Omega] )
)
Зачастую приходится проверять, являются ли значения пустыми. Функция
ISBLANK проверяет переданный аргумент и возвращает TRUE в случае, если он
является пустым. Это бывает полезно, особенно когда значение недоступно, но
нельзя при этом считать его нулевым. В следующем примере мы рассчитаем
стоимость доставки заказа, а если поле с указанием веса (Weight) не заполнено,
будем брать стоимость доставки по умолчанию (DefaultShippingCost):
= IF (
ISBLANK ( Sales[Weight] );
Sales[DefaultShippingCost];
-- Если вес не заполнен
-- то возвращаем стоимость доставки
-- по умолчанию
62 ГЛАВА 2 Знакомство с DAX
Sales[Weight] * Sales[ShippingPrice] -- иначе умножаем вес на тариф стоимости
-- доставки
)
Если просто умножить вес на тариф, мы получим пустые значения для стои-
мости доставки для всех заказов с незаполненным весом из-за характерного
поведения значений BLANK в операциях умножения.
Используя переменные, вы должны отлавливать ошибки во время их объ-
явления, а не использования. Посмотрите на примеры ниже. Первая строчка
кода вернет ноль, вторая - ошибку, а третья выдаст разные результаты в зави-
симости от версии продукта, использующего DAX (в последних версиях также
будет сгенерирована ошибка):
IFERROR ( SQRT ( -1 ); 0 )
VAR WrongValue = SQRT ( -1 )
RETURN
IFERROR ( WrongValue; 0 )
IFERROR (
VAR WrongValue = SQRT ( -1 )
RETURN
WrongValue;
0
)
- - Вернет 0
- - Ошибка возникает здесь, так что результатом
- - всегда будет ошибка
- - Эта строка никогда не будет выполнена
- - Разные результаты в зависимости от версии
- - IFERROR сгенерирует ошибку в версии 2017
- - IFERROR вернет 0 в версиях до 2016
Ошибка возникает в момент вычисления переменной WrongValue, так что
движок никогда не вызовет функцию IFERROR во втором примере. А в третьем
фрагменте результат зависит от версии продукта. При перехвате ошибок нуж-
но с особой внимательностью относиться к переменным.
Избегайте использования функций перехвата ошибок
Несмотря на то что тему оптимизации кода мы будем отдельно обсуждать далее в этой
книге, вам уже сейчас полезно будет узнать, что функции перехвата ошибок способны
негативным образом сказаться на эффективности выражений. И проблема не в том, что
эти функции медленные сами по себе. Просто движок DAX не может использовать оп-
тимальный план выполнения запроса в случае возникновения ошибки. В большинстве
случаев предварительная проверка операндов на допустимость значений будет лучшим
решением в сравнении с использованием функций DAX для обработки ошибок. Напри-
мер, вместо такого фрагмента кода:
IFERROR (
SQRT ( Test[Omega] );
BLANK ()
)
лучше будет использовать такой вариант:
IF (
Test[Omega] >= 0;
SQRT ( Test[Omega] );
BLANK ()
)
ГЛАВА 2 Знакомство c DAX 63
Второй вариант не нуждается в перехвате ошибок, а значит, будет выполняться быст-
рее первого. Это общее правило. Более подробно об оптимизации кода мы будем гово-
рить в главе 19.
Еще одним поводом отказаться от использования функции IFERROR является то, что
она не умеет перехватывать ошибки на более глубоком уровне вложенности. Например,
в следующем фрагменте кода осуществляется перехват ошибок в столбце Table[Anount]
на случай содержания в ней нечисловых значений. Как мы отмечали выше, это довольно
дорогостоящая операция, поскольку она выполняется для каждой строки в таблице.
SUMX (
Table;
IFERROR ( VALUE ( Table[Amount] ); BLANK () )
)
Теперь посмотрите на следующее выражение. Здесь по причине оптимизации движка
DAX те же ошибки, которые перехватывались в предыдущем примере, перехватываться
не будут. Если в столбце Table[Amount] встретится нечисловое значение только в одной
строке, все выражение в целом сгенерирует ошибку, которая не будет перехвачена функ-
цией IFERROR.
IFERROR (
SUMX (
Table;
VALUE ( Table[Amount] )
);
BLANK ()
)
Функция ISERROR характеризуется таким же поведением, как и IFERROR. Используйте
их осторожно и для перехвата только тех ошибок, которые возникают непосредственно
в блоке IFERROR/ISERROR, а не на вложенных уровнях.
Генерирование ошибок
Иногда ошибка - это просто ошибка, и формула не должна при ее возникно-
вении возвращать значение по умолчанию. Более того, при возврате любого
значения результат может оказаться неправильным. Например, если в конфи-
гурационной таблице содержится противоречивая информация, не соответ-
ствующая действительности, необходимо предупредить об этом пользователя,
вместо того чтобы возвращать заведомо ложные данные, которые могут быть
приняты за истину.
Более того, нам может понадобиться создать собственное сообщение об
ошибке, а не пользоваться общим - так мы сможем лучше донести до пользо-
вателя суть проблемы.
Представьте, что вам необходимо вычислить квадратный корень из абсолют-
ной температуры, выраженной в градусах Кельвина, чтобы соответствующим
образом скорректировать значение скорости звука в сложном научном рас-
чете. Очевидно, мы не ожидаем, что температура может быть отрицательной.
Если же это произошло, значит, возникли проблемы при измерении, и нам не-
обходимо сгенерировать ошибку и остановить дальнейшие расчеты.
В этом случае представленный ниже код будет таить в себе потенциальную
опасность:
64 ГЛАВА 2 Знакомство с DAX
= IFERROR (
SQRT ( Test[Temperature] );
0
)
Для того чтобы сделать код более безопасным, мы должны сами генериро-
вать ошибку. Перепишем формулу следующим образом:
= IF (
Test[Temperature] >= 0;
SQRT ( Test[Temperature] );
ERROR ( "Значение температуры не может быть отрицательным. Вычисление прервано." )
)
Форматирование кода на DAX
Перед тем как продолжить говорить о DAX, позвольте нам уделить немного
внимания одному важному аспекту для любого языка программирования -
форматированию кода. DAX - функциональный язык, так что вне зависимо-
сти от сложности выражения оно, по сути, представляет собой вызов функции.
В свою очередь, совокупность вложенных функций определяет сложность все-
го выражения в целом.
В DAX нередко можно увидеть выражения на десять, а то и двадцать строк.
И с ростом количества строк большую важность приобретает вопрос едино-
образного форматирования кода для его лучшей читаемости.
Каких-то «официальных» правил форматирования кода DAX не существу-
ет, но мы считаем важным рассказать, каких принципов придерживаемся мы
сами. Разумеется, наше форматирование нельзя считать эталонным, и вы мо-
жете предпочесть иные правила. С этим нет никаких проблем - выбирайте свой
стиль и придерживайтесь его. Единственное, что вам необходимо помнить:
форматируйте свой код и никогда не пишите сложные формулы в одну строку,
иначе у вас возникнут проблемы, и раньше, чем вы предполагаете.
Чтобы понять важность форматирования исходного текста запросов, взгля-
ните на следующее выражение, работающее с датой и временем. Это сложная
формула, но не самая сложная из тех, что вы будете писать. Вот как будет вы-
глядеть код без должного форматирования:
IF(CALCULATE(NOT ISEMPTY(Balances); ALLEXCEPT (Balances; BalanceDate));SUMX (ALL(Balances
[Account]); CALCULATE(SUM (Balances[Balance]);LASTNONBLANK(DATESBETWEEN(
BalanceDate[Date]; BLANK();MAX(BalanceDate[Date]));CALCULATE(COUNTROWS(Balances)))));
BLANK())
Понять, что делает эта формула, с первого взгляда просто невозможно. Тут
даже не видно, какая функция является внешней и сколько параметров она
принимает. Студенты частенько просят нас разобраться в своих формулах, на-
писанных подобным образом. И знаете, что мы делаем в первую очередь? Ко-
нечно, форматируем код.
Вот как может выглядеть та же формула в отформатированном виде:
ГЛАВА 2 Знакомство с DAX 65
IF (
CALCULATE (
NOT ISEMPTY ( Balances );
ALLEXCEPT (
Balances;
BalanceDate
)
);
SUMX (
ALL ( Balances[Account] );
CALCULATE (
SUM ( Balances[Balance] );
LASTNONBLANK (
DATESBETWEEN (
BalanceDate[Date];
BLANK ();
MAX ( BalanceDate[Date] )
);
CALCULATE (
COUNTROWS ( Balances )
)
)
)
);
BLANK ()
)
Код остался прежним, но теперь мы хотя бы отчетливо видим три входных
параметра внешней функции IF. Кроме того, по единообразным отступам лег-
ко отличить блоки кода и понять, что выполняет каждый из них. Да, код остал-
ся таким же сложным, как и раньше, но теперь проблема в языке, а не в фор-
матировании. С введением дополнительных переменных выражение может
несколько упроститься, пусть и за счет увеличения в объеме, но и в этом случае
форматирование играет очень важную роль, позволяя визуально выделить об-
ласти действия переменных:
if (
CALCULATE (
NOT ISEMPTY ( Balances );
ALLEXCEPT (
Balances;
BalanceDate
)
);
SUMX (
ALL ( Balances[Account] );
VAR PrevlousDates =
DATESBETWEEN (
BalanceDate[Date];
BLANK ();
MAX ( BalanceDate[Date] )
)
66 ГЛАВА 2 Знакомство c DAX
VAR LastDateWithBalance =
LASTNONBLANK (
PreviousDates;
CALCULATE (
COUNTROWS ( Balances )
)
)
RETURN
CALCULATE (
SUM ( Balances[Balance] );
LastDateWithBalance
)
);
BLANK ()
)
DAXFormatter.com
Мы создали сайт, посвященный форматированию кода на DAX. Изначально мы сде-
лали его для себя, чтобы не тратить драгоценное время на форматирование каждой
формулы в исходном тексте. После того как сайт заработал, мы решили показать его
всем, кто так же, как и мы, не желает форматировать текст вручную. Одновременно
с этим мы популяризировали свои принципы и подходы к форматированию кода.
Посетить наш сайт вы можете по адресу www.daxformatter.com. Интерфейс
сайта предельно прост - вставляйте свой текст на DAX и жмите кнопку FOR-
MAT. Страница перезагрузится, и перед вами будет отформатированный код,
который вы сможете перенести обратно в свой инструмент.
Вот краткий перечень правил, которых мы придерживаемся при формати-
ровании кода DAX:
всегда отделяйте названия функций вроде IF, SUMX и CALCULATE от дру-
гих элементов кода пробелом и пишите их прописными буквами;
ссылайтесь на столбцы таблиц следующим образом: TableName[Column-
Name] - без пробела между названием таблицы и открывающей квадрат-
ной скобкой. Не опускайте наименование таблицы;
названия мер пишите без указания названия таблицы: [MeasureName];
всегда ставьте пробелы после точек с запятыми, а не перед ними;
если формула прекрасно укладывается в одну строку, не используйте ни-
каких правил;
если формула не помещается в строку, следуйте таким рекомендациям:
- название функции с открывающей скобкой выносите на отдельную
строку;
- каждый параметр функции пишите на отдельной строке с дополни-
тельным отступом из четырех пробелов и завершающей точкой с за-
пятой;
- закрывающую скобку размещайте непосредственно под названием
соответствующей ей функции.
ГЛАВА 2 Знакомство с DAX 67
Это базовые правила. С полным списком принципов форматирования кода
можно ознакомиться по адресу: http://sqL.bi/daxruLes.
Если вы решите, что вам подходят другие правила форматирования исходно-
го текста, используйте их. Основная цель этого действия состоит в облегчении
чтения кода, так что вы вольны выбирать свой стиль. Главное, чтобы формати-
рование позволяло максимально быстро обнаруживать ошибки в коде, - в этом
его основное предназначение. К примеру, если бы в изначально показанном
коде без форматирования движок выдал ошибку, связанную с отсутствием за-
крывающей скобки, вам было бы очень непросто понять, где именно нужно
ее поставить. В отформатированном тексте, напротив, хорошо видны соответ-
ствия между функциями и их скобками.
Помощь при форматировании кода на DAX
Форматирование кода на DAX - задача непростая, поскольку обычно им приходится за-
ниматься в небольшом окошке и с текстом маленького размера. В зависимости от версии
инструменты Power Bl, ExceL и Visual Studio предлагают различные средства для напи-
сания кода на DAX. Следующие советы могут помочь вам при работе с текстом в этих
редакторах:
чтобы увеличить шрифт, покрутите колесо мыши с зажатой клавишей Ctrl - это
облегчит чтение;
переход на новую строку осуществляется одновременным нажатием Shift+Enter;
если вам не нравится программировать непосредственно в редакторе, вы можете
писать исходный текст в других программах, таких как Блокнот или DAX Studio,
а затем переносить его в редактор.
При взгляде на код DAX бывает непросто сразу понять, что перед вами: вычисляемый
столбец или мера. В наших статьях и книгах мы используем обычный знак равенства (=)
для создания вычисляемых столбцов и знак присваивания (:=) для определения мер:
CalcCol = SUM ( Sales[SalesAmount] ) -- это вычисляемый столбец
Store[CalcCol] = SUM ( SalesfSalesAmount] ) -- это вычисляемый столбец
-- в таблице Store
CalcMsr := SUM ( SalesfSalesAmount] ) --а это мера
Наконец, мы советуем при объявлении вычисляемых столбцов и мер всегда указывать
название соответствующей таблицы для вычисляемых столбцов и никогда - для мер.
Этого правила мы будем придерживаться во всех примерах данной книги.
Введение в агрегаторы и итераторы
Почти во всех моделях данных есть необходимость оперировать агрегирован-
ными значениями. DAX предлагает сразу несколько функций, агрегирующих
значения в столбцах таблицы и возвращающих скалярный результат. Мы на-
зываем эту группу функциями агрегирования (aggregation functions). Например,
следующая мера вычисляет сумму по всему столбцу SalesAmount в таблице
Sales:
Sales := SUM ( SalesfSalesAmount] )
68 ГЛАВА 2 Знакомство c DAX
Функция SUM агрегирует все строки в таблице, если используется в вычис-
ляемом столбце. При использовании в мере в расчет берутся только строки,
проходящие через фильтры по срезам, строкам и столбцам в отчете.
В DAX много агрегирующих функций, в числе которых SUM, AVERAGE, MIN,
МАХ и STDEV, и их поведение отличается лишь способом агрегирования: SUM
возвращает сумму значений, MIN - минимальное число и т. д. Почти все эти
функции работают исключительно с числовыми значениями и датами, и лишь
функции MIN и МАХ способны оперировать со строками. Более того, DAX ни-
когда не учитывает при агрегировании пустые ячейки, и такое его поведение
отличается от Excel (подробнее об этом мы расскажем далее в данной главе).
Примечание Функции MIN и МАХ выделяются своим поведением: если они применяют-
ся с двумя параметрами,то возвращают из них минимальное или максимальное значение
соответственно. Таким образом, MIN (1, 2) вернет 1, а МАХ (1, 2) - 2. Подобное поведение
полезно, когда нужно сравнить результаты сложных выражений, поскольку позволяет из-
бежать многократного их повторения в коде с использованием функции IF.
Все описанные выше функции агрегирования работают исключительно со
столбцами. Таким образом, агрегирование применяется только к значениям
одного столбца. Но есть функции, способные оперировать, целыми выраже-
ниями, а не со столбцами. Из-за принципа своей работы они названы итераци-
онными функциями, или просто итераторами (iterators). Эти функции очень
полезны, особенно когда вам необходимо провести вычисления с использо-
ванием столбцов из связанных таблиц или снизить количество вычисляемых
столбцов в таблице.
Итераторы принимают как минимум два параметра: таблицу, в которой бу-
дет проводиться сканирование, и выражение, которое будет вычисляться для
каждой строки в таблице. После выполнения сканирования таблицы с вычис-
лением указанного выражения для каждой строки функция приступает к агре-
гированию результатов в соответствии со своей семантикой.
Например, мы можем рассчитать количество дней, требуемых для достав-
ки товаров по заказам в вычисляемом столбце с названием DaysToDeliver
и построить отчет по полученным данным. Его результаты представлены на
рис. 2.6. Заметьте, что в итоговом значении произведено суммирование дней,
что не несет никакой пользы в подобных расчетах:
Sales[DaysToDeUver] = INT ( Sales[Delivery Date] - Sales[Order Date] )
Итог с усредненным значением мы можем получить в мере с названием Avg-
Delivery, показывающей количество дней доставки для каждого заказа и сред-
нее значение в строке итогов:
AvgDelivery := AVERAGE ( Sales[DaysToDeliver] )
Результат работы этой меры показан на рис. 2.7.
Мера вычисляет среднее значение, применяя агрегирование к вычисляемо-
му столбцу. Но можно избежать этого промежуточного шага создания вычис-
ляемого столбца при помощи применения итератора, что позволит сэконо-
мить ресурсы. И хотя функция AVERAGE не умеет агрегировать выражения,
ГЛАВА 2 Знакомство с DAX 69
с этим прекрасно справляется ее коллега AVERAGEX. При помощи нее мы мо-
жем пройти по всем строкам в таблице, вычислить необходимое значение для
каждой строки и агрегировать полученные результаты. Вот код для меры, по-
зволяющей ограничиться одним шагом вместо двух:
AvgDelivery :=
AVERAGEX (
Sales,
INT ( Sales[Delivery Date] - Sales[Order Date] )
)
SalesKey ж Order Date Delivery Date DaysToDeliver
200701022CS425-0013 01/02/2007 01/08/2007 6
200701022CS425-0014 01/02/2007 01/09/2007 7
200701022CS425-0015 01/02/2007 01/10/2007 8
200701022CS425-0016 01/02/2007 01/11/2007 9
200701022CS425-0017 01/02/2007 01/12/2007 10
200701022CS425-0018 01/02/2007 01/13/2007 11
200701023CS425-0202 01/02/2007 01/08/2007 6
200701023CS425-0203 01/02/2007 01/09/2007 7
200701023CS425-0204 01/02/2007 01/10/2007 8
200701023CS425-0205 01/02/2007 01/11/2007 9
Total 848075
Рис. 2.6 В итоговой строке мы видим сумму дней,
хотя могли бы пожелать увидеть среднее значение
SalesKey ▲ Order Date Delivery Date DaysToDeliver AvgDelivery
200701022CS425-0013 01/02/2007 01/08/2007 6 6.00
200701022CS425-0014 01/02/2007 01/09/2007 7 7.00
200701022CS425-0015 01/02/2007 01/10/2007 8 8.00
200701022CS425-0016 01/02/2007 01/11/2007 9 9.00
200701022CS425-0017 01/02/2007 01/12/2007 10 10.00
200701022CS425-0018 01/02/2007 01/13/2007 11 11.00
200701023CS425-0202 01/02/2007 01/08/2007 6 6.00
200701023CS425-0203 01/02/2007 01/09/2007 7 7.00
200701023CS425-0204 01/02/2007 01/10/2007 8 8.00
200701023CS425-0205 01/02/2007 01/11/2007 9 9.00
Total 848075 8.46
Рис. 2.7 В новой мере показано агрегирование дней по среднему
Главным преимуществом такого подхода является то, что здесь мы не по-
лагаемся на вычисляемый столбец. В результате мы вовсе можем обойтись без
него, возложив всю функциональность на итератор.
Большинство итерационных функций имеют такие же названия, как у их не-
итерационных аналогов, но с добавлением буквы X. Например, у функции SUM
есть зеркальное отражение в виде SUMX, у MIN - MINX. Но есть и итераторы,
не имеющие аналогов среди обычных функций. Далее в этой книге вы позна-
70 ГЛАВА 2 Знакомство с DAX
комитесь с функциями FILTER, ADDCOLUMNS, GENERATE и другими - все они,
по сути, являются итераторами, хоть и не выполняют агрегирующие действия.
Впервые используя DAX, вы могли бы подумать, что итерационные функ-
ции по своей природе должны быть весьма медленными. Метод построчного
обхода таблицы может показаться довольно затратным для центрального про-
цессора (CPU). На самом же деле итераторы работают очень быстро и ни в чем
не уступают традиционным агрегирующим функциям. Фактически обычные
агрегаторы являются укороченными версиями соответствующих итераци-
онных функций, представляя так называемый синтаксический сахар (syntax
sugar).
Посмотрите на следующую функцию:
SUM ( Sales[Quantity] )
При выполнении это выражение переводится в форму соответствующей
итерационной функции такого вида:
SUMX ( Sales; Sales[Quantity] )
Единственным преимуществом использования функции SUM в данном
случае является более короткое выражение. При этом между функциями SUM
и SUMX нет никакой разницы в скорости выполнения применительно к одно-
му столбцу. Это фактически синонимы.
Подробнее мы коснемся поведения этих функций в главе 4. Именно там мы
познакомим вас с концепцией контекста вычисления и более детально опи-
шем работу итерационных функций.
Использование распространенных функций DAX
Теперь, когда вы познакомились с фундаментальными основами DAX и научи-
лись перехватывать ошибки в коде, пришло время пробежаться по самым рас-
пространенным функциям и выражениям языка.
Функции агрегирования
В предыдущих разделах мы кратко коснулись основных агрегаторов SUM, AVE-
RAGE, MIN и МАХ. Вы узнали, что функции SUM и AVERAGE, например, работа-
ют только со столбцами числового типа.
DAX также предлагает альтернативный синтаксис для функций агрегирова-
ния, унаследованных из Excel, с добавлением окончания А к имени функции,
чтобы они выглядели и вели себя как в Excel. Однако эти функции могут ока-
заться полезными только применительно к столбцам с типом Boolean, в кото-
рых значение TRUE расценивается как 1, a FALSE - как 0. Применительно к тек-
стовым столбцам эти функции будут давать 0. Так что использование функции
МАХА со столбцом текстового типа вне зависимости от его содержимого всегда
выдаст 0. Более того, DAX никогда не учитывает в расчетах агрегации пустые
ячейки. И хотя эти функции могут применяться к нечисловым столбцам без
ГЛАВА 2 Знакомство с DAX 71
возврата ошибки, результат их будет не так полезен, поскольку в DAX отсут-
ствует автоматическое приведение текстовых столбцов к числовым. Это функ-
ции AVERAGE A, COUNTA, MINA и МАХА. Мы бы советовали не использовать
функции, поведение которых в будущем сохранится для обратной совмести-
мости с существующим кодом.
Примечание Несмотря на то что названия этих функций совпадают со статистическими
функциями, в DAX и Excel они используются по-разному, поскольку в DAX каждый стол-
бец хранит данные строго одного типа, и именно этим типом определяется поведение
агрегирующей функции. В Excel допустимо размещать в одном столбце разнородную
информацию,тогда как в DAX это невозможно, в нем столбцы строго типизированы. Если
столбцу в Power Bl назначен числовой тип, все значения в нем могут быть либо число-
выми, либо пустыми. Если столбец имеет текстовый тип, все эти функции (кроме COUNTA)
будут возвращать для него 0, даже если текст может быть переведен в число. В то же
время в Excel оценка значений на их принадлежность числовому типу производится от
ячейки к ячейке. По этой причине такие функции будут бесполезны применительно к тек-
стовым столбцам. В DAX только функции MIN и МАХ поддерживают работу с текстовыми
значениями.
\_______________________________________________________________)
Функции, которые вы изучили ранее, полезны для выполнения агрегирова-
ния значений. Но иногда вам может потребоваться просто посчитать значе-
ния, а не агрегировать их. Для этих целей DAX представляет сразу несколько
функций:
COUNT оперирует со всеми типами данных, за исключением Boolean;
COUNTA работает со столбцами всех типов;
COUNTBLANK возвращает количество пустых ячеек (BLANK или пустые
строки)в столбце;
COUNTROWS подсчитывает количество строк в таблице;
DISTINCTCOUNT возвращает количество уникальных значений в столб-
це, включая пустые значения, если они есть;
DISTINCTCOUNTNOBLANK возвращает количество уникальных значений
в столбце, исключая пустые значения.
COUNT и COUNTA - почти идентичные функции в DAX. Они возвращают ко-
личество непустых значений в столбце вне зависимости от их типа. Эти функ-
ции унаследованы от Excel, где COUNTA подсчитывает значения всех типов
данных, включая текст, тогда как COUNT работает только с числовыми значе-
ниями. Если нужно подсчитать количество пустых значений в столбце, можно
воспользоваться функцией COUNTBLANK, которая наравне учитывает BLANK
и пустые значения. Наконец, для подсчета количества строк в таблице сущест-
вует функция COUNTROWS, принимающая в качестве параметра не столбец,
а таблицу.
Последние две функции из этого раздела - DISTINCTCOUNT и DISTINCT-
COUNTNOBLANK - очень полезны, поскольку делают ровно то, что и должны,
исходя из названий, - считают количество уникальных значений в столбце, ко-
торый принимают в качестве единственного параметра. Разница между ними
заключается в том, что функция DISTINCTCOUNT считает BLANK как одно из
значений, тогда как DISTINCTCOUNTNOBLANK игнорирует такие значения.
72 ГЛАВА 2 Знакомство с DAX
Примечание Функция DISTINCTCOUNT появилась в DAX в версии 2012 года. До этого мо-
мента для подсчета уникальных значений в столбце мы вынуждены были пользоваться
конструкцией COUNTROWS ( DISTINCT (table [column] ) ). Конечно, использовать одну функцию
DISTINCTCOUNT для этого лучше и проще. Функция DISTINCTCOUNTNOBLANK появилась
только в 2019 году, привнеся в DAX простую семантику выражения COUNT DISTINCT из
SQL без необходимости написания длинных выражений.
\__Z_______________________________________________________________________)
Логические функции
Иногда нам необходимо встроить логическое условие в выражение - напри-
мер, для осуществления различных вычислений в зависимости от значения
в столбце или перехвата ошибки. В этих случаях нам помогут логические функ-
ции. В разделе, посвященном обработке ошибок, мы уже упомянули две важ-
нейшие функции из этой группы: IF и IFERROR. Первую из них мы также опи-
сывали в разделе с условными выражениями.
Логические функции - одни из простейших в DAX и выполняют ровно те
действия, которые заложены в их названиях. К таким функциям относятся
AND, FALSE, IF, IFERROR, NOT, TRUE и OR. Например, если вам необходимо по-
лучить результат перемножения цены на количество только в случае, если цена
(Price) содержит числовое значение, вы можете воспользоваться следующим
шаблоном:
Sales[Amount] = IFERROR ( Sales[Quantity] * Sales[Price]; BLANK ( ) )
Если бы мы не использовали функцию IFERROR, то при наличии недопусти-
мого значения в столбце Price каждая строка вычисляемого столбца содержа-
ла бы ошибку, поскольку присутствие ошибки в одной строке автоматически
распространяется на весь столбец. Применение функции IFERROR позволило
перехватить возникшую ошибку в строке и заменить значение в ней на BLANK.
Еще одной интересной функцией из этой группы является функция SWITCH.
Она полезна в случае, если в столбце содержится небольшое количество уни-
кальных значений и мы хотим реализовать разное поведение выражения
в зависимости от текущего значения. Представьте, что в столбце Size (размер)
таблицы Product содержатся значения S, М, L или XL и нам необходимо рас-
шифровать их. Для этого мы можем создать вычисляемый столбец со следую-
щей формулой с вложенными функциями IF:
'Product'[SizeDesc] =
IF (
'Product'[Size] = "S";
"Small";
IF (
'Product'[Size] = "M";
"Medium";
IF (
'Product'[Size] = "L";
"Large";
IF (
'Product'[Size] = "XL";
ГЛАВА 2 Знакомство c DAX 73
"Extra Large";
"Other"
)
)
)
)
Более лаконичная запись формулы с использованием функции SWITCH мог-
ла бы выглядеть так:
'Product'[SizeDesc] =
SWITCH (
'Product'[Size];
"S"; "Small";
"M"; "Medium";
"L"; "Large";
"XL"; "Extra Large";
"Other"
)
Код стал лучше читаться, но быстрее он при этом не стал, поскольку при вы-
полнении функция SWITCH все равно заменяется на вложенные инструкции IF.
Примечание Функция SWITCH часто используется для проверки параметра и опреде-
ления результирующего значения меры. Например, вы могли бы создать таблицу пара-
метров, состоящую из трех строк со значениями YTD, MTD и QTD, и дать пользователю
возможность выбирать тип агрегации в мере. Так часто делали до 2019 года. Теперь, когда
появились группы вычислений, которые мы подробно обсудим в главе 9, подобная необ-
ходимость отпала. Группы вычислений являются более предпочтительным инструментом
для вычисления значений с учетом выбранных пользователем параметров.
Совет. Есть один любопытный способ использования функции SWITCH для осуществления
множественных проверок в одном выражении. Поскольку эта функция в итоге преобра-
зуется в набор вложенных функций IF, где выбирается первое совпавшее условие, можно
использовать множественные проверки следующим образом:
SWITCH (
TRUE ();
Product[Size] = "XL" && Product[Color] = "Red"; "Red and XL";
Product[Size] = "XL" && Product[Color] = "Blue"; "Blue and XL";
Product[Size] = "L" && Product[Color] = "Green"; "Green and L"
)
Использование функции TRUE в качестве первого параметра говорит: «Верни первый
набор условий, соответствующий TRUE».
Информационные функции
При необходимости проанализировать тип выражения вы можете воспользо-
ваться одной из информационных функций DAX. Все эти функции возвращают
значение типа Boolean и могут быть использованы в любом логическом выра-
74 ГЛАВА 2 Знакомство с DAX
жении. К информационным функциям относятся: ISBLANK, ISERROR, ISLOGI-
CAL, ISNONTEXT, ISNUMBER и ISTEXT.
Когда в качестве параметра вместо выражения передается столбец, функции
ISNUMBER, ISTEXT и ISNONTEXT всегда возвращают TRUE или FALSE в зависи-
мости от типа данных столбца и проверки на пустоту каждой ячейки. В резуль-
тате эти функции становятся почти бесполезными в DAX - они просто были
унаследованы в первой версии движка от Excel.
Вам, должно быть, интересно, можно ли использовать функцию ISNUMBER
с текстовым столбцом для проверки возможности конвертирования его зна-
чений в числа. К сожалению, такое применение этой функции невозможно.
Единственный способ проверить, можно ли перевести текст в число, в DAX -
попытаться это сделать и отловить соответствующую ошибку. Например, для
проверки, можно ли значение из столбца Price (имеющего текстовый тип) пе-
ревести в число, вы должны использовать код, подобный приведенному ниже:
Sales[IsPriceCorrect] = NOT ISERROR ( VALUE ( Sales[Price] ) )
Сначала движок DAX попытается перевести строку в число. Если ему это
удастся, он вернет TRUE (поскольку результатом функции ISERROR будет
FALSE), а иначе - FALSE (поскольку результатом ISERROR будет TRUE). Таким
образом, для строк, в которых в качестве цены проставлено текстовое значе-
ние «N/А», проверка не пройдет.
Если же мы попытаемся использовать функцию ISNUMBER для аналогичной
проверки, как в примере ниже, результат всегда будет FALSE:
Sales[IsPriceCorrect] = ISNUMBER ( Sales[Price] )
В данном случае функция ISNUMBER всегда будет возвращать FALSE, по-
скольку, согласно определению модели данных, столбец Price содержит тексто-
вую информацию вне зависимости от того, что конкретно введено в той или
иной строке.
Математические функции
Набор математических функций, доступных в DAX, схож с аналогичным набо-
ром в Excel - с похожим синтаксисом и поведением. К самым распространен-
ным математическим функциям можно отнести следующие: ABS, EXP, FACT,
LN, LOG, LOGIO, MOD, PI, POWER, QUOTIENT, SIGN и SORT. Для генерации слу-
чайных чисел в DAX применяются функции RAND и RANDBETWEEN. Используя
функции EVEN и ODD, можно проверить числа. Функции GCD и LCM полезны
для вычисления наибольшего общего делителя и наименьшего общего крат-
ного двух чисел соответственно. Функция QUOTIENT возвращает целую часть
результата деления двух чисел.
Также стоит упомянуть несколько функций округления чисел. Фактически вы
можете самыми разными способами добиться одного и того же результата.
Внимательно рассмотрите формулы следующих столбцов с результатами вы-
числений, представленными на рис. 2.8:
FLOOR = FLOOR ( Tests[Value]; 0,01 )
TRUNC = TRUNC ( Tests[Value]; 2 )
ГЛАВА 2 Знакомство c DAX 75
ROUNDDOWN = ROUNDDOWN ( Tests[Value]; 2 )
MROUND = MROUND ( Tests[Value]; 0,01 )
ROUND = ROUND ( Tests[Value]; 2 )
CEILING = CEILING ( Tests[Value]; 0,01 )
ISO.CEILING = ISO.CEILING ( Tests[Value]; 0,01 )
ROUNDUP = ROUNDUP ( Tests[Value]; 2 )
INT = INT ( Tests[Value] )
FIXED = FIXED ( Tests[Value]; 2; TRUE )
Test Value FLOOR TRUNC ROUNDDOWN MROUND ROUND CEILING ISO.CEILING ROUNDUP INT FIXED
А 1.123450 1.12 1.12 1.12 1.12 1.12 1.13 1.13 1.13 1 1.12
В 1.265000 1.26 1.26 1.26 1.26 1.27 1.27 1.27 1.27 1 1.27
С 1.265001 1.26 1.26 1.26 1.27 1.27 1.27 1.27 1.27 1 1.27
D 1.499999 1.49 1.49 1.49 1.50 1.50 1.50 1.50 1.50 1 1.50
Е 1.511110 1.51 1.51 1.51 1.51 1.51 1.52 1.52 1.52 1 1.51
F 1.000001 1.00 1.00 1.00 1.00 1.00 1.01 1.01 1.01 1 1.00
G 1.999999 1.99 1.99 1.99 2.00 2.00 2.00 2.00 2.00 1 2.00
Рис. 2.8 Результаты использования различных функций округления
Функции FLOOR, TRUNC и ROUNDDOWN похожи, за исключением способа
задания количества знаков округления. Функции CEILING и ROUNDUP дают
одинаковые результаты. Различия можно заметить в выводе функций MROUND
и ROUND.
Тригонометрические функции
DAX предлагает богатый выбор тригонометрических функций, среди которых
можно отметить COS, COSH, СОТ, СОТН, SIN, SINH, TAN и TANH. Префикс в виде
буквы А приведет к вычислению обратных тригонометрических функций:
арккосинуса, арксинуса и т. д. Мы не будем подробно останавливаться на этих
функциях, поскольку их действие весьма прозрачно.
Функции DEGREES и RADIANS помогут вам осуществить конверсию в граду-
сы и радианы соответственно, а функция SQRTPI вернет в качестве результата
квадратный корень из переданного параметра, предварительно умноженного
на число л.
Текстовые функции
Большинство текстовых функций в DAX похожи на свои аналоги из Excel, за
некоторыми исключениями. Среди текстовых функций можно выделить сле-
дующие: CONCATENATE, CONCATENATEX, EXACT, FIND, FIXED, FORMAT, LEFT,
LEN, LOWER, MID, REPLACE, REPT, RIGHT, SEARCH, SUBSTITUTE, TRIM, UPPER
и VALUE. Эти функции применяются для манипулирования текстом и извлече-
ния необходимой информации из строк, содержащих множество значений. На
рис. 2.9 показан пример извлечения имени и фамилии из строк, содержащих
перечисление через запятую имени, фамилии, а также обращения, которое не-
обходимо убрать из результата.
76 ГЛАВА 2 Знакомство с DAX
Name
Ferrari, Alberto
Ferrari, Mr., Alberto
Russo, Mr., Marco
Commal Comma2 FirstLastName SimpleConversion
8
8
6
Alberto Ferrari Ferrari, Alberto Ferrari
13 Alberto Ferrari Alberto Ferrari
11 Marco Russo Marco Russo
Рис. 2.9 Извлечение имени и фамилии посредством текстовых функций
Для начала необходимо получить позиции запятых в исходном тексте. После
этого мы используем полученную информацию для извлечения нужных со-
ставляющих из текста. Формула вычисляемого столбца SimpleConversion может
вернуть неправильный результат, если в строке меньше двух запятых. К тому
же она выдаст ошибку, если запятых нет вовсе. Вторая формула - для вычисля-
емого столбца FirstLastName - учитывает эти нюансы и выводит правильный
результат в случае недостатка запятых.
People[Commal] = IFERROR ( FIND ( People[Name] ); BLANK ( ) )
People[Comma2] = IFERROR ( FIND ( " ,People[Name]; People[Commal] + 1 ); BLANK ( ) )
People[SimpleConversion] =
MID ( People[Name]; People[Comma2] + 1; LEN ( People[Name] ) )
& " "
& LEFT ( People[Name]; People[Commal] - 1 )
People[FirstLastName] =
TRIM (
MID (
People[Name];
IF ( ISNUMBER ( People[Comma2] ); People[Comma2]; People[Commal] ) + 1;
LEN ( People[Name] )
& IF (
ISNUMBER ( People[Commal] );
" " & LEFT ( People[Name]; People[Commal] - 1 );
Как видите, формула для вычисляемого столбца FirstLastName получилась
довольно длинной, но нам пришлось пойти на такие ухищрения, чтобы избе-
жать возникновения ошибок, которые распространились бы на весь столбец.
Функции преобразования
Ранее вы усвоили, что DAX осуществляет автоматическое преобразование ти-
пов данных под нужды конкретного оператора. Несмотря на это, в языке также
есть несколько полезных функций, позволяющих преобразовывать типы дан-
ных явно.
Функция CURRENCY, к примеру, предпринимает попытку преобразовать ар-
гумент к типу Currency, тогда как INT- к целочисленному типу. Функции DATE
и TIME принимают в качестве параметра дату и время соответственно и воз-
вращают корректное значение типа DateTime. Функция VALUE преобразовы-
ГЛАВА2 Знакомство с DAX 77
вает текстовое значение в числовое, a FORMAT принимает в качестве первого
параметра число, а в качестве второго - текстовый формат и выполняет соот-
ветствующее преобразование. Часто функции FORMAT и DateTime применяют-
ся совместно. Например, следующий пример возвращает строку «2019 янв 12»:
= FORMAT ( DATE ( 2019; 01; 12 ); "уууу iwi dd" )
Обратная операция преобразования строки в тип DateTime выполняется при
помощи функции DATEVALUE.
DATEVALUE с датами в разных форматах
Функция DATEVALUE характеризуется разным поведением в зависимости от формата.
Согласно европейскому стандарту даты записываются в виде «dd/mm/уу»,тогда как
американский формат предписывает указывать сначала месяц: «mm/dd/уу». Таким
образом, дата 28 февраля будет представлена по-разному в двух форматах. Если вы
передадите в функцию DATEVALUE строку, которую невозможно будет преобразовать
в корректную дату с использованием региональных настроек по умолчанию, вместо
того чтобы выдать ошибку, DAX попытается поменять местами день и месяц. Функция
DATEVALUE также поддерживает недвусмысленный формат даты в виде «уууу-mm-
dd». Например, следующие три выражения вернут 28 февраля 2018 года вне зави-
симости от региональных настроек.
DATEVALUE ( "28/02/2018" )
DATEVALUE ( "02/28/2018" )
DATEVALUE ( "2018-02-28" )
- - Это 28 февраля в европейском формате
- - Это 28 февраля в американском формате
- - Это 28 февраля вне зависимости от формата
Бывает, что функция DATEVALUE не генерирует ошибку даже в тех случаях, когда вы
от нее этого ожидаете. Но так уж задумано разработчиками.
Функции для работы с датой и временем
Работа с датой и временем - неотъемлемая часть любой аналитической дея-
тельности. В DAX есть множество функций для оперирования с календарными
вычислениями. Некоторые из них перекликаются с аналогичными функциями
из Excel, облегчая преобразования в/из формата DateTime. Вот лишь несколько
функций для работы с датой и временем в DAX: DATE, DATEVALUE, DAY, EDATE,
EOMONTH, HOUR, MINUTE, MONTH, NOW, SECOND, TIME, TIMEVALUE, TODAY,
WEEKDAY, WEEKNUM, YEAR и YEARFRAC.
Этот инструментарий предназначен для работы с календарем, но в него не
входят специальные функции логики операций со временем (time intelligence),
позволяющие, к примеру, сравнивать агрегированные значения из разных лет
и рассчитывать меры нарастающим итогом с начала года. Эти функции состав-
ляют отдельный набор инструментов DAX, с которым мы подробно познако-
мимся только в восьмой главе.
Как мы уже упоминали ранее, значения типа данных DateTime внутренне
хранятся как числа с плавающей запятой, где целая часть представляет коли-
чество дней, прошедших с 30 декабря 1899 года, а дробная - долю текущего
дня. Таким образом, часы, минуты и секунды преобразуются в десятичные
78 ГЛАВА 2 Знакомство с DAX
доли дня. Получается, что прибавление целого числа к дате фактически пере-
носит ее на это количество дней вперед. Но вам, возможно, покажутся более
удобными функции для извлечения дня, месяца и года из определенной даты.
Следующие формулы лежат в основе вычисляемых столбцов, показанных на
рис. 2.10:
'Date'[Day] = DAY ( Calendar[Date] )
'Date'[Month] = FORMAT ( Calendar[Date]; "rwim" )
'Date'[MonthNumber] = MONTH ( Calendar[Date] )
'Date'[Year] = YEAR ( Calendar[Date] )
Date Day Month Year
1/1/2010 1 January 2010
1/2/2010 2 January 2010
1/3/2010 3 January 2010
1/4/2010 4 January 2010
1/5/2010 5 January 2010
1/6/2010 6 January 2010
1/7/2010 7 January 2010
1/8/2010 8 January 2010
1/9/2010 9 January 2010
Рис. 2.10 Извлечение составляющих частей даты при помощи специальных функций
Функции отношений
В DAX есть две полезные функции, которые позволят вам осуществлять навига-
цию по связям внутри модели данных. Это функции RELATED и RELATEDTABLE.
Вы уже знаете, что вычисляемые столбцы могут ссылаться на значения дру-
гих столбцов в таблице, в которой они определены. Таким образом, вычисляе-
мый столбец, созданный в таблице Sales, может обращаться к любым столбцам
из этой таблицы. А что, если вам понадобится обратиться к столбцам другой
таблицы? Вообще, в формулах этого делать нельзя, за исключением случая,
когда таблица, на которую вы хотите сослаться, объединена с текущей табли-
цей при помощи связи. Так что вы легко можете обратиться к столбцу в связан-
ной таблице посредством функции RELATED.
Представьте, что вам необходимо создать вычисляемый столбец в таблице
Sales, в котором будет применяться понижающий коэффициент на базовую
стоимость в случае принадлежности проданного товара категории Cell phones
(«Мобильные телефоны»). Чтобы это сделать, нам необходимо будет как-то об-
ратиться к признаку категории товара, который находится в другой таблице.
Но, как вы видите по рис. 2.11, от таблицы Sales можно добраться до Product
Category через промежуточные таблицы Product и Product Subcategory.
Вне зависимости от того, через сколько связей придется пройти до нужной
таблицы, движок DAX отыщет нужную информацию и вернет в исходную фор-
мулу. Таким образом, формула для вычисляемого столбца AdjustedCost может
выглядеть следующим образом:
ГЛАВА 2 Знакомство с DAX 79
Sales[AdjustedCost] =
IF (
RELATED ( 'Product Category'[Category] ) = "Cell Phone";
Sales[Unit Cost] * 0,95;
Sales[Unit Cost]
)
Sa let
Я AdjustedCcwt
T Curer^yKey
П Ctistorve'Key
Delivery Date
□ Net Price
Г Order Date
~ Order Line Nurr.oer
”1 Order Nun-^er
—‘ orcurDateKey
Product
rz Available Date
ГЧ Brand
П Co or
Л Manufacturer
a product Code
Product Name
~1 ProductKey
П ProductSubcategoryKey
П unit соя
1 Unit Fnce
Product Sub category
Я ProductCategoryKey
□ ProductSubcategoryKey
Subcitegory
Я Subcategory code
Рис. 2.11 Таблица Soles опосредованно связана с таблицей Product Category
Функция RELATED обеспечивает доступ по связи со стороны «многие» к сто-
роне «один», поскольку в этом случае у нас будет максимум одна целевая стро-
ка. Если соответствующих строк в связанной таблице не будет, функция RE-
LATED вернет значение BLANK.
Если вам нужно обратиться по связи со стороны «один» к стороне «многие»,
функция RELATED вам не поможет, поскольку в этом случае результатом может
быть сразу несколько строк. Здесь необходимо использовать функцию RELAT-
EDTABLE. Результатом выполнения этой функции будет таблица, содержащая
все связанные строки по запросу, соответствующие выбранной строке. Напри-
мер, если вас интересует, сколько товаров содержится в каждой категории, вы
можете создать вычисляемый столбец в таблице Product Category со следующей
формулой:
'Product Category'[NumOfProducts] = COUNTROWS ( RELATEDTABLE ( Product ) )
Как видно по рис. 2.12, в этом столбце будет отображено количество товаров
по каждой категории.
Category
NumOfProducts
Audio 115
Cameras and camcorders 372
Cell phones 285
Computers 606
Games and Toys 166
Home Appliances 661
Music, Movies and Audio Books 90
TV and Video 222
Рис. 2.12 Количество товаров по категориям
можно посчитать функцией RELATEDTABLE
80 ГЛАВА 2 Знакомство с DAX
Как и RELATED, функция RELATEDTABLE может проходить через целую це-
почку связей, всегда следуя от стороны «один» к стороне «многие». Часто эта
функция используется вместе с итераторами. Например, если нам нужно для
каждой категории перемножить количество на цену и просуммировать резуль-
таты, можно написать следующую формулу в вычисляемом столбце:
'Product Category'[CategorySales] =
SUMX (
RELATEDTABLE ( Sales );
Sales[Quantity] * Sales[Net Price]
)
Результат этого вычисления показан на рис. 2.13.
Category
CategorySales
Audio $384,518.16
Cameras and camcorders $7,192,581.95
Cell phones $1,604,610.26
Computers $6,741,548.73
Games and Toys $360,652.81
Home Appliances $9,600,457.04
Music, Movies and Audio Books $314,206.74
TV and Video $4,392,768.29
Рис. 2.13 С использованием функции RELATEDTABLE и итератора
мы смогли получить сумму продаж по категориям
Поскольку мы имеем дело с вычисляемым столбцом, результаты сохраня-
ются в таблице и не меняются в зависимости от выбора пользователя в отчете,
как в случае с мерой.
Заключение
В этой главе вы познакомились с некоторыми функциями языка DAX и встре-
тились с фрагментами кода. После одного прочтения вы могли не запомнить
все функции, но чем чаще вы будете их использовать на практике, тем быстрее
привыкнете к ним.
Наиболее важные моменты, которые вы узнали из этой главы:
вычисляемые столбцы являются частью таблицы, в которой они созданы,
и значения в них рассчитываются на этапе обновления данных, а не ме-
няются в зависимости от выбора пользователя;
меры представляют собой вычисления на языке DAX. В отличие от вы-
числяемых столбцов, значения в них рассчитываются не в момент обнов-
ления данных, а в момент запроса. Соответственно, выбор пользователя
в отчетах будет влиять и на значения мер;
ГЛАВА 2 Знакомство с DAX 81
в выражениях DAX могут возникать ошибки, и предпочтительно заранее
выявлять их при помощи соответствующих условий, а не ждать, пока они
возникнут, после чего осуществлять их перехват;
агрегирующие функции вроде SUM полезны при работе со столбцами.
Если вам необходимо агрегировать целые выражения, можно прибег-
нуть к помощи итерационных функций совместно с агрегаторами. Такие
функции сканируют таблицу, вычисляя значения для каждой строки, пос-
ле чего выполняют соответствующую агрегацию.
В следующей главе мы перейдем к изучению важных табличных функций
языка DAX.
ГЛАВА 3
Использование основных
табличных функций
В этой главе вы познакомитесь с базовыми табличными функциями языка
DAX. Табличные функции (table functions) отличаются от обычных тем, что воз-
вращают не скалярные значения, а целые таблицы. Они бывают очень полез-
ны в запросах DAX и сложных вычислениях, требующих прохода по таблицам.
Здесь мы покажем вам несколько примеров таких вычислений.
В данной главе мы лишь познакомим вас с концепцией табличных функций
и покажем несколько из них в действии, а не будем подробно описывать работу
всех табличных функций языка. С большим количеством функций мы столк-
немся при дальнейшем их изучении в главах 12 и 13. Здесь же мы поработаем
с самыми распространенными табличными функциями DAX и посмотрим, как
их можно использовать в различных сценариях, включая скалярные выраже-
ния на DAX.
Введение в табличные функции
До сих пор вы видели выражения на DAX, возвращающие строки или числа.
Такие выражения называются скалярными (scalar expressions). Создавая меру
или вычисляемый столбец, вы, по сути, пишете скалярные выражения, как на
примерах ниже:
= 4 + 3
= "DAX - прекрасный язык"
= SUM ( Sales[Quantity] )
Главной целью создания мер является их вывод в отчетах, сводных таблицах
и графиках. В конце концов, в основе всех этих отчетов лежат цифры - иными
словами, скалярные выражения. И все же при вычислении этих выражений вам
нередко приходится использовать таблицы. Например, в простой мере, вычис-
ляющей сумму продаж, для итераций используется таблица:
Sales Amount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
В этой формуле итератор SUMX проходит по таблице Sales. Так что, несмотря
на то что итоговым результатом будет скалярное выражение, в процессе его
вычисления мы использовали таблицу Sales. Также мы можем проходить не по
ГЛАВА 3 Использование основных табличных функций 83
таблице, а по результату табличной функции, как показано ниже. Тут мы вы-
числяем сумму продаж только по товарам, купленным в количестве двух штук
и более:
Sales Amount Multiple Items :=
SUMX (
FILTER (
Sales;
Sales[Quantity] > 1
);
Sales[Quantity] * Sales[Net Price]
)
В этой формуле мы использовали функцию FILTER вместо ссылки на таб-
лицу. Как ясно из названия, эта функция фильтрует содержимое таблицы по
определенному условию. Подробнее мы расскажем об этой функции позже.
Сейчас же вам достаточно знать, что любую ссылку на таблицу в выражениях
можно заменить на результат выполнения табличной функции.
Важно В предыдущем примере мы использовали фильтрацию совместно с функцией
суммирования. Это не лучшая практика. В следующих главах мы покажем, как строить
более гибкие и эффективные фильтры при помощи функции CALCULATE. Здесь же мы не
пытаемся научить вас писать оптимальные запросы на DAX, а просто показываем, как
табличные функции можно использовать в простых выражениях. Позже мы применим эти
концепции к более сложным сценариям.
В главе 2 вы научились использовать переменные как составную часть вы-
ражений на DAX. Там мы хранили в переменных скалярные величины. Но они
вполне подходят и для хранения таблиц. Предыдущий пример можно было бы
переписать такс использованием переменной:
Sales Amount Multiple Items :=
VAR
MultipleltemSales = FILTER ( Sales; Sales[Quantity] > 1 )
RETURN
SUMX (
MultipleltemSales;
Sales[Quantity] * Sales[Unit Price]
)
В переменной MultipleltemSales будет храниться целая таблица, поскольку ей
присваивается результат выполнения табличной функции. Мы настоятельно
советуем вам использовать переменные всегда, когда это возможно, ведь они
существенно облегчают чтение кода. К тому же, просто присваивая значения
переменным, вы одновременно создаете документацию к своему коду.
Также в вычисляемом столбце или внутри итерации вы можете использо-
вать функцию RELATEDTABLE для доступа к строкам связанной таблицы. На-
пример, в следующем вычисляемом столбце в таблице Product мы рассчитаем
сумму продаж по соответствующему товару:
84 ГЛАВА Ъ Использование основных табличных функций
'Product'[Product Sales Amount] =
SUMX (
RELATEDTABLE ( Sales );
Sales[Quantity] * Sales[Unit Price]
)
Кроме того, табличные функции можно вкладывать одну в другую. В следу-
ющем примере мы вычисляем сумму продаж только по товарам, которые про-
давались в количестве больше одной штуки:
'Product'[Product Sales Amount Multiple Items] =
SUMX (
FILTER (
RELATEDTABLE ( Sales );
Sales[Quantity] > 1
);
Sales[Quantity] * Sales[Unit Price]
)
В этом примере функция RELATEDTABLE вложена внутрь FILTER. В таких слу-
чаях движок DAX сначала вычисляет результат вложенной функции, а затем
переходит к выполнению внешней.
Примечание Как вы увидите дальше, вложенные табличные функции иногда могут
вводить в замешательство, поскольку порядок выполнения функций CALCULATE/CALCU-
LATETABLE и FILTER отличается. В следующем разделе мы познакомимся с поведением
функции FILTER. Описание функций CALCULATE и CALCULATETABLE будет дано в главе 5.
\________________________________________________________________________________J
Как правило, мы не можем использовать результат табличной функции
в качестве значения для меры или вычисляемого столбца, поскольку они тре-
буют скалярных выражений. Но мы вправе присвоить результат выполнения
табличной функции вычисляемой таблице. Вычисляемая таблица (calculated
table) представляет собой таблицу, содержимое которой определяется выраже-
нием на DAX, а не загружается из источника.
Например, мы можем создать вычисляемую таблицу, содержащую все това-
ры с ценой за единицу, превышающей 3000. Для этого достаточно использо-
вать следующее выражение:
ExpensiveProducts =
FILTER (
'Product';
'Product'[Unit Price] > 3000
)
Создание вычисляемых таблиц допустимо в Power BI и Analysis Services, но
не в Power Pivot для Excel (на 2019 год). Чем больше у вас будет опыта работы
с табличными функциями, тем чаще вы будете их использовать для создания
сложных моделей данных с применением вычисляемых таблиц и/или сложных
табличных выражений внутри мер.
ГЛАВА 3 Использование основных табличных функций 85
Введение в синтаксис EVALUATE
Редакторы запросов вроде DAX Studio бывают очень удобны в плане написа-
ния сложных табличных выражений. При использовании таких инструментов
ключевым словом для просмотра результата табличного выражения является
EVALUATE:
EVALUATE
FILTER (
'Product';
'Product'[Unit Price] > 3000
)
Можно запустить на выполнение предыдущий запрос в любом из инстру-
ментов, поддерживающих DAX (DAX Studio, Microsoft Excel, SQL Server Manage-
ment Studio, Reporting Services и т. д.). Запрос DAX - это обычное выражение,
возвращающее таблицу, которое предваряет ключевое слово EVALUATE. Пол-
ный синтаксис EVALUATE достаточно сложен, и мы рассмотрим его в главе 13.
Здесь же мы познакомим вас только с его базовыми параметрами, показанны-
ми ниже:
[DEFINE { MEASURE <tableName>[<name>] = <expression> }]
EVALUATE <table>
[ORDER BY {<expression> [{ASC | DESC}]} [; ...]]
Инструкция DEFINE MEASURE может оказаться полезной для определения
мер с областью действия, ограниченной данным запросом. Это бывает удоб-
но при отладке формул, поскольку мы можем определить локальную меру,
проверить ее как следует и интегрировать код в модель данных, только когда
все недостатки будут устранены. Большая часть синтаксиса EVALUATE опцио-
нальна, а простейшим использованием этого ключевого слова является из-
влечение всех строк и столбцов из существующей таблицы, как показано на
рис. 3.1.
EVALUATE 'Product'
1 EVALUATE ’Product'l
ProductKey Product Code Product Name Manufacturer Brand Color
17070702001 MGS Dal of Honor Airbor... Tailspin Toys Tailspin Toys Silver
17080702002 MGS Collector's Ml60 Tailspin Toys Tailspin Toys Black
17090702003 MGS Gears of War M170 Tailspin Toys Tailspin Toys Blue
17100702004 MGS Age of Empires III: T... Tailspin Toys Tailspin Toys Silver
17110702005 MGS Age of Empires III: T... Tailspin Toys Tailspin Toys Black
I 171ЭП7ПЭЛЛЛ КЛЛХС Cli/'ikt Cimi Y Л Tailmin Tnuc Tailmin Tr»wc Cili/or
Рис. 3.1 Результат выполнения запроса в DAX Studio
Инструкция ORDER BY, как понятно из названия, предназначена для сор-
тировки результирующего набора:
86 ГЛАВА 3 Использование основных табличных функций
EVALUATE
FILTER (
'Product';
'Product'[Unit Price] > 3000
)
ORDER BY
'Product'[Color];
'Product'[Brand] ASC;
'Product'[Class] DESC
Примечание Нужно отметить, что настройка Sort By Column (Сортировать по столбцу),
определенная для модели, не влияет на сортировку в запросе DAX. Порядок сортиров-
ки, указанный в инструкции EVALUATE, может включать только столбцы, присутствующие
в результирующем наборе.Так что клиент, создающий запросы DAX динамически, должен
считать свойство Sort By Column из метаданных модели, включить столбец для сортиров-
ки в запрос и затем дополнить инструкцию соответствующим условием ORDER BY.
\.___________________
Ключевое слово EVALUATE само по себе потенциала запросам не добавляет.
Вся мощь запросов заключается в умелом использовании множества таблич-
ных функций, доступных в языке DAX. В следующих разделах вы узнаете, как
можно производить эффективные вычисления, комбинируя разные таблич-
ные функции.
Введение в функцию FILTER
Теперь, когда вы знаете, что из себя представляют табличные функции, пришло
время поближе познакомиться с основными из них. Комбинируя и вкладывая
одну в другую табличные функции, можно производить достаточно сложные
вычисления в DAX. И первой мы представим вам табличную функцию FILTER.
Синтаксис этой функции следующий:
FILTER ( <table>; <condition> )
Функция FILTER принимает в качестве параметров таблицу и логическое
условие фильтрации. Результатом выполнения этой функции является на-
бор строк из исходной таблицы, удовлетворяющих заданному условию.
Функция FILTER одновременно является и табличной функцией, и итера-
тором. Чтобы вернуть результат, она сканирует таблицу построчно, сверяя
каждую строку с условием. Иными словами, функция FILTER запускает ите-
рации по таблице.
Например, следующее выражение возвращает все товары бренда Fabrikam:
FabrikamProducts =
FILTER (
'Product';
'Product'[Brand] = "Fabrikam"
)
ГЛАВА 3 Использование основных табличных функций 87
Функция FILTER часто используется для уменьшения количества строк
в итерациях. К примеру, если вам нужно посчитать сумму продаж по товарам
красного цвета, вы можете создать меру со следующей формулой:
RedSales :=
SUMX (
FILTER (
Sales;
RELATED ( 'Product'[Color] ) = "Red"
);
Sales[Quantity] * Sales[Net Price]
)
Результат вычисления этой меры можно видеть на рис. 3.2 вместе с общими
продажами.
Category Sales Amount RedSales
Audio 384,518.16 33,123.82
Cameras and camcorders 7,192,581.95 1,514.39
Cell phones 1,604,610.26 38,227.47
Computers 6,741,548.73 240,222.29
Games and Toys 360,652.81 19,938.31
Home Appliances 9,600,457.04 770,373.33
Music, Movies and Audio Books 314,206.74 6,702.49
TV and Video 4,392,768.29
Total 30,591,343.98 1,110,102.10
Рис. 3.2 Мера RedSales отражает продажи исключительно по красным товарам
Мера RedSales при вычислении проходит по ограниченному набору товаров
красного цвета. Функция FILTER добавляет свои условия к существующим. На-
пример, в строке Audio мера RedSales показывает продажи по красным товарам
из категории Audio.
Функции FILTER можно вкладывать одну в другую. В принципе, вложенные
функции FILTER идентичны использованию функции FILTER совместно с AND.
Следующие два выражения возвращают один и тот же результат:
FabrikamHighMarginProducts =
FILTER (
FILTER (
'Product';
'Product'[Brand] = "Fabrikam"
);
'Product'[Unit Price] > 'Product'[Unit Cost] * 3
)
FabrikamHighMarginProducts =
FILTER (
'Product';
AND (
88 ГЛАВА 3 Использование основных табличных функций
'Product'[Brand] = "Fabrikam";
'Product'[Unit Price] > 'Product'[Unit Cost] * 3
)
)
В то же время скорость двух этих запросов применительно к таблицам боль-
шого объема может быть разной в зависимости от избирательности (selectiv-
ity) условий. При разной избирательности условий первым лучше применять
то, которое обладает большей избирательностью, - именно его и стоит разме-
щать во вложенной функции FILTER.
Например, если в таблице есть много товаров бренда Fabrikam и мало това-
ров, цена которых минимум втрое превышает их стоимость, следует размес-
тить условие, сверяющее цену и стоимость, во вложенном запросе, как пока-
зано ниже. Сделав это, вы первым примените более ограничивающий фильтр,
что позволит значительно снизить количество итераций при последующей
проверке бренда:
FabrikamHighMarginProducts =
FILTER (
FILTER (
'Product';
'Product'[Unit Price] > 'Product'[Unit Cost] * 3
);
'Product'[Brand] = "Fabrikam"
)
Применение функции FILTER делает код более надежным и легким для
чтения. Представьте, что вам нужно посчитать количество красных товаров
в базе. Без использования табличных функций ваша формула могла бы вы-
глядеть так:
NumOfRedProducts :=
SUMX (
'Product';
IF ( 'Product'[Color] = "Red"; 1; 0 )
)
Внутренний IF возвращает 1 или 0 в зависимости от цвета товара, а функция
SUMX подсчитывает сумму получившихся единичек. И хотя эта формула рабо-
тает, выглядит она не лучшим образом. Можно сформулировать код для меры
более изящно:
NumOfRedProducts :=
COUNTROWS (
FILTER ( 'Product'; 'Product'[Color] = "Red" )
)
Это выражение лучше отражает намерения автора запроса. Более того, дан-
ный код лучше читается не только человеком, но и машиной, а это позволит
оптимизатору движка DAX построить более эффективный план выполнения
запроса, что положительно скажется на его быстродействии.
ГЛАВА Ъ Использование основных табличных функций 89
Введение в функции ALL и ALLEXCEPT
В предыдущем разделе вы познакомились с функцией FILTER, полезной в слу-
чаях, когда нам необходимо ограничить количество строк в результирую-
щем наборе. Но иногда нам требуется обратное - расширить набор строк для
нужд конкретных вычислений. В DAX есть множество функций для этих це-
лей, в числе которых ALL, ALLEXCEPT, ALLCROSSFILTERED, ALLNOBLANKROW
и ALLSELECTED. В этом разделе мы рассмотрим первые две функции из дан-
ного перечня. Последние две будут описаны далее в этой главе, а с функцией
ALLCROSSFILTERED вы познакомитесь только в главе 14.
Функция ALL возвращает все строки таблицы или все значения из одного
или нескольких столбцов в зависимости от переданных параметров. Напри-
мер, следующее выражение DAX вернет вычисляемую таблицу ProductCopy
с копиями всех строк из таблицы Product:
ProductCopy = ALL ( 'Product' )
Примечание Применять функцию ALL в вычисляемых таблицах нет необходимости, по-
скольку на них не оказывают влияния установленные фильтры в отчетах. Эта функция
будет гораздо более полезна в мерах, как будет показано далее.
\___________________________________________________________________________________J
Функция ALL может пригодиться при расчете процентов или соотношений,
поскольку позволяет игнорировать установленные в отчете фильтры. Пред-
ставьте, что вам понадобился отчет, показанный на рис. 3.3, в котором в раз-
ных столбцах отражены сумма продажи и доля этой суммы от общего итога по
продажам.
Category Sales Amount Sales Pct
Audio 384,518.16 1.26%
Cameras and camcorders 7,192,581.95 23.51%
Cell phones 1,604,610.26 5.25%
Computers 6,741,548.73 22.04%
Games and Toys 360,652.81 1.18%
Home Appliances 9,600,457.04 31.38%
Music, Movies and Audio Books 314,206.74 1.03%
TV and Video 4,392,768.29 14.36%
Total 30,591,343.98 100.00%
Рис. 3.3 В отчете показаны суммы продаж и их проценты от общих продаж
В мере Sales Amount рассчитывается сумма продажи путем осуществления
итераций по таблице Sales и перемножения значений в столбцах Sales [Quan-
tity] и SalesfNet Price]:
Sales Amount :=
SUMX (
90 ГЛАВА 3 Использование основных табличных функций
Sales;
Sales[Quantity] * Sales[Net Price]
)
Чтобы узнать долю суммы продаж, необходимо разделить этот показатель
на общую сумму продаж. Таким образом, нам нужно как-то получить итого-
вые продажи, несмотря на наложенный фильтр по категории. Это можно легко
сделать при помощи функции ALL. Следующая формула позволяет рассчитать
общие продажи вне зависимости от выбранных в отчете фильтров:
All Sales Amount :=
SUMX (
ALL ( Sales );
Sales[Quantity] * Sales[Net Price]
)
В этой формуле мы заменили Sales на ALL ( Sales ), тем самым применив
функцию ALL для обращения ко всей таблице. Теперь мы можем получить
долю продаж путем обычного деления:
Sales Pct := DIVIDE ( [Sales Amount]; [All Sales Amount] )
На рис. 3.4 показаны все три меры вместе.
Category Sales Amount All Sales Amount Sales Pct
Audio 384,518.16 30,591,343.98 1.26%
Cameras and camcorders 7,192,581.95 30,591,343.98 23.51%
Cell phones 1,604,610.26 30,591,343.98 5.25%
Computers 6,741,548.73 30,591,343.98 22.04%
Games and Toys 360,652.81 30,591,343.98 1.18%
Home Appliances 9,600,457.04 30,591,343.98 31.38%
Music, Movies and Audio Books 314,206.74 30,591,343.98 1.03%
TV and Video 4,392,768.29 30,591,343.98 14.36%
Total 30,591,343.98 30,591,343.98 100.00%
Рис. 3.4 В мере All Sales Amount все значения одинаковые и равны общей сумме продаж
Параметром функции ALL не может быть табличное выражение. Это должна
быть либо таблица, либо перечень столбцов. Вы уже увидели, что делает функ-
ция ALL с таблицей. А что будет, если дать ей список столбцов? В этом случае
функция вернет уникальный набор значений из переданных столбцов исход-
ной таблицы. Вычисляемая таблица Categories из следующего примера будет
содержать уникальные значения из столбца Category таблицы Product:
Categories = ALL ( 'Product'[Category] )
На рис. 3.5 показан результат этого вычисления.
В качестве параметров в функцию ALL можно передать несколько столбцов
из одной таблицы. В этом случае она вернет все уникальные комбинации зна-
чений этих столбцов. Например, мы можем получить список категорий с под-
ГЛАВА 3 Использование основных табличных функций 91
категориями, добавив в параметры функции ALL столбец ProductfSubcategory].
Результат данной функции показан на рис. 3.6:
Categories =
ALL (
'Product'[Category];
'Product'[Subcategory]
)
Category
Audio
Cameras and camcorders
Cell phones
Computers
Games and Toys
Home Appliances
Music, Movies and Audio Books
TV and Video
Рис. 3.5 Использование функции ALL
с указанием столбца позволило извлечь
список уникальных категорий товаров
Функция ALL игнорирует все ранее наложенные фильтры при вычислении
результата. При этом мы можем использовать ее в качестве параметра итера-
ционных функций, таких как SUMX и FILTER, или как фильтрующий аргумент
функции CALCULATE, с которой мы познакомимся в главе 5.
Если нам нужно включить большинство, но не все столбцы в итоговый резуль-
тат, мы можем вместо функции ALL воспользоваться ее коллегой - ALLEXCEPT.
Синтаксисом этой функции предусмотрена передача ссылки на таблицу, а также
столбцы, которые мы хотим исключить из результата. В итоге функция ALLEX-
CEPT вернет уникальные строки из оставшихся столбцов исходной таблицы.
Category
Audio
Audio
Audio
Cameras and camcorders
Cameras and camcorders
Cameras and camcorders
Cameras and camcorders
Cell phones
Cell phones
Cell phones
Cell phones
Subcategory
Bluetooth Headphones
MP4&MP3
Recording Pen
Camcorders
Cameras 84 Camcorders Accessories
Digital Cameras
Digital SLR Cameras
Cell phones Accessories
Home & Office Phones
Smart phones & PDAs
Touch Screen Phones
Рис. 3.6 Список содержит уникальные сочетания категорий и подкатегорий
Можно использовать функцию ALLEXCEPT для написания выражений на
DAX, включающих в итоговый результат столбцы, которые могут появиться
92 ГЛАВА 3 Использование основных табличных функций
в таблице в будущем. Например, если в таблице Product содержится пять столб-
цов (ProductKey, Product Name, Brand, Class, Color), следующие два выражения
вернут одинаковый результат:
ALL ( 'Product'[Product Name]; 'Product'[Brand]; 'Product'[Class] )
ALLEXCEPT ( 'Product'; 'Product'[ProductKey]; 'Product'[Color] )
Если мы в будущем добавим в таблицу еще два столбца Product[Unit Cost]
и Product[Unit Price], функция ALL проигнорирует их, тогда как функция ALLEX-
CEPT вернет эквивалент следующего выражения:
ALL (
'Product'[Product Name];
'Product'[Brand];
'Product'[Class];
'Product'[Unit Cost];
'Product'[Unit Price]
)
Иными словами, в функции ALL мы указываем столбцы, которые мы хотим
видеть, тогда как в ALLEXCEPT перечисляем столбцы, которые хотим исклю-
чить из вывода. Функция ALLEXCEPTчасто бывает полезна в качестве парамет-
ра функции CALCULATE при выполнении сложных вычислений, и подобная
конструкция редко поддается упрощению. Подробнее о таком использовании
функции ALLEXCEPT вы узнаете позже в этой книге.
Самые продаваемые категории и подкатегории
Для демонстрации работы ALL в качестве табличной функции представим, что нам нуж-
но создать панель мониторинга (dashboard) с отображением категории и подкатегории
товаров, сумма продажи по которым минимум в два раза превышает среднюю сумму
продажи. Для этого мы сначала должны вычислить среднюю сумму продажи по под-
категории, а затем, когда значение будет получено, вывести список подкатегорий, сумма
продажи по которым минимум вдвое больше этого среднего значения.
Следующий код осуществляет нужный нам расчет, и мы советуем вам подробно его
изучить, чтобы понять всю мощь применения табличных функций и переменных:
Bestcategories =
VAR Subcategories =
ALL ( 'Product'[Category]; 'Product'[Subcategory] )
VAR AverageSales =
AVERAGEX (
Subcategories;
SUMX ( RELATEDTABLE ( Sales ); Sales[Quantity] * Sales[Net Price] )
)
VAR TopCategories =
FILTER (
Subcategories;
VAR SalesOfCategory =
SUMX ( RELATEDTABLE ( Sales ); Sales[Quantity] * Sales[Net Price] )
RETURN
SalesOfCategory >= AverageSales * 2
)
ГЛАВА 3 Использование основных табличных функций 93
RETURN
TopCategories
В первой переменной (Subcategories) хранится список уникальных сочетаний катего-
рий и подкатегорий. В переменной AverageSales вычисляются средние значения суммы
продаж по каждой подкатегории. Наконец, в переменную TopCategories попадает список
из Subcategories, из которого будут удалены подкатегории, сумма продажи по которым не
превышает среднюю продажу AverageSales вдвое.
Результат выражения показан на рис. 3.7.
Category
Subcategory
Cameras and camcorders Camcorders
Cameras and camcorders Digital SLR Cameras
Computers Computers Home Appliances Laptops Projectors & Screens Washers & Dryers
Рис. 3.7. Самые продаваемые подкатегории, сумма продажи
по которым минимум вдвое превышает среднюю сумму продажи
После того как вы усвоите функцию CALCULATE и контекст фильтра, вы сможете на-
писать представленные выше вычисления с использованием гораздо более лаконичного
и эффективного синтаксиса. Но уже сейчас вы видите, что с помощью комбинирования
табличных функций в выражениях можно производить довольно сложные вычисления,
результаты которых могут быть помещены на панели мониторинга и в отчеты.
Введение в функции VALUES, DISTINCT
и пустые строки
В предыдущем разделе вы видели, что использование функции ALL со столбцом
в качестве параметра возвращает таблицу, состоящую из уникальных значе-
ний этого столбца. В DAX есть еще две похожие функции, служащие примерно
тем же целям, - VALUES и DISTINCT. Эти функции работают почти идентично,
единственным отличием является то, как они обрабатывают пустые строки,
которые могут присутствовать в таблице. Позже в этом разделе мы объясним
вам природу образования этих пустых строк, а пока сосредоточимся на этих
двух функциях.
Функция ALL всегда возвращает набор уникальных значений из столбца. Ре-
зультатом работы функции VALUES также будут уникальные значения, но толь-
ко видимые. Легко проследить это различие на примере, представленном ниже:
NumOfAllColors := COUNTROWS ( ALL ( 'Product'[Color] ) )
NumOfColors := COUNTROWS ( VALUES ( ’Product’[Color] ) )
В мере NumOfAllColors подсчитывается количество уникальных цветов из
таблицы Product, тогда как в NumOfColors попадут только те цвета, которые
94 ГЛАВА 3 Использование основных табличных функций
видны в отчете в данный момент, то есть прошедшие фильтрацию. Результат
вычисления двух этих мер в разрезе категорий товаров представлен на рис. 3.8.
Category NumOfColors NumOfAIIColors
Audio 10 16
Cameras and camcorders 14 16
Cell phones 8 16
Computers 12 16
Games and Toys 11 16
Home Appliances 13 16
Music, Movies and Audio Books 8 16
TV and Video 4 16
16 16
Рис. 3.8 Функция VALUES для каждой категории возвращает ее подмножество цветов
Поскольку отчет построен в разрезе категорий, очевидно, что в каждой из
них могут присутствовать товары определенных цветов, но не всех возмож-
ных. Функция VALUES возвращает набор уникальных значений из столбца
в рамках наложенных фильтров. Если использовать функции VALUES или DIS-
TINCT в вычисляемом столбце или вычисляемой таблице, их результат будет
полностью совпадать с итогом работы функции ALL, поскольку эти объекты не
зависят от внешних фильтров. В то же время, будучи использованными внутри
меры, функции VALUES и DISTINCT строго подчиняются наложенным фильт-
рам, тогда как функция ALL их просто игнорирует.
Как мы уже сказали, действие этих двух функций очень похоже. Теперь при-
шло время разобраться в их отличии, которое сводится к способу обработки
пустых строк в таблицах. Но сначала нужно понять, как могли попасть пустые
строки в нашу таблицу, если мы не добавляли их в нее явно.
Дело в том, что движок DAX автоматически создает пустые строки в табли-
це, находящейся в отношении на стороне «один», в случае присутствия не-
действительной связи. Чтобы продемонстрировать эту ситуацию на примере,
давайте удалим из таблицы Product все товары серебряного цвета. Поскольку
изначально у нас было 16 уникальных цветов товаров в модели, логично было
бы предположить, что теперь их стало 15. Вместо этого мы видим довольно
неожиданную картину - в нашем отчете, показанном на рис. 3.9, мера NumOf-
AllColors по-прежнему показывает число 16, а сверху добавилась строка без на-
звания категории.
Поскольку таблица Product находится в связи с Sales на стороне «один», каж-
дой строке в таблице Sales должна соответствовать строка в таблице Product.
А поскольку мы умышленно удалили строки с одним из цветов из таблицы
товаров, получилось, что множество строк в таблице Sales остались без соот-
ветствия с зависимыми записями в Product. Важно подчеркнуть, что мы не
удаляли строки из таблицы Sales, мы удалили именно товары с определенным
цветом, чтобы намеренно нарушить действие связи.
И для гарантии того, что отсутствующие строки будут участвовать во всех
вычислениях, движок автоматически добавил в таблицу Product строку с пус-
ГЛАВА 3 Использование основных табличных функций 95
тыми значениями во всех столбцах, и все «осиротевшие» строки из таблицы
Sales привязались к этой пустой строке.
Важно В таблииу Product добавилась только одна пустая строка, несмотря на то что сразу
несколько товаров, на которые ссылалась таблица Sales, утратили связь с соответствую-
щим ProductKey в таблице Product.
ч___________________________________________________________________________)
Category
NumOfColors NumOfAIIColors
1 16
Audio 9 16
Cameras and camcorders 13 16
Cell phones 7 16
Computers 11 16
Games and Toys 10 16
Home Appliances 12 16
Music, Movies and Audio Books 7 16
TV and Video 3 16
Total
16 16
Рис. 3.9 В первой строке отчета выведена пустая категория,
а общее количество цветов осталось равным 16
Мы видим, что в отчете, изображенном на рис. 3.9, в первой строке ука-
зана пустая категория, при этом мера NumOfColors показывает один цвет.
Это число отражает наличие строки с пустой категорией, цветом и всеми
остальными столбцами. Вы не увидите эту строку при просмотре таблицы,
поскольку она автоматически создается на этапе загрузки модели данных.
Если в какой-то момент связь вновь станет действительной - к примеру, мы
вернем в таблицу Product товары серебряного цвета, - пустая строка пропа-
дет из таблицы.
Некоторые функции DAX учитывают в своих расчетах пустые строки, дру-
гие - нет. Допустим, функция VALUES воспринимает пустую строку как полно-
ценную запись в таблице и возвращает ее при обращении. Функция DISTINCT
ведет себя иначе и не учитывает пустые строки в расчетах. Разницу между
ними легко проследить на примере следующей меры, основанной на функции
DISTINCT, а не VALUES:
NumOfDlstInctColors := COUNTROWS ( DISTINCT ( 'Product'[Color] ) )
Результат вычисления этой меры показан на рис. 3.10.
В хорошо продуманной модели данных не должны появляться недействи-
тельные связи. Таким образом, если модель правильно спроектирована, обе
функции всегда будут давать одинаковые результаты. Но вы должны помнить
о различиях в работе этих функций на случай возникновения недействитель-
ных связей в модели данных. В противном случае ваши вычисления могут
давать непредсказуемые результаты. Представьте, что вам необходимо рас-
96 ГЛАВА 3 Использование основных табличных функций
считать среднюю сумму продажи по товарам. Одним из возможных вариантов
будет определить общую сумму продажи и затем поделить ее на количество
товаров. Сделать это можно при помощи такого кода:
AvgSalesPerProduct :=
DIVIDE (
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
);
COUNTROWS (
VALUES ( 'Product'[Product Code] )
)
)
Category NumOfColors NumOfDistinctColors NumOfAllColors
1 16
Audio 9 9 16
Cameras and camcorders 13 13 16
Cell phones 7 7 16
Computers 11 11 16
Games and Toys 10 10 16
Home Appliances 12 12 16
Music, Movies and Audio Books 7 7 16
TV and Video 3 3 16
Total 16 15 16
Рис. 3.10 Мера NumOfDistinctColors показывает пустое значение для пустой категории,
а в итогах отображает число 15, а не 16
Результат вычисления данной меры показан на рис. 3.11. Очевидно, что
здесь есть какая-то ошибка, поскольку в первой строке мы видим огромную
и бессмысленную сумму.
Category
AvgSalesPerProduct
Audio
Cameras and camcorders
Cell phones
Computers
Games and Toys
Home Appliances
Music, Movies and Audio Books
TV and Video
Total
6,798,560.86
2,959.80
18,954.27
5,522.99
9,903.37
2,242.14
14,611.76
3,337.06
14,698.67
14,560.37
Рис. 3.11 В первой строке стоит огромная сумма,
соответствующая пустой категории товаров
ГЛАВА 3 Использование основных табличных функций 97
Это загадочное большое число в строке с пустой категорией относится
к продажам товаров серебряного цвета, которых больше нет в таблице Product.
Иными словами, эта пустая строка ассоциируется с товарами серебряного цве-
та, которые больше не представлены в справочнике. В числителе функции DI-
VIDE мы учитываем все продажи серебряных товаров, тогда как в знаменателе
будет присутствовать единственная строка, возвращенная функцией VALUES.
Получается, что один несуществующий товар (пустая строка) вобрал в себя все
продажи по разным товарам из таблицы Sales, по которым нет соответствий
в таблице Product. Именно это привело к образованию такого гигантского чис-
ла. И проблема тут в наличии недействительной связи в модели, а не в форму-
ле как таковой. Какую бы формулу мы ни написали, в таблице Sales не станет
меньше строк с отсутствующими товарами в справочнике. Но будет полезно
взглянуть, как разные функции возвращают разные наборы данных. Посмот-
рите на следующие две формулы:
AvgSalesPerDistinctProduct :=
DIVIDE (
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] );
COUNTROWS ( DISTINCT ( 'Product'[Product Code] ) )
)
AvgSalesPerDistinctKey :=
DIVIDE (
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] );
COUNTROWS ( VALUES ( Sales[ProductKey] ) )
)
В первом случае мы использовали функцию DISTINCT вместо VALUES. В ре-
зультате функция COUNTROWS вернула пустое значение, которое и попало
в вывод. Во втором варианте мы, как и раньше, применили функцию VALUES,
но на этот раз мы считаем строки по столбцу Sales[ProductKey]. Помните, что
в таблице присутствует множество значений Sales[ProductKey], относящихся
к одной и той же пустой строке. Результат вычисления новых мер показан на
рис. 3.12.
Category
AvgSalesPerProduct AvgSalesPerDistinctProduct AvgSalesPerDistinctKey
6,798,560.86 18,474.35
Audio 2,959.80 2,959.80 3,634.18
Cameras and camcorders 18,954.27 18,954.27 20,786.51
Cell phones 5,522.99 5,522.99 6,163.00
Computers 9,903.37 9,903.37 11,416.98
Games and Toys 2,242.14 2,242.14 2,386.79
Home Appliances 14,611.76 14,611.76 16,238.64
Music, Movies and Audio Books 3,337.06 3,337.06 3,883.12
TV and Video 14,698.67 14,698.67 16,687.96
Total 14,560.37 14,567.31 13,687.40
Рис. 3.12 При наличии недействительной связи все меры, скорее всего, будут
ошибочными - каждая по-своему
98 ГЛАВА 3 Использование основных табличных функций
Любопытно отметить, что правильные результаты показала только мера
AvgSalesPerDistinctKey. Поскольку наш отчет сгруппирован по категориям,
а в каждой из них присутствуют товары, утратившие ссылку на справочник,
все они объединились в общую пустую строку.
И все же правильным способом было бы устранение недействительной
связи из модели, чтобы все без исключения строки в таблице продаж имели
свои соответствия в справочнике товаров. В хорошо спроектированной мо-
дели данных не должно быть недействительных связей. Если они по той или
иной причине появились, вы должны быть очень осторожными в обращении
с пустыми строками и учитывать их возможное влияние на производимые
вычисления.
В заключение хотелось бы отметить, что функция ALL всегда будет возвра-
щать пустую строку, если она присутствует в исходной таблице. Если вы не хо-
тите, чтобы пустая строка появлялась в выводе, можете использовать функцию
ALLNOBLANKROW.
Функция VALUES с множественными столбцами
Функции VALUES и DISTINCT могут принимать в качестве параметра только один столбец
таблицы. У этих функций нет соответствующих аналогов для приема нескольких столб-
цов, как в случае с ALL и ALLNOBLANKROW. Если вам необходимо извлечь уникальные
сочетания видимых столбцов в отчете, функция VALUES вам не поможет. В главе 12 вы
узнаете, что аналог выражения
VALUES ( 'Product'[Category]; 'Product'[Subcategory] )
может быть записан так:
SUMMARIZE ( 'Product'; 'Product'[Category]; 'Product'[Subcategory] )
Позже вы увидите, что функции VALUES и DISTINCT часто используются
в качестве параметра в итераторах. И в случае отсутствия недействительных
связей они дают одинаковые результаты. Проходя по строкам таблицы при по-
мощи итерационных функций, вы должны рассматривать пустую строку как
полноценную запись, чтобы гарантировать просмотр всех значений без ис-
ключения. В целом лучше всегда использовать функцию VALUES, a DISTINCT
приберечь для случаев, когда вам нужно будет явно исключить из результата
возможные пустые строки. Позже в этой книге вы узнаете, как применение
функции DISTINCT вместо VALUES может предотвратить появление цикличе-
ских зависимостей (circular dependencies). Мы поговорим об этом в главе 15.
Функции VALUES и DISTINCT могут принимать в качестве аргумента не
только столбец, но и целую таблицу. В этом случае, однако, они ведут себя по-
разному:
функция DISTINCT возвращает уникальные значения из таблицы, не учи-
тывая пустые строки. Таким образом, в выводе будут отсутствовать дуб-
ликаты;
функция VALUES возвращает строки исходной таблицы без удаления дуб-
ликатов вместе с пустой строкой, если такие есть в таблице. Дублирую-
щиеся записи остаются в итоговом наборе.
ГЛАВА Ъ Использование основных табличных функций 99
Использование таблиц в качестве
скалярных значений
Несмотря на то что VALUES является табличной функцией, мы будем часто ис-
пользовать ее для вычисления скалярных величин. Это возможно благодаря
одной особенности DAX, заключающейся в том, что таблица с одним столбцом
и одной строкой может быть интерпретирована как скалярное значение. Пред-
ставьте, что мы строим отчет по количеству брендов с разбивкой по категори-
ям и подкатегориям, как показано на рис. 3.13.
Category NumOfBrands
Audio 3
Bluetooth Headphones 2
MP4&MP3 1
Recording Pen 1
Cameras and camcorders 3
Camcorders 1
Cameras & Camcorders Accessories 1
Digital Cameras 1
Digital SLR Cameras 3
Cell phones 2
Cell phones Accessories 1
Рис. 3.13 В отчете показано количество брендов
для каждой категории и подкатегории
При формировании отчета нам может понадобиться также видеть названия
брендов в таблице. Одним из решений может быть использование функции
VALUES для извлечения наименований брендов вместо их количества. Это воз-
можно только в случае, если категория или подкатегория представлена един-
ственным брендом. Можно просто вернуть значение функции VALUES, и DAX
автоматически конвертирует его в скалярную величину. А чтобы убедиться,
что бренд в строке только один, можно дополнить выражение проверкой, как
показано ниже:
Brand Name :=
IF (
COUNTROWS ( VALUES ( Product[Brand] ) ) = 1;
VALUES ( Product[Brand] )
Результат вычисления этой меры показан на рис. 3.14. Пустые ячейки
в столбце Brand Name означают, что в этой категории или подкатегории есть
сразу несколько брендов.
В формуле меры Brand Name используется функция COUNTROWS для провер-
ки того, что в столбце Brand таблицы Products для данного выбора присутствует
только одно значение. Это довольно часто используемый шаблон в DAX, и для
него существует более простая функция HASONEVALUE, проверяющая столбец
100 ГЛАВА 3 Использование основных табличных функций
на единственное видимое выражение. Ниже показан более оптимальный син-
таксис для меры Brand Name с использованием функции HASONEVALUE.
Brand Name :=
IF (
HASONEVALUE ( 'Product'[Brand] );
VALUES ( 'Product'[Brand] )
Category
NumOfBrands Brand Name
Audio
Bluetooth Headphones
MP4&MP3
Recording Pen
Cameras and camcorders
Camcorders
Cameras & Camcorders Accessories
Digital Cameras
Digital SLR Cameras
Cell phones
Cell phones Accessories
3
2
1 Contoso
1 Wide World Importers
3
1 Fabrikam
1 Contoso
1 A. Datum
3
2
1 Contoso
Рис. 3.14 Когда функция VALUES возвращает одну строку, можно перевести
ее значение в скалярную величину, как показано в мере Brand Name
А чтобы еще больше облегчить жизнь разработчикам, DAX предлагает функ-
цию, автоматически проверяющую столбец на единственное значение и воз-
вращающую его в виде скалярной величины. Для множественных вхождений
допустимо задать в функции значение по умолчанию. Речь идет о функции
SELECTEDVALUE, с помощью которой можно переписать предыдущую меру
следующим образом:
Brand Name := SELECTEDVALUE ( 'Product'[Brand] )
Включив в качестве второго аргумента значение по умолчанию, можно со-
ответствующим образом обработать ситуации со множественными вхожде-
ниями:
Brand Name := SELECTEDVALUE ( 'Product'[Brand]; "Multiple brands" )
Результат вычисления этой меры показан на рис. 3.15.
А что, если вместо строки «Multiple brands» (Несколько брендов) мы захо-
тим видеть перечисление названий этих брендов? В этом случае мы можем
пройти по таблице, возвращенной функцией VALUES, примененной к столбцу
Product[Brand], и использовать функцию CONCATENATEX для сращивания мно-
жественных значений:
[Brand Name] :=
CONCATENATEX (
VALUES ( 'Product'[Brand] );
ГЛАВА 3 Использование основных табличных функций 101
'Product'[Brand];
Category
NumOfBrands Brand Name
Audio
Bluetooth Headphones
MP4&MP3
Recording Pen
Cameras and camcorders
Camcorders
Cameras & Camcorders Accessories
Digital Cameras
Digital SLR Cameras
Cell phones
Cell phones Accessories
3 Multiple brands
2 Multiple brands
1 Contoso
1 Wide World Importers
3 Multiple brands
1 Fabrikam
1 Contoso
1 A. Datum
3 Multiple brands
2 Multiple brands
1 Contoso
Рис. 3.15 Функция SELECTEDVALUE возвращает значение по умолчанию в случае,
если в категорию или подкатегорию входит сразу несколько брендов
Теперь в случае присутствия в строке нескольких брендов их наименования
будут аккуратно перечислены через запятую, что видно по рис. 3.16.
Category
NumOfBrands Brand Name
Audio
Bluetooth Headphones
MP4&MP3
Recording Pen
iCameras and camcorders
Camcorders
Cameras & Camcorders Accessories
Digital Cameras
Digital SLR Cameras
Cell phones
Cell phones Accessories
3 Contoso. Wide World Importers. Northwind Traders
2 Wide World Importers, Northwind Traders
1 Contoso
1 Wide World Importers
3 Contoso, Fabrikam, A. Datum
1 Fabrikam
1 Contoso
1 A. Datum
3 Contoso, Fabrikam, A. Datum
2 Contoso. The Phone Company
1 Contoso
Рис. 3.16 Функция CONCATENATEX умеет собирать в строку содержимое таблиц
Введение в функцию ALLSELECTED
Последняя табличная функция, относящаяся к разряду базовых, - это ALLSE-
LECTED. На самом деле это очень сложная функция - возможно, самая сложная
из табличных функций в DAX. В главе 14 мы расскажем обо всех ее нюансах,
а сейчас просто познакомимся с ней, поскольку ее использование может быть
крайне полезным и на начальной стадии изучения языка.
Функция ALLSELECTED применяется для извлечения списка значений из таб-
лицы или столбца с учетом только внешних фильтров, не входящих в элемент
102 ГЛАВА 3 Использование основных табличных функций
визуализации. Чтобы понять, чем может быть полезна функция ALLSELECTED,
взгляните на отчет, представленный на рис. 3.17.
Category Category Sales Amount Sales Pct
Audio Ж
Cameras and camcorders Audio 384,518.16 1.26%
Cell phones Cameras and camcorders 7,192,581.95 23.51%
Computers Cell phones 1,604,610.26 5.25%
Games and Toys Computers 6,741,548.73 22.04%
Home Appliances Games and Toys 360,652.81 1.18%
Music, Movies and Audio Books Home Appliances 9,600,457.04 31.38%
TV and Video Music, Movies and Audio Books 314,206.74 1.03%
TV and Video 4,392,768.29 14.36%
Total 30,591,343.98 100.00%
Рис. 3.17 Отчет представляет собой матрицу и срезы, размещенные на одной странице
Значение в столбце Sales Pct рассчитано при помощи следующей меры:
Sales Pct :=
DIVIDE (
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] );
SUMX ( ALL ( Sales ); Sales[Quantity] * Sales[Net Price] )
)
Поскольку в знаменателе формулы используется функция ALL, результат
будет вычисляться по итогам всех продаж вне зависимости от установленных
фильтров. И если нам вздумается в срезах выбрать конкретные категории то-
варов, процент все равно продолжит считаться по отношению к общим про-
дажам. На рис. 3.18 показано, что произойдет, если выбрать не все категории.
Category Category Sales Amount Sales Pct
Audio A
| Cameras and camcorders Cameras and camcorders 7,192,581.95 23.51%
| Cell phones Cell phones 1,604,610.26 5.25%
| Computers Computers 6,741,548.73 22.04%
| Games and Toys
| Home Appliances Games and Toys 360,652.81 1.18%
Music Movies and Audio Books Home Appliances 9,600,457.04 31.38%
TV and Video Total 25,499,850.79 83.36%
Рис. 3.18 Использование функции ALL ведет к расчетам относительно общего итога
по продажам
Несколько строк в отчете исчезли, как и ожидалось, но проценты по остав-
шимся не изменились. Более того, итог по столбцу Sales Pct не равен 100 %.
Если и вам кажется, что результаты получились неверными и правильно было
бы рассчитывать проценты не относительно общего итога по продажам, а от-
носительно видимых категорий, вам поможет функция ALLSELECTED.
Если в знаменателе меры Sales Pct использовать функцию ALLSELECTED
вместо ALL, то расчеты будут производиться с учетом всех фильтров за преде-
лами нашей матрицы. Иными словами, в знаменателе будут учтены все катего-
рии товаров, кроме Audio, Music и TV, которые остались невыбранными.
ГЛАВА 3 Использование основных табличных функций 103
Sales Pct :=
DIVIDE (
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] );
SUMX ( ALLSELECTED ( Sales ); Sales[Quantity] * Sales[Net Price] )
)
Результат вычисления этой меры показан на рис. 3.19.
Category Category Sales Amount Sales Pct
Audio ж
| Cameras and camcorders Cameras and camcorders 7,192,581.95 28.21%
Ц Ceil phones Cell phones 1,604,610.26 6.29%
В Computers Computers 6,741,548.73 26.44%
| Games and Toys
Ц Home Appliances Games and Toys 360,652.81 1.41%
Music. Movies and Audio Books Home Appliances 9,600,457.04 37.65%
TV and Video Total 25,499,850.79 100.00%
Рис. 3.19 С использованием функции ALLSELECTED
проценты вычислились только с учетом внешних фильтров
Итог по столбцу Sales Pct вновь вернулся к значению 100 %, и все вычисле-
ния были произведены только в рамках видимой области, а не относительно
общих итогов. Функция ALLSELECTED очень мощная и полезная. К сожалению,
за эту мощь приходится платить повышенной сложностью. Подробно обо всех
нюансах использования этой функции мы расскажем далее в книге. Из-за сво-
ей сложности функция ALLSELECTED зачастую возвращает не самые очевид-
ные результаты. Это не значит, что они неправильные, но понять их бывает
непросто даже опытным специалистам по DAX.
Однако и в таких простых ситуациях, как эта, функция ALLSELECTED также
бывает чрезвычайно полезной.
Заключение
Как вы узнали из данной главы, даже базовые табличные функции DAX облада-
ют очень серьезным потенциалом и позволяют производить достаточно слож-
ные вычисления. FILTER, ALL, VALUES и ALLSELECTED - весьма распространен-
ные функции языка, встречающиеся в формулах довольно часто.
В использовании табличных функций очень важно умело сочетать их в вы-
ражениях - так вы сможете поднять планку сложности расчетов на новый
качественный уровень. А в совокупности с функцией CALCULATE и техникой
преобразования контекста табличные функции способны очень компактно
и лаконично производить невероятно сложные вычисления. В следующих гла-
вах мы познакомим вас с контекстами вычислений и функцией CALCULATE.
После этого вы по-новому взглянете на то, что уже узнали о табличных функ-
циях, ведь использование их в качестве параметров функции CALCULATE по-
зволит извлечь максимум потенциала из этой связки.
ГЛАВА 4
Введение в контексты
вычисления
На этой стадии чтения книги вы уже овладели основами языка DAX. Вы знае-
те, как создавать вычисляемые столбцы и меры, и понимаете предназначение
основных функций DAX. В этой главе вы выйдете на новый уровень владения
языком, а усвоив полную теоретическую базу DAX, станете настоящим гуру.
С теми знаниями, что у вас уже есть, вы способны строить разные интерес-
ные отчеты, но для создания более сложных формул вам просто необходимо
погрузиться в изучение контекстов вычисления. Фактически на этой концеп-
ции основываются все продвинутые возможности языка DAX.
Однако нам стоит предостеречь наших читателей. Концепция контекстов
вычисления довольно проста сама по себе, и вы очень скоро ее усвоите. Несмот-
ря на это, в ней есть несколько важных нюансов, которые просто необходимо
понять. Если этого не сделать сразу, в какой-то момент вы рискуете потерять-
ся в мире DAX. У нас есть опыт обучения языку DAX тысяч людей, так что мы
можем с уверенностью сказать, что это нормально. На определенной стадии
вам, возможно, покажется, что формулы DAX работают каким-то магическим
образом, и вы не понимаете, как именно это происходит. Не беспокойтесь, вы
не одиноки. Большинство наших студентов проходили через это, и многие еще
пройдут в будущем. Причина в том, что им не удается постигнуть все нюансы
контекстов вычисления с первого раза. И решение здесь только одно - вер-
нуться к этой главе позже, прочитать ее заново и закрепить в памяти то, что
ускользнуло от вас при первом прочтении.
Стоит отметить, что контексты вычисления играют важную роль при со-
вместном использовании с функцией CALCULATE - вероятно, наиболее мощ-
ной и сложной для понимания функцией во всем языке. Мы познакомимся
с CALCULATE в следующей главе и будем использовать на протяжении всей
оставшейся части книги. Досконально изучить поведение функции CALCULATE
без полного понимания концепции контекстов вычисления будет проблема-
тично. С другой стороны, усвоить всю значимость контекстов вычисления, не
опробовав в действии функцию CALCULATE, тоже невозможно. По опыту на-
писания прошлых книг мы можем предположить, что именно эта и следую-
щая главы будут наиболее полезными для усвоения языка DAX, и именно здесь
многие из вас оставят закладку на будущее.
На протяжении всей книги мы будем использовать концепции, с которыми
познакомимся здесь. В главе 14 вы узнаете о расширенных таблицах и тем са-
мым завершите изучение контекстов вычисления. Здесь же мы лишь начнем
ГЛАВА 4 Введение в контексты вычисления 105
знакомиться с этой концепцией, чтобы в будущем вы были готовы к освоению
таких мощных инструментов, как расширенные таблицы. Таким образом, вы
изучите всю теоретическую базу, касающуюся контекстов вычисления, за не-
сколько шагов.
Введение в контексты вычисления
В DAX существует два контекста вычисления (evaluation context): контекст
фильтра (filter context) и контекст строки (row context). В следующих разделах
вы познакомитесь с ними и научитесь их использовать в работе. Но перед на-
чалом изучения необходимо отметить важную вещь, состоящую в том, что эти
два контекста представляют совершенно разные концепции с разной функ-
циональностью и принципами применения.
Одной из самых распространенных ошибок новичков в DAX является то,
что они путают эти два контекста, считая, что контекст строки является лишь
разновидностью контекста фильтра. Но это не так. Контекст фильтра огра-
ничивает выводимые данные, тогда как контекст строки осуществляет ите-
рации по таблице. Когда в DAX идут итерации по таблице, фильтрация не
осуществляется, и наоборот. Несмотря на всю кажущуюся простоту этой кон-
цепции, мы знаем по опыту, что усвоить ее бывает непросто. Похоже, наш
мозг всегда стремится пробиться к знаниям кратчайшим путем - когда он
видит какие-то сходства в концепциях, он предпочитает для упрощения объ-
единять эти концепции в одну. Не попадайтесь на эту удочку. Всякий раз,
когда вам вдруг покажется, что два контекста вычисления выглядят похоже,
остановитесь и повторите, словно мантру: «Контекст фильтра ограничивает
выводимые данные, а контекст строки осуществляет итерации по таблице.
Это не одно и то же».
Контекст вычисления по определению представляет собой контекст, в ко-
тором происходит вычисление выражения на DAX. На самом деле одно и то
же выражение может производить разные результаты в зависимости от кон-
текста, в котором оно выполняется. Такое поведение выражений интуитивно
понятно, и именно поэтому мы можем оперировать формулами на DAX даже
без глубокого изучения контекстов вычисления. Вы ведь в первых трех главах
уже писали код на DAX и при этом ничего не знали о контекстах. Но сейчас
вы переходите на совершенно новый уровень, а значит, вам необходимо раз-
ложить по полочкам в голове уже полученные знания по DAX и приготовиться
к посвящению в язык со всей его безграничной мощью.
Знакомство с контекстом фильтра
Для начала давайте разберемся, что из себя представляет контекст вычисле-
ния. В DAX все выражения вычисляются внутри определенного контекста. Кон-
текст - это своеобразное «окружение», в котором выполняется формула. Возь-
мем для примера следующую меру:
Sales Amount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
106 ГЛАВА 4 Введение в контексты вычисления
В этой формуле вычисляется сумма произведений значений столбцов ко-
личества и цены из таблицы Sales. Мы можем использовать созданную меру
в отчете, что видно по рис. 4.1.
Sales Amount
30,591,343.98
Рис. 4.1 Мера Sales Amount без контекстов
показывает итоговый показатель
Само по себе это число не представляет большого интереса. При этом фор-
мула сделала ровно то, что мы и попросили, - посчитала сумму по указанному
выражению. Но в реальном отчете нам может понадобиться осуществить не-
которые срезы по столбцам. Например, можно выбрать бренды товаров, раз-
местить их в строках матрицы, и в результате мы получим уже более показа-
тельный отчет, представленный на рис. 4.2.
Brand Sales Amount
A. Datum 2,096,184.64
AdventureWorks 4,011,112.28
Contoso 7,352,399.03
Fabrikam 5,554,015.73
Litware 3,255,704.03
Northwind Traders 1,040,552.13
Proseware 2,546,144.16
Southridge Video 1,384,413.85
Tailspin Toys 325,042.42
The Phone Company 1,123,819.07
Wide World Importers 1,901,956.66
Total 30,591,343.98
Рис. 4.2 Мера Sales Amount показывает сумму продаж
по каждому бренду в отдельных строках
Итоговое значение продаж по-прежнему присутствует в отчете, и сейчас
оно представляет сумму продаж по отдельным брендам. Все значения в отчете
в совокупности составляют предмет некого детализированного анализа. При
этом можно отметить одну странность: формула делает не то, что мы ей сказа-
ли. Фактически мы не видим в каждой строке сумму по всем продажам. Вместо
этого мы видим сумму продаж по конкретному бренду. Заметьте при этом, что
нигде в коде меры мы не указывали, что формула должна работать с подмно-
жествами данных. Эта фильтрация произошла где-то за пределами формулы.
В каждой строке мы видим свое собственное значение по причине того, что
наша формула выполняется в определенном контексте вычисления. Можете
думать о контексте вычисления как о своеобразном окружении, обрамляющем
ячейку, в котором происходит вычисление.
ГЛАВА 4 Введение в контексты вычисления 107
В DAX все вычисления производятся в соответствующем контексте.
Одна и та же формула может давать совершенно разные результаты,
будучи примененной к разным наборам данных.
Здесь мы имеем дело с контекстом фильтра, который, как понятно из на-
звания, осуществляет фильтрацию таблиц. И как мы уже говорили, одна и та же
формула может давать совершенно разные результаты в зависимости от того,
в каком контексте вычисления она была выполнена. Такое поведение формул
кажется интуитивно понятным, но вы должны хорошо усвоить эту концепцию,
поскольку она скрывает в себе ряд сложностей.
У каждой ячейки в отчете свой собственный контекст фильтра. Вы должны
иметь в виду, что в каждой отдельно взятой ячейке может производиться свое
вычисление - как если бы это был другой запрос, не зависящий от остальных
ячеек в отчете. Движок DAX может применять определенную внутреннюю оп-
тимизацию для ускорения вычислений, но вы должны четко понимать, что
в каждой ячейке производится собственное автономное вычисление предпи-
санного выражения DAX. Таким образом, значение в итоговой строке отчета,
показанного на рис. 4.2, не рассчитывается путем суммирования всех значений
в столбце. Оно вычисляется отдельно посредством агрегирования всех строк
в таблице Sales, несмотря на то что для других строк в отчете это вычисление
уже было произведено. Таким образом, в зависимости от выражения DAX ито-
говая строка может содержать результат, не зависящий от других строк в отчете.
Примечание В наших примерах мы для простоты используем отчет типа матрица. Но
можно задавать контекст вычисления и непосредственно в запросах, с чем вы познакоми-
тесь в следующих главах. На данной стадии будет лучше думать только об отчетах, чтобы
в наиболее простом и визуальном виде понять описываемые концепции.
\________________________________________________________________________________)
Когда поле Brand вынесено в строки, контекст фильтра отбирает по одному
бренду для каждой строки. Если усложнить матрицу, добавив годы в столбцы,
мы получим результат, показанный на рис. 4.3.
Brand CY 2007 CY 2008 CY 2009 Total
A. Datum 1,181,110.71 463,721.61 451,352.33 2,096,184.64
Adventure Works 2,249,988.11 892,674.52 868,449.65 4,011,112.28
Contoso 2,729,818.54 2,369,167.68 2,253,412.80 7,352,399.03
Fabrikam 1,652,751.34 1,993,123.48 1,908,140.91 5,554,015.73
Litware 647,385.82 1,487,846.74 1,120,471.47 3,255,704.03
Northwind Traders 372,199.93 469,827.70 198,524.49 1,040,552.13
Proseware 880,095.80 763,586.23 902,462.12 2,546,144.16
Southridge Video 688,107.56 294,635.04 401,671.25 1,384,413.85
Tailspin Toys 74,603.14 97,193.87 153,245.41 325,042.42
The Phone Company 362,444.46 355,629.36 405,745.25 1,123,819.07
Wide World Importers 471,440.71 740,176.76 690,339.18 1,901,956.66
Total 11,309,946.12 9,927,582.99 9,353,814.87 30,591,343.98
Рис. 4.3 Мера Sales Amount, отфильтрованная по брендам и годам
108 ГЛАВА 4 Введение в контексты вычисления
Теперь в каждой ячейке отражены продажи по конкретному бренду в кон-
кретном году. Дело в том, что контекст фильтра в данный момент одновре-
менно фильтрует бренды и годы. В итоговых ячейках по строкам учитываются
только указанные бренды, а в итогах по столбцам - конкретные годы. Един-
ственная ячейка, в которой вычисляется общий итог по продажам, находится
на пересечении строки и столбца итогов, и на нее не действуют никакие из
установленных в модели фильтров.
На этом этапе вам должны быть уже ясны правила игры: чем больше столб-
цов мы будем использовать в нашем отчете, тем больше столбцов будет затра-
гивать контекст фильтра в каждой отдельной ячейке матрицы. Если, например,
вынести на строки также столбец Store[Continent], результат отчета вновь из-
менится и станет таким, как показано на рис. 4.4.
Brand CY 2007 CY 2008 CY 2009 Total
A. Datum 1,181,110.71 463,721.61 451,352.33 2,096,184.64
Asia 281,936.73 125,055.80 145,386.55 552,379.08
Europe 395,159.31 165,924.22 146,867.73 707,951.26
North America 504,014.67 172,741.59 159,098.05 835,854.31
Adventure Works 2,249,988.11 892,674.52 868,449.65 4,011,112.28
Asia 620,545.52 347,150.65 414,507.89 1,382,204.07
Europe 662,553.70 275,126.51 264,973.65 1,202,653.86
North America 966,888.88 270,397.36 188,968.10 1,426,254.35
Contoso 2,729,818.54 2,369,167.68 2,253,412.80 7,352,399.03
Asia 838,967.94 998,113.24 753,146.22 2,590,227.39
Europe 905,295.91 529,596.05 694,250.12 2,129,142.08
North America 985,554.69 841,458.40 806,016.47 2,633,029.56
Fabrikam 1,652,751.34 1,993,123.48 1,908,140.91 5,554,015.73
Asia 640,664.16 727,025.63 783,871.11 2,151,560.89
Europe 503,428.83 383,827.59 454,944.80 1,342,201.22
Total 11,309,946.12 9,927,582.99 9,353,814.87 30,591,343.98
Рис. 4.4 Контекст определяется набором полей, вынесенных в строки и столбцы
Теперь контекст фильтра в каждой ячейке матрицы состоит из бренда, кон-
тинента и года. Иными словами, контекст фильтра состоит из полного набора
полей, которые пользователь выносит в строки и столбцы своего отчета.
Примечание Поле может находиться в строках или столбцах отчета, в срезах или фильт-
рах уровня страницы, отчета или визуализации либо в других фильтрующих элементах -
это абсолютно не важно. Все эти фильтры определяют единый контекст фильтра, который
DAX использует при расчете конкретной формулы. Вывод полей на строки или столбцы
полезен только в качестве элемента визуализации, для движка DAX эти эстетические ню-
ансы не играют никакой роли.
\_____________________________________________________________________________________J
В Power BI контекст фильтра строится путем комбинирования различных
визуальных элементов из графического интерфейса. На самом деле контекст
фильтра для конкретной ячейки вычисляется при помощи объединения всех
ГЛАВА 4 Введение в контексты вычисления 109
фильтров, расположенных в строках, столбцах, срезах и других визуальных
фильтрующих элементах. Взгляните на рис. 4.5.
Sales Amount by Occupation Brand CY 2007 CY 2C08 CY 2009 Total
Clerical A. Datum 57,276.00 57,276.00
Manual Ao venture Works 77,413.46 8,110.53 85,523.99
Professional | Contoso 125,596.01 2,638.18 14,156.95 142,391.14
Management Fabrikam 4,340.62 8,640.00 29,854.98 42,835.60
Skilled Manual Litware 17,910.87 7,956.00 25,866.87
North wind Traders 34,161.39 12,733.92 2,12232 49,017.63
00M 0.5M
Proseware 13,183.70 10,647.00 23,830.70
Continent Southridge Video 27,239.71 774.23 3,874.18 31,838.12
□ Asia Tailspin Toys 4,581.53 3,976.38 5,886.67 14,444.57
| Europe North Amen .a The Phone Company 1,384.30 864.90 2,249.79
Wide World Importers 2,395.37 2,395.37
Total 365-483.46 29,627.61 82,608.63 477,719.70
Рис. 4.5 В типичном отчете контекст содержит множество элементов,
включая срезы и фильтры
Контекст фильтра верхней левой ячейки (бренд: A.Datum, год: СУ 2007, зна-
чение: 57 276,00) состоит не только из полей строки и столбца, но также из
фильтров по виду деятельности (Professional) и континенту (Europe), располо-
женных слева в своих визуальных элементах. Все эти фильтры составляют еди-
ный контекст фильтра, действительный для каждой ячейки, и DAX применяет
его ко всей модели данных перед вычислением формулы.
Формально можно сказать, что контекст фильтра представляет собой набор
фильтров. Фильтр, в свою очередь, является списком кортежей, а каждый кор-
теж - это набор значений для определенных столбцов. На рис. 4.6 визуально
показано действие контекста фильтра, в рамках которого вычисляется значе-
ние в ячейке. Каждый элемент отчета является составной частью контекста
фильтра, и в каждой ячейке контекст фильтра будет свой.
CY 2CC-8
CY2JC9
Quantity by Calendar Year
CY 2007
Brand
A Datum
Adventure Works
Contoso
Fabnkam
Litware
Nort
645
1,639
3,000
210
478
OK
Educjiton
(Blank)
] Bachelors
Graduate Degree
High School
Partial College
Partial High School
SK
10K
roseware
Southridge Video
The Phone Company
Wide World Importers
Total
663
152
3904
3,670
103
249
13
Рис.4.6 Визуальное представление контекста фильтра в отчете Power Bl
110 ГЛАВА 4 Введение в контексты вычисления
Контекст фильтра из примера на рис. 4.6 состоит из трех фильтров. Первый
фильтр содержит кортеж по полю Calendar Year с единственным значением
CY 2007. Второй фильтр представляет собой два кортежа для поля Education
со значениями High School и Partial College. В третьем фильтре присутствует
один кортеж для поля Brand со значением Contoso. Вы могли заметить, что
каждый отдельный фильтр содержит кортежи для одного столбца. Позже вы
узнаете, как создавать кортежи для нескольких столбцов. Такие кортежи яв-
ляются одновременно очень мощным и сложным инструментом в руках раз-
работчика.
Перед тем как идти дальше, давайте вспомним меру, с которой мы начали
этот раздел:
Sales Amount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
Вот как правильно звучит предназначение этой меры: мера вычисляет сумму
произведений столбцов Quantity и Net Price для всех строк таблицы Sales, види-
мых в текущем контексте фильтра.
То же самое применимо и к более простым агрегациям. Рассмотрим такую
меру:
Total Quantity := SUM ( Sales[Quantity] )
Здесь производится суммирование значений по столбцу Quantity для всех
строк таблицы Sales, видимых в текущем контексте фильтра. Лучше понять
действия, которые выполняет эта формула, можно на примере соответствую-
щей функции SUMX:
Total Quantity := SUMX ( Sales; Sales[Quantity] )
Глядя на эту формулу, можно предположить, что контекст фильтра оказыва-
ет влияние на выражение Sales, в результате чего из таблицы Sales возвраща-
ются только строки, видимые в текущем контексте фильтра. Это правда, как
и то, что контекст фильтра влияет и на перечисленные ниже меры, для которых
не существует соответствующих итерационных функций:
Customers := DISTINCTCOUNT ( Sales[CustomerKey] ) -- Подсчитываем количество
-- покупателей в контексте фильтра
Colors :=
VAR ListColors = DISTINCT ( 'Product'[Color] ) -- Уникальные цвета в контексте
-- фильтра
RETURN COUNTROWS ( ListColors ) -- Количество уникальных цветов
Вас уже, наверное, раздражают наши постоянные повторения о том, что
контекст фильтра всегда активен и влияет на все расчеты. Но DAX требует от
вас предельной внимательности. Сложность этого языка состоит не в освоении
новых функций, а в наличии множества тонких нюансов представленных кон-
цепций. А когда эти концепции совмещаются, мы получаем довольно сложные
сценарии. Сейчас контекст фильтра у нас определен в самом отчете. Но когда
вы научитесь создавать контексты фильтра самостоятельно (этому будет по-
священа следующая глава), на первый план выйдет умение определять, какой
контекст активен в той или иной части формулы.
ГЛАВА 4 Введение в контексты вычисления 111
Знакомство с контекстом строки
В предыдущем разделе вы познакомились с контекстом фильтра. Теперь при-
шло время узнать, что из себя представляет второй вид контекста вычисления,
а именно контекст строки. Помните о том, что хоть оба контекста и являются
разновидностями контекста вычисления, они представляют совершенно раз-
ные концепции. Ранее вы узнали, что главным предназначением контекста
фильтра, как ясно из названия, является выполнение отбора, или фильтра-
ции, таблиц. Контекст строки не является инструментом для фильтрации таб-
лиц. Его забота - осуществлять итерации по таблице и вычислять значения
в столбцах.
На этот раз мы будем использовать вычисляемый столбец для подсчета ва-
ловой прибыли:
Sales[Gross Margin] = Sales[Quantity] * ( Sales[Net Price] - Sales[Unit Cost] )
Значения в этом столбце для каждой строки будут отличаться, как видно по
рис. 4.7.
Quantity Unit Cost Net Price Gross Margin
1 915.08 1,989.90 1,074.82
1 960.82 2,464.99 1,504.17
1 1,060.22 2,559.99 1,499.77
1 1,060.22 2,719.99 1,659.77
1 1,060.22 2,879.99 1,819.77
1 1,060.22 3,199.99 2,139.77
2 0.48 0.76 0.56
2 0.48 0.88 0.81
2 1.01 1.79 1.56
2 1.01 1.85 1.68
Рис. 4.7 Значения в вычисляемом столбце Gross Margin разные
и зависят от других столбцов
Как мы и ожидали, значения в созданном нами вычисляемом столбце для
каждой строки будут свои. Это вполне естественно, поскольку значения других
трех столбцов, от которых зависит наша новая величина, также разнятся. Как
и в случае с контекстом фильтра, причина таких различий состоит в наличии
определенного контекста вычисления. Но на этот раз контекст не фильтрует
таблицу. Вместо этого он идентифицирует строку, для которой выполняется
вычисление.
Примечание Контекст строки ссылается на конкретную строку в результате табличного
выражения DAX. Не стоит путать его со строкой в отчете.У DAX нет возможности напрямую
ссылаться на строки или столбцы в отчетах. Значения, показываемые в матрице в Power
Bl и сводной таблице в Excel, являются результатом вычисления мер в контексте фильтра
или значениями, сохраненными в обычных или вычисляемых столбцах таблицы.
112 ГЛАВА 4 Введение в контексты вычисления
Мы знаем, что значения вычисляемого столбца рассчитываются построчно,
но как DAX понимает, в какой строке мы находимся в текущий момент? В этом
ему помогает специальный вид контекста вычисления, называемый контекс-
том строки. Когда мы добавляем вычисляемый столбец к таблице из миллиона
строк, DAX одновременно создает контекст строки, вычисляющий значение
в столбце строка за строкой.
Во время добавления вычисляемого столбца DAX по умолчанию создает
контекст строки. В этом случае нет необходимости делать это вручную - вы-
числяемый столбец и так всегда вычисляется в контексте строки. Но вы уже
знаете, как создавать контекст строки вручную - при помощи итератора. Фак-
тически мы можем написать для подсчета валовой прибыли меру следующего
содержания:
Gross Margin :=
SUMX (
Sales;
Sales[Quantity] * ( Sales[Net Price] - Sales[Unit Cost] )
)
В этом случае, поскольку мы имеем дело с мерой, контекст строки автома-
тически не создается. Функция SUMX, будучи итератором, создает контекст
строки, который начинает проходить по таблице Sales построчно. Во время
итерации происходит запуск второго выражения с функцией SUMX внутри
контекста строки. Таким образом, на каждой итерации DAX знает, какие зна-
чения использовать для трех столбцов, присутствующих в выражении.
Контекст строки появляется, когда мы создаем вычисляемый столбец или
рассчитываем выражение внутри итерации. Другого способа создать контекст
строки не существует. Можно считать, что контекст строки необходим нам для
извлечения значения столбца для конкретной строки. Например, следующее
выражение для меры недопустимо. Формула пытается вычислить значение
столбца SalesfNet Price], но в отсутствие контекста строки не может получить
информацию о строке, для которой необходимо произвести вычисление:
Gross Margin := Sales[Quantity] * ( Sales[Net Price] - Sales[Unit Cost] )
Эта формула будет вполне допустимой для вычисляемого столбца, но не
для меры. И причина не в том, что вычисляемые столбцы и меры как-то по-
разному используют формулы DAX. Просто вычисляемый столбец располагает
контекстом строки, созданным автоматически, а мера - нет. Если вам необхо-
димо внутри меры вычислить определенное выражение построчно, вам при-
дется использовать итерационную функцию для принудительного создания
контекста строки.
Примечание Ссылка на столбец требует наличия контекста строки для возврата значе-
ния из столбца таблицы. Столбец также может использоваться в качестве аргумента для
некоторых функций DAX, не располагающих контекстом строки. Например, функции DIS-
TINCT и DISTINCTCOUNTмогут принимать на вход столбец, не определяя при этом контекст
строки. Но в выражениях DAX ссылка на столбец должна обладать контекстом строки,
чтобы можно было извлечь конкретное значение.
\______________________________________________________________________________
ГЛАВА 4 Введение в контексты вычисления 113
Здесь мы должны повторить одну важную концепцию: контекст строки не
является разновидностью контекста фильтра, отбирающей одну строку. Кон-
текст строки не фильтрует модель, а лишь указывает движку DAX, какую строку
таблицы использовать. Если вам необходимо применить фильтр в модели дан-
ных, нужно воспользоваться контекстом фильтра. С другой стороны, если вам
нужно вычислить какое-то выражение построчно, контекст строки прекрасно
справится с этой задачей.
Тест на понимание контекстов вычисления
Перед тем как приступить к изучению более сложных аспектов контекстов
вычисления, полезно будет пройти небольшую проверку на уже полученные
знания на паре примеров. Пожалуйста, не смотрите ответ сразу, остановитесь
и попытайтесь ответить на поставленный вопрос самостоятельно и только
после этого сверьтесь с описанием. В качестве подсказки можем напомнить
вам нашу дежурную мантру: «Контекст фильтра фильтрует, контекст строки
осуществляет итерации по таблице. И наоборот - контекст строки НЕ фильтру-
ет, а контекст фильтра НЕ осуществляет итерации по таблице».
Использование функции SUM в вычисляемых столбцах
В первом примере мы будем использовать агрегирующую функцию внутри
вычисляемого столбца. Каким будет результат вычисления следующей форму-
лы, написанной в вычисляемом столбце таблицы Sales?
Sales[SumOfSalesQuantity] = SUM ( Sales[Quantity] )
Помните, что внутри движок DAX преобразует эту формулу в выражение
с итератором следующего вида:
Sales[SumOfSalesQuantity] = SUMX ( Sales; Sales[Quantity] )
Поскольку здесь мы имеем дело с вычисляемым столбцом, его значение
будет рассчитываться построчно в рамках контекста строки. Какой вывод вы
ожидаете увидеть в итоге? На выбор предлагаем три ответа:
значение столбца Quantity для текущей строки, свое для каждой записи;
итог по столбцу Quantity, одинаковый для всех строк;
ошибку, поскольку мы не можем использовать функцию SUM в вычисля-
емом столбце.
Остановитесь и подумайте, как бы вы ответили на этот вопрос.
Вопрос вполне правомочный. Ранее мы говорили, что подобную формулу
можно прочитать так: «Получить сумму по количеству для всех строк, видимых
в текущем контексте фильтра». А поскольку код выполняется для вычисляемо-
го столбца, DAX будет проводить вычисление построчно в рамках контекста
строки. В свою очередь, контекст строки не фильтрует таблицу. Единственный
контекст, который способен это делать, - контекст фильтра. Все это ставит пе-
ред нами новый вопрос: а каким будет контекст фильтра в момент вычисления
114 ГЛАВА 4 Введение в контексты вычисления
этого выражения? Ответ достаточно прост: контекст фильтра будет пустым.
Вообще, контекст фильтра создается при помощи визуальных элементов от-
чета и дополнительных условий в запросе, а значения в вычисляемом столбце
рассчитываются в момент обновления данных, когда никакие фильтры еще не
наложены. Таким образом, функция SUM применяется ко всей таблице Sales,
агрегируя значения столбца Sales [Quantity] для всех записей таблицы Sales.
Так что верным будет второй ответ. В вычисляемом столбце будет одина-
ковое значение для всех строк, и оно будет отражать общий итог по столбцу
Sales [Quantity]. На рис. 4.8 показан вывод отчета с вычисляемым столбцом Sum-
OfSalesQuantity.
Quantity Unit Cost Net Price SumOfSalesQuantity
1 0.48 0.76 140,180.00
1 0.48 0.86 140,180.00
1 0.48 0.88 140,180.00
1 0.48 0.95 140,180.00
1 1.01 1.79 140,180.00
1 1.01 1.85 140,180.00
1 1.01 1.99 140,180.00
1 1.50 2.35 140,180.00
1 1.50 2.50 140,180.00
1 1.50 2.65 140,180.00
1 1.50 2.79 140,180.00
1 1.50 2.94 140,180.00
Рис. 4.8 Функция SUM ( Sa les [Quantity] ) в вычисляемом столбце
распространяется на все строки таблицы
Из этого примера видно, что два контекста вычисления могут мирно со-
существовать и при этом не взаимодействовать друг с другом. Оба контекста
влияют на итоговые результаты, но делают они это по-разному. Агрегирующие
функции вроде SUM, MIN и МАХ используют только контекст фильтра, игнори-
руя при этом контекст строки. Если вы выбрали первый вариант ответа, что де-
лают многие студенты, это нормально. Это означает, что вы по-прежнему пу-
таете контекст фильтра с контекстом строки. Еще раз повторим, что контекст
фильтра фильтрует, контекст строки осуществляет итерации по таблице. Пер-
вый ответ выбирают те, кто полагаются на интуицию, и теперь вы понимаете,
почему. Если же вы сделали правильный выбор, что ж, поздравляем - значит,
эта глава помогла вам понять разницу между различными контекстами.
Использование ссылок на столбцы в мерах
Вторая задача будет из противоположной области. Представьте, что вы пишете
формулу для расчета валовой прибыли с использованием меры, а не вычисляе-
мого столбца. У нас есть столбцы с ценой (Net Price) и себестоимостью (Unit
Cost) за единицу товара, и мы пишем следующее выражение:
ГЛАВА 4 Введение в контексты вычисления 115
GrossMargin% := ( Sales[Net Price] - Sales[Unit Cost] ) / Sales[Unit Cost]
Какой результат мы получим? Как и в первой задаче, мы предлагаем вам три
варианта ответа на выбор:
выражение выглядит корректно, пора запускать отчет;
здесь есть ошибка, такую формулу писать нельзя;
такое выражение написать можно, но оно вернет ошибку при формиро-
вании отчета.
Как и в первом случае, остановитесь ненадолго, подумайте над ответом
и только потом продолжайте чтение.
В этом коде мы ссылаемся на столбцы SalesfNet Price] и Sales [Unit Cost] без ис-
пользования агрегирующих функций. Так что DAX придется вычислять значе-
ние по каждому из столбцов для конкретной строки. Но у движка нет возмож-
ности определить, с какой именно строкой он имеет дело в данный момент,
поскольку мы не запускали итерации по таблице, а код написали не в вычисля-
емом столбце, а в мере. Иными словами, здесь в распоряжении DAX нет контек-
ста строки, который мог бы помочь извлечь значение из нужного нам столбца
в рамках этого выражения. Помните, что при создании меры автоматически
не появляется контекст строки, это происходит только при создании вычис-
ляемого столбца. Если нам нужен контекст строки внутри меры, необходимо
воспользоваться итерационными функциями.
Таким образом, и здесь правильным вариантом ответа будет второй. Мы
не можем написать такую формулу, поскольку она синтаксически неверна,
и ошибка возникнет непосредственно в момент сохранения формулы.
Использование контекста строки с итераторами
Вы уже знаете, что DAX создает контекст строки всякий раз, когда мы добавля-
ем в таблицу вычисляемый столбец или начинаем проходить по таблице при
помощи итерационной функции. Когда мы имеем дело с вычисляемым столб-
цом, понятие контекста строки вполне прозрачно и понятно. Фактически мы
можем создавать вычисляемые столбцы, и не зная о наличии какого-то кон-
текста строки. Причина в том, что движок DAX автоматически создает контекст
строки в момент создания вычисляемого столбца. Так что нам нет смысла бес-
покоиться о его создании или использовании. С другой стороны, когда мы про-
ходим по таблице при помощи итератора, мы лично ответственны за создание
и использование контекста строки. Более того, применяя итерационные функ-
ции, мы вправе создавать множественные контексты строки, вложенные друг
в друга, что увеличивает сложность кода. Это обусловливает важность доско-
нального понимания применения контекста строки совместно с итераторами.
Посмотрите на следующее выражение на DAX:
IncreasedSales := SUMX ( Sales; Sales[Net Price] * 1.1 )
Поскольку функция SUMX является итератором, она создает контекст строки
в рамках таблицы Sales и использует его во время перебора по таблице. Кон-
текст строки проходит по таблице Sales (первый параметр функции) и на каж-
116 ГЛАВА 4 Введение в контексты вычисления
дой итерации предоставляет текущее значение строки выражению, распола-
гающемуся во втором параметре. Иными словами, DAX вычисляет внутреннее
выражение (второй параметр функции SUMX) в контексте строки, содержащей
текущую строку из первого параметра.
Стоит отметить, что два параметра функции SUMX используют разные кон-
тексты. Фактически каждый фрагмент кода DAX выполняется в контексте,
в котором он был вызван. Таким образом, в момент вычисления выражения
могут быть активны контекст фильтра и один или несколько контекстов строк.
Посмотрите на следующую формулу с комментариями:
SUMX (
Sales; -- Внешние контексты фильтра и строки
Sales[Net Price] * 1.1 -- Внешние контексты фильтра и строки + новый контекст
-- строки
)
Первый параметр, Sales, обрабатывается в контексте, пришедшем из вызы-
вающей области кода. В свою очередь, второй параметр, являющийся выраже-
нием, вычисляется одновременно во внешних контекстах и вновь созданном
контексте строки.
Все итерационные функции ведут себя одинаково:
1) обрабатывают первый переданный параметр в существующих кон-
текстах для определения списка строк для сканирования;
2) создают новый контекст строки для каждой строки таблицы, опреде-
ленной на первом шаге;
3) осуществляют итерации по таблице и вычисляют второй параметр
в существующем контексте вычисления, включая вновь созданный
контекст строки;
4) агрегируют значения, рассчитанные на предыдущем шаге.
Помните, что исходные контексты продолжают действовать в момент вы-
числения внутреннего выражения. Итераторы лишь добавляют новый контекст
строки, они не изменяют существующий контекст фильтра. Например, если во
внешнем фильтре определено условие, ограничивающее товары по красному
цвету (Red), этот фильтр останется активным на протяжении всех итераций.
Также стоит всегда держать в уме, что контекст строки осуществляет итерации
по таблице, а не фильтрует ее. Так что у него нет никаких инструментов для
переопределения внешнего контекста фильтра.
Это правило действует всегда, однако здесь есть один важный, но не вполне
очевидный нюанс. Если во внешнем контексте вычисления содержится кон-
текст строки по той же самой таблице, что и во внутреннем, то прежний кон-
текст будет скрыт при вычислении вложенных выражений. У новичков DAX
этот сложный аспект традиционно является источником ошибок, так что мы
рассмотрим эту особенность языка в следующих двух разделах.
Вложенные контексты строки в разных таблицах
Выражение, выполняемое внутри итерационной функции, может быть до-
статочно сложным. Более того, оно может включать в себя дополнительные
ГЛАВА 4 Введение в контексты вычисления 117
итерации. На первый взгляд кажется, что открывать новый цикл в рамках су-
ществующего - это довольно странно. Но в DAX это является весьма распро-
страненной практикой, поскольку вложенные итераторы позволяют строить
действительно мощные выражения.
Например, в представленном ниже коде мы видим сразу три уровня вложен-
ности итераторов, сканирующих три разные таблицы: Product Category, Product
и Sales.
SUMX (
'Product Category'; -- Сканируем таблицу Product Category
SUMX ( -- Для каждой категории
RELATEDTABLE ( 'Product' ); -- Сканируем товары
SUMX ( -- Для каждого товара
RELATEDTABLE ( 'Sales' ); -- Сканируем продажи по товару
Sales[Quantity]
* 'Product'[Unit Price] -- Получаем сумму по этой продаже
* 'Product Category'[Discount]
)
)
)
В выражении на максимальном уровне вложенности, где идет обращение
к трем столбцам, мы ссылаемся сразу на три таблицы. Фактически в этот мо-
мент активны сразу три контекста строки - по одному на каждую из трех таб-
лиц, по которым мы проходим. Также стоит отметить, что две вложенные
функции RELATEDTABLE возвращают строки из связанных таблиц, начиная
с текущего контекста строки. Так что функция RELATEDTABLE ( 'Product'), бу-
дучи вызванной в контексте строки, пришедшем из таблицы Product Category,
возвращает товары только указанной категории. То же самое касается и вызо-
ва функции RELATEDTABLE ('Sales'), возвращающей продажи по конкретному
товару.
При этом показанный код является далеко не самым оптимальным с точки
зрения читаемости и производительности. Вкладывать итераторы один в дру-
гой принято только в случае, если строк для перебора будет не так много: сот-
ни - нормально, тысячи - приемлемо, миллионы - плохо. В противном случае
может серьезно пострадать производительность запроса. Предыдущую форму-
лу мы использовали, исключительно чтобы продемонстрировать возможность
создания множественных вложенных контекстов строки. Позже в этой книге
вы увидите более полезные примеры с применением вложенных итераторов.
Здесь же мы отметим, что данную формулу можно было написать гораздо более
лаконично с использованием единого контекста строки и функции RELATED:
SUMX (
Sales;
Sales[Quantity]
* RELATED ( 'Product'[Unit Price] )
* RELATED ( 'Product Category'[Discount] )
)
Когда у нас есть множество контекстов строки в рамках разных таблиц, мы
можем использовать их для ссылки на эти таблицы в одном выражении DAX. Но
118 ГЛАВА 4 Введение в контексты вычисления
существует более сложный сценарий, в котором вложенные контексты строки
принадлежат одной и той же таблице. Именно такой случай мы рассмотрим
в следующем разделе.
Вложенные контексты строки в одной таблице
Может показаться, что сценарий с вложенными контекстами строки в рамках
одной и той же таблицы - явление довольно редкое. Но это не так. Этот прием
встречается повсеместно, и чаще всего его можно увидеть в формулах вычис-
ляемых столбцов. Представьте, что вам необходимо ранжировать товары по их
цене. Наиболее дорогой товар в ассортименте должен получить ранг, равный
единице, второй по дороговизне - двойку и т. д. Мы могли бы решить эту за-
дачу с использованием функции RANKX, но в образовательных целях покажем,
как обойтись более простыми функциями языка DAX.
Для определения ранга можно просто подсчитать количество товаров в таб-
лице, цена на которые превышает цену текущего товара. Если в базе не окажет-
ся товаров с более высокой ценой, мы присвоим текущему товару первый ранг.
Если функция подсчета строк вернет единицу, значит, ранг текущего товара
будет равен двум. Все, что нам нужно сделать, - это посчитать, у скольких то-
варов цена выше нашей, и прибавить к результату единицу.
Мы могли бы попробовать написать следующую формулу для вычисляемого
столбца, где PriceOfCurrentProduct - это временная заглушка для подстановки
в дальнейшем цены текущего товара:
1. 'Product'[UnitPriceRank] =
2. COUNTROWS (
3. FILTER (
4. 'Product';
5. 'Product'[Unit Price] > PriceOfCurrentProduct
6. )
7. ) + 1
Функция FILTER вернет список товаров с ценой, большей, чем у текущего
товара, a COUNTROWS подсчитает количество возвращенных строк. Един-
ственная проблема - как написать формулу для нахождения цены текущего
товара, чтобы заменить ей временный заполнитель PriceOfCurrentProduct? Под
текущим товаром мы подразумеваем значение в заданном столбце текущей
строки в момент вычисления выражения. И эта задача на самом деле сложнее,
чем может показаться.
Обратите внимание на пятую строку кода. В ней выражение Product [Unit
Price] ссылается на значение столбца Unit Price в текущем контексте строки.
А какой контекст строки активен на момент выполнения пятой строки кода?
Таких контекстов два. Поскольку наш код написан в вычисляемом столбце,
у нас есть автоматически созданный контекст строки для сканирования таб-
лицы Product. В то же время функция FILTER сама по себе является итерато-
ром, а значит, создает свой собственный контекст строки, распространяю-
щийся на ту же самую таблицу Product. Эта ситуация показана графически на
рис. 4.9.
ГЛАВА 4 Введение в контексты вычисления 119
Рис. 4.9 Во время выполнения внутреннего выражения существуют
сразу два контекста строки для таблицы Product
Внешняя рамка характеризует контекст строки вычисляемого столбца,
осуществляющего итерации по таблице Product. В то же время внутренняя
рамка демонстрирует контекст строки функции FILTER, которая проходит
по той же самой таблице. Следовательно, значение выражения Product[Unit
Price] будет напрямую зависеть от того, в каком контексте строки выполня-
ется вычисление. Получается, что ссылка на столбец Product[Unit Price] во
внутренней рамке может ссылаться исключительно на значение текущей
итерации функции FILTER. Проблема же состоит в том, что в этой внутрен-
ней рамке нам необходимо как-то обратиться к значению столбца Unit Price
со ссылкой на внешний контекст строки вычисляемого столбца, который
в данный момент скрыт.
Если мы не создаем внутри вычисляемого столбца дополнительных контек-
стов строки при помощи итераторов, обратиться к нужному столбцу можно
просто по имени в текущем контексте строки, как показано ниже:
Product[Test] = Product[Unit Price]
Чтобы еще более наглядно продемонстрировать проблему, давайте попро-
буем вычислить значение Product[Unit Price] в обеих рамках, вставив в код спе-
циальные заглушки. В результате мы получили разные значения, что видно по
рис. 4.10, где мы добавили вычисление выражения Product [Unit Price] прямо
перед COUNTROWS исключительно в образовательных целях.
Рис. 4.10 Во внешней рамке выражение Product[Unit Price]
ссылается на текущий контекст строки вычисляемого столбца
120 ГЛАВА 4 Введение в контексты вычисления
Напомним, что происходит в нашем сценарии:
внутренний контекст строки, созданный функцией FILTER, скрывает
внешний контекст строки;
наша задача - сравнить значения выражения Product [Unit Price] во внут-
реннем и внешнем контекстах;
если написать операцию сравнения во внутренней рамке, мы не сможем
напрямую обратиться к значению Product [Unit Price] из внешнего контекста.
Но мы ведь можем получить значение цены товара во внешней рамке, а зна-
чит, лучшим решением будет там же сохранить ее в переменной. В результате
мы сможем получить значение переменной в контексте строки вычисляемого
столбца с использованием следующего кода:
'Product'[UnitPriceRank] =
VAR
PriceOfCurrentProduct = 'Product'[Unit Price]
RETURN
COUNTROWS (
FILTER (
'Product';
'Product'[Unit Price] > PriceOfCurrentProduct
)
) + 1
Лучше делать подобные формулы более многословными и использовать по-
больше переменных, чтобы можно было проследить все этапы вычисления.
Кроме того, такой код будет легче читать и поддерживать:
'Product'[UnitPriceRank] =
VAR PriceOfCurrentProduct = 'Product'[Unit Price]
VAR MoreExpensiveProducts =
FILTER (
'Product';
'Product'[Unit Price] > PriceOfCurrentProduct
)
RETURN
COUNTROWS ( MoreExpensiveProducts ) + 1
На рис. 4.11 графически показаны разные контексты строки в этом коде. Так
вам будет легче понять, в каком контексте вычисляется какое выражение.
На рис. 4.12 показан результат ранжирования товаров, проведенный при по-
мощи нашего вычисляемого столбца.
Поскольку в нашей таблице оказалось сразу 14 товаров с самой высокой це-
ной, у всех у них проставился ранг, равный единице. У товаров со второй по
величине ценой ранг был установлен в значение 15. Было бы здорово, если бы
товары в таблице ранжировались последовательными числами 1, 2, 3 и т. д.,
а не 1,15,19, как это происходит сейчас. Мы совсем скоро исправим этот недо-
чет, а сейчас позвольте сделать небольшое отступление.
Чтобы решить предложенную задачу ранжирования, необходимо очень хо-
рошо понимать, что из себя представляет контекст строки, уметь точно опре-
делять, какой контекст строки активен в том или ином фрагменте кода, и, что
ГЛАВА 4 Введение в контексты вычисления 121
более важно, разбираться в том, как именно контекст строки влияет на вычис-
ление выражения DAX. Стоит еще раз подчеркнуть, что одно и то же выраже-
ние Product[Unit Price], вычисленное в разных участках кода, дало совершенно
разные результаты из-за разницы контекстов. Без полного понимания того,
как работают контексты вычисления в DAX, разбираться в столь сложном коде
будет очень проблематично.
Рис. 4.11 Значение переменной PriceOfCurrentProduct
вычисляется во внешнем контексте строки
Product Name Unit Price UnitPriceRank ▲
Fabrikam Refrigerator 24.7CuFt X9800 Blue 3,199.99 1
Fabrikam Refrigerator 24.7CuFt X9800 Brown 3,199.99 1
Fabrikam Refrigerator 24.7CuFt X9800 Green 3,199.99 1
Fabrikam Refrigerator 24.7CuFt X9800 Grey 3,199.99 1
Fabrikam Refrigerator 24.7CuFt X9800 Orange 3,199.99 1
Fabrikam Refrigerator 24.7CuFt X9800 Silver 3,199.99 1
Fabrikam Refrigerator 24.7CuFt X9800 White 3,199.99 1
Litware Refrigerator 24.7CuFt X980 Blue 3,199.99 1
Litware Refrigerator 24.7CuFt X980 Brown 3,199.99 1
Litware Refrigerator 24.7CuFt X980 Green 3,199.99 1
Litware Refrigerator 24.7CuFt X980 Grey 3,199.99 1
Litware Refrigerator 24.7CuFt X980 Silver 3,199.99 1
Litware Refrigerator 24.7CuFt X980 White 3,199.99 1
Litware Refrigerator L1200 Orange 3,199.99 1
Adventure Works 52“ LCD HDTV X590 Black 2,899.99 15
Adventure Works 52й LCD HDTV X590 Brown 2,899.99 15
Adventure Works 52" LCD HDTV X590 Silver 2,899.99 15
Adventure Works 52” LCD HDTV X590 White 2,899.99 15
NT Washer & Dryer 27in L2700 Blue 2,652.90 19
NT Washer & Dryer 27in L2700 Green 2,652.90 19
NT Washer & Drver 27in L2700 Silver 2,652.90 19
Рис. 4.12 UnitPriceRank - отличный пример использования переменных
для навигации по вложенным контекстам строки
122 ГЛАВА 4 Введение в контексты вычисления
Как видите, даже простое вычисление ранга с использованием двух контек-
стов строки вызвало у нас серьезные трудности. А в главе 5 мы будем работать
с примерами со множественными контекстами, и там сложность кода будет
гораздо выше. Но если вы понимаете, как взаимодействуют контексты, все бу-
дет просто. Перед тем как двигаться дальше, вам просто необходимо хорошо
разобраться в контекстах вычисления. Именно поэтому мы посоветовали бы
вам пробежаться по этому разделу еще раз, а может, и по всей главе, чтобы за-
крепить усвоенный материал. Это значительно облегчит дальнейший процесс
обучения языку DAX.
А сейчас мы решим последнюю проблему - с последовательностями рангов.
Постараемся привести их к привычному виду 1,2,3 и т. д. Решение будет гораз-
до более простым, чем вы могли бы себе представить. Фактически в предыду-
щем фрагменте кода мы концентрировались на подсчете количества товаров
с большей ценой. В результате 14 товарам был присвоен ранг 1, а следующие
товары получили ранг 15. Значит, считать товары было не самой лучшей идеей.
Гораздо лучше будет считать цены - в этом случае все 14 товаров с одной ценой
сольются в один.
'Product'[UnitPriceRankDense] =
VAR PriceOfCurrentProduct = 'Product'[Unit Price]
VAR HigherPrices =
FILTER (
VALUES ( 'Product'[Unit Price] );
'Product'[Unit Price] > PriceOfCurrentProduct
)
RETURN
COUNTROWS ( HigherPrices ) + 1
На рис. 4.13 показан новый вычисляемый столбец наряду со столбцом Unit-
PriceRank.
Последним шагом в этой задаче был переход на подсчет цен вместо товаров,
и решение оказалось более простым, чем можно было ожидать. Чем больше вы
будете работать с DAX, тем легче вам будет начать мыслить категориями вре-
менных таблиц, создаваемых для определенных вычислений.
Из данного примера вы узнали, что лучшим способом управления множест-
венными контекстами в рамках одной таблицы является создание вспомога-
тельных переменных. Помните, что переменные были введены в языке толь-
ко в 2015 году. Так что вы вполне можете столкнуться с примерами на DAX,
которые были написаны до введения переменных, но прекрасно справлялись
со множественными контекстами строки с использованием функции EARLIER,
с которой мы и познакомимся в следующем разделе.
Использование функции EARLIER
Язык DAX предоставляет нам функцию EARLIER, предназначенную специаль-
но для обращения к внешнему контексту строки. Функция EARLIER извлекает
значение по столбцу с использованием предыдущего контекста строки вместо
текущего. Так что мы вполне можем переписать формулу для нашей заглушки
PriceOfCurrentProduct следующим образом: EARLIER ( Product[UnitPrice] ).
ГЛАВА 4 Введение в контексты вычисления 123
Product Name
Unit Price UnitPriceRank UnitPriceRankDense
Fabrikam Refrigerator 24.7CuFt Х9800 Blue 3,199.99 1 1
Fabrikam Refrigerator 24.7CuFt X9800 Brown 3,199.99 1 1
Fabrikam Refrigerator 24.7CuFt X9800 Green 3,199.99 1 1
Fabrikam Refrigerator 24.7CuFt X9800 Grey 3,199.99 1 1
Fabrikam Refrigerator 24.7CuFt X9800 Orange 3,199.99 1 1
Fabrikam Refrigerator 24.7CuFt X9800 Silver 3,199.99 1 1
Fabrikam Refrigerator 24.7CuFt X9800 White 3,199.99 1 1
Litware Refrigerator 24.7CuFt X980 Blue 3,199.99 1 1
Litware Refrigerator 24.7CuFt X980 Brown 3,199.99 1 1
Litware Refrigerator 24.7CuFt X980 Green 3,199.99 1 1
Litware Refrigerator 24.7CuFt X980 Grey 3,199.99 1 1
Litware Refrigerator 24.7CuFt X980 Silver 3,199.99 1 1
Litware Refrigerator 24.7CuFt X980 White 3,199.99 1 1
Litware Refrigerator L1200 Orange 3,199.99 1 1
Adventure Works 52" LCD HDTV X590 Black 2,899.99 15 2
Adventure Works 52" LCD HDTV X590 Brown 2,899.99 15 2
Adventure Works 52" LCD HDTV X590 Silver 2,899.99 15 2
Adventure Works 52" LCD HDTV X590 White 2,899.99 15 2
NT Washer & Dryer 27in L2700 Blue 2,652.90 19 3
NT Washer & Dryer 27in L2700 Green 2,652.90 19 3
NT Washer & Dryer 27in L2700 Silver 2,652.90 19 3
NT Washer & Dryer 27in L2700 White 2,652.90 19 3
Рис. 4.13 Столбец UnitPriceRankDense показывает более показательные ранги,
поскольку считает цены, а не товары
Многие новички пугаются функции EARLIER, поскольку не очень хорошо
понимают концепцию контекста строки и не до конца осознают пользу вло-
женных друг в друга контекстов строки из одной и той же таблицы. С другой
стороны, функция EARLIER не представляет сложности для тех, кто усвоил по-
нятия контекстов строки и их вложенности. Можно написать наш предыдущий
код с использованием этой функции и без применения переменных:
'Product'[UnitPriceRankDense] =
COUNTROWS (
FILTER (
VALUES ( 'Product'[Unit Price] );
'Product'[UnitPrice] > EARLIER ( 'Product'[UnitPrice] )
)
) + 1
Примечание Функция EARLIER принимает второй аргумент, указывающий на то, сколько
шагов нужно пропустить, чтобы была возможность перепрыгнуть через несколько кон-
текстов. Более того, существует также функция EARLIEST, позволяющая обратиться к само-
му верхнему контексту строки, определенному для данной таблицы. В реальности же ни
функция EARLIEST, ни второй аргумент функции EARLIER часто не используются. Если сце-
нарии с двумя вложенными контекстами строки встречаются на практике довольно часто,
три и более уровня вложенности - достаточно редкое явление. К тому же с появлением
в DAX переменных функция EARLIER практически вышла из обращения - большинство
разработчиков отдают предпочтение именно переменным.
\__________________________________________.___________________________________J
124 ГЛАВА 4 Введение в контексты вычисления
Таким образом, единственная польза от изучения функции EARLIER сегодня
состоит в возможности читать чужой код на DAX. Использовать эту функцию
в своих выражениях нет никакого смысла, поскольку переменные прекрасно
справляются с сохранением значений в том или ином доступном контексте
строки. Использовать переменные для этой цели считается более приемлемым
вариантом: это делает код более легким для восприятия.
Функции FILTER, ALL и взаимодействие между
контекстами
Ранее мы использовали функцию FILTER исключительно для осуществления
фильтрации таблицы. Это очень распространенная функция, необходимая для
создания новых ограничений, накладывающихся на существующий контекст
фильтра.
Представьте, что вам нужно создать меру, в которой будет подсчитано коли-
чество красных товаров. С учетом знаний, полученных нами ранее, мы можем
легко написать такую формулу:
NumOfRedProducts :=
VAR RedProducts =
FILTER (
'Product';
'Product'[Color] = "Red"
)
RETURN
COUNTROWS ( RedProducts )
Эту меру спокойно можно использовать в отчетах. Например, мы можем
вынести наименования брендов на строки и построить отчет, показанный на
рис. 4.14.
Brand
NumOfRedProducts
Adventure Works 6
Contoso 36
Fabrikam 12
Litware 12
Northwind Traders 3
Proseware 7
Southridge Video 13
Tailspin Toys 6
Wide World Importers 4
Total 99
Рис. 4.14 Можно подсчитать
количество красных товаров в таблице
с использованием функции FILTER
Перед тем как двигаться дальше, остановитесь на минутку и подумайте
о том, как именно DAX получил эти значения. Столбец Brand принадлежит
таблице Product. Внутри каждой ячейки отчета контекст фильтра накладывает
ГЛАВА 4 Введение в контексты вычисления 125
ограничения по одному конкретному бренду. Таким образом, в каждой ячей-
ке мы видим количество товаров определенного бренда и при этом исключи-
тельно красного цвета. Причина этого в том, что функция FILTER проходит по
таблице Product в том виде, в котором она видна в рамках текущего контекста
фильтра, включающего конкретный бренд. Кажется, что это все очень просто,
но не лишним будет повторить это несколько раз, чтобы уж точно не забыть.
Такое поведение меры становится более очевидным с добавлением к отче-
ту среза, фильтрующего товары по цвету. На рис. 4.15 показаны два идентич-
ных отчета со срезами по цвету, каждый из которых фильтрует отчет непо-
средственно справа от себя. В отчете слева выбран фильтр по красному цвету
товаров (Red), и цифры в этом отчете такие же, как на рис. 4.14. В то же время
правый отчет, где в срезе указан лазурный цвет (Azure), остался пустым.
Cotot Brand N u mOf Red P rod u ct s Color Brand NumOfRedProducts
Azine Ц Azure
Biack Adventure Works 6 Black Total
Blue B'o\s P- Contoso 36 Blue Bro'.vn
Gold Fabrikam 12 Gold
Green Litware 12 Green
Grey Oranqe Northwind Traders 3 Grey Orange
Pink Proseware 7 Pink
Pu'ole Southridge Video 13 Purple
Red Si ver Tailspin Toys 6 Red S<lver
Si ver Grey Wide World Importers 4 S:lver Grey
Transparent White Total 99 Transparent Wh-te
Рис. 4.15 DAX рассчитывает меру NumOfRedProducts, принимая во внимание внешний
контекст фильтра, заданный в срезе
В правом отчете функция FILTER проходит по таблице Product в рамках
внешнего контекста фильтра, включающего в себя лазурный цвет, при этом
в самой мере прописан фильтр по красному цвету, что приводит к несогласо-
ванным данным и пустому выводу в отчете. Иными словами, в каждой ячейке
этого отчета мера NumOfRedProducts возвращает BLANK.
На этом примере мы показали, что в одной и той же формуле вычисление
производится как во внешнем контексте фильтра, учитывающем срезы вне са-
мого отчета, так и в контексте строки, созданном непосредственно функцией
FILTER внутри формулы. Оба контекста действуют одновременно и влияют на
результат вычисления. Контекст фильтра DAX использует для оценки таблицы
Product, а контекст строки - для построчной проверки фильтрующего условия
во время итераций в функции FILTER,
Мы хотим заново повторить эту концепцию: функция FILTER не изменяет
контекст фильтра. FILTER - это итерационная функция, которая сканирует таб-
лицу (уже отфильтрованную при помощи контекста фильтра) и возвращает
набор данных, соответствующий условиям фильтра. В отчете, показанном на
рис. 4.14, контекст фильтра включает в себя только бренды, и после возврата
результата из функции FILTER он по-прежнему фильтрует только бренды. До-
бавив в отчет срезы по цвету (рис. 4.15), мы расширили контекст фильтра до
брендов и цветов. Именно поэтому в левом отчете функция FILTER вернула
126 ГЛАВА 4 Введение в контексты вычисления
все товары из итераций, а в правом список товаров оказался пустым. В обоих
отчетах функция FILTER не изменяла контекст фильтра, а только сканировала
таблицу и возвращала отфильтрованный результат.
На этом этапе кому-то из вас, вероятно, хотелось бы иметь функцию, по-
зволяющую получить полный список красных товаров, независимо от выбора
пользователя в срезах.
И здесь на помощь придет уже знакомая нам функция ALL. Эта функция воз-
вращает содержимое таблицы, игнорируя при этом контекст фильтра. Давайте
определим новую меру, назовем ее NumOfAURedProducts и пропишем для нее
следующую формулу:
NumOfAURedProducts : =
VAR AllRedProducts =
FILTER (
ALL ( 'Product' );
'Product'[Color] = "Red"
)
RETURN
COUNTROWS ( AllRedProducts )
В этом случае в функции FILTER будут запускаться итерации не по таблице
Product, а по выражению ALL ( Product ).
Функция ALL игнорирует установленный контекст фильтра и всегда возвра-
щает все строки из таблицы, так что функция FILTER в данном случае вернет
красные товары, даже если таблица Products была предварительно отфильтро-
вана по другим брендам или цветам.
Результат, показанный на рис. 4.16, может вас удивить, несмотря на свою
корректность.
Color Brand NumOfAURedProducts Color Brand NumOfAURedProducts
мгиге
В э:и. Adventure Works 99 Б la c •. Blue Bnj.vn GC'lit A. Datum 99
В Liр В-и'Л'г Gdd Contoso 99 Total 99
Fabrikam 99
ljrcc'1 Litware 99 Green
Grey Northwind Traders 99 u re у
Oranqp Омпае
Pink Proseware 99 P nk
Purp'p Southridge Video 99 Pufpl?
p'et; Tailspin Toys 99 Ped
5, ург Grpy Wide World Importers 99 Ч н f г Li г г у
Transparent Total 99 transparent
WMe Wi tp
Рис. 4.16 Мера NumOfAURedProducts вернула неожиданный результат
Здесь есть пара любопытных моментов, и мы хотим поговорить о них по-
дробно:
результат во всех ячейках равен 99 вне зависимости от бренда в строке;
бренды в левом отчете отличаются от брендов в правом.
Заметим, что 99 - это количество всех красных товаров в таблице, а не их ко-
личество по конкретному бренду. Функция ALL, как и ожидалось, проигнори-
ГЛАВА 4 Введение в контексты вычисления 127
ровала все фильтры, наложенные на таблицу Product. При этом игнорируются
не только фильтры по цвету, но и по брендам. Возможно, вы этого не хотели.
Однако функция ALL работает именно так - она очень простая и мощная, но
действует по принципу «или все, или ничего», игнорируя абсолютно все фильт-
ры, наложенные на указанную таблицу. В данный момент у вас не хватает зна-
ний, чтобы обеспечить игнорирование только части установленных фильтров.
В нашем примере логичнее было бы игнорировать лишь фильтр по цвету. Но
с функцией CALCULATE, которая позволяет более выборочно управлять нало-
женными фильтрами, мы познакомимся только в следующей главе.
Теперь рассмотрим второй момент, заключающийся в том, что список брен-
дов в двух отчетах отличается. Поскольку у нас в срезе выбран только один
цвет, полная матрица отчета вычисляется с учетом этого цвета. В левом отчете
у нас выбран красный цвет, а в правом - лазурный. Этот выбор ограничивает
список товаров, а значит, и список брендов. Перечень брендов, используемый
для вывода в отчет, строится с учетом текущего контекста фильтра, содержа-
щего фильтр по цвету. После определения списка брендов происходит вычис-
ление меры, в результате чего мы получаем число 99 вне зависимости от теку-
щего бренда и цвета. Таким образом, в левом отчете мы видим список брендов,
в которых есть товары красного цвета, а в правом - лазурного, тогда как все
цифры в обоих отчетах показывают количество товаров красного цвета, неза-
висимо от бренда.
Примечание Поведение этого отчета не характерно для DAX, а больше подходит для
функции SUMMARIZECOLUMNS, используемой в Power Bl. Мы познакомимся с этой функ-
цией в главе 13.
На данном этапе мы закончим работать с этим примером. Решение этого
сценария придет позже, когда мы познакомимся с функцией CALCULATE, даю-
щей больше свободы в работе с контекстами фильтра. Здесь мы использовали
этот пример, чтобы показать вам, что даже простые формулы могут давать не-
ожиданные результаты вследствие взаимодействия контекстов и сосущество-
вания в одном и том же выражении контекста фильтра и контекста строки.
Работа с несколькими таблицами
Теперь, когда вы изучили основы контекстов вычисления, можно поговорить
о том, как ведут себя контексты при наличии связей между таблицами. Мало
какие модели данных состоят всего из одной таблицы. Скорее всего, вы бу-
дете иметь дело с несколькими таблицами, объединенными связями. А если
таблицы Sales и Product объединены связью, означает ли это, что фильтр, нало-
женный на таблицу Product, будет автоматически распространяться на табли-
цу Sales? А как насчет фильтра на Sales? Будет ли он распространяться на Pro-
duct? Поскольку существует два вида контекста вычисления (контекст фильтра
и контекст строки) и две стороны у связей («один» и «многие»), у нас набирает-
ся ровно четыре сценария для анализа.
128 ГЛАВА 4 Введение в контексты вычисления
Ответы на заданные выше вопросы можно найти в нашей любимой мантре,
звучащей так: «Контекст фильтра фильтрует, а контекст строки осуществля-
ет итерации по таблице, и наоборот - контекст строки НЕ фильтрует, а кон-
текст фильтра НЕ осуществляет итерации по таблице».
Для нашего сценария мы будем использовать модель данных из шести таб-
лиц, показанную на рис. 4.17.
Рис. 4.17 Модель данных для изучения взаимодействий между контекстами и связями
Стоит отметить пару деталей относительно представленной модели данных:
от таблицы Sales к таблице Product Category ведет целая цепочка связей
через таблицы Product и Product Subcategory;
единственная двунаправленная связь объединяет таблицы Sales и Pro-
duct. Все остальные связи в модели - однонаправленные.
Подобная модель данных может быть очень полезной при изучении взаимо-
действий между контекстами вычисления и связями, о чем мы будем говорить
в следующих разделах.
Контексты строки и связи
Контекст строки осуществляет итерации по таблице, он не фильтрует. Речь
идет о построчном сканировании таблицы и последовательном выполнении
той или иной операции. Обычно в отчетах нам нужны какие-то агрегации
вроде суммы или среднего значения. Во время прохода по таблице контекст
строки перебирает строки конкретной таблицы, предоставляя доступ к инфор-
ГЛАВА 4 Введение в контексты вычисления 129
мации по всем столбцам, но только из этой таблицы. В других таблицах - даже
связанных с нашей - контекст строки в этот момент не создан. Иными слова-
ми, контекст строки сам по себе автоматически не взаимодействует с сущест-
вующими в модели связями.
Давайте рассмотрим для примера вычисляемый столбец в таблице Sales,
хранящий разницу между ценой товара в таблице фактов и ценой из справоч-
ника. Следующий код на языке DAX работать не будет, поскольку мы пытаемся
ссылаться на столбец Product [UnitPri.ce], в то время как контекст строки для таб-
лицы Product не создан:
Sales[UnitPriceVariance] = Sales[Unit Price] - RELATED ( 'Product'[Unit Price] )
Функция RELATED требует наличия контекста строки (то есть итерации)
в таблице, находящейся в связи на стороне «многие». Если контекст строки бу-
дет активным на стороне «один», функция RELATED нам не поможет, посколь-
ку она найдет сразу несколько строк, следуя по связи. В случае, когда мы осу-
ществляем итерации по таблице со стороны «один», придется воспользоваться
функцией RELATEDTABLE. Функция RELATEDTABLE возвращает все строки из
таблицы, находящейся в связи на стороне «многие», соотносящиеся с табли-
цей, по которой мы осуществляем итерации. Например, если вам необходимо
подсчитать количество продаж по каждому товару, вам поможет следующая
формула для вычисляемого столбца в таблице Product:
Product[NumberOfSales] =
VAR SalesOfCurrentProduct = RELATEDTABLE ( Sales )
RETURN
COUNTROWS ( SalesOfCurrentProduct )
Это выражение подсчитывает количество строк в таблице Sales, соответству-
ющих выбранному товару. Результат вычисления меры представлен на рис. 4.18.
Product Name
NumberOfSales
A. Datum Advanced Digital Camera M300 Azure 13
A. Datum Advanced Digital Camera M300 Black 23
A. Datum Advanced Digital Camera M300 Green 32
A. Datum Advanced Digital Camera M300 Grey 32
A. Datum Advanced Digital Camera M300 Orange 3
A. Datum Advanced Digital Camera M300 Pink 41
A. Datum Advanced Digital Camera M300 Silver 18
A. Datum All in One Digital Camera M200 Azure 29
A. Datum All in One Digital Camera M200 Black 16
A. Datum All in One Digital Camera M200 Green 19
A. Datum All in One Digital Camera M200 Grey 51
Рис. 4.18 Функция RELATEDTABLE может быть полезна в контексте строки
на стороне «один»
При этом обе функции - RELATED и RELATEDTABLE - способны проходить
по целым цепочкам связей, а не ограничены доступом только к таблице, объ-
130 ГЛАВА 4 Введение в контексты вычисления
единенной с текущей напрямую. Допустим, вы можете создать вычисляемый
столбец с такой же формулой, как в предыдущем примере, но на этот раз в таб-
лице Product Category:
'Product Category'[NumberOfSales] =
VAR SalesOfCurrentProductCategory = RELATEDTABLE ( Sales )
RETURN
COUNTROWS ( SalesOfCurrentProductCategory )
Результатом будет количество продаж по каждой категории товаров, при
этом доступ к таблице Sales будет осуществляться не непосредственно, а сразу
через две транзитные таблицы Product Subcategory и Product,
Похожим образом вы можете создать вычисляемый столбец в таблице Pro-
duct с копией наименования категории из таблицы Product Category:
'Product'[Category] = RELATED ( 'Product Category'[Category] )
В этом случае функция RELATED проделает путь от таблицы Product к Product
Category через транзитную таблицу Product Subcategory.
Примечание Единственным исключением из этого правила будет поведение функций
RELATED и RELATEDTABLE в связях типа «один к одному». Если две таблицы объединены
такой связью, можно применять любую из этих функций, но результатом будет либо зна-
чение столбца, либо таблица с одной строкой в зависимости от используемой функции,
ч/
Также стоит отметить, что для успешного прохождения по цепочке все свя-
зи должны быть одного типа, то есть «один ко многим» или «многие к одно-
му». Если две таблицы будут связаны через промежуточную таблицу-мост
(bridge table) связями «один ко многим» и «многие к одному» соответственно,
ни RELATED, ни RELATEDTABLE не будут корректно работать при условии, что
все связи будут однонаправленными. При этом из этих двух функций только
RELATEDTABLE умеет работать с двунаправленными связями, как будет по-
казано далее. С другой стороны, связь типа «один к одному» ведет себя од-
новременно как связь «один ко многим» и «многие к одному». Так что такая
связь вполне может быть одним из звеньев цепочки, соединяющей несколько
таблиц.
Например, в нашей модели данных таблица Customer связана с Sales, a Sales -
с Product. При этом таблицы Customer и Sales объединены связью типа «один
ко многим», a Sales с Product - «многие к одному». Получается, что у нас есть
цепочка связей между таблицами Customer и Product. Но при этом две связи из
этой цепочки различаются по типу. Такой сценарий характеризуется образо-
ванием связи «многие ко многим». Одному покупателю соответствуют многие
купленные им товары, и в то же время один товар могут приобрести сразу не-
сколько покупателей. Подробно о связях типа «многие ко многим» мы будем
говорить в главе 15, а сейчас нас интересуют только вопросы, связанные с кон-
текстом строки. Если вы будете использовать функцию RELATEDTABLE для таб-
лиц, объединенных связью типа «многие ко многим», то получите неправиль-
ные результаты. Посмотрите на следующий вычисляемый столбец, созданный
в таблице Product:
ГЛАВА 4 Введение в контексты вычисления 131
Product[NumOfBuyingCustomers] =
VAR CustomersOfCurrentProduct = RELATEDTABLE ( Customer )
RETURN
COUNTROWS ( CustomersOfCurrentProduct )
В результате мы получим не количество покупателей, приобретавших кон-
кретный товар, а общее количество покупателей, как показано на рис. 4.19.
Product Name
NumOfBuyingCustomers
A. Datum Advanced Digital Camera M300 Azure 18869
A. Datum Advanced Digital Camera M300 Black 18869
A. Datum Advanced Digital Camera M300 Green 18869
A. Datum Advanced Digital Camera M300 Grey 18869
A. Datum Advanced Digital Camera M300 Orange 18869
A. Datum Advanced Digital Camera M300 Pink 18869
A. Datum Advanced Digital Camera M300 Silver 18869
A. Datum All in One Digital Camera M200 Azure 18869
A. Datum All in One Digital Camera M200 Black 18869
A. Datum All in One Digital Camera M200 Green 18869
Рис. 4.19 Функция RELATEDTABLE не работает co связью «многие ко многим»
Функция RELATEDTABLE не может пробиться по цепочке связей, посколь-
ку они различаются по типу. Контекст строки от таблицы Product не достигает
таблицы Customers. Интересно следующее: если мы будем проходить по це-
почке связей в обратном направлении, то есть попытаемся получить количест-
во товаров, которые были приобретены конкретным покупателем, результат
окажется правильным, - мы увидим для каждого отдельного покупателя свое
количество товаров. Причина такого поведения модели не в распространении
контекста строки, а в преобразовании контекста, осуществляемом функцией
RELATEDTABLE. Последнее замечание мы сделали исключительно для полноты
картины. Вы гораздо лучше поймете, как это работает, прочитав главу 5.
Контекст фильтра и связи
В предыдущем разделе вы узнали, что контекст строки предназначен для осу-
ществления итераций по таблице, а значит, не использует связи. Контекст
фильтра, напротив, фильтрует данные. Кроме того, контекст фильтра не при-
надлежит какой-то одной таблице, а воздействует на всю модель данных в це-
лом. И сейчас мы можем немного обновить нашу мантру, посвященную кон-
текстам вычисления, чтобы она отражала истинную картину:
Контекст фильтра фильтрует модель данных, а контекст строки осуществ-
ляет итерации по таблице.
Поскольку контекст фильтра воздействует на всю модель, логично предпо-
ложить, что для этого он использует связи между таблицами. При этом взаимо-
действие контекста фильтра со связями осуществляется автоматически и за-
висит от направления кросс-фильтрации (cross-filter direction), установленного
132 ГЛАВА 4 Введение в контексты вычисления
для каждой из них. Направление кросс-фильтрации обозначается в модели
данных маленькой стрелкой посередине связи, как видно на рис. 4.20.
HS Customer
□ Conti" .e"t
П Courit'yReg’on
Customer CoJe
3 Customer Type
П CustomerKey
a Date F rst Purchase
s Education
Gender
□ House Ownership
О Ma^taJ Status
ffi Date
П Calendar Year
Я Calendar Year Montn
PI Calendar Year Month Mu..
s Calendar Year Number
a Caler, dar Year Quarter
3 Calendar Year Quarter N...
Э Cate
Г5 DateKey
P Day of Wee*
--------
HS Prcd ict
G Product Name
П P^oductKey
П P^oductSubcategoryKey
p Unit Cost
H Unit Prfce
П Weight
1
О “I I
s
Pi CustomerKey
PI Net Price
PI Order Date
'oauctKey
tpty
V Ur t Cos*
P] Unit Discount
Э Ln t Price
Направление кросс-фильтрации:
двунаправленная
Направление кросс-фильтрации:
однонаправленная
Рис. 4.20 Поведение контекста фильтра и связей
Контекст фильтра распространяется по связи в направлении, показанном
стрелкой. Во всех без исключения связях распространение контекста фильтра
осуществляется от стороны «один» к стороне «многие», тогда как обратное
распространение допустимо только при включении режима двунаправленной
кросс-фильтрации.
Связь с установленным однонаправленным режимом кросс-фильтрации на-
зывается однонаправленной связью (unidirectional relationship), а с двунаправ-
ленным режимом - двунаправленной (bidirectional relationship).
Такое поведение контекста фильтра интуитивно понятно. И хотя мы ра-
нее специально не касались этой терминологии, фактически во всех отче-
тах, которые мы использовали, так или иначе было реализовано описанное
поведение контекста фильтра. Например, в типичном отчете с фильтром по
цвету товаров (ProductfColor]) и агрегацией по количеству проданных товаров
(SalesfQuantity]) вполне логично ожидать, что фильтр от таблицы Product рас-
пространит свое действие на таблицу Sales. Причина такого поведения фильт-
ра в том, что таблица Product находится в связи с Sales на стороне «один», что
позволяет фильтрам беспрепятственно распространяться с таблицы товаров
на таблицу продаж вне зависимости от установленного направления кросс-
фильтрации.
Поскольку в нашей модели данных присутствует как двунаправленная связь,
так и множество однонаправленных, можно продемонстрировать поведение
фильтра на примере, используя три меры, подсчитывающие количество строк
в трех разных таблицах: Sales, Product и Customer.
[NumOfSales] := COUNTROWS ( Sales )
[NumOfProducts] := COUNTROWS ( Product )
[NumOfCustomers] := COUNTROWS ( Customer )
ГЛАВА 4 Введение в контексты вычисления 133
В следующем отчете мы вынесли на строки столбец ProductfColor]. Таким об-
разом, каждая мера рассчитывается в контексте фильтра, включающем цвет
товара. Результаты отчета показаны на рис. 4.21.
Color NumOfSales NumOfProducts NumOfCustomers
Azure 398 14 18,869
Black 24,048 602 18,869
Blue 6,277 200 18,869
Brown 1,840 77 18,869
Gold 988 50 18,869
Green 2,150 74 18,869
Grey 8,525 283 18,869
Orange 1,577 55 18,869
Pink 3,518 84 18,869
Purple 75 6 18,869
Red 5,802 99 18,869
Silver 19,735 417 18,869
Silver Grey 675 14 18,869
Transparent 896 1 18,869
White 21,854 505 18,869
Yellow 1,873 36 18,869
Total 100,231 2,517 18,869
Рис. 4.21 Демонстрация поведения контекста фильтра и связей
В этом примере фильтры беспрепятственно распространяются по связям от
стороны «один» к стороне «многие». Фильтр начинает движение cProduct[Color].
Далее он распространяется на таблицу Sales, расположенную в связи с Product
на стороне «многие», и на саму таблицу Product. В то же время в мере NumOf-
Customers для всех строк в таблице показано одно и то же значение, отражаю-
щее общее количество покупателей в базе. Это происходит из-за невозмож-
ности распространения фильтра по связи между таблицами Customer и Sales
от таблицы Sales к Customer. В результате фильтр проходит от таблицы Product
к Sales, но до Customer не доходит.
Вы могли заметить, что связь между таблицами Sales и Product в нашей моде-
ли двунаправленная. В связи с этим контекст фильтра, включающий информа-
цию из таблицы Customer, легко распространится на Sales и Product. Мы можем
доказать это, немного перестроив отчет и вынеся на строки CustomerfEducation]
(Образование) вместо ProductfColor]. Результат показан на рис. 4.22.
На этот раз фильтр берет свое начало с таблицы Customer. Он легко достигает
таблицы Sales, поскольку она располагается в связи на стороне «многие». Далее
фильтр распространяется с таблицы Sales на Product благодаря двунаправлен-
ному характеру связи между этими таблицами.
Заметьте, что наличие в цепочке единственной двунаправленной связи не
делает таковой всю цепочку. Например, похожая мера, призванная подсчиты-
вать количество подкатегорий, наглядно демонстрирует, что контекст фильтра
не может распространиться от таблицы Customer на Product Subcategory:
134 ГЛАВА 4 Введение в контексты вычисления
Education
NumOfSales NumOfProducts NumOfCustomers
78,059 2,097 385
Bachelors 5,963 415 5,356
Graduate Degree 3,351 290 3,189
High School 4,721 392 3,294
Partial College 5,747 423 5,064
Partial High School 2,390 263 1,581
Total 100,231 2,517 18,869
Рис. 4.22 Фильтрация по образованию покупателей,
таблица Pro duct также фильтруется
NumOfSubcategories := COUNTROWS ( 'Product Subcategory' )
Добавление новой меры к предыдущему отчету привело к результату, по-
казанному на рис. 4.23. Заметьте, что количество подкатегорий для всех строк
оказалось одинаковым.
Education
NumOfSales NumOfProducts NumOfCustomers NumOfSubcategories
78,059 2,097 385 44
Bachelors 5,963 415 5,356 44
Graduate Degree 3,351 290 3,189 44
High School 4,721 392 3,294 44
Partial College 5,747 423 5,064 44
Partial High School 2,390 263 1,581 44
Total 100,231 2,517 18,869 44
Рис. 4.23 Из-за однонаправленности связи таблица покупателей
не может фильтровать данные по подкатегориям
Поскольку связь между таблицами Product и Product Subcategory однонаправ-
ленная, фильтр от товаров на таблицу подкатегорий распространиться не мо-
жет. Если мы обновим модель данных, сделав связь двунаправленной, то полу-
чим результат, показанный на рис. 4.24.
Education
NumOfSales NumOfProducts NumOfCustomers NumOfSubcategories
78,059 2,097 385 32
Bachelors 5,963 415 5,356 32
Graduate Degree 3,351 290 3,189 32
High School 4,721 392 3,294 32
Partial College 5,747 423 5,064 32
Partial High School 2,390 263 1,581 31
Total 100,231 2,517 18,869 44
Рис. 4.24 Если связь двунаправленная,
покупатели могут фильтровать подкатегории товаров
ГЛАВА 4 Введение в контексты вычисления 135
Для распространения контекста строки по связям мы используем функции
RELATED и RELATEDTABLE, тогда как для распространения контекста фильтра
никаких дополнительных функций не требуется. Контекст фильтра накладыва-
ет ограничения на модель, а не на таблицу. Таким образом, после применения
контекста фильтра к любой таблице фильтр автоматически распространяется
по связям на всю модель.
Важно По нашим примерам могло сложиться впечатление, что включение двунаправ-
ленной кросс-фильтрации для всех без исключения таблиц в модели данных является
оптимальным выбором, поскольку в этом случае все фильтры будут автоматически воз-
действовать на всю модель. Но это не так. Подробнее мы поговорим о связях в главе 15.
Двунаправленные связи несут в себе определенную сложность, но вам пока рано об этом
знать. Как бы то ни было, использовать такие связи можно только с полным понимани-
ем возможных последствий. Как правило, вы должны включать режим двунаправленной
фильтрации для связи в конкретной мере, используя функцию CROSSFILTER, и делать это
нужно только в случае крайней необходимости.
Использование функций DISTINCT и SUMMARIZE
в контекстах фильтра
На данном этапе вы должны уже хорошо разбираться в контекстах вычисле-
ния, и мы используем эти знания для пошагового решения одного интересного
сценария. Попутно мы расскажем вам о некоторых неочевидных нюансах, ко-
торые, надеемся, смогут расширить ваши фундаментальные знания в области
контекстов строки и контекстов фильтра. Также в этом разделе мы более по-
дробно коснемся функции SUMMARIZE, которую вскользь затронули в главе 3.
Перед тем как начать, заметим, что в процессе разбора сценария мы будем
постепенно переходить от неправильных вариантов решения к правильному.
Это сделано в образовательных целях, ведь нашей задачей является научить
вас писать код на DAX, а не предоставить готовое решение. Создавая меры, вы,
разумеется, поначалу будете допускать ошибки, и на этом примере мы поста-
раемся подробно описать наш ход мыслей, что может помочь вам в будущем
самостоятельно исправлять неточности в своем коде.
Задача перед нами стоит следующая: вычислить средний возраст покупате-
лей компании Contoso. И хотя на первый взгляд кажется, что вопрос сформу-
лирован корректно, на самом деле это не так. О каком возрасте мы говорим?
О текущем или о том, в котором они совершали покупки? Если человек покупал
товары трижды, должны ли мы считать это одной покупкой или тремя разными
при подсчете среднего? А что, если он приобретал товары в разном возрасте?
Нужно сформулировать задачу более конкретно. И мы это сделали: «Требуется
посчитать средний возраст покупателей на момент покупки, при этом все по-
купки, совершенные человеком в одном возрасте, учитывать только один раз».
Процесс решения можно условно разбить на две стадии:
вычисление возраста покупателя на момент покупки;
усреднение вычисленного показателя.
136 ГЛАВА 4 Введение в контексты вычисления
Возраст покупателя меняется со временем, так что нам нужно иметь воз-
можность сохранять его в таблице Sales. Что ж, будем реализовывать хранение
возраста покупателя на момент приобретения товара в каждой строке таблицы
Sales. Следующий вычисляемый столбец вполне подойдет для решения этой
задачи:
Sales[Customer Age] =
DATEDIFF ( -- Вычисляем разницу между
RELATED ( Customer[Birth Date] ); -- датой рождения покупателя
Sales[Order Date]; -- и датой покупки
YEAR -- в годах
)
Поскольку Customer Age является вычисляемым столбцом, его значение вы-
числяется в контексте строки, осуществляющем итерации по таблице Sales.
В формуле нам потребовалось обратиться к столбцу Customer[Birth Date] из
таблицы Customer, находящейся на стороне «один» в связи с таблицей Sales.
В этом случае мы можем использовать функцию RELATED для доступа к целе-
вой таблице. При этом в базе данных Contoso есть немало покупателей, у кото-
рых не заполнено поле даты рождения. И функция DATEDIFF послушно вернет
пустое значение, если таковым будет ее первый параметр.
Поскольку наша задача состоит в получении среднего возраста покупателей,
нашей первой (и неверной) мыслью может быть создание меры, вычисляющей
среднее значение по столбцу:
Avg Customer Age Wrong := AVERAGE ( Sales[Customer Age] )
Результат, который мы получим, будет некорректным, поскольку в столбце
Sales [Customer Age] будет много повторяющихся значений, если покупатель
несколько раз приобретал товары в одном возрасте. Согласно нашей задаче,
в этом случае необходимо учитывать только один факт покупки, а наша теку-
щая формула работает иначе. На рис. 4.25 показан результат вычисления на-
шей меры в сравнении с корректными ожидаемыми значениями.
Проблема состоит в том, что возраст каждого покупателя должен быть учтен
лишь раз. Возможное решение - и тоже неправильное - заключается в при-
менении функции DISTINCT к столбцу с возрастом и дальнейшем усреднении
полученного значения, как показано в мере ниже:
Avg Customer Age Wrong Distinct :=
AVERAGEX ( -- Проходим по уникальным значениям возрастов
DISTINCT ( Sales[Customer Age] ); --и рассчитываем среднее значение
Sales[Customer Age] -- по этому показателю
)
Это решение, как мы уже сказали, также неправильное. Фактически функция
DISTINCT просто вернет уникальные значения возрастов из нашей базы про-
даж. Таким образом, два покупателя, совершивших покупки в одном возрас-
те, посчитаются лишь раз. Получается, что мы хотели учитывать покупателей
в одном возрасте один раз, а учитываем сам возраст без привязки к покупате-
лям. На рис. 4.26 показан вывод меры Avg Customer Age в сравнении с правиль-
ными значениями. Как видите, мы все еще далеки от правильного решения.
ГЛАВА 4 Введение в контексты вычисления 137
Color Avg Customer Age Wrong Correct Average
Azure 46.44 46.44
Black 46.59 46.67
Blue 45.87 45.91
Brown 45.48 45.48
Gold 45.26 45.26
Green 47.26 47.26
Grey 46.44 46.44
Orange 37.27 37.27
Pink 46.18 46.17
Purple 50.09 50.09
Red 45.42 45.45
Silver 45.87 45.82
Silver Grey 49.93 49.93
White 46.00 46.25
Yellow 47.76 47.76
Total 46.18 46.20
Рис. 4.25 Простое усреднение возраста покупателей
не дало ожидаемых результатов
Color Avg Customer Age Wrong Distinct Correct Average
Azure 50.92 46.44
Black 58.38 46.67
Blue 55.33 45.91
Brown 50.15 45.48
Gold 45.14 45.26
Green 50.92 47.26
Grey 54.33 46.44
Orange 38.33 37.27
Pink 53.45 46.17
Purple 53.74 50.09
Red 56.10 45.45
Silver 61.67 45.82
Silver Grey 47.93 49.93
White 58.57 46.25
Yellow 55.83 47.76
Total 62.00 46.20
Рис. 4.26 Усреднение уникальных возрастов покупателей также не помогло
Можно, конечно, попробовать заменить в нашей формуле параметр функ-
ции DISTINCT с Customer Age на CustomerKey, чтобы получился следующий код:
Avg Customer Age Invalid Syntax :=
AVERAGEX (
DISTINCT ( Sales[CustomerKey] );
-- Проходим по уникальным значениям
-- Sales[CustomerKey] и рассчитываем среднее
138 ГЛАВА 4 Введение в контексты вычисления
Sales[Customer Age]
по этому показателю
)
Но такая формула вовсе не выполнится, поскольку содержит ошибку. Сможе-
те найти ее самостоятельно, не читая следующий абзац?
Дело в том, что функция AVERAGEX, как любой итератор, создает при за-
пуске контекст строки. Первым параметром в функцию AVERAGEX передается
DISTINCT ( Sales[CustomerKey] ). Функция DISTINCT возвращает таблицу с един-
ственным столбцом, содержащим уникальные значения кодов покупателей.
Таким образом, контекст строки, созданный функцией AVERAGEX, будет со-
держать только один столбец, а именно Sales[CustomerKey]. DAX просто не смо-
жет вычислить значение Sales[Customer Age] в контексте строки, содержащем
только Sales[CustomerKey].
Что нам нужно, так это каким-то образом получить контекст строки с грану-
лярностью (granularity) на уровне Sales[CustomerKey], который также будет со-
держать Sales [Customer Age]. А мы помним, что функция SUMMARIZE, которую
мы проходили в главе 3, умеет создавать набор уникальных комбинаций двух
столбцов из таблицы. Это ее свойство и поможет нам написать правильную
формулу, отвечающую всем нашим требованиям:
Correct Average :=
AVERAGEX (
SUMMARIZE (
Sales;
Sales[CustomerKey];
Sales[Customer Age]
);
Sales[Customer Age]
)
- - Проходим по всем существующим
- - комбинациям в
- - таблице Sales
- - из ключа покупателя
- - и его возраста
- - и считаем средний возраст
Как обычно, можно использовать переменные, чтобы разбить выполнение
кода на этапы. Заметьте, что обращение к столбцу Customer Age по-прежнему
требует ссылки на таблицу Sales во втором параметре функции AVERAGEX.
Дело в том, что переменная может содержать в себе таблицу, но не может ис-
пользоваться в качестве ссылки на нее.
Correct Average :=
VAR CustomersAge =
SUMMARIZE (
Sales;
Sales[CustomerKey];
Sales[Customer Age]
)
RETURN
AVERAGEX (
CustomersAge;
Sales[Customer Age]
)
- - Существующие комбинации
- - в таблице Sales
- - из ключа покупателя
- - и его возраста
- - Проходим по сочетаниям
- - ключей и возрастов покупателей в таблице Sales
- - и вычисляем среднее значение возраста
Функция SUMMARIZE помогает получить список из уникальных комбина-
ций покупателей и их возрастов в текущем контексте фильтра. Таким обра-
ГЛАВА 4 Введение в контексты вычисления 139
зом, разные покупатели с одинаковым возрастом будут учитываться отдель-
но. Функция AVERAGEX игнорирует присутствие CustomerKey в таблице, она
использует только возраст покупателей. Столбец CustomerKey нужен лишь для
корректного подсчета количества уникальных возрастов.
Необходимо подчеркнуть, что наша мера вычисляется в рамках контекста
фильтра в отчете. Таким образом, функцией SUMMARIZE будут обработаны
и возвращены только те покупатели, которые приобретали товары. В каждой
ячейке отчета действует свой контекст фильтра, учитывающий только тех по-
купателей, которые приобрели как минимум один товар определенного цвета,
указанного в отчете.
Заключение
Пришло время вспомнить, что мы узнали из этой главы о контекстах вычис-
ления:
существуют два контекста вычисления: контекст фильтра и контекст
строки. При этом они не являются разновидностями одной концепции:
контекст фильтра фильтрует всю модель данных, а контекст строки осу-
ществляет итерации по одной таблице;
чтобы понять поведение той или иной формулы, необходимо правильно
оценить оба контекста вычисления, поскольку они действуют одновре-
менно;
DAX открывает контекст строки автоматически всякий раз, когда соз-
дается вычисляемый столбец в таблице. Также создать контекст строки
можно программно при помощи итерационной функции. Каждая такая
функция открывает свой контекст строки;
контексты строки можно вкладывать друг в друга, и в случае если они дей-
ствуют в одной и той же таблице, внутренний контекст строки будет скры-
вать внешний. Для сохранения значений, полученных в определенном
контексте строки, можно использовать переменные. В ранних версиях
DAX, в которых переменные не присутствовали, можно было для обраще-
ния к предыдущему контексту строки обращаться при помощи функции
EARLIER. Сегодня в использовании этой функции нет необходимости;
при проходе по таблице, являющейся результатом выполнения таблич-
ного выражения, контекст строки содержит только столбцы, возвращен-
ные табличным выражением;
в клиентских инструментах наподобие Power BI контекст фильтра созда-
ется при размещении элементов в строках, столбцах, срезах и фильтрах.
Контекст фильтра также может быть создан программно при помощи
функции CALCULATE, о которой мы будем говорить в следующей главе;
контекст строки не распространяется по связям автоматически. При не-
обходимости распространить его вручную можно использовать функции
RELATED и RELATEDTABLE. При этом каждую из этих функций нужно ис-
пользовать в контексте строки строго на определенной стороне связи:
RELATED на стороне «многие», RELATEDTABLE - на стороне «один»;
140 ГЛАВА 4 Введение в контексты вычисления
контекст фильтра фильтрует модель данных, используя связи в ней в со-
ответствии с их направлениями кросс-фильтрации. Фильтры всегда рас-
пространяются от стороны «один» к стороне «многие». Если установить
режим двунаправленной кросс-фильтрации для связи, фильтры будут
распространяться по ней и в обратном направлении - от стороны «мно-
гие» к стороне «один».
К этому моменту вы усвоили все самые сложные концепции языка DAX. Эти
концепции полностью определяют и регулируют процесс вычисления всех ва-
ших формул и являются настоящими столпами языка. Если написанные вами
выражения не дают ожидаемых результатов, велика вероятность, что вы прос-
то не до конца усвоили перечисленные выше правила.
Как мы уже сказали во введении, на первый взгляд эти правила выглядят
весьма простыми. Такими они и являются на самом деле. Сложность заключа-
ется в том, что в своих выражениях на DAX вам часто придется поддерживать
разные активные контексты вычисления в разных частях формулы. Мастер-
ство в работе со множественными контекстами вычисления приходит с опы-
том, и мы постараемся обеспечить вам его при помощи многочисленных при-
меров в следующих главах. В процессе самостоятельного написания формул на
DAX к вам постепенно придет понимание того, какие контексты в тот или иной
момент используются и каких функций они требуют. Шаг за шагом вы освоите
все тонкости языка и станете настоящим гуру в мире DAX.
ГЛАВА 5
Функции CALCULATE
и CALCULATETABLE
В этой главе мы продолжим путешествовать по миру DAX и детально опишем
лишь одну функцию CALCULATE. Сразу заметим, что все сказанное далее будет
относиться и к функции CALCULATETABLE, отличающейся от CALCULATE лишь
тем, что она возвращает таблицу, а не скалярную величину. Для простоты изло-
жения мы будем показывать примеры с использованием функции CALCULATE,
но вы должны помнить, что они также будут работать и с функцией CALCULA-
TETABLE.
CALCULATE - наиболее важная, полезная и сложная функция в языке DAX,
так что она заслуживает отдельной главы. Усвоить саму функцию не составит
большого труда, поскольку она выполняет не так много действий. Сложность
функций CALCULATE и CALCULATETABLE состоит в том, что только они в языке
DAX способны создавать новые контексты фильтра. Так что, несмотря на свою
видимую простоту, использование этих функций в выражениях DAX сразу по-
вышает их сложность.
Материал в этой главе по трудности усвоения не уступает содержимому пре-
дыдущей главы. Мы советуем вам внимательно прочитать ее, усвоив базовую
концепцию функции CALCULATE, и двигаться дальше. А затем, когда вы столк-
нетесь со сложностями при понимании той или иной формулы, можете вер-
нуться и перечитать эту главу заново. Вероятнее всего, вы будете обнаруживать
для себя что-то новое при каждом следующем прочтении.
Введение в функции CALCULATE и CALCULATETABLE
В предыдущей главе мы говорили главным образом о двух контекстах вычис-
ления: контексте строки и контексте фильтра. Контекст строки создается ав-
томатически при добавлении в таблицу вычисляемого столбца, а также может
быть создан программно при задействовании итерационной функции. Кон-
текст фильтра появляется в модели данных в момент конфигурирования отче-
та пользователем, а о его программном создании мы пока не говорили. Имен-
но для управления контекстом фильтра и существуют в языке DAX функции
CALCULATE и CALCULATETABLE. На самом деле только эти две функции спо-
собны создавать новые контексты фильтра при помощи манипулирования су-
ществующими. Здесь и далее мы будем показывать примеры преимуществен-
но с использованием функции CALCULATE, но вы должны помнить, что все
142 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
эти же операции доступны и для функции CALCULATETABLE, единственным
отличием которой от CALCULATE является то, что она возвращает таблицу, а не
скалярную величину. Позже в этой книге - в главах 12 и 13 - мы рассмотрим
больше примеров с использованием функции CALCULATETABLE.
Создание контекста фильтра
В этом разделе мы покажем на примере, зачем вам может понадобиться соз-
давать контексты фильтра. Вы также увидите, что формулы без использования
созданных программно контекстов фильтра могут оказаться чересчур много-
словными и плохо читаемыми. Дополнение этих функций вручную созданны-
ми контекстами способно значительно облегчить код, ранее казавшийся очень
сложным.
Contoso - это компания, торгующая электроникой по всему миру. При этом
определенная часть их товаров принадлежит собственному бренду Contoso.
Наша задача - построить отчет, в котором можно было бы в сумме и в про-
центах сравнить валовую прибыль от продажи товаров собственного бренда
со сторонними. Для начала определимся с базовыми расчетами, показанными
ниже:
Sales Amount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
Gross Margin := SUMX ( Sales; Sales[Quantity] * ( Sales[Net Price] - Sales[Unit Cost] ) )
GM % := DIVIDE ( [Gross Margin]; [Sales Amount] )
Прелесть DAX состоит в том, что сложные расчеты вы можете производить
на основе более простых составляющих, вычисленных ранее. Вы можете ви-
деть эту концепцию на примере меры GM %, в которой происходит деление
рассчитанной до этого валовой прибыли на сумму продажи. Если у вас уже есть
вычисленное выражение в мере, вы можете просто сослаться на него в других
расчетах, чтобы не повторять сложную формулу заново.
С использованием этих трех мер мы можем построить наш первый отчет
в этой главе, показанный на рис. 5.1.
Category Sales Amount Gross Margin GM %
Audio 384,518.16 196,713.38 51.16%
Cameras and camcorders 7,192,581.95 4,162,105.17 57.87%
Cell phones 1,604,610.26 821,136.57 51.17%
Computers 6,741,548.73 3,594,082.52 53.31%
Games and Toys 360,652.81 174,283.26 48.32%
Home Appliances 9,600,457.04 4,939,739.79 51.45%
Music, Movies and Audio Books 314,206.74 180,968.34 57.60%
TV and Video 4,392,768.29 2,173,609.72 49.48%
Total 30,591,343.98 16,242,638.75 53.10%
Рис. 5.1 Три меры в отчете позволяют бегло оценить валовую прибыль компании
в разрезе категорий товаров
Следующие шаги в работе с этим отчетом будут не самыми простыми. Фи-
нальный отчет, к которому мы хотели бы прийти, показан на рис. 5.2. Тут мы
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 143
видим два дополнительных столбца, в которых выводится валовая прибыль
по нашему собственному бренду Contoso, выраженная в деньгах и процентах.
Category Sales Amount Gross Margin GM % Contoso GM Contoso GM %
Audio 384,518.16 196,713.38 51.16% 87,279.45 51.28%
Cameras and camcorders 7,192,581.95 4,162,105.17 57.87% 807,222.16 60.79%
Cell phones 1,604,610.26 821,136.57 51.17% 228,309.82 47.49%
Computers 6,741,548.73 3,594,082.52 53.31% 579,245.67 54.95%
Games and Toys 360,652.81 174,283.26 48.32%
Home Appliances 9,600,457.04 4,939,739.79 51.45% 1,660,590.09 50.40%
Music, Movies and Audio Books 314,206.74 180,968.34 57.60% 92,994.17 57.84%
TV and Video 4,392,768.29 2,173,609.72 49.48% 421,429.28 48.79%
Total 30.591.343.98 16.242.638.75 53.10% 3,877.070.65 52.73%
Рис. 5.2 В последних двух колонках показана валовая прибыль в деньгах и процентах
по бренду Contoso
У вас уже достаточно знаний, чтобы самостоятельно написать код для этих
двух мер. В действительности, по причине того, что нам необходимо ограни-
чить вычисления единственным брендом, лучше всего будет воспользоваться
функцией FILTER, которая и создана для подобных операций:
Contoso GM :=
VAR ContosoSales = -- Сохраняем строки из Sales по товарам бренда Contoso
FILTER ( -- в отдельную переменную
Sales;
RELATED ( 'Product'[Brand] ) = "Contoso"
)
VAR ContosoMargin = -- Проходим по табличной переменной ContosoSales,
SUMX ( -- чтобы рассчитать валовую прибыль только для Contoso
ContosoSales;
Sales[Quantity] * ( Sales[Net Price] - Sales[Unit Cost] )
)
RETURN
ContosoMargin
В табличной переменной ContosoSales содержатся строки из исходной таб-
лицы Sales, относящиеся исключительно к товарам бренда Contoso. После
вычисления этой переменной мы проходим по строкам в ней при помощи
функции SUMX и рассчитываем валовую прибыль. Поскольку итерации мы за-
пускаем по таблице Sales, а фильтр накладываем на таблицу Product, нам не-
обходимо использовать функцию RELATED для извлечения соответствующих
товаров из Sales. Похожим образом мы можем вычислить валовую прибыль
для бренда Contoso в процентах - для этого нам придется дважды пройти по
исходной таблице продаж:
Contoso GM % :=
VAR ContosoSales = -- Сохраняем строки из Sales по товарам бренда Contoso
FILTER ( -- в отдельную переменную
Sales;
RELATED ( 'Product'[Brand] ) = "Contoso"
144 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
)
VAR ContosoMargin = -- Проходим по табличной переменной ContosoSales,
SUMX ( -- чтобы рассчитать валовую прибыль только для Contoso
ContosoSales;
Sales[Quantity] * ( Sales[Net Price] - Sales[Unit Cost] )
)
VAR ContosoSalesAnount = -- Проходим по табличной переменной ContosoSales,
SUMX ( -- чтобы рассчитать сумму продаж только для Contoso
ContosoSales;
Sales[Quantity] * Sales[Net Price]
)
VAR Ratio =
DIVIDE ( ContosoMargin; ContosoSalesAmount )
RETURN
Ratio
Код для меры Contoso GM % получился чуть более длинным, но в плане логи-
ки он во многом повторяет формулы меры Contoso GM. Хотя эти меры и выво-
дят правильные результаты, легко заметить, что присущая DAX элегантность
куда-то вдруг пропала. В самом деле, у нас в модели уже есть две меры для
вычисления валовой прибыли в деньгах и процентах, но из-за необходимости
накладывать дополнительные фильтры на бренд нам пришлось, по сути, пере-
писывать их заново.
Стоит подчеркнуть, что наши базовые меры Gross Margin и GM % вполне
способны справиться с вычислениями по бренду Contoso. По рис. 5.2 вид-
но, что валовая прибыль для товаров бренда Contoso составляет 3 877 070,65
в деньгах и 52,73 - в процентах. Но мы можем получить те же самые цифры
и при помощи среза по бренду для наших мер Gross Margin и GM %, как видно
по рис. 5.3.
Brand Sales Amount Gross Margin GM %
A. Datum 2,096,184.64 1,231,215.46 58.74%
Adventure Works 4,011,112.28 2,041,254.77 50.89%
Contoso 7,352,399.03 3,877,070.65 52.73%
Fabrikam 5,554,015.73 3,063,160.86 55.15%
Litware 3,255,704.03 1,687,426.65 51.83%
Northwind Traders 1,040,552.13 537,637.20 51.67%
Proseware 2,546,144.16 1,392,412.47 54.69%
Southridge Video 1,384,413.85 685,143.25 49.49%
Tailspin Toys 325,042.42 155,099.09 47.72%
The Phone Company 1,123,819.07 592,826.75 52.75%
Wide World Importers 1,901,956.66 979,391.62 51.49%
Total 30f591,343.98 16,242,638.75 53.10%
Рис. 5.3 Срез по бренду позволил получить данные по Contoso
в мерах Gross Margin и GM %
В выделенной строке контекст фильтра был создан путем наложения фильт-
ра по бренду Contoso. Как мы помним, контекст фильтра фильтрует всю модель
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 145
данных в целом. Таким образом, наложенный на столбец Product[Brand] фильтр
оказал воздействие и на таблицу Sales благодаря связи, присутствующей меж-
ду таблицами Sales и Product. Так что мы просто воспользовались косвенной
фильтрацией таблиц по связям.
Ах, если бы мы могли создать контекст фильтра для меры Gross Margin про-
граммно и отфильтровать при помощи него только бренд Contoso. Тогда две
оставшиеся меры мы бы вычислили очень легко и просто. И здесь на помощь
приходит функция CALCULATE.
Полное описание функции CALCULATE мы дадим далее в этой главе. А сей-
час просто посмотрим на ее синтаксис:
CALCULATE ( Выражение, Условие!, ... Условием )
Функция CALCULATE может принимать любое количество параметров. Един-
ственным обязательным параметром при этом является первый, в котором
содержится выражение для вычисления. Условия, следующие за выражением,
называются аргументами фильтра (filter arguments). Функция CALCULATE соз-
дает новый контекст фильтра, основываясь на переданных аргументах фильт-
ра. Созданный контекст фильтра применяется ко всей модели, и в рамках него
вычисляется выражение из первого параметра. Таким образом, воспользовав-
шись функцией CALCULATE, можно существенно упростить код для мер Con-
toso Margin и Contoso GM %:
Contoso GM :=
CALCULATE (
[Gross Margin]; -- Рассчитываем валовую прибыль
'Product'[Brand] = "Contoso" -- в контексте фильтра, где бренд = Contoso
)
Contoso GM % :=
CALCULATE (
[GM %]; -- Рассчитываем валовую прибыль в процентах
'Product'[Brand] = "Contoso" -- в контексте фильтра, где бренд = Contoso
)
И снова здравствуйте, простота и элегантность языка DAX! Создав контекст
фильтра, в котором бренд отфильтрован по названию Contoso, мы смогли вос-
пользоваться существующими мерами с измененным поведением, вместо
того чтобы писать все заново.
Функция CALCULATE позволяет создавать новые контексты фильтра путем
манипулирования фильтрами в текущем контексте. Как видите, это позволи-
ло нам сделать наш код элегантным и лаконичным. В следующих разделах мы
представим полное, более формализованное определение функции CALCU-
LATE и подробно расскажем, как она работает и как можно воспользоваться
всеми ее преимуществами. Пока мы оставим наш пример в том виде, в каком
он есть, хотя на самом деле изначальное определение мер по бренду Con-
toso не в полной мере эквивалентно семантически итоговому определению.
Между ними есть некоторые различия, которые необходимо очень хорошо
понимать.
146 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Знакомство с функцией CALCULATE
Теперь, когда вы увидели в действии функцию CALCULATE, пришло время по-
знакомиться с ней ближе. Как мы уже говорили ранее, CALCULATE является
единственной функцией в DAX, способной модифицировать контекст фильтра.
Напомним, что все, что мы говорим о функции CALCULATE, касается также и CAL-
CULATETABLE. На самом деле функция CALCULATE не изменяет существующий
контекст фильтра, а создает новый, объединяя его параметры фильтра с сущест-
вующим контекстом фильтра. По выходу из функции CALCULATE созданный ей
контекст фильтра удаляется, и в действие вступает прежний контекст фильтра.
Мы уже представляли вам синтаксис функции CALCULATE:
CALCULATE ( Выражение, Условие!, ... Условием )
Первым параметром в функцию передается выражение, которое будет вы-
числено. Но перед тем как начать вычисление, функция CALCULATE анализи-
рует аргументы фильтра, переданные в качестве остальных параметров, ис-
пользуя их для манипулирования контекстом фильтра.
Первое, что очень важно уяснить, - это то, что переданные в функцию CAL-
CULATE аргументы фильтра не являются логическими выражениями, это таб-
лицы. Всякий раз, когда в качестве параметра в функцию CALCULATE поступа-
ет логическое выражение, DAX переводит его в таблицу значений.
В предыдущем разделе мы использовали следующее выражение:
Contoso GM :=
CALCULATE (
[Gross Margin]; -- Рассчитываем валовую прибыль
'Product*[Brand] = "Contoso" -- в контексте фильтра, где бренд = Contoso
)
Использование логического выражения в качестве второго параметра функ-
ции CALCULATE является лишь упрощением полноценной языковой конструк-
ции, часто называемым синтаксическим сахаром. На самом деле предыдущую
формулу нужно читать так:
Contoso GM :=
CALCULATE (
[Gross Margin]; -- Рассчитываем валовую прибыль
FILTER ( -- с использованием допустимых значений
Product[Brand]
ALL ( 'Product*[Brand] ); -- все значения Product[Brand],
'Product*[Brand] = "Contoso" -- содержащие строку "Contoso"
)
)
Приведенные выше выражения полностью эквивалентны, между ними нет
никаких семантических или иных отличий. И все же на первых порах мы на-
стоятельно рекомендуем вам использовать табличную форму записи для ар-
гументов фильтра. Это сделает поведение функции CALCULATE более очевид-
ным. Когда вы освоитесь с данной функцией, более удобной для вас может
стать короткая форма записи. Ее легче читать и воспринимать.
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 147
Аргумент фильтра - это таблица, то есть список значений. Таблица, передан-
ная в функцию CALCULATE в качестве параметра, определяет список значений,
которые будут видимы для столбца во время вычисления выражения. В нашем
предыдущем примере функция FILTER возвращает таблицу из одной строки,
содержащей столбец Product[Brand] со значением «Contoso». Иными словами,
«Contoso» - единственное значение, которое в функции CALCULATE будет ви-
димым для столбца Product[Brand]. Таким образом, функция CALCULATE от-
фильтрует модель данных только по товарам бренда Contoso. Посмотрите на
следующие два выражения:
Sales Amount :=
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
Contoso Sales :=
CALCULATE (
[Sales Amount];
FILTER (
ALL ( 'Product'[Brand] );
'Product'[Brand] = "Contoso"
)
)
В мере Contoso Sales второй параметр функции FILTER, вложенной в CALCU-
LATE, сканирует выражение ALL(Product[Brand]), так что все ранее наложенные
фильтры по брендам перезаписываются новым фильтром. Более очевидным
такое поведение становится в отчете с этой мерой и срезом по брендам. На
рис. 5.4 мера Contoso Sales показывает одно и то же значение во всех строках,
и оно совпадает со столбцом Sales Amount для бренда Contoso.
Brand Sales Amount Contoso Sales
A. Datum 2,096,184.64 7,352,399.03
Adventure Works 4,011,112.28 7,352,399.03
Contoso 7,352,399.03 7,352,399.03
Fabrikam 5,554,015.73 7,352,399.03
Litware 3,255,704.03 7,352,399.03
Northwind Traders 1,040,552.13 7,352,399.03
Proseware 2,546,144.16 7,352,399.03
Southridge Video 1,384,413.85 7,352,399.03
Tailspin Toys 325,042.42 7,352,399.03
The Phone Company 1,123,819.07 7,352,399.03
Wide World Importers 1,901,956.66 7,352,399.03
Total 30,591,343.98 7,352,399.03
Рис. 5.4 Мера Contoso Sales перезаписывает существующий фильтр
при помощи фильтра по Contoso
148 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
В каждой строке отчета создается свой контекст фильтра, включающий
конкретный бренд. Например, в строке с брендом Litware контекст фильтра,
установленный в отчете изначально, включает только это значение Litware
и больше ничего. Функция CALCULATE оценивает свой аргумент фильтра, воз-
вращающий таблицу с брендами, содержащую только бренд Contoso. Создан-
ный фильтр перезаписывает существующий фильтр, который был установлен
на тот же столбец. Графическое представление этого процесса можно видеть
на рис. 5.5.
Brand
Sales Amount Contos<j Sales
A. Datum 2,096,184.64 7,352,399.03
Adventure Works 4,011,112.28 7 352,399.03
Contoso 7,352,399.03 7,352,399.03
Fabrikam 5,554,015.73 7.352,39903
Litware 3.255,704 03 7,352.399.03
Northwind Traders 1,040,552.13 7,352,399 03
Prosewart 2546,144.16 7,352.399.03
South «dge Video 1,384,413 85 7,352,399.03
Taiispiii Toys 325.0^2.42 7,352,399.03
The Phone Company 1,123,819.07 7352,599.03
Wide World Importers 1,901,956.66 7,352,399.03
Total \ 30,591,343.98 7,352,399.03
Contoso Sales :=
CALCULATE (
[Sales Amount] ;
FILTER (
ALL ( ’Product’[Brand] );
’Product’[Brand] = "Contoso
Рис. 5.5 Фильтр по бренду Litware перезаписан фильтром по Contoso
из функции CALCULATE
Функция CALCULATE не перезаписывает весь исходный контекст фильтра.
Она заменяет на новые фильтры по столбцам, которые присутствуют и в старом
контексте, и в новом. Фактически если заменить срез в отчете, вынеся в строки
категории товаров вместо брендов, результат будет иным, что видно по рис. 5.6.
Теперь в отчете присутствует срез по столбцу Product [Category], тогда как
функция CALCULATE при вычислении меры Contoso Sales применяет фильтр на
столбец Product[Brand]. Два фильтра воздействуют на разные столбцы табли-
цы Product. Таким образом, никакой перезаписи не происходит, и оба фильт-
ра объединяются в единый контекст фильтра. В результате в каждой строке
новой меры показываются продажи товаров конкретной категории, входящих
в бренд Contoso. Графически этот сценарий показан на рис. 5.7.
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 149
Category Sales Amount Contoso Sales
Audio 384,518.16 170,194.00
Cameras and camcorders 7,192,581.95 1,327,792.74
Cell phones 1,604,610.26 480,791.19
Computers 6,741,548.73 1,054,179.83
Games and Toys 360,652.81
Home Appliances 9,600,457.04 3,294,849.09
Music, Movies and Audio Books 314,206.74 160,764.56
TV and Video 4,392,768.29 863,827.61
Total 30,591,343.98 7,352,399.03
Рис. 5.6 Если отчет изначально фильтруется по категориям,
то фильтр по брендам просто объединится
с ранее настроенным контекстом фильтра
Category Sa2es Amount Contoso Soles
Audio 384,518.16 170,194.00
Cameras and camcorders 7,192,581 95 1,327,792.74
Cell phones 1,604,610.26 480,791.19
Computers 6,741,548.73 1,054,179.83
Games and Toys 360.652.8i
Home Appliances 9,600,457.04 3,294.840 09
Music, Movies and AlkIeo Books 314,206 74 160,764.56
TV and Video 4,392,768.29 863,827.61
Total \ 30,591 343.98 7,352,399.03
Contoso Sales :=
CALCULATE (
[Sales Amount];
FILTER (
ALL ( ’Product'[Brand] );
’Product'[Brand] = "Contoso
Рис. 5.7 Функция CALCULATE перезаписывает фильтр по одному и тому же столбцу.
По разным столбцам происходит объединение
Теперь, когда вы усвоили базовую концепцию функции CALCULATE, можно
подытожить ее семантические особенности:
функция CALCULATE создает копию существующего контекста фильтра;
функция CALCULATE оценивает каждый аргумент фильтра и для каждого
условия создает список доступных значений по указанным столбцам;
если аргументы фильтра затрагивают один и тот же столбец, фильтры по
ним объединяются при помощи оператора AND (или, как сказали бы ма-
тематики, посредством пересечения множеств);
150 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
функция CALCULATE использует новое условие для замены существую-
щих фильтров по столбцам в модели данных. Если на столбец уже дей-
ствует фильтр, новый фильтр заменит его. В противном случае новый
фильтр просто добавится к текущему контексту фильтра;
по готовности нового контекста фильтра функция CALCULATE применя-
ет его к модели данных и производит вычисление выражения, передан-
ного в первом параметре. По завершении работы функция CALCULATE
восстанавливает исходный контекст фильтра, возвращая вычисленный
результат.
Примечание Функция CALCULATE выполняет еще одно важное действие, а именно
трансформирует любой существующий контекст строки в эквивалентный контекст фильт-
ра. Далее в этой главе мы поговорим об этом более подробно. При повторном прочтении
этого раздела помните, что функция CALCULATE создает контекст фильтра на основе су-
ществующего контекста строки.
Функция CALCULATE принимает фильтры двух типов:
список значений в виде табличного выражения. В этом случае вы пере-
даете конкретный список значений, который хотите сделать видимым
в новом контексте фильтра. При этом в фильтре может содержаться таб-
лица с любым количеством столбцов. И фильтром будут рассматриваться
только существующие комбинации значений в разных столбцах;
логическое выражение, как, например, Product [Color] = "White". Этот
тип фильтра должен работать с одним столбцом, поскольку результатом
должен быть список значений для одного столбца. Такой тип аргумента
фильтра также называется предикатом (predicate).
Если вы используете для фильтров логическое выражение, DAX все равно
преобразует его в список значений. Таким образом, если написать:
Sales Amount Red Products :=
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red"
)
DAX трансформирует это выражение в:
Sales Amount Red Products :=
CALCULATE (
[Sales Amount];
FILTER (
ALL ( 'Product'[Color] );
'Product'[Color] = "Red"
)
)
По этой причине при использовании логических выражений вы можете
ссылаться только на один столбец. Движку необходимо извлечь один столбец,
чтобы запустить по нему итерации в функции FILTER, создаваемой автомати-
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 151
чески. Если в логическом выражении вам необходимо сослаться на два столбца
и более, вам придется явно прописывать функцию FILTER, как вы узнаете поз-
же в этой главе.
Использование функции CALCULATE для расчета процентов
Вы уже достаточно узнали о функции CALCULATE, и теперь пришло время
воспользоваться ей для проведения определенных вычислений. Целью этого
раздела будет привлечь ваше внимание к некоторым особенностям функции
CALCULATE, не заметным с первого взгляда. Позже в этой главе мы поговорим
о еще более продвинутых аспектах применения данной функции. Сейчас же
сосредоточимся на проблемах, с которыми вы можете столкнуться при работе
с CALCULATE на первых порах.
Одним из наиболее распространенных шаблонов вычислений является
расчет процентов. Работая с процентами, очень важно четко определять тип
вычислений, который вам необходим. В этом разделе вы увидите, как разное
использование функций CALCULATE и ALL может приводить к совершенно
различным результатам.
Начнем с простого расчета процентов. Нашей целью будет построить прос-
той отчет с суммами продаж по категориям товаров и их долями от общего
итога. Результат, который мы хотим получить, показан на рис. 5.8.
Category
Sales Amount Sales Pct
Audio 384,518.16 1.26%
Cameras and camcorders 7,192,581.95 23.51%
Cell phones 1,604,610.26 5.25%
Computers 6,741,548.73 22.04%
Games and Toys 360,652.81 1.18%
Home Appliances 9,600,457.04 31.38%
Music, Movies and Audio Books 314,206.74 1.03%
TV and Video 4,392,768.29 14.36%
Total 30,591,343.98 100.00%
Рис. 5.8 В столбце Sales Pct показана доля продаж по категории товаров
по отношению к общей сумме продаж
Чтобы рассчитать процент, необходимо поделить значение меры Sales
Amount из текущего контекста фильтра на значение Sales Amount в контексте
фильтра, игнорирующем существующий фильтр по категории товара. Факти-
чески значение для первой строки (Audio) составляет 1,26 %, что является ре-
зультатом деления 384 518,16 на 30 591 343,98.
В каждой строке отчета контекст фильтра содержит ссылку на текущую
категорию. Таким образом, мера Sales Amount изначально будет показывать
правильный результат по сумме продаж по этой категории. В знаменателе мы
должны как-то проигнорировать текущий контекст фильтра, чтобы получить
общую сумму продаж. Поскольку аргументами фильтра функции CALCULATE
являются таблицы, нам достаточно передать табличную функцию, которая
152 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
будет игнорировать текущий контекст фильтра по столбцу категории товара,
а значит, всегда возвращать все категории вне зависимости от установленных
фильтров. Ранее вы узнали, что такие возможности нам предоставляет функ-
ция ALL. Посмотрите на следующее определение меры:
АП Category Sales : =
CALCULATE (
[Sales Amount];
ALL ( 'Product'[Category] )
)
- - Меняет контекст фильтра
- - для суммы продаж так,
- - чтобы были видны все (ALL) категории
Функция ALL удаляет фильтр по столбцу Product[Category] в текущем контек-
сте фильтра. Следовательно, в каждой ячейке таблицы будет проигнорирован
фильтр, установленный на категорию, а именно тот фильтр, который был на-
ложен в строке. Посмотрите на рис. 5.9. Вы видите, что во всех строках таблицы
в мере All Category Sales показывается одно и то же число, а именно итог по
мере Sales Amount.
Category
Sales Amount All Category Sales
Audio
Cameras and camcorders
Cell phones
Compilers
Gaines ^nd Toys
Home A fiances
Music, MAvies and Aua:o Books
TV and Vi
Total
384 518.16
7,192,581.95
1634.610.26
6,7*1 548.73
360,652.81
9 600.457.04
314,20674
4,392,768.29
30,591,343.98
30.591343.98
30.591343.98
30.591343.98
30.591343.98
30.591 343.98
30,591.343.98
30.591,343.98
30,591.343.98
30,591,343.98
All Category Sales :=
CALCULATE (
[Sales Amount];
ALL ( ’Product’[Category] )
Category
Audio
УДАЛЯЕМ
ФИЛЬТР
Category
ALL ('Product’[Category])
удаляет текущий фильтр
по категории
Рис. 5.9 Функция ALL удалила фильтр по категории, так что контекст фильтра
в функции CALCULATE не содержит ограничений по этому столбцу
Мера All Category Sales сама по себе не представляет никакого интереса. Мало-
вероятно, что пользователю понадобится создать отчет с одинаковыми значе-
ниями в столбце по всем строкам. Но это значение прекрасно подойдет нам в ка-
честве знаменателя при вычислении процента продаж по категории. Формула
для вычисления этого процента может быть написана следующим образом:
Sales Pct :=
VAR CurrentCategorySales =
[Sales Amount]
VAR AllCategoriesSales =
CALCULATE (
- - CurrentCategorySales содержит
- - сумму продаж в текущем контексте
- - AllCategoriesSales содержит
- - сумму продаж в контексте фильтра,
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 153
[Sales Amount]; -- где все категории товаров
ALL ( 'Product'[Category] ) -- видимы
)
VAR Ratio =
DIVIDE (
CurrentCategorySales;
AllCategoriesSales
)
RETURN
Ratio
Как видно из этого примера, сочетание табличных функций с CALCULATE
позволяет выполнять сложные вычисления довольно просто. В данной книге
мы будем часто пользоваться этим приемом, поскольку в DAX на нем основано
большинство вычислений.
Примечание Функция ALL обладает специфической семантикой при использовании
в качестве аргумента фильтра в функции CALCULATE. Фактически она не заменяет теку-
щий контекст фильтра всеми значениями. Вместо этого функция CALCULATE использует
ALL для удаления фильтра по столбцу категории товаров из контекста фильтра. У такого
поведения есть определенные побочные эффекты, которые слишком сложны для того,
чтобы мы разбирали их здесь. Остановимся на них более подробно далее в этой главе,
ч__________________________________________________________________________)
Как мы уже отметили во вводной части раздела, при расчете процентов,
подобных этим, необходимо соблюдать большую осторожность. Здесь наши
проценты будут работать правильно, только если срез в отчете выполнен по
категориям товаров. В коде удаляется фильтр по категории, но не затрагивают-
ся другие возможные фильтры. Таким образом, если в отчет включить другие
фильтры, результат может оказаться неожиданным. Взгляните на отчет, пока-
занный на рис. 5.10, где мы также вынесли поле Pro duct [Color] в строки - на
второй уровень детализации.
Category Color Sales Amount Sales Pct
Audio Black 61,823.15 1.05%
Blue 66,799.65 2.74%
Green 30,731.27 2.19%
Orange 3,965.88 0.46%
Pink 21,544.69 2.60%
Purple 499.95 8.37%
Red 33,123.82 2.98%
Silver 97,417.78 1.43%
White 54,806.65 0.94%
Yellow 13,805.31 15.39%
Total 384,518.16 1.26%
Cameras and camcorders Azure 97,389.89 100.00%
Black 1,005,267.83 17.15%
Blue 698,711.40 28.69%
Рис. 5.10 Добавление цвета товара в отчет привело
к неожиданным результатам на этом уровне
154 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Похоже, на уровне категорий мы получили правильные проценты, тогда как
на уровне цветов цифры не соответствуют действительности. Проценты по
цветам при суммировании не бьются ни с итогами по категориям, ни с общей
долей, равной 100 %. Чтобы узнать, что значат и как рассчитываются конкрет-
ные значения в отчете, полезно взять для примера одну ячейку и попытаться
понять, что при ее вычислении происходит с контекстом фильтра. Посмотрите
на рис. 5.11.
Category
Audio
Camerbs and camcorders
Со’ог Sales Amount Sales Pct
Black 61.823.15 1.05%
Blue 66 799.65 2.74%
Green 30.731.27 2.19%
Orange 3,965.88 0.46%
Pmk 21,544 69 2.60%
Purple 499.95 8.37%
Rea 33,123.32 2.98%
Silver 97 417.78 1 43%
White 54.806.55 0.94%
Yellow 13,805.31 15.39%
Total 384,518.16 1.26%
Azure 97.389.89 100.00%
Black 1.005,267 83 17.15%
BJue 698.711.40 28.69%
Sales Pct :=
VAR
VAR
VAR
CurrentCategorySales =
[Sales Amount]
AllCategoriesSales =
CALCULATE (
[Sales Amount];
ALL ( ’Product’[Category]
RETURN Ratio
УДАЛЯЕМ
ФИЛЬТР
Category
Рис. 5.11 Функция ALL c Product[Category] удаляет фильтр с категории товаров,
но оставляет его по цветам
Ratio =
DIVIDE (
CurrentCategorySales ;
AllCategoriesSales
Исходный контекст фильтра, созданный в отчете, содержал фильтры по ка-
тегориям и цветам. Фильтр по цветам не был перезаписан функцией CALCU-
LATE - она затронула только фильтр по категориям. В результате в итоговый
контекст фильтра вошел лишь фильтр по цветам. Следовательно, в знамена-
теле при расчете процента будет содержаться сумма продаж по всем товарам
конкретного цвета (в рассматриваемой строке - черного (Black)) и всех без ис-
ключения категорий.
Нельзя сказать, что эти ошибки в расчетах стали для нас неожиданностью.
В нашей формуле изначально была прописана работа с фильтром по категори-
ям товаров, все остальные возможные фильтры она не затрагивает. Та же самая
формула в другом отчете великолепно отработает. Смотрите, что будет, если
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 155
группировки по строкам поменять местами - сначала поставить цвета, а лишь
затем категории товаров. Такой отчет показан на рис. 5.12.
Color Category Sales Amount Sales Pct
Azure Cameras and camcorders 97,389.89 100.00%
Total 97,389.89 100.00%
Black Audio 61,823.15 1.05%
Cameras and camcorders 1,005,267.83 17.15%
Cell phones 556,308.72 9.49%
Computers 2,195,921.21 37.47%
Games and Toys 82,000.86 1.40%
Home Appliances 706,021.60 12.05%
Music, Movies and Audio Books 102,542.26 1.75%
TV and Video 1,150,180.50 19.63%
Total 5,860,066.14 100.00%
Blue Audio 66,799.65 2.74%
Cameras and camcorders 698,711.40 28.69%
Computers 172,083.09 7.07%
Games and Toys 85,788.39 3.52%
Home Appliances 1,411,124.43 57.94%
Music, Movies and Audio Books 937.66 0.04%
Total 2,435,444.62 100.00%
Рис. 5.12 После того как цвета и категории поменялись местами,
цифры стали осмысленными
Теперь этот отчет имеет смысл. Формула меры не изменилась, но цифры
стали интуитивно понятными из-за смены внешнего вида. Теперь цифры точ-
но указывают проценты по категориям товаров внутри каждого цвета, а их
итог везде составляет 100 %.
Иными словами, при необходимости рассчитывать те или иные проценты
необходимо быть очень внимательными к определению знаменателя. Функ-
ции CALCULATE и ALL - ваши главные помощники по этой части, но детали
формулы нужно корректировать в зависимости от требований.
Вернемся к нашему примеру. Мы хотим, чтобы проценты правильно счита-
лись как по категориям товаров, так и по их цветам. Существуют разные спо-
собы решения этой задачи, но все они приводят к отличающимся результатам.
Сейчас мы рассмотрим несколько из них.
Первым и очевидным решением возникшей проблемы может быть напи-
сание функции CALCULATE, которая будет удалять фильтры как с категорий
товаров, так и с цветов. Добавление еще одного аргумента фильтра в функцию
позволит нам это сделать:
Sales Pct :=
VAR CurrentCategorySales =
[Sales Amount]
VAR AllCategoriesAndColorSales =
156 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
CALCULATE (
[Sales Amount];
ALL ( 'Product'[Category] ); -- Два условия ALL могут быть заменены на
ALL ( 'Product'[Color] ) -- ALL ( 'Product'[Category]; 'Product'[Color])
)
VAR Ratio =
DIVIDE (
CurrentCategorySales;
AllCategoriesAndColorSales
)
RETURN
Ratio
Новая мера будет прекрасно работать в отчетах с категориями товаров
и цветами, но она не избавилась от недостатков своих прежних версий. Да,
она показывает правильные результаты по категориям и цветам, что видно по
рис. 5.13, но проблемы вернутся, если добавить в отчет еще один срез.
Category Color Sales Amount Sales Pct
Audio Black 61,823.15 0.20%
Blue 66,799.65 0.22%
Green 30,731.27 0.10%
Orange 3,965.88 0.01%
Pink 21,544.69 0.07%
Purple 499.95 0.00%
Red 33,123.82 0.11%
Silver 97,417.78 0.32%
White 54,806.65 0.18%
Yellow 13,805.31 0.05%
Total 384,518.16 1.26%
Рис. 5.13 С использованием функции ALL по категориям и цветам товаров
проценты стали показывать корректные цифры
Чтобы не возникало проблем с добавлением в отчет новых столбцов из таб-
лицы Product, можно всю ее включить в функцию ALL, как показано ниже:
Sales Pct All Products :=
VAR CurrentCategorySales =
[Sales Amount]
VAR AllProductSales =
CALCULATE (
[Sales Amount];
ALL ( 'Product' )
)
VAR Ratio =
DIVIDE (
CurrentCategorySales;
AllProductSales
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 157
)
RETURN
Ratio
Функция ALL, которой передана целая таблица Product, удаляет фильтры со
всех столбцов из этой таблицы. На рис. 5.14 вы можете видеть вывод новой
меры.
Category Color Brand Sales Amount Sales Pct All Products
Audio Black Contoso 22,696.16 0.07%
Northwind Traders 8,623.52 0.03%
Wide World Importers 30,503.47 0.10%
Total 61,823.15 0.20%
Blue Contoso 19,780.93 0.06%
Northwind Traders 29,053.82 0.09%
Wide World Importers 17,964.91 0.06%
Total 66,799.65 0.22%
Green Contoso 23,475.45 0.08%
Northwind Traders 1,619.84 0.01%
Wide World Importers 5,635.99 0.02%
Total 30,731.27 0.10%
Рис. 5.14 Функция ALL с таблицей Product в качестве аргумента удаляет фильтры
со всех ее столбцов
До сих пор вы видели, что совместное использование функций CALCULATE
и ALL позволяет удалять фильтры со столбца, нескольких столбцов и целой таб-
лицы. Истинная мощь функции CALCULATE заключается в ее возможностях
управлять контекстами фильтра, но даже этим ее потенциал не ограничивается.
Фактически вы можете осуществлять срезы и вычислять проценты сразу по не-
скольким таблицам из модели данных. Например, если вы захотите сделать вы-
борку по категориям товаров и континенту проживания покупателя, последняя
созданная нами мера не даст ожидаемых результатов, что видно по рис. 5.15.
Category Continent Sales Amount Sales Pct All Products
Audio Asia 110,501.26 1.03%
Europe 132,735.79 1.53%
North America 141,281.10 1.26%
Total 384,518.16 1.26%
Cameras and camcorders Asia 2,288,813.15 21.34%
Europe 2,182,339.59 25.18%
North America 2,721,429.21 24.30%
Total 7,192,581.95 23.51%
Cell phones Asia 557,888.46 5.20%
Europe 507,813.97 5.86%
North America 538,907.83 4.81%
Total 1,604,610.26 5.25%
Рис. 5.15 Срез по столбцам из разных таблиц возвращает нас
к неправильным результатам подсчета процентов
158 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
На этот раз вы и сами понимаете источник проблемы. В знаменателе фор-
мулы мы удалили все фильтры из таблицы Product, но фильтр по столбцу
Customer[Continent] удален не был. Таким образом, здесь будут учтены продажи
по всем товарам покупателям с определенного континента.
Как и в предыдущем примере, тут мы можем снова добавить в аргументы
фильтра функции CALCULATE необходимый параметр:
Sales Pct All Products and Customers :=
VAR CurrentCategorySales =
[Sales Amount]
VAR AllProductAndCustomersSales =
CALCULATE (
[Sales Amount];
ALL ( 'Product' );
ALL ( Customer )
)
VAR Ratio =
DIVIDE (
CurrentCategorySales;
AllProductAndCustomersSales
)
RETURN
Ratio
Используя функцию ALL внутри CALCULATE, мы смогли удалить фильтр сра-
зу с двух таблиц. Результат, представленный на рис. 5.16, ожидаемо оказался
верным.
Category Continent Sales Amount Sales Pct All Products and Customers
Audio Asia 110,501.26 0.36%
Europe 132,735.79 0.43%
North America 141,281.10 0.46%
Total 384,518.16 1.26%
Cameras and camcorders Asia 2,288,813.15 7.48%
Europe 2,182,339.59 7.13%
North America 2,721,429.21 8.90%
Total 7,192,581.95 23.51%
Cell phones Asia 557,888.46 1.82%
Europe 507,813.97 1.66%
North America 538,907.83 1.76%
Total 1,604,610.26 5.25%
Рис. 5.16 Использование ALL с двумя таблицами позволило удалить фильтры с обеих
С двумя таблицами в CALCULATE мы попали в такую же ситуацию, как
и с двумя столбцами из одной таблицы. При добавлении третьей таблицы
фильтры по ней вновь удаляться не будут. Одним из решений для удаления
фильтров со всех таблиц, которые могут повлиять на расчеты, является вклю-
чение в функцию CALCULATE самой таблицы фактов. В нашей модели данных
таблицей фактов является Sales. А так можно написать формулу в мере для рас-
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 159
чета совокупного процента вне зависимости от количества фильтров, взаимо-
действующих с таблицей Sales:
Pct All Sales :=
VAR CurrentCategorySales =
[Sales Amount]
VAR AllSales =
CALCULATE (
[Sales Amount];
ALL ( Sales )
)
VAR Ratio =
DIVIDE (
CurrentCategorySales;
AllSales
)
RETURN
Ratio
В этой мере используются связи в модели данных для удаления фильтров
с любой таблицы, способной фильтровать таблицу Sales. На данном этапе мы
не можем объяснить все подробности того, как это работает, поскольку в этом
процессе задействованы расширенные таблицы, с которыми мы познакомим-
ся в главе 14. Вы можете насладиться поведением новой меры, взглянув на
рис. 5.17, - мы убрали из отчета суммы и вынесли календарные годы в столбцы.
Заметьте, что столбец Calendar Year принадлежит таблице Date, упоминание
которой не присутствует в нашей мере. Несмотря на это, фильтр по таблице
Date был удален в числе прочих фильтров по таблице Sales.
Category CY 2007 CY 2008 CY 2009 Total
Audio 0.34% 0.34% 0.58% 1.26%
Cameras and camcorders 10.71% 7.14% 5.67% 23.51%
Cell phones 1.56% 1.51% 2.17% 5.25%
Computers 8.70% 6.75% 6.59% 22.04%
Games and Toys 0.29% 0.35% 0.54% 1.18%
Home Appliances 7.67% 12.95% 10.76% 31.38%
Music, Movies and Audio Books 0.29% 0.39% 0.35% 1.03%
TV and Video 7.42% 3.01% 3.93% 14.36%
Total 36.97% 32.45% 30.58% 100.00%
Рис. 5.17 Функция ALL с таблицей фактов в качестве аргумента
удаляет также фильтры со всех связанных таблиц
Перед тем как завершить этот длинный пример с вычислением процентов,
мы покажем вам еще один способ управления контекстами фильтра. Как вы
видите по рис. 5.17, все проценты, как и ожидалось, вычислены относительно
общих итогов. А что, если нам понадобится подсчитать долю продаж в рамках
каждого года? В этом случае новый контекст фильтра, созданный функцией
CALCULATE, должен быть соответствующим образом подготовлен. А именно
160 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
в знаменателе должны подсчитываться итоги по всем продажам без учета лю-
бых фильтров, за исключением текущего года. Этого можно добиться следую-
щими двумя действиями:
удалить фильтры с таблицы фактов;
восстановить фильтр по году.
Имейте в виду, что оба условия будут действовать одновременно, даже если
кажется, что это два последовательных шага. Вы уже умеете удалять все фильт-
ры с таблицы фактов. Теперь пришло время научить восстанавливать сущест-
вующий фильтр.
Примечание В этой главе мы ставим себе цель научить вас базовым техникам управ-
ления контекстами фильтра. Позже мы покажем более простой способ решить задачу
с подсчетом процента в рамках видимых в таблице итогов - при помощи функции ALLSE-
LECTED.
В главе 3 вы познакомились с функцией VALUES. Она возвращает список
значений столбца в текущем контексте фильтра. А поскольку результатом
функции VALUES является таблица, ее вполне можно использовать в качестве
аргумента фильтра в функции CALCULATE. В этом случае функция CALCULATE
применит фильтр к указанному столбцу, ограничив его значения списком, воз-
вращенным функцией VALUES. Взгляните на следующий код:
Pct All Sales CY :=
VAR CurrentCategorySales =
[Sales Amount]
VAR AllSalesInCurrentYear =
CALCULATE (
[Sales Amount];
ALL ( Sales );
VALUES ( 'Date*[Calendar Year] )
)
VAR Ratio =
DIVIDE (
CurrentCategorySales;
AllSalesInCurrentYear
)
RETURN
Ratio
Будучи использованной в отчете, эта мера рассчитает проценты по прода-
жам в рамках каждого отдельного года, что видно по рис. 5.18.
На рис. 5.19 графически показано выполнение этой сложной формулы.
Вот что происходит на этой диаграмме:
в ячейке, содержащей значение 4,22 % (продажи товаров категории Cell
Phones (Мобильные телефоны) за Calendar Year (Календарный год) 2007),
контекст фильтра включает в себя Cell phones и CY 2007;
в функции CALCULATE присутствует два аргумента фильтра: ALL ( Sales )
и VALUES (DatefCalendar Year]):
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 161
- функция ALL (Sales ) удаляет фильтр с таблицы Sales;
- функция VALUES ( Date[Calendar Year] ) выполняется в исходном кон-
тексте фильтра, в котором присутствует значение CY 2007. И именно
его функция и возвращает как единственное значение, видимое в те-
кущем контексте фильтра.
Category CY 2007 CY 2008 CY 2009 Total
Audio 0.91% 1.06% 1.89% 1.26%
Cameras and camcorders 28.96% 22.00% 18.53% 23.51%
Cell phones 4.22% 4.66% 7.10% 5.25%
Computers 23.52% 20.81% 21.54% 22.04%
Games and Toys 0.79% 1.07% 1.76% 1.18%
Home Appliances 20.75% 39.91% 35.18% 31.38%
Music, Movies and Audio Books 0.78% 1.22% 1.13% 1.03%
TV and Video 20.07% 9.27% 12.86% 14.36%
Total 100.00% 100.00% 100.00% 100.00%
Рис. 5.18 Функция VALUES позволяет частично восстановить контекст фильтра
путем извлечения столбцов из исходного контекста
Category CY 2007 £¥2008 CY 2009 Total
Abd io 0 91% LC6> «>ч£1.89% 1.26%
Cameras and camcorders 28 96% 22.00% 18.5j 23.51%
Cell phones ^-4 22% 4,66% 7.10%
Computers 2152% 20.81% 21.54% 22.04%
Carnes and Toys / 0 79% 1.07% 1 76% 1.18%
Home Appliances 20.75% 3991% 35.18% 31.38%
Music, Movies and AdGio Books 0.78% 1.22% 1.13% 1.03%
TV and Vjdeo / 20.07% 9.27% 12 86% 14.36%
Total I 100.00% 100 00% 100.00% 100.00%
VAR
Pct
VAR
All Sales CY :=
CurrentCategorySales =
[Sales Amount]
AllSalesInCurrentYear =
CALCULATE (
[Sales Amount];
ALL ( Sales ) ;
VALUES ( 'Date'[Calendar Year] )
VAR
Ratio =
DIVIDE (
CurrentCategorySales;
AllSalesInCurrentYear
RETURN Ratio
CY 2007
Category
Calendar Year
| Cellphones | | CY 2007
Calendar Year
УДАЛЯЕМ
ФИЛЬТР
Category
Рис. 5.19 Важно понять, что функция VALUES
выполняется в рамках исходного контекста фильтра
Два аргумента фильтра функции CALCULATE применяются к текущему кон-
тексту фильтра, в результате чего создается новый контекст фильтра, содержа -
162 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
щий единственный фильтр Calendar Year. В знаменателе формулы вычисляется
общая сумма продаж в рамках контекста фильтра, состоящего из одного года
CY 2007.
Крайне важно уяснить, что аргументы фильтра функции CALCULATE оце-
ниваются в рамках исходного контекста фильтра, в котором эта функция вы-
зывается. Фактически функция CALCULATE меняет контекст фильтра, но это
происходит только после оценки аргументов фильтра.
Использование функции ALL для таблицы с последующим вызовом функ-
ции VALUES для столбца является распространенной техникой для замены
контекста фильтра на фильтр по отдельному столбцу.
Примечание Предыдущий пример также можно было бы решить при помощи функции
ALLEXCEPT. При этом семантика использования связки ALL/VALUES отличается от приме-
нения функции ALLEXCEPT. В главе 10 мы подробно расскажем о том, чем именно отлича-
ется использование функции ALLEXCEPT от последовательности ALL/VALUES.
Вы, наверное, заметили по этим примерам, что сама по себе функция CAL-
CULATE не так уж и сложна. Ее поведение довольно просто описать. В то же
время сложность кода, в котором активно используется функция CALCULATE,
заметно возрастает. На самом деле все, что вам нужно, - это сосредоточить
внимание на контекстах фильтра и понять, как именно функция CALCULATE их
создает. Простое вычисление процентов сопряжено с большими сложностями,
кроющимися в мелочах. Если не понять должным образом, как работают кон-
тексты вычисления, DAX останется для вас загадкой. Ключ ко всем тонкостям
этого языка находится как раз в искусном управлении контекстами вычисле-
ния. При этом в рассмотренных нами примерах было всего по одной функции
CALCULATE. В действительно сложных формулах количество одновременно
использующихся контекстов нередко доходит до четырех-пяти, и в них вы мо-
жете увидеть не одну функцию CALCULATE.
Было бы неплохо, если бы вы прочитали этот раздел о расчетах процентов
как минимум дважды. По опыту можем сказать, что второе прочтение всегда
дается легче, и человек обращает гораздо больше внимания на важные нюан-
сы кода. Мы решили показать вам этот сложный пример, чтобы подчеркнуть
важность освоения теоретической базы при работе с функцией CALCULATE.
Незначительные изменения в коде способны кардинальным образом повли-
ять на результаты вычислений. После повторного прочтения предлагаем вам
переходить к следующим разделам, где мы больше внимания уделим теории,
а не практике.
Введение в функцию KEEPFILTERS
В предыдущих разделах вы узнали, что аргументы фильтра функции CALCU-
LATE перезаписывают все существующие фильтры по одним и тем же столб-
цам. Таким образом, следующая мера вернет продажи по всей категории Audio
вне зависимости от того, были ли ранее наложены какие-то фильтры на стол-
бец ProductfCategory]:
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 163
Audio Sales :=
CALCULATE (
[Sales Amount];
’Product1[Category] = "Audio"
)
Как видно по рис. 5.20, новая мера по всем строкам заполнена одним и тем
же значением из категории Audio.
Category Sales Amount Audio Sales
Audio 384,518.16 384,518.16
Cameras and camcorders 7,192,581.95 384,518.16
Cell phones 1,604,610.26 384,518.16
Computers 6,741,548.73 384,518.16
Games and Toys 360,652.81 384,518.16
Home Appliances 9,600,457.04 384,518.16
Music, Movies and Audio Books 314,206.74 384,518.16
TV and Video 4,392,768.29 384,518.16
Total 30,591,343.98 384,518.16
Рис. 5.20 Мера Audio Sales во всех строках выводит сумму продаж по категории Audio
Функция CALCULATE перезаписывает существующие фильтры по столбцам,
на которые накладываются новые фильтры. Все оставшиеся столбцы контекста
фильтра остаются неизменными. Если же вы не хотите, чтобы существующие
фильтры перезаписывались, можете обернуть аргумент фильтра в функцию
KEEPFILTERS. Например, если вы хотите показывать сумму продаж по катего-
рии Audio в случае ее присутствия в контексте фильтра, а в противном случае
выводить пустое значение, вы можете написать следующую формулу:
Audio Sales KeepFilters :=
CALCULATE (
[Sales Amount];
KEEPFILTERS ( 'Product'[Category] = "Audio" )
)
Функция KEEPFILTERS представляет собой второй модификатор функции
CALCULATE, первым был ALL. Позже в этой главе мы еще поговорим про мо-
дификаторы функции CALCULATE. KEEPFILTERS меняет подход функции CAL-
CULATE к применению фильтров в новом контексте фильтра. В этом случае,
вместо того чтобы перезаписывать существующий фильтр по одному и тому
же столбцу, функция просто добавляет новый фильтр к предыдущему. В ре-
зультате значение окажется видимым только в тех ячейках, где отфильтрован-
ная категория была включена в исходный контекст фильтра. Вы можете видеть
это на рис. 5.21.
Функция KEEPFILTERS делает ровно то, что и должна, исходя из названия.
Она сохраняет существующий фильтр и добавляет к контексту фильтра новый.
На рис. 5.22 графически показана работа этого модификатора.
164 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Category Sales Amount Audio Sales Audio Sales KeepFilters
Audio 384,518.16 384,518.16 384,518.16
Cameras and camcorders 7,192,581.95 384,518.16
Cell phones 1,604,610.26 384,518.16
Computers 6,741,548.73 384,518.16
Games and Toys 360,652.81 384,518.16
Home Appliances 9,600,457.04 384,518.16
Music, Movies and Audio Books 314,206.74 384,518.16
TV and Video 4,392,768.29 384,518.16
Total 30.591,343.98 384,518.16 384,518.16
Рис. 5.21 В мере Audio Sales KeepFilters продажи по категории Audio
показаны только в соответствующей строке и в итогах
Category
Sales Amt unt Audio Saks KeepF'iiters
Audio
Cameras and camcorders
Cell phones
Co-npuTers
Gaines and Joys
home Apt/ifnces
Music, Movies and Audio Books
TV ano
Total
384.518.16
7,192.581 95
1.634,61026
6.741,548.73
360,652.61
9,600,457.04
314,236.74
4,392.768.29
30,591,343.98
384516 16
Audio Sales KeepFilters :=
CALCULATE (
[Sales Amount];
KEEPFILTERS ( 'Product'[Category] = "Audio
deo
3M 518.16
Рис. 5.22 Контекст фильтра, созданный посредством функции KEEPFILTERS,
включает одновременно категории Cell phones и Audio
Поскольку функция KEEPFILTERS предотвращает перезапись, новый фильтр,
создаваемый посредством аргумента фильтра функции CALCULATE, попрос-
ту добавляется к существующему контексту. Если проследить за поведением
меры Audio Sales KeepFilters в строке с категорией Cell Phones, можно заметить,
что результирующий контекст фильтра будет включать в себя одновременно
фильтры по категориям Cell Phones и Audio. Пересечение двух противореча-
щих друг другу условий приведет к образованию пустого набора данных, что
повлечет за собой вывод пустого значения в ячейке.
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 165
Поведение функции KEEPFILTERS становится более очевидным, когда в сре-
зе по столбцу выбрано сразу несколько элементов. Давайте рассмотрим сле-
дующие меры, фильтрующие категории одновременно по Audio и Computers:
одна с использованием модификатора KEEPFILTERS, другая - без:
Always Audio-Computers :=
CALCULATE (
[Sales Amount];
'Product'[Category] IN { "Audio"; "Computers" }
)
KeepFilters Audio-Computers :=
CALCULATE (
[Sales Amount];
KEEPFILTERS ( 'Product'[Category] IN { "Audio"; "Computers" } )
)
На рис. 5.23 видно, что версия меры с KEEPFILTERS рассчитывает значения
только для категорий Audio и Computers, оставляя остальные строки в столбце
пустыми. При этом в итоговой строке просуммированы продажи по категори-
ям Audio и Computers.
Category Sales Amount Always Audio-Computers KeepFilters Audio-Computers
Audio 384,518.16 7,126,066.89 384,518.16
Cameras and camcorders 7,192,581.95 7,126,066.89
Cell phones 1,604,610.26 7,126,066.89
Computers 6,741,548.73 7,126,066.89 6,741,548.73
Games and Toys 360,652.81 7,126,066.89
Home Appliances 9,600,457.04 7,126,066.89
Music, Movies and Audio Books 314,206.74 7,126,066.89
TV and Video 4,392,768.29 7,126,066.89
Total 30,591,343.98 7,126,066.89 7,126,066.89
Рис. 5.23 Модификатор KEEPFILTERS позволяет объединить старый и новый
контексты фильтра
При этом функция KEEPFILTERS может использоваться как с предикатом, так
и с таблицей. По сути, предыдущую меру можно переписать в более разверну-
том виде:
KeepFilters Audio-Computers :=
CALCULATE (
[Sales Amount];
KEEPFILTERS (
FILTER (
ALL ( 'Product'[Category] );
'Product'[Category] IN { "Audio"; "Computers" }
)
)
)
166 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Этот пример мы показали исключительно в образовательных целях. Для ар-
гумента фильтра следует использовать простой синтаксис с предикатами. На-
кладывая фильтр на один столбец, можно не указывать функцию FILTER явным
образом. Позже мы рассмотрим более сложные примеры, в которых указание
функции FILTER будет обязательным. В таких случаях модификатор KEEPFIL-
TERS может обрамлять функцию FILTER, как вы увидите в следующих разделах.
Фильтрация по одному столбцу
В предыдущем разделе мы рассмотрели аргументы фильтра, ссылающиеся на
один столбец в функции CALCULATE. Но важно отметить, что в одном выра-
жении у вас может быть сразу несколько ссылок на один и тот же столбец. До-
пустим, следующий пример синтаксиса с двойной ссылкой на один столбец
таблицы (Sales[Net Price]) вполне употребим:
Sales 10-100 :=
CALCULATE (
[Sales Amount];
Sales[Net Price] >= 10 && Sales[Net Price] <= 100
)
Фактически это выражение приводится к следующему:
Sales 10-100 :=
CALCULATE (
[Sales Amount];
FILTER (
ALL ( Sales[Net Price] );
Sales[Net Price] >= 10 && Sales[Net Price] <= 100
)
)
Результирующий контекст фильтра, созданный функцией CALCULATE, до-
бавляет всего один фильтр на столбец Sales[Net Price]. Важной особенностью
предикатов, используемых в качестве аргументов фильтров в функции CAL-
CULATE, является то, что они по своей сути являются таблицами, а не усло-
виями. По первому из предыдущих двух фрагментов кода можно понять, что
функция CALCULATE оценивает условие. На самом деле она оценивает список
всех значений столбца Sales[Net Price], удовлетворяющих условию. После этого
CALCULATE использует эту таблицу значений для осуществления фильтрации
в модели данных.
Два условия, объединенных логическим AND (И), могут быть представлены
как два отдельных фильтра. В действительности предыдущее выражение экви-
валентно следующему:
Sales 10-100 :=
CALCULATE (
[Sales Amount];
Sales[Net Price] >= 10;
Sales[Net Price] <= 100
)
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 167
При этом стоит иметь в виду, что множественные аргументы фильтра
функции CALCULATE всегда объединяются посредством логического AND.
Так что при необходимости применить объединение фильтров при помощи
логического OR (ИЛИ) вам придется использовать одно условие, как показа-
но ниже:
Sales Blue+Red :=
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red" || 'Product'[Color] = "Blue"
)
Используя множественные условия, вы можете объединять два независи-
мых фильтра в едином контексте фильтра. Следующая мера всегда будет воз-
вращать пустое значение, поскольку не бывает товаров одновременно красно-
го и синего цвета:
Sales Blue and Red :=
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red";
'Product'[Color] = "Blue"
)
Фактически эта мера преобразуется в следующую меру с одним фильтром:
Sales Blue and Red :=
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red" && 'Product'[Color] = "Blue"
)
Аргумент фильтра будет всегда возвращать пустой список цветов, допусти-
мых в контексте фильтра. Следовательно, эта мера всегда будет возвращать
пустое значение.
Всякий раз, когда аргумент фильтра ссылается на один столбец, вы можете
использовать предикат. И мы настоятельно советуем вам делать именно так,
поскольку это позволяет сделать код более легким для восприятия. С условия-
ми, объединенными посредством логического AND, следует поступать точно
так же. Но не стоит забывать, что это лишь синтаксический сахар. Функция
CALCULATE работает исключительно с таблицами, даже если компактный син-
таксис говорит об обратном.
С другой стороны, если в аргументе фильтра содержатся ссылки на два
столбца и более, вам необходимо использовать функцию FILTER в качестве таб-
личного выражения. О том, как это делать, вы узнаете из следующего раздела.
Фильтрация по сложным условиям
Аргумент фильтра, ссылающийся на множество столбцов, требует явного ис-
пользования в формуле табличного выражения. И очень важно владеть раз-
ными техниками для написания подобных фильтров. Помните, что хорошей
168 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
практикой считается использование фильтров с минимальным количеством
столбцов, необходимым для написания предиката.
Представьте, что вам нужно создать меру для агрегации продаж только по
тем транзакциям, сумма которых больше или равна 1000. Чтобы получить сум-
му продаж по транзакции, нам необходимо перемножить значения столбцов
Quantity и Net Price, поскольку мы не храним эти произведения в таблице Sales
базы данных Contoso. Скорее всего, вам захочется написать формулу, подоб-
ную следующей, но, увы, работать она не будет:
Sales Large Amount :=
CALCULATE (
[Sales Amount];
Sales[Quantity] * Sales[Net Price] >= 1000
)
Такая формула не сработает, поскольку аргумент фильтра функции CAL-
CULATE ссылается сразу на два столбца в выражении. Следовательно, DAX не
сможет автоматически преобразовать такой фильтр в корректное выражение
с использованием функции FILTER. Лучшим способом здесь является исполь-
зование таблицы, в которой будут присутствовать все комбинации значений
столбцов, имеющихся в предикате:
Sales Large Amount :=
CALCULATE (
[Sales Amount];
FILTER (
ALL ( Sales[Quantity]; Sales[Net Price] );
Sales[Quantity] * Sales[Net Price] >= 1000
)
)
В результате будет создан контекст с фильтром по двум столбцам и коли-
чеством строк, соответствующим числу уникальных комбинаций столбцов
Quantity и Net Price, удовлетворяющих условиям фильтра. Такой контекст
фильтра показан на рис. 5.24.
Рис. 5.24 Фильтр по нескольким столбцам
включает в себя все сочетания полей Quantity
и Net Price, произведение которых будет
не меньше 1000
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 169
Результат применения такого фильтра показан на рис. 5.25.
Net Рпсе Category Sales Amount Sales Large Amount
$0.76 $3,199 99
e Audio 384,518.16 7,803.95
1 Cameras and camcorders 7,192,581.95 3,078,829.16
Cell phones 1,604,610.26 150,687.21
Computers 6,741,548.73 3,036,735.73
Games and Toys 360,652.81
Home Appliances 9,600,457.04 5,390,769.53
Music, Movies and Audio Books 314,206.74 11,873.57
TV and Video 4,392,768.29 1,256,714.63
Total 30.591.343.98 12.933.413.78
Рис. 5.25 Мера Sales Large Amount показывает только транзакции с суммой,
большей или равной 1000
Стоит отметить, что срез, показанный на рис. 5.25, не ограничивает значе-
ния в отчете. Представленные два значения в срезе отражают минимальное
и максимальное значения столбца Net Price в таблице - не более. На следующем
шаге мы покажем, как наша мера взаимодействует с установленным пользо-
вателем фильтром. При написании мер, подобных Sales Large Amount, необхо-
димо иметь в виду то, что существующие фильтры по столбцам Quantity и Net
Price будут перезаписаны. В самом деле, поскольку в аргументе фильтра ис-
пользуется функция ALL по двум столбцам, все ранее установленные фильтры
по ним - а в нашем случае это значения в срезе - будут проигнорированы.
Вывод отчета на рис. 5.26 абсолютно такой же, как на рис. 5.25, несмотря на то
что мы ограничили Net Price в срезе значениями 500 и 3000. Результат может
вас удивить.
Net Price Category Sales Amount Sales Large Amount
$500.00 $3,000.00
Audio 7,803.95
1 1 Cameras and camcorders 4,786,139.80 3,078,829.16
Cell phones 47,152.49 150,687.21
Computers 3,717,785.81 3,036,735.73
Home Appliances 5,839,778.70 5,390,769.53
Music, Movies and Audio Books 11,873.57
TV and Video 987,758.58 1,256,714.63
Total 15.378.615.38 12.933.413.78
Рис. 5.26 По категории Audio не было продаж в указанном ценовом диапазоне,
но в мере Sales Large Amount все равно есть значение
Вас может удивить тот факт, что мера Sales Large Amount заполнена значения-
ми по категориям Audio и Music, Movies and Audio Books. По товарам из этих
групп действительно не было продаж в диапазоне цен, установленном в кон-
тексте фильтра при помощи среза. А значения в этих строках присутствуют.
Причина в том, что контекст фильтра, созданный посредством среза, был
попросту проигнорирован мерой Sales Large Amount, которая перезаписала
фильтры по столбцам Quantity и Net Price. Если внимательно изучить два пред-
170 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
ставленных отчета, можно заметить, что значения в столбце Sales Large Amount
в них полностью идентичны, как если бы никакого среза в отчете и не было.
Давайте посмотрим, как было вычислено значение нашей меры для строки
Audio:
Sales Large Amount :=
CALCULATE (
CALCULATE (
[Sales Amount];
FILTER (
ALL ( Sales[Quantity]; Sales[Net Price] );
Sales[Quantity] * Sales[Net Price] >= 1000
)
);
'Product1[Category] = "Audio";
Sales[Net Price] >= 500
)
Из этого фрагмента кода следует, что вызов ALL во внутренней функции
CALCULATE полностью игнорирует фильтр по Sales[Net Price], установленный
во внешней CALCULATE. Здесь мы можем использовать модификатор KEEPFIL-
TERS, чтобы избежать перезаписи фильтров:
Sales Large Amount KeepFilter :=
CALCULATE (
[Sales Amount];
KEEPFILTERS (
FILTER (
ALL ( Sales[Quantity]; Sales[Net Price] );
Sales[Quantity] * Sales[Net Price] >= 1000
)
)
)
Вывод новой меры Sales Large Amount KeepFilter показан на рис. 5.27.
Net Price
S500.00 $ 3.000.00
I------------------------1
Category Sales Amount Sales Large Amount Sales Large Amount KeepFilter
Audio 7,803.95
Cameras and camcorders 4,786,139.80 3,078,829.16 2,683,625.23
Cell phones 47,152.49 150,687.21 21,034.71
Computers 3,717,785.81 3,036,735.73 2,656,140.41
Home Appliances 5,839,778.70 5,390,769.53 4,560,035.33
Music, Movies and Audio Books 11,873.57
TV and Video 987,758.58 1,256,714.63 490,518.59
Total 15,378,615.38 12,933,413.78 10,411,354.27
Рис. 5.27 Использование модификатора KEEPFILTERS
позволило включить в расчеты внешние срезы в отчете
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 171
Еще одним способом использовать в мере сложные фильтры является вклю-
чение в формулу фильтра по таблице, а не по столбцу. Такую технику в ос-
новном предпочитают новички в мире DAX, при этом она таит немало опас-
ностей. С использованием табличного фильтра переписать предыдущую меру
можно так:
Sales Large Amount Table :=
CALCULATE (
[Sales Amount];
FILTER (
Sales;
Sales[Quantity] * Sales[Net Price] >= 1000
)
)
Как вы помните, все аргументы фильтра в функции CALCULATE оценива-
ются в рамках контекста фильтра, в котором эта функция была вызвана. Та-
ким образом, итерации по таблице Sales будут производиться только по стро-
кам, удовлетворяющим условиям внешнего контекста фильтра, включающего
фильтр по Net Price. Таким образом, семантика новой меры Sales Large Amount
Table полностью согласуется с предыдущей мерой Sales Large Amount KeepFilter.
И хотя такой подход выглядит логичным и несложным, применять его следу-
ет с особой осторожностью, поскольку он может привести к проблемам с произ-
водительностью отчета и корректностью результатов. В главе 14 мы подробнее
разберем детали возможных проблем. Пока же достаточно будет запомнить,
что лучше всего стараться использовать фильтры с минимально возможным
количеством столбцов.
Кроме того, следует избегать использования табличных фильтров, которые
обычно негативно сказываются на производительности меры. Таблица Sales
может быть довольно большой, и ее сканирование с целью оценки предикатов
может занимать немало времени. С другой стороны, в мере Sales Large Amount
KeepFilter количество итераций равно числу уникальных сочетаний значений
в столбцах Quantity и Net Price. А это число обычно намного меньше количества
строк в таблице Sales.
Порядок вычислений в функции CALCULATE
Обычно в выражениях DAX первыми вычисляются вложенные операции. По-
смотрите на следующую формулу:
Sales Amount Large :=
SUMX (
FILTER ( Sales; Sales[Quantity] >= 100 );
Sales[Quantity] * Sales[Net Price]
)
Перед тем как вызывать функцию SUMX, DAX оценит результат выполнения
табличной функции FILTER. По сути, функция SUMX осуществляет итерации по
таблице. А поскольку ее аргументом является таблица, полученная в результате
запуска функции FILTER, она не может приступить к работе, пока не завершит-
172 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
ся выполнение функции FILTER. Это правило распространяется в DAX на все
функции, за исключением CALCULATE и CALCULATETABLE. Особенность этих
функций состоит в том, что они сначала оценивают свои аргументы фильтра
и лишь затем вычисляют выражение из первого параметра, которое и обуслов-
ливает итоговый результат.
Осложняет ситуацию тот факт, что функция CALCULATE сама меняет кон-
текст фильтра. Все аргументы фильтра оцениваются в рамках контекста фильт-
ра, в котором вызвана функция CALCULATE, при этом каждый фильтр обра-
батывается независимо от остальных. Порядок следования фильтров внутри
функции CALCULATE не имеет значения. Таким образом, все меры, указанные
ниже, будут полностью эквивалентны:
Sales Red Contoso :=
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red";
KEEPFILTERS ( 'Product'[Brand] = "Contoso" )
)
Sales Red Contoso :=
CALCULATE (
[Sales Amount];
KEEPFILTERS ( 'Product'[Brand] = "Contoso" );
'Product'[Color] = "Red"
)
Sales Red Contoso :=
VAR ColorRed =
FILTER (
ALL ( 'Product'[Color] );
'Product'[Color] = "Red"
)
VAR BrandContoso =
FILTER (
ALL ( 'Product'[Brand] );
'Product'[Brand] = "Contoso"
)
VAR SalesRedContoso =
CALCULATE (
[Sales Amount];
ColorRed;
KEEPFILTERS ( BrandContoso )
)
RETURN
SalesRedContoso
Версия меры Sales Red Contoso co вспомогательными переменными получи-
лась более многословной по сравнению с остальными, но ее предпочтительно
использовать, если вы имеете дело с достаточно сложными выражениями с не-
обходимостью явного использования функции FILTER. В таких случаях исполь-
зование переменных помогает понять, что фильтры оцениваются прежде, чем
будет вычислено выражение.
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 173
Это правило оказывается полезным при использовании вложенных функ-
ций CALCULATE. В таком случае внешние фильтры будут оценены первыми,
а внутренние - последними. Понимание работы вложенных функций CALCU-
LATE очень важно, ведь вы сталкиваетесь с этим каждый раз, когда вкладывае-
те меры друг в друга. Взгляните на следующий пример, где мера Green calling
Red вызывает меру Sales Red:
Sales Red :=
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red"
)
Green calling Red :=
CALCULATE (
[Sales Red];
'Product'[Color] = "Green"
)
Чтобы сделать вызов меры из другой меры более очевидным, напишем пол-
ный код с вложенными функциями CALCULATE:
Green calling Red Exp :=
CALCULATE (
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red"
);
'Product'[Color] = "Green"
)
Порядок вычислений тут будет следующим.
1. Сначала в рамках внешней функции CALCULATE применяется фильтр
Product[Color] = "Green".
2. Затем во внутренней функции CALCULATE применяется фильтр
Product [Color] = "Red". Этот фильтр перезаписывает предыдущий.
3. В последнюю очередь DAX вычисляет значение [Sales Amount] с дейст-
вующим фильтром Product[Color] = "Red".
Таким образом, результаты вычисления мер Sales Red и Green calling Red бу-
дут одинаковыми и будут отражать продажи красных товаров, что видно по
рис. 5.28.
Примечание Мы привели такое описание последовательности исключительно в обра-
зовательных целях. В действительности движок DAX применяет отложенную оценку кон-
текстов фильтра.Таким образом, в представленном выше коде оценка внешнего фильтра
может вовсе не произойти по причине ненадобности. Это сделано исключительно для
оптимизации выполнения запросов и никоим образом не влияет на семантику функции
CALCULATE.
ч___________________________________________________________________________________J
174 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Category Sales Amount Sales Red Green calling Red Green calling Red Exp
Audio 384,518.16 33,123.82 33,123.82 33,123.82
Cameras and camcorders 7,192,581.95 1,514.39 1,514.39 1,514.39
Cell phones 1,604,610.26 38,227.47 38,227.47 38,227.47
Computers 6,741,548.73 240,222.29 240,222.29 240,222.29
Games and Toys 360,652.81 19,938.31 19,938.31 19,938.31
Home Appliances 9,600,457.04 770,373.33 770,373.33 770,373.33
Music, Movies and Audio Books 314,206.74 6,702.49 6,702.49 6,702.49
TV and Video 4,392,768.29
Total 30,591,343.98 1,110,102.10 1,110,102.10 1,110,102.10
Рис. 5.28 Последние три меры вернули одинаковые результаты,
отражающие продажи по красным товарам
Рассмотрим последовательность выполнения операций и оценку фильтров
на другом примере:
Sales YB :=
CALCULATE (
CALCULATE (
[Sales Amount];
'Product'[Color] IN { "Yellow"; "Black" }
);
'Product'[Color] IN { "Black"; "Blue" }
)
Изменение контекста фильтра в мере Sales YB показано графически на
рис. 5.29.
CALCULATE (
CALCULATE (
Product[Color] IN { "Yellow"; "Black"
Product[Color] { "Black"; "Blue
Рис. 5.29 Внутренний фильтр перезаписывает внешний
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 175
Как вы уже видели ранее, внутренний фильтр по столбцу Product[Color] пе-
резаписывает внешние. Таким образом, мера выдаст результаты по товарам
желтого (Yellow) и черного (Black) цветов. Использование модификатора KEEP-
FILTERS во внутренней функции CALCULATE позволит сохранить внешний
фильтр:
Sales YB KeepFilters :=
CALCULATE (
CALCULATE (
[Sales Amount];
KEEPFILTERS ( 'Product'[Color] IN { "Yellow"; "Black" } )
);
'Product'[Color] IN { "Black"; "Blue" }
)
Изменение контекста фильтра в мере Sales YB KeepFilters показано на
рис. 5.30.
KEEPFILTERS ( Product[Color] IN {
CALCULATE (
CALCULATE (
"Black"; "Blue
Рис. 5.30 Использование модификатора KEEPFILTERS
позволяет функции CALCULATE не перезаписывать
существующий контекст фильтра
Product[Color] {
KEEPFILTERS
"Yellow"; "Black
Поскольку оба фильтра сохранились, их наборы, по сути, пересеклись. Та-
ким образом, в итоговом контексте фильтра единственным видимым цветом
товаров останется черный (Black), поскольку только он присутствует в обоих
фильтрах.
При этом порядок следования аргументов фильтра внутри одной и той же
функции CALCULATE не имеет значения - все они применяются к контексту
фильтра независимо друг от друга.
176 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Преобразование контекста
В главе 4 мы несколько раз повторили, что контекст строки и контекст фильтра
представляют собой совершенно разные концепции. И это правда. Как правда
и то, что функция CALCULATE обладает уникальной способностью трансформи-
ровать контекст строки в контекст фильтра. Эта операция получила название пре-
образования контекста (context transition) и описывается следующим образом:
Функция CALCULATE отменяет действие любого контекста строки. Она ав-
томатически преобразует все столбцы из текущего контекста строки в ар-
гументы фильтра, используя их фактические значения в строке, по которой
осуществляется итерация.
Концепцию преобразования контекста новичкам в DAX будет усвоить не-
легко. Даже специалисты с опытом могут испытывать проблемы, когда стал-
киваются с тонкими нюансами этой концепции. Мы абсолютно уверены, что
данного ранее определения преобразования контекста будет совершенно не-
достаточно для всестороннего понимания этой возможности языка.
В этой главе мы попытаемся объяснить вам, что из себя представляет преоб-
разование контекста, на примерах, постепенно двигаясь от простого к сложно-
му. Но перед этим необходимо убедиться, что вы досконально понимаете, что
такое контекст строки и контекст фильтра.
Повторение темы контекста строки и контекста фильтра
Давайте повторим все важные факты, касающиеся контекста строки и контек-
ста фильтра, при помощи рис. 5.31, на котором показан отчет по продажам
с вынесенными на строки брендами и вспомогательная диаграмма, описы-
вающая схему работы контекстов. Таблицы Products и Sales на диаграмме не
отражают реальные данные. В них показано несколько строк для облегчения
понимания общей картины.
Brand
Sales Amount
A. Datum
Adventure Works
Contoso
Fabnkam
Litware
Northw.nd Traders
Pioseware
Southr dgp Video
Tailspin Toys
The Phone Company
Wide Wodd Importers
Totai
Контекст фильтра
2 0C6,134.64
4,011,112.2В
7,352.39903
5,554.015 73
3.255,704 03
1.040,552.13
2 546,144.16
1,384.413.85
325 042.42
| Contoso^ ।
Products
A
В
Brand
Brand
Product
1,123 819.0
Э56.66
30,591,343.98
Contoso
Litware
Итерации SUMX
Sales Amount =
SUMX (
ales;
Sales[Quantity] * Sales[Net Price]
Iteration Operation Result
Контекст строки
1*11.00 11.00
1
2
2*10.99 21.98
Рис. 5.31 На диаграмме схематично показано выполнение простой итерации
при помощи функции SUMX
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 177
Приведенные ниже комментарии помогут вам проверить свое понимание
полного процесса вычисления меры Sales Amount по строке с брендом Contoso:
в отчете создан контекст фильтра, включающий в себя фильтр Рго-
duct[Brand] = “Contoso”}
действие фильтра распространяется на всю модель данных, включая таб-
лицы Product и Sales;
контекст фильтра ограничивает набор строк в итерациях для функции
SUMX по таблице Sales. В результате функция SUMX проходит только по
строкам из таблицы Sales, относящимся к товарам бренда Contoso;
в таблице Sales на представленном рисунке содержится две строки по то-
вару А, принадлежащему бренду Contoso;
функция SUMX проходит по этим двум строкам. Итогом первой итерации
будет результат 1*11,00, составляющий 11,00, а по второй - 2*10,99, что
дает 21,98;
функция SUMX возвращает сумму полученных на предыдущем шаге ре-
зультатов;
во время осуществления итераций по таблице Sales функция SUMX про-
ходит только по видимой части таблицы, создавая контекст строки для
каждой видимой строки;
в первой строке таблицы значение столбца Sales [Quantity] равно 1,
a Sales[Net Price] - 11. В следующей строке значения будут уже другие.
В каждом столбце есть текущее значение, зависящее от строки, по кото-
рой в данный момент осуществляется итерация. Для каждой отдельной
строки значения в столбцах могут отличаться;
во время итерации одновременно существуют контекст строки и контекст
фильтра. При этом контекст фильтра остается неизменным (с фильтром
по бренду Contoso), поскольку ни одна функция CALCULATE его не ме-
няла.
В свете предстоящего разговора о преобразовании контекстов последний
пункт приобретает важное значение. Во время осуществления итераций по
таблице контекст фильтра действительно остается неизменным и содержит
фильтр по бренду Contoso. Контекст строки в это время занят, собственно,
выполнением итераций по таблице Sales. Каждый столбец таблицы Sales со-
держит свое значение, и контекст строки предоставляет очередное значение,
считывая его из текущей строки. Помните о том, что контекст строки занят
осуществлением итераций по таблице, контекст фильтра этим не занимается.
Это очень важное замечание. Мы настоятельно рекомендуем вам дважды
убедиться в том, что вы досконально поймете следующий сценарий. Пред-
ставьте, что вы создали меру для подсчета количества строк в таблице Sales со
следующей формулой:
NumOfSales := COUNTROWS ( Sales )
В отчете данная мера будет показывать количество транзакций в табли-
це Sales в рамках текущего контекста фильтра. В результате, показанном на
рис. 5.32, нет ничего неожиданного: для каждого бренда свое количество
транзакций.
178 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Brand NumOfSales
A. Datum 4,921
Adventure Works 7,819
Contoso 37,984
Fabrikam 7,861
Litware 7,214
Northwind Traders 1,636
Proseware 6,673
Southridge Video 10,658
Tailspin Toys 7,571
The Phone Company 3,106
Wide World Importers 4,788
Total 100,231
Рис. 5.32 Мера NumOfSales
подсчитывает количество строк,
видимых в текущем контексте
фильтра в таблице Sales
В таблице Sales присутствует 37 984 записи для бренда Contoso, а это значит,
что именно столько итераций будет сделано по таблице. Мера Sales Amount,
которую мы обсуждали выше, справится со своей работой за 37 984 операции
умножения.
Сможете ли вы, вооружившись полученными знаниями, ответить на вопрос
о том, какой результат покажет следующая мера в строке с брендом Contoso?
Sun Nun Of Sales := SUMX ( Sales; COUNTROWS ( Sales ) )
He спешите с ответом. Подумайте хорошенько и дайте осмысленную вер-
сию. В следующем абзаце мы дадим правильный ответ.
Контекст фильтра включает в себя бренд Contoso. По предыдущим приме-
рам мы знаем, что функция SUMX должна сделать ровно 37 984 итерации. И для
каждой из этих 37 984 строк функция SUMX подсчитает количество видимых
строк из таблицы Sales в текущем контексте фильтра. При этом контекст фильт-
ра все это время будет оставаться неизменным, так что для каждой из строк
функция COUNTROWS будет выдавать ответ 37 984. Следовательно, функция
SUMX просуммирует значение 37 984 ровно 37 984 раза. И результатом меры
будет 37 984 в квадрате. Вы можете убедиться в этом, взглянув на рис. 5.33, на
котором показан вывод этого отчета.
Теперь, когда вы освежили память относительно контекста строк и контек-
ста фильтра, можно приступать к изучению концепции преобразования кон-
текста.
Введение в преобразование контекста
Контекст строки создается всякий раз, когда по таблице начинают осуществ-
ляться итерации. Внутри одной итерации значения столбцов зависят от кон-
текста строки. Для демонстрации этого процесса подойдет хорошо знакомая
вам мера:
Sales Anount :=
SUMX (
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 179
Sales;
Sales[Quantity] * Sales[Unit Price]
Brand NumOfSales Sum Num Of Sales
A. Datum 4,921 24,216,241
Adventure Works 7,819 61,136,761
Contoso 37,984 1,442,784,256
Fabrikam 7,861 61,795,321
Litware 7,214 52,041,796
Northwind Traders 1,636 2,676,496
Proseware 6,673 44,528,929
Southridge Video 10,658 113,592,964
Tailspin Toys 7,571 57,320,041
The Phone Company 3,106 9,647,236
Wide World Importers 4,788 22,924,944
Total 100,231 10,046,253,361
Рис. 5.33 В мере Sum Num Of Sales выводится значение NumOfSales в квадрате,
поскольку для каждой строки производится подсчет строк
На каждой итерации в столбцах Quantity и UnitPrice содержится свое значе-
ние, зависящее от текущего контекста строки. В предыдущем разделе мы пока-
зывали, что если выражение внутри итерации не привязано к контексту стро-
ки, оно будет вычисляться в контексте фильтра. А значит, результаты могут
быть неожиданными, по крайней мере для новичков в DAX. Несмотря на это,
вы вольны использовать любые функции внутри контекста строки. Но среди
прочих сильно выделяется функция CALCULATE.
Вызванная в рамках контекста строки, она отменяет его действие еще до
вычисления своего выражения. Внутри выражения, вычисляемого функцией
CALCULATE, все предыдущие контексты строки утрачивают свое действие. Та-
ким образом, следующее выражение для меры будет недопустимым, выдаст
синтаксическую ошибку:
Sales Anount :=
SUMX (
Sales;
CALCULATE ( Sales[Quantity] ) -- Нет контекста строки внутри CALCULATE, ОШИБКА !
)
Причина в том, что значение столбца Sales [Quantity] не может быть полу-
чено внутри функции CALCULATE, поскольку она отменяет действие контекста
строки, в котором была вызвана. Но это только часть того, что происходит во
время операции преобразования контекста. Вторая, и главная, часть заключа-
ется в том, что функция CALCULATE переносит все столбцы из текущего кон-
текста строки вместе с их значениями в аргументы фильтра. Посмотрите на
следующий код:
180 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Sales Amount :=
SUMX (
Sales;
CALCULATE ( SUM ( Sales[Quantity] ) ) -- функция SUM не требует наличия контекста
-- строки
)
У этой функции CALCULATE нет аргументов фильтра. Единственным ее ар-
гументом является само выражение, которое требуется вычислить. Так что эта
функция CALCULATE вряд ли перезапишет существующий контекст фильтра.
Вместо этого она молча создаст множество аргументов фильтра. Фактически
для каждого столбца в таблице, по которой осуществляются итерации, будет
создан свой фильтр. Вы можете посмотреть на рис. 5.34, чтобы получить пер-
вое представление о том, как работает преобразование контекста. Мы умень-
шили количество столбцов для простоты восприятия.
Sales
Product Quantity Net Price
А 1 11.00
В 2 25.00
А 2 10.99
Контекст строки
Test :=
SUMX (
Sales;
CALCULATE ( SUM ( Sales[Quantity] ) )
Результат функции
SUMX: 5
Рис. 5.34 Вызов функции CALCULATE в контексте строки ведет к созданию контекста
фильтра с образованием фильтра для каждого столбца таблицы
После начала итераций функция CALCULATE попадает в первую строку таб-
лицы и пытается вычислить выражение SUM ( Sales [Quantity ] ). При этом она
создает по одному аргументу фильтра для каждого столбца в таблице, по ко-
торой осуществляются итерации. В нашем примере таких столбцов три: Pro-
duct, Quantity и Net Price. Созданный в результате преобразования из контекста
строки контекст фильтра содержит текущие значения (А, 1,11.00) для каждого
столбца (Product, Quantity и Net Price). Конечно, подобная операция выполняет-
ся для каждой из трех строк во время итераций функции SUMX.
По сути, результат предыдущего выражения эквивалентен следующему:
CALCULATE (
SUM ( Sales[Quantity] );
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 181
Sales[Product] = "A";
Sales[Quantity] = 1;
Sales[Net Price] = 11
) +
CALCULATE (
SUM ( Sales[Quantity] );
Sales[Product] = "B";
Sales[Quantity] = 2;
Sales[Net Price] = 25
) +
CALCULATE (
SUM ( Sales[Quantity] );
Sales[Product] = "A";
Sales[Quantity] = 2;
Sales[Net Price] = 10,99
)
Эти аргументы фильтра скрыты. Они добавляются движком DAX автома-
тически, и повлиять на этот процесс никак нельзя. Поначалу концепция пре-
образования контекста кажется очень странной. Но к ней нужно привыкнуть,
чтобы понять всю ее прелесть. Освоить преобразование контекста бывает не
так просто, но это действительно очень мощная концепция.
Давайте подытожим рассуждения, приведенные выше, после чего погово-
рим о некоторых аспектах более подробно:
преобразование контекста - дорогостоящая операция. Если исполь-
зовать преобразование контекста применительно к таблице из десяти
столбцов и миллиона строк, функции CALCULATE придется применять
десять фильтров миллион раз. В любом случае это будет довольно долго.
Это не значит, что не стоит использовать преобразование контекста во-
все. Но применять функцию CALCULATE следует довольно осторожно;
итогом преобразования контекста не обязательно будет одна стро-
ка. Исходный контекст строки, в котором вызывается функция CALCU-
LATE, всегда указывает ровно на одну строку. Контекст строки идет по-
следовательно - от строки к строке. Но когда контекст строки переходит
в контекст фильтра посредством преобразования контекста, вновь об-
разованный контекст будет фильтровать все строки, удовлетворяющие
выбранным значениям. Таким образом, неправильно говорить, что
преобразование контекста ведет к созданию контекста фильтра с одной
строкой. Это очень важный момент, и мы вернемся к нему в следующих
разделах;
преобразование контекста задействует столбцы, не присутствую-
щие в формуле. Несмотря на то что столбцы, используемые в фильтре,
скрыты, они являются частью выражения. Это делает любую формулу,
в которой есть функция CALCULATE, намного сложнее, чем кажется на
первый взгляд. При использовании преобразования контекста все столб-
цы таблицы становятся скрытыми аргументами фильтра. Такое поведе-
ние может приводить к образованию неожиданных зависимостей, и мы
поговорим об этом далее в этом разделе;
182 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
преобразование контекста создает контекст фильтра на основании
контекста строки. Вы должны помнить нашу любимую мантру: «Кон-
текст фильтра фильтрует, контекст строки осуществляет итерации по
таблице». Преобразуя контекст строки в контекст фильтра, мы, по сути,
меняем саму природу фильтра. Вместо прохода по одной строке DAX осу-
ществляет фильтрацию всей модели данных, используя при этом связи
между таблицами. Иными словами, преобразование контекста, приме-
ненное к одной таблице, способно распространить фильтрацию далеко
за пределы этой отдельной таблицы, в которой был изначально создан
контекст строки;
преобразование контекста происходит всегда, когда есть активный
контекст строки. Всякий раз, когда вы будете использовать функцию
CALCULATE в вычисляемом столбце, будет происходить преобразование
контекста. При создании вычисляемого столбца контекст строки появля-
ется автоматически, и этого достаточно для того, чтобы произошло пре-
образование контекста;
преобразование контекста затрагивает все контексты строки. Когда
мы имеем дело с вложенными итерациями по разным таблицам, преоб-
разование контекста будет учитывать все активные контексты строки.
Таким образом, эта операция отменит действие всех из них и создаст ар-
гументы фильтра для всех без исключения столбцов, которые участвуют
в итерациях во всех активных контекстах строки;
преобразование контекста отменяет действие контекстов строки.
Несмотря на то что мы повторили это уже не один раз, важно обратить
на этот аспект особое внимание. Ни один из внешних контекстов строк не
будет действовать внутри выражения, вычисляемого при помощи функ-
ции CALCULATE. Все внешние контексты строки будут трансформирова-
ны в соответствующие поля контекста фильтра.
Как мы уже говорили ранее, многие из этих аспектов нуждаются в дополни-
тельном объяснении. В оставшейся части данного раздела мы углубимся в не-
которые очень важные моменты. И хотя мы написали об этих аспектах как
о предостережениях, на самом деле это просто особенности концепции. Если
игнорировать их, результаты вычислений могут оказаться непредсказуемы-
ми. Но когда вы освоите этот прием, то сможете использовать его по своему
усмотрению. Единственным отличием между странным поведением и полез-
ной возможностью - по крайней мере, в DAX - является глубина знаний в этой
области.
Преобразование контекста в вычисляемых столбцах
Значение в вычисляемом столбце рассчитывается в рамках контекста строки.
Следовательно, использование функции CALCULATE в вычисляемом столбце
автоматически приведет к преобразованию контекста. Давайте применим эту
особенность в таблице Product, для того чтобы особым образом пометить това-
ры, продажа которых приносит компании более 1 % от всех продаж.
Чтобы произвести требуемое вычисление, нам нужны два значения: сумма
продаж по конкретному товару и общая сумма продаж. Для вычисления перво-
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 183
го из них необходимо отфильтровать таблицу Sales так, чтобы в расчет брался
только этот товар, тогда как при расчете общей суммы продаж нужно снять все
фильтры по товарам. Посмотрите на представленный ниже код:
'Product'[Performance] =
VAR TotalSales = -- Общая сумма продаж
SUMX (
Sales; -- Sales не отфильтрована,
Sales[Quantity] * Sales[Net Price] -- так что считаются все продажи
)
VAR Currentsales =
CALCULATE ( -- Происходит преобразование контекста
SUMX (
Sales; -- Продажи только по одному товару
Sales[Quantity] * Sales[Net Price] -- Здесь мы вычисляем продажи
) --по конкретному товару
)
VAR Ratio = 0.01 -- 1 %, выраженный как число
VAR Result =
IF (
Currentsales >= TotalSales * Ratio;
"High Performance product"; -- Очень популярный товар
"Regular product" -- Обычный товар
)
RETURN
Result
Вы, наверное, заметили, что между двумя переменными есть лишь одно не-
большое различие: переменная TotalSales рассчитывается путем осуществления
обычных итераций, а в CurrentSales тот же самый код DAX заключен в функцию
CALCULATE. Поскольку мы имеем дело с вычисляемым столбцом, при встрече
с функцией CALCULATE происходит преобразование контекста строки в кон-
текст фильтра. При этом контекст фильтра распространяется на всю модель
данных, достигая таблицы Sales и фильтруя ее по одному выбранному товару.
Таким образом, несмотря на внешнее сходство, эти переменные выполня-
ют совершенно разные функции. В TotalSales подсчитывается общая сумма
продаж по всем товарам, поскольку контекст фильтра в рамках вычисляемого
столбца всегда пустой и не фильтрует товары. В то же время CurrentSales от-
ражает сумму продаж по конкретному товару благодаря преобразованию кон-
текста, выполненному в функции CALCULATE.
Оставшаяся часть кода вопросов вызывать не должна - здесь просто выпол-
няется проверка на соответствие определенному условию и присвоение товару
соответствующего статуса. Созданный вычисляемый столбец можно использо-
вать в отчете, как показано на рис. 5.35.
В коде вычисляемого столбца Performance мы использовали функцию CAL-
CULATE и инициированное ей преобразование контекста. Перед тем как дви-
гаться дальше, давайте посмотрим, все ли нюансы мы учли. В таблице Product
достаточно мало строк - всего несколько тысяч. Так что производительность
вычисляемого столбца просесть не должна. Контекст фильтра, созданный
функцией CALCULATE, включает в себя все столбцы. Есть ли у нас гарантия,
184 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
что в переменную CurrentSales попадут продажи только по выбранному това-
ру? В нашем конкретном случае да, поскольку у нас все товары уникальные -
это обеспечивается тем, что в таблице Product содержится столбец ProductKey
с уникальными значениями. Следовательно, образованный путем преобразо-
вания из контекста строки контекст фильтра будет гарантированно содержать
одну строку.
Performance Sales Amount NumOfProducts
High Performance product 3,078.318.10 4
A. Datum SLR Camera X137 Grey 725,840.28 1
Adventure Works 26" 720p LCD HDTV M140 Silver 1.303,983.46 1
Contoso Telephoto Conversion Lens X400 Silver 683,779.95 1
SV 16xDVD M36O Black 364,714.41 1
Regular product 27.513.025.88 2513
A Datum Advanced Digital Camera M300 Azure 2,723.83 1
A. Datum Advanced Digital Camera M300 Black 5,313.82 1
A. Datum Advanced Digital Camera M300 Green 8,244.99 1
A. Datum Advanced Digital Camera M300 Grey 7,624.83 1
A. Datum Advanced Digital Camera M300 Orange 754.00 1
Total 30.591,343.98 2517
Рис. 5.35 Лишь четыре товара получили статус «High Performance» (Очень популярный)
В этом случае мы полностью можем полагаться на преобразование контек-
ста, ведь каждая строка в таблице, по которой осуществляются итерации, уни-
кальная в своем роде. Но так будет не всегда. И сейчас мы продемонстрируем
ситуацию, не подходящую для преобразования контекста. Создадим следую-
щий вычисляемый столбец в таблице Sales:
Sales[Wrong Ant] =
CALCULATE (
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
)
Будучи вычисляемым столбцом, Wrong Amt при создании способствует обра-
зованию контекста строки. Функция CALCULATE преобразует контекст строки
в контекст фильтра, и функция SUMX проходит по всем строкам в таблице Sales
с набором значений в столбцах, соответствующим текущей строке из таблицы
Sales. Проблема в том, что в таблице продаж нет столбца с уникальными зна-
чениями. Таким образом, вполне вероятно, что мы обнаружим сразу несколь-
ко строк с идентичными значениями столбцов, которые будут отфильтрованы
вместе. Иными словами, у нас нет никакой гарантии, что функция SUMX прой-
дет только по одной строке в столбце Wrong Amt.
Если вам повезет, в вашей таблице окажется много дубликатов строк, и ито-
говое значение, рассчитанное в рамках вычисляемого столбца, окажется оче-
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 185
видно неправильным. В этом случае проблема может быть быстро обнаружена
и локализована. Но в большинстве случаев дублей будет довольно мало, что
сильно затруднит поиск ошибочного значения. Пример базы данных, который
бы используем в нашей книге, не является исключением. Посмотрите на от-
чет, показанный на рис. 5.36, где в столбце Sales Amount выведено правильное
значение, а в столбце Wrong Amt - ошибочное.
Brand Sales Amount Wrong Amt
A. Datum 2,096,184.64 2,096,184.64
Adventure Works 4,011,112.28 4,011,112.28
Contoso 7,352,399.03 7,352,399.03
Fabrikam 5,554,015.73 5,558,757.73
Litware 3,255,704.03 3,255,704.03
Northwind Traders 1,040,552.13 1,040,552.13
Proseware 2,546,144.16 2,546,144.16
Southridge Video 1,384,413.85 1,384,413.85
Tailspin Toys 325,042.42 325,042.42
The Phone Company 1,123,819.07 1,123,819.07
Wide World Importers 1,901,956.66 1,901,956.66
Total 30,591,343.98 30,596,085.98
Рис. 5.36 Большинство значений в столбцах совпадают
Как видите, значения в столбцах отличаются только по бренду Fabrikam
и в итоговой строке. Дело в том, что в таблице Sales есть несколько дублей по
товарам бренда Fabrikam, и именно это привело к двойным подсчетам. Но
важно то, что присутствие таких строк в столбце может быть вполне обосно-
ванным: один и тот же покупатель мог приобрести один товар в том же самом
магазине и в тот же день - утром и вечером, тогда как в таблице Sales хранит-
ся только дата без указания времени. А поскольку таких случаев будет очень
мало, в основной своей массе результаты будут выглядеть корректно. В то же
время ошибка будет, поскольку в своих расчетах мы опирались на данные из
таблицы с дубликатами. И чем больше будет дубликатов, тем сильнее будет
расхождение.
В этом случае полагаться на преобразование контекста будет ошибкой. Ког-
да нет гарантии, что все записи в таблице будут уникальными, прием с преоб-
разованием контекста может оказаться небезопасным. И эксперт по языку DAX
должен уметь предвидеть такие ситуации. Кроме того, в таблице Sales может
быть много записей - до нескольких миллионов. Так что наш вычисляемый
столбец не только рискует выдавать неправильные результаты, но и рассчиты-
ваться он может очень долго.
Преобразование контекста в мерах
Хорошее понимание концепции преобразования контекста очень важно и по
причине следующей интересной особенности языка DAX:
186 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Каждая ссылка на меру неявным образом обрамляется в функцию CAL-
CULATE.
Это приводит к тому, что обращение к мере в присутствии любого контек-
ста строки автоматически ведет к преобразованию контекста. Именно поэто-
му в DAX так важно соблюдать единообразные принципы именования при об-
ращении к столбцам (с обязательным указанием названия таблицы) и мерам
(без указания таблицы). Таким образом, всегда важно помнить о возможности
возникновения неявного преобразования контекста при чтении и написании
кода на DAX.
Приведенное нами короткое определение в начале этого раздела нуждает-
ся в подробном пояснении с примерами. Первое, что стоит отметить, - любая
мера при обращении к ней автоматически заключается в функцию CALCU-
LATE. Посмотрите на следующий код, где мы создаем меру Sales Amount и вы-
числяемый столбец Product Sales в таблице Product:
Sales Amount :=
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
'Product'[Product Sales] = [Sales Amount]
Вычисляемый столбец Product Sales, как мы и ожидали, рассчитывает меру
Sales Amount только для текущего товара в таблице Product. На самом деле код
вычисляемого столбца Product Sales при обращении к мере Sales Amount не-
явным образом оборачивает ее в функцию CALCULATE, что приводит к такой
формуле:
'Product'[Product Sales] =
CALCULATE
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
)
Без функции CALCULATE вычисляемый столбец оказался бы заполненным
одинаковыми значениями, суммирующими продажи по всем товарам. При-
сутствие функции CALCULATE запускает операцию преобразования контекста,
что и позволяет добиться правильного результата. Таким образом, ссылка на
меру всегда вызывает функцию CALCULATE. Это очень важно помнить для на-
писания коротких и мощных выражений на DAX. В то же время в этой обла-
сти потенциально могут возникать ошибки, если забыть, что при обращении
к мере в рамках действующего контекста строки происходит преобразование
контекста.
Как правило, вы всегда можете заменить меру на соответствующее выра-
жение, заключенное в функцию CALCULATE. Давайте рассмотрим следующее
определение меры Max Daily Sales, вычисляющей максимальное значение по
мере Sales Amount в рамках дня:
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 187
Max Daily Sales :=
MAXX (
'Date';
[Sales Amount]
)
Эта формула интуитивно понятна. Но мера Sales Amount должна рассчиты-
ваться по каждой дате, а значит, таблицу продаж в ней нужно отфильтровать
по конкретной дате. Именно это нам помогает сделать преобразование кон-
текста. Внутренне DAX заменяет меру Sales Amount на ее выражение, заклю-
ченное в функцию CALCULATE, как показано ниже:
Max Daily Sales :=
MAXX (
'Date';
CALCULATE (
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
)
)
В главе 7 мы будем использовать эту особенность языка при написании
сложных формул на DAX для решения специфических сценариев. Сейчас же
вам достаточно знать, что такое преобразование контекста, и понимать, что
оно возникает в следующих случаях:
когда функции CALCULATE или CALCULATETABLE вызываются в присут-
ствии любого контекста строки;
когда идет обращение к мере в рамках контекста строки, поскольку DAX
автоматически обрамляет вызов меры в функцию CALCULATE.
Существует и другая опасность для потенциального возникновения ошибки,
связанная с предположением о том, что любую меру в коде можно заменить
на ее определение. Это не так. Это допустимо лишь в том случае, если вы де-
лаете это не внутри контекста строки, например в другой мере, но в рамках
контекста строки этого делать нельзя. Это правило легко забыть, поэтому мы
приведем пример, в котором произведем заведомо неправильные расчеты,
поддавшись ошибочным суждениям.
Вы, наверное, заметили, что в предыдущем нашем примере для вычисляе-
мого столбца мы дважды повторили одинаковый фрагмент кода с итерациями
по таблице Sales. Повторим эту формулу:
'Product'[Performance] =
VAR TotalSales = -- Общая сумма продаж
SUMX (
Sales; -- Sales не отфильтрована,
Sales[Quantity] * Sales[Net Price] -- так что считаются все продажи
)
VAR CurrentSales =
CALCULATE ( -- Происходит преобразование контекста
188 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
SUMX (
Sales; -- Продажи только по одному товару
Sales[Quantity] * Sales[Net Price] -- Здесь мы вычисляем продажи
) --по конкретному товару
)
VAR Ratio =0.01 -- 1 %, выраженный как число
VAR Result =
IF (
Currentsales >= TotalSales * Ratio;
"High Performance product"; -- Очень популярный товар
"Regular product" -- Обычный товар
)
RETURN
Result
Код с итерациями внутри функции SUMX действительно в точности повто-
ряется в обеих переменных. Разница состоит только в том, что в одном случае
он заключен в функцию CALCULATE, а в другом - нет. Кажется, что можно было
бы разместить этот повторяющийся код в отдельной мере и использовать ее
в переменных. Этот вариант выглядит очень логичным, особенно если повто-
ряющийся код не будет состоять из простой итерации при помощи функции
SUMX, а будет длинным и сложным. К сожалению, такой способ сократить фор-
мулу неприменим, поскольку DAX автоматически заключает код, на который
ссылается мера, в функцию CALCULATE.
Представьте, что мы создали меру Sales Amount, а затем в вычисляемом
столбце дважды обратились к ней при объявлении переменных: один раз с ис-
пользованием функции CALCULATE, другой - без.
Sales Amount :=
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
1 Product1[Performance] =
VAR TotalSales = [Sales Amount]
VAR Currentsales = CALCULATE ( [Sales Amount] )
VAR Ratio =0.01
VAR Result =
IF (
Currentsales >= TotalSales * Ratio;
"High Performance product";
"Regular product"
)
RETURN
Result
Выглядит этот код неплохо, но при запуске даст ошибочные результаты.
Причина в том, что оба вызова меры внутри вычисляемого столбца автома-
тически неявным образом будут обернуты в функцию CALCULATE. Таким об-
разом, в переменной TotalSales окажется не общая сумма продаж по всем то-
варам, а продажа по текущему товару - как раз из-за скрытого заключения
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 189
выражения в функцию CALCULATE, а значит, и выполнения преобразования
контекста. В переменной CurrentSales при этом окажется то же самое значение.
Здесь второе обрамление в функцию CALCULATE будет просто избыточным -
одна такая функция уже присутствует здесь в неявном виде из-за ссылки на
меру в контексте строки, открытом в вычисляемом столбце. Если мы развер-
нем код, то увидим все это сами:
'Product'[Performance] =
VAR TotalSales =
CALCULATE (
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
)
VAR CurrentSales =
CALCULATE (
CALCULATE (
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
)
)
VAR Ratio =0.01
VAR Result =
IF (
CurrentSales >= TotalSales * Ratio;
"High Performance product";
"Regular product"
)
RETURN
Result
Каждый раз, когда в коде DAX вы видите ссылку на меру, вы должны под-
разумевать обрамляющую ее функцию CALCULATE. Она там просто есть, и все.
В главе 2 мы говорили, что при обращении к столбцам лучше всегда явно ука-
зывать название таблицы и никогда не делать этого, ссылаясь на меры. И при-
чина этого заключается в том, что мы обсуждаем сейчас.
Читая код DAX, пользователь должен четко понимать, ссылается ли тот или
иной фрагмент на меру либо столбец таблицы. И признанным стандартом для
разработчиков является избегание употребления имени таблицы перед мерой.
Автоматическое обрамление кода в функцию CALCULATE позволяет легко
писать сложные формулы с использованием итераций. В главе 7 мы побольше
поработаем с этим на примерах, решая специфические сценарии.
Циклические зависимости
Разрабатывая модель данных, вы должны обращать особое внимание на воз-
можность появления так называемых циклических зависимостей (circular de-
190 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
pendencies) в формулах. В этом разделе вы узнаете, что такое циклические за-
висимости и как избегать их появления в вашей модели данных. Перед тем
как приступать к обсуждению циклических зависимостей, стоит поговорить
о простых линейных зависимостях (linear dependencies) на конкретных приме-
рах. Посмотрите на такую формулу для вычисляемого столбца:
Sales[Margin] = Sales[Net Price] - Sales[Unit Cost]
Образованный вычисляемый столбец Margin зависит от двух столбцов: Net
Price и Unit Cost. Это означает, что для того, чтобы вычислить значение Mar-
gin, DAX необходимо предварительно узнать значения двух других столбцов.
Зависимости - важная часть модели данных DAX, поскольку именно они
определяют порядок расчетов в вычисляемых столбцах и таблицах. В нашем
примере значение в столбце Margin может быть вычислено только после рас-
чета Net Price и Unit Cost. Разработчику не стоит заботиться о зависимостях.
DAX прекрасно справляется с ними сам путем построения сложных схем
последовательности вычисления всех внутренних объектов. Но бывает, что
при написании кода в этой последовательности возникает циклическая за-
висимость. Причина ее появления в том, что DAX не может самостоятельно
определить порядок вычисления выражений при попадании в бесконечный
цикл.
Рассмотрим следующие два вычисляемых столбца:
Sales[MarginPct] = DIVIDE ( Sales[Margin]; Sales[Unit Cost] )
Sales[Margin] = Sales[MarginPct] * Sales[Unit Cost]
Вычисляемый столбец MarginPct зависит от Margin, тогда как Margin, в свою
очередь, зависит от MarginPct. Возникает замкнутый цикл зависимостей. При
попытке сохранить последнюю формулу DAX выдаст ошибку, говорящую об
обнаружении циклической зависимости.
Циклические зависимости возникают в коде не так часто, поскольку разра-
ботчики делают все, чтобы их не было. К тому же сама проблема понятна всем.
В не может зависеть от А, если А зависит от В. Но бывает, что циклические зави-
симости возникают. Не потому, что разработчик этого захотел, а из-за недопо-
нимания всех тонкостей языка DAX. В этом сценарии мы будем использовать
функцию CALCULATE.
Представьте, что в таблице Sales есть вычисляемый столбец со следующей
формулой:
Sales[AUSalesQty] = CALCULATE ( SUM ( Sales[Quantity] ) )
Попробуйте ответить на вопрос: от какого столбца зависит значение в вы-
числяемом столбце AUSalesQty? На первый взгляд кажется, что единственным
столбцом, от которого зависит AUSalesQty, является Sales [Quantity], посколь-
ку другие столбцы в выражении просто не упоминаются. Как же легко забыть
про действительную семантику функции CALCULATE и связанную с ней кон-
цепцию преобразования контекста! Поскольку функция CALCULATE вызвана
в контексте строки, текущие значения всех столбцов таблицы будут включены
в выражение, пусть и незримо. Таким образом, полное выражение, которое по-
ступит на исполнение движку DAX, будет выглядеть так:
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 191
Sales [AUSalesQty] =
CALCULATE (
SUM ( Sales[Quantity] );
Sales[ProductKey] = <CurrentValueOfProductKey>;
Sales[StoreKey] = <CurrentValueOfStoreKey>;
• • • ।
Sales[Margin] = <CurrentValueOfMargin>
)
Как видите, список столбцов, от которых зависит AUSalesQty, включает в себя
полный набор столбцов таблицы. Поскольку функция CALCULATE была вызва-
на в контексте строки, выражение автоматически получает зависимости от
всех без исключения столбцов таблицы, по которой осуществляются итерации.
Это более очевидно в случае с вычисляемым столбцом, в котором контекст
строки присутствует по умолчанию.
Если написать один вычисляемый столбец с использованием функции CAL-
CULATE, ничего страшного не произойдет. Проблема возникнет, если создать
сразу два вычисляемых столбца в таблице с применением функции CALCU-
LATE, инициирующей преобразование контекста для обоих столбцов. Так что
попытка создания следующего вычисляемого столбца завершится неудачей:
Sales[NewAllSalesQty] = CALCULATE ( SUM ( Sales[Quantity] ) )
Причина возникновения ошибки в том, что функция CALCULATE автомати-
чески принимает все столбцы таблицы в качестве аргументов фильтра. А до-
бавление в таблицу нового столбца влияет на определение других столбцов.
Если бы DAX позволил нам создать столбец NewAUSalesQty, код двух вычисляе-
мых столбцов выглядел бы примерно так:
Sales [AUSalesQty] =
CALCULATE (
SUM ( Sales[Quantity] );
Sales[ProductKey] = <CurrentValueOfProductKey>;
• • • ।
Sales[Margin] = <CurrentValueOfMargin>;
Sales[NewAllSalesQty] = <CurrentValueOfNewAllSalesQty>
)
Sales[NewAllSalesQty] =
CALCULATE (
SUM ( Sales[Quantity] );
Sales[ProductKey] = <CurrentValueOfProductKey>;
• • • ।
Sales[Margin] = <CurrentValueOfMargin>;
Sales[AllSalesQty] = <CurrentValueOfAllSalesQty>
)
Как видите, две выделенные строки ссылаются друг на друга. Получается,
что столбец AUSalesQty зависит от значения столбца NewAllSalesQty, который,
в свою очередь, находится в зависимости от AUSalesQty. В результате мы полу-
чаем циклическую зависимость. DAX обнаруживает ее и запрещает нам сохра-
нять код, ведущий к ее образованию.
192 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Обнаружить эту проблему бывает не так просто, но решается она легко. Если
в таблице, в которой создаются вычисляемые столбцы с функцией CALCULATE,
присутствует столбец с уникальными записями и DAX знает об этом, при пре-
образовании контекста будет фильтроваться значение только этого столбца.
Представьте, что мы создали следующий вычисляемый столбец в таблице
Product:
'Product'[Productsales] = CALCULATE ( SUM ( Sales[Quantity] ) )
В данном случае нет никакой необходимости добавлять все столбцы в ка-
честве аргументов фильтра. В таблице Product есть столбец ProductKey, содер-
жащий уникальные значения. И DAX знает о существовании этого столбца, по-
скольку таблица Product находится в связи с Sales на стороне «один». А значит,
во время преобразования контекста движок не будет добавлять фильтр для
каждого столбца таблицы. Таким образом, код может преобразоваться в по-
добный:
'Product'[Productsales] =
CALCULATE (
SUM ( Sales[Quantity] );
'Product'[ProductKey] = <CurrentValueOfProductKey>
)
Как видите, вычисляемый столбец ProductSales в таблице Product зависит ис-
ключительно от поля ProductKey. В этом случае вы можете создавать множество
вычисляемых столбцов в данной таблице - каждое из них будет зависеть толь-
ко от ключевого столбца.
Примечание Последнее замечание о функции CALCULATE в действительности не
слишком верно. Мы использовали этот довод исключительно в образовательных целях.
На самом деле функция CALCULATE добавляет в качестве аргументов фильтра все столб-
цы таблицы независимо оттого, есть в ней ключевое поле или нет. При этом внутренняя
зависимость создается только для столбца с уникальными значениями. Наличие клю-
чевого поля позволяет DAX создавать множество вычисляемых столбцов с использова-
нием функции CALCULATE. При этом семантика функции остается прежней: все без ис-
ключения столбцы таблицы, по которой осуществляются итерации, включаются в число
аргументов фильтра.
Мы уже сказали выше, что в таблице, в которой есть дублирующиеся стро-
ки, полагаться на преобразование контекста не стоит. Возможность появления
циклических зависимостей - еще один повод отказаться от использования
функции CALCULATE, инициирующей преобразование контекста, в случае от-
сутствия гарантии уникальности строк в таблице.
Кроме того, одного наличия в таблице столбца с уникальными значения-
ми недостаточно для того, чтобы надеяться, что при преобразовании контекс-
та функция CALCULATE будет зависеть только от него. Модель данных также
должна быть оповещена о присутствии ключевого столбца. А как сообщить
DAX о наличии такого столбца? Есть множество способов передать эту инфор-
мацию движку:
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 193
если таблица находится в связи с другой таблицей на стороне «один»,
столбец, по которому осуществляется связь, помечается как уникальный.
Эта техника работает во всех инструментах;
если для таблицы установлено свойство «Отметить как таблицу дат»
(Mark As Date Table), столбец с датами по умолчанию считается уникаль-
ным. Подробно мы будем говорить об этом в главе 8;
вы можете вручную установить свойство уникальности для столбца, ис-
пользуя пункт «Поведение таблицы» (Table Behavior). Этот способ работа-
ет только в Power Pivot для Excel и Analysis Services Tabular. В Power BI на
момент написания книги такой функционал не реализован.
Выполнения любого из этих условий будет достаточно, чтобы движок DAX
посчитал, что в таблице есть ключевое поле. Это позволит вам использовать
функцию CALCULATE, не опасаясь возникновения циклических зависимостей.
В этом случае преобразование контекста будет зависеть исключительно от
ключевого столбца.
Примечание Мы говорим о таком поведении движка как о его особенности, но на самом
деле это лишь побочный эффект оптимизации. Семантика языка DAX предполагает созда-
ние зависимостей от всех столбцов. Однако в рамках одной из ранних оптимизаций было
установлено, что при наличии ключевого поля зависимость будет создаваться только для
него одного. Сегодня очень многие пользователи применяют эту особенность, ставшую со
временем составной частью языка. В некоторых сценариях, например когда в формуле
задействуется функция USERELATIONSHIP, оптимизация не выполняется, что возвращает
нас к ошибке, связанной с циклическими зависимостями.
Ч__________________________________________________________________________________)
Модификаторы функции CALCULATE
Как вы уже узнали из этой главы, функция CALCULATE - исключительно мощ-
ная и гибкая, и она позволяет писать очень сложный код на DAX. До сих пор мы
рассматривали только работу с аргументами фильтра функции и преобразова-
нием контекста. Но есть еще одна важная концепция, без понимания которой
невозможно в полной мере овладеть навыками использования функции CAL-
CULATE. Речь идет о модификаторах (modifier) этой функции.
Ранее мы уже познакомились с двумя модификаторами функции CALCU-
LATE: ALL и KEEPFILTERS. И если ALL может использоваться и как модифика-
тор, и как табличная функция, то KEEPFILTERS является исключительно моди-
фикатором аргументов фильтра, а значит, определяет способ взаимодействия
конкретного фильтра с исходным контекстом фильтра. Функция CALCULATE
может использовать несколько модификаторов, влияющих на подготовку но-
вого контекста фильтра. И главным из них, пожалуй, является функция ALL,
с которой вы уже хорошо знакомы. Когда ALL применяется к фильтрам функ-
ции CALCULATE, она выступает исключительно в качестве модификатора, а не
табличной функции. К модификаторам функции CALCULATE также относятся
USERELATIONSHIP, CROSSFILTER и ALLSELECTED. Их мы рассмотрим отдельно.
Что касается модификаторов ALLEXCEPT, ALLSELECTED, ALLCROSSFILTERED
194 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
и ALLNOBLANKROW, все они обладают одинаковыми правилами старшинства
(precedence rules) с модификатором ALL.
В этом разделе мы познакомимся со всеми этими модификаторами, а затем
обсудим вопросы, связанные с правилами старшинства. В заключение мы со-
ставим полную схему правил для функции CALCULATE.
Модификатор USERELATIONSHIP
Первым модификатором функции CALCULATE, с которым вы познакомитесь,
будет USERELATIONSHIP. Посредством этого модификатора функция CALCU-
LATE способна активировать ту или иную связь во время вычисления выра-
жения. Изначально в модели данных присутствуют как активные (active), так
и неактивные (inactive) связи. Неактивные связи могут появиться, например,
в случае наличия нескольких связей между таблицами, при этом активной
в любой момент времени может быть только одна из них.
Например, в таблице Sales мы можем хранить как дату заказа, так и дату по-
ставки. Обычно анализ продаж производится на основании дат заказов, но для
специфических мер вполне может потребоваться учет даты поставки. В этом
случае вы можете изначально создать две связи между таблицами Sales и Date:
одну на основании поля Order Date (Дата заказа), а вторую - на основании De-
livery Date (Дата поставки). Модель данных при этом будет выглядеть как на
рис. 5.37.
Рис. 5.37 Таблицы Soles и Dote объединены двумя связями,
но активной в любой момент времени может быть только одна из них
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 195
В каждый отдельный момент времени активной может быть только одна
связь между двумя таблицами в модели. На рис. 5.37 активна связь по столбцу
Order Date, тогда как связь по Delivery Date лишь ждет своего часа. Чтобы на-
писать меру с использованием связи по столбцу Delivery Date, необходимо ак-
тивировать ее на время выполнения вычисления. В этом случае вам поможет
модификатор USERELATIONSHIP, как показано в следующем фрагменте кода:
Delivered Amount:=
CALCULATE (
[Sales Amount];
USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[Date] )
)
В результате связь между столбцами Delivery Date и Date будет активна на
протяжении всего вычисления меры. В это время связь по полю Order Date бу-
дет неактивна. Еще раз акцентируем ваше внимание на том, что в любой мо-
мент времени между двумя таблицами может быть активна только одна связь.
Таким образом, модификатор USERELATIONSHIP временно активирует одну
связь, деактивируя при этом связь, активную за пределами выполнения функ-
ции CALCULATE.
На рис. 5.38 наглядно показано отличие между мерой Sales Amount, рассчи-
танной по связи с Order Date, и новой мерой Delivered Amount, в основании ко-
торой лежит связь по полю Delivery Date.
Calendar Year Sales Amount Delivered Amount
CY 2007 11,309,946.12 11,034,860.44
January 794,248.24
February 891,135.91
March 961,289.24
April 1,128,104.82
May 936,192.74
June 982,304.46
July 922,542.98
August 952,834.59
September 1,009,868.98
October 914,273.54
November 825,601.87
December 991,548.75
otal 30,591,343.98
624,650.61
790,981.53
992,760.62
1,140,575.75
839,658.92
991,050.56
1,078,819.68
776,586.75
1,082,690.27
901,968.98
872,217.70
942,899.08
30,591,343.98
Рис. 5.38 Разница между продажами
по дате заказа и дате поставки
Используя модификатор USERELATIONSHIP для активации связи, важно
иметь в виду один важный момент: связи определяются в момент использо-
вания ссылки на таблицу, а не в момент вызова RELATED или любой другой
функции для работы со связями. Мы подробнее обсудим этот нюанс в главе 14,
когда будем говорить о расширенных таблицах. Сейчас же достаточно будет
одного несложного примера. Следующая мера для расчета суммы по товарам,
доставленным в 2007 году, не сработает:
196 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Delivered Amount 2007 vl :=
CALCULATE (
[Sales Amount];
FILTER (
Sales;
CALCULATE (
RELATED ( 'Date*[Calendar Year] );
USERELATIONSHIP ( Sales[Delivery Date]; ‘Date1[Date] )
) = "CY 2007"
)
)
Фактически функция CALCULATE отменит действие контекста строки, соз-
данного функцией FILTER во время итераций. Так что внутри выражения
в CALCULATE нельзя использовать функцию RELATED. Одним из способов на-
писать нужную нам формулу будет следующий:
Delivered Amount 2007 v2 :=
CALCULATE (
[Sales Amount];
CALCULATETABLE (
FILTER (
Sales;
RELATED ( 'Date1[Calendar Year] ) = "CY 2007"
);
USERELATIONSHIP (
Sales[Delivery Date];
'Date1[Date]
)
)
)
В этой формуле мы обращаемся к таблице Sales после того, как функция
CALCULATE активировала нужную нам связь. Так что функция RELATED внут-
ри FILTER будет использовать связь на основании Delivery Date. Мера Delivered
Amount 2007 v2 покажет правильный результат, но лучше при подобных вы-
числениях полагаться на распространение контекста фильтра, а не на функ-
цию RELATED:
Delivered Amount 2007 v3 :=
CALCULATE (
[Sales Amount];
’Date'[Calendar Year] = "CY 2007";
USERELATIONSHIP (
Sales[Delivery Date];
'Date'[Date]
)
)
Когда мы используем модификатор USERELATIONSHIP в функции CALCU-
LATE, все аргументы фильтра вычисляются с учетом этого модификатора -
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 197
вне зависимости от порядка их следования. Например, в представленной
мере Delivered Amount 2007 v3 модификатор USERELATIONSHIP будет оказы-
вать влияние на предикат с использованием Calendar Year, несмотря на то что
расположен позже.
Такая особенность поведения осложняет применение альтернативных свя-
зей в вычисляемых столбцах. Ссылка на таблицу присутствует в определении
вычисляемого столбца в неявном виде. Так что мы не можем контролировать
этот момент и изменить такое поведение при помощи функции CALCULATE
с модификатором USERELATIONSHIP.
Важно отметить, что сам по себе модификатор USERELATIONSHIP не яв-
ляется фильтрующим элементом в выражении. Это не аргумент фильтра,
а просто модификатор, регламентирующий применение остальных указан-
ных фильтров к модели данных. Если внимательно посмотреть на опреде-
ление меры Delivered Amount in 2007 vS, можно заметить, что в аргументе
фильтра по 2007 году не указано, какую связь использовать: по столбцу Order
Date или Delivery Date. Этот момент как раз и определяется модификатором
USERELATIONSHIP.
Таким образом, функция CALCULATE сначала модифицирует структуру мо-
дели данных, активируя нужную связь, и только после этого применяет аргу-
мент фильтра. Если бы последовательность действий была иной, то есть аргу-
мент фильтра всегда применялся бы с использованием текущей связи, мера
показала бы неправильный результат.
В области применения аргументов фильтра и модификаторов в функции
CALCULATE действуют определенные правила старшинства. Первое правило
гласит, что модификаторы в функции CALCULATE всегда применяются раньше
аргументов фильтра, так что все фильтры накладываются уже на измененную
версию модели данных. Более детально правила старшинства в функции CAL-
CULATE мы обсудим позже.
Модификатор CROSSFILTER
Следующим модификатором функции CALCULATE, который мы изучим, будет
CROSSFILTER. CROSSFILTER в некоторой степени похож на USERELATIONSHIP,
поскольку также оказывает влияние на архитектуру связей в модели данных.
В то же время CROSSFILTER может выполнять две операции:
изменять направление кросс-фильтрации существующих связей;
деактивировать связь.
Модификатор USERELATIONSHIP позволяет сделать активной нужную для
вычисления связь, но он не может деактивировать связь между двумя таблица-
ми, не активируя при этом другую связь. CROSSFILTER работает иначе. Этот мо-
дификатор принимает два параметра, указывающих на столбцы, вовлеченные
в связь, и третий параметр, который может принимать одно из следующих зна-
чений: NONE (Нет связи), ONEWAY (Односторонняя связь) или BOTH (Двусто-
ронняя связь). Например, в следующей мере рассчитывается количество уни-
кальных цветов товаров после установки двунаправленной кросс-фильтрации
для связи между таблицами Sales и Product:
198 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
NumOfColors :=
CALCULATE (
DISTINCTCOUNT ( 'Product'[Color] );
CROSSFILTER ( Sales[ProductKey]; 'Product'[ProductKey]; BOTH )
)
Как и в случае с USERELATIONSHIP, модификатор CROSSFILTER не устанав-
ливает фильтры. Он только изменяет структуру связей, оставляя задачу фильт-
рации данных аргументам фильтра. В этом примере модификатор оказыва-
ет влияние лишь на функцию DISTINCTCOUNT, поскольку других аргументов
фильтра в данной функции CALCULATE не представлено.
Модификатор KEEPFILTERS
Ранее в этой главе мы уже встречались с модификатором KEEPFILTERS. Чисто
технически KEEPFILTERS является модификатором не функции CALCULATE,
а ее аргументов фильтра. И действительно, этот модификатор не оказывает
влияния на вычисление выражения внутри CALCULATE. Вместо этого он опре-
деляет способ применения конкретного аргумента фильтра к итоговому кон-
тексту фильтра, созданному функцией CALCULATE.
Мы уже детально обсуждали поведение функции CALCULATE в выражениях,
подобных тому, что показано ниже:
Contoso Sales :=
CALCULATE (
[Sales Amount];
KEEPFILTERS ( 'Product'[Brand] = "Contoso" )
)
Присутствие модификатора KEEPFILTERS означает, что фильтр по столб-
цу Brand не будет перезаписывать ранее существовавшие фильтры по этому
столбцу. Вместо этого он будет добавлен к текущему контексту фильтра. Моди-
фикатор KEEPFILTERS применяется индивидуально к тому аргументу фильтра,
в котором указан, и не меняет семантику функции CALCULATE в целом.
Есть и еще один вариант использования KEEPFILTERS, пусть и не столь оче-
видный. Можно применять его в качестве модификатора для таблицы, по ко-
торой осуществляются итерации, как показано ниже:
ColorBrandSales :=
SUMX (
KEEPFILTERS ( ALL ( 'Product'[Color]; 'Product'[Brand] ) );
[Sales Amount]
)
Присутствие KEEPFILTERS в качестве функции верхнего уровня внутри ите-
ратора вынуждает DAX применять этот модификатор ко всем неявным аргу-
ментам фильтра, созданным функцией CALCULATE во время преобразования
контекста. Фактически во время итераций по значениям столбцов ProductfColor]
и Product[Brand] функция SUMX вызывает CALCULATE как составную часть вы-
числения меры Sales Amount. В результате происходит преобразование кон-
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 199
текста, и контекст строки превращается в контекст фильтра путем добавления
аргументов фильтра для полей Color и Brand.
А поскольку при этом был использован модификатор KEEPFILTERS, в мо-
мент преобразования контекста не будут перезаписаны текущие фильтры.
Вместо этого будет выполнено их пересечение с новыми фильтрами. Это не
самая распространенная техника использования модификатора KEEPFILTERS.
В главе 10 мы рассмотрим несколько примеров на эту тему.
Использование модификатора ALL в функции CALCULATE
Как вы узнали в главе 3, ALL представляет собой табличную функцию. Кроме
того, она может быть использована и в качестве модификатора функции CAL-
CULATE, когда присутствует в ней как аргумент фильтра. Название функции
остается тем же, но семантика использования ALL совместно с CALCULATE для
многих может оказаться неожиданной.
Глядя на следующий пример, можно было бы подумать, что функция ALL
выдаст все годы и тем самым изменит контекст фильтра, сделав все годы ви-
димыми:
All Years Sales :=
CALCULATE (
[Sales Amount];
ALL ( 'Date1[Year] )
)
Однако это не так. Будучи использованной в качестве функции верхнего
уровня в аргументе фильтра функции CALCULATE, ALL удаляет существующий
фильтр вместо создания нового. Так что здесь эту функцию можно было бы
назвать не ALL, a REMOVEFILTER. Но по историческим соображениям было ре-
шено оставить название функции неизменным. Мы же поясним на примере,
как работает эта функция.
Если воспринимать ALL как табличную функцию, можно интерпретировать
работу CALCULATE так, как показано на рис. 5.39.
Внутренний ALL по столбцу DatefYear] представляет собой функцию верх-
него уровня в рамках CALCULATE. А значит, она ведет себя не как табличная
функция. Здесь ее действительно более уместно было бы назвать REMOVEFIL-
TER. Фактически, вместо того чтобы вернуть все годы, тут ALL действует как
модификатор функции CALCULATE, удаляющий все фильтры с аргумента. Что
на самом деле происходит в этом коде, показано на рис. 5.40.
Разница между этими поведениями незначительная. В большинстве вычис-
лений столь небольшие отличия в семантике останутся незамеченными. Но
при написании более сложных формул эти нюансы могут сыграть решающую
роль. Сейчас же вам нужно запомнить, что когда функция ALL используется
в качестве REMOVEFILTER, то она выступает в роли модификатора CALCULATE,
а не табличной функции.
Это очень важно по причине определенности порядка применения фильт-
ров в функции CALCULATE. Модификаторы функции CALCULATE применяют-
ся к итоговому контексту фильтра до явных аргументов фильтра. Рассмотрим
200 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
пример, в котором ALL и KEEPFILTERS указаны для разных аргументов фильт-
ра функции CALCULATE, В этом случае результат будет таким же, как если бы
фильтр применялся к этому же столбцу без модификатора KEEPFILTERS. Таким
образом, следующие два определения меры Sales Red дадут одинаковый ре-
зультат:
Sales Red :=
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red"
)
Sales Red :=
CALCULATE (
[Sales Amount];
KEEPFILTERS ( 'Product'[Color] = "Red" );
ALL ( 'Product'[Color] )
)
CALCULATE (
CALCULATE (
Рис. 5.39 Можно представить, что ALL возвращает все годы
и использует этот список для перезаписи существующего контекста фильтра
Причина в том, что здесь ALL выступает в качестве модификатора функции
CALCULATE. Следовательно, он будет применен раньше, чем KEEPFILTERS. Та-
кие же правила старшинства распространяются и на все другие функции с пре-
фиксом ALL, в числе которых ALLSELECTED, ALLNOBLANKROW, ALLCROSSFIL-
TERED и ALLEXCEPT. Обычно мы обращаемся к этой группе функций по общему
имени ALL*. Как правило, функции ALL* выступают в качестве модификаторов
CALCULATE, когда присутствуют в ней в виде функций верхнего уровня в аргу-
ментах фильтра.
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 201
CALCULATE (
CALCULATE (
Рис. 5.40 Функция ALL удаляет все ранее наложенные фильтры из контекста,
будучи использованной как REMOVEFILTER
Использование ALL и ALLSELECTED без параметров
С функцией ALLSELECTED мы познакомились в главе 3. Мы представили ее
вам так рано, поскольку она действительно очень полезная. Как и все функ-
ции группы ALL*, функция ALLSELECTED может играть роль модификатора
в CALCULATE, когда включена в нее как функция верхнего уровня в аргументах
фильтра. Более того, когда мы описывали функцию ALLSELECTED, то говорили
о ней как о табличной функции, которая умеет возвращать как один столбец,
так и целую таблицу.
В следующем фрагменте кода рассчитаем процент продаж по отношению ко
всем цветам товаров, выбранным за границами текущей визуализации отчета.
Это возможно благодаря способности функции ALLSELECTED восстанавливать
контекст фильтра за пределами визуализации - в нашем случае по столбцу
Product[Color].
SalesPct :=
DIVIDE (
[Sales];
CALCULATE (
[Sales];
ALLSELECTED ( 'Product'[Color] )
)
)
Того же эффекта можно добиться, написав ALLSELECTED ( Product ) - без ука-
зания конкретного столбца. Более того, в качестве модификаторов CAL-
CULATE функции ALL и ALLSELECTED могут использоваться вовсе без пара-
метров.
202 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
Таким образом, следующий синтаксис вполне применим в формуле:
SalesPct :=
DIVIDE (
[Sales];
CALCULATE (
[Sales];
ALLSELECTED ( )
)
)
Здесь, как вы понимаете, ALLSELECTED не может быть использована как
табличная функция. Это модификатор функции CALCULATE, дающий команду
восстановить контекст фильтра, который был активным за пределами текущей
визуализации отчета. Описание того, как происходит вычисление в этом слу-
чае, будет довольно сложным. В главе 14 мы выведем использование функции
ALLSELECTED на новый уровень. Функция ALL без параметров очищает кон-
текст фильтра со всех таблиц в модели данных, восстанавливая при этом кон-
текст без активных фильтров.
Теперь, когда мы полностью рассмотрели структуру функции CALCULATE,
можно детально поговорить о порядке вычисления ее элементов.
Правила вычисления в функции CALCULATE
В заключительном разделе этой длинной и трудной главы мы решили пред-
ставить подробный обзор функции CALCULATE. Возможно, вы не раз будете
обращаться к этому разделу за помощью при дальнейшем чтении книги. Если
вам нужно будет уточнить какие-то нюансы поведения функции CALCULATE,
ответы на свои вопросы вы наверняка найдете здесь.
Не бойтесь возвращаться к этому списку. Мы работаем с DAX уже много лет
и по-прежнему при написании сложных формул иногда обращаемся к этим
правилам. DAX - простой и мощный язык, но очень легко забыть какие-то де-
тали, которые могут повлиять на расчеты в том или ином сценарии.
Итак, представляем вам общую картину работы функции CALCULATE:
функция CALCULATE вызывается в контексте вычисления, который со-
стоит из контекста фильтра и одного или нескольких контекстов строки.
Этот контекст называется исходным;
функция CALCULATE создает новый контекст фильтра, в рамках которо-
го вычисляет выражение, переданное в нее первым параметром. Новый
контекст содержит в себе только контекст фильтра. Все контексты строки
переходят в контекст фильтра по правилам преобразования контекста;
функция CALCULATE принимает на вход три типа параметров:
- выражение, которое будет вычислено в новом контексте фильтра. Это
выражение всегда передается первым параметром;
- набор явно заданных аргументов фильтра, применяющихся к исход-
ному контексту фильтра. Каждый аргумент может быть снабжен мо-
дификатором, например KEEPFILTERS;
ГЛАВА 5 Функции CALCULATE и CALCULATETABLE 203
- набор модификаторов функции CALCULATE, способных менять мо-
дель данных и/или структуру исходного контекста фильтра, удаляя
фильтры или изменяя схему связей;
если исходный контекст включает в себя один или несколько контекстов
строки, функция CALCULATE инициирует операцию преобразования кон-
текста, добавляя скрытые и неявные аргументы фильтра. Неявные аргу-
менты, полученные из контекстов строки путем итераций по табличным
выражениям, помеченным модификатором KEEPFILTERS, сохраняют этот
модификатор и в новом контексте фильтра.
При обращении с принятыми параметрами функция CALCULATE следует
очень четкому алгоритму. Если разработчик планирует писать сложные фор-
мулы в DAX, он просто обязан понимать всю последовательность действий
функции CALCULATE.
1. Функция CALCULATE оценивает все явные аргументы фильтра в исход-
ном контексте вычисления. Сюда включаются и контексты строки (если
есть), и контекст фильтра. Все явные аргументы оцениваются незави-
симо друг от друга в исходном контексте вычисления. После окончания
оценки функция CALCULATE приступает к созданию нового контекста
фильтра.
2. Функция CALCULATE создает копию исходного контекста фильтра для
подготовки нового контекста. При этом она отменяет действие контек-
стов строки, поскольку в новом контексте вычисления не будет контек-
ста строки.
3. Функция CALCULATE выполняет преобразование контекста. При этом
используются текущие значения столбцов из исходных контекстов стро-
ки для создания фильтра с уникальными значениями для всех столбцов,
по которым осуществляются итерации в исходных контекстах строки.
Фильтр может содержать больше одной строки. Нет никакой гарантии,
что новый контекст фильтра будет состоять ровно из одной строки. Если
в исходном контексте вычисления не было активных контекстов строки,
этот шаг пропускается. После применения к новому контексту всех не-
явных аргументов фильтра, созданных на этапе преобразования контек-
ста, функция CALCULATE переходит к следующему шагу.
4. Функция CALCULATE приступает к оценке модификаторов USERELA-
TIONSHIP, CROSSFILTER и ALL '. Этот шаг выполняется только после
шага 3. Это очень важно, поскольку означает, что на данном этапе мы
можем удалить последствия преобразования контекста путем использо-
вания функции ALL, как будет описано в главе 10. Модификаторы функ-
ции CALCULATE применяются только после выполнения преобразова-
ния контекста, чтобы можно было повлиять на его последствия.
5. Функция CALCULATE оценивает все явные аргументы фильтра в исход-
ном контексте фильтра. На этом этапе происходит применение этих
фильтров к новому контексту фильтра, созданному на шаге 4. Поскольку
преобразование контекста к этому моменту уже выполнено, аргументы
могут перезаписывать новый контекст фильтра - после удаления фильт-
ра (эти фильтры не удаляются при помощи модификаторов группы
204 ГЛАВА 5 Функции CALCULATE и CALCULATETABLE
ALL*) и после обновления структуры связей модели. При этом оценка
аргументов фильтра происходит в рамках исходного контекста фильтра
и не подвержена влиянию со стороны других модификаторов или фильт-
ров из той же функции CALCULATE.
Контекст фильтра, образованный в результате выполнения пятого шага, ис-
пользуется для вычисления выражения функции CALCULATE.
ГЛАВА 6
Переменные
Переменные в языке DAX играют важную роль сразу по двум причинам: во-
первых, они делают код более легким для восприятия, во-вторых, положитель-
но влияют на его производительность. В данной главе мы подробно обсудим
создание и использование переменных в DAX, а вопросы производительности
и читаемости кода затрагиваются на протяжении всей книги. В самом деле,
мы используем переменные почти в каждом примере, а иногда показываем
версии формул с переменными и без них, чтобы вы почувствовали разницу.
Гораздо позже, в главе 20, мы покажем случаи, когда переменные могут зна-
чительно увеличить производительность кода. Здесь же мы просто соберем во-
едино всю важную и полезную информацию о переменных.
Введение в синтаксис переменных VAR
В выражениях объявление переменной начинается с ключевого слова VAR, сле-
дом за чем идет обязательный блок RETURN, определяющий возвращаемый
результат. Так выглядит типичный код, использующий переменную:
VAR SalesAnt =
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
RETURN
IF (
SalesAnt > 100000;
SalesAnt;
SalesAnt * 1.2
)
В одном блоке может быть объявлено сразу несколько переменных, тогда
как блок RETURN в блоке должен быть один. Очень важно отметить, что блоки
VAR/RETURN по своей сути являются выражениями. А значит, их можно при-
менять везде, где допустимо использовать выражения. Это позволяет нам объ-
явить переменные внутри итерации или в составе более сложного выражения,
как показано в примере ниже:
VAR SalesAnt =
SUMX (
206 ГЛАВА 6 Переменные
Sales;
VAR Quantity = Sales[Quantity]
VAR Price = Sales[Price]
RETURN
Quantity * Price
)
RETURN
Обычно переменные объявляются в начале кода меры и используются на
протяжении всего его определения. Но это лишь условность. В сложных вы-
ражениях объявление переменных на разных уровнях вложенности внутри
функций - вполне обычная практика. В предыдущем примере переменные
Quantity и Price инициализируются для каждой строки в таблице Sales во время
осуществления итераций при помощи функции SUMX. Значения этих пере-
менных будут недоступны за пределами выражения, вычисленного функцией
SUMX для каждой строки.
В переменной может храниться как скалярная величина, так и таблица. При
этом тип самой переменной может - и часто это так и есть - отличаться от типа
выражения, возвращаемого в блоке RETURN. Кроме того, внутри одного блока
VAR/RETURN могут присутствовать переменные разных типов, хранящие как
скалярные величины, так и таблицы.
Зачастую переменные используются для разбиения сложной формулы на
более мелкие логические шаги - в этом случае результат каждого шага запи-
сывается в отдельную переменную. В следующем примере демонстрируется
использование переменных для хранения промежуточных результатов вычис-
ления:
Margin% :=
VAR SalesAnount =
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
VAR TotalCost =
SUMX ( Sales; Sales[Quantity] * Sales[Unit Cost] )
VAR Margin =
SalesAnount - TotalCost
VAR MarginPerc =
DIVIDE ( Margin; TotalCost )
RETURN
MarginPerc
Та же самая формула без использования переменных будет читаться гораздо
хуже:
Margin% :=
DIVIDE (
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
) - SUMX (
Sales;
Sales[Quantity] * Sales[Unit Cost]
ГЛАВА 6 Переменные 207
);
SUMX (
Sales;
Sales[Quantity] * Sales[Unit Cost]
)
)
Более того, преимущество версии с переменными состоит еще и в том, что
каждая из них вычисляется только один раз. К примеру, TotalCost в предыду-
щем примере встречается дважды, но поскольку это переменная, DAX гаран-
тирует, что ее значение будет вычислено лишь один раз.
В блоке RETURN вы можете написать любое выражение. Но обычно принято
указывать здесь только одну переменную. Допустим, в предыдущем примере
мы могли бы избавиться от переменной MarginPerc и после ключевого слова
RETURN написать DIVIDE ( Margin; TotalCost ). Однако использование в бло-
ке RETURN переменной позволяет легко изменить возвращаемое значение из
меры. Это бывает полезно при проверке значений промежуточных шагов. Если
в нашем примере мера будет возвращать ошибочный результат, можно будет
проверить значения на всех промежуточных шагах, каждый раз включая меру
в отчет. То есть мы бы заменили MarginPerc сначала на Margin, затем на Total-
Cost, а после этого на SalesAmount в заключительном блоке RETURN. Запуск этих
отчетов дал бы нам понять, что на самом деле происходит внутри нашей меры.
Переменные - это константы
Несмотря на свое название, переменные в языке DAX в действительности яв-
ляются константами. Однажды присвоив значение переменной, мы не сможем
его изменить. Например, будучи объявленной внутри итератора, переменная
каждый раз создается заново и инициализируется. Более того, обратиться
к этой переменной можно будет только внутри итерационной функции, в рам-
ках которой она объявлена.
Amount at Current Price :=
SUMX (
Sales;
VAR Quantity = Sales[Quantity]
VAR CurrentPrice = RELATED ( 'Product'[Unit Price] )
VAR AmountAtCurrentPrice = Quantity * CurrentPrice
RETURN
AmountAtCurrentPrice
)
-- Любые ссылки на переменные Quantity, CurrentPrice или AmountAtCurrentPrice
-- будут недействительными за пределами функции SUMX
Значение переменной вычисляется один раз в области видимости своего
определения (VAR), а не там, где к ней обращаются. В следующем фрагменте
кода мера будет всегда возвращать значение 100 %, поскольку на переменную
SalesAmount не распространяется влияние функции CALCULATE. Значение этой
208 ГЛАВА 6 Переменные
переменной будет вычислено лишь однажды, и каждая очередная ссылка на
нее будет возвращать одно и то же значение вне зависимости от того, в каком
контексте фильтра происходит обращение к этой переменной.
% of Product :=
VAR SalesAmount = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
RETURN
DIVIDE (
SalesAmount;
CALCULATE (
SalesAmount;
ALL ( 'Product' )
)
)
В последнем примере мы использовали переменную там, где лучше было
применить меру. Если мы хотим избежать дублирования кода для SalesAmount
в разных частях выражения, правильно будет использовать меру вместо пере-
менной, чтобы результат оказался таким, как мы ожидаем. В следующем при-
мере мы создали две меры и получили правильный результат:
Sales Amount :=
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
% of Product :=
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Amount];
ALL ( 'Product' )
)
)
В этом случае мера Sales Amount будет вычислена дважды в двух разных
контекстах фильтра, что приведет к разным результатам вычислений, что нам
и нужно.
Области видимости переменных
Любая переменная в своем определении может ссылаться на другие перемен-
ные, объявленные в рамках того же блока VAR/RETURN. Все переменные, ини-
циализированные во внешнем блоке VAR, также будут доступны.
В своем определении переменные могут ссылаться на другие переменные,
объявленные в коде до нашей переменной, но не после. Следующий код от-
работает правильно:
Margin :=
VAR SalesAmount =
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
ГЛАВА 6 Переменные 209
VAR TotalCost =
SUMX ( Sales; Sales[Quantlty] * Sales[Unlt Cost] )
VAR Margin = SalesAnount - TotalCost
RETURN
Margin
Если же перенести объявление переменной Margin в начало меры, DAX не
примет такой синтаксис. Дело в том, что в этом случае переменная Margin ссы-
лается на переменные SalesAmount и TotalCost, которые в данный момент еще
не объявлены:
Margin :=
VAR Margin = SalesAnount - TotalCost Ошибка: SalesAnount и TotalCost не объявлены
VAR SalesAnount =
SUMX ( Sales; Sales[Quantlty] * Sales[Net Price] )
VAR TotalCost =
SUMX ( Sales; Sales[Quantlty] * Sales[Unlt Cost] )
RETURN
Margin
Поскольку к переменной невозможно обратиться до ее объявления, нет рис-
ка создания циклических зависимостей между переменными или образования
любого рода рекурсивных определений.
Блоки VAR/RETURN допустимо вкладывать один в другой или содержать
несколько таких блоков в одном выражении. Область видимости переменных
(scope of variables) для каждого из этих сценариев будет своя. Например, в сле-
дующем фрагменте кода переменные LineAmount и LineCost объявлены в двух
разных областях видимости, не вложенных друг в друга. Таким образом, ни
в один момент времени мы не сможем обратиться сразу к обеим переменным
в одном выражении:
Margin :=
SUMX (
Sales,
(
VAR LlneAnount = Sales[Quantlty] * Sales[Net Price]
RETURN
LlneAnount
) -- Скобки закрывают область видимости переменной LlneAnount
-- Переменная LlneAnount будет недоступна здесь и далее
(
VAR LineCost = Sales[Quantity] * Sales[Unlt Cost]
RETURN
LineCost
)
)
Разумеется, мы привели этот пример исключительно в образовательных це-
лях. Гораздо лучше будет объявить обе переменные рядом и свободно исполь-
зовать внутри меры Margin:
210 ГЛАВА 6 Переменные
Margin :=
SUMX (
Sales;
VAR LineAnount = Sales[Quantity] * Sales[Net Price]
VAR LineCost = Sales[Quantity] * Sales[Unit Cost]
RETURN
LineAnount - LineCost
)
В качестве еще одного обучающего примера интересно рассмотреть дей-
ствительную область видимости переменных в случае, когда скобки не при-
меняются, а в выражении объявляются и используются сразу несколько пере-
менных в отдельных блоках VAR/RETURN. Посмотрите следующий пример:
Margin :=
SUMX (
Sales;
VAR LineAnount = Sales[Quantity] * Sales[Net Price]
RETURN LineAnount
VAR LineCost = Sales[Quantity] * Sales[Unit Cost]
RETURN LineCost -- Здесь переменная LineAnount по-прежнему доступна
)
Выражение, стоящее после первого ключевого слова RETURN, является
частью единого выражения. Так что объявление переменной LineCost на самом
деле вложено в определение переменной LineAmount. Применение скобок для
разграничения блоков и использование надлежащих отступов делают этот
факт более очевидным:
Margin :=
SUMX (
Sales;
VAR LineAnount = Sales[Quantity] * Sales[Net Price]
RETURN (
LineAnount
- VAR LineCost = Sales[Quantity] * Sales[Unit Cost]
RETURN (
LineCost
-- Здесь переменная LineAnount по-прежнему доступна
)
)
)
Поскольку, как было показано в предыдущем примере, переменные могут
быть объявлены внутри выражений, они могут быть определены также и в рам-
ках выражений, принадлежащих другим переменным. Иными словами, пере-
менные в DAX могут быть вложенными. Посмотрите на следующий пример:
Anount at Current Price :=
SUMX (
'Product';
ГЛАВА 6 Переменные 211
VAR CurrentPrice = 'Product'[Unit Price]
RETURN -- Переменная CurrentPrice доступна во внутренней функции SUMX
SUMX (
RELATEDTABLE ( Sales );
VAR Quantity = Sales[Quantity]
VAR AnountAtCurrentPrice = Quantity * CurrentPrice
RETURN
AnountAtCurrentPrice
)
-- Ссылки на переменные Quantity и AnountAtCurrentPrice
-- будут недоступны за пределами внутренней функции SUMX
)
-- Ссылки на переменную CurrentPrice
-- будут недоступны за пределами внешней функции SUMX
Основные правила, касающиеся области видимости переменных:
переменная доступна после ключевого слова RETURN соответствующего
блока VAR/RETURN. Также доступ к этой переменной будет у всех пере-
менных, объявленных позже нее в одном блоке VAR/RETURN. Блок VAR/
RETURN может заменять собой любое выражение DAX, и внутри этого
выражения к переменной будет доступ. Иными словами, к переменной
можно обращаться с момента ее объявления и до конца выражения, сле-
дующего за ключевым словом RETURN, являющимся частью того же бло-
ка VAR/RETURN]
переменная недоступна за пределами своего блока VAR/RETURN. После
выражения, следующего за ключевым словом RETURN, переменная, объ-
явленная в этом блоке VAR/RETURN, будет не видна, и обращение к ней
выдаст синтаксическую ошибку.
Использование табличных переменных
В переменной может храниться как скалярная величина, так и таблица. Тип
переменной зависит от ее определения. Например, если выражение, исполь-
зуемое для инициализации переменной, является табличным, то и сама пере-
менная приобретет табличный тип. Рассмотрим следующий код:
Anount :=
IF (
HASONEVALUE ( Slicer[Factor] );
VAR
Factor = VALUES ( Slicer[Factor] )
RETURN
DIVIDE (
[Sales Anount];
Factor
)
)
212 ГЛАВА 6 Переменные
Если Slicer [Factor] в текущем контексте фильтра окажется столбцом с одной
строкой, то ее значение может быть преобразовано в скалярную величину. Пе-
ременная Factor хранит таблицу, поскольку при ее объявлении была использо-
вана функция VALUES, принадлежащая к табличному типу. Если не проверять
выражение Slicer[Factor] на присутствие одной строки, присвоение значения
переменной произойдет успешно. Ошибка же возникнет на втором параметре
функции DIVIDE, где происходит обращение к этой переменной. И это будет
ошибка преобразования.
Если в переменной содержится таблица, скорее всего, вам захочется пройти
по ней при помощи итераций. Важно отметить, что во время таких итераций
обращаться к столбцам табличной переменной нужно по имени исходной таб-
лицы. Иными словами, название табличной переменной не является псевдони-
мом (alias) наименования лежащей в ее основании таблицы:
Filtered Amount :=
VAR
MultiSales = FILTER ( Sales; Sales[Quantity] > 1 )
RETURN
SUMX (
MultiSales;
-- MultiSales не является названием таблицы при обращении к столбцам
-- Попытка записи MultiSales[Quantity] приведет к возникновению ошибки
Sales[Quantity] * Sales[Net Price]
)
Несмотря на то что функция SUMX осуществляет итерации по табличной
переменной MultiSales, при обращении к столбцам Quantity и Net Price необхо-
димо использовать префикс Sales, являющийся названием исходной таблицы.
Обратиться к столбцу при помощи выражения MultiSales [Quantity] нельзя.
На данный момент одним из ограничений DAX является то, что переменная
в коде не может называться так же, как одна из таблиц в модели данных. Это
предотвращает возможную путаницу между обращениями к таблице и пере-
менной. Рассмотрим следующую формулу:
SUMX (
LargeSales;
Sales[Quantity] * Sales[NetPrice]
)
Читающий этот код сразу поймет, что LargeSales является ссылкой на таблич-
ную переменную, поскольку при обращении к столбцам используется другой
префикс, а именно Sales. Но в DAX возможные неоднозначности трактовки ре-
шили снять на уровне языка. Так что одно название может относиться либо
к физической таблице, либо к табличной переменной, но не к обеим сразу.
На первый взгляд кажется, что это очень логичное и удобное ограничение,
призванное исключить неразбериху с именами в коде. Однако в долгосрочной
перспективе оно может доставлять некоторые неудобства. В самом деле, объ-
являя в коде переменную, вы должны обеспокоиться тем, чтобы в будущем
в модели данных не появилась таблица с таким же названием. В противном
случае вы получите ошибку на этапе создания новой таблицы. Любые ограни-
ГЛАВА6 Переменные 213
чения синтаксиса, предполагающие учет возможных событий в будущем - та-
ких как именование таблиц, - являются потенциально проблемными, если не
сказать больше.
По этой причине, когда Power BI генерирует код DAX, он снабжает все имена
переменных префиксом в виде двух знаков подчеркивания (__). Вероятность
того, что пользователь назовет таблицу в модели данных таким именем, не-
велика.
Примечание Подобное поведение DAX может быть изменено в будущем, что позволит
разработчикам называть переменные и таблицы одинаково. Когда это произойдет, можно
будет больше не опасаться, что в какой-то момент кто-то захочет создать таблицу с име-
нем, которое уже было использовано в переменной. При совпадении имен во избежание
неоднозначности можно будет пользоваться одинарными кавычками для именования
таблиц, как показано ниже:
variableName -- имя переменной
'tableName' -- имя таблицы
Если разработчик будет использовать генератор кода DAX в существующих выражениях,
названия таблиц могут быть заключены им в одинарные кавычки. Если имена таблиц
и переменных не пересекаются, об этом можно не заботиться.
Отложенное вычисление переменных
Как вы уже знаете, DAX рассчитывает значение каждой переменной в том кон-
тексте вычисления, в котором она определена, а не в том, из которого была
вызвана. Но при этом само вычисление ее значения произойдет только тогда,
когда в коде впервые встретится ссылка на эту переменную. Данная техника
получила название «ленивое» вычисление (lazy evaluation), также именуемое от-
ложенным. Такой подход очень важен в плане производительности: перемен-
ная, которая по тем или иным причинам не будет участвовать в вычислении
выражения, не будет и рассчитана. Кроме того, будучи вычисленной один раз,
переменная не будет рассчитываться повторно в той же области видимости.
Рассмотрим следующий пример:
Sales Amount :=
VAR SalesAmount =
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
VAR DummyError =
ERROR ( "Эта ошибка никогда не произойдет" )
RETURN
SalesAmount
Переменная DummyError не используется на протяжении всего кода, а зна-
чит, ее значение никогда не будет вычислено. Таким образом, ошибка никогда
не возникнет, и мера будет работать корректно.
Очевидно, что никто не будет писать такой код. Целью этого примера было
показать, что DAX экономно относится к драгоценным ресурсам центрального
214 ГЛАВА 6 Переменные
процессора при вычислении значений переменных и задействует их только
в случае необходимости. Вы можете полагаться на такое поведение движка при
написании своих формул.
Если в вашем коде будет несколько раз использоваться одно и то же выраже-
ние, лучше будет определить для него переменную. Это гарантирует однократ-
ное вычисление переменной. И с точки зрения производительности это даже
более важно, чем вы можете себе представить. Подробнее мы рассмотрим эту
тему в главе 20, а здесь просто сформулируем основную идею.
Оптимизатор DAX располагает специальным процессом, который называет-
ся определение подформулы (sub-formula detection). В сложных фрагментах кода
этот процесс отвечает за поиск повторяющихся подформул, которые можно
вычислить лишь раз. Взгляните на следующий код:
SalesAmount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
TotalCost := SUMX ( Sales; Sales[Quantity] * Sales[Unit Cost] )
Margin := [SalesAmount] - [TotalCost]
Margin% := DIVIDE ( [Margin]; [TotalCost] )
Мера TotalCost здесь вызывается дважды: один раз при вычислении Margin,
второй - для расчета Margin%. В зависимости от качества оптимизатора мо-
жет быть выявлен факт двукратного обращения к одной и той же переменной
TotalCost и определена возможность вычислить ее значение один раз. Однако
оптимизатору не всегда удается эффективно выполнять поиск этих подфор-
мул. Вам как разработчику обычно лучше известно, какие выражения предпо-
лагается использовать в коде многократно.
Если вы привыкли в своих формулах использовать переменные, возьмите
за правило определять в качестве переменных подформулы. Многократно ис-
пользуя ссылки на них в коде, вы поможете оптимизатору построить макси-
мально эффективный план выполнения запроса.
Распространенные шаблоны использования
переменных
В данном разделе мы обсудим вопросы практического использования пере-
менных. Конечно, мы не приведем исчерпывающий список сценариев, в кото-
рых использование переменных может оказаться полезным. Мы покажем лишь
наиболее распространенные шаблоны, часто встречающиеся на практике.
Первая и одна из важнейших причин для применения переменных в коде
состоит в возможности снабдить его своеобразной документацией. Пред-
ставьте, что вам нужно написать сложный многоступенчатый расчет с исполь-
зованием функции CALCULATE. Предварительная запись аргументов фильтра
функции CALCULATE поможет повысить легкость восприятия кода. Семантика
выражения и его производительность при этом не изменятся в лучшую сторо-
ну. Фильтры в любом случае будут проанализированы за границами преобра-
зования контекста, запущенного функцией CALCULATE, и контексты фильтра
будут вычислены отложенным способом. Но код станет легко читаемым, а для
ГЛАВА 6 Переменные 215
разработчика это чрезвычайно важно. Давайте рассмотрим следующую фор-
мулу для меры:
Sales Large Customers :=
VAR LargeCustomers =
FILTER (
Customer;
[Sales Amount] > 10000
)
VAR WorkingDaysIn2008 =
CALCULATETABLE (
ALL ( 'Date'[IsWorkingDay]; 'Date'[Calendar Year] );
'Date'[IsWorkingDay] = TRUE ();
'Date'[Calendar Year] = "CY 2008"
)
RETURN
CALCULATE (
[Sales Amount];
LargeCustomers;
WorkingDaysIn2008
)
Использование переменных для хранения отфильтрованных таблиц с поку-
пателями и датами позволило разбить итоговое вычисление на три этапа: опре-
деление покупателей с определенной суммой продаж, ограничение периода
для анализа и, наконец, вычисление меры с двумя примененными фильтрами.
Может показаться, что мы говорим только о стилистике программного кода,
но не стоит забывать о том, что у элегантных и простых формул больше шан-
сов выдавать на выходе корректный результат. В процессе упрощения кода
разработчик сможет лучше понять его функционал и исключить возможные
ошибки. Любое выражение объемом больше десяти строк следует разбивать на
отдельные переменные. Это также поможет программисту сосредоточиться на
более мелких фрагментах кода.
Еще одним распространенным шаблоном для применения переменных яв-
ляется присутствие в запросе вложенных друг в друга контекстов строки из од-
ной и той же таблицы. В этом случае вы можете использовать переменные для
хранения информации из скрытых контекстов строки и тем самым избежать
применения функции EARLIER:
' Product' [RankPri.ce] =
VAR CurrentProductPri.ce = 'Product'[Unit Price]
VAR MoreExpensiveProducts =
FILTER (
'Product';
'Product'[Unit Price] > CurrentProductPrice
)
RETURN
COUNTROWS ( MoreExpensiveProducts ) + 1
Контексты фильтра также могут быть вложенными друг в друга. Но это не
создает таких проблем с написанием кода, как в случае с вложенными кон-
216 ГЛАВА 6 Переменные
текстами строки. С разными уровнями контекстов фильтра часто приходится
сталкиваться при необходимости сохранения результатов предварительных
расчетов для дальнейшего их использования после смены контекста фильтра.
К примеру, если вам нужно узнать, какие покупатели приобретают товары
на сумму больше средней, следующий код вам не подойдет:
AverageSalesPerCustomer :=
AVERAGEX ( Customer, [Sales Amount] )
CustomersBuyingMoreThanAverage :=
COUNTROWS (
FILTER (
Customer;
[Sales Amount] > [AverageSalesPerCustomer]
)
)
Причина этого в том, что мера AverageSalesPerCustomer будет вычисляться
внутри итерации по таблице Customer. А значит, мы смело можем мысленно
обрамлять нашу меру в функцию CALCULATE, которая инициирует преобразо-
вание контекста. Следовательно, мера AverageSalesPerCustomer вместо своего
прямого предназначения будет на каждой итерации выдавать результат по од-
ному текущему покупателю. В итоге наша мера всегда будет показывать пустое
значение.
Чтобы получить правильный результат, необходимо вычислить значение
меры за пределами итерации. И в этом нам поможет переменная:
AverageSalesPerCustomer :=
AVERAGEX ( Customer; [Sales Amount] )
CustomersBuyingMoreThanAverage :=
VAR AverageSales = [AverageSalesPerCustomer]
RETURN
COUNTROWS (
FILTER (
Customer;
[Sales Amount] > AverageSales
)
)
Здесь DAX вычислит значение меры AverageSalesPerCustomer по всем покупа-
телям за пределами итерации и сохранит его в переменную AverageSales. К тому
же оптимизатор поймет, что это значение нужно рассчитать только один раз,
а значит, быстродействие нашей результирующей меры может увеличиться.
Заключение
Переменные в языке DAX полезно применять сразу по нескольким причинам,
среди которых упрощение кода, а также повышение его элегантности и произ-
водительности. Всякий раз, когда соберетесь писать достаточно сложный код
ГЛАВА 6 Переменные 217
на DAX, задумайтесь о том, чтобы разбить его на отдельные переменные. Вы
будете благодарны сами себе в следующий раз, когда будете разбираться в сво-
их формулах.
Код с использованием переменных обычно получается менее лаконичным,
чем формулы без переменных. Но объемный код - не проблема, когда каждая
составляющая его часть предельно проста для понимания. К сожалению, в не-
которых инструментах написание длинного кода на DAX, превышающего де-
сять строк, является проблематичным. В результате вы можете отдать предпо-
чтение более короткому коду без использования переменных, который будет
проще ввести в редактор DAX того же Power BI. Но это неправильные доводы.
Конечно, все мы хотим, чтобы появились инструменты, позволяющие писать
длинный код на DAX с комментариями и множеством переменных. И такие
инструменты скоро появятся. А пока, вместо того чтобы вписывать заведомо
ущербные формулы непосредственно в редакторы существующих инструмен-
тов BI, можно воспользоваться сторонними программными продуктами вроде
DAX Studio для написания полноценных запросов на DAX и вставки готовых
формул обратно в Power BI или Visual Studio.
ГЛАВА 7
Работа с итераторами
и функцией CALCULATE
В предыдущих главах мы много говорили о теоретических основах языка DAX:
о контекстах фильтра и строки, а также о преобразовании контекста. Это основа
любых выражений в DAX. Мы также представили вам итерационные функции
и показали, как использовать их в формулах. Но истинная мощь итераторов
кроется в их использовании совместно с контекстами вычисления и преобра-
зованием контекста.
В данной главе мы выведем понимание итераторов на новый уровень, рас-
скажем о наиболее распространенных практиках их использования и позна-
комимся с целым рядом новых функций. Умелое обращение с итерационными
функциями являет собой очень важный навык для любого разработчика DAX.
А использование итераторов совместно с преобразованием контекста - и во-
все уникальная особенность языка. По опыту преподавания можем сказать,
что студентам часто бывает непросто сразу осознать всю мощь итерацион-
ных функций. Но это не значит, что это такая уж сложная тема для освоения.
Концепция применения итераторов на самом деле довольно проста, как и их
совместное использование с техникой преобразования контекста. Что быва-
ет действительно сложно, так это понять, что какая-то непростая на первый
взгляд задача легко решается при помощи итерационных функций. Именно
поэтому мы решили сделать акцент на вычислениях, в которых вам могут ока-
заться полезными итераторы.
Использование итерационных функций
Большинство итерационных функций принимают минимум два параметра:
таблицу для осуществления итераций и выражение, которое необходимо вы-
числить на каждом проходе в контексте строки, создаваемом во время итера-
ций. Вот простейший пример использования итератора SUMX:
Sales Amount :=
SUMX (
Sales;
Sales[Quantity] *
-- Таблица для осуществления итераций
-- Выражение для вычисления в каждой строке
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 219
Функция SUMX проходит по таблице Sales и для каждой строки выполняет
умножение цены на количество. Итерационные функции отличаются друг от
друга тем, как обращаются с промежуточными результатами. Функция SUMX
представляет собой простой итератор, суммирующий результаты построчных
вычислений.
Важно понимать различия между двумя параметрами. В первом содержится
результат табличного выражения для осуществления итераций. Этот параметр
вычисляется до начала итераций. Второй параметр представляет собой выра-
жение, которое не рассчитывается до прохода по таблице. Вместо этого его вы-
числение происходит в контексте строки на каждой итерации. В официальной
документации Microsoft нет строгой классификации итерационных функций.
Более того, там даже не указано, какие параметры представляют собой значе-
ние, а какие - выражение, вычисляемое на каждой итерации. В инструкции по
адресу https://dax.guide все функции, рассчитывающие выражение в контексте
строки, помечены специальным маркером (ROW CONTEXT) для выделения па-
раметров, вычисляемых в контексте строки. Все функции, параметры которых
имеют такую отметку, являются итераторами.
Некоторые итерационные функции принимают более двух параметров. На-
пример, у функции RANKX множество параметров, тогда как простые итерато-
ры SUMX, AVERAGEX замечательно обходятся двумя. В данной главе мы опи-
шем работу разных итерационных функций, но сначала рассмотрим важные
аспекты, объединяющие все без исключения итераторы.
Кратность итератора
Первой важной характеристикой итерационных функций является их крат-
ность (iterator cardinality). Кратностью итератора называется количество
строк, по которым осуществляются итерации. Если в следующем примере в таб-
лице Sales будет миллион строк, кратность итератора будет равна миллиону:
Sales Amount :=
SUMX (
Sales; -- В таблице Sales 1 млн строк, значит,
Sales[Quantity] * Sales[Net Price] -- выражение вычислится ровно 1 млн раз
)
Говоря о кратности, мы редко оперируем цифрами. Фактически в преды-
дущем примере кратность итератора напрямую зависит от количества строк
в таблице Sales. В таком случае мы обычно говорим, что кратность итератора
такая же, как кратность таблицы Sales. Чем больше строк будет в таблице, тем
больше итераций выполнится.
Если мы имеем дело с вложенными друг в друга итерационными функция-
ми, результирующая кратность будет составляться из кратностей двух итера-
торов - вплоть до произведения количества строк в исходных таблицах. Рас-
смотрим следующую формулу:
Sales at List Price 1 :=
SUMX (
'Product';
220 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
SUMX (
RELATEDTABLE ( Sales );
'Product'[Unit Price] * Sales[Quantity]
)
)
Представленное выражение включает в себя две итерационные функции.
Внешняя проходит по таблице Product. Таким образом, ее кратность будет рав-
на кратности таблицы Product. Затем, внутри внешней итерации, для каждого
товара проводится сканирование по таблице Sales и возврат только тех строк,
которые связаны с текущим товаром. В нашем случае, поскольку каждой стро-
ке в таблице Sales соответствует только одна строка в таблице Product, итоговая
кратность выражения будет равна кратности таблицы Sales. Если бы выраже-
ние во вложенной итерации не зависело от внешней таблицы, кратность вы-
росла бы в разы.
Рассмотрим следующий пример. В нем мы рассчитываем те же значения, но,
в отличие от первого случая, к таблице продаж обращаемся не по связи, а при
помощи функции IF, ограничивающей количество строк в таблице Sales:
Sales at List Price High Cardinality :=
SUMX (
VALUES ( 'Product' );
SUMX (
Sales;
IF (
Sales[ProductKey] = 'Product'[ProductKey];
'Product'[Unit Price] * Sales[Quantity];
0
)
)
)
В данном примере внутренняя функция SUMX каждый раз проходит по всей
таблице Sales и при помощи условной функции IF отбирает строки, относя-
щиеся к текущему товару. Здесь кратность внешней функции SUMX будет со-
впадать с кратностью таблицы Product, а внутренней - с таблицей Sales. Общая
кратность выражения составит произведение двух составных кратностей, что
намного больше, чем в предыдущем примере. Отметим, что это выражение
мы показали исключительно в образовательных целях. На практике подобная
формула будет отличаться очень низкой производительностью.
Лучше будет переписать это выражение так:
Sales at List Price 2 :=
SUMX (
Sales;
RELATED ( 'Product'[Unit Price] ) * Sales[Quantity]
)
Кратность этой итерационной функции, как и в случае с мерой List Price 1,
будет равна кратности таблицы Sales, но план выполнения запроса при этом
будет более оптимальным. Заметьте, что здесь нет вложенных итераторов.
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 221
Вложенные итерации часто возникают вследствие преобразования контекста.
С первого взгляда и не скажешь, что в следующем выражении присутствуют
вложенные итерационные функции:
Sales at List Price 3 :=
SUMX (
'Product';
'Product'[Unit Price] * [Total Quantity]
)
Но здесь внутри функции SUMX есть ссылка на меру Total Quantity, что нель-
зя не учитывать. Вот как будет выглядеть развернутый код нашей меры, вклю-
чая определение меры Total Quantity:
Total Quantity :=
SUM ( Sales[Quantity] ) -- Внутреннее представление: SUMX ( Sales, Sales[Quantity] )
Sales at List Price 4 :=
SUMX (
'Product';
'Product'[Unit Price] *
CALCULATE (
SUMX (
Sales;
Sales[Quantity]
)
)
)
Теперь вы видите, что в этой формуле присутствуют вложенные итераторы:
SUMX внутри SUMX. Более того, появилась еще и функция CALCULATE, иници-
ирующая преобразование контекста.
При наличии вложенных итераторов есть возможность оптимизировать
план выполнения только для внутренней функции. Присутствие внешних ите-
раторов требует создания временных таблиц в памяти компьютера. В этих
таблицах хранятся промежуточные результаты вычислений вложенных ите-
рационных функций. В результате получаем низкую производительность
формул и расход драгоценных ресурсов компьютера. А значит, использования
вложенных итераторов следует избегать в случаях, когда кратность внешних
функций достаточно высока - от нескольких миллионов строк и выше.
Заметим, что в присутствии преобразования контекста бывает не так просто
правильно спланировать вложенность итераторов. Типичной ошибкой является
создание вложенных итераций с применением меры, которая может повторно
использовать существующую меру. Это опасно, когда существующая логика меры
повторно используется внутри итератора. Рассмотрим следующую формулу:
Sales at List Price 5 :=
SUMX (
'Sales';
RELATED ( 'Product'[Unit Price] ) * [Total Quantity]
)
222 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
Внешне мера Sales at List Price 5 очень напоминает меру Sales at List Price 3.
К сожалению, тот факт, что здесь итерации во внешнем цикле осуществляются
по таблице Sales, нарушает сразу несколько правил преобразования контекста,
изложенных в главе S. Преобразование контекста тут выполняется на таблице
большого объема (Sales), и, что еще хуже, нет никакой гарантии, что в ней не
будет дублирующихся строк. Следовательно, мало того, что эта формула будет
выполняться медленно, она может выдавать неправильные результаты.
Это не значит, что вложенные итерационные функции использовать не
следует. Есть масса сценариев, в которых эта концепция вполне применима.
И в оставшейся части главы мы приведем целый ряд примеров с уместным ис-
пользованием вложенных итераторов.
Использование преобразования контекста в итераторах
Вычисление может потребовать задействования вложенных итерационных
функций - например, когда необходимо рассчитать значение меры в разных
контекстах. И в этих случаях на помощь приходит преобразование контек-
ста, позволяющее писать лаконичный и эффективный код для действительно
сложных вычислений.
Рассмотрим меру, подсчитывающую максимальные дневные продажи за
определенный период времени. Описание меры очень важно, поскольку по-
могает сразу определиться с ее гранулярностью. Чтобы решить задачу, нам
необходимо сначала посчитать дневные продажи за период, а затем найти
максимальное значение из полученного ряда. Можно предположить, что нам
понадобится таблица, в которой будут собраны дневные продажи и по которой
мы будем впоследствии искать максимум. Но в DAX нет необходимости стро-
ить такую таблицу. Вместо этого можно обратиться за помощью к итерацион-
ным функциям, которые способны решить эту задачу без обращения к вспо-
могательным таблицам.
Алгоритм решения задачи будет следующим:
проходим по таблице Date;
рассчитываем сумму дневных продаж за день;
находим максимум среди значений, полученных на предыдущем шаге.
Можно написать подобную меру следующим образом:
Max Daily Sales 1 :=
MAXX (
'Date;
VAR DailyTransactions =
RELATEDTABLE ( Sales )
VAR DailySales =
SUMX (
DailyTransactions;
Sales[Quantity] * Sales[Net Price]
)
RETURN
DailySales
)
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 223
Но лучше будет применить подход, в котором используется неявное преоб-
разование контекста с мерой Sales Amount:
Sales Amount :=
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
Max Daily Sales 2 :=
MAXX (
'Date';
[Sales Amount]
)
В обоих случаях мы имеем дело с вложенными итераторами. Внешние ите-
рации запускаются по таблице Date, в которой должно быть не больше несколь-
ких сотен записей. Более того, каждая строка в этой таблице уникальна. Так что
обе меры выполнятся безопасно и быстро. При этом первая версия получилась
более многословной, поскольку в ней пошагово выполняется весь алгоритм.
Во второй версии скрываются многие детали, что делает код более легким для
восприятия, а преобразование контекста незаметно переносит фильтр с таб-
лицы Date на Sales.
На рис. 7.1 представлен отчет с максимальными дневными продажами по
месяцам.
Calendar Year SalesAmount Max Daily Sales
CY2OO7 11,309,946.12 126,742.18
January 794,248.24 92,244.07
February 891,135.91 108,923.95
March 961,289.24 122,503.54
April 1,128,104.82 126,742.18
May 936,192.74 102,857.58
June 982,304.46 77,082.30
July 922,542.98 124,176.88
August 952,834.59 85,114.89
September 1,009,868.98 102,588.78
October 914,273.54 81,926.23
November 825,601.87 71,959.23
December 991,548.75 101,708.68
Рис. 7.1 Вывод меры Мох Doily Soles по годам и месяцам
Воспользовавшись преобразованием контекста, можно сделать код более
элегантным и интуитивно понятным. Единственное, чего стоит опасаться
в случае с использованием преобразования контекста, - это снижения произ-
водительности вычисления: не стоит обращаться к мерам внутри объемных
итераторов.
При просмотре отчета с рис. 7.1 возникает логичный вопрос: а в какой
именно день каждого месяца продажи достигали максимума? Например, из
224 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
отчета ясно, что в какой-то день в январе 2007 года было продано товаров на
92 244,07 доллара. Но в какой день конкретно? Итераторы в связке с преоб-
разованием контекста являют собой достаточно мощный инструмент, чтобы
ответить и на этот вопрос. Взгляните на следующий код:
Date of Max :=
VAR MaxDailySales = [Max Daily Sales]
VAR DatesWithMax =
FILTER (
VALUES ( 'Date'[Date] );
[Sales Amount] = MaxDailySales
)
VAR Result =
IF (
COUNTROWS ( DatesWithMax ) = 1;
DatesWithMax;
BLANK ()
)
RETURN
Result
Сначала мы сохраняем значение меры Max Daily Sales в переменную MaxDai-
lySales. Затем создаем временную таблицу с датами, в которые продажи равня-
лись значению переменной MaxDailySales. Если такая дата была одна, фильтр
возвратит единственную строку. Если же дат было несколько, возвращаем пус-
тое значение, оповещающее о том, что конкретную дату определить не уда-
лось. Результат вывода можно видеть на рис. 7.2.
Calendar Year Sales Amount Max Daily Sales Date of Max
CY 2007 11,309,946.12 126,742.18 04/21/2007
January 794,248.24 92,244.07 01/03/2007
February 891,135.91 108,923.95 02/03/2007
March 961,289.24 122,503.54 03/15/2007
April 1,128,104.82 126,742.18 04/21/2007
May 936,192.74 102,857.58 05/14/2007
June 982,304.46 77,082.30 06/27/2007
July 922,542.98 124,176.88 07/11/2007
August 952,834.59 85,114.89 08/11/2007
September 1,009,868.98 102,588.78 09/06/2007
October 914,273.54 81,926.23 10/12/2007
November 825,601.87 71,959.23 11/22/2007
December 991,548.75 101,708.68 12/01/2007
Рис. 7.2 Мера Dote of Мох показывает дату
с максимальной дневной продажей
Использование итераторов в DAX требует, чтобы вы определились со следу-
ющими составляющими алгоритма и ровно в таком порядке:
гранулярность, на которой вы хотите произвести вычисление;
выражение для вычисления на этом уровне гранулярности;
тип агрегации.
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 225
В предыдущем примере (Max Daily Sales 2) гранулярность была определена
на уровне даты, в качестве выражения была выбрана сумма продаж, а типом
агрегации явилась функция МАХ. В итоге мы получили максимальные днев-
ные продажи.
Существует множество сценариев, где такой шаблон может быть полезен.
Еще один пример - подсчет средней суммы продаж по покупателю. Если ду-
мать об этой задаче категориями, описанными выше, получится такая после-
довательность: гранулярность - отдельный покупатель, выражение - сумма
продаж, тип агрегации - AVERAGE.
В результате четкого следования этому мыслительному процессу мы при-
шли к простой и понятной формуле:
Avg Sales by Customer :=
AVERAGEX ( Customer; [Sales Amount] )
Эту меру вполне можно использовать в отчетах вроде того, что показан на
рис. 7.3, где выводятся средние продажи по покупателям в разрезе континен-
тов и лет.
Continent CY 2007 CY 2008 CY 2009 Total
Asia 2,503.71 3,647.64 7,732.60 4,972.51
Europe 1,306.95 2,458.10 1,836.57 2,253.27
North America 1,090.43 2,543.29 3,887.40 2,223.02
Total 1,413.92 2,841.32 3,420.04 2,770.70
Рис. 7.3 Вывод меры Avg Soles by Customer по континентам и годам
Преобразование контекста в итерационных функциях - довольно мощный
инструмент. Но использование этой концепции способно снизить произво-
дительность вычислений. Чтобы этого не происходило, необходимо уделять
внимание кратности внешнего итератора в формуле. Это позволит вам писать
более эффективный код на DAX.
Использование функции CONCATENATEX
В данном разделе мы покажем вариант использования функции CONCATENA-
TEX для. отображения значений фильтров, выбранных пользователем в отчете.
Представьте, что вы строите простую визуализацию по продажам в разрезе
континентов и лет, а затем встраиваете ее в сложный отчет, где пользователь
может выбрать срез по цветам товаров. Сам элемент фильтра при этом может
находиться как рядом с визуализацией, так и на другой странице.
Если фильтр расположен на соседней странице, при просмотре отчета не по-
нятно, сформирован он по товарам всех цветов или каких-то отдельных. В этом
случае полезно будет добавить метку в отчет, в текстовом виде отображающую
сделанный пользователем выбор, как показано на рис. 7.4.
Просмотреть выбранные пользователем цвета можно при помощи функции
VALUES. Функция CONCATENATEX пригодится, чтобы сконвертировать полу-
ченную таблицу в строку. Взгляните на определение меры Selected Colors, кото-
226 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
рую мы использовали для вывода пользовательского выбора цветов в отчете,
показанном на рис. 7.4:
Selected Colors :=
"Showing " &
CONCATENATE/ (
VALUES ( 'Product'[Color] );
'Product'[Color];
II II ,
'Product'[Color];
ASC
) & " colors."
Continent
Asia
Europe
North America
Total
CY 2007
1,125,060.75
1,062,029.30
1,148,462.69
3,335,552.74
CY 2008
1,708,318.19
912,736.27
1,321,175.78
3,942,230.25
CY 2009
1,216,841.47
981,869.39
1,251,710.25
3,450,421.10
Total
4,050,220.41
2,956,634.96
3,721,348.72
10,728,204.09
Showing Black, Blue, Brown, Green colors.
Рис. 7.4 Метка внизу отчета показывает текущий выбор пользователя
Функция CONCATENATEX проходит по списку цветов и составляет из них
строку с разделителем в виде запятой. Как видите, у этой функции много па-
раметров. Как обычно, первые два представляют таблицу для сканирования
и вычисляемое выражение. В третьем параметре передается символ раздели-
теля, а в четвертом и пятом - поле для сортировки и ее направление (ASC или
DESC).
Единственным минусом этой меры является то, что в случае отсутствия
пользовательского выбора будет выведен длинный список из всех цветов в мо-
дели. К тому же если пользователь выберет больше пяти цветов, строка окажет-
ся слишком длинной, и всю ее поместить в отчет не удастся. Мы можем решить
эти проблемы, дополнив нашу меру:
Selected Colors :=
VAR Colors =
VALUES ( 'Product'[Color] )
VAR NumOfColors =
COUNTROWS ( Colors )
VAR NumOfAllColors =
COUNTROWS (
ALL ( 'Product'[Color] )
)
VAR AllColorsSelected = NumOfColors = NumOfAllColors
VAR SelectedColors =
CONCATENATEX (
Colors;
'Product'[Color];
II II ,
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 227
'Product'[Color]; ASC
)
VAR Result =
IF (
AllColorsSelected;
"Showing all colors.";
IF (
NumOfColors > 5;
"More than 5 colors selected, see slicer page for details.";
"Showing " & SelectedColors & " colors."
)
)
RETURN
Result
На рис. 7.5 мы показали два варианта отчета с разным пользовательским
выбором. Теперь пользователь видит, какие цвета выбраны, и в случае необхо-
димости может обратиться к листу с фильтрами.
Continent СУ 2007 СУ 2008 СУ 2009 Total
Asia 1,156,160.73 1,738,396.65 1,274,148.22 4,168,705.60
Europe 1,138,376.83 973,048.39 1,023,358.17 3,134,783.39
North America 1,202,649.32 1,386,848.85 1,294,102.82 3,883,600.99
Total 3,497,186.88 4,098,293.89 3,591,609.21 11,187,089.99
More than 5 colors selected, see slicer page for details.
Continent
Asia
Europe
North America
Total
CY 2007
3,532,732.93
3,582,341.75
4,194,871.44
11,309,946.12
CY 2008
3,713,296.91
2,391,726.88
3,822,559.21
9,927,582.99
CY 2009
3,479,670.07
2,694,249.12
3,179,895.68
9,353,814.87
Total
10,725,699.91
8,668,317.75
11,197,326.32
30,591,343.98
Showing all colors
Рис. 7.5 В зависимости от выбора пользователя метка показывает разные сообщения
Но и последняя версия нашей меры не идеальна. В случае если пользова-
тель, к примеру, выберет пять цветов, но в текущем выборе будет представлено
только четыре цвета из-за сокрытия некоторых цветов другими фильтрами,
в мере выведется неполный список, в него будут включены только присут-
ствующие цвета. В главе 10 мы еще поработаем с данной мерой и решим эту
проблему. Чтобы написать окончательную версию меры, нам сначала нужно
познакомиться с новыми функциями для исследования содержимого текущего
контекста фильтра.
Итераторы, возвращающие таблицы
До сих пор мы работали только с итерационными функциями, агрегирующими
значения. Но есть итераторы, возвращающие таблицы, полученные путем объ-
228 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
единения исходной таблицы с выражениями, вычисленными в контексте стро-
ки итерации. И две из них - ADDCOLUMNS и SELECTCOLUMNS - представляют
для нас большой интерес. О них мы и расскажем в данном разделе.
Как ясно из названия, функция ADDCOLUMNS добавляет столбцы к таблич-
ному выражению, переданному в качестве первого параметра. Для каждого
добавляемого столбца функции ADDCOLUMNS необходимо знать его название
и определяющее его выражение.
Например, мы можем добавить два столбца к списку цветов, отображающих
количество товаров этого цвета и сумму продаж по товарам этого цвета:
Colors =
ADDCOLUMNS (
VALUES ( 'Product'[Color] );
"Products"; CALCULATE ( COUNTROWS ( 'Product' ) );
"Sales Amount"; [Sales Amount]
)
Результатом данного выражения будет таблица, состоящая из трех столбцов:
цвета товара, полученного из значений столбца Product[Color], и двух новых
столбцов, добавленных функцией ADDCOLUMNS, как видно по рис. 7.6.
Color Sales Amount Products
Azure 97,389.89 14
Black 5,860,066.14 602
Blue 2,435,444.62 200
Brown 1,029,508.95 77
Gold 361,496.01 50
Green 1,403,184.38 74
Grey 3,509,138.09 283
Orange 857,320.28 55
Pink 828,638.54 84
Purple 5,973.84 6
Red 1,110,102.10 99
Silver 6,798,560.86 417
Silver Grey 371,908.92 14
Transparent 3,295.89 1
White 5,829,599.91 505 Рис. 7.6 Столбцы Sales Amount и Products
Yellow 89,715.56 36 добавлены и рассчитаны функцией ADDCOLUMNS
Функция ADDCOLUMNS возвращает все столбцы из исходной таблицы, по
которой осуществляет итерации, добавляя при этом новые. А чтобы из исход-
ной таблицы взять только определенный набор столбцов, можно использовать
функцию SELECTCOLUMNS, возвращающую лишь запрошенные столбцы. На-
пример, мы можем переписать предыдущую формулу следующим образом:
Colors =
SELECTCOLUMNS (
VALUES ( 'Product'[Color] );
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 229
"Color"; 'Product'[Color];
"Products"; CALCULATE ( COUNTROWS ( 'Product' ) );
"Sales Anount"; [Sales Anount]
)
Результат будет таким же, но в этом случае необходимо указать столбец Color
явным образом. Функция SELECTCOLUMNS бывает полезна, когда нужно со-
кратить количество столбцов в таблице, часто являющейся результатом про-
межуточных вычислений.
Функции ADDCOLUMNS и SELECTCOLUMNS могут быть удобны при созда-
нии новых таблиц, как было показано в нашем первом примере. Также они
часто применяются при создании мер, чтобы сделать код более быстрым и по-
нятным. Взгляните на меру, вычисляющую максимальные дневные продажи,
с которой мы уже встречались ранее в данной главе:
Max Daily Sales :=
МАХХ (
'Date';
[Sales Anount]
)
Date of Max :=
VAR MaxDailySales = [Max Daily Sales]
VAR DatesWithMax =
FILTER (
VALUES ( 'Date'[Date] );
[Sales Anount] = MaxDailySales
)
VAR Result =
IF (
COUNTROWS ( DatesWithMax ) = 1;
DatesWithMax;
BLANK ()
)
RETURN
Result
Если внимательно вчитаться в код, можно заметить, что он далеко не так
оптимален с точки зрения производительности. Фактически для вычисления
переменной MaxDailySales DAX необходимо подсчитывать дневные продажи
товаров, чтобы найти максимум. В процессе вычисления второй перемен-
ной движку приходится снова рассчитывать дневные продажи для поиска дат
с максимальными показателями. Таким образом, мы дважды проходим по
таблице Date и каждый раз вычисляем сумму продажи за каждый день. Тео-
ретически оптимизатор DAX достаточно продвинут, чтобы понять, что можно
вычислять дневные продажи лишь раз, а затем использовать уже вычисленное
ранее значение, но никто не может гарантировать, что так и будет. Воспользо-
вавшись функцией ADDCOLUMNS, мы можем написать более быстрый код для
этой меры. Мы сделаем это, предварительно подготовив таблицу с дневными
продажами и сохранив ее в переменную. Затем мы используем эти данные
230 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
для вычисления значения максимальной дневной продажи и дня, когда это
произошло:
Date of Max :=
VAR DailySales =
ADDCOLUMNS (
VALUES ( 'Date'[Date] );
"Daily Sales"; [Sales Anount]
)
VAR MaxDailySales = MAXX ( DailySales; [Daily Sales] )
VAR DatesWithMax =
SELECTCOLUMNS (
FILTER (
DailySales;
[Daily Sales] = MaxDailySales
);
"Date"; 'Date'[Date]
)
VAR Result =
IF (
COUNTROWS ( DatesWithMax ) = 1;
DatesWithMax;
BLANK ()
)
RETURN
Result
Алгоритм работы данного кода похож на предыдущий, за исключением не-
которых деталей:
переменная DailySales содержит таблицу с датами и суммами продаж
в эти даты. Эта таблица - результат работы функции ADDCOLUMNS;
переменная MaxDailySales больше не вычисляет дневные продажи. Вмес-
то этого она сканирует предварительно вычисленную таблицу в пере-
менной DailySales, что положительно отражается на времени выполнения
формулы;
то же самое происходит и в случае с DatesWithMax, которая сканирует таб-
лицу в переменной DailySales. А поскольку после этого нам нужны будут
только даты, а не дневные продажи, мы воспользовались функцией SE-
LECTCOLUMNS для исключения столбца с дневными продажами из ре-
зультата.
Итоговая версия кода получилась более сложной по сравнению с первона-
чальной. Но простотой формул часто приходится жертвовать во время опти-
мизации кода. Чтобы сделать код более быстрым, приходится писать более
сложные конструкции.
В главах 12 и 13 мы детальнее обсудим работу функций ADDCOLUMNS и SE-
LECTCOLUMNS. А поговорить есть о чем, особенно если вы хотите использовать
результат функции SELECTCOLUMNS в итераторе с дальнейшим преобразова-
нием контекста.
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 231
Решение распространенных сценариев
при помощи итераторов
В данном разделе мы продолжим работать с примерами, в которых использу-
ются уже известные вам итерационные функции, а также познакомимся с еще
одним полезным итератором - RANKX. Вы научитесь рассчитывать скользя-
щее среднее и почувствуете разницу между использованием для этих целей
итератора и обычной арифметической операции. Позже в этом разделе мы да-
дим полное определение функции RANKX, полезной при расчете рангов, осно-
вываясь на выражениях.
Расчет среднего и скользящего среднего
Вы можете рассчитать среднее значение по набору данных, воспользовавшись
одной из следующих функций языка DAX:
AVERAGE: возвращает среднее значение по столбцу с числами;
AVERAGEX: рассчитывает среднее значение по выражениям, вычислен-
ным в таблице.
Примечание В DAX есть еще одна функция для расчета средних значений - AVERAGEA,
она возвращает среднее по числовым значениям из текстового столбца. Но вам не сле-
дует ее использовать. Функция AVERAGEA присутствует в DAX только для совместимости
с Excel. Проблема с этой функцией заключается в том, что когда вы используете в качестве
ее параметра текстовый столбец, DAX даже не пытается преобразовать текстовые значе-
ния в числа, как это делает Excel. Вместо этого он будет выдавать нули.Так что эта функция
является абсолютно бесполезной. Функция AVERAGE в такой ситуации вернет ошибку, де-
монстрируя невозможность вычисления средних значений по текстовым данным.
Ранее в данной главе мы уже рассказывали про расчет средних значений по
таблице. В этом разделе мы пойдем чуть дальше и рассмотрим метод расчета
скользящего среднего. Допустим, вам необходимо проанализировать дневные
продажи в базе данных Contoso. Если просто построить график по дневным
продажам за период, понять по нему что-то будет сложно из-за больших от-
клонений, как видно по рис. 7.7.
Чтобы сгладить линию графика, обычно используется техника расчета сред-
них значений за определенное количество дней раньше текущего. В нашем
примере мы будем вычислять среднее по 30 последним дням. Таким образом,
в каждой точке на графике будет показано усредненное значение по продажам
за предыдущие 30 дней. Этот метод поможет убрать пики с графика и облегчит
понимание тренда.
Приведем формулу расчета среднего за последние 30 дней:
AvgXSales30 :=
VAR LastVisibleDate = MAX ( 'Date'[Date] )
VAR NumberOfDays = 30
VAR PeriodTollse =
232 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
FILTER (
ALL ( 'Date' );
AND (
'Date'[Date] > LastVisibleDate - NumberOfDays;
'Date'[Date] <= LastVisibleDate
)
)
VAR Result =
CALCULATE (
AVERAGEX ( 'Date'; [Sales Amount] );
PeriodToUse
)
RETURN
Result
Сначала в формуле определяется последний видимый день. Поскольку кон-
текст фильтра в визуализации установлен на уровне даты, мы получим выбран-
ную дату. После этого мы определяем 30-дневный набор дат ранее последней
даты. На заключительном шаге мы используем полученный период в качестве
фильтра функции CALCULATE, чтобы вложенная в нее функция AVERAGEX вы-
числяла среднее значение за эти даты.
Результат данного вычисления показан на рис. 7.8. Как видите, линия на гра-
фике оказалась куда более плавной по сравнению с дневным графиком, что
позволяет анализировать тенденции по продажам.
Когда пользователь полагается на функции вычисления средних значений
вроде AVERAGEX, нужно с большой осторожностью относиться к полученному
результату. Фактически при расчете среднего DAX игнорирует пустые значе-
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 233
ния. Если в какой-то день из выбранного периода продаж не было, этот день не
будет учтен вовсе при расчете среднего. Об этой особенности нужно помнить.
Функция AVERAGEX не подразумевает использование нуля в случае отсутству-
ющего значения. И такое поведение может быть нежелательным при расчете
средних показателей по дням.
Рис. 7.8 Скользящее среднее за последние 30 дней дает более плавный график
Если вам необходимо дни с отсутствующими продажами учитывать как
нулевые, то лучше будет использовать обычную функцию деления вместо
AVERAGEX. Кроме того, этот метод будет быстрее, поскольку преобразование
контекста, возникающее в случае использования функции AVERAGEX, требу-
ет больше памяти и времени для выполнения. Посмотрите на такой вариант
вычисления скользящего среднего, в котором единственное изменение было
сделано внутри функции CALCULATE:
AvgXSales30 :=
VAR LastVisibleDate = MAX ( 'Date'[Date] )
VAR NumberOfDays = 30
VAR PeriodToUse =
FILTER (
ALL ( 'Date' );
AND (
'Date'[Date] > LastVisibleDate - NumberOfDays;
'Date'[Date] <= LastVisibleDate
)
)
VAR Result =
CALCULATE (
234 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
DIVIDE ( [Sales Amount], COUNTROWS ( 'Date* ) );
PeriodToUse
)
RETURN
Result
Здесь мы не пользуемся функцией AVERAGEX для подсчета средних значе-
ний, а значит, дни с отсутствием продаж будут учитываться как нулевые. Это
изменение отразится на графике, но совсем незначительно. К тому же значе-
ния на новом графике могут быть меньше предыдущих, но не больше, посколь-
ку знаменатель в формуле будет время от времени превышать предыдущие
значения, что видно по рис. 7.9.
Как часто бывает в бизнес-аналитике, в данном случае нельзя одно из реше-
ний считать однозначно лучше другого. Все зависит от требований к отчетно-
сти. DAX предлагает самые разные способы достижения результата, и лишь вам
решать, каким из них воспользоваться. Например, в новой мере использова-
ние функции COUNTROWS позволило учитывать дни без продаж как нулевые,
но в то же время в учет попали выходные и праздничные дни. Правильно ли
это - зависит от ваших требований к отчетности, и при необходимости вы мо-
жете легко переписать меру, чтобы она учитывала специфику бизнеса.
I Дгг -iji-.l Аг*. п «nd А. ХО I» Di 1с
•$4к Amount *ММ^> •AvgidwMi
Рис. 7.9 Разные методы расчета скользящего среднего привели к разным результатам
Использование функции RANKX
Функция RANKX используется для вычисления ранга элементов согласно ука-
занному типу сортировки. Типичным примером применения функции RANKX
является ранжирование товаров или покупателей на основании объемов про-
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 235
даж. RANKX принимает несколько параметров, но в большинстве случаев ис-
пользуется только два из них. Остальные параметры необязательные и при-
меняются довольно редко.
Представьте, что вам необходимо построить отчет по категориям товаров
с ранжированием по объему продаж, показанный на рис. 7.10.
Category
Sales Amount Rank Cat on Sales
Audio 384,518.16 6
Cameras and camcorders 7,192,581.95 2
Cell phones 1,604,610.26 5
Computers 6,741,548.73 3
Games and Toys 360,652.81 7
Home Appliances 9,600,457.04 1
Music, Movies and Audio Books 314,206.74 8
TV and Video 4,392,768.29 4
Total 30,591,343.98 1
Рис. 7.10 Мера Rank Cot on Soles показывает ранг категории,
исходя из объема продаж
В этом сценарии можно использовать функцию RANKX. Эта функция отно-
сится к разряду итерационных и является предельно простой в применении.
В то же время ее использование может быть сопряжено с определенными труд-
ностями, которые требуют более детального пояснения.
Код меры Rank Cat on Sales представлен ниже:
Rank Cat on Sales :=
RANKX (
ALL ( 'Product'[Category] );
[Sales Amount]
)
Функция RANKX выполняется в три этапа.
1. Функция RANKX создает таблицу поиска (lookup table) в процессе скани-
рования исходной таблицы, переданной в качестве первого параметра.
Во время итераций происходит вычисление выражения из второго па-
раметра в контексте строки итерации. После создания таблицы поиска
выполняется ее сортировка.
2. Функция RANKX вычисляет выражение, переданное вторым парамет-
ром, в исходном контексте вычисления.
3. Функция RANKX возвращает позицию значения, вычисленного на вто-
ром шаге, в отсортированной таблице поиска.
Алгоритм работы функции показан на рис. 7.11 на примере ранжирования
категории «Cameras and camcorders» по мере Sales Amount.
Теперь рассмотрим схему работы функции RANKX в показанном выше при-
мере подробно:
в процессе итераций по исходной таблице строится таблица поиска.
В данном случае мы использовали табличное выражение ALL ( 'Product'
[Category] ), чтобы проигнорировать текущий контекст фильтра. Ина-
236 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
че таблица поиска состояла бы только из одной строки с текущей кате-
горией;
значение меры Sales Amount отличается для каждой категории по при-
чине преобразования контекста. При использовании итерационной
функции образуется контекст строки. А поскольку выражением для вы-
числения является мера, неявным образом содержащая в себе функцию
CALCULATE, DAX запустит преобразование контекста и рассчитает меру
Sales Amount только для одной текущей категории;
в таблице поиска будут содержаться исключительно значения выраже-
ния. Ссылки на категории здесь не нужны, поскольку ранжирование вы-
полняется только по значениям, если таблица отсортирована правильно;
рассчитывается значение меры Sales Amount за пределами итераций -
в исходном контексте вычисления. Изначально контекст фильтра вклю-
чал в себя только категорию Cameras and camcorders. Таким образом, на
этом шаге будет вычислено значение меры по этой категории товаров;
значение 2 является результатом поиска рассчитанного значения меры
в отсортированной таблице поиска.
Шаг 1 Шаг 2
Таблица Значение
поиска
Category Sales Amount Pank Cat on Sales 9.600,457 34 7,192 581 95
Audio 384,518 6 К 7 192 Sfii
Cameras and ra recorders 7,192 581 95 l.JJ
Cell phones 1,604.610-26 5 r 6.741,548,73
Computers 6.741 548 73 3 4,392,76829
Gamas and Toys 36Q 652.81 7 1,604,610.26 ШагЗ
Home Appliances 9,60Q 457 04 1 334 51816 Позиция
Music ies and Audio Books 314,20674 8 360,65231 Результат:
TV and Video 4.392J( 8 29 4 314,206.74
Total 30,591,343 98 1
Рис. 7.11 Функция RANKX определяет ранг категории Cameras and camcorders в три этапа
Вы могли заметить, что в итоговой строке функция RANKX выдала значе-
ние 1. Это значение не имеет никакого смысла, поскольку операция ранжи-
рования не подразумевает никакой агрегации итогов. Несмотря на это, в ито-
говой строке был проведен такой же анализ, как и в остальных строках, но
результат никого не интересует. На рис. 7.12 изображен процесс расчета значе-
ния ранга для итогов.
Значение, полученное на втором шаге, составляет общую сумму продажи,
которая будет всегда больше, чем аналогичные показатели по отдельным ка-
тегориям товаров. Так что единичка в итоговой строке - никакая не ошибка,
а, скорее, особенность поведения функции RANKX, вычисляющая значение, не
имеющее никакого смысла на уровне итогов. Правильно будет скрывать эти
значения силами языка DAX. По сути, операция ранжирования категорий име-
ет смысл только в том случае, если в текущем контексте фильтра выбрана одна
категория. Так что здесь мы можем воспользоваться функцией HASONEVALUE,
чтобы обеспечить вычисление меры лишь там, где это необходимо:
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 237
Rank Cat on Sales :=
IF (
HASONEVALUE ( 'Product'[Category] );
RANKX (
ALL ( 'Product'[Category] );
[Sales Amount]
)
)
Sales Amount Rank Cat on Sales
Category
Audio 364 51b. 16
Cameras and camcorders 7.192 58’ 95
Cell phones 1,604,61026
Computers 6,741.543.73
Carnes and Toys 363,6526’
Home App. tances 9,600,45704
Musk Moves ardl Audio Books 314.206.74
iv ard v.deu 4,392,76829
Total 30591,343 93
Шаг1
Таблица
поиска
9,600.457 04
7,192.581.95
6 741,548 73
4,392,768.29
1,604,610.26
384 51816
360 652 81
314 20674
Шаг 2
Значение
30,591343.98
ШагЗ
Позиция
Результат:
Рис. 7.12 В итоговой строке ранг всегда будет равен единице,
если таблица поиска отсортирована по убыванию
Эта мера вернет пустые значения для строк с множественным выбором
категорий в текущем контексте фильтра, а значит, выведет пустоту в итогах.
Когда вы используете функцию RANKX, а в общем случае любую меру, завися-
щую от специфики текущего контекста фильтра, то всегда должны снабжать
ее условием, чтобы расчеты проводились только для нужных ячеек, а во всех
остальных случаях выводили пустые значения или ошибки. Именно это и по-
казано в предыдущем примере.
Как мы упоминали ранее, функция RANKX может принимать еще несколько
параметров, помимо двух обязательных. Таких параметров может быть три:
третьим параметром является значение, которое может оказаться полез-
ным в случае, если вам необходимо использовать разные выражения для
таблицы поиска и ранжирования;
четвертым параметром можно передать способ сортировки таблицы по-
иска. Двумя допустимыми значениями этого параметра могут быть ASC
и DESC. Значением по умолчанию является DESC, при котором столбец
сортируется по убыванию, а минимальное значение ранга будет соответ-
ствовать максимальному числу;
пятый параметр определяет метод расчета ранга в случае равенства зна-
чений. Вы можете указать два значения этого параметра: DENSE или SKIP.
Если передать DENSE, одинаковые значения будут удалены из таблицы
поиска. В противном случае они сохранятся.
Давайте рассмотрим эти параметры на примерах.
Третий параметр функции RANKX можно использовать в случаях, когда для
формирования значений в таблице поиска и собственно ранжирования ис-
238 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
пользуются разные выражения. Представьте, что нам необходимо провести
ранжирование с использованием следующей таблицы, представленной на
рис. 7.13.
Sales
О
100,000
500,000
1,000,000 2,000,000 5,000,000 10,000,000 Рис. 7.13 Вместо динамической таблицы поиска всегда можно использовать статическую
Если вы хотите использовать эту таблицу в качестве таблицы поиска, то для
ее построения нужно использовать значение, отличное от меры Sales Amount.
Тут вам и пригодится третий параметр функции RANKX. Таким образом, чтобы
осуществить ранжирование по определенной таблице поиска - в нашем случае
это Sales Ranking, - следует использовать приведенную ниже меру:
Rank On Fixed Table :=
RANKX (
'Sales Ranking';
'Sales Ranking'[Sales];
[Sales Amount]
)
Таблицапоискабудетпостроенапутемрасчетазначения 'Sales Ranking' [Sales]
в контексте строки таблицы Sales Ranking. А когда таблица поиска будет готова,
функция RANKX приступит к расчету меры [Sales Amount] в исходном контекс-
те вычисления.
Результат ранжирования показан на рис. 7.14.
Category ж Sales Amount Rank On Fixed Table
Audio 384,518.16 6
Cameras and camcorders 7,192,581.95 2
Cell phones 1,604,610.26 4
Computers 6,741,548.73 2
Games and Toys 360,652.81 6
Home Appliances 9,600,457.04 2
Music, Movies and Audio Books 314,206.74 6
TV and Video 4,392,768.29 3
Total 30,591,343.98 1
Рис. 7.14 Ранжирование с использованием меры Soles Amount
по фиксированной таблице поиска Soles Ranking
Весь процесс ранжирования изображен на рис. 7.15, где также видно, что таб-
лица поиска сортируется перед использованием.
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 239
Шаг 1 Таблица поиска Шаг 2 Значение
Category Sales Amount Rank On cixed Table
А Audiс 384518.16 6 10,000,000 6741 543.73
Cameras and camcorders 7,192 581 95 2 5,000,000
Cell phones 1,b04 610.26 4
Computers 6,741 54873 2 2,000,000
Games and Toys 380,65? 81 6 1,000,000
Home Appliances 9,600 4 57.04 2 Шаг 3
Music Movies and Au J о Bocks 314,20674 6 500,000 Позиция
tv and Video 4,392 768.29 3 *00,000 Результат:
Total 30 591343.98 1
0
Рис. 7.15 При использовании фиксированной таблицы поиска выражение,
применяемое для построения таблицы поиска, отличается от использованного на шаге 2
Четвертый параметр функции может принимать значение ASC или DESC
и влияет на тип сортировки таблицы поиска. По умолчанию используется зна-
чение DESC, означающее, что чем выше значение, тем ниже ранг. При приме-
нении значения ASC более низким значениям будут соответствовать низкие
ранги из-за сортировки таблицы поиска по возрастанию.
Пятый параметр будет полезен при наличии одинаковых значений. Чтобы
продемонстрировать этот случай, мы используем другую меру - Rounded Sales.
В этой мере значения округляются до ближайшего числа, кратного миллиону.
А в срезах мы используем бренды:
Rounded Sales := MROUND ( [Sales Amount]; 1000000 )
Затем определим две меры для ранжирования: одну используем для опреде-
ления ранга по умолчанию (со значением SKIP), а вторую для альтернативного
ранга (со значением DENSE):
Rank On Rounded Sales :=
RANKX (
ALL ( 'Product'[Brand] );
[Rounded Sales]
)
Rank On Rounded Sales Dense :=
RANKX (
ALL ( 'Product'[Brand] );
[Rounded Sales];
DENSE
)
Результаты вычисления двух мер будут отличаться. Мера по умолчанию бу-
дет подсчитывать количество одинаковых рангов, и для следующего отлича-
ющегося значения будет использоваться ранг с определенным шагом. В мере
с использованием DENSE в качестве последнего параметра функции RANKX
ранги будут расти вне зависимости от количества повторений. Результат вы-
числения обеих мер показан на рис. 7.16.
240 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
Brand
Rounded Sales Rank On Rounded Sales Rank On Rounded Sales Dense
Contoso 7,000,000.00 1 1
Fabrikam 6,000,000.00 2 2
Adventure Works 4,000,000.00 3 3
Litware 3,000,000.00 4 4
Proseware 3,000,000.00 4 4
A. Datum 2,000,000.00 6 5
Wide World Importers 2,000,000.00 6 5
Northwind Traders 1,000,000.00 8 6
Southridge Video 1,000,000.00 8 6
The Phone Company 1,000,000.00 8 6
Tailspin Toys 0.00 11 7
Рис. 7.16 Использование значений DENSE и SKIP приводит к разным результатам
в присутствии одинаковых значений в таблице поиска
По сути, применение значения DENSE выполняет операцию DISTINCT при-
менительно к таблице поиска перед ее использованием. SKIP этого не делает,
используя таблицу поиска в том виде, в котором она была построена изна-
чально.
Применяя функцию RANKX, важно уделять особое внимание ее первому
параметру для получения желаемого результата. В предыдущих примерах мы
указывали в качестве первого параметра выражение ALL ( Product [Brand] ),
поскольку в наши планы входило ранжирование по всем брендам. Для кратко-
сти мы не использовали условие с функцией HASONEVALUE. В своих запросах
вы никогда не должны их пропускать, иначе рискуете получить неожиданные
результаты. Например, следующая мера будет выдавать ошибочные значения,
если в отчете не будет использоваться срез по брендам:
Rank On Sales :=
RANKX (
ALL ( 'Product'[Brand] );
[Sales Amount]
)
На рис. 7.17 мы выполнили срез по цветам товаров, и результат везде ока-
зался равен единице.
Color Sales Amount Rank On Sales
Azure 97,389.89 1
Black 5,860,066.14 1
Blue 2,435,444.62 1
Brown 1,029,508.95 1
Gold 361,496.01 1
Green 1,403,184.38 1
Grey 3,509,138.09 1
Orange 857,320.28 1 Рис. 7.17 Ранжирование по брендам
Pink 828,638.54 1 даст неожиданные результаты
Purple 5,973.84 1 в отчете со срезом по цвету товаров
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 241
Причина в том, что в таблице поиска окажутся продажи со срезом по брен-
ду и цвету, тогда как значения для поиска будут включать только цвета. А по-
скольку продажи по определенному цвету всегда будут выше, чем любое зна-
чение из подгруппы по бренду, ранг всегда будет равен единице. Добавление
условия с использованием IF HASONEVALUE может помочь выводить пустые
значения для ранга, в случае если в текущем контексте вычисления есть что-то
еще, помимо одного бренда.
Наконец, функция RANKX часто используется совместно с ALLSELECTED.
Если пользователь выбрал определенный поднабор из общего количества брен-
дов, функция ALL может привести к образованию пропусков в ранжировании,
поскольку она возвращает все бренды вне зависимости от выбранных в срезе.
Сравните следующие две меры:
Rank On Selected Brands :=
RANKX (
ALLSELECTED ( 'Product*[Brand] );
[Sales Anount]
)
Rank On All Brands :=
RANKX (
ALL ( 'Product*[Brand] );
[Sales Amount]
)
На рис. 7.18 показан вывод этих мер в присутствии фильтра по определен-
ным брендам в срезе.
Brand
A. Datum Brand Sales Amount Rank On All Brands Rank On Selected Brands
Adventure Works A.
Contoso Contoso 7,352,399.03 1 1
Fabrikam Fabrikam 5,554,015.73 2 2
Litware Adventure Works 4,011,112.28 3 3
Northwind Traders Proseware 2,546,144.16 5 4
Proseware A. Datum 2,096,184.64 6 5
Southridge Video Southridge Video 1,384,413.85 8 6
Tailspin Toys Northwind Traders 1,040,552.13 10 7
The Phone Company Tailspin Toys 325,042.42 11 8
Wide World Importers
Рис. 7.18 Использование функции ALLSELECTED уберет пропуски в рангах, получившиеся
в результате применения функции ALL
Использование функции RANK.EQ
Функция RANK.EQ в DAX соответствует аналогичной функции в Excel. Она возвращает
ранг значения в списке, предоставляя при этом часть функциональности RANKX. В DAX
вы будете использовать эту функцию редко, разве что для переноса формул из Excel.
Функция RANK.EQ имеет следующий синтаксис:
RANK.EQ ( <value>; <column> [; <order>] )
242 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
Параметр <value> может быть DAX-выражением для вычисления, a <column> представляет
существующий столбец, по которому будет производиться ранжирование. Третий пара-
метр необязательный и может принимать значение 0 для сортировки столбца по убыва-
нию и 1 - по возрастанию. В Excel вместо целого столбца в функцию может быть передан
диапазон ячеек. В DAX чаще всего в первый параметр будет передаваться тот же самый
столбец, чтобы провести ранжирование внутри него. Одним из сценариев, когда вам мо-
жет потребоваться использовать разные параметры, является наличие двух таблиц: одна
для значений, которые необходимо ранжировать, например конкретная группа товаров,
вторая - для полного набора элементов, к примеру список всех товаров. Однако из-за
ограничений, наложенных на параметр <column> (в частности, вы не можете использо-
вать в нем выражения или любые табличные функции, включая ADDCOLUMNS и SELECT-
COLUMNS), функция RANK.EQ обычно применяется в вычисляемом столбце с передачей
одной и той же колонки из этой же таблицы в качестве параметров, как показано ниже:
Product[Price Rank] =
RANK.EQ ( Product[Unit Price]; Product[Unit Price] )
Функция RANKX намного более мощная по сравнению с RANK.EO.Tax что, изучив по-
следнюю, вам вряд ли захочется тратить много времени на освоение ее менее эффек-
тивного аналога.
Изменение гранулярности вычисления
Существует ряд сценариев, в которых формулы не могут быть вычислены на
уровне итогов. Вместо этого значения должны вычисляться на более высоких
уровнях и затем агрегироваться.
Представьте, что вам необходимо подсчитать сумму продаж в расчете на
каждый рабочий день. Количество рабочих дней в каждом месяце разное из-
за наличия выходных и праздничных дней. Для простоты в этом примере мы
не будем учитывать праздники, а возьмем только субботу и воскресенье в ка-
честве нерабочих дней. В реальных примерах необходимо также принимать
в расчет праздничные дни.
В таблице Date у нас есть столбец с названием IsWorkingDay, в котором со-
держатся 1 или 0 в зависимости от того, рабочий это день или нет. Такие флаги
удобно хранить в качестве целочисленных значений, поскольку это упрощает
подсчет рабочих или праздничных дней. Следующие две меры вычисляют об-
щее количество дней и количество рабочих дней в рамках текущего контекста
фильтра:
NumOfDays := COUNTROWS ( 'Date' )
NumOfWorkingDays := SUM ( 'Date'[IsWorkingDay] )
На рис. 7.19 представлен отчет с этими двумя мерами.
Основываясь на этих мерах, можно вычислить сумму продаж в расчете на
один рабочий день. Это значение очень полезно для вычисления показателя
эффективности для каждого месяца с учетом валовых продаж и количества
дней, в которые эти продажи совершались. Предполагаемая формула пред-
ставляется довольно простой, но она скрывает в себе определенные трудности,
которые мы решим при помощи итерационных функций. Как мы уже не раз
делали в данной книге, мы будем приближаться к правильному расчету шаг за
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 243
шагом, параллельно указывая на ошибки. Цель этого примера состоит не в том,
чтобы предоставить вам готовый шаблон решения. Мы вместе допустим ошиб-
ки, типичные для подобных задач, и вместе же их исправим.
Calendar Year SalesAmount NumOfDays NumOfWorkingDays
CY 2007 4,694,127.73 365 261
January 31 23
February 28 20
March 31 22
April 30 21
May 31 23
June 30 21
July 31 22
August 952,834.59 31 23
September 1,009,868.98 30 20
October 914,273.54 31 23
November 825,601.87 30 22
December 991,548.75 31 21
CY 2008 9,927,582.99 366 262
January 656,766.69 31 23
February 600,080.00 29 21
Total 20,844,079.45 1,096 784
Рис. 7.19 Число рабочих дней отличается в каждом месяце
в зависимости от количества суббот и воскресений
Как и можно было ожидать, простая операция деления меры Sales Amount на
количество рабочих дней даст правильный результат только на уровне месяца.
Любопытно, что в итоговой строке значение оказалось меньше, чем даже в лю-
бом отдельно взятом месяце:
SalesPerWorkingDay := DIVIDE ( [Sales Amount]; [NumOfWorkingDays] )
На рис. 7.20 вы можете видеть результат вычисления этой меры.
Если посмотреть на итоговое значение по 2007 году, можно обнаружить чис-
ло 17 985,16. Это довольно мало с учетом того, что в каждом месяце этого года
продажи превышали отметку в 37 000,00. Причина в том, что общее количество
рабочих дней в 2017 году составляло 261, включая месяцы, когда продаж не
было. В нашей модели данных продажи стартовали только в августе 2007-го,
и было бы неправильно учитывать в расчете средних значений те месяцы, ког-
да продажи не велись. Та же проблема проявится и в периоде, содержащем по-
следнюю дату с заполненной информацией. Например, в текущем году общее
количество рабочих дней затронет и будущие месяцы.
Есть несколько способов исправить формулу. Мы выберем самый простой из
них: если в месяце не было продаж, мы не будем учитывать его при подсчете
количества дней.
Поскольку вычисление производится помесячно, нам необходимо в форму-
ле проходить по месяцам и проверять, были ли в этом месяце продажи. Если да,
то рабочие дни этого месяца будут учитываться в общем количестве рабочих
244 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
дней. В противном случае этот месяц просто пропускается. Функция SUMX по-
может нам реализовать данный алгоритм:
SalesPerWorkingDay :=
VAR WorkingDays =
SUMX (
VALUES ( 'Date'[Month] );
IF (
[Sales Amount] > 0;
[NumOfWorkingDays]
)
)
VAR Result =
DIVIDE (
[Sales Amount];
WorkingDays
)
RETURN
Result
Calendar Year SalesAmount NumOfDays NumOfWorkingDays SalesPerWorkingDay
У 2007 4,694,127.73 365 261 17,985.16
January 31 23
February 28 20
March 31 22
April 30 21
May 31 23
June 30 21
July 31 22
August 952,834.59 31 23 41,427.59
September 1,009,868.98 30 20 50,493.45
October 914,273.54 31 23 39,751.02
November 825,601.87 30 22 37,527.36
December 991,548.75 31 21 47,216.61
У 2008 9,927,582.99 366 262 37,891.54
January 656,766.69 31 23 28,555.07
February 600,080.00 29 21 28,575.24
'otal 20,844,079.45 1,096 784 26,586.84
Рис. 7.20 По месяцам значения правильные, но к итогам есть большие вопросы
Новая мера позволила нам выправить значения на уровне годов, как видно
по рис. 7.21, но она по-прежнему далека от идеала.
Выполняя вычисления на разных уровнях гранулярности, необходимо обес-
печить их правильность. Функция проходит по столбцу с месяцами с января
по декабрь. На уровне годов все теперь считается правильно, но с итоговым
значением остались проблемы, как видно по рис. 7.22.
Когда в контексте фильтра присутствует год, итерации по месяцам работа-
ют правильно, поскольку после преобразования контекста в новом контексте
фильтра оказывается как месяц, так и год. Однако в итоговой строке год в кон-
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 245
тексте фильтра отсутствует. Соответственно, в фильтре остается только месяц,
и формула вычисляется не по этому месяцу в рамках текущего года, а по этому
месяцу в рамках всех лет.
Calendar Year Sales Amount NumOfDays NumOfWorkingDays SalesPerWorkingDay
IY2007 4,694,127.73 365 261 43,065.39
January 31 23
February 28 20
March 31 22
April 30 21
May 31 23
June 30 21
July 31 22
August 952,834.59 31 23 41,427.59
September 1,009,868.98 30 20 50,493.45
October 914,273.54 31 23 39,751.02
November 825,601.87 30 22 37,527.36
December 991,548.75 31 21 47,216.61
IY 2008 9,927,582.99 366 262 37,891.54
January 656,766.69 31 23 28,555.07
February 600,080.00 29 21 28,575.24
'otal 20,844,079.45 1,096 784 26,586.84
Рис. 7.21 Использование итерационной функции позволило скорректировать
значения продаж на уровне годов
Calendar Year Sales Amount NumOfDays NumOfWorkingDays SalesPerWorkingDay
CY 2007 4,694,127.73 365 261 43,065.39
CY 2008 9,927,582.99 366 262 37,891.54
CY 2009 6,222,368.73 365 261 35,967.45
Total 20,844,079.45 1,096 784 26,586.84
Рис. 7.22 По каждому году значения превышают 35 000,
а в строке общего итога мы видим сильно заниженное число
Иными словами, проблема заключается в том, что мы осуществляем ите-
рации только по столбцу с месяцами. Правильной гранулярностью в итера-
ции будет не месяц, а месяц вместе с годом. И лучшим вариантом здесь будет
создание столбца, в котором будет храниться месяц с годом. В нашей модели
данных такой столбец есть, и он называется Calendar Year Month. Таким об-
разом, чтобы исправить формулу, достаточно изменить столбец для итераций
следующим образом:
SalesPerWorkingDay :=
VAR WorkingDays =
SUMX (
VALUES ( 'Date*[Calendar Year Month] );
IF (
[Sales Amount] > 0;
246 ГЛАВА 7 Работа с итераторами и функцией CALCULATE
[NumOfWorkingDays]
)
)
VAR Result =
DIVIDE (
[Sales Amount];
WorkingDays
)
RETURN
Result
Финальная версия кода работает правильно, поскольку считает значение
для итогов на правильном уровне гранулярности. Результат можно видеть на
рис. 7.23.
Calendar Year Sales Amount NumOfDays NumOfWorkingDays SalesPerWorkingDay
CY 2007 4,694,127.73 365 261 43,065.39
CY 2008 9,927,582.99 366 262 37,891.54
CY 2009 6,222,368.73 365 261 35,967.45
Total 20,844,079.45 1,096 784 38,316.32
Рис. 7.23 Вычисление формулы на правильном уровне гранулярности
позволило привести в порядок итоговые значения
Заключение
Как обычно, подведем итоги того, что мы узнали из этой главы:
итерационные функции являются важнейшей составляющей DAX, и чем
больше вы будете использовать этот язык, тем чаще вам придется с ними
сталкиваться;
в DAX присутствуют два вида итераций: первый из них применяется для
простых последовательных вычислений строка за строкой, а второй ис-
пользует технику преобразования контекста. В мере Sales Amount, кото-
рую мы часто применяем в данной книге, происходит построчное пере-
множение количества на цену. В этой главе мы также познакомились
с итераторами, использующими преобразование контекста. Они пред-
ставляют собой очень мощный инструмент для проведения более слож-
ных вычислений;
используя итерационные функции совместно с преобразованием кон-
текста, необходимо тщательно следить за их кратностью - она должна
быть достаточно мала. Кроме того, нужно постараться гарантировать
уникальность строк в таблице, по которой осуществляются итерации.
В противном случае вы рискуете, что код будет выполняться медленно
и с ошибками;
работая со средними значениями в отношении дат, дважды подумайте
о том, подходят ли для ваших целей итерационные функции. Например,
ГЛАВА 7 Работа с итераторами и функцией CALCULATE 247
функция AVERAGEX не учитывает в процессе вычисления пустые значе-
ния, а при работе с датами это может оказаться не всегда верно. Так что
тщательно продумывайте свой сценарий - каждый случай уникален;
итераторы могут оказаться полезными при расчете значений на разных
уровнях гранулярности, как вы видели в последнем примере. Работая
с разными гранулярностями, очень важно проверять все расчеты, чтобы
они выполнялись на правильном уровне.
В оставшейся части книги вы еще не раз встретитесь с итерационными
функциями. Уже в следующей главе, в которой мы будем говорить про логику
операций со временем, вы увидите множество расчетов, большинство из кото-
рых основывается на итерациях.
ГЛАВА 8
Логика операций со временем
Практически в каждой модели данных так или иначе будет присутствовать ло-
гика операций со временем. DAX предлагает множество функций для упроще-
ния таких расчетов, и вы можете использовать их с пользой, если ваша модель
данных удовлетворяет определенным требованиям. Если же у вас очень специ-
фическая модель в отношении работы с датами и временем, вы всегда можете
написать свои функции, отвечающие особенностям вашего бизнеса.
Из этой главы вы узнаете, как средствами DAX реализовать распространен-
ные приемы работы со временем, среди которых расчет сумм нарастающим
итогом с начала года, сравнение сопоставимых периодов разных лет и другие
вычисления, в том числе опирающиеся на неаддитивные (non-additive) и по-
луаддитивные (semi-additive) меры. Вы научитесь использовать специальные
функции DAX для работы со временем, а также познакомитесь со специфич-
ными методами для создания нестандартных календарей и расчетов на основе
недель.
Введение в логику операций со временем
Обычно в любой модели данных присутствует таблица с датами или кален-
дарь. Фактически, осуществляя срезы в отчетах по году или месяцу, лучше все-
го пользоваться столбцами из таблицы, специально предназначенной для ра-
боты с датами и временем. Использовать для этих целей вычисляемые столбцы
с извлеченными частями дат из полей типа Date или DateTime - менее предпо-
чтительный вариант.
Этот выбор обусловлен сразу несколькими причинами. Использование таб-
лицы с датами делает модель более простой и понятной для навигации. Кроме
того, у вас появляется возможность пользоваться специальными функциями
DAX для работы с логикой операций со временем. По сути, для корректной ра-
боты большинству подобных функций DAX требуется наличие отдельной таб-
лицы с датами.
В случае если в модели данных присутствует сразу несколько полей с дата-
ми, например если есть даты заказа и даты поставки, у вас есть выбор: либо
поддерживать несколько связей с единой таблицей дат, либо создать несколько
календарей. Модели данных в обоих вариантах будут отличаться, как и сами
расчеты. Позже в данной главе мы поговорим про этот нелегкий выбор более
подробно.
Так или иначе, если в вашей модели присутствуют столбцы с датами, без соз-
дания как минимум одной таблицы дат вам будет не обойтись. Power BI и Power
ГЛАВА 8 Логика операций со временем 249
Pivot для Excel предлагают свои возможности для автоматического создания
таблиц и столбцов для работы с датами, тогда как в Analysis Services отсутствуют
специальные средства для работы с датами и временем. При этом стоит при-
знать, что реализация этих особенностей не лучшим образом сочетается с со-
держанием единой таблицы с датами в модели данных. Кроме того, эти средства
обладают рядом ограничений, так что лучше создавать календари в модели са-
мостоятельно. В следующих разделах мы расскажем об этом подробнее.
Автоматические дата и время в Power Bl
В Power BI есть настройка автоматических даты и времени, располагающаяся
в секции Загрузка данных (Data Load) меню Параметры и настройки (Options).
Окно настроек показано на рис. 8.1.
Ootions
GLOBAL
Data t-oad
Power Query Editor
D -ectQjery
R script rig
Securrty
Pr.vacy
Regional Settings
Update*
Usege Data
D cgnostcs
Preview features
Auto recover}*
CURRENT FILE
Data Load
Region^ Settings
Pcvacy
Auto recover)'
Query reduction
Renert settings
Type Detection
s Automatically detect column tyoes and beaders for unstructured sources
Relationships
J Imoo't re ?t orsh.ps f'onr data sources ?
Update reat onsnips when 'efresning queries Q
J Autodetect new re atonships aftir data is loaded Q
Time intelligence
J Auto Date/Time
Background Data
Allow data preview to download in the background
Parallel loading of tab es
•v' Enable oarallel loading of ♦ao’es •
Natural language
Turn on nature1 language questions with Q&A
O«:
Carte'
Рис. 8.1 В новой модели данных пункт Автоматические дата и время
включен по умолчанию
Когда эта настройка включена (по умолчанию), Power BI автоматически соз-
дает отдельную таблицу для каждого столбца типа Date или DateTime в модели
данных. Здесь и далее мы будем называть такое поле столбцом с датой (date
column). Создание вспомогательных таблиц позволяет автоматически выпол-
нять фильтрацию в таких столбцах по году, кварталу, месяцу и дню. Подобные
таблицы невидимы для пользователя и недоступны для редактирования. При
250 ГЛАВА 8 Логика операций со временем
подключении к модели данных Power BI Desktop посредством DAX Studio эти
таблицы становятся видимыми для разработчиков.
У настройки автоматической даты и времени есть два существенных недо-
статка:
Power BI Desktop создает по отдельной таблице для каждого столбца с да-
той. Это приводит к образованию большого количества не связанных
между собой таблиц в модели. В связи с этим создание простого отчета
с выводом заказанных и проданных товаров в одной матрице становится
настоящим вызовом;
эти таблицы скрыты и не могут быть изменены разработчиком. Соответ-
ственно, можете даже не надеяться добавить в них, к примеру, день не-
дели.
Совсем скоро вы научитесь создавать собственные удобные таблицы дат, ко-
торые дадут вам полную свободу. К тому же это вопрос всего нескольких стро-
чек кода на DAX. Позволить модели данных нарушить правила хорошего тона
в моделировании только ради того, чтобы вы сэкономили пару минут на ее
создание, - не лучший выбор.
Автоматические столбцы с датами в Power Pivot для Excel
В Power Pivot для Excel также есть возможность автоматически создавать
структуры, облегчающие работу с датами. Но тут она реализована еще хуже,
чем в Power BI. Фактически, когда вы используете столбец с датами в сводной
таблице, Power Pivot создает набор вычисляемых столбцов в той же таблице.
Таким образом, в таблице сами собой появляются дополнительные столбцы
с годом, названием месяца, кварталом и номером месяца, необходимым для
сортировки. В сумме четыре новых столбца в таблице.
Плохо то, что здесь унаследованы все недостатки Power BI и добавлены свои.
Если в вашей таблице несколько столбцов с датами, количество вспомогатель-
ных колонок начнет неумолимо расти. Нет никакой возможности использо-
вать одни и те же поля для фильтрации разных дат, как в Power BI. И наконец,
если столбец с датой присутствует в таблице с миллионами строк, что часто
бывает, эти вычисляемые столбцы существенно увеличивают размер файла
модели и объем используемой ей памяти.
Эту особенность в Excel можно отключить на странице с настройками, как
показано на рис. 8.2.
Шаблон таблицы дат в Power Pivot для Excel
Excel предлагает еще один инструмент, который работает лучше, чем ранее
описанная особенность. Начиная с 2017 года в Power Pivot для Excel есть воз-
можность создания таблицы дат (date table) на панели инструментов Power
Pivot (на вкладке Конструктор), как показано на рис. 8.3.
Нажатие на пункт Создать (New) в раскрывающемся списке кнопки Табли-
ца дат (Date Table) приведет к созданию новой таблицы в модели данных
с набором вычисляемых столбцов, включающих год, месяц и день недели. Вам
останется только правильно настроить связи между таблицами. Также у вас
ГЛАВА 8 Логика операций со временем 251
есть возможность изменить названия и формулы вычисляемых столбцов и до-
бавить новые.
Рис. 8.2 В настройках Excel есть возможность отключить автоматическое группирование
столбцов даты и времени в сводных таблицах
Рис. 8.3 В Power Pivot для Excel можно создать таблицу дат прямо из панели инструментов
252 ГЛАВА 8 Логика операций со временем
Кроме того, вы можете сохранить существующую таблицу как шаблон, ко-
торый может быть использован в будущем при создании других таблиц дат.
В целом эта техника работает нормально. Таблица дат, созданная при помощи
Power Pivot, является обычной таблицей и отвечает всем требованиям к кален-
дарю. Учитывая тот факт, что Power Pivot для Excel не поддерживает вычисляе-
мые таблицы, можно назвать эту возможность крайне полезной.
Создание таблицы дат
Как вы уже знаете, первым шагом на пути создания вычислений с использо-
ванием дат является создание соответствующей таблицы с календарем. Это
очень важная таблица в модели данных, и к ее созданию необходимо подхо-
дить довольно тщательно. В данном разделе мы подробно обсудим все тонко-
сти создания таблицы дат. Двумя главными особенностями при работе с таки-
ми таблицами являются технический аспект и аспект моделирования данных.
С технической точки зрения таблицы дат должны отвечать следующим тре-
бованиям:
таблица дат обязана включать в себя все даты, входящие в аналитиче-
ский период. Например, если самой ранней датой в таблице Sales явля-
ется 3 июля 2016 года, а самой поздней - 27 июля 2019-го, диапазон дат
в календаре должен начинаться 1 января 2016 года и заканчиваться 31 де-
кабря 2019-го. Иными словами, в календаре должны полностью присут-
ствовать все годы, в которые осуществлялись продажи. При этом между
датами не должно быть пропусков - все без исключения даты должны
присутствовать в таблице вне зависимости от того, были транзакции
в этот день или нет;
таблица дат должна содержать один столбец типа DateTime с уникальны-
ми значениями. При этом тип Date наиболее предпочтителен, поскольку
гарантирует отсутствие хранения времени. Если столбец DateTime содер-
жит часть, отвечающую за время, то все эти части должны быть идентич-
ными во всей таблице;
совсем не обязательно, чтобы связь между таблицей Sales и календарем
была основана на поле с типом DateTime. Эти таблицы вполне могут быть
связаны по полю с целочисленным типом, но при этом столбец с типом
DateTime должен присутствовать;
календарь должен быть помечен в модели данных как таблица дат. И хотя
это не строго обязательно, так вам будет проще писать код. Мы погово-
рим об этой особенности далее в данной главе.
Важно Новички обычно склонны создавать огромную таблицу дат с гораздо большим
количеством лет, чем необходимо. Это ошибка. Например, можно заполнить календарь
всеми годами начиная с 1900-го по 2100-й - просто на всякий случай. Чисто технически
такая таблица дат работать будет, но к ее эффективности в вычислениях непременно
возникнут вопросы. Лучше, чтобы в календаре содержались только те годы, для которых
в модели существуют транзакции.
ГЛАВА 8 Логика операций со временем 253
С точки зрения теории достаточно, чтобы в таблице дат содержался всего
один столбец с этими самыми датами. Но пользователю обычно требуется ана-
лизировать данные по годам, месяцам, кварталам, дням недели и многим дру-
гим атрибутам. Соответственно, идеальная таблица дат должна быть дополне-
на вспомогательными столбцами, которые, хоть и не используются движком,
значительно облегчат жизнь пользователю.
Если вы загружаете календарь из существующего внешнего источника, впол-
не вероятно, что все необходимые столбцы там уже присутствуют. Если необ-
ходимо, дополнительные колонки можно создать при помощи вычисляемых
столбцов или подкорректировав запрос к источнику. Всегда более предпочти-
тельно поработать с внешним источником при помощи запросов, чем созда-
вать вычисляемые столбцы в модели. Их количество желательно ограничить до
предела. Еще одним способом является создание таблицы дат в виде вычисля-
емой таблицы в DAX. Мы подробно расскажем об этом варианте в следующих
разделах, когда будем говорить о функциях CALENDAR и CALENDARAUTO.
Примечание Слово Dote является зарезервированным в DAX для соответствующей
функции DATE.Так что вам необходимо заключать его в кавычки при использовании в ка-
честве названия таблицы, несмотря на то что оно не содержит пробелов и специальных
символов. Кроме того, вы можете использовать таблицу с названием Dotes вместо Dote,
чтобы избежать необходимости всегда помнить о кавычках. Но не стоит забывать о пре-
емственности в именовании объектов в модели данных. Если другие таблицы вы именуе-
те в единственном числе,то и с таблицей дат желательно придерживаться такого подхода.
\______________________________________________________________________________-
Использование функций CALENDAR и CALENDARAUTO
Если в вашем источнике данных отсутствует таблица дат, вы всегда можете
создать ее самостоятельно при помощи функций CALENDAR и CALENDARAU-
TO. Обе эти функции возвращают таблицу, состоящую из одного столбца типа
DateTime. И если функция CALENDAR требует задания нижней и верхней гра-
ниц предполагаемого интервала дат, то CALENDARAUTO просто сканирует все
столбцы с датами в модели данных, находит самую раннюю и самую позднюю
даты и заполняет таблицу на основании всех лет между этими значениями.
Например, простая таблица дат, учитывающая все возможные годы транзак-
ций из таблицы Sales, может быть построена следующим образом:
Date =
CALENDAR (
DATE ( YEAR ( MIN ( Sales[Order Date] ) ); 1; 1 );
DATE ( YEAR ( MAX ( Sales[Order Date] ) ); 12; 31 )
)
Чтобы таблица была заполнена всеми датами в интервале от начала января
до конца декабря, функция извлекает минимальное и максимальное значения
года из исходной таблицы и использует их в качестве ограничений календа-
ря с подстановкой соответствующего дня и месяца. Такой же результат может
быть получен при помощи функции CALENDARAUTO:
Date = CALENDARAUTO ( )
254 ГЛАВА 8 Логика операций со временем
Функция CALENDARAUTO сканирует все поля с датами в модели данных, за
исключением вычисляемых столбцов. Например, если вы используете функцию
CALENDARAUTO для создания таблицы Date в модели, в которой содержатся
продажи с 2007 по 2011 год, а в таблице Product есть также столбец AvailableFor-
SaleDate с самой ранней датой в 2004 году, результатом будет интервал с 1 янва-
ря 2004 года по 31 декабря 2011-го. Если в модели будут и другие столбцы типа
дата, они также окажут действие на интервал, генерируемый функцией CAL-
ENDARAUTO. Часто бывает, что в календаре оказываются даты, совершенно не
нужные для анализа. Например, если среди прочих дат в модели будет присут-
ствовать поле с датами рождения покупателей, функция CALENDARAUTO при
создании календаря будет учитывать годы рождения самого пожилого и самого
молодого покупателей. В результате мы получим очень объемную таблицу дат,
что может негативно сказаться на производительности вычислений.
Функция CALENDARAUTO также принимает необязательный параметр, от-
вечающий за номер последнего месяца финансового года. Если этот параметр
передан, функция при создании календаря будет вести отсчет с первого дня
следующего месяца и до последнего дня месяца, номер которого передан в ка-
честве параметра. Это бывает полезно, когда в организации финансовый год
заканчивается не 31 декабря, а, скажем, 30 июня, как показано в следующем
примере создания календаря:
Date = CALENDARAUTO ( 6 )
Функцию CALENDARAUTO использовать легче, чем CALENDAR, поскольку
она сама определяет границы календаря. Но при этом CALENDARAUTO может
включить в таблицу нежелательные даты. На этот случай есть возможность
ограничить даты, автоматически генерируемые этой функцией, при помощи
фильтра следующим образом:
Date =
VAR MinYear = YEAR ( MIN ( Sales[Order Date] ) )
VAR MaxYear = YEAR ( MAX ( Sales[Order Date] ) )
RETURN
FILTER (
CALENDARAUTO ( );
YEAR ( [Date] ) >= MinYear &&
YEAR ( [Date] ) <= MaxYear
)
Результирующая таблица будет содержать даты только из интервала табли-
цы продаж. При этом вычислять первый и последний день года совсем не обя-
зательно, функция CALENDARAUTO справится с этим сама.
После получения необходимого списка дат разработчику остается допол-
нить календарь необходимыми столбцами, применяя выражения на DAX. При-
ведем пример часто используемых столбцов для календарей с дальнейшим их
выводом, показанным на рис. 8.4:
Date =
VAR MinYear = YEAR ( MIN ( Sales[Order Date] ) )
VAR MaxYear = YEAR ( MAX ( Sales[Order Date] ) )
ГЛАВА 8 Логика операций co временем 255
RETURN
ADDCOLUMNS (
FILTER (
CALENDARAUTO ( );
YEAR ( [Date] ) >= MinYear &&
YEAR ( [Date] ) <= MaxYear
);
"Year"; YEAR ( [Date] );
"Quarter Number"; INT ( FORMAT ( [Date]; "q" ) );
"Quarter"; "Q" & INT ( FORMAT ( [Date]; "q" ) );
"Month Number"; MONTH ( [Date] );
"Month"; FORMAT ( [Date]; "mmmm" );
"Week Day Number"; WEEKDAY ( [Date] );
"Week Day"; FORMAT ( [Date]; "dddd" );
"Year Month Number"; YEAR ( [Date] ) * 100 + MONTH ( [Date] );
"Year Month"; FORMAT ( [Date]; "mmmm" ) & " " & YEAR ( [Date] );
"Year Quarter Number"; YEAR ( [Date] ) * 100 + INT ( FORMAT ( [Date]; "q" ) );
"Year Quarter"; "Q" & FORMAT ( [Date]; "q" )&"-"& YEAR ( [Date] )
Date Year Month Month Number Quarter Quarter Number Week Day Week Day Number Year Month Year Month Number
01/01/07 2007 January 1 Q1 1 Monday 2 January 2007 200701
01/02/07 2007 January 1 Q1 1 Tuesday 3 January 2007 200701
01/03/07 2007 January 1 Q1 1 Wednesday 4 January 2007 200701
01/04/07 2007 January 1 QI 1 Thursday 5 January 2007 200701
01/05/07 2007 January 1 Q1 1 Friday 6 January 2007 200701
01/06/07 2007 January 1 Q1 1 Saturday 7 January 2007 200701
01/07/07 2007 January 1 QI 1 Sunday 1 January 2007 200701
01/08/07 2007 January 1 Q1 1 Monday 2 January 2007 200701
01/09/07 2007 January 1 QI 1 Tuesday 3 January 2007 200701
01/10/07 2007 January 1 Q1 1 Wednesday 4 January 2007 200701
01/11/07 2007 January 1 QI 1 Thursday 5 January 2007 200701
01/12/07 2007 January 1 Q1 1 Friday 6 January 2007 200701
01/13/07 2007 January 1 Q1 1 Saturday 7 January 2007 200701
01/14/07 2007 January 1 Q1 1 Sunday 1 January 2007 200701
01/15/07 2007 January 1 Q1 1 Monday 2 January 2007 200701
01/16/07 2007 January 1 Q1 1 Tuesday 3 January 2007 200701
01/17/07 2007 January 1 QI 1 Wednesday 4 January 2007 200701
Рис. 8.4 При помощи функции ADDCOLUMNS можно создать таблицу дат в одном выражении
Такого же результата можно добиться, создавая вычисляемые столбцы пря-
мо в пользовательском интерфейсе. Главным преимуществом использования
функции ADDCOLUMNS является возможность повторного применения этого
кода в других проектах.
Использование шаблонов DAX для работы с датами
Представленный выше пример был приведен исключительно в образовательных це-
лях, и в нем были оставлены только самые важные столбцы, чтобы код можно было
разместить в книге. Но в интернете можно найти и другие шаблоны для работы с да-
тами. Например, мы создали свой шаблон для Power Bl и разместили его по адресу
https://www.sqlbi.com/tools/dax-date-template/. Вы также можете извлечь из этого
шаблона код на DAX и использовать его в своих проектах в Analysis Services.
256 ГЛАВА 8 Логика операций со временем
Работа со множественными датами
Если в вашей модели данных присутствует несколько столбцов с датами, вы
должны сделать выбор: либо оперировать множеством связей с единой табли-
цей дат, либо создать несколько календарей. Это очень важный выбор, от ко-
торого будут зависеть написание кода DAX в будущем и глубина возможного
анализа в вашей модели данных.
Представьте, что в таблице Sales у вас есть три поля с датами:
Order Date: дата оформления заказа;
Due Date: дата ожидаемой поставки товара;
Delivery Date: дата фактической поставки товара.
Разработчик может создать связи по всем трем столбцам к единой таблице
дат, подразумевая, что в любой момент времени активной может быть только
одна из них. А может создать три отдельных календаря, чтобы иметь возмож-
ность свободно осуществлять срезы по всем этим столбцам. К тому же вполне
вероятно, что другие таблицы также будут содержать столбцы с датами. Напри-
мер, в таблице Purchase могут присутствовать даты, связанные с закупками,
а в Budget - с составлением бюджетного плана. В конце концов, почти в любой
модели данных присутствует множество столбцов с датами, и только разработ-
чик модели способен понять, как лучше с ними обращаться.
В следующих разделах мы подробно поговорим о представленных вари-
антах и посмотрим, какое влияние этот выбор оказывает на написание кода
на DAX.
Поддержка множественных связей с таблицей дат
При моделировании данных существует возможность создания нескольких
связей между двумя таблицами. При этом в любой момент времени активной
может быть лишь одна из созданных связей, остальные остаются неактивны-
ми. Неактивные связи могут быть активированы в функции CALCULATE при
помощи модификатора USERELATIONSHIP, как мы показывали в главе S.
Рассмотрим модель данных, представленную на рис. 8.S. Между таблица-
ми Sales и Date создано две связи, но лишь одна из них может быть актив-
ной. На представленном примере активной является связь между столбцами
Sales[Order Date] и Date[Date].
Вы можете создать две меры по продажам, основывающиеся на разных свя-
зях с таблицей Date:
Ordered Amount :=
SUMX ( Sales; Sales[Net Price] * Sales[Quantity] )
Delivered Amount :=
CALCULATE (
SUMX ( Sales; Sales[Net Price] * Sales[Quantity] );
USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[Date] )
)
ГЛАВА 8 Логика операций co временем 257
Cl
77 Си mercy Key
П Customer Ke
Delwery Date
Г Net Price
Order Cate
Order L ne Number
Crder Number
□ Order DateKey
3 ProductKey
PromotionKey
C7 Quant ty
’ Store К
Date
Year Qua "let
Рис. 8.5 Активная связь соединяет столбцы Soles[Order Dote] и Dote[Dote]
Month
Mort*h Num be
Q-a'ter
Q-a'ter Nurrser
Week Day
7 Week Di1 Number
Year
' Year Month
Year Month Kurroer
В первой мере Ordered Amount используется активная связь между таблица-
ми Sales и Date, в основе которой лежит столбец Sales[Order Date]. Вторая мера
Delivered Amount использует то же выражение DAX, но при этом полагается
на связь по полю Sales [Delivery Date]. Модификатор USERELATIONSHIP меняет
активную связь между таблицами Sales и Date в контексте фильтра, определен-
ном функцией CALCULATE. В отчете, показанном на рис. 8.6, выведены обе эти
меры.
Year Ordered Amount Delivered Amount
2007 11,309,946.12 11,034,860.44
January 794,248.24 624,650.61
February 891,135.91 790,981.53
March 961,289.24 992,760.62
April 1,128,104.82 1,140,575.75
May 936,192.74 839,658.92
June 982,304.46 991,050.56
July 922,542.98 1,078,819.68
August 952,834.59 776,586.75
September 1,009,868.98 1,082,690.27
October 914,273.54 901,968.98
November 825,601.87 872,217.70
December 991,548.75 942,899.08
2008 9,927,582.99 9,901,407.94
Рис. 8.6 Значения в мерах Ordered Amount и Delivered Amount отличаются по месяцам,
поскольку дата поставки могла перескочить на следующий месяц
Использование множественных связей с таблицей дат увеличивает общее ко-
личество созданных мер в модели данных. Обычно в таком случае разработчик
создает конкретные меры для использования с определенными датами. Если
вы не хотите поддерживать большое количество мер в модели и желаете ис-
258 ГЛАВА 8 Логика операций со временем
пользовать любую созданную меру применительно к разным датам, то можете
прибегнуть к помощи групп вычислений, описываемых в следующих главах.
Поддержка нескольких таблиц дат
Вместо того чтобы дублировать меры, можно создавать копии таблиц дат в мо-
дели данных - по одной для каждой даты. Таким образом, мера будет вычис-
лять значение, исходя из выбранной пользователем даты в отчете. В плане
поддержки это решение может показаться более оптимальным, поскольку ве-
дет к уменьшению количества мер и позволяет, например, выбирать продажи,
пересекающиеся по двум месяцам. Но в то же время дублирование таблиц дат
усложняет использование модели данных в целом. Допустим, вы можете по-
строить отчет с общим количеством заказов, сформированных в январе, а до-
ставленных в феврале того же года. Но удобно отобразить эту информацию на
графике довольно затруднительно.
Такой способ организации данных также известен как работа с ролевыми
измерениями (role-playing dimension). Таблица дат представляет собой изме-
рение, которое дублируется для каждой связи, а значит, и для каждой роли.
В целом же два этих подхода (активирование связей и дублирование таблиц
дат) являются взаимодополняющими.
Чтобы создать таблицы Delivery Date и Order Date, вы должны создать две ко-
пии существующего календаря в модели данных, меняя при этом название. На
рис. 8.7 показана модель данных, содержащая несколько таблиц дат, объеди-
ненных связями с таблицей Sales.
Рис. 8.7 Каждая дата в таблице Soles связана со своей таблицей дат
El Delivery Date
Delivery Date
Delivery Month
Delivery Month Number
Delivery Quarter
Delivery Quarter Number
Delivery V/eek Day
Delivery Week Day Num-.
Delivery Year
Delivery Year Month
Sales
CuirencyKey
3 Си st о тег Key
1 Delivery Date
Met Price
n Order Date
□ Order Line Number
П Order Numbe'
OrderDateKey
□ ProduciKey
ЕЭ Order Date
□ Order Date
”1 Order Month
□ Order Month Number
Order Quarter
Г I Order Q-a^ter Number
Order Week Day
Г1 Order v'veelc Day Num be
Order Year
Order Year Month
Order Year Month Number
Важно При создании копии таблицы дат вы должны физически дублировать ее в моде-
ли данных. Таким образом, лучше всего будет создать разные представления для разных
ролевых измерений в источнике данных, чтобы столбцы в них назывались по-разному
и имели разное содержимое. Например, вместо того чтобы создавать столбец с именем
Year в каждом календаре, лучше будет назвать их Order Year и Delivery Year в таблице дат
формирования заказов и их поставки соответственно. В этом случае навигация по отче-
там существенно упростится. Это также видно по рис. 8.7. Более того, хорошей практикой
является хранение разного содержимого в столбцах. Например, вы можете добавлять
префикс к году в зависимости от роли таблицы: CY (Creation Year - Дата создания) для
содержимого столбца Order Year и DY - для Delivery Year (Дата поставки).
ГЛАВА 8 Логика операций со временем 259
На рис. 8.8 показана матрица с одновременным выводом дат из двух кален-
дарей. Такой отчет не может быть создан при выборе подхода с поддержкой
множественных связей с единой таблицей Date. Как видите, уникальные на-
звания столбцов и содержимое с конкретными префиксами помогает сделать
отчет более легким для восприятия. Во избежание неразберихи между датами
оформления заказов и их поставки мы используем префиксы СУ и DY соот-
ветственно.
Order Year DY 2007 DY 2008 DY 2009 DY 2010 Total
CY 2007 11,034,860.44 275,085.69 11,309,946.12
CY2008 9,626,322.26 301,260.73 9,927,582.99
CY2009 9,141,025.36 212,789.51 9,353,814.87
Total 11,034,860.44 9,901,407.94 9,442,286.09 212,789.51 30,591,343.98
Рис. 8.8 Разные префиксы в содержимом позволяют быстро понять,
где дата заказа, а где дата поставки
В подходе с разными таблицами дат одна и та же мера может давать разные
результаты в зависимости от используемого столбца в фильтре. Однако было
бы неправильно выбирать этот вариант организации хранения данных только
по причине снижения количества мер в модели данных. В конце концов, в этом
случае вы не сможете вынести в отчет одну и ту же меру, сгруппированную по
разным датам. Представьте себе линейную диаграмму, отображающую меру
Sales Amount по столбцам Order Date и Delivery Date. Для этого вам понадобится,
чтобы на оси дат учитывались данные из одной таблицы Date, а со множест-
венными календарями в модели такого результата будет добиться довольно
проблематично.
Если вашим первостепенным приоритетом является уменьшение количест-
ва мер в модели данных и возможность вычислять одну и ту же меру по раз-
ным датам, вам стоит присмотреться к группам вычислений, которые будут
описаны в главе 9, с содержанием единой таблицы дат в модели. Единствен-
ной пользой от присутствия множества календарей в модели является возмож-
ность использования пересечений одной и той же меры в одной визуализации
по разным датам, как показано на рис. 8.8. Во всех остальных случаях лучшим
выбором будет содержание одной таблицы дат в модели данных со множест-
венными связями.
Знакомство с базовыми вычислениями
в работе со временем
В предыдущих разделах вы узнали, как правильно создать таблицу дат, которая
пригодится вам для осуществления вычислений при работе с датами и време-
нем. В DAX есть множество функций для облегчения таких вычислений. Ис-
пользовать эти функции довольно просто, но при этом они помогают произ-
водить очень сложные и полезные расчеты. Понимание деталей работы этих
260 ГЛАВА 8 Логика операций со временем
функций позволит вам быстро начать применять их в своей работе. В целях
обучения мы сначала покажем, как производить вычисления с датами и вре-
менем в DAX стандартными средствами - с использованием функций CALCU-
LATE, CALCULATETABLE, FILTER и VALUES. Позже в данной главе мы перейдем
к применению специализированных функций из раздела логики операций со
временем для тех же расчетов, и вы увидите, как они помогают облегчить на-
писание кода и сделать его гораздо более легким для восприятия.
Мы решили использовать такой подход в обучении сразу по нескольким
причинам. Но главная из них в том, что, когда речь заходит о логике операций
со временем, далеко не все вычисления могут быть произведены с примене-
нием стандартных функций DAX. В какой-то момент в карьере разработчика
DAX вам понадобится осуществить более сложный расчет, чем просто сумма
нарастающим итогом с начала года, и вы обнаружите, что в DAX нет специ-
альных функций, удовлетворяющих вашим требованиям. Если вы опытный
разработчик, то для вас это не будет большой проблемой. Вы закатаете рукава
и в итоге напишете правильный фильтр без использования специализирован-
ных функций DAX. Если же у вас нет достаточного опыта в разработке на языке
DAX, вам придется несладко.
Посмотрим на практике, что из себя представляет логика операций со вре-
менем. Представьте, что у вас есть простая мера, вычисление которой произ-
водится в текущем контексте фильтра:
Sales Anount :=
SUMX ( Sales; Sales[Net Price] * Sales[Quantity] )
Поскольку таблицы Sales и Date объединены связью, текущий фильтр в таб-
лице Date ограничит выбор в Sales. Чтобы произвести вычисление в таблице
Sales за другой временной период, разработчику необходимо будет изменить
существующий фильтр в таблице дат. Например, чтобы получить сумму про-
даж нарастающим итогом с начала года при текущем фильтре по февралю
2007 года, необходимо перед осуществлением итераций по таблице Sales из-
менить контекст фильтра таким образом, чтобы в него вошли как январь, так
и февраль этого года.
Для этого можно использовать уже знакомую вам функцию CALCULATE
с указанием аргумента фильтра, которая вернет сумму нарастающим итогом
на февраль 2007 года:
Sales Anount Jan-Feb 2007 :=
CALCULATE (
SUMX ( Sales; Sales[Net Price] * Sales[Quantity] );
FILTER (
ALL ( 'Date' );
AND (
'Date'[Date] >= DATE ( 2007; 1; 1 );
'Date'[Date] <= DATE ( 2007; 2; 28 )
)
Результат вычисления этой меры показан на рис. 8.9.
ГЛАВА 8 Логика операций со временем 261
Year Sales Amount Sales Amount Jan-Feb 2007
2007 11,309,946.12 1,685,384.15
January 794,248.24 1,685,384.15
February 891,135.91 1,685,384.15
March 961,289.24 1,685,384.15
April 1,128,104.82 1,685,384.15
May 936,192.74 1,685,384.15
June 982,304.46 1,685,384.15
July 922,542.98 1,685,384.15
August 952,834.59 1,685,384.15
September 1,009,868.98 1,685,384.15
October 914,273.54 1,685,384.15
Рис. 8.9 Результатом вычисления меры будет сумма продаж
за январь и февраль 2007 года вне зависимости от месяца в строке
Функция FILTER, используемая в качестве аргумента фильтра в CALCULATE,
возвращает набор дат, замещающий собой текущий выбор в таблице Date.
Иными словами, несмотря на то что в текущем контексте фильтра присутству-
ет фильтр по месяцу, исходящий от строк, мера рассчитывается совсем по дру-
гому интервалу.
Очевидно, что мера, возвращающая сумму продаж по двум статическим ме-
сяцам, никакого интереса не представляет. Но, поняв сам механизм, вы можете
вооружиться некоторыми стандартными функциями DAX и правильно вычис-
лить сумму продаж нарастающим итогом, как показано ниже:
Sales Anount YTD :=
VAR LastVisibleDate = MAX ( 'Date'[Date] )
VAR CurrentYear = YEAR ( LastVisibleDate )
VAR SetOfDatesYtd =
FILTER (
ALL ( 'Date' );
AND (
'Date'[Date] <= LastVisibleDate;
YEAR ( 'Date'[Date] ) = CurrentYear
)
)
VAR Result =
CALCULATE (
SUMX ( Sales; Sales[Net Price] * Sales[Quantity] );
SetOfDatesYtd
)
RETURN
Result
И хотя код этой меры получился более сложным, принцип расчета остался
прежним. Сначала мы сохраняем в переменную LastVisibleDate последнюю ви-
димую дату в текущем контексте фильтра. После этого в переменную Current-
Year записываем год этой даты. В третьей переменной SetOfDatesYtd хранятся
все даты с начала года по последнюю видимую дату. Этим набором дат мы
262 ГЛАВА 8 Логика операций со временем
заменяем текущий контекст фильтра в отношении дат для вычисления нарас-
тающего итога, как видно по рис. 8.10.
Year Sales Amount Sales Amount YTD
2007 11,309,946.12 11,309,946.12
January 794,248.24 794,248.24
February 891,135.91 1,685,384.15
March 961,289.24 2,646,673.39
April 1,128,104.82 3,774,778.20
May 936,192.74 4,710,970.95
June 982,304.46 5,693,275.41
July 922,542.98 6,615,818.39
August 952,834.59 7,568,652.98
September 1,009,868.98 8,578,521.96
October 914,273.54 9,492,795.50
November 825,601.87 10,318,397.37
December 991,548.75 11,309,946.12
Рис. 8.10 Мера Soles Amount YTD рассчитывает нарастающий итог
по сумме продаж с использованием функции FILTER
Как мы и утверждали, вы вполне можете производить вычисления при ра-
боте с датой и временем, пользуясь при этом стандартными функциями DAX.
Важно понимать, что такие вычисления по своей сути ничем не отличаются
от любых других, в которых также производятся манипуляции с контекстом
фильтра. Поскольку мера призвана агрегировать значения по другому набору
дат, ее вычисление производится в два этапа. Сначала мы определяем новый
фильтр по датам, а затем применяем его к модели данных для произведения
вычисления. Все расчеты в области даты и времени производятся одинаково.
И когда вы поймете базовые принципы, логика операций со временем пере-
станет быть для вас тайной.
Перед тем как двигаться дальше, стоит подробнее остановиться на том, как
поступает DAX со связями по столбцам с датами. Взгляните на чуть изменен-
ный предыдущий код, где вместо фильтра по всей таблице дат мы используем
фильтр только по столбцу DatefDate]:
Sales Amount YTD :=
VAR LastVisibleDate = MAX ( 'Date'[Date] )
VAR CurrentYear = YEAR ( LastVisibleDate )
VAR SetOfDatesYtd =
FILTER (
ALL ( 'Date*[Date] );
AND (
'Date'[Date] <= LastVisibleDate;
YEAR ( 'Date'[Date] ) = CurrentYear
)
)
VAR Result =
ГЛАВА 8 Логика операций co временем 263
CALCULATE (
SUMX ( Sales; Sales[Net Price] * Sales[Quantity] );
SetOfDatesYtd
)
RETURN
Result
Если использовать эту меру в отчете вместо предыдущей, мы не увидим
никаких изменений. Фактически обе меры вычисляют одинаковые значения,
хотя не должны. Давайте рассмотрим, как работает мера на примере одной
строки - допустим, апреля 2007 года.
Контекст фильтра в этой строке включает в себя год 2007 и месяц апрель.
Соответственно, в переменной LastVisibleDate окажется значение 30 апре-
ля 2007 года, а в CurrentYear - 2007. Табличная переменная SetOfDatesYtd, со-
гласно методике расчета, будет содержать все даты между 1 января 2007 года
и 30 апреля 2007 года. Иными словами, в строке с апрелем 2007 года выполня-
емая формула будет эквивалентна следующей:
CALCULATE (
CALCULATE (
[Sales Amount];
AND ( -- Этот фильтр эквивалентен
'Date'[Date] >= DATE ( 2007; 1; 1); -- результату функции FILTER
'Date'[Date] <= DATE ( 2007; 04; 30 )
)
);
'Date'[Year] = 2007; -- Эти фильтры приходят из строки
'Date'[Month] = "April" -- матрицы по апрелю 2007 года
)
Если вы вспомните, что уже знаете о контекстах фильтра и функции CALCU-
LATE, то поймете, что этот код не должен правильно вычислять нарастающий
итог с начала года. И действительно, аргумент фильтра внутренней функции
CALCULATE вернет таблицу, содержащую столбец Date[Date]. Таким образом, этот
фильтр должен перезаписать все существующие фильтры по столбцу Date[Date],
оставив фильтры по другим столбцам неизменными. А поскольку во внешней
функции CALCULATE применяются фильтры к столбцам Date [Year] и Date [Month],
итоговый контекст фильтра, в котором рассчитывается мера [Sales Amount], дол-
жен содержать только апрель 2007 года. Но мера все же возвращает правильные
значения, включая все остальные месяцы начиная с января.
Причина такого неожиданно правильного поведения меры в том, что DAX
особым образом обрабатывает результаты, в случае если таблицы связаны по
столбцу с датой, как у нас. Всякий раз, когда фильтр применяется к столбцу
типа Date или DateTime, который используется как связующий между двумя
таблицами, DAX автоматически добавляет функцию ALL ко всей таблице дат
в качестве дополнительного аргумента фильтра функции CALCULATE. Таким
образом, предыдущий пример преобразуется так:
CALCULATE (
CALCULATE (
264 ГЛАВА 8 Логика операций со временем
[Sales Amount];
AND (
'Date'[Date] >= DATE ( 2007; 1; 1);
'Date'[Date] <= DATE ( 2007; 04; 30 )
-- Этот фильтр эквивалентен
-- результату функции FILTER
);
ALL ( 'Date* ) -- Эта строка добавлена движком DAX автоматически
);
'Date'[Year] = 2007;
'Date'[Month] = "April
-- Эти фильтры приходят из строки
-- матрицы по апрелю 2007 года
Когда фильтр применяется к столбцу типа Date или DateTime, являющемуся
основанием для связи «один ко многим», DAX автоматически распространя-
ет действие фильтра на другую таблицу, заменяя фильтры по любым другим
столбцам в той же таблице поиска.
Это сделано для того, чтобы максимально упростить логику операций со
временем, когда таблицы связаны по столбцу с датой. В следующем разделе
мы расскажем про специальную отметку для таблиц дат, позволяющую реали-
зовать такое же поведение для связей не по столбцу с датой.
Пометка календарей как таблиц дат
Применение фильтра к столбцу с датами в календаре прекрасно работает, если
этот столбец используется в качестве основы для связи. Но вам может понадо-
биться связать таблицы по другому столбцу. Во многих календарях есть поле
с целочисленным значением - обычно в формате YYYYMMDD, - по которому
и производится объединение с другими таблицами в модели данных.
Чтобы продемонстрировать такую ситуацию, мы создали столбец DateKey
в таблицах Date и Sales. После этого настроили связь между этими таблицами
по данному полю, а не по полю типа дата. Получившуюся модель данных мож-
но видеть на рис. 8.11.
Рис. 8.11 Связь между таблицами Soles и Dote организована по столбцу DoteKey типа Integer
Теперь тот же самый код с вычислением суммы продаж нарастающим ито-
гом с начала года, который раньше работал нормально, вдруг сломался. Резуль-
тат вычисления нашей меры показан на рис. 8.12.
ГЛАВА 8 Логика операций со временем 265
Year Sales Amount Sales Amount YTD
2007 11,309,946.12 11,309,946.12
January 794,248.24 794,248.24
February 891,135.91 891,135.91
March 961,289.24 961,289.24
April 1,128,104.82 1,128,104.82
May 936,192.74 936,192.74
June 982,304.46 982,304.46
July 922,542.98 922,542.98
August 952,834.59 952,834.59
September 1,009,868.98 1,009,868.98
October 914,273.54 914,273.54
November 825,601.87 825,601.87
December 991,548.75 991,548.75
Рис. 8.12 Использование целочисленного столбца для связи
привело к ошибочным результатам вычисления меры
Как видите, после изменения связи отчет показывает одинаковые значения
в столбцах с мерами Sales Amount и Sales Amount YTD. Поскольку связь между
таблицами более не основывается на столбце типа DateTime, DAX автоматиче-
ски не добавляет функцию ALL к таблице дат. А следовательно, новый фильтр
по дате будет пересекаться с существующим - из внешнего запроса, что при-
ведет к неправильным расчетам.
Здесь возможны два решения. Во-первых, можно вручную добавлять ALL ко
всем вычислениям, связанным с логикой операций со временем. Это доволь-
но обременительный вариант для разработчика, который должен не забывать
вставлять данную функцию во все аналогичные расчеты. Второй способ гораз-
до более удобный и заключается в пометке календаря как таблицы дат на па-
нели инструментов.
Если таблица дат помечена соответствующим образом, DAX будет автома-
тически добавлять модификатор ALL ко всем вычислениям, даже если связь
организована не по столбцу с датами. Помните о том, что после специальной
пометки календаря ALL будет добавляться всякий раз, когда происходит из-
менение контекста фильтра по столбцу с датами. В некоторых ситуациях такое
поведение будет нежелательным, и разработчику придется писать довольно
сложный код, чтобы наладить правильную фильтрацию. Мы расскажем об
этом далее в данной главе.
Знакомство с базовыми функциями логики
операций со временем
Теперь, когда вы узнали базовые механизмы вычислений при работе с датой
и временем, пришло время упростить код. Если бы разработчикам DAX прихо-
дилось писать сложные выражения с использованием функции FILTER всякий
266 ГЛАВА 8 Логика операций со временем
раз, когда необходимо рассчитать простую сумму нарастающим итогом с на-
чала года, жизнь не казалась бы им медом.
Для облегчения вычислений, связанных с логикой операций со временем,
в DAX имеется целый ряд специальных функций, автоматически выполняю-
щих фильтрацию, которую мы производили вручную в предыдущем примере.
Вспомним меру Sales Amount YTD, которую мы написали ранее:
Sales Anount YTD :=
VAR LastVisibleDate = MAX ( 'Date'[Date] )
VAR CurrentYear = YEAR ( LastVisibleDate )
VAR SetOfDatesYtd =
FILTER (
ALL ( 'Date'[Date] );
AND (
'Date'[Date] <= LastVisibleDate;
YEAR ( 'Date'[Date] ) = CurrentYear
)
)
VAR Result =
CALCULATE (
SUMX ( Sales; Sales[Net Price] * Sales[Quantity] );
SetOfDatesYtd
)
RETURN
Result
С использованием специальной функции DATESYTD этот код можно значи-
тельно упростить, приведя к следующему виду:
Sales Anount YTD :=
CALCULATE (
SUMX ( Sales; Sales[Net Price] * Sales[Quantity] );
DATESYTD ( 'Date'[Date] )
)
Функция DATESYTD сделала все то же самое, что и гораздо более громоздкий
код с использованием фильтрации. Поведение меры и ее эффективность при
этом остались прежними, но на написание этой формулы у вас уйдет намно-
го меньше времени, которое можно потратить на изучение этих специальных
функций.
Простые вычисления нарастающим итогом с начала года, квартала, меся-
ца, а также сравнение показателей текущего года с предыдущим можно очень
легко реализовать при помощи базовых функций для работы с логикой вре-
менных периодов. Более сложные расчеты могут потребовать сочетания раз-
ных специальных функций DAX. Написание действительно объемного и слож-
ного кода на DAX может понадобиться только при необходимости построения
нестандартных календарей наподобие недельного или в больших комплекс-
ных расчетах, где стандартного набора функций DAX может оказаться недо-
статочно.
ГЛАВА 8 Логика операций со временем 267
Примечание Все функции логики операций со временем применяют фильтры к столбцу
с датами таблицы Date. С некоторыми примерами работы с датой и временем вы встрети-
тесь в данной книге, а полный перечень специальных функций DAX с шаблонами из этой
области расположен по адресу http://www.daxpatterns.com/time-patterns/.
В следующих разделах мы познакомимся с базовыми вычислениями в DAX
при помощи специальных функций логики операций со временем. Позже
в данной главе коснемся более сложных выражений.
Нарастающие итоги с начала года, квартала, месяца
Все вычисления показателей нарастающим итогом - будь то с начала года,
квартала или месяца - очень похожи друг на друга. Разница лишь в том, что
итоги с начала месяца актуальны только на уровне дня, тогда как годовые
и квартальные итоги часто используются для анализа месячных показа-
телей.
Чтобы вычислить сумму продаж нарастающим итогом с начала года, необхо-
димо изменить контекст фильтра по датам в выражении таким образом, чтобы
начало периода вычисления перенеслось на 1 января текущего года, а конец
остался на месяце, соответствующем выбранной ячейке. Простой пример по-
добного вычисления приведен ниже:
Sales Amount YTD :=
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
)
Функция DATESYTD возвращает таблицу, заполненную датами с начала года
и до последней даты в текущем контексте фильтра. Эта таблица впоследствии
используется в качестве аргумента фильтра в функции CALCULATE для уста-
новки обновленного контекста фильтра, в котором будет вычислена мера Sales
Amount. В одну группу с DATESYTD входят еще две аналогичные функции для
вычисления меры нарастающим итогом с начала месяца (DATESMTD) и с на-
чала квартала (DATESQTD). Пример работы этих функций можно видеть на
рис. 8.13.
Этот подход базируется на использовании функции CALCULATE. Но в DAX
также есть ряд функций, упрощающих вычисление мер нарастающим итогом.
Это TOTALYTD, TOTALQTD и TOTALMTD. В следующем примере показано пре-
дыдущее вычисление с использованием функции TOTALYTD:
YTD Sales :=
TOTALYTD (
[Sales Amount];
'Date'[Date]
)
Синтаксис этой меры отличается от предыдущего примера, поскольку функ-
ция TOTALYTD принимает на вход название меры для вычисления в качестве
268 ГЛАВА 8 Логика операций со временем
первого параметра и столбец с датами в качестве второго. В остальном поведе-
ние этих двух мер идентично. Функция TOTALYTD скрывает в себе вложенную
функцию CALCULATE, что само по себе ограничивает ее использование. Если
в расчете участвует функция CALCULATE, нужно сделать все, чтобы она была
видимой. Это сделает код более очевидным, в том числе из-за преобразования
контекста, который может быть инициирован функцией CALCULATE.
Year Sales Amount Sales Amount YTD Sales Amount QTD
2007 11,309,946.12 11,309,946.12 2,731,424.16
January 794,248.24 794,248.24 794,248.24
February 891,135.91 1,685,384.15 1,685,384.15
March 961,289.24 2,646,673.39 2,646,673.39
April 1,128,104.82 3,774,778.20 1,128,104.82
May 936,192.74 4,710,970.95 2,064,297.56
June 982,304.46 5,693,275.41 3,046,602.02
July 922,542.98 6,615,818.39 922,542.98
August 952,834.59 7,568,652.98 1,875,377.57
September 1,009,868.98 8,578,521.96 2,885,246.55
October 914,273.54 9,492,795.50 914,273.54
November 825,601.87 10,318,397.37 1,739,875.41
December 991,548.75 11,309,946.12 2,731,424.16
Рис. 8.13 Меры Sales Amount YTD и Sales Amount QTD
выведены вместе с базовой мерой Sales Amount
Похожим образом вы можете использовать и функции для расчета нарас-
тающих итогов по месяцам и кварталам, как показано ниже:
QTD Sales := TOTALQTD ( [Sales Anount]; 'Date'[Date] )
QTD Sales := CALCULATE ( [Sales Amount]; DATESQTD ( 'Date'[Date] ) )
MTD Sales := TOTALMTD ( [Sales Amount]; 'Date'[Date] )
MTD Sales := CALCULATE ( [Sales Amount]; DATESMTD ( 'Date'[Date] ) )
Вычисление нарастающего итога с начала года в случае использования не-
стандартного финансового календаря с отличной от 31 декабря датой окон-
чания отчетного периода требует передачи дополнительного необязательного
параметра в функции TOTALYTD и DATESYTD. В следующем примере показан
расчет нарастающего итога для нестандартного финансового года:
Fiscal YTD Sales := TOTALYTD ( [Sales Amount]; 'Date'[Date]; "06-30" )
Fiscal YTD Sales := CALCULATE ( [Sales Amount]; DATESYTD ( 'Date'[Date]; "06-30" ) )
Опциональный параметр "06-30" соответствует дате окончания финансо-
вого года - 30 июня. Целый ряд специальных функций логики операций со
временем в DAX принимает дополнительный параметр для этих целей. Это
функции STARTOFYEAR, ENDOFYEAR, PREVIOUSYEAR, NEXTYEAR, DATESYTD,
TOTALYTD, OPENINGBALANCEYEAR и CLOSINGBALANCE YEAR.
ГЛАВА 8 Логика операций со временем 269
Важно. В зависимости от региональных настроек вам может потребоваться сначала ука-
зывать день, а затем месяц. Вы также можете использовать строку в формате YYYY-MM-
DD, чтобы избежать неоднозначности в трактовке даты. В этом случае указание года не
будет оказывать влияния на определение последней даты финансового года при расчете
нарастающих итогов:
Fiscal YTD Sales := TOTALYTD ( [Sales Amount]; 'Date'[Date]; "30-06" )
Fiscal YTD Sales := CALCULATE ( [Sales Amount]; DATESYTD ( 'Date'[Date]; "30-06" ) )
Fiscal YTD Sales := CALCULATE ( [Sales Amount]; DATESYTD ( 'Date'[Date]; "2018-06-30" ) )
По состоянию на июнь 2018 года не исправлены ошибки в расчетах, в случае если финан-
совый год начинается в марте и заканчивается в феврале. Подробнее об этой проблеме
и способах ее решения мы поговорим далее в данной главе.
\________________________________________________________________________________________
Сравнение временных интервалов
Многие вычисления требуют сравнения показателей текущего временного ин-
тервала с тем же интервалом в прошедшем году. Это может быть полезно для
сравнения тенденций в определенный период нынешнего года и прошлого.
При осуществлении таких вычислений вам поможет функция SAMEPERIOD-
LASTYEAR:
PY Sales := CALCULATE ( [Sales Amount]; SAMEPERIODLASTYEAR ( 'Date'[Date] ) )
Функция SAMEPERIODLASTYEAR возвращает набор аналогичных дат пе-
риода, сдвинутых ровно на год назад. SAMEPERIODLASTYEAR представляет со-
бой частный случай более общей функции DATEADD, которая предназначена
для сдвигов любых временных интервалов на определенное количество шагов.
Среди этих интервалов могут быть следующие: YEAR, QUARTER, MONTH и DAY.
Например, вы можете переписать предыдущую меру с использованием функ-
ции DATEADD для сдвига текущего контекста фильтра ровно на год назад:
PY Sales := CALCULATE( [Sales Amount]; DATEADD ( 'Date'[Date]; -1; YEAR ) )
В общем случае функция DATEADD является более мощной по сравнению
с SAMEPERIODLASTYEAR, поскольку может вычислять аналогичные показатели
не только по прошлому году, но и по прошлому кварталу, месяцу и даже дню:
PQ Sales := CALCULATE ( [Sales Amount]; DATEADD ( 'Date'[Date]; -1; QUARTER ) )
PM Sales := CALCULATE ( [Sales Amount]; DATEADD ( 'Date'[Date]; -1; MONTH ) )
PD Sales := CALCULATE ( [Sales Amount]; DATEADD ( 'Date'[Date]; -1; DAY ) )
На рис. 8.14 показан результат вычисления некоторых из этих мер.
Еще одной полезной функцией является PARALLELPERIOD, похожая на
DATEADD, но возвращающая полный интервал, указанный в третьем параметре,
а не частичный, как функция DATEADD. Таким образом, несмотря на то что в те-
кущем контексте фильтра выбран один месяц, следующая мера с использова-
нием функции PARALLEPERIOD вернет сумму продаж за весь предыдущий год:
PY Total Sales :=
CALCULATE ( [Sales Amount]; PARALLELPERIOD ( 'Date'[Date]; -1; YEAR ) )
270 ГЛАВА 8 Логика операций co временем
Year Sales Amount PY Sales PQ Sales PM Sales
2007 11,309,946.12 8,578,521.96 10,318,397.37
January 794,248.24
February 891,135.91 794,248.24
March 961,289.24 891,135.91
April 1,128,104.82 794,248.24 961,289.24
May 936,192.74 891,135.91 1,128,104.82
June 982,304.46 961,289.24 936,192.74
July 922,542.98 1,128,104.82 982,304.46
August 952,834.59 936,192.74 922,542.98
September 1,009,868.98 982,304.46 952,834.59
October 914,273.54 922,542.98 1,009,868.98
November 825,601.87 952,834.59 914,273.54
December 991,548.75 1,009,868.98 825,601.87
2008 9,927,582.99 11,309,946.12 9,861,395.69 9,997,422.60
January 656,766.69 794,248.24 914,273.54 991,548.75
February 600,080.00 891,135.91 825,601.87 656,766.69
March 559,538.52 961,289.24 991,548.75 600,080.00
Рис. 8.14 Функция DATEADD позволяет сместить временной период
на любое количество интервалов
Применяя другие значения параметров, можно получить информацию за
соответствующие временные интервалы:
PQ Total Sales :=
CALCULATE ( [Sales Amount]; PARALLELPERIOD ( 'Date'[Date]; -1; QUARTER ) )
На рис. 8.15 показаны значения мер с использованием функции PARALLEL-
PERIOD за предыдущий год и квартал.
Есть функции, близкие по смыслу, но не идентичные функции PARALLELPE-
RIOD. Это PREVIOUSYEAR, PRE VIOUSQUARTER, PREVIOUSMONTH, PREVIOUS-
DAY, NEXTYEAR, NEXTQUARTER, NEXTMONTH и NEXTDAY. Эти функции ве-
дут себя так же, как PARALLELPERIOD, при условии что выбран один элемент,
соответствующий названию конкретной функции, - год, квартал, месяц или
день. Если выбрано несколько периодов, функция PARALLELPERIOD вернет
сдвинутые во времени значения для всех из них. Если же использовать более
специализированные функции по году, кварталу, месяцу или дню, то в случае
множественного выбора элементов будет возвращен единственный элемент,
смежный с выбранным периодом вне зависимости от количества элементов
в нем. Например, следующий код вернет набор из марта, апреля и мая 2008 го-
да, если выбран второй квартал 2008 года (апрель, май и июнь):
PM Total Sales :=
CALCULATE ( [Sales Amount]; PARALLELPERIOD ( 'Date'[Date]; -1; MONTH ) )
Следующий же код в случае выбора второго квартала 2008 года вернет толь-
ко март:
Last PM Sales :=
CALCULATE ( [Sales Amount]; PREVIOUSMONTH( 'Date'[Date] ) )
ГЛАВА 8 Логика операций co временем 271
Year Sales Amount PY Total Sales PQ Total Sales
2007 11,309,946.12 8,578,521.96
Q1 2,646,673.39
January 794,248.24
February 891,135.91
March 961,289.24
Q2 3,046,602.02 2,646,673.39
April 1,128,104.82 2,646,673.39
May 936,192.74 2,646,673.39
June 982,304.46 2,646,673.39
Q3 2,885,246.55 3,046,602.02
July 922,542.98 3,046,602.02
August 952,834.59 3,046,602.02
September 1,009,868.98 3,046,602.02
Q4 2,731,424.16 2,885,246.55
October 914,273.54 2,885,246.55
November 825,601.87 2,885,246.55
December 991,548.75 2,885,246.55
2008 9,927,582.99 11,309,946.12 9,861,395.69 рис функция
Q1 1,816,385.21 11,309,946.12 2,731,424.16 PARALLELPERIOD возвращает
January 656,766.69 11,309,946.12 2,731,424.16 полный указанный период,
February 600,080.00 11,309,946.12 2,731,424.16 а не текущий период, сдвинутый
March 559,538.52 11,309,946.12 2,731,424.16 во времени
Разница в поведении между двумя мерами хорошо видна по рис. 8.16. Мера
Last PM Sales возвращает значение за декабрь 2007 года как для всего 2008 го-
да, так и для его первого квартала, тогда как PM Total Sales всегда возвращает
агрегацию за то же количество месяцев, что и в текущем выборе, - за три для
квартала и за двенадцать для года. Это происходит, даже если исходный выбор
смещается назад на один месяц.
Year Sales Amount Last PM Sales PM Total Sales
Q4 2,731,424.16 1,009,868.98 2,749,744.39
October 914,273.54 1,009,868.98 1,009,868.98
November 825,601.87 914,273.54 914,273.54
December 991,548.75 825,601.87 825,601.87
2008 9,927,582.99 991,548.75 9,997,422.60
Q1 1,816,385.21 991,548.75 2,248,395.44
January 656,766.69 991,548.75 991,548.75
February 600,080.00 656,766.69 656,766.69
March 559,538.52 600,080.00 600,080.00
Q2 2,738,040.73 559,538.52 2,452,437.65
April 999,667.17 559,538.52 559,538.52 D ол, Рис. 8.16 Функция
May 893,231.96 999,667.17 999,667.17 prevIOUSMONTH возвращает
June 845,141.60 893,231.96 oqo pal qc один месяц,даже если
Q3 2,575,545.59 845,141.60 2,457,249.97 в исходном выборе находится
July 890,547.41 845,141.60 845,141.60 квартал или год
272 ГЛАВА 8 Логика операций со временем
Сочетание функций логики операций со временем
Одной из полезных особенностей функций логики операций со временем яв-
ляется их сочетаемость, помогающая проводить более сложные вычисления.
Первым параметром большинства этих функций является столбец с датами
в календаре. На самом деле это только синтаксический сахар, позволяющий
избегать написания полной формулы, требующей передачи первым парамет-
ром таблицы, как видно в следующем сравнении двух эквивалентных мер. При
вычислении столбец с датами невидимо для нас трансформируется в таблицу
с уникальными значениями, активными в текущем контексте фильтра после
преобразования из контекста строки, если таковой присутствовал:
PY Sales :=
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
)
- - это эквивалентно следующей записи:
PY Sales :=
CALCULATE (
[Sales Amount];
DATESYTD ( CALCULATETABLE ( DISTINCT ( 'Date'[Date] ) ) )
)
Таким образом, функции логики операций со временем принимают в ка-
честве первого параметра таблицу и работают как машина времени. Эти функ-
ции просто берут содержимое переданной таблицы и смещают его во времени
на необходимое количество лет, кварталов, месяцев или дней. А поскольку они
получают на вход таблицу, значит, на ее месте спокойно может оказаться лю-
бое табличное выражение, включая другую функцию логики операций со вре-
менем. Это открывает возможности для сочетания разных временных функ-
ций при помощи каскадных вложений.
Например, мы можем сравнить сумму продаж нарастающим итогом с нача-
ла года с соответствующим значением прошлого года. Это можно сделать при
помощи сочетания функций SAMEPERIODLASTYEAR и DATESYTD в одном вы-
ражении. Любопытно отметить при этом, что изменение порядка следования
функций не повлияет на результат:
PY YTD Sales :=
CALCULATE (
[Sales Amount];
SAMEPERIODLASTYEAR ( DATESYTD ( 'Date'[Date] ) )
)
- - это эквивалентно следующей записи:
PY YTD Sales :=
CALCULATE (
ГЛАВА 8 Логика операций co временем 273
[Sales Amount];
DATESYTD ( SAMEPERIODLASTYEAR ( 'Date'[Date] ) )
)
Можно также использовать функцию CALCULATE, чтобы сместить во вре-
мени текущий контекст фильтра, а затем вызвать функцию, которая, в свою
очередь, также проанализирует переданный контекст и сместит его во време-
ни. Следующие две меры PY YTD Sales эквивалентны предыдущим двум; меры
YTD Sales и PYSales были определены ранее в данной главе:
PY YTD Sales :=
CALCULATE (
[YTD Sales];
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
- - это эквивалентно следующей записи:
PY YTD Sales :=
CALCULATE (
[PY Sales];
DATESYTD ( 'Date'[Date] )
)
Результат вычисления меры PY YTD Sales можно видеть на рис. 8.17. Как
видите, значения меры YTD Sales сдвинуты на год при расчете меры PY YTD
Sales.
Year Sales Amount YTD Sales PY YTD Sales
2007 11,309,946.12 11,309,946.12
January 794,248.24 794,248.24
February 891,135.91 1,685,384.15
March 961,289.24 2,646,673.39
April 1,128,104.82 3,774,778.20
May 936,192.74 4,710,970.95
June 982,304.46 5,693,275.41
July 922,542.98 6,615,818.39
August 952,834.59 7,568,652.98
September 1,009,868.98 8,578,521.96
October 914,273.54 9,492,795.50
November 825,601.87 10,318,397.37
December 991,548.75 11,309,946.12
2008 9,927,582.99 9,927,582.99 11,309,946.12
January 656,766.69 656,766.69 794,248.24
February 600,080.00 1,256,846.69 1,685,384.15
March 559,538.52 1,816,385.21 2,646,673.39
April 999,667.17 2,816,052.38 3,774,778.20
May 893,231.96 3,709,284.34 4,710,970.95
June 845,141.60 4,554,425.94 5,693,275.41
July 890,547.41 5,444,973.35 6,615,818.39
Рис. 8.17 Сумма продаж нарастающим итогом за прошлый год
может быть получена путем сочетания функций логики операций со временем
274 ГЛАВА 8 Логика операций со временем
Все примеры из этого раздела могут быть адаптированы для работы с квар-
талами, месяцами и днями, но не с неделями. В DAX нет специальных функций
логики операций со временем для подсчета недель из-за отсутствия строгого
соответствия между неделями и годами, кварталами и месяцами. Таким об-
разом, при необходимости вам придется самостоятельно реализовывать вы-
ражения для работы с неделями. Далее в этой главе мы покажем один пример
подобных вычислений.
Расчет разницы по сравнению с предыдущим периодом
Одной из распространенных операций при работе со временем является рас-
чет разницы между нынешним значением меры и ее значением в предыду-
щем году. Эта разница может быть выражена как в абсолютных величинах, так
и в процентах. Вы уже видели, как можно получить прошлогоднее значение
меры при помощи функции SAMEPERIODLASTYEAR:
PY Sales := CALCULATE ( [Sales Amount]; SAMEPERIODLASTYEAR ( 'Date'[Date] ) )
Чтобы вычислить разницу между двумя значениями меры Sales Amount в аб-
солютном выражении, достаточно воспользоваться простой арифметической
операцией вычитания. При этом необходимо добавить проверку, чтобы пока-
зывать разницу между значениями только в случае присутствия обоих. Здесь
лучше будет воспользоваться переменными, дабы не вычислять одну меру
дважды. Меру YOY Sales (от «year-over-year» - «по сравнению с предыдущим
годом») можно определить так:
YOY Sales :=
VAR CySales = [Sales Amount]
VAR PySales = [PY Sales]
VAR YoySales =
IF (
NOT ISBLANK ( CySales ) && NOT ISBLANK ( PySales );
CySales - PySales
)
RETURN
YoySales
Чтобы вычислить разницу между значениями нарастающим итогом теку-
щего года по сравнению с предыдущим, необходимо задействовать меры YTD
Sales и PY YTD Sales, которые мы определили ранее:
YTD Sales := TOTALYTD ( [Sales Amount]; 'Date'[Date] )
PY YTD Sales :=
CALCULATE (
[Sales Amount];
DATESYTD ( SAMEPERIODLASTYEAR ( 'Date'[Date] ) )
)
YOY YTD Sales :=
VAR CyYtdSales = [YTD Sales]
ГЛАВА 8 Логика операций co временем 275
VAR PyYtdSales = [PY YTD Sales]
VAR YoyYtdSales =
IF (
NOT ISBLANK ( CyYtdSales ) && NOT ISBLANK ( PyYtdSales );
CyYtdSales - PyYtdSales
)
RETURN
YoyYtdSales
Зачастую в отчетах разницу между годами лучше выводить не в абсолютных
значениях, а в процентах, рассчитать которые в нашем случае можно, поделив
меру YOY Sales на PY Sales. За точку отсчета мы будем принимать прошлогод-
ний показатель продаж, причем разница 100 % будет означать двукратное уве-
личение значения за год. В следующем выражении мы рассчитаем меру YOY
Sales% при помощи функции DIVIDE, чтобы избежать ошибки деления на ноль
в строках, для которых отсутствует прошлогодний показатель:
YOY Sales% := DIVIDE ( [YOY Sales]; [PY Sales] )
Похожая формула будет и для расчета разницы в процентах применительно
к нарастающим итогам с начала года. Назовем эту меру YOY YTD Sales%:
YOY YTD Sales% := DIVIDE ( [YOY YTD Sales]; [PY YTD Sales] )
На рис. 8.18 показан отчет co всеми рассчитанными мерами.
Year Sales Amount PY Sales YOY Sales YOY Sales% YTD Sales PY YTD Sales YOY YTD Sales YOY YTD Sales%
2007 11,309,946.12 11,309,946.12 11,309,946.12 11,309,946.12
January 794,248.24 794,248.24 794,248.24 794,248.24
February 891,135.91 891,135.91 1,685,384.15 1,685,384.15
March 961,289.24 961,289.24 2,646,673.39 2,646,673.39
April 1,128,104.82 1,128,104.82 3,774,778.20 3,774,778.20
May 936,192.74 936,192.74 4,710,970.95 4,710,970.95
June 982,304.46 982,304.46 5,693,275.41 5,693,275.41
July 922,542.98 922,542.98 6,615,818.39 6,615,818.39
August 952,834.59 952,834.59 7,568,652.98 7,568,652.98
September 1,009,868.98 1,009,868.98 8,578,521.96 8,578,521.96
October 914,273.54 914,273.54 9,492,795.50 9,492,795.50
November 825,601.87 825,601.87 10,318,397.37 10,318,397.37
December 991,548.75 991,548.75 11,309,946.12 11,309,946.12
2008 9,927,582.99 11,309,946.12 -1,382,363.13 -12.22% 9,927,582.99 11,309,946.12 -1,382,363.13 -12.22%
January 656,766.69 794,24824 -137,481.55 -17.31% 656,766.69 794,248.24 -137,481.55 -17.31%
February 600,080.00 891,135.91 -291,055.92 -32.66% 1,256,846.69 1,685,384.15 -428,537.46 -25.43%
March 559,538.52 961,289.24 -401,750.72 -41.79% 1,816,385.21 2,646,673.39 -830,288.18 -31.37%
April 999,667.17 1,128,104.82 -128,437.65 -11.39% 2,816,052.38 3,774,778.20 -958,725.82 -25.40%
Рис. 8.18 В отчете показаны все меры, сравнивающие два года
Расчет скользящей годовой суммы
Еще одним распространенным экономическим показателем, не учитываю-
щим сезонные изменения, является скользящая годовая сумма (moving annual
total - МАТ). Этот показатель учитывает агрегацию значения за последние
12 месяцев. В главе 7 мы говорили о методике расчета скользящего среднего.
276 ГЛАВА 8 Логика операций со временем
Здесь мы хотели бы показать формулу для расчета похожего значения при по-
мощи функций логики операций со временем.
Например, рассчитаем скользящую годовую сумму продаж (мера MAT Sales)
для марта 2008 года, сложив показатели продажи с апреля 2007 года по март
2008-го. Сделать это можно при помощи функции DATESINPERIOD. Функция
DATESINPERIOD возвращает список дат, входящих в заданный интервал. При
этом единицей измерения могут быть года, кварталы, месяцы или дни.
MAT Sales :=
CALCULATE (
[Sales Amount];
DATESINPERIOD (
'Date'[Date];
MAX ( 'Date'[Date] );
-1;
YEAR
)
)
- - Рассчитываем меру в новом контексте фильтра,
- - измененном следующим аргументом фильтра.
- - Возвращает таблицу, содержащую
- - значения Date[Date]
- - начиная с последней видимой даты
- - и заканчивая датой, отстоящей
- - от нее на год назад
Использование функции DATESINPERIOD обычно идеально подходит при
расчете скользящей годовой суммы. В образовательных целях полезно будет
рассмотреть и другие техники получения такого же результата. Вот еще один
вариант расчета меры MAT Sales:
MAT Sales :=
CALCULATE (
[Sales Amount];
DATESBETWEEN (
'Date'[Date];
NEXTDAY ( SAMEPERIODLASTYEAR ( LASTDATE ( 'Date'[Date] ) ) );
LASTDATE ( 'Date'[Date] )
)
)
Эта реализация меры требует особого рассмотрения. В формуле использу-
ется функция DATESBETWEEN, возвращающая список дат переданного столб-
ца в рамках указанных дат. Поскольку функция DATESBETWEEN работает на
уровне дней, даже если в отчете используются месяцы, второй и третий пара-
метры функции должны быть выражены в днях. Чтобы получить последнюю
дату, можно использовать функцию LASTDATE. Функция LASTDATE похожа на
МАХ, но вместо значения она возвращает таблицу, которая впоследствии мо-
жет быть передана в качестве параметра другой функции логики операций со
временем. Получив последнюю видимую дату, мы переносим ее на год назад
при помощи функции SAMEPERIODLASTYEAR, после чего берем следующий
день, применив функцию NEXTDAY.
Одна из проблем показателя скользящей годовой суммы заключается в том,
что при его расчете используется агрегация в виде суммирования. Чтобы полу-
чить средние значения, необходимо разделить получившиеся суммы на коли-
чество месяцев, включенных в интервал. Так мы получим скользящую среднего-
довую сумму (moving annual average - МАА):
ГЛАВА 8 Логика операций со временем 277
MAA Sales :=
CALCULATE (
DIVIDE ( [Sales Amount]; DISTINCTCOUNT ( 'Date'[Year Month] ) );
DATESINPERIOD (
'Date'[Date];
MAX ( 'Date'[Date] );
-i;
YEAR
)
)
Как видите, используя функции логики операций со временем, можно про-
водить довольно сложные расчеты. На рис. 8.19 приведен отчет, в котором вы-
ведены и скользящая годовая сумма, и скользящая среднегодовая сумма.
Year Sales Amount MAT Sales MAA Sales
2007 11,309,946.12 11,309,946.12 942,495.51
January 794,248.24 794,248.24 794,248.24
February 891,135.91 1,685,384.15 842,692.08
March 961,289.24 2,646,673.39 882,224.46
April 1,128,104.82 3,774,778.20 943,694.55
May 936,192.74 4,710,970.95 942,194.19
June 982,304.46 5,693,275.41 948,879.23
July 922,542.98 6,615,818.39 945,116.91
August 952,834.59 7,568,652.98 946,081.62
September 1,009,868.98 8,578,521.96 953,169.11
October 914,273.54 9,492,795.50 949,279.55
November 825,601.87 10,318,397.37 938,036.12
December 991,548.75 11,309,946.12 942,495.51
2008 9,927,582.99 9,927,582.99 827,298.58
January 656,766.69 11,172,464.58 931,038.71
February 600,080.00 10,881,408.66 906,784.06
March 559,538.52 10,479,657.94 873,304.83
Рис. 8.19 Меры MAT Sales и МАА Sales легко реализовать
с помощью функций логики операций со временем
Выбор порядка вложенности функций логики операций
со временем
При работе со вложенными функциями логики операций со временем очень
важно выбрать правильный порядок иерархии. В предыдущем примере мы ис-
пользовали следующее выражение для извлечения первого дня для интервала
при расчете скользящей годовой суммы:
NEXTDAY ( SAMEPERIODLASTYEAR ( LASTDATE ( 'Date'[Date] ) ) )
Такого же результата можно добиться, поменяв местами функции NEXTDAY
и SAMEPERIODLASTYEAR:
SAMEPERIODLASTYEAR ( NEXTDAY ( LASTDATE ( 'Date'[Date] ) ) )
278 ГЛАВА 8 Логика операций co временем
Результат почти всегда будет одинаковым, но в последнем случае мы рис-
куем получить неправильную дату в конце периода. Мера для вычисления
скользящей годовой суммы со следующей формулой может выдавать непра-
вильные результаты:
MAT Sales Wrong :=
CALCULATE (
[Sales Amount];
DATESBETWEEN (
'Date'[Date];
SAMEPERIODLASTYEAR ( NEXTDAY ( LASTDATE ( 'Date'[Date] ) ) );
LASTDATE ( 'Date'[Date] )
)
)
Эта версия формулы будет давать ошибочные цифры на верхней границе
периода. Это легко увидеть в отчете, представленном на рис. 8.20.
эг Sales Amount MAT Sales Wrong
12/20/09 386.51 9,410,763.07
12/21/09 65,730.00 9,430,959.15
12/22/09 14,818.07 9,423,905.05
12/23/09 10,483.74 9,411,255.28
12/24/09 35,297.95 9,424,346.89
12/25/09 52,181.37 9,446,383.19
12/26/09 21,490.72 9,456,772.35
12/27/09 19,949.44 9,465,159.42
12/28/09 21,174.80 9,463,422.23
12/29/09 15,790.76 9,343,878.70
12/30/09 16,428.63 9,340,739.84
12/31/09 40,930.59 30,591,343.98
tai 9,353,814.87 30,591,343.98
Рис. 8.20 Мера MAT Sales Wrong выдает неправильный результат в конце 2009 года
Вплоть до 30 декабря 2009 года все значения меры верны, однако в послед-
ний день года сумма оказалась сильно завышена. Дело в том, что для 31 декаб-
ря 2009 года функция NEXTDAY должна вернуть таблицу, содержащую 1 января
2010 года, но не может этого сделать по причине отсутствия такой даты в на-
шем календаре. В результате функция NEXTDAY возвращает пустую таблицу.
Функция SAMEPERIODLASTYEAR, получив на вход пустую таблицу, также воз-
вращает пустую таблицу. А поскольку функция DATESBETWEEN ожидает на
вход скалярную величину, пустота, сгенерированная функцией SAMEPERIOD-
LASTYEAR, будет воспринята ей как значение BLANK. В свою очередь, BLANK,
приведенный к типу DateTime, дает ноль, что, как мы знаем, соответствует
дате 30 декабря 1899 года. Следовательно, функция DATESBETWEEN возвратит
все даты из календаря, поскольку пустое значение в качестве нижней даты не
устанавливает никаких ограничений на интервал. Как итог мы получаем со-
вершенно неправильный результат.
ГЛАВА 8 Логика операций со временем 279
Решение здесь очень простое и состоит в использовании правильного по-
рядка вложенности функций. Если первой будет вызвана функция SAMEPERI-
ODLASTYEAR, то из 31 декабря 2009 года мы перенесемся ровно на год на-
зад - в 31 декабря 2008 года. А применение функции NEXTDAY к этой дате даст
вполне корректное значение 1 января 2009 года, которое присутствует в нашей
таблице дат.
Как правило, все функции логики операций со временем возвращают набор
дат, существующих в календаре. Если конкретной даты в календаре нет, ре-
зультатом работы функции будет пустая таблица, которая трансформируется
в BLANK. В некоторых сценариях такое поведение функций может приводить
к непредсказуемым результатам, как показано в данном разделе. При расче-
те скользящей годовой суммы более быстро и безопасно отработает функция
DATESINPERIOD, но описанные в этом разделе нюансы могут пригодиться вам
при комбинировании функций логики операций со временем во время вычис-
ления других показателей.
Знакомство с полуаддитивными вычислениями
Техника агрегирования значений по разным временным интервалам, которую
вы изучили, прекрасно работает с обычными аддитивными мерами. Аддитив-
ной (additive measure) называется мера, вычисления по которой могут агре-
гироваться простой операцией суммирования при срезе по любому атрибуту.
Давайте для примера возьмем меру, отражающую сумму продаж. Сумма про-
даж по всем покупателям будет равна арифметической сумме всех продаж по
каждому отдельному покупателю. Также верно и то, что сумма продаж за год
составляется из продаж за каждый день этого года. В аддитивных мерах нет
ничего особенного - их очень легко понять и использовать.
Но не все вычисления являются аддитивными. Существуют и неаддитив-
ные меры (non-additive measure). Примером такой меры может служить коли-
чество уникальных полов покупателей. Для каждого отдельного покупателя
результат будет составлять 1. Если вычислять это значение для группы поку-
пателей, итог никогда не будет превышать количество полов (в базе Contoso
это три: пустое значение, М (Male) и F (Female)). Таким образом, итог по груп-
пе покупателей, дат или любому другому атрибуту не может быть рассчитан
путем простого суммирования индивидуальных значений. Неаддитивные
меры - частые гости в отчетах, и в большинстве случаев они характеризуют
как раз уникальное количество чего бы то ни было. Неаддитивные меры труд-
нее понять и использовать по сравнению с традиционными аддитивными. Но
есть и третий - гораздо более сложный - тип аддитивности, именуемый полу-
аддитивностью.
Полуаддитивные меры (semi-additive measure) характеризуются одним ти-
пом агрегации (обычно это сумма) по одним столбцам и другим (например,
значением на последнюю дату) - по другим. Отличным примером такой меры
может являться баланс банковского счета. Баланс всех клиентов банка мож-
но рассчитать путем суммирования индивидуальных балансов. В то же время
баланс клиента за год не является суммой балансов по месяцам. Вместо этого
280 ГЛАВА 8 Логика операций со временем
он равняется сумме баланса на последнюю дату года. Таким образом, срез ба-
лансов по покупателям агрегируется стандартным способом, тогда как срез по
дате требует совершенно иного подхода. Посмотрите для примера на рис. 8.21.
Name Date Balance
Katie Jordan 1/31/2010 1,687.00
Luis Bonifaz 1/31/2010 1,470.00
Maurizio Macagno 1/31/2010 1,500.00
Katie Jordan 2/28/2010 2,812.00
Luis Bonifaz 2/28/2010 2,450.00
Maurizio Macagno 2/28/2010 2,500.00
Katie Jordan 3/31/2010 3,737.00
Luis Bonifaz 3/31/2010 3,430.00
Maurizio Macagno 3/31/2010 3,500.00
Рис. 8.21 Фрагмент данных по полуаддитивной мере
По этому отчету мы видим, что баланс на счете Кэти Джордан (Katie Jordan)
на конец января составлял 1687,00, тогда как по истечении февраля он увели-
чился до 2812,00. Рассматривая два месяца вместе, мы не можем суммировать
балансы по ним. Вместо этого мы берем последний доступный баланс. С дру-
гой стороны, суммарный баланс клиентов на конец января можно рассчитать
путем сложения балансов по всем трем клиентам.
Если использовать простую операцию суммирования для всех вычислений
по этой мере, может получиться отчет, показанный на рис. 8.22.
Year Katie Jordan Luis Bonifaz Maurizio Macagno Total
CY 2010 17,742.00 15,631.00 15,650.00 49,023.00
Q1 8,236.00 7,350.00 7,500.00 23,086.00
January 1,687.00 1,470.00 1,500.00 4,657.00
February 2,812.00 2,450.00 2,500.00 7,762.00
March 3,737.00 3,430.00 3,500.00 10,667.00
Q2 6,975.00 6,076.00 6,200.00 19,251.00
April 2,250.00 1,960.00 2,000.00 6,210.00
May 2,025.00 1,764.00 1,800.00 5,589.00
June 2,700.00 2,352.00 2,400.00 7,452.00
Q3 2,531.00 2,205.00 1,950.00 6,686.00
July 2,531.00 2,205.00 1,950.00 6,686.00
Total 17,742.00 15,631.00 15,650.00 49,023.00
Рис. 8.22 Здесь мы видим два типа итогов: итоги по датам
для каждого клиента и итоги по всем клиентам в рамках разных временных периодов
Как видите, значения по месяцам показаны правильно. Но по кварталам
и годам производится обычное суммирование балансов, что не может нас
ГЛАВА 8 Логика операций со временем 281
устраивать. Правильный отчет продемонстрирован на рис. 8.23, здесь по атри-
буту времени всегда берется последнее известное значение.
Year Katie Jordan Luis Bonifaz Maurizio Macagno Total
CY 2010 2,531.00 2,205.00 1,950.00 6,686.00
Q1 3,737.00 3,430.00 3,500.00 10,667.00
January 1,687.00 1,470.00 1,500.00 4,657.00
February 2,812.00 2,450.00 2,500.00 7,762.00
March 3,737.00 3,430.00 3,500.00 10,667.00
Q2 2,700.00 2,352.00 2,400.00 7,452.00
April 2,250.00 1,960.00 2,000.00 6,210.00
May 2,025.00 1,764.00 1,800.00 5,589.00
June 2,700.00 2,352.00 2,400.00 7,452.00
Q3 2,531.00 2,205.00 1,950.00 6,686.00
July 2,531.00 2,205.00 1,950.00 6,686.00
Total 2,531.00 2,205.00 1,950.00 6,686.00
Рис. 8.23 Этот отчет показывает правильные значения
Работать с полуаддитивными мерами бывает непросто как раз из-за разных
подходов к агрегации значений по разным осям и необходимости обращать
внимание на все нюансы. В следующих разделах мы познакомимся с базовыми
техниками взаимодействия с полуаддитивными вычислениями.
Использование функций LASTDATE и LASTNONBLANK
DAX предлагает сразу несколько функций для удобной работы с полуадди-
тивными мерами. Но в обращении с такими нестандартными вычисления-
ми найти нужную функцию - это только полдела. Здесь необходимо уделять
внимание самым незначительным нюансам в расчетах. В данном разделе мы
продемонстрируем разные версии одного и того же кода, которые будут или
не будут работать в зависимости от данных. Неправильные решения мы по-
казываем исключительно в образовательных целях. К тому же в более сложных
сценариях к правильному варианту решения нужно идти поэтапно.
Первой функцией, с которой мы познакомимся, будет LASTDATE. Мы уже
использовали эту функцию ранее при расчете скользящей годовой суммы.
Функция LASTDATE возвращает таблицу из одной строки, содержащей по-
следнюю видимую дату в текущем контексте фильтра. Будучи использованной
в качестве аргумента фильтра в функции CALCULATE, LASTDATE перезаписы-
вает контекст фильтра по таблице дат таким образом, чтобы видимой осталась
только последняя дата выбранного временного периода. В следующем фраг-
менте кода вычисляется последний доступный баланс с использованием функ-
ции LASTDATE, перезаписывающей контекст фильтра по таблице Date:
LastBalance :=
CALCULATE (
SUM ( Balances[Balance] );
282 ГЛАВА 8 Логика операций со временем
LASTDATE ( 'Date'[Date] )
)
Функция LASTDATE очень проста в использовании, но, к сожалению, она не
годится для многих полуаддитивных расчетов. Фактически эта функция ска-
нирует таблицу дат, всегда возвращая последнюю видимую дату. Например, на
уровне месяца она всегда вернет последний день месяца, а на уровне кварта-
ла - последний день квартала. Если на этот день нет данных в рассчитываемой
мере, результат будет пустым значением. Посмотрите на рис. 8.24. Здесь мы не
видим результатов по третьему кварталу (03), а также общих итогов. Посколь-
ку мера LastBalance по третьему кварталу вернула пустое значение, этот квар-
тал даже не отображается в отчете, что приводит в некоторое замешательство.
Year Katie Jordan Luis Bonifaz Maurizio Macagno Total
CY 2010
Q1 3,737.00 3,430.00 3,500.00 10,667.00
January 1,687.00 1,470.00 1,500.00 4,657.00
February 2,812.00 2,450.00 2,500.00 7,762.00
March 3,737.00 3,430.00 3,500.00 10,667.00
Q2 2,700.00 2,352.00 2,400.00 7,452.00
April 2,250.00 1,960.00 2,000.00 6,210.00
May 2,025.00 1,764.00 1,800.00 5,589.00
June 2,700.00 2,352.00 2,400.00 7,452.00
Total
Рис. 8.24 Отчет с функцией LASTDATE является неполным,
если на последнюю дату месяца нет информации
Если вместо месяца на нижнем уровне отчета использовать группировку по
дням, проблема использования функции LASTDATE станет еще более очевид-
ной, как видно по рис. 8.25. Третий квартал теперь стал видим, но значения по
нему отсутствуют.
В случае если в отчете могут присутствовать данные на предпоследний
день даты, а на последний - отсутствовать, лучше будет использовать функ-
цию LASTNONBLANK. Функция LASTNONBLANK представляет собой итератор,
сканирующий таблицу и возвращающий последнее значение, для которого
второй параметр возвращает непустое значение. В нашем примере мы ис-
пользуем функцию LASTNONBLANK для сканирования таблицы Date в поисках
последней даты, для которой присутствовало значение в таблице Balances:
LastBalanceNonBlank :=
CALCULATE (
SUM ( Balances[Balance] );
LASTNONBLANK (
'Date*[Date];
COUNTROWS ( RELATEDTABLE ( Balances ) )
)
)
ГЛАВА 8 Логика операций co временем 283
Year
Katie Jordan Luis Bonifaz Maurizio Macagno Total
CY 2010
Q1 3,737.00 3,430.00 3,500.00 10,667.00
01/31/2010 1,687.00 1,470.00 1,500.00 4,657.00
02/28/2010 2,812.00 2,450.00 2,500.00 7,762.00
03/31/2010 3,737.00 3,430.00 3,500.00 10,667.00
Q2 2.700.00 2,352.00 2,400.00 7,452.00
04/30/2010 2,250.00 1,960.00 2,000.00 6,210.00
05/31/2010 2,025.00 1,764.00 1,800.00 5,589.00
06/30/2010 2,700.00 2,352.00 2,400.00 7,452.00
Q3
07/15/2010 2,531.00 2,205.00 4,736.00
07/18/2010 1,950.00 1,950.00
Total
Рис. 8.25 При срезе по дням данные присутствуют на нижнем уровне отчета,
но на уровнях агрегации мы видим пустые значения
На уровне месяца функция LASTNONBLANK проходит по всем дням месяца
и для каждого из них проверяет таблицу Balances на присутствие значений.
Внутренняя функция RELATEDTABLE выполняется в контексте строки итерато-
ра LASTNONBLANK, а значит, возвращает строки только по текущей дате. Если
баланса для этой даты не было, результатом вычисления будет пустая таблица,
и функция COUNTROWS вернет пустое значение. В итоге на выходе функция
LASTNONBLANK выдаст последнюю дату, для которой значение второго пара-
метра было не BLANK.
Если по всем клиентам балансы заполнены на одинаковые даты, функция
LASTNONBLANK отработает идеально. Но в нашем примере для одного и того
же месяца балансы по разным клиентам указаны для разных дат, и это создает
определенные проблемы. Как мы уже говорили в начале раздела, в случае с по-
луаддитивными мерами дьявол кроется в деталях. Функция LASTNONBLANK
справляется с задачей гораздо лучше, чем LASTDATE, поскольку выполняет по-
иск последней даты с непустым значением. Но для расчета итоговых показате-
лей она не годится, что видно по рис. 8.26.
По каждому отдельному клиенту расчеты выглядят правильными. И дей-
ствительно, последний известный баланс на счету Кэти Джордан составлял
2531,00, и именно эта сумма была помещена в итоговой строке. Для Луиса Бо-
нифаца (Luis Bonifaz) и Маурицио Маканьо (Maurizio Macagno) мы также видим
правильные цифры. И все же итоговые значения не совсем верны. Общая ито-
говая цифра в отчете показывает значение 1950,00, что совпадает с балансом
Маурицио Маканьо. Согласитесь, довольно странно выглядит, когда в итоговой
строке отчета показаны три значения (2531,00, 2205,00 и 1950,00), а в общем
итоге присутствует только последнее из них.
Причину такого поведения отчета объяснить непросто. Когда контекст
фильтра включает в себя Кэти Джордан, последней датой с актуальным значе-
нием является 15 июля. В случае с Маурицио Маканьо такой датой будет уже
18 июля. А когда в контексте фильтра не присутствуют клиенты вовсе, послед-
284 ГЛАВА 8 Логика операций со временем
ней датой будет 18 июля, что совпадает с датой последнего актуального балан-
са Маурицио Маканьо. Ни у Кэти Джордан, ни у Луиса Бонифаца на 18 июля
баланс не значится. Таким образом, для июля формула покажет только значе-
ние по Маурицио.
Year Katie Jordan Luis Bonifaz Maurizio Macagno Total
CY 2010 2,531.00 2,205.00 1,950.00 1,950.00
Q1 3,737.00 3,430.00 3,500.00 10,667.00
01/31/2010 1,687.00 1,470.00 1,500.00 4,657.00
02/28/2010 2,812.00 2,450.00 2,500.00 7,762.00
03/31/2010 3,737.00 3,430.00 3,500.00 10,667.00
Q2 2,700.00 2,352.00 2,400.00 7,452.00
04/30/2010 2,250.00 1,960.00 2,000.00 6,210.00
05/31/2010 2,025.00 1,764.00 1,800.00 5,589.00
06/30/2010 2,700.00 2,352.00 2,400.00 7,452.00
Q3 2,531.00 2,205.00 1,950.00 1,950.00
07/15/2010 2,531.00 2,205.00 4,736.00
07/18/2010 1,950.00 1,950.00
Total 2,531.00 2,205.00 1,950.00 1,950.00
Рис. 8.26 Отчет почти правильный.
Вопросы вызывают только строки итогов и третьего квартала
Как часто и бывает, в данном случае в поведении DAX нет никаких ошибок.
Проблема лишь в том, что наш код пока не учитывает тот факт, что для разных
клиентов в нашей модели данных могут присутствовать разные последние
даты баланса.
В зависимости от наших требований формула может быть поправлена
разными способами. Для начала нужно определиться с тем, что показывать
в строке итогов. Учитывая то, что на 18 июля в модели есть только частичные
данные, можно:
принять 18 июля как последнюю дату для всех клиентов вне зависимости
от того, какие последние даты для каждого из них. Таким образом, если
у клиента нет данных на конкретную дату, значит, у него был нулевой
баланс;
учитывать последнюю дату для каждого отдельного клиента и агрегиро-
вать итоги, исходя из этого. В этом случае актуальным балансом клиента
будет считаться последний доступный для него баланс.
Оба определения правильны, и здесь все зависит от требований отчета. А раз
так, мы рассмотрим написание кода для обоих вариантов. Наиболее простым
из них является тот, в котором последней датой считается та, на которую есть
какие-то данные, вне зависимости от клиентов. Для этого варианта нам лишь
слегка придется изменить поведение функции LASTNONBLANK:
LastBalanceAUCustomers : =
VAR LastDateAllCustomers =
CALCULATETABLE (
ГЛАВА 8 Логика операций со временем 285
LASTNONBLANK (
'Date'[Date];
COUNTROWS ( RELATEDTABLE ( Balances ) )
);
ALL ( Balances[Name] )
)
VAR Result =
CALCULATE (
SUM( Balances[Balance] );
LastDateAUCustomers
)
RETURN
Result
Здесь мы использовали функцию CALCULATETABLE для очистки фильтра по
клиенту при вычислении выражения с функцией LASTNONBLANK. В этом слу-
чае для строки общих итогов функция LASTNONBLANK всегда будет возвра-
щать 18 июля - не учитывая текущего клиента в контексте фильтра. В резуль-
тате итоговые балансы Кэти Джордан и Луиса Бонифаца остались пустыми, что
видно по рис. 8.27.
Year Katie Jordan Luis Bonifaz Maurizio Macagno Total
CY 2010 1,950.00 1,950.00
Q1 3,737.00 3,430.00 3,500.00 10,667.00
01/31/2010 1,687.00 1,470.00 1,500.00 4,657.00
02/28/2010 2,812.00 2,450.00 2,500.00 7,762.00
03/31/2010 3,737.00 3,430.00 3,500.00 10,667.00
Q2 2,700.00 2,352.00 2,400.00 7,452.00
04/30/2010 2,250.00 1,960.00 2,000.00 6,210.00
05/31/2010 2,025.00 1,764.00 1,800.00 5,589.00
06/30/2010 2,700.00 2,352.00 2,400.00 7,452.00
Q3 1,950.00 1,950.00
07/15/2010 2,531.00 2,205.00 4,736.00
07/18/2010 1,950.00 1,950.00
Total 1,950.00 1,950.00
Рис. 8.27 Использование единой последней даты для всех клиентов
привело к разным результатам в итоговой строке
Второй вариант требует чуть больших пояснений. Когда мы используем
свою последнюю дату для каждого клиента, итоговые значения не могут быть
рассчитаны, просто исходя из действующего контекста фильтра на уровне
итогов. Формула должна вычислять подытоги для каждого клиента и агреги-
ровать итоги. Это тот самый случай, когда применение итерационной функции
является самым простым и эффективным решением. В следующем примере
внешний итератор SUMX используется для того, чтобы суммировать промежу-
точные итоги по клиентам:
286 ГЛАВА 8 Логика операций со временем
LastBalancelndividualCustomer :=
SUMX (
VALUES ( Balances[Name] );
CALCULATE (
SUM ( Balances[Balance] );
LASTNONBLANK (
'Date'[Date];
COUNTROWS ( RELATEDTABLE ( Balances ) )
)
)
)
Эта мера рассчитывает последнюю дату индивидуально для каждого клиен-
та. После этого происходит агрегирование значений. Результат работы меры
можно видеть на рис. 8.28.
Year Katie Jordan Luis Bonifaz Maurizio Macagno Total
CY 2010 2,531.00 2,205.00 1,950.00 6,686.00
Q1 3,737.00 3,430.00 3,500.00 10,667.00
01/31/2010 1,687.00 1,470.00 1,500.00 4,657.00
02/28/2010 2,812.00 2,450.00 2,500.00 7,762.00
03/31/2010 3,737.00 3,430.00 3,500.00 10,667.00
Q2 2,700.00 2,352.00 2,400.00 7,452.00
04/30/2010 2,250.00 1,960.00 2,000.00 6,210.00
05/31/2010 2,025.00 1,764.00 1,800.00 5,589.00
06/30/2010 2,700.00 2,352.00 2,400.00 7,452.00
Q3 2,531.00 2,205.00 1,950.00 6,686.00
07/15/2010 2,531.00 2,205.00 4,736.00
07/18/2010 1,950.00 1,950.00
Total 2,531.00 2,205.00 1,950.00 6,686.00
Рис. 8.28 Теперь итоги для каждого клиента выводятся на свою последнюю дату
Примечание При большом количестве клиентов мера LastBalancelndividualCustomer мо-
жет вычисляться неэффективно. Причина в том, что в формуле содержится два вложенных
итератора, и внешний отличается большей гранулярностью. Более эффективный и быст-
рый подход к этой задаче будет описан в главе 10, и заключается он в использовании
функции TREATAS, которую мы обсудим далее в данной книге.
\___________________________________________________________________________J
Как вы поняли, сложность в обращении с полуаддитивными мерами кроется
вовсе не в коде, а в выборе подхода к таким мерам. Когда этот выбор сделан,
написать правильную формулу не представляет большого труда.
В данном разделе мы остановились на двух наиболее распространенных
функциях для работы с полуаддитивными мерами: LASTDATE и LASTNON-
BLANK. Есть еще две похожие функции для получения первой даты периода,
а не последней. Их названия FIRSTDATE и FIRSTNONBLANK. Существуют и дру-
ГЛАВА 8 Логика операций со временем 287
гие функции, целью которых является упрощение расчетов в сценариях, по-
добных описанному выше. Мы поговорим о них в следующем разделе.
Работа с остатками на начало и конец периода
Язык DAX предлагает множество функций вроде LASTDATE, облегчающих по-
лучение значений на начало и конец определенного периода. И хотя эти функ-
ции довольно полезны, у всех из них есть определенные ограничения, кото-
рые мы описывали в предыдущем разделе. В общем, они нормально работают
только при наличии данных для всех без исключения дат.
Речь идет о функциях STARTOFYEAR, STARTOFQUARTER, STARTOFMONTH
и соответствующих им аналогах ENDOFYEAR, ENDOFQUARTER и ENDOF-
MONTH. Как ясно из названия, функция STARTOFYEAR всегда возвращает
1 января выбранного года в текущем контексте фильтра. STARTOFQUARTER
и STARTOFMONTH дадут начало квартала и месяца соответственно.
В качестве примера мы подготовили другой сценарий, в котором будут ис-
пользованы полуаддитивные меры. В демонстрационном файле содержатся
цены на акции Microsoft в период с 2013 по 2018 год. Цены актуальны для каж-
дого дня. Но какие значения будут показываться на других уровнях? Скажем,
для квартала? Обычно в таких случаях выводят последнюю цену акций за пе-
риод. В общем, и здесь нас ждет работа с полуаддитивными мерами.
Простая реализация с получением последней цены акций за период пре-
красно работает в несложных отчетах. Следующая формула вычисляет послед-
нюю цену за выбранный период, усредняя ее в случае наличия нескольких
строк за один день:
Last Value :=
CALCULATE (
AVERAGE ( MSFT[Value] );
LASTDATE ( 'Date'[Date] )
)
Мера хорошо отработает на дневном графике цен на акции, пример которо-
го показан на рис. 8.29.
Но в том, что график так приятно выглядит, нет нашей заслуги как разра-
ботчиков кода. Просто мы вынесли даты на ось X, и клиентский инструмент -
в данном случае Power BI - неплохо поработал над тем, чтобы проигнорировать
пустые значения в нашем наборе данных. В результате мы получили непре-
рывную линию на графике. Но если ту же меру вынести в область значений
в матрице со срезом по году и месяцу, мы увидим пропуски в ячейках, как
показано на рис. 8.30.
Использование функции LASTDATE подразумевает возможность появления
пустых ячеек, в случае если в последний день конкретного месяца не было зна-
чения в таблице. А это может быть, если этот день приходился на выходные или
праздничные дни. Правильная формула для меры Last Value будет выглядеть
так:
Last Value :=
CALCULATE (
288 ГЛАВА 8 Логика операций со временем
AVERAGE ( MSFT[Value] );
LASTNONBLANK (
'Date'[Date];
COUNTROWS ( RELATEDTABLE ( MSFT ) )
)
)
Рис. 8.29 Линейный график с ежедневными ценами на акции выглядит впечатляюще
Month CY 2013 CY 2014 CY 2015 CY 2016 CY 2017 CY 2018 Total
January 37.84 64.65 95.01 95.01
February 38.31 50.88 63.98 93.77 93.77
March 40.99 40.66 55.23 65.86
April 40.40 48.64 93.52 93.52
May 34.90 53.00 69.84
June 41.70 44.15 51.17 68.93
July 31.84 43.16 46.70 72.70
August 43.52 57.46 74.77
September 33.28 46.36 44.26 57.60
October 35.41 46.95 59.92 83.18
November 54.35 60.26 84.17
December 37.41 46.45 55.48
Total 37.41 46.45 55.48
Рис. 8.30 Матрица по годам и месяцам содержит пустые ячейки
Если к таким формулам подходить со всей осторожностью, результаты не
станут для вас неожиданными. Что бы мы делали, если бы нам понадобилось
рассчитать прирост цен на акции Microsoft с начала квартала? Можно было бы
написать такой код, который будет ошибочным:
SOQ : =
CALCULATE (
ГЛАВА 8 Логика операций со временем 289
AVERAGE ( MSFT[Value] );
STARTOFQUARTER ( 'Date'[Date] )
)
SOQ% :=
DIVIDE (
[Last Value] - [SOQ];
[SOQ]
)
Функция STARTOFQUARTER возвращает дату начала текущего квартала вне
зависимости от того, есть ли значение в эту дату. Например, начало первого
квартала - дата 1 января, являющаяся праздничным днем. Соответственно,
цен на акции на 1 января просто не бывает, и предыдущая мера выдаст резуль-
тат, показанный на рис. 8.31.
Year Last Value SOQ SOQ%
CY 2016 62.14
Q1 55.23
January 55.09
February 50.88
March 55.23
Q2 51.17 55.57 -7.92%
April 49.87 55.57 -10.26%
May 53.00 55.57 -4.62%
June 51.17 55.57 -7.92%
Q3 57.60 51.16 12.59%
July 56.68 51.16 10.79%
August 57.46 51.16 12.31%
September 57.60 51.16 12.59%
Q4 62.14
October 59.92
November 60.26
December 62.14
Рис. 8.31 Функция STARTOFQUARTER вернет дату начала квартала
вне зависимости оттого, был ли этот день праздничным
Легко заметить, что для первого квартала значение меры SOQ не вычислено.
И такая проблема будет в каждом квартале, который начинается с нерабочего
дня. Чтобы получить значения на начало или конец периода, но учитывать при
этом только даты, для которых заполнены данные, следует прибегнуть к помо-
щи функций FIRSTNONBLANK и LASTNONBLANK в сочетании с другими функ-
циями логики операций со временем, такими как DATESINPERIOD.
Гораздо лучшей реализацией меры SOQ будет следующая:
SOQ : =
VAR FirstDatelnQuarter =
CALCULATETABLE (
290 ГЛАВА 8 Логика операций со временем
FIRSTNONBLANK (
'Date'[Date];
COUNTROWS ( RELATEDTABLE( MSFT ) )
);
PARALLELPERIOD ( 'Date'[Date]; 0; QUARTER )
)
VAR Result =
CALCULATE (
AVERAGE ( MSFT[Value] );
FirstDatelnQuarter
)
RETURN
Result
Этот код, конечно, труднее понять и написать. Зато работать он будет вне за-
висимости от отсутствия каких-либо данных в исходной таблице. Вывод новой
версии меры SOQ можно видеть на рис. 8.32.
Year Last Value SOQ SOQ%
CY2016 62.14 54.80 13.39%
Q1 55.23 54.80 0.78%
January 55.09 54.80 0.53%
February 50.88 54.80 -7.15%
March 55.23 54.80 0.78%
Q2 51.17 55.57 -7.92%
April 49.87 55.57 -10.26%
May 53.00 55.57 -4.62%
June 51.17 55.57 -7.92%
Q3 57.60 51.16 12.59%
July 56.68 51.16 10.79%
August 57.46 51.16 12.31%
September 57.60 51.16 12.59%
Q4 62.14 57.42 8.22%
October 59.92 57.42 4.35%
November 60.26 57.42 4.95%
December 62.14 57.42 8.22%
Рис. 8.32 Новая версия меры SOQ выводит данные
вне зависимости от распределения выходных и праздничных дней
Не боясь показаться излишне занудными, мы просто вынуждены еще раз
повторить концепцию работы с полуаддитивными мерами, которую описы-
вали в начале раздела. Дьявол кроется в деталях. Язык DAX предлагает мно-
жество функций для работы с датами, но они изящно работают только в слу-
чае присутствия данных в таблице для всех без исключения дат. К сожалению,
в реальности это далеко не всегда так. И в таких сценариях нужно очень осто-
рожно подходить к использованию стандартных функций логики операций
со временем. Скорее, эти функции можно рассматривать как кирпичики для
составления более сложных выражений. Комбинируя функции для работы со
ГЛАВА 8 Логика операций со временем 291
временем, можно производить очень тонкие расчеты, учитывающие все осо-
бенности конкретной модели данных.
Именно поэтому, вместо того чтобы дать вам по одному работающему при-
меру для каждой функции, мы предпочли провести вас через всю логику при
написании сложных вычислений. Цель этой главы - и всей книги в целом - со-
стоит не в том, чтобы показать, как пользоваться функциями. Мы хотим, чтобы
вы научились мыслить категориями DAX, самостоятельно определять нюансы
того или иного вычисления и производить расчеты даже тогда, когда стандарт-
ных средств языка для этого оказывается недостаточно.
В следующем разделе мы сделаем еще один шаг вперед в этом направлении
и покажем, как можно производить сложные вычисления в области времен-
ных периодов без применения функций логики операций со временем. Цель
на этот раз будет не только образовательная. Дело в том, что, работая с нестан-
дартными календарями, например с недельным, вы не сможете воспользо-
ваться специальными функциями DAX. Так что вы должны быть готовы к тому,
что вам придется писать сложный код без помощи функций логики операций
со временем.
Усовершенствованные методы работы с датой
и временем
В данном разделе мы обсудим важные особенности работы функций логики
операций со временем. Чтобы углубиться в эту тему, мы напишем несколько
вычислений с использованием стандартных функций языка DAX, таких как
FILTER, ALL, VALUES, MIN и MAX. Цель этого раздела состоит не в том, чтобы
отговорить вас от использования специальных функций для работы со вре-
менем в пользу стандартных. Наоборот, мы сделаем все, чтобы вы как можно
глубже разобрались в принципах их работы на конкретных примерах. Это по-
может вам в будущем самостоятельно писать достаточно сложные формулы,
даже если для этого будет не хватать стандартного набора функций. Также вы
увидите, что переход на стандартные функции DAX зачастую может приводить
к значительному увеличению объема кода, поскольку в специальных функци-
ях логики операций со временем многие действия от вас просто скрыты.
Умение писать вычисления для работы с датами и временем при помощи
традиционных функций DAX пригодится вам при работе с нестандартными
календарями, в которых первым днем года может быть отнюдь не 1 января.
В частности, вы сможете свободно работать с недельными календарями стан-
дарта ISO (International Organization for Standardization). В этом случае ваши
обычные представления о том, что год, месяц и квартал могут быть легко вы-
числены по конкретной дате, не будут иметь ничего общего с реальностью. Вы
вправе корректировать логику выражений по своему усмотрению путем изме-
нения условий в фильтрах, а можете воспользоваться помощью дополнитель-
ных столбцов в таблице дат, чтобы чересчур не усложнять итоговую формулу.
Примеры такого подхода вы найдете далее в этом разделе, когда мы будем об-
суждать использование нестандартных календарей.
292 ГЛАВА 8 Логика операций со временем
Вычисления нарастающим итогом
Ранее мы уже описывали работу специальных функций, предназначенных
для вычисления показателей нарастающим итогом с начала месяца, кварта-
ла и года - DATESMTD, DATESQTD и DATESYTD соответственно. Результат этих
функций очень напоминает результат выполнения стандартной функции
FILTER с определенным набором параметров. Возьмем, к примеру, функцию
DATESYTD:
DATESYTD ( 'Date'[Date] )
В расширенном виде эту функцию можно заменить функцией FILTER, встро-
енной в CALCULATETABLE, как показано ниже:
CALCULATETABLE (
VAR LastDatelnSelection = MAX ( 'Date'[Date] )
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= LastDatelnSelection
&& YEAR ( 'Date'[Date] ) = YEAR ( LastDatelnSelection )
)
)
Или взять функцию DATESMTD:
DATESMTD ( 'Date'[Date] )
Этот код легко меняется на следующий:
CALCULATETABLE (
VAR LastDatelnSelection = MAX ( 'Date'[Date] )
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= LastDatelnSelection
&& YEAR ( 'Date'[Date] ) = YEAR ( LastDatelnSelection )
&& MONTH ( 'Date'[Date] ) = MONTH ( LastDatelnSelection )
)
)
He стоит уточнять, что и функция DATESQTD работает по похожему шабло-
ну. Все альтернативные варианты действуют примерно одинаково: извлекают
информацию о годе, месяце или квартале из последней даты в текущем вы-
боре, а затем используют полученное значение для построения подходящего
фильтра.
Преобразование контекста и функции логики операций со временем
Вы, наверное, заметили, что в предыдущих фрагментах кода мы везде заключали все
выражение в обрамляющую функцию CALCULATETABLE. Это делалось для того, чтобы ини-
циировать преобразование контекста, что необходимо в случае, если дата указана в ка-
честве ссылки на столбец. Ранее в данной главе мы уже говорили, что указание столбца
ГЛАВА 8 Логика операций со временем 293
с датой в качестве первого параметра функции логики операций со временем автомати-
чески ведет к получению таблицы путем вложенного вызова функций CALCULATETABLE
и DISTINCT:
DATESYTD ( 'Date'[Date] )
преобразуется в
DATESYTD ( CALCULATETABLE ( DISTINCT ( 'Date'[Date] ) ) )
Таким образом, преобразование контекста возникает только для перевода столбца
в таблицу. Этого не происходит, если в качестве параметра в функцию передан не стол-
бец, а таблица. Так что более точный аналог функции DATESYTD будет следующий:
DATESYTD ( 'Date'[Date] )
Он преобразуется в
VAR LastDatelnSelection =
МАХХ ( CALCULATETABLE ( DISTINCT ( 'Date'[Date] ) ); [Date] )
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= LastDatelnSelection
&& YEAR ( 'Date'[Date] ) = YEAR ( LastDatelnSelection )
)
Преобразование контекста не происходит, если в качестве параметра в функцию ло-
гики операций со временем передается таблица.
Функция CALCULATETABLE, генерируемая при передаче ссылки на столбец,
необходима в случае, если есть активный контекст строки. Взгляните на следу-
ющие два вычисляемых столбца, созданных в таблице Date:
'Date'[CountDatesYTD] = COUNTROWS ( DATESYTD ( 'Date'[Date] ) )
'Date'[CountFilter] =
COUNTROWS (
VAR LastDatelnSelection =
MAX ( 'Date'[Date] )
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= LastDatelnSelection
&& YEAR ( 'Date'[Date] ) = YEAR ( LastDatelnSelection )
)
)
И хотя они выглядят похоже, это на самом деле не так. Результат вычисления
этих столбцов виден на рис. 8.33.
CountDatesYTD возвращает количество дней с начала года до даты в текущей
строке. Чтобы добиться такого результата, функции DATESYTD нужно проана-
лизировать текущий контекст фильтра и извлечь выбранный период. Однако,
поскольку речь идет о вычисляемых столбцах, контекста фильтра у нас нет. По-
ведение столбца CountFilter объяснить проще. При вычислении максимальной
294 ГЛАВА 8 Логика операций со временем
даты всегда возвращается последняя дата из календаря, поскольку в контексте
фильтров нет никаких фильтров. CountDatesYTD ведет себя иначе, ведь функ-
ция DATESYTD инициирует преобразование контекста по причине передачи
в нее столбца с датами. Таким образом, создается контекст фильтра с одной
текущей датой из итерации.
Date CountDatesYTD CountFilter
01/01/07 1 365
01/02/07 2 365
01/03/07 3 365
01/04/07 4 365
01/05/07 5 365
01/06/07 6 365
01/07/07 7 365
01/08/07 8 365
01/09/07 9 365
01/10/07 10 365
01/11/07 11 365
01/12/07 12 365
01/13/07 13 365
Рис. 8.33 В столбце CountFilter
не выполняется преобразование контекста,
а в столбце CountDatesYTD - выполняется
Если вы используете функцию DATESYTD, но при этом знаете, что код не
будет запускаться внутри контекста строки, можете убрать внешнюю функцию
CALCULATETABLE, которая в этом случае будет просто не нужна. Это характер-
но для аргумента фильтра в функции CALCULATE, вызванной не внутри итера-
тора, - где обычно используется функция DATESYTD. В таких случаях вместо
функции DATESYTD можно написать:
VAR LastDatelnSelection = МАХ ( 'Date*[Date] )
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= LastDatelnSelection
&& YEAR ( 'Date'[Date] ) = YEAR ( LastDatelnSelection )
)
С другой стороны, чтобы получить дату из контекста строки, например в вы-
числяемом столбце, можно извлечь эту дату из текущей строки в переменную
вместо использования функции МАХ:
\IM( CurrentDate = 'Date*[Date]
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= CurrentDate
&& YEAR ( 'Date'[Date] ) = YEAR ( CurrentDate )
)
ГЛАВА 8 Логика операций co временем 295
Функция DATESYTD позволяет указать дату окончания года, что бывает по-
лезно для вычислений нарастающим итогом в финансовых календарях. На-
пример, если финансовый год начинается 1 июля, в качестве даты окончания
года можно указать дату 30 июня одним из следующих способов:
DATESYTD ( 'Date'[Date]; "06-30" )
DATESYTD ( 'Date'[Date]; "30-06" )
Чтобы не закладываться на региональные настройки, можно использовать
функцию FILTER вместо DATESYTD следующим образом:
VAR LastDatelnSelection = МАХ ( 'Date'[Date] )
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] > DATE ( YEAR ( LastDatelnSelection ) - 1; <месяц>; <день> )
&& 'Date'[Date] <= LastDatelnSelection
)
Важно Необходимо помнить, что действие функции DATESYTD всегда начинается с даты,
следующей за указанной датой окончания финансового года. Проблемы могут возник-
нуть, когда финансовый год компании начинается 1 марта. Фактически началом года
в этом случае может быть как 28 февраля,так и 29 февраля в зависимости оттого, висо-
косный год или нет. По состоянию на апрель 2019 года функция DATESYTD этот сценарий
корректно не обрабатывает. Таким образом, в моделях компаний, финансовый год кото-
рых начинается 1 марта, функцию DATESYTD применять не следует. С обходным путем для
этой проблемы можно ознакомиться по адресу: http://sqL.bi/fymarch.
Функция DATE ADD
Функция DATEADD извлекает набор дат, смещенный во времени на опреде-
ленное количество интервалов. При анализе контекста фильтра функция
DATEADD определяет, является ли текущий выбор месяцем или другим особым
периодом, как, например, начало или конец месяца. Допустим, если функция
DATEADD используется для смещения полного месяца на квартал назад, часто
в итоговом наборе дат будет больше строк, чем в текущем выборе. Это проис-
ходит из-за того, что функция определяет, что выбран ровно месяц, а значит,
и вернуть нужно месяц с определенным смещением вне зависимости от того,
сколько в нем будет дней.
Такое особое поведение функции DATEADD описывается при помощи трех
правил, о которых мы расскажем в данном разделе. С учетом этих правил будет
очень затруднительно написать замену для функции DATEADD для обобщен-
ной таблицы дат. Код получится крайне сложным, и поддерживать его будет
почти невозможно. Функция DATEADD использует только значения столбца
с датами, извлекая из него необходимую информацию вроде года, квартала
или месяца. Эту логику было бы очень трудно реализовать стандартными сред-
ствами DAX. С другой стороны, воспользовавшись дополнительными столбца-
ми таблицы Date, можно написать альтернативную версию функции DATEADD.
296 ГЛАВА 8 Логика операций со временем
Мы рассмотрим эту технику позже в данной главе - в разделе с пользователь-
скими календарями.
Сейчас же посмотрим на следующую формулу:
DATEADD ( 'Date'[Date]; -1; MONTH )
Близкий, но не точный аналог этого выражения на языке DAX будет выгля-
деть так:
VAR OffsetMonth = -1
RETURN TREATAS (
SELECTCOLUMNS (
CALCULATETABLE ( DISTINCT ( 'Date'[Date] ) );
"Date"; DATE (
YEAR ( 'Date'[Date] );
MONTH ( 'Date'[Date] ) + OffsetMonth;
DAY ( 'Date'[Date] )
)
);
'Date'[Date]
)
Примечание В предыдущем примере и в других формулах из этой главы мы исполь-
зуем функцию TREATAS, которая применяет табличное выражение к контексту фильтра
по столбцам, указанным во втором и остальных параметрах. Ближе с этой функцией вы
познакомитесь в главе 10.
Данная формула будет также работать и для января, поскольку при указании
значения для месяца меньше нуля происходит смещение на предыдущий год.
Однако эта реализация будет правильно функционировать, только если в целе-
вом месяце столько же дней, сколько и в текущем. При смещении с февраля на
январь формула пропустит два или три дня в зависимости от года. В то же вре-
мя если сместиться с марта на февраль, результат может захватить дни марта.
У функции DATEADD нет таких проблем, она возвращает целый месяц при
смещении, если в исходном диапазоне был выбран ровно месяц. Для реализа-
ции такого поведения функция DATEADD следует трем следующим правилам.
1. Функция DATEADD возвращает только даты, существующие в столбце
с датами. Если ожидаемые даты в ней не найдены, функция вернет толь-
ко те даты, которые есть в таблице.
2. Если дата отсутствует в соответствующем месяце после операции сме-
щения, то функция DATEADD вернет последний день соответствующего
месяца.
3. Если текущий выбор включает в себя два последних дня месяца, то в ре-
зультат функции DATEADD войдут все даты между соответствующими
днями в смещенной таблице и окончанием месяца.
Нескольких примеров будет достаточно, чтобы понять действие перечис-
ленных выше правил. Рассмотрим следующие меры: Day count осуществляет
подсчет выбранных дней, PM Day count подсчитывает количество дней в сме-
ГЛАВА 8 Логика операций со временем 297
щенном месяце, a PM Range возвращает диапазон дат, выбранных функцией
DATEADD.
Day count :=
COUNTROWS ( 'Date' )
PM Day count :=
CALCULATE ( [Day count]; DATEADD ( 'Date'[Date]; -1; MONTH ) )
PM Range :=
CALCULATE (
VAR MinDate = MIN ( 'Date'[Date] )
VAR MaxDate = MAX ( 'Date'[Date] )
VAR Result =
FORMAT ( MinDate; "MM/DD/YYYY - " ) & FORMAT ( MaxDate; "MM/DD/YYYY" )
RETURN
Result;
DATEADD ( 'Date'[Date]; -1; MONTH )
)
Правило 1 применяется, когда выбор находится рядом с границами диа-
пазона дат, включенных в столбец с датами. Например, по рис. 8.34 вид-
но, что меры PM Day count и PM Range возвращают правильные значения
по февралю 2007 года, поскольку даты из января 2007-го присутствуют
в столбце с датами. В то же время эти меры не отрабатывают в январе
2007 года, ведь в нашем столбце с датами нет декабря 2006-го.
Year Day count PM Day count PM Range
01/27/07 1
01/28/07 1
01/29/07 1
01/30/07 1
01/31/07 1
February 28 31 01/01/2007 - 01/31/2007
02/01/07 1 1 01/01/2007 -01/01/2007
02/02/07 1 1 01/02/2007 - 01/02/2007
02/03/07 1 1 01/03/2007 - 01/03/2007
02/04/07 1 1 01/04/2007 -01/04/2007
02/05/07 1 1 01/05/2007 - 01/05/2007
02/06/07 1 1 01/06/2007 -01/06/2007
Рис. 8.34 Выбранные даты смещаются на месяц назад
Главная причина того, почему таблица Date должна содержать все даты
в рамках одного года, заключается в особенностях поведения функции
DATEADD. Всегда помните, что многие функции логики операций со вре-
менем внутренне используют функцию DATEADD. Таким образом, при-
сутствие в таблице дат всех без исключения дней без пропусков является
залогом правильной работы функций для работы с датами.
298 ГЛАВА 8 Логика операций со временем
Правило 2 является также очень важным, поскольку в разных месяцах
разное количество дней. 31-е число присутствует далеко не во всех ме-
сяцах. И если этот день выбран в исходном диапазоне, то в смещенном
периоде он будет представлен как последний день месяца. На рис. 8.35
показано, как последние дни марта переносятся на один и тот же послед-
ний день февраля, поскольку 29,30 и 31 февраля в соответствующем году
просто не было.
ar Day count PM Day count PM Range
03/22/07 1 1 02/22/2007 - 02/22/2007
03/23/07 1 1 02/23/2007 - 02/23/2007
03/24/07 1 1 02/24/2007 - 02/24/2007
03/25/07 1 1 02/25/2007 - 02/25/2007
03/26/07 1 1 02/26/2007 - 02/26/2007
03/27/07 1 1 02/27/2007 - 02/27/2007
03/28/07 1 1 02/28/2007 - 02/28/2007
03/29/07 1 1 02/28/2007 - 02/28/2007
03/30/07 1 1 02/28/2007 - 02/28/2007
03/31/07 1 1 02/28/2007 - 02/28/2007
Рис. 8.35 Дата, отсутствующая в целевом месяце,
заменяется на последний день месяца
Следствием из этого правила является то, что в итоговом наборе может
оказаться меньше дней, чем в исходном. Это вполне естественно, если
представить смещение с целого марта на февраль, в котором дней всег-
да меньше, и в итоге мы получим 28 или 29 дней вместо 31. Но когда
в вашем исходном выборе меньше дней, результат может вас несколько
удивить. По рис. 8.36 видно, что пять выбранных дней в марте могут пре-
вратиться всего в два дня в феврале.
Year Day count PM Day count PM Range
2007 5
March 5
03/27/07 1
03/28/07 1
03/29/07 1
03/30/07 1
03/31/07 1
Total 5
2 02/27/2007 - 02/28/2007
2 02/27/2007 - 02/28/2007
1 02/27/2007 - 02/27/2007
1 02/28/2007 - 02/28/2007
1 02/28/2007 - 02/28/2007
1 02/28/2007-02/28/2007
1 02/28/2007 - 02/28/2007
2 02/27/2007 - 02/28/2007
Рис. 8.36 Некоторые дни из исходного выбора могут превращаться
в один и тот же день в результате выполнения функции DATEADD
Правило 3 описывает особый случай, когда в исходную выборку включен
последний день месяца. Например, представьте себе начальный диапа-
зон из трех дней с 29 июня по 1 июля 2007 года. В этой выборке всего три
ГЛАВА 8 Логика операций со временем 299
дня, но среди них есть последний день месяца, а именно 30 июня. Когда
функция DATEADD смещает диапазон назад, она включает в себя послед-
ний день мая, то есть 31 мая. На рис. 8.37 изображен этот случай, и к нему
стоит присмотреться внимательнее. Как вы могли заметить, 30 июня
превратилось в 30 мая. Только если в выборку включаются и 29 июня,
и 30 июня, результирующий набор будет содержать 31 мая. В этом случае
количество дней в предыдущем месяце будет больше, чем в исходном вы-
боре: два выбранных дня в июне 2007 года превратятся в три дня в мае
того же года.
Year Day count PM Day count PM Range
2007 3 4 05/29/2007 - 06/01/2007
June 2 3 05/29/2007-05/31/2007
06/29/07 1 1 05/29/2007 - 05/29/2007
06/30/07 1 1 05/30/2007 - 05/30/2007
July 1 1 06/01/2007-06/01/2007
07/01/07 1 1 06/01/2007 - 06/01/2007
Total 3 4 05/29/2007-06/01/2007
Рис. 8.37 Результат функции DATEADD включает все даты
между первым и последним днями выбранного диапазона после операции смещения
Причина для установки этих правил состоит в том, чтобы формулы логики
операций со временем интуитивно понятно работали на уровне месяцев. Как
видно по рис. 8.38, сравнивая показатели по месяцам, мы видим ясную и лег-
кую для понимания картину. Смещенный диапазон включает в себя все дни
предыдущего месяца.
Year Day count PM Day count PM Range
2007 365 334 01/01/2007-11/30/2007
January 31
February 28 31 01/01/2007 - 01/31/2007
March 31 28 02/01/2007 - 02/28/2007
April 30 31 03/01/2007 - 03/31/2007
May 31 30 04/01/2007 - 04/30/2007
June 30 31 05/01/2007 - 05/31/2007
July 31 30 06/01/2007 - 06/30/2007
August 31 31 07/01/2007 - 07/31/2007
September 30 31 08/01/2007 - 08/31/2007
October 31 30 09/01/2007 - 09/30/2007
November 30 31 10/01/2007 - 10/31/2007
December 31 30 11/01/2007 - 11/30/2007
2008 366 366 12/01/2007-11/30/2008
January 31 31 12/01/2007 - 12/31/2007
February 29 31 01/01/2008 - 01/31/2008
March 31 29 02/01/2008 - 02/29/2008
April 30 31 03/01/2008 - 03/31/2008
Рис. 8.38 Мера PM Day count показывает количество дней в предыдущем месяце
300 ГЛАВА 8 Логика операций со временем
Понимание перечисленных выше правил необходимо для того, чтобы справ-
ляться со случаями частичного выбора дней в смещенном месяце. Например,
представьте, что вам нужно наложить фильтр на отчет по дням недели. В этот
фильтр могут не быть включены последние дни месяца, что гарантировало
бы выбор полного предыдущего месяца. Кроме того, смещение, выполняемое
функцией DATEADD, учитывает количество дней в месяце, а не дней недели.
Применение фильтра к столбцу с датами таблицы Date также подразумевает
неявное добавление модификатора ALL на эту таблицу, что приведет к удале-
нию всех ранее наложенных фильтров на календарь, включая дни недели. Та-
ким образом, срез по дням недели просто несовместим в отчете с функцией
DATEADD, он попросту выдает неправильный результат.
На рис. 8.39 показан отчет с выводом меры PM Sales DateAdd, отображающей
значение Sales Amount предыдущего месяца:
PM Sales DateAdd :=
CALCULATE (
[Sales Amount];
DATEADD ( 'Date'[Date]; -1; MONTH )
)
Year Sales Amount PM Sales DateAdd Week Day
2007 8,522,387.91 7,577,161.01 f nday
|| Monday
January 512,658.97 В Saturday
February 733,016.32 525,255.79 В Sunday
March 812,661.96 735,642.52 | Thursday
April 938,504.90 718,494.01 | Tuesday Wednesday
May 764,664.76 961,369.32
June 614,322.15 565,356.50
July 623,356.94 619,184.77
August 760,652.04 777,717.96
September 755,777.34 638,583.64
October 565,028.19 890,962.06
November 641,518.91 599,002.38
December 800,225.43 538,999.02
Total 8,522,387.91 7,577,161.01
Рис. 8.39 Значения в мере PM Sales DateAdd
не согласуются с мерой Sales Amount по предыдущему месяцу
Мера PM Sales DateAdd образует фильтр дней, не соответствующий полному
месяцу. В результате происходит смещение дней выбранного месяца с включе-
нием дополнительных дней в конце месяца, согласно правилу 3. Этот фильтр
полностью перезаписывает выбор по Day of Week для значений предыдуще-
го месяца. В результате мы получаем неправильные результаты в столбце РМ
Sales DateAdd - иногда даже большие, чем в мере Sales Amount, что видно на
примере марта и мая 2007 года.
Чтобы обеспечить правильный результат, здесь нужно провести дополни-
тельные вычисления, как показано ниже в мере PM Sales Weekday. Мы приме-
ГЛАВА 8 Логика операций со временем 301
няем фильтр к столбцу YearMonthNumber, сохраняя при этом фильтр по Day of
Week и удаляя по всем остальным столбцам таблицы Date при помощи функ-
ции ALLEXCEPT. Вычисляемый столбец YearMonthNumber представляет собой
сквозной порядковый номер месяца:
Date[YearMonthNumber] =
'Date'[Year] * 12 + 'Date'[Month Number] - 1
PM Sales Weekday :=
VAR CurrentMonths = DISTINCT ( 'Date'[YearMonthNumber] )
VAR PreviousMonths =
TREATAS (
SELECTCOLUMNS (
CurrentMonths;
"YearMonthNumber"; 'Date'[YearMonthNumber] - 1
);
'Date'[YearMonthNumber]
)
VAR Result =
CALCULATE (
[Sales Amount];
ALLEXCEPT ( 'Date'; 'Date'[Week Day] );
PreviousMonths
)
RETURN
Result
Результат вычисления меры показан на рис. 8.40.
Year Sales Amount PM Sales Weekday We ek Day
2007 8,522,387.91 7,722,162.48 Friday Monday
January 512,658.97 Saturday
February 733,016.32 512,658.97 Sunday
March 812,661.96 733,016.32 Thursday
April 938,504.90 812,661.96 Tuesday Wednesday
May 764,664.76 938,504.90
June 614,322.15 764,664.76
July 623,356.94 614,322.15
August 760,652.04 623,356.94
September 755,777.34 760,652.04
October 565,028.19 755,777.34
November 641,518.91 565,028.19
December 800,225.43 641,518.91
Total 8,522,387.91 7,722,162.48
Рис. 8.40 Значения меры PM Sales Weekday
в точности соответствуют цифрам из Sales Amount за предыдущий месяц
Однако это решение будет работать только для данного отчета. Если бы дни
были выбраны на основании другого критерия, например по первым шести
302 ГЛАВА 8 Логика операций со временем
дням месяца, мера PM Sales Weekday взяла бы полный месяц, тогда как мера
PM Sales DateAdd в этом случае отработала бы корректно. Методы вычислений
напрямую зависят от столбцов, видимых пользователю. Например, в следую-
щей мере PM Sales используется функция ISFILTERED для проверки активности
фильтра по столбцу Day of Week. Более подробно мы будем говорить о функции
ISFILTERED в главе 10.
PM Sales :=
IF (
ISFILTERED ( 'Date'[Day of Week] );
[PM Sales Weekday];
[PM Sales DateAdd]
)
Функции FIRSTDATE, LASTDATE, FIRSTNONBLANK
и LASTNONBLANK
В разделе по полуаддитивным мерам ранее в данной главе мы уже говорили
о двух похожих функциях: LASTDATE и LASTNONBLANK. Эти функции ведут
себя по-разному, как и их аналоги FIRSTDATE и FIRSTNONBLANK.
Функции FIRSTDATE и LASTDATE оперируют исключительно со столбцом с да-
тами. Они возвращают первую и последнюю даты из текущего контекста фильт-
ра соответственно, игнорируя при этом любые данные в связанных таблицах:
FIRSTDATE ( 'Date'[Date] )
LASTDATE ( 'Date'[Date] )
По сути, функция FIRSTDATE просто извлекает из столбца с датами мини-
мальное значение, a LASTDATE - максимальное. Получается, что функции
FIRSTDATE и LASTDATE работают так же, как MIN и МАХ, за одним существен-
ным отличием: FIRSTDATE и LASTDATE возвращают таблицу и инициируют
преобразование контекста, тогда как MIN и МАХ возвращают скалярные вели-
чины без осуществления преобразования контекста.
Рассмотрим следующее выражение:
CALCULATE (
SUM ( Inventory[Quantity] );
LASTDATE ( 'Date'[Date] )
)
Можно переписать эту формулу с использованием функции МАХ вместо
LASTDATE, но код при этом увеличится в объеме:
CALCULATE (
SUM ( Inventory[Quantity] );
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] = MAX ( 'Date'[Date] )
)
)
ГЛАВА 8 Логика операций co временем 303
Помимо этого, функция LASTDATE выполняет преобразование контекста.
Так что точным эквивалентом функции LASTDATE будет следующее выраже-
ние:
CALCULATE (
SUM ( Inventory[Quantity] );
VAR LastDatelnSelection =
MAXX ( CALCULATETABLE ( DISTINCT ( 'Date'[Date] ) ); 'Date'[Date] )
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] = LastDatelnSelection
)
)
Преобразование контекста важно, когда вы пользуетесь функциями FIRST-
DATE/LASTDATE в условиях наличия контекста строки. Лучше всего пользо-
ваться функциями FIRSTDATE/LASTDATE при написании выражений фильтров,
поскольку в этом случае ожидается табличное выражение, тогда как функции
MIN/MAX лучше подойдут при составлении логических выражений в контексте
строки, где обычно требуются скалярные величины. Действительно, функция
LASTDATE, используемая со ссылкой на столбец, подразумевает выполнение
преобразования контекста, что скрывает внешний контекст фильтра.
Например, стоит предпочестьFLRSTDATE/LASTDATE функциям MLN/MAXпри
использовании в аргументе фильтра функций CALCULATE/CALCULATETABLE,
поскольку синтаксис в этом случае будет проще. При этом стоит использовать
функции MLN/MAX в тех случаях, когда преобразование контекста в результате
применения функций FLRSTDATE/LASTDATE может повлиять на итоги вычис-
лений. Примером такого использования может быть функция FILTER. Следую-
щее выражение фильтрует даты для расчета промежуточных итогов:
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= MAX ( 'Date'[Date] )
)
Здесь правильно использовать функцию МАХ. Фактически применение
функции LASTDATE вместо МАХ привело бы к извлечению всех дат вне зави-
симости от текущего выбора из-за нежелательного преобразования контекста.
Таким образом, следующее выражение всегда будет выдавать полный набор
дат. Это происходит из-за того, что функция LASTDATE по причине выпол-
нения преобразования контекста возвращает значение Date[Date] по каждой
строке в итерации функции FLLTER:
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= LASTDATE ( 'Date'[Date] ) -- это условие всегда возвращает истину
)
Функции LASTNONBLANK и FLRSTNONBLANK отличаются от FLRSTDATE
и LASTDATE. По своей сути они являются итераторами, а это означает, что они
304 ГЛАВА 8 Логика операций со временем
проходят по таблице в контексте строки и возвращают последнее (или первое)
значение, для которого второй параметр будет непустым. Обычно во второй
параметр этих функций передается либо мера, либо выражение с использова-
нием функции CALCULATE для выполнения преобразования контекста.
Чтобы получить правильное значение для последней непустой даты для
конкретной меры/таблицы, необходимо использовать выражение, подобное
следующему:
LASTNONBLANK ( 'Date'[Date]; CALCULATE ( COUNTROWS ( Inventory ) ) )
Эта формула вернет последнюю дату (в текущем контексте фильтра), для ко-
торой существуют строки в таблице Inventory. Для этой цели можно использо-
вать и следующую формулировку:
LASTNONBLANK ( 'Date'[Date]; COUNTROWS ( RELATEDTABLE ( Inventory ) ) )
Это выражение вернет последнюю дату (также в текущем контексте фильт-
ра), для которой есть связанные строки в таблице Inventory.
Стоит отметить, что функции FIRSTNONBLANK/LASTNONBLANK могут при-
нимать данные любого типа в качестве первого параметра, тогда как FIRST-
DATE/LASTDATE требуют на вход столбец типа DateTime или Date. Таким
образом, функции FIRSTNONBLANK и LASTNONBLANK вполне могут быть ис-
пользованы и с другими таблицами вроде покупателей или товаров, хотя это
встречается редко.
Использование детализации с функциями логики операций
со временем
Детализация (drillthrough) представляет собой операцию запроса к строкам
источника данных, соответствующим контексту фильтра, используемому
в определенном вычислении. Всегда, когда вы применяете функции логики
операций со временем, вы изменяете контекст фильтра в таблице Date. Это
приводит к получению результата меры, отличного от результата ее вычис-
ления в исходном контексте фильтра. Используя клиентское приложение, по-
зволяющее выполнять детализацию в отчете, например Excel с его сводными
таблицами, вы могли наблюдать неожиданное для вас поведение операции
детализации данных. По сути, детализация не учитывает изменения в контек-
сте фильтра, определенном самой мерой. Вместо этого она учитывает только
контекст фильтра, определенный строками, столбцами, фильтрами и срезами
сводной таблицы.
Например, по умолчанию детализация по марту 2007 года всегда будет воз-
вращать одни и те же строки вне зависимости от функций логики операций со
временем, используемых в мере. Если применяется функция TOTALYTD, мож-
но ожидать, что результатом будет общее количество дней с января по март
этого года. От функции SAMEPERIODLASTYEAR мы будем ждать марта преды-
дущего года, а от LASTDATE - строк по 31 марта 2007 года. На самом деле по
умолчанию все перечисленные фильтры всегда будут возвращать строки по
марту 2007 года. Это поведение можно контролировать при помощи свойства
ГЛАВА 8 Логика операций со временем 305
Detail Rows (Строки детализации) в модели Tabular. На момент написания дан-
ной книги (апрель 2019 года) это свойство доступно в Analysis Services 2017
и Azure Analysis Services, но в Power BI и Power Pivot для Excel оно отсутствует.
В свойстве Detail Rows должен применяться тот же фильтр, что и в соответ-
ствующей мере. Например, у нас есть мера, рассчитывающая сумму продаж
нарастающим итогом с начала года:
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
)
Свойство Detail Rows для этой меры должно содержать следующую формулу:
CALCULATETABLE (
Sales; -- Этим выражением также определяются возвращаемые
-- столбцы
DATESYTD ( 'Date'[Date] )
)
Работа с пользовательскими календарями
Как вы уже знаете, стандартные функции логики операций со временем в DAX
поддерживают только традиционный григорианский календарь. Он базирует-
ся на солнечном календаре, разделенном на 12 месяцев, в каждом из которых
свое количество дней. Эти функции удобно применять для анализа данных по
годам, кварталам, месяцам или дням. Но существуют и другие модели, базиру-
ющиеся на своих определениях временных периодов. К ним относится, напри-
мер, недельный календарь с соблюдением стандарта ISO (ISO week date system).
Если вам необходимо работать с нестандартными календарями, вам придет-
ся переписать всю логику операций со временем, поскольку специальными
функциями DAX из этой области вы воспользоваться не сможете.
Когда речь заходит о нестандартных календарях, нужно понимать, что их
существует огромное множество, и осветить работу в каждом из них просто
невозможно. Так что мы лишь покажем вам несколько примеров расчетов для
случаев, когда вы не сможете воспользоваться стандартными функциями DAX
для работы со временем.
С целью упрощения вычислений в таких случаях принято переносить часть
бизнес-логики непосредственно в таблицу дат путем создания соответству-
ющих столбцов. Специальные функции логики операций со временем не ис-
пользуют в своей работе другие столбцы из таблицы дат, кроме как столбец
с датами. Это было сделано специально, чтобы язык не зависел от присутствия
дополнительных метаданных для определения года, квартала, месяца или дня,
как это было в MDX и Analysis Services Multidimensional. Будучи владельцем
своей модели данных и кода на DAX, вы можете строить свои собственные
предположения о работе, что позволит значительно упростить код для работы
с нестандартными вычислениями, связанными с датой и временем.
306 ГЛАВА 8 Логика операций со временем
В этом заключительном разделе главы мы представим вам несколько при-
меров с формулами для работы с нестандартными календарями. Если пона-
добится, вы всегда можете найти больше информации, примеров и готовых
решений в следующих статьях:
временные шаблоны: http://www.daxpatterns.com/time-patterns/;
работа со временем в недельных календарях: http://sqL.bi/isoweeks/.
Работа с неделями
DAX не предоставляет специальных функций логики операций со временем,
адаптированных к работе с недельными календарями. Причина в том, что есть
множество способов и техник определения недель в рамках года и осуществ-
ления расчетов внутри недели. Часто неделя может пересекать границы года,
квартала или месяца. Вам необходимо написать собственный код для произ-
ведения вычислений при использовании недельных календарей. Например,
в рамках недельного календаря ISO даты 1 и 2 января 2011 года принадлежат
52-й неделе 2010 года, а первая неделя 2011 года начинается только 3 января.
И хотя стандартов существует много, вы можете усвоить общий подход, ко-
торый пригодится в работе с любым недельным календарем. Суть этого под-
хода состоит в создании дополнительных вычисляемых столбцов в таблице дат
для хранения соответствий между неделями и годами/кварталами и месяца-
ми, которым они принадлежат. А смена календаря будет означать, что вам не-
обходимо будет просто обновить данные в таблице Date, не модифицируя при
этом код мер.
Например, вы можете расширить таблицу дат следующими вычисляемыми
столбцами, чтобы она поддерживала недельный стандарт ISO:
'Date'[Calendar Week Number] = WEEKNUM ( 'Date'[Date]; 1 )
'Date'[ISO Week Number] = WEEKNUM ( 'Date'[Date]; 21 )
'Date'[ISO Year Number] = YEAR ( 'Date'[Date] + ( 3 - WEEKDAY ( 'Date'[Date]; 3 ) ) )
'Date'[ISO Week] = "W" & 'Date'[ISO Week Number] & & 'Date'[ISO Year Number]
'Date'[ISO Week Sequential] = INT ( ( 'Date'[Date] - 2 ) / 7 )
'Date'[ISO Year Day Number] =
VAR CurrentlsoYearNumber = 'Date'[ISO Year Number]
VAR CurrentDate = 'Date'[Date]
VAR DateFirstJanuary = DATE ( CurrentlsoYearNumber; 1; 1 )
VAR DayOfFirstJanuary = WEEKDAY ( DateFirstJanuary; 3 )
VAR OffsetStartlsoYear = - DayOfFirstJanuary + ( 7 * ( DayOfFirstJanuary > 3 ) )
VAR StartOfIsoYear = DateFirstJanuary + OffsetStartlsoYear
VAR Result = CurrentDate - StartOfIsoYear
RETURN
Result
На рис. 8.41 показаны созданные столбцы. Столбец ISO Week будет видим для
пользователей, тогда как ISO Week Sequential останется невидимым и будет ис-
пользоваться для внутренних вычислений. В столбце ISO Year Day Number будет
храниться количество дней, прошедших с начала года ISO. С этими вспомога-
тельными столбцами будет гораздо легче производить сравнение различных
временных периодов.
ГЛАВА 8 Логика операций со временем 307
Date ISO Week Number Calendar Week Number ISO Year Number ISO Week ISO Week Sequential ISO Year Day Number
12/27/07 52 52 2007 W52-2007 5634 361
12/28/07 52 52 2007 W52-2007 5634 362
12/29/07 52 52 2007 W52-2OO7 5634 363
12/30/07 52 53 2007 W52-2OO7 5634 364
12/31/07 1 53 2008 W1 -2008 5635 1
01/01/08 1 1 2008 W1-2008 5635 2
01/02/08 1 1 2008 W1-2008 5635 3
01/03/08 1 1 2008 W1-2008 5635 4
01/04/08 1 1 2008 W1-2008 5635 5
01/05/08 1 1 2008 W1-2008 5635 6
01/06/08 1 2 2008 W1-2008 5635 7
01/07/08 2 2 2008 W2-2OO8 5636 8
01/08/08 2 2 2008 W2-2OO8 5636 9
01/09/08 2 2 2008 W2-2008 5636 10
Рис. 8.41 Вычисляемые столбцы в таблице дат для поддержки недель ISO
Разработчик может строить собственные агрегации нарастающим итогом
с начала года, используя столбец ISO Year Number вместо извлечения года не-
посредственно из даты. Техника по своей сути останется той же, что и в обсуж-
дении ранее в данной главе. Мы только добавили дополнительную проверку на
выбор одного года ISO перед вызовом функции VALUES:
ISO YTD Sales :=
IF (
HASONEVALUE ( 'Date'[ISO Year Number] );
VAR LastDatelnSelection = MAX ( 'Date'[Date] )
VAR YearSelected = VALUES ( 'Date'[ISO Year Number] )
VAR Result =
CALCULATE (
[Sales Amount];
'Date'[Date] <= LastDatelnSelection;
'Date'[ISO Year Number] = YearSelected;
ALL ( 'Date' )
)
RETURN
Result
)
На рис. 8.42 показан вывод меры ISO YTD Sales для начала 2008 года в срав-
нении с мерой, вычисленной с применением стандартной функции DATESYTD.
Заметьте, что версия ISO включает в себя 31 декабря 2007 года - дату, входя-
щую в состав 2008 года ISO.
При сравнении показателей с предыдущим годом необходимо сопостав-
лять соответствующие недели. А поскольку даты при этом могут быть разные,
легче всего использовать другие столбцы таблицы дат для реализации логики
сравнения. Распределение недель внутри года всегда одинаковое, ведь любая
неделя состоит из семи дней. В то же время месяцы насчитывают разное ко-
личество дней, а значит, не могут похвастаться такой же универсальностью.
В недельных календарях вы можете упростить вычисления путем поиска тех
же относительных дней в предыдущем году, которые были выбраны в текущем
контексте фильтра.
308 ГЛАВА 8 Логика операций со временем
ISO Year Number Sales Amount ISO YTD Sales CAL YTD Sales
2008 9,751,677.59 9,751,677.59 9,744,825.64
W1-2008 121,701.75 121,701.75 114,849.81
12/31/07 6,851.94 6,851.94 11,309,946.12
01/01/08 19,143.33 25,995.27 19,143.33
01/02/08 14,731.14 40,726.41 33,874.46
01/03/08 54,558.58 95,284.98 88,433.04
01/04/08 95,284.98 88,433.04
01/05/08 18,047.97 113,332.96 106,481.01
01/06/08 8,368.80 121,701.75 114,849.81
Рис. 8.42 В мере ISO YTD Sales 31 декабря 2007 года
включается в 2008 год ISO
Следующая мера фильтрует текущую выборку дней применительно к пре-
дыдущему году. Эта техника также работает, когда в выборку включены полные
недели, поскольку дни здесь выбраны по столбцу ISO Year Day Number, а не по
дате как таковой.
ISO PY Sales :=
IF (
HASONEVALUE ( 'Date'[ISO Year Number] );
VAR DatesInSelection = VALUES ( 'Date'[ISO Year Day Number] )
VAR YearSelected = VALUES ( 'Date'[ISO Year Number] )
VAR PrevYear = YearSelected - 1
VAR Result =
CALCULATE (
[Sales Amount];
DatesInSelection;
'Date'[ISO Year Number] = PrevYear;
ALL ( 'Date' )
)
RETURN
Result
)
На рис. 8.43 показан отчет с выводом меры ISOPYSales. Справа мы добавили
информацию о продажах 2007 года, чтобы вам было легче понять, как произ-
водится выборка данных в мере ISO PY Sales.
Обращаться с недельными календарями довольно просто по причине пред-
положений, которые можно сделать по поводу симметрии между одними
и теми же днями в разные годы. С расчетами по месяцам такая логика обычно
несовместима, так что если вам необходимо использовать обе иерархии (меся-
цы и недели), то придется писать свои расчеты для каждой из них.
Пользовательские вычисления нарастающим итогом
Ранее в данной главе вы узнали, как можно переписать стандартную функцию
DATESYTD, использующуюся для произведения вычисления нарастающим
итогом с начала года. И там мы использовали атрибуты даты, такие как год,
ГЛАВА 8 Логика операций со временем 309
из столбца с датами. Когда речь идет о календарях ISO, мы более не можем
полагаться исключительно на столбец с датами. Вместо этого воспользуемся
созданными ранее вычисляемыми столбцами. В этом разделе мы продемон-
стрируем на примере, как вместо извлечения атрибутов даты использовать
вспомогательные столбцы в таблице дат.
ISO Year Number Sales Amount CAL PY Sales ISO PY Sales ISO Year Number Sales Amount
2008 9,751,677.59 11,243,758.33 11,303,094.18 2007 11,303,094.18
W1-2008 121,701.75 216,891.21 240,196.84 W1-2007 240,196.84
12/31/07 6,851.94 01/02/07 48,646.02
01/01/08 19,143.33 48,646.02 01/03/07 92,244.07
01/02/08 14,731.14 48,646.02 92,244.07 01/04/07 13,950.29
01/03/08 54,558.58 92,244.07 13,950.29 01/05/07 62,050.83
01/04/08 13,950.29 62,050.83 01/07/07 23,305.63
01/05/08 18,047.97 62,050.83 W2-2007 77,368.65
01/06/08 8,368.80 23,305.63 01/09/07 20,543.35
W2-2008 121,345.28 100,674.28 77,368.65 01/10/07 6,565.56
01/07/08 16,425.61 23,305.63 01/11/07 22,693.05
01/08/08 23,523.00 20,543.35 01/12/07 16,251.63
01/09/08 41,778.21 20,543.35 6,565.56 01/13/07 11,315.05
01/10/08 942.56 6,565.56 22,693.05 W3-2007 281,655.20
01/11/08 22,059.58 22,693.05 16,251.63 01/15/07 58,224.87
01/12/08 16,251.63 11,315.05 01/16/07 45,595.65
01/13/08 16.616.33 11.315.05 01/17/07 29.600.55
Рис. 8.43 В мере ISO PY Sales рассчитаны продажи предыдущего года
по аналогичной неделе
Рассмотрим стандартную меру YTD Sales:
YTD Sales :=
CALCULATE (
[Sales Anount];
DATESYTD ( 'Date'[Date] )
)
Соответствующий синтаксис для меры в DAX без использования специаль-
ных функций логики операций со временем будет выглядеть следующим об-
разом:
YTD Sales :=
VAR LastDatelnSelection = MAX ( 'Date'[Date] )
VAR Result =
CALCULATE (
[Sales Anount];
'Date'[Date] <= LastDatelnSelection
&& YEAR ( 'Date'[Date] ) = YEAR ( LastDatelnSelection )
)
RETURN
Result
310 ГЛАВА 8 Логика операций co временем
Если вы используете нестандартный календарь, вам следует заменить функ-
цию YEAR на обращение к созданному столбцу с годом, как показано в следу-
ющей мере YTD Sales Custom:
YTD Sales Custom :=
VAR LastDatelnSelection = MAX ( 'Date'[Date] )
VAR LastYearlnSelection = MAX ( 'Date'[Calendar Year Number] )
VAR Result =
CALCULATE (
[Sales Amount];
'Date'[Date] <= LastDatelnSelection;
'Date*[Calendar Year Number] = LastYearlnSelection;
ALL ( 'Date' )
)
RETURN
Result
Можно использовать этот же шаблон для написания вычислений нарас-
тающим итогом с начала квартала или месяца. Единственным отличием бу-
дет столбец, к которому вы будете обращаться вместо столбца Calendar Year
Number:
QTD Sales Custom :=
VAR LastDatelnSelection = MAX ( 'Date'[Date] )
VAR LastYearQuarterInSelection = MAX ( 'Date'[Calendar Year Quarter Number] )
VAR Result =
CALCULATE (
[Sales Amount];
'Date'[Date] <= LastDatelnSelection;
'Date*[Calendar Year Quarter Number] = LastYearQuarterInSelection;
ALL ( 'Date' )
)
RETURN
Result
MTD Sales Custom :=
VAR LastDatelnSelection = MAX ( 'Date'[Date] )
VAR LastYearMonthInSelection = MAX ( 'Date'[Calendar Year Month Number] )
VAR Result =
CALCULATE (
[Sales Amount];
'Date'[Date] <= LastDatelnSelection;
'Date*[Calendar Year Month Number] = LastYearMonthInSelection;
ALL ( 'Date' )
)
RETURN
Result
Эти формулы можно использовать как в работе со стандартными календа-
рями (если вы хотите улучшить производительность за счет использования
режима DirectQuery), так и с пользовательскими (если вы работаете с нестан-
дартными временными периодами).
ГЛАВА 8 Логика операций со временем 311
Заключение
В этой длинной главе вы познакомились с основами применения функций ло-
гики операций со временем в DAX. Вот главные моменты, которые вы должны
были усвоить:
в Power Pivot и Power BI существуют свои механизмы для автоматическо-
го создания таблицы дат. Но пользоваться этой возможностью не стоит,
за исключением случаев, когда вы будете иметь дело с совсем уж простой
моделью данных. Очень важно иметь полный контроль над своими ка-
лендарями, а упомянутые выше механизмы не позволяют вам произво-
дить необходимые изменения в таблицах дат;
чтобы создать таблицу дат вручную, достаточно воспользоваться функ-
цией CALENDARAUTO и написать пару строчек на DAX. Однако стоит по-
тратить время на создание действительно удобной таблицы дат, посколь-
ку в дальнейшем вы сможете использовать ее в своих новых проектах. Вы
также можете загрузить из сети шаблоны для создания таблицы дат;
календарь должен быть явным образом помечен как таблица дат для об-
легчения применения функций логики операций со временем;
в DAX существует множество специальных функций логики операций со
временем. Большинство из них возвращают таблицу с датами, которую
в дальнейшем можно использовать в качестве аргумента фильтра функ-
ции CALCULATE;
вы должны научиться воспринимать функции логики операций со вре-
менем как своеобразные кирпичики для построения комплексных выра-
жений. Сочетая разные функции, вы сможете производить действитель-
но сложные и полезные вычисления со временем;
если ваши требования не позволяют воспользоваться стандартными
функциями логики операций со временем, значит, пришло время зака-
тать рукава и написать собственные формулы при помощи традицион-
ных функций языка DAX;
в данной книге приведено немало примеров по работе с датой и време-
нем. Но еще больше шаблонов вы найдете по адресу: https://www.daxpat-
terns.com/time-patterns/.
ГЛАВА 9
Группы вычислений
В 2019 году произошло серьезное обновление DAX, в рамках которого, помимо
прочего, были представлены так называемые группы вычислений (calculation
groups). На создание групп вычислений разработчиков DAX вдохновила похо-
жая концепция в языке MDX, известная под названием вычисляемые элемен-
ты (calculated members). Если вы уже знакомы с этой концепцией, вам будет
куда проще освоить данную тему. И все же между вычисляемыми элементами
в MDX и группами вычислений в DAX есть существенные различия, так что вне
зависимости от имеющихся знаний мы советовали бы вам прочитать, что из
себя представляет эта новинка в DAX и как с ее помощью можно производить
впечатляющие вычисления.
Группы вычислений использовать очень просто. Однако проектирование
модели данных с наличием нескольких групп вычислений и использованием
элементов вычислений в мерах может оказаться затруднительным. Мы поста-
раемся рассказать обо всем по порядку, чтобы вы избежали возможных слож-
ностей. Отклонение от описанного нами пути при разработке модели возмож-
но при очень хорошем понимании концепции групп вычислений.
Группы вычислений - это абсолютная новинка в DAX, и на момент написа-
ния книги (апрель 2019 года) эта технология не была закончена и официально
выпущена. На протяжении данной главы мы будем отмечать моменты, кото-
рые могут претерпеть изменения в будущих версиях этой концепции. Также
мы советуем вам посетить страницу https://www.sqLbi.com/caLcuLation-groups, где
можно найти обновленный материал данной главы и примеры использования
групп вычислений в DAX.
Знакомство с группами вычислений
Перед тем как дать определение групп вычислений, полезно будет рассмотреть
требования бизнес-аналитики, приведшие к появлению этой концепции. А по-
скольку вы только что завершили читать главу, касающуюся логики операций
со временем, мы используем пример именно из этой области.
Определим в нашей модели данных следующие меры для расчета суммы
продаж, общих издержек, прибыли и количества проданных товаров:
Sales Anount := SUMX ( Sales; Sales[Quantity] * SalesfNet Price] )
Total Cost := SUMX ( Sales; SalesfQuantity] * SalesfUnit Cost] )
Margin := [Sales Anount] - [Total Cost]
Sales Quantity := SUM ( Sales[Quantity] )
ГЛАВА 9 Группы вычислений 313
Все четыре меры очень полезны сами по себе и позволяют проводить анализ
деятельности компании. Более того, все они являются прекрасными кандида-
тами на вычисление нарастающим итогом. Количество проданных товаров
нарастающим итогом с начала года может быть не менее интересным пока-
зателем, чем сумма продаж или сумма прибыли. То же самое касается и дру-
гих операций работы со временем: вычисление показателя за тот же период
в предыдущем году, процентное изменение по сравнению с предыдущим го-
дом и многое другое.
Но если мы захотим создать такие расчеты для всех наших мер, то общее
количество мер в модели данных очень быстро превысит все мыслимые преде-
лы. Мы бы никому не пожелали пользоваться моделью, общее количество мер
в которой исчисляется сотнями. К тому же большинство мер в нашем случае бу-
дут написаны по одному и тому же шаблону - меняться будет только название
меры. Посмотрите для примера на список мер, вычисляющих нарастающий
итог с начала года по четырем указанным выше мерам:
YTD Sales Amount :=
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
)
YTD Total Cost :=
CALCULATE (
[Total Cost];
DATESYTD ( 'Date'[Date] )
)
YTD Margin :=
CALCULATE (
[Margin];
DATESYTD ( 'Date'[Date] )
)
YTD Sales Quantity :=
CALCULATE (
[Sales Quantity];
DATESYTD ( 'Date'[Date] )
)
Все перечисленные меры отличаются лишь базовой мерой, с которой про-
изводится действие. А именно накладывается один и тот же фильтр DATE-
SYTD, в рамках которого вычисляется значение первого параметра функции
CALCULATE. Было бы здорово, если бы у разработчика была возможность на-
писать какое-то общее определение с шаблоном для подстановки нужной
меры:
YTD <Measure> :=
CALCULATE (
<Measure>;
314 ГЛАВА 9 Группы вычислений
DATESYTD ( 'Date'[Date] )
)
Этот код не соответствует синтаксису языка DAX, но он дает определенное
представление о том, что из себя представляют элементы вычисления (calcula-
tion item). Предыдущий код можно прочитать так: когда нужно выполнить рас-
чет меры нарастающим итогом с начала года, примените фильтр DATESYTD
к столбцу Date[Date], после чего вычислите меру. В этом и состоит суть элемента
вычисления. Он представляет собой выражение на языке DAX с шаблоном-за-
полнителем (placeholder). Этот шаблон заменяется на переданную меру непо-
средственно перед вычислением результата. Иными словами, элемент вычис-
ления является разновидностью выражения, которое может быть применено
к любой мере.
Зачастую разработчику может понадобиться производить разного рода вы-
числения в области логики операций со временем. Как мы уже отмечали в на-
чале раздела, операции расчета нарастающим итогом с начала года или квар-
тала, а также сравнения с аналогичным периодом предыдущего года являются
составной частью единой группы вычислений. И поэтому в DAX представлены
как элементы вычисления, так и группы. Группа вычислений являет собой набор
элементов вычисления, объединенных общей тематикой.
Продолжим писать псевдокод на DAX:
CALCULATION GROUP "Time Intelligence"
CALCULATION ITEM CY := <Measure>
CALCULATION ITEM PY := CALCULATE ( <Measure>; SAMPEPERIODLASTYEAR ( 'Date'[Date] ) )
CALCULATION ITEM QTD := CALCULATE ( <Measure>; DATESQTD ( 'Date'[Date] ) )
CALCULATION ITEM YTD := CALCULATE ( <Measure>; DATESYTD ( 'Date'[Date] ) )
Как видите, мы объединили четыре меры, связанные с логикой операций со
временем, в одну группу с названием Time Intelligence. Всего в четырех строч-
ках кода мы, по сути, определили десятки мер, поскольку элементы вычисле-
ния могут быть применены к самым разным мерам в модели данных. Таким
образом, создав меру, разработчик автоматически получит в свое распоряже-
ние расчет нарастающего итога с начала года и квартала, а также сравнение
с аналогичным периодом предыдущего года по этой мере.
Вы пока еще не постигли все нюансы групп вычислений, но в данный мо-
мент, для того чтобы создать свою первую группу, вам необходимо ответить
только на один вопрос: как пользователь будет выбирать конкретную разно-
видность операции? Как мы уже говорили, элемент вычисления не является
мерой, это лишь ее разновидность. Так что у пользователя должна быть воз-
можность вставить в отчет конкретную меру с одной или несколькими ее раз-
новидностями. А поскольку пользователи привыкли выбирать в отчеты столб-
цы из таблиц, группы вычислений было решено реализовать, как если бы они
являлись столбцами в таблице, а элементы вычисления - значениями в этих
столбцах. Следовательно, пользователь может вынести группу вычислений
на столбцы в матрице, чтобы таким образом отобразить различные вариации
меры. Например, ранее описанные элементы вычисления можно расположить
на столбцах матрицы, как показано на рис. 9.1, чтобы провести различные опе-
рации над мерой Sales Amount.
ГЛАВА 9 Группы вычислений 315
Calendar Year Month CY PY QTD YTD
CY 2005 CY 2006 A January 656,766.69 794,248.24 656,766.69 656,766.69
CY 2007 February 600,080.00 891,135.91 1,256,846.69 1,256,846.69
CY 2008 CY 2009 March 559,538.52 961,289.24 1,816,385.21 1,816,385.21
April 999,667.17 1,128,104.82 999,667.17 2,816,052.38
May June July August September October November December Total 893,231.96 845,141.60 890,547.41 721,560.95 963,437.23 719,792.99 1,156,109.32 921,709.14 9,927,582.99 936,192.74 982,304.46 922,542.98 952,834.59 1,009,868.98 914,273.54 825,601.87 991,548.75 11,309,946.12 1,892,899.13 2,738,040.73 890,547.41 1,612,108.36 2,575,545.59 719,792.99 1,875,902.31 2,797,611.46 2,797,611.46 3,709,284.34 4,554,425.94 5,444,973.35 6,166,534.30 7,129,971.53 7,849,764.52 9,005,873.85 9,927,582.99 9,927,582.99
Рис. 9.1 Можно пользоваться группой вычислений так,
как если бы она являлась столбцом таблицы в модели данных
Создание групп вычислений
Реализация групп вычислений в модели Tabular зависит от пользовательского
интерфейса инструмента разработчика. На момент написания книги (апрель
2019 года) ни Power BI, ни SQL Server Data Tools (SSDT) для Analysis Services не
обладали специальным интерфейсом для групп вычислений, а доступны они
были только через API объектной модели Tabular (Tabular Object Model - ТОМ).
Первым инструментом, предоставившим возможность для работы с группами
вычислений, стал Tabular Editor - редактор с открытым исходным кодом, до-
ступный по адресу https://tabuLareditor.github.io/.
Чтобы создать группу вычислений при помощи Tabular Editor, необходи-
мо выбрать пункт New Calculation Group в меню Model. В результате груп-
па будет отображена в модели данных как таблица со специальной иконкой.
На рис. 9.2 видно созданную группу вычислений, которую мы переименовали
в Time Intelligence.
Группа вычислений представляет собой специальную таблицу с единствен-
ным столбцом, по умолчанию в Tabular Editor названным Attribute. В нашей мо-
дели данных мы переименовали столбец в Time calc, после чего добавили три
элемента вычисления (YTD, QTD для нарастающих итогов и SPLY для срав-
нения с аналогичным периодом в предыдущем году), выбрав в контекстном
меню столбца Time calc пункт New Calculation. Каждый элемент вычисления
содержит свое выражение на DAX, как показано на рис. 9.3.
Функция SELECTEDMEASURE представляет собой реализацию шаблона
<Measure>, который мы использовали ранее в псевдокоде DAX. Действитель-
ный код DAX для каждого элемента вычисления представлен ниже. Коммента-
рий, предшествующий каждому выражению, идентифицирует соответствую-
щий элемент вычисления.
316 ГЛАВА 9 Группы вычислений
Примечание Лучше всегда выражать бизнес-логику модели данных через меры. Ког-
да в модель включены группы вычислений, клиент Power Bl не позволяет разработчику
производить агрегацию столбцов, поскольку группы вычислений могут быть применены
только к мерам - они не оказывают никакого влияния на функции агрегирования, а опе-
рируют только мерами.
Ч______________________________________________________________________________________/
K:\SQLBI\Bcoks\Th6 Definitive Guide to DAX2.0\Ch 09 - Calculation groups\Figures - Workbc,.. — □ X
File EJt View Model Took Calculation Group
Ф ЦП Perspective (All objects] - Translation: (No translation] - Filter
e I § |a|»i jo |ГХ| ЦД Expression Editor Advanced Scripting
* О Medel
> Data Sources
Perspectives
> Reiationsh p s
Roles
Shared Expressions
v ' Tables
> ffl Customer
> H Date
> H Sales
> ffl Product
> [Й] Tune Intell ger.ee
Translations
7= Property:
v Basic
Description
Hidden
Name
Obiect Type
v Cuiculat.on Group
> Annotate ns
Description
Precedence
Faise
Time Intdligerice
Calculation Group
C annotations
P
Name
The name of this object. Warning: Changing the name can break formula logic, if Automatic Fcmula
Fix-up is disabled
1 calculation group selected.
Рис. 9.2 В Tabular Editor группа вычислений Time Intelligence
отображается как особенная таблица
ф K:\SQLBI\Books\The Def nitive Guide to DAX 2.0\Ch C9 - Calc... - □ X
File Edit View Model Tools Calculation Item
* Ф |il Perspective: (All objects) T Translation.
Expression Editor Advanced Scripting
v® Model у EJ 4 £h ’2 Property:
> Я Data Sources
Perspectives
> Relationships
Roles
Shared Expressions
v Tables
> ffi Customer
> ffi Date
> ffi Safes
> §5 Product
v [Ц Time Intelligence
E Tme calc
CALCULATE (
SELECTEDMEASURE (),
DATESYTD ’Date'[Dave] )
'i
v Basic
Description
Name YTD
Object Type Calculation Item
v Metadata
Error Message
State Ready
v Opt'ons
Expression CALCULATE ( SELECTEDMEASUPEO, D.
Чгтгзе
The name of this object. Warning: Changirgthe name can break
formula logic, if Automatic Formula Fix-up is disabled
C SELT
Translations
1 calculation item selected.
,0 BP issues
Рис. 9.3 Каждый элемент вычисления содержит выражение на DAX,
которое можно изменить в Tabular Editor
ГЛАВА 9 Группы вычислений 317
Calculation Item: YTD
CALCULATE (
SELECTEDMEASURE ();
DATESYTD ( 'Date'[Date] )
-- Calculation Item: QTD
CALCULATE (
SELECTEDMEASURE ();
DATESQTD ( 'Date'[Date] )
Calculation Item: SPLY
CALCULATE (
SELECTEDMEASURE ();
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
С таким определением пользователю будет представлена новая таблица
Time Intelligence с единственным столбцом Time calc, содержащим три значе-
ния: YTD, QTD и SPLY. Пользователь имеет право создавать срезы по этому
столбцу или выносить его на строки или столбцы визуализации, как если бы он
представлял собой обычный столбец в таблице. Например, если пользователь
выберет значение YTD, движок применит элемент вычисления YTD к любой
мере, которая находится в отчете. На рис. 9.4 показана матрица, в которую вы-
несена мера Sales Amount. А поскольку в срезе выбрана только вариация меры
YTD, в отчете показаны нарастающие итоги меры с начала года.
Time calc Month CY 2007 CY 2008 CY 2009
QTD ▲
SPLY January 794,24824 656,766.69 580,901.05
YTD February 1,685,384.15 1,256,846.69 1,203,482.19
March 2,646,673.39 1,816,385.21 1,699,620.05
April 3,774,77820 2,816,052.38 2,378,513.27
May 4,710,970.95 3,709,284.34 3,445,678.50
June 5,693,275.41 4,554,425.94 4,318,264.70
July 6,615,818.39 5,444,973.35 5,386,661.27
August 7,568,652.98 6,166,534.30 6,222,368.73
September 8,578,521.96 7,129,971.53 6,931,979.13
October 9,492,795.50 7,849,764.52 7,738,717.35
November 10,318,397.37 9,005,873.85 8,606,881.36
December 11,309,946.12 9,927,582.99 9,353,814.87
Total 11,309,946.12 9,927,582.99 9,353,814.87
Рис. 9.4 Когда пользователь выбирает YTD, значения для меры в отчете
рассчитываются нарастающим итогом с начала года
318 ГЛАВА 9 Группы вычислений
Если в том же отчете пользователь выберет разновидность SPLY, результаты
будут другими, что видно по рис. 9.5.
Time calc Month CY 2008 CY 2009 CY 2010
QTD ▲
SPLY January 794,248.24 656,766.69 580,901.05
YTD February 891,135.91 600,080.00 622,581.14
March 961,289.24 559,538.52 496,137.87
April 1,128,104.82 999,667.17 678,893.22
May 936,192.74 893,231.96 1,067,165.23
June 982,304.46 845,141.60 872,586.20
July 922,542.98 890,547.41 1,068,396.58
August 952,834.59 721,560.95 835,707.46
September 1,009,868.98 963,437.23 709,610.40
October 914,273.54 719,792.99 806,738.22
November 825,601.87 1,156,109.32 868,164.01
December 991,548.75 921,709.14 746,933.50
Total 11,309,946.12 9,927,582.99 9,353,814.87
Рис. 9.5 Выбор SPLY привел к изменению значений в мере Sales Amount -
теперь они представляют показатели, смещенные на год назад
Если оставить столбец в срезе без выбора или выбрать сразу несколько зна-
чений, движок не будет предпринимать никаких действий с мерой, оставив ее
неизменной, что видно по рис. 9.6.
Time calc Month CY 2007 CY 2008 CY 2009
QTD ▲
SPLY January 794,248.24 656,766.69 580,901.05
YTD February 891,135.91 600,080.00 622,581.14
March 961,289.24 559,538.52 496,137.87
April 1,128,104.82 999,667.17 678,893.22
May 936,192.74 893,231.96 1,067,165.23
June 982,304.46 845,141.60 872,586.20
July 922,542.98 890,547.41 1,068,396.58
August 952,834.59 721,560.95 835,707.46
September 1,009,868.98 963,437.23 709,610.40
October 914,273.54 719,792.99 806,738.22
November 825,601.87 1,156,109.32 868,164.01
December 991,548.75 921,709.14 746,933.50
Total 11,309,946.12 9,927,582.99 9,353,814.87
Рис. 9.6 Когда ни одно значение в срезе не выбрано,
мера показывается без изменений
Примечание Поведение групп вычислений без выбора значений или со множествен-
ным выбором может измениться в будущем. На апрель 2019 года поведение мер со мно-
жественным выбором и без выбора одинаковое. Но в будущих версиях это может изме-
ниться - например, при множественном выборе может появляться ошибка.
\______________________________________________________________________________>
ГЛАВА 9 Группы вычислений 319
Однако группы вычислений способны на гораздо большее. В начале разде-
ла мы представили вам четыре меры: Sales Amount, Total Cost, Margin и Sales
Quantity. Было бы здорово, если бы пользователь мог выбирать, какую метрику
показывать, а не только вид применяемого вычисления. В этом случае мы мог-
ли бы построить общий отчет с четырьмя возможными метриками по месяцу
и году, предоставив выбор конкретной метрики пользователю. Отчет должен
выглядеть примерно так, как показано на рис. 9.7.
Time calc Month CY 2007 CY 2008 CY 2009
QTD ▲
SPLY January 411,542.33 329,414.92 283,697.42
YTD February 883,064.36 637,218.14 602,841.63
March 1,387,425.09 925,460.26 855,248.48
April 1,987,901.79 1,445,669.78 1,203,631.81
May 2,514,206.64 1,930,431.61 1,811,248.06
Metric June 3,055,699.88 2,393,304.49 2,299,080.65
Margin July 3,579,035.50 2,879,436.96 2,902,951.43
Sales Amount August 4,111,793.71 3,277,582.86 3,360,057.10
Sales Quantity September 4,687,776.20 3,796,855.62 3,742,990.25
Total Cost October 5,189,581.59 4,187,403.58 4,198,974.31
November 5,598,058.99 4,757,022.88 4,606,252.00
December 6,075,652.35 5,212,190.14 4,954,796.26
Total 6,075,652.35 5,212,190.14 4,954,796.26
Рис. 9.7 В отчете показан расчет YTD, примененный к мере Margin,
но пользователь вправе выбрать любое другое сочетание меры и вычисления
В примере, показанном на рис. 9.7, пользователь решил посмотреть значе-
ние прибыли нарастающим итогом с начала года. Но он также может просмат-
ривать любые комбинации двух групп вычислений: Metric и Time calc.
Чтобы построить этот отчет, мы создали дополнительную группу вычисле-
ний Metric, вместившую в себя элементы вычисления Sales Amount, Total Cost,
Margin и Sales Quantity. В выражении для каждого элемента вычисления про-
писана просто ссылка на соответствующую меру, как показано на рис. 9.8 на
примере элемента вычисления Sales Amount.
Когда в модели данных присутствует несколько групп вычислений, очень
важно определить порядок, в котором они будут применяться движком DAX.
За этот аспект отвечает свойство Precedence: первой применяется группа с мак-
симальным значением этого свойства. Для достижения желаемого результата
мы увеличили значение свойства Precedence у группы вычислений Time Intelli-
gence до 10, как показано на рис. 9.9. Таким образом, движок применяет группу
вычислений Time Intelligence раньше, чем Metric, свойство Precedence которой
осталось нулевым по умолчанию. Позже в данной главе мы обсудим порядок
применения групп вычислений более подробно.
В следующем коде DAX представлены все элементы вычисления в группе
вычислений Metric:
-- Calculation Item: Margin
320 ГЛАВА 9 Группы вычислений
[Margin]
- - Calculation Item: Sales Amount
[Sales Amount]
- - Calculation Item: Sales Quantity
[Sales Quantity]
- - Calculation Item: Total Cost
[Total Cost]
T K:'.SQLBI\Books\The Definitive Guide to DAX 2.C\Ch 09 - Calculation groupsX... — □ X
File Edit View, Model Calculation Item Tools
Ф H=l Perspective; (All objects) r Translation (No translation)
0 ПД Expression Editor Advanced Scripting
v (2) Model
> Data Sources
Perspectives
> Relationships
Roles
Й Shared Expressions
v- Tables
> ffi Customer
> Date
> Sales
> НЯ Product
> |Ш] Time Intelligence
v |Щ] 1 Metric
v § Metre
У X | тм! T " | P вЬ | ® | Property:
^Sales Amount]
Sales Amount
Total Cost
Ur-*i Margin
Sales Quantity
Translations
v Basic
Description
Nan ie
Object Type
v Metadata
Error Message
State
V ilptiCri!
Expression
Format String Expression
Sales Amount
Calculation Hern
Ready
[Sales Amount]
\апи_!
The name of ths object. Warning. Changing the name can break formula logic if
Automatic Formula Fix-up is disabled.
1 calculation item ^elected.
[b BP issues
|c0.43 seconds.
Рис. 9.8 Группа вычислений Metric включает в себя четыре элемента вычисления,
каждый из который представляет соответствующую меру
В этих элементах вычисления оригинальные меры не изменяются, они
представлены в исходном виде. Чтобы добиться такого поведения, достаточно
не указывать в коде элемента функцию SELECTEDMEASURE. Эта функция очень
часто применяется в элементах вычисления, но это отнюдь не обязательно.
Последний пример также полезен для демонстрации одного из многих ню-
ансов, связанных с использованием групп вычислений. Если пользователь вы-
берет метрику Quantity, в отчете будет показано количество проданных то-
варов в таком же формате (с двумя знаками после запятой), как и для других
метрик. А поскольку исходная мера Quantity характеризуется целочисленным
ГЛАВА 9 Группы вычислений 321
типом, было бы удобно удалить эти знаки после запятой или изменить форма-
тирование. Ранее мы уже говорили, что присутствие в выражении нескольких
групп вычислений требует указания порядка их применения, как было показа-
но в предыдущем примере. Это лишь одно из многих правил, которых следует
придерживаться для создания эффективных групп вычислений.
Примечание Если вы используете Analysis Services, помните, что добавление группы вы-
числений требует обновления соответствующей ей таблицы, чтобы элементы вычисления
оказались видимы пользователю. Это не самое очевидное требование, ведь размещение
новой меры, допустим, не нуждается в подобном обновлении - она становится види-
мой пользователю сразу после сохранения. Но поскольку группы и элементы вычислений
представлены на клиенте в виде таблиц и столбцов, после их размещения необходимо
запустить операцию обновления, чтобы загрузить внутренние данные в таблицы и столб-
цы. В Power Bl такое обновление будет производиться автоматически при помощи поль-
зовательского интерфейса, но это только наши домыслы, поскольку на момент написания
книги в этом инструменте группы вычислений не были представлены.
\_________________________________________________________________________________-
f < K:\SQLBI\Book5\The Definitive Guideto DAX2.0\Ch09 - Calculation groupsV. — □ X
File Edit View Medel Tools Calculation Group
fcl Ф цИ Perspective: (All objects)
T Translation. (No translation)
Expression Editor Advanced Scnptina
v ф Model
> I Data Sources
Perspectives
> I Relationships
Roles
У X О " >' BL I = Property:
Shared Expressions
* Tables
> ffi Customer
> Date
> Sales
> НЯ Product
Time Нейуепсе
v [Ц Metric
g Metric
Sales Amount
Total Cost
Margin
Saies Quantity
Translations
Description
Hidden
Name
Object Type
v Calculation Group
> Annotations
False
Time Intelligence
Calculation Group
0 annotations
Description
| Precedence 1C
Translaiioni. and PerspecJ.ve^
>__Annotations[[annotations
Name
The name of th s ooject Warning: Changing the name can break formula logic
if Mcmatic Formula Fix-up is disab’ed.
1 calculation group selected.
Рис. 9.9 Свойство Precedence определяет, в каком порядке группы вычисления
будут применяться к мере
Знакомство с группами вычислений
В предыдущем разделе мы сосредоточились на использовании групп вычисле-
ний и их создании при помощи Tabular Editor. Сейчас же мы подробнее позна-
комимся со свойствами и поведением групп и элементов вычислений.
Существует два вида сущностей: группы вычислений и элементы вычисле-
ния. Группа вычислений представляет собой коллекцию элементов вычисле-
322 ГЛАВА 9 Группы вычислений
ния, объединенную по выбранному пользователем критерию. Обе сущности
обладают своим набором свойств, которые корректно должны быть установле-
ны разработчиком. Сейчас мы познакомим вас с этими свойствами, а в остав-
шейся части главы представим несколько подробных примеров использования
групп и элементов вычислений.
Группа вычислений является довольно простой сущностью, определяемой
следующими свойствами:
названием группы или свойством Name. Под этим именем таблица, пред-
ставляющая группу вычислений, будет представлена на клиенте;
очередностью применения группы вычислений к мерам, то есть свой-
ством Precedence. При наличии множества активных групп вычислений
это число используется для определения порядка, в котором группы вы-
числений будут применяться к мерам;
свойством Name для атрибута группы вычислений. Это название будет
дано столбцу с элементами вычисления, отображаемому на клиенте.
Элемент вычисления - сущность чуть более сложная, и она включает следу-
ющие свойства:
название элемента вычисления (Name). Это свойство характеризует зна-
чение, которое будет присутствовать в столбце. По сути, элемент вычис-
ления представлен в группе вычислений одной строкой;
выражение элемента вычисления (Expression). Выражение на языке DAX,
которое может включать специальные функции вроде SELECTEDMEA-
SURE. Это выражение определяет, как будет применяться к мере элемент
вычисления;
порядок сортировки элементов вычисления (Ordinal). Этим свойством
определяется, как элементы вычисления будут отсортированы при пред-
ставлении пользователю, и напоминает сортировку по столбцу в модели
данных. По состоянию на апрель 2019 года это свойство недоступно, но
должно быть реализовано к выходу релиза;
строка форматирования (Format String). Если не указана, то будет уна-
следована от базовой меры. Если же модификатор меняет вычисление,
можно переопределить строку форматирования меры этим свойством.
Свойство Format String является очень важным, поскольку позволяет до-
биться предсказуемого поведения от мер в модели данных, соответствующего
применяемому к ним элементу вычисления. Представьте, что в вашей группе
вычислений есть два элемента вычисления, выполняющих операции со вре-
менем: YOY (year-over-year) представляет разницу в значениях между выбран-
ным периодом и аналогичным периодом предыдущего года, a YOY% выводит
разницу YOY между периодами в процентном отношении:
Calculation Item: YOY
VAR CurrYear =
SELECTEDMEASURE ()
VAR PrevYear =
CALCULATE (
ГЛАВА 9 Группы вычислений 323
SELECTEDMEASURE ();
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
VAR Result =
CurrYear - PrevYear
RETURN Result
-- Calculation Iten: YOY%
VAR CurrYear =
SELECTEDMEASURE ()
VAR PrevYear =
CALCULATE (
SELECTEDMEASURE ();
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
VAR Result =
DIVIDE (
CurrYear - PrevYear;
PrevYear
)
RETURN Result
Применение этих элементов вычисления в отчете дает правильные резуль-
таты, но если не переопределить свойство Format String для элемента YOY%,
то его значение будет выводиться с двумя знаками после запятой, а не в виде
процента, как показано на рис. 9.10.
Calendar Year Month YOY YOY%
CY 2005 ▲
CY 2006 January -75,865.64 -0.12
CY 2007 February 22,501.14 0.04
March -63,400.65 -0.11
CY 2008
CY 2009 April -320,773.95 -0.32
CY 2010 May 173,933.27 0.19
June 27,444.59 0.03
CY 2011
July 177,849.17 0.20
August 114,146.50 0.16
September -253,826.83 -0.26
October 86,945.23 012 Рис. 9.10 Элементы вычисления
November -287,945.31 -0.25 YOY и YOY% обладают тем же
December -174,775.64 -0.19 форматированием, что и мера
Total -573,768.12 -0.06 Sales Amount
На рис. 9.10 видно, что элемент вычисления YOY унаследовал строку фор-
матирования от исходной меры Sales Amount, что в данном случае вполне при-
емлемо. Что касается элемента YOY%, было бы логичнее выводить его в от-
чет не с десятичными знаками, а в виде процента. То есть для января хотелось
бы видеть не -0,12, а -12 %. Таким образом, нам необходимо переопределить
строку форматирования для этого столбца, чтобы она не зависела от исходной
324 ГЛАВА 9 Группы вычислений
меры. Чтобы добиться желаемого результата, нужно в свойстве Format String
элемента вычисления YOY% выбрать проценты и тем самым переопределить
базовые правила форматирования меры. Результат показан на рис. 9.11. Если
свойство Format String не задано для элемента вычисления, будет использовано
существующее значение.
Calendar Year Month YOY YOY%
CY 2005 СУ 2006 ▲ January -75,865.64 -12%
CY 2007 February 22,501.14 4%
March -63,400.65 -11%
CY 2008 CY 2009 April -320,773.95 -32%
CY 2010 May 173,933.27 19%
CY 2011 June 27,444.59 3%
July 177,849.17 20%
August 114,146.50 16%
September -253,826.83 -26%
October 86,945.23 12%
November -287,945.31 -25%
December -174,775.64 -19%
Total -573,768.12 -6%
Рис. 9.11 В элементе вычисления YOY%
переопределяется строка форматирования меры Sales Amount
При этом свойство Format String может быть задано как в фиксированном
виде, так и - для более сложных случаев - в виде выражения DAX, возвраща-
ющего строку форматирования. В последнем случае допустимо обращаться
к строке форматирования текущей меры при помощи функции SELECTED-
MEASUREFORMATSTRING. Например, если в модели данных есть мера, возвра-
щающая выбранную в данный момент валюту, и вы хотите добавить в отобра-
жении символ соответствующей валюты, вы можете использовать для этого
следующий код:
SELECTEDMEASUREFORMATSTRING () & " " & [Selected Currency]
Настройка строк форматирования элементов вычисления может быть очень
полезна для сохранения привычного восприятия модели данных пользовате-
лем. При этом разработчик должен помнить, что строка форматирования эле-
мента вычисления будет распространяться на все меры, используемые с этим
элементом. Кроме того, при наличии нескольких групп вычислений в отчете
результат этого свойства также будет зависеть от очередности применения
групп, о чем мы поговорим в следующем разделе.
Применение элемента вычисления
До сих пор в своих объяснениях относительно того, что из себя представляют
элементы вычисления, мы не углублялись в детали. Причина этого в том, что
сначала мы хотели показать, как работает эта концепция на практике - без
ГЛАВА 9 Группы вычислений 325
лишних подробностей, которые могли вас отвлечь от основной идеи. Мы ска-
зали о том, что элементы вычисления могут применяться пользователями -
скажем, путем их включения в срезы. При наличии активного в текущем кон-
тексте фильтра элемента вычисления он фактически заменяет собой исходную
меру. По сути, вместо вычисления меры происходит вычисление выражения,
прописанного в соответствующем элементе.
Представьте, что у вас есть следующий элемент вычисления:
Calculation Item: YTD
CALCULATE (
SELECTEDMEASURE ();
DATESYTD ( 'Date'[Date] )
)
Чтобы применить элемент вычисления в выражении, необходимо отфильт-
ровать группу вычислений. Можно создать фильтр путем вызова функции
CALCULATE, как в следующем примере. Именно эту технику используют кли-
ентские инструменты в срезах и элементах визуализации:
CALCULATE (
[Sales Amount];
'Time Intelligence'[Time calc] = "YTD"
)
В группах вычислений нет ничего магического - это просто таблицы, а зна-
чит, они могут быть отфильтрованы функцией CALCULATE, как и все другие
таблицы. Когда функция CALCULATE применяет фильтр к элементу вычисле-
ния, DAX использует определение этого элемента для переопределения выра-
жения, после чего запускает его на выполнение.
Таким образом, основываясь на определении указанного элемента вычисле-
ния, предыдущий код будет интерпретироваться следующим образом:
CALCULATE (
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
)
)
Примечание Во внутренней функции CALCULATE допустимо использовать функцию
ISFILTERED для проверки вхождения элемента вычисления в фильтр. В нашем примере
мы убрали внешнюю фильтрацию в выражении для простоты восприятия, чтобы показать,
что элемент вычисления уже был применен. При этом элемент вычисления сохраняет
свои фильтры, и в дальнейших подвыражениях по-прежнему может быть выполнена за-
мена меры.
\_______________________________________________________________________)
Несмотря на кажущуюся простоту при использовании в простых сценариях,
элементы вычисления таят в себе определенные сложности. Применение эле-
326 ГЛАВА 9 Группы вычислений
мента вычисления выполняет замену исходной меры на выражение элемента.
Обратите внимание на формулировку: выполняет замену исходной меры. А без
меры элемент вычисления не выполняет никаких преобразований. Например,
в следующей формуле не будет применяться элемент вычисления, поскольку
в ней нет ссылки на меру:
CALCULATE (
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] );
'Tine Intelligence'[Tine calc] = "YTD"
)
В этом случае элемент вычисления не будет выполнять никаких преобразо-
ваний, поскольку в первом параметре функции CALCULATE отсутствует ссылка
на меру. Таким образом, после применения элемента вычисления будет вы-
полнен следующий код:
CALCULATE (
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
)
Если выражение в функции CALCULATE будет содержать сразу несколько
ссылок на меры, все они будут заменены на определение элемента вычисле-
ния. Например, следующая мера Cost Ratio YTD содержит сразу две ссылки на
меры: Total Cost и Sales Amount:
CR YTD :=
CALCULATE (
DIVIDE (
[Total Cost];
[Sales Anount]
);
'Tine Intelligence'[Tine calc] = "YTD"
)
Чтобы получить код, который будет выполнен, необходимо все ссылки на
меры заменить на определение соответствующего элемента вычисления, как
показано в мере CR YTD Actual Code:
CR YTD Actual Code :=
CALCULATE (
DIVIDE (
CALCULATE (
[Total Cost];
DATESYTD ( 'Date'[Date] )
);
CALCULATE (
[Sales Anount];
DATESYTD ( 'Date'[Date] )
)
)
)
ГЛАВА 9 Группы вычислений 327
В данном случае результат выполнения этого кода будет эквивалентен сле-
дующему фрагменту меры CR YTD Simplified, чуть более понятному:
CR YTD Simplified :=
CALCULATE (
CALCULATE (
DIVIDE (
[Total Cost];
[Sales Amount]
);
DATESYTD ( 'Date'[Date] )
)
)
Все три меры вернут одинаковые результаты, как показано на рис. 9.12.
Calendar Year Month CRYTD CR YTD Actual Code CR YTD Simplified
CY 2005 CY 2006 ж January 49.84 % 49.84 % 49.84 %
CY 2007 February 49.30 % 49.30 % 49.30 %
CY 200ft March 49.05 % 49.05 % 49.05 %
CY 2009 April 48.66 % 48.66 % 48.66 %
CY 2010 May 47.96 % 47.96 % 47.96 %
CY 2011 June 47.45 % 47.45 % 47.45 %
July 47.12% 47.12% 47.12%
August 46.85 % 46.85 % 46.85 %
September 46.75 % 46.75 % 46.75 %
October 46.66 % 46.66 % 46.66 %
November 47.18% 47.18% 47.18%
December 47.50 % 47.50 % 47.50 %
Total 47.50 % 47.50 % 47.50 %
Рис. 9.12 Меры CR YTD, CR YTD Actual Code и CR YTD Simplified дают одинаковые цифры
Но вам необходимо соблюдать предельную осторожность, поскольку форму-
ла меры CR YTD Simplified не в точности соответствует коду, сгенерированному
элементом вычисления, который показан в мере CR YTD Actual Code. Да, в дан-
ном конкретном случае эти меры эквивалентны. Но в более сложных сценариях
результаты будут совершенно разными, и понять причину этих различий может
быть очень непросто. Давайте рассмотрим пару примеров. В первом из них мера
Sales YTD 2008 2009 будет содержать две вложенные функции CALCULATE: во
внешней будет устанавливаться фильтр на 2008 год, а во внутренней - на 2009-й:
Sales YTD 2008 2009 :=
CALCULATE (
CALCULATE (
[Sales Amount];
'Date'[Calendar Year] = "CY 2009"
);
'Time Intelligence'[Time calc] = "YTD";
'Date'[Calendar Year] = "CY 2008"
)
328 ГЛАВА 9 Группы вычислений
Также в фильтре внешней функции CALCULATE указан элемент вычисления
YTD. Но при этом выражение не будет изменено, поскольку оно не содержит
напрямую ни одной ссылки на меру. В результате функция CALCULATE при-
меняет элемент вычисления, но это не ведет ни к каким изменениям в коде.
Обратите внимание на то, что ссылка на меру Sales Amount находится в об-
ласти видимости внутренней функции CALCULATE. Применение элемента вы-
числения оказывает влияние на меры в текущей области видимости контекста
фильтра и никак не затрагивает меры во вложенных функциях. Эти меры могут
быть преобразованы своей функцией CALCULATE (или эквивалентным кодом
вроде функции CALCULATETABLE либо преобразования контекста), в которой
может присутствовать, а может и не присутствовать тот же фильтр с указанием
элемента вычисления.
Когда внутренняя функция CALCULATE применяет свой контекст фильтра,
она не меняет статус фильтра элемента вычисления. Таким образом, элемент
вычисления сохраняет свое место в фильтре и будет сохранять его, пока другая
функция CALCULATE не изменит его. Здесь ситуация такая же, как если бы мы
имели дело с обычным столбцом. Во внутренней функции CALCULATE присут-
ствует ссылка на меру, и DAX применяет к ней элемент вычисления. Результи-
рующий код показан ниже в мере Sales YTD 2008 2009 Actual Code:
Sales YTD 2008 2009 Actual Code :=
CALCULATE (
CALCULATE (
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
);
'Date'[Calendar Year] = "CY 2009"
);
'Date'[Calendar Year] = "CY 2008"
)
Результаты вычисления этих двух мер показаны на рис. 9.13. Выбор на срезе
слева распространяется на матрицу посередине, в которой размещены меры
Sales YTD 2008 2009 и Sales YTD 2008 2009 Actual Code. При этом выбор года CY
2008 перекрывается годом CY 2009. Это легко проверить, взглянув на матрицу
справа, где показан вывод меры Sales Amount, трансформированной элемен-
том вычисления YTD по годам CY 2008 и CY 2009. Цифры в средней матрице
соответствуют столбцу CY 2009 справа.
Функция DATESYTD применяется в тот момент, когда контекст фильтра
установлен на 2009 год, а не на 2008-й. Несмотря на то что элемент вычисления
стоит в фильтрах рядом с 2008 годом, в действительности его применение про-
исходит в другом контексте фильтра, а именно во внутренней функции. Такое
поведение выглядит не совсем логично, если не сказать больше. И чем сложнее
выражение будет использоваться внутри функции CALCULATE, тем труднее бу-
дет понять, как это все работает.
Такое поведение элементов вычисления наводит на мысль о том, что ис-
пользовать их для модификации выражения надо в том и только в том случае,
если выражение само по себе является ссылкой на меру. Предыдущий пример
ГЛАВА 9 Группы вычислений 329
мы использовали, чтобы продемонстрировать вам это важное правило, а те-
перь рассмотрим более сложный сценарий. В следующей формуле рассчитаем
количество рабочих дней в тех месяцах, когда были продажи:
SUMX (
VALUES ( 'Date'[Calendar Year month] );
IF (
[Sales Amount] >0; -- Ссылка на меру
[# Working Days] -- Ссылка на меру
)
)
Calendar Year Month Sales YTD 2008 2009 Sales YTD 2008 2009 Actual Code Month CY 2008 CY 2009
CY 2005 January 580,901.05 580,901.05 January 656,766.69 580,901.05
CY 2006 February 1,203,482.19 1,203,482.19 February 1,256,846.69 1,203,482.19
CY 2007 March 1,699,620.05 1,699,620.05 March 1,816,385.21 1,699,620.05
CY 2008 April 2,378,513.27 2,378,513.27 April 2,816,052.38 2,378,513.27
CY 2009 May 3,445,678.50 3,445,678.50 May 3,709,284.34 3,445,678.50
CY 2010 June 4,318,264.70 4,318,264.70 June 4,554,425.94 4,318,264.70
CY 2011 July 5,386,661.27 5,386,661.27 July 5,444,973.35 5,386,661.27
August 6,222,368.73 6,222,368.73 August 6,166,534.30 6,222,368.73
September 6,931,979.13 6,931,979.13 September 7,129,971.53 6,931,979.13
October 7,738,717.35 7,738,717.35 October 7,849,764.52 7,738,717.35
November 8,606,881.36 8,606,881.36 November 9,005,873.85 8,606,881.36
December 9,353,814.87 9,353,814.87 December 9,927,582.99 9,353,814.87
Total 9.353,814.87 9.353.814.87 Total 9,927,582.99 9,353,814.87
Рис. 9.13 Меры Sales YTD 2008 2009 и Sales YTD 2008 2009 Actual Code
дают одинаковые результаты
Это вычисление может быть полезным для расчета меры Sales Amount в от-
ношении к рабочим дням по месяцам, когда совершались продажи. В следую-
щем примере это выражение используется в составе более сложной формулы:
DIVIDE (
[Sales Amount]; -- Ссылка на меру
SUMX (
VALUES ( 'Date'[Calendar Year month] );
IF (
[Sales Amount] > 0; -- Ссылка на меру
[# Working Days] -- Ссылка на меру
)
)
)
Если это выражение использовать внутри функции CALCULATE, отфильтро-
ванной по элементу вычисления YTD, получим следующую меру, которая бу-
дет выдавать неожиданные результаты:
Sales WD YTD 2008 :=
CALCULATE (
DIVIDE (
[Sales Amount]; -- Ссылка на меру
330 ГЛАВА 9 Группы вычислений
SUMX (
VALUES ( 'Date'[Calendar Year month] );
IF (
[Sales Amount] > 0; -- Ссылка на меру
[# Working Days] -- Ссылка на меру
)
)
);
'Time Intelligence'[Time calc] = "YTD";
'Date'[Calendar Year] = "CY 2008"
)
Можно было бы предположить, что эта мера вычисляет сумму продаж в рас-
чете на количество рабочих дней с учетом только тех месяцев, когда были про-
дажи. Иначе говоря, итоговый код можно было бы представить так:
Sales WD YTD 2008 Expected Code : =
CALCULATE (
CALCULATE (
DIVIDE (
[Sales Amount]; -- Ссылка на меру
SUMX (
VALUES ( 'Date'[Calendar Year month] );
IF (
[Sales Amount] >0; -- Ссылка на меру
[# Working Days] -- Ссылка на меру
)
)
);
DATESYTD ( 'Date'[Date] )
);
'Date'[Calendar Year] = "CY 2008"
)
Вы, наверное, заметили, что мы все три меры внутри выражения отметили
специальными комментариями. И это не случайно. Применение элемента вы-
числения происходит к ссылке на меру, а не ко всему выражению. А значит,
итоговый код с заменой мер на элементы вычисления, активные в текущем
контексте фильтра, будет таким:
Sales WD YTD 2008 Actual Code :=
CALCULATE (
DIVIDE (
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
);
SUMX (
VALUES ( 'Date'[Calendar Year month] );
IF (
CALCULATE (
[Sales Amount];
ГЛАВА 9 Группы вычислений 331
DATESYTD ( 'Date'[Date] )
) > 0;
CALCULATE (
[# Working Days];
DATESYTD ( 'Date'[Date] )
)
)
)
'Date'[Calendar Year] = "CY 2008"
)
Эта последняя версия формулы будет совершенно неправильно считать ра-
бочие дни, фактически суммируя нарастающие итоги по количеству рабочих
дней с начала года для всех месяцев в текущем контексте фильтра. Понятно,
что правильных результатов от такого алгоритма можно не ждать. По одному
выбранному месяцу результат оказался правильным (просто повезло), но по
кварталу или году ошибка в расчетах более чем очевидна. Это хорошо заметно
на рис. 9.14.
Q1-2008 Q2-2008 Q3-2008 Q4-2008
Sales Amount 1,816,385.21 2,738,040.73 2,575,545.59 2,797,611.46
# Working Days 91 91 92 92
Sales WDYTD 2008 9,980.14 10,009.73 9,753.72 9,868.37
Sales WD YTD 2008 Expected Code 19,960.28 25,024.32 26,021.79 27,124.54
Sales WD YTD 2008 Actual Code 9,980.14 10,009.73 9,753.72 9,868.37
Sales WDYTD 2008 Fixed 19,960.28 25,024.32 26,021.79 27,124.54
Рис. 9.14 Результаты разных версий меры Sales WD по всем кварталам 2008 года
Мера Sales WD YTD 2008 Expected Code возвращает правильные результаты
по кварталам, тогда как в мерах Sales WD YTD 2008 и Sales WD YTD 2008 Actual
Code цифры оказались занижены. И это неудивительно, поскольку количество
рабочих дней в знаменателе в этих мерах рассчитывается путем сложения на-
растающих итогов с начала года по всем месяцам в выбранном периоде.
Можно легко избежать проблем с этим, если придерживаться главного пра-
вила: использовать функцию CALCULATE с элементами вычисления только для
мер. При написании корректной меры Sales WD YTD 2008 Fixed мы включили
в функцию CALCULATE только одну меру, вычисленную заранее:
- - Мера Sales WD
Sales WD :=
DIVIDE (
[Sales Amount];
SUMX (
VALUES ( 'Date'[Calendar Year month] );
IF (
[Sales Amount] > 0;
332 ГЛАВА 9 Группы вычислений
[# Working Days]
)
)
)
- - Мера Sales WD YTD 2008 Fixed
- - Новая версия меры Sales WD YTD 2008 с применением элемента вычисления YTD
Sales WD YTD 2008 Fixed :=
CALCULATE (
[Sales WD]; -- Ссылка на меру
'Tine Intelligence'[Tine calc] = "YTD";
'Date'[Calendar Year] = "CY 2008"
)
В этом случае код, сгенерированный в результате применения элемента вы-
числения, получится гораздо более интуитивно понятным:
Sales WD YTD 2008 Fixed Actual Code :=
CALCULATE (
CALCULATE (
[Sales WD];
DATESYTD ( 'Date'[Date] )
);
'Date'[Calendar Year] = "CY 2008"
)
Из данного примера видно, что фильтр с функцией DATESYTD применяется
ко всему выражению в целом, что ведет к образованию кода, ожидаемого от
применения элемента вычисления. Результаты вычисления мер Sales WD YTD
2008 Fixed и Sales WD YTD 2008 Fixed Actual Code также показаны на рис. 9.14.
Для простейших вычислений допустимо отходить от сформулированного
выше главного правила использования элементов вычисления. Но при этом
нужно дважды подумать о возможных последствиях, поскольку при усложне-
нии выражения велика вероятность того, что оно начнет выдавать неправиль-
ные результаты.
При использовании клиентских инструментов вроде Power BI вам не при-
дется беспокоиться об этих деталях. В таких приложениях производится при-
нудительная проверка того, что элементы вычисления применяются правиль-
но, и в итоговом запросе все действия производятся исключительно с одной
мерой. Но вам как разработчику DAX придется неоднократно использовать
элементы вычисления в качестве фильтра в функции CALCULATE. И когда вы
это делаете, обращайте внимание на выражение, используемое в качестве пер-
вого параметра функции. Если хотите, чтобы функция CALCULATE гарантиро-
ванно выдавала правильные результаты, позаботьтесь о том, чтобы в выраже-
нии всегда находилась ссылка на меру. Никогда не используйте CALCULATE
с выражениями.
Наконец, мы советуем вам изучать элементы вычисления путем переписы-
вания готовых выражений функции CALCULATE. Это поможет вам лучше по-
нять, что происходит в движке DAX.
ГЛАВА 9 Группы вычислений 333
Очередность применения групп вычислений
В предыдущем разделе мы говорили о том, что элемент вычисления следует
применять исключительно к мерам. При этом есть возможность применить
к одной мере сразу несколько элементов. Несмотря на то что в каждой группе
вычислений может быть активен только один элемент, присутствие несколь-
ких групп может позволить применить к мере больше одного элемента вы-
числения. Это возможно, когда пользователь работает со множественными
срезами по разным группам вычисления или когда в функции CALCULATE при-
сутствуют фильтры по элементам вычисления из разных групп. В начале главы
мы определили две группы вычислений: одну для определения базовой меры,
а вторую - для расчета, связанного с логикой операций со временем, который
должен быть применен к базовой мере.
Если в текущем контексте фильтра активны несколько элементов вычисле-
ния, важно определиться с тем, какой из них будет применяться первым. Для
этого необходимо определить правила очередности их применения. В DAX
при наличии нескольких групп вычислений обязательно требуется установить
свойство Precedence для каждой из них. В данном разделе мы рассмотрим на
примерах, как правильно устанавливать это свойство, и увидим, как при этом
будут меняться результаты.
Для подготовки демонстрации мы создали две группы вычислений, в каж-
дой из которых присутствует по одному элементу вычисления:
Calculation Group: 'Time Intelligence'[Time calc]
Calculation Item: YTD
CALCULATE (
SELECTEDMEASURE ();
DATESYTD ( 'Date'[Date] )
)
- - Calculation Group: 'Averages'[Averages]
- - Calculation Item: Daily AVG
DIVIDE (
SELECTEDMEASURE ();
COUNTROWS ( 'Date' )
)
YTD представляет собой обычный нарастающий итог с начала года, a Daily
AVG рассчитывает среднедневное значение путем деления значения меры на
количество дней в текущем контексте фильтра. Оба элемента вычисления пре-
334 ГЛАВА 9 Группы вычислений
красно работают, как видно по рис. 9.15, где мы использовали две меры для
индивидуального применения элементов:
YTD : =
CALCULATE (
[Sales Amount];
'Tine Aggregation'[Aggregation] = "YTD"
)
Daily AVG :=
CALCULATE (
[Sales Amount];
'Averages'[Averages] = "Daily AVG"
)
Calendar Year Month Sales Amount Daily AVG YTD
CY 2005 ▲
CY 2006 January 580,901.05 18,738.74 580,901.05
CY 2007 February 622,581.14 22,235.04 1,203,482.19
CY 2008 March 496,137.87 16,004.45 1,699,620.05
CY 2009 April 678,893.22 22,629.77 2,378,513.27
CY 2010 May 1,067,165.23 34,424.68 3,445,678.50
CY 2011 June 872,586.20 29,086.21 4,318,264.70
July 1,068,396.58 34,464.41 5,386,661.27
August 835,707.46 26,958.31 6,222,368.73
September 709,610.40 23,653.68 6,931,979.13
October 806,738.22 26,023.81 7,738,717.35
November 868,164.01 28,938.80 8,606,881.36
December 746,933.50 24,094.63 9,353,814.87
Total 9,353,814.87 25,626.89 9,353,814.87
Рис. 9.15 Меры Daily AVG и YTD работают правильно
при применении элементов вычисления к мерам отдельно
Но сценарий значительно усложнится, если использовать два элемента вы-
числения одновременно. Взгляните на следующее определение меры Daily
YTD AVG:
Daily YTD AVG :=
CALCULATE (
[Sales Amount];
'Time Intelligence'[Time calc] = "YTD";
'Averages'[Averages] = "Daily AVG"
)
К мере применяются оба элемента вычисления одновременно, что порож-
дает конфликт очередности их применения. Должен ли движок DAX сначала
применить элемент YTD, а затем Daily AVG, или наоборот? Иными словами,
какое из указанных ниже выражений в результате должно образоваться?
ГЛАВА 9 Группы вычислений 555
Сначала применяется YTD, а затем DIVIDE
DIVIDE (
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
);
COUNTROWS ( 'Date' )
- - Сначала применяется DIVIDE, а затем YTD
CALCULATE (
DIVIDE (
[Sales Amount];
COUNTROWS ( 'Date' )
);
DATESYTD ( 'Date'[Date] )
)
Вероятно, правильным будет второй вариант. Но без специальных на то ука-
заний DAX не может сам сделать правильный выбор. А значит, разработчик
должен ему в этом помочь.
Очередность применения элементов вычисления напрямую зависит от зна-
чений свойства Precedence соответствующих им групп. Элемент из группы вы-
числений с наибольшим значением очередности будет применяться первым,
а все остальные - следом за ним в порядке убывания очередности. На рис. 9.16
показан результат неправильного расчета со следующими выбранными пара-
метрами:
группа вычислений Time Intelligence - Precedence: 0;
группа вычислений Averages - Precedence: 10.
Calendar Year Month Sales Amount Daily AVG YTD Daily YTD AVG
CY 2005 A
CY 2006 January 580,901.05 18,738.74 580,901.05 18,738.74
CY 2007 February 622,581.14 22,235.04 1,203,482.19 42,981.51
CY 2008 March 496,137.87 16,004.45 1,699,620.05 54,826.45
CY 2009 April 678,893.22 22,629.77 2,378,513.27 79,283.78
CY 2010 May 1,067,165.23 34,424.68 3,445,678.50 111,150.92
CY 2011 June 872,586.20 29,086.21 4,318,264.70 143,942.16
July 1,068,396.58 34,464.41 5,386,661.27 173,763.27
August 835,707.46 26,958.31 6,222,368.73 200,721.57
September 709,610.40 23,653.68 6,931,979.13 231,065.97
October 806,738.22 26,023.81 7,738,717.35 249,636.04
November 868,164.01 28,938.80 8,606,881.36 286,896.05
December 746,933.50 24,094.63 9,353,814.87 301,735.96
Total 9,353,814.87 25,626.89 9,353,814.87 25,626.89
Рис. 9.16 В мере Daily YTD AVG вычисляются неправильные результаты
336 ГЛАВА 9 Группы вычислений
Очевидно, что по всем месяцам, кроме января, мера Daily YTD AVG показы-
вает некорректные значения. Давайте разберемся, что же произошло. Очеред-
ность группы вычислений Averages установлена в 10, а значит, эта группа бу-
дет вступать в действие первой. Применение элемента вычисления Daily AVG
приводит к следующему вычислению:
CALCULATE (
DIVIDE (
[Sales Anount];
COUNTROWS ( 'Date' )
);
'Tine Intelligence'[Tine calc] = "YTD"
)
В этот момент DAX активирует элемент вычисления YTD из группы вы-
числений Time Intelligence. Применение элемента YTD приводит к изменению
единственной меры в этой формуле, а именно Sales Amount. Соответственно,
результирующий код меры Daily YTD AVG получится таким:
DIVIDE (
CALCULATE (
[Sales Anount];
DATESYTD ( 'Date'[Date] )
);
COUNTROWS ( 'Date' )
)
Следовательно, итоговый результат будет получен путем деления значения
меры Sales Amount, вычисленного под действием элемента вычисления YTD,
на количество дней в выбранном месяце. Например, значение меры для де-
кабря было получено путем деления 9 353 814,87 (YTD, примененный к Sales
Amount) на 31 (количество дней в декабре). Но правильный результат должен
быть гораздо меньше, поскольку элемент YTD необходимо применять как
к числителю, так и к знаменателю функции DIVIDE, используемой в элементе
вычисления Daily AVG.
Чтобы решить эту задачу, нужно сначала применить элемент YTD, а затем
Daily AVG. В этом случае изменение контекста фильтра для столбца Date про-
изойдет раньше, чем будет вычислено значение COUNTROWS по таблице дат.
Для этого мы изменим значение свойства Precedence для группы вычислений
Time Intelligence на 20, что приведет к следующим настройкам:
группа вычислений Time Intelligence - Precedence: 20;
группа вычислений Averages - Precedence: 10.
С такими настройками очередности применения групп вычислений резуль-
тат меры Daily YTD AVG будет правильным, что видно по рис. 9.17.
В этом случае DAX сначала применяет элемент вычисления YTD из группы
вычислений Time Intelligence, преобразуя выражение следующим образом:
CALCULATE (
CALCULATE (
[Sales Anount];
ГЛАВА 9 Группы вычислений 557
DATESYTD ( 'Date'[Date] )
);
'Averages'[Averages] = "Daily AVG"
Calendar Year Month Sales Amount Daily AVG YTD Daily YTD AVG
CY 2005 CY 2006 ж January 580,901.05 18,738.74 580,901.05 18,738.74
CY 2007 February 622,581.14 22,235.04 1,203,482.19 20,398.00
CY 2008 CY 2009 March 496,137.87 16,004.45 1,699,620.05 18,884.67
April 678,893.22 22,629.77 2,378,513.27 19,820.94
CY 2010 May 1,067,165.23 34,424.68 3,445,678.50 22,819.06
CY 2011 June 872,586.20 29,086.21 4,318,264.70 23,857.82
July 1,068,396.58 34,464.41 5,386,661.27 25,408.78
August 835,707.46 26,958.31 6,222,368.73 25,606.46
September 709,610.40 23,653.68 6,931,979.13 25,391.87
October 806,738.22 26,023.81 7,738,717.35 25,456.31
November 868,164.01 28,938.80 8,606,881.36 25,769.11
December 746,933.50 24,094.63 9,353,814.87 25,626.89
Total 9,353,814.87 25,626.89 9,353,814.87 25,626.89
Рис. 9.17 Мера Daily YTD AVG показывает правильный результат
После этого применяется элемент Daily AVG из группы вычислений Avera-
ges, заменяя меру на функцию DIVIDE, что приводит к такому выражению:
CALCULATE (
DIVIDE (
[Sales Anount];
COUNTROWS ( 'Date' )
);
DATESYTD ( 'Date'[Date] )
)
Теперь при вычислении значения для декабря в знаменателе будут учиты-
ваться все 365 дней года, а значит, и общий результат будет правильным. Об-
ратите внимание также, что в этом примере мы строго следовали нашему глав-
ному правилу использования элементов вычисления, то есть применяли их
исключительно к мерам. При отображении меры Sales Amount в визуализации
Power BI применение одного из двух элементов вычисления преобразовало
меру таким образом, что результат перестал соответствовать действительно-
сти. Получается, в нашем сценарии недостаточно было просто следовать на-
шему правилу - нужно было еще верно определить очередность применения
групп вычислений.
Все элементы внутри одной группы обладают одной и той же родительской
очередностью применения. Невозможно для разных элементов вычисления
в рамках одной группы задать разные значения очередности.
Целочисленное свойство Precedence задается именно для группы вычисле-
ний. Чем выше его значение, тем выше приоритет группы. Таким образом, эле-
менты группы с наивысшим приоритетом будут применяться к мерам в пер-
338 ГЛАВА 9 Группы вычислений
вую очередь. Иными словами, DAX применяет группы вычисления в порядке
следования значений их свойств Precedence от большего к меньшему. Само аб-
солютное значение этого свойства не несет никакой информации. Смысл име-
ет только относительное значение Precedence в сравнении с другими группами.
Кроме того, в модели данных не могут присутствовать две группы вычислений
с одинаковыми значениями свойства Precedence.
Поскольку все группы вычислений должны иметь свои уникальные значе-
ния свойства Precedence, этому необходимо уделить внимание при проекти-
ровании модели данных. Правильный выбор очередности применения групп
вычислений на этапе создания модели имеет важнейшее значение, ведь изме-
нение этого свойства у той или иной группы может повлиять на уже готовые
отчеты. Если в вашей модели данных присутствует несколько групп вычисле-
ний, потратьте время, чтобы убедиться, что все вычисления дают ожидаемые
результаты при использовании любой комбинации элементов вычисления. Без
должной проверки очень высока вероятность того, что будет допущена ошибка
с очередностью применения групп к мерам.
Включение и исключение мер из элементов вычисления
Существуют сценарии, в которых тот или иной элемент вычисления подходит
не для всех мер. По умолчанию элемент вычисления распространяет свое влия-
ние на все без исключения меры. Но в руках разработчика есть инструмент, по-
зволяющий ограничить сферу влияния элементов на меры.
В DAX можно написать условия для определения того, какая именно мера
вычисляется в данный момент. Для этого служат функции ISSELECTEDMEA-
SURE и SELECTEDMEASURENAME. Давайте попробуем ограничить примене-
ние элемента вычисления Daily AVG таким образом, чтобы меры, в которых
вычисляются проценты, не трансформировались в среднедневные показате-
ли. Функция ISSELECTEDMEASURE возвращает True, если мера, вычисляемая
функцией SELECTEDMEASURE, входит в список переданных параметров:
-- Calculation Group: 'Averages'[Averages]
-- Calculation Item: Daily AVG
IF (
ISSELECTEDMEASURE (
[Sales Amount];
[Gross Amount];
[Discount Amount];
[Sales Quantity];
[Total Cost];
[Margin]
);
DIVIDE (
SELECTEDMEASURE ();
ГЛАВА 9 Группы вычислений 339
COUNTROWS ( 'Date' )
)
)
Как видите, код позволяет выбрать, для каких мер рассчитывать средне-
дневное значение. Для всех мер, не вошедших в указанный список, примене-
ние элемента вычисления Daily AVG будет возвращать пустое значение. Если
вам необходимо включить в список все меры, кроме определенных, код можно
переписать так:
-- Calculation Group: 'Averages'[Averages]
-- Calculation Iten: Daily AVG
IF (
NOT ISSELECTEDMEASURE ( [Margin %] );
DIVIDE (
SELECTEDMEASURE ();
COUNTROWS ( 'Date' )
)
В обоих случаях мера Margin % будет исключена из числа мер, к которым
будет применяться элемент вычисления Daily AVG, как видно на рис. 9.18.
Calendar Year Month Sales Quantity Sales Amount Margin Margin %
CY 2005 ▲
CY 2006 January 50 9,363.67 4,721.91
CY 2007 February 54 10,729.93 5,575.99
rv March 48 9,294.77 4,815.71
CY 2009 April 56 13,365.07 6,995.57
CY 2010 May 58 13,348.34 7,459.37
CY 1 June 55 12,857.30 7,105.71
July 61 13,278.74 7,434.74
Averages August 54 11,567.29 6,396.36
Daily AVG September 54 12,775.79 7,038.99
October 50 11,247.95 6,213.54
November 57 13,570.83 6,597.02
December 60 12,258.95 5,904.63
Total 55 11,968.44 6,354.71
Рис. 9.18 Элемент вычисления Daily AVG не применяется к мере Margin %
Еще одной функцией, позволяющей анализировать выбранную меру, явля-
ется функция SELECTEDMEASURENAME, возвращающая не булево значение,
а строку с названием меры. Эту функцию можно применять вместо ISSELECT-
EDMEASURE, как показано ниже:
-- Calculation Group: 'Averages'[Averages]
340 ГЛАВА 9 Группы вычислений
- - Calculation Item: Daily AVG
IF (
NOT ( SELECTEDMEASURENAME () = "Margin %" );
DIVIDE (
SELECTEDMEASURE ();
COUNTROWS ( 'Date' )
)
)
Результат вычисления будет одинаковым, но предпочтительнее использо-
вать функцию ISSELECTEDMEASURE, и сразу по нескольким причинам:
если допустить опечатку в названии меры при использовании функции
SELECTEDMEASURENAME, DAX просто вернет значение False, не сообщив
при этом об ошибке;
при наличии опечатки с применением функции ISSELECTEDMEASURE
выражение выдаст ошибку, говорящую о неправильном входном пара-
метре функции;
если мера будет переименована в модели данных, все выражения с при-
менением функции ISSELECTEDMEASURE будут автоматически исправле-
ны в редакторе модели с помощью адресной привязки, тогда как строки,
использующиеся в функции SELECTEDMEASURENAME, придется исправ-
лять вручную.
Функцию SELECTEDMEASURENAME следует использовать в случаях, когда
бизнес-логика подразумевает трансформирование меры посредством элемен-
та вычисления в зависимости от внешней конфигурации. Например, эта функ-
ция пригодится, если у вас есть таблица со списком мер, призванная регули-
ровать поведение элемента вычисления при помощи внешней конфигурации,
которая может быть изменена без необходимости корректировать код на DAX.
Косвенная рекурсия
Элементы вычисления в DAX не предполагают возникновения полноценной
рекурсии. Но можно воспользоваться ограниченным вариантом этой концеп-
ции, получившим название косвенная рекурсия (sideways recursion). Мы пояс-
ним эту непростую тему на примерах. Но начнем с объяснения того, что из
себя представляет рекурсия и почему так важно обсудить эту тему. Рекурсия
может возникать, когда элемент вычисления ссылается сам на себя, что приво-
дит к бесконечному циклу. Давайте рассмотрим пример.
Представьте, что в группе вычислений Time Intelligence есть два следующих
элемента:
Calculation Group: 'Time Intelligence'[Time calc]
ГЛАВА 9 Группы вычислений 341
Calculation Item: YTD
CALCULATE (
SELECTEDMEASURE ();
DATESYTD ( 'Date'[Date] )
)
Calculation Item: SPLY
CALCULATE (
SELECTEDMEASURE ();
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
Нам необходимо добавить третий элемент вычисления, который будет рас-
считывать нарастающий итог с начала года по предыдущему году. В главе 8 вы
узнали, что такой тип вычислений можно легко осуществить путем комбини-
рования двух функций логики операций со временем: DATESYTD и SAMEPERI-
ODLASTYEAR. Следующее выражение поможет решить наш сценарий:
Calculation Item: PYTD
CALCULATE (
SELECTEDMEASURE ();
DATESYTD ( SAMEPERIODLASTYEAR ( 'Date'[Date] ) )
)
По причине простоты вычисления эту формулу вполне можно назвать оп-
тимальной. Но для разминки мозгов мы попробуем написать этот код иначе.
У нас ведь уже есть элемент вычисления для расчета нарастающего итога с на-
чала года (YTD). Может, попробовать применить этот элемент повторно, чтобы
не использовать вложенные функции логики операций со временем? Взгляни-
те на такой вариант определения для элемента вычисления PYTD:
Calculation Item: PYTD
CALCULATE (
SELECTEDMEASURE ();
SAMEPERIODLASTYEAR ( 'Date'[Date] );
'Time Intelligence'[Time calc] = "YTD"
)
Этот элемент вычисления служит тем же целям, что и предыдущий, но ис-
пользует при этом совершенно иную технику. Функция SAMEPERIODLAST-
YEAR смещает текущий контекст фильтра ровно на год назад, а нарастающий
итог с начала года мы получим, применив уже имеющийся элемент вычисле-
342 ГЛАВА 9 Группы вычислений
ния YTD из группы Time calc. Как мы и предполагали, в этом случае код станет
более трудным для понимания. Но в более сложных сценариях возможность
использовать ранее созданные элементы вычисления может помочь избежать
многократного повторения одного и того же кода в определении меры.
Это действительно мощный инструмент, применимый в комплексных ре-
шениях. И основан он на принципе рекурсии, который стоит пояснить отдель-
но. Как вы видели на примере PYTD, синтаксически вполне допустимо опре-
делять новый элемент вычисления на основании существующего из той же
группы вычислений. Если бы концепция рекурсии была применима в DAX без
ограничений, это могло бы приводить к очень сложным зависимостям. К при-
меру, элемент вычисления А мог бы зависеть от элемента В, который зависел
бы от С, а тот, в свою очередь, зависел от А. Следующий вымышленный пример
демонстрирует эту ситуацию:
-- Calculation Group: Infinite[Loop]
-- Calculation Item: Loop A
CALCULATE (
SELECTEDMEASURE ();
Infinite[Loop] = "Loop B"
)
-- Calculation Item: Loop В
CALCULATE (
SELECTEDMEASURE ();
Infinite[Loop] = "Loop A"
)
Если попытаться использовать эту группу вычислений в выражении, как
показано в следующем примере, DAX не удастся применить элементы вычис-
ления, поскольку элемент А требует применения элемента В, который, в свою
очередь, ожидает применения элемента А:
CALCULATE (
[Sales Amount];
Infinite[Loop] = "Loop A"
)
В некоторых языках программирования допускается использование таких
циклических зависимостей при определении выражений - обычно в функци-
ях, что ведет к образованию так называемых рекурсивных определений (recursive
definition). Определение рекурсивной функции предполагает использование
самой себя. Рекурсия является очень мощной концепцией, но разработчикам
бывает не так просто писать подобные функции, а оптимизаторам - искать
наиболее эффективный план выполнения запроса.
ГЛАВА 9 Группы вычислений 343
Именно поэтому в DAX не допускается написание рекурсивных элементов
вычисления. Вместо этого разработчик имеет право в одном элементе вычис-
ления ссылаться на другой элемент из той же группы, но повторный вызов
элемента недопустим. Иными словами, можно использовать функцию CALCU-
LATE для применения элемента вычисления к мере, но примененный элемент
не может прямо или косвенно вызывать исходный элемент вычисления. Имен-
но в этом и заключается принцип косвенной рекурсии. Полноценная рекурсия
в DAX недопустима, но повторно использовать элементы вычисления без об-
ратных вызовов можно.
Примечание Если вы знакомы с языком MDX,to должны знать, что в нем допустимо ис-
пользование как косвенной,так и полноценной рекурсии. Отчасти поэтому MDX считается
более сложным языком по сравнению с DAX. Кроме того, полноценная рекурсия зачастую
влечет за собой проблемы в плане производительности. И это еще одна причина, по ко-
торой в DAX она не используется.
S______________________________________________________________________________________)
Стоит также помнить, что рекурсия может возникать и вследствие установки
фильтра на элемент вычисления в самой мере - без взаимных вызовов между
элементами. Рассмотрим следующий пример определения мер (Sales Amount,
МА, МВ) и элементов вычисления (А и В):
- - Определение мер
Sales Amount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
MA := CALCULATE ( [Sales Amount]; Infinite[Loop] = "A" )
MB := CALCULATE ( [Sales Amount]; Infinite[Loop] = "B" )
-- Calculation Group: Infinite[Loop]
Calculation Item: A
[MB]
Calculation Item: В
[MA]
Элементы вычисления не ссылаются непосредственно друг на друга. Но при
этом они ссылаются на меры, которые, в свою очередь, ссылаются на элементы,
провоцируя запуск бесконечного цикла. Можно заметить это, последовательно
пройдя по шагам. Рассмотрим следующее выражение:
CALCULATE (
[Sales Amount];
Infinite[Loop] = "A"
)
344 ГЛАВА 9 Группы вычислений
Применение элемента вычисления А порождает такой результат:
CALCULATE (
CALCULATE ( [MB] )
)
При этом мера МВ внутренне ссылается на меру SalesAmount и элемент вы-
числения В, что приводит к следующему преобразованию кода:
CALCULATE (
CALCULATE (
CALCULATE (
[Sales Amount];
Infinite[Loop] = "B"
)
)
)
На этом этапе применение элемента вычисления В приводит к такому ре-
зультату:
CALCULATE (
CALCULATE (
CALCULATE (
CALCULATE ( [MA] )
)
)
)
Мера MA внутренне ссылается на меру Sales Amount и элемент вычисления
А, что ведет к следующему преобразованию кода:
CALCULATE (
CALCULATE (
CALCULATE (
CALCULATE (
CALCULATE (
[Sales Amount];
Infinite[Loop] = "A"
)
)
)
)
)
В результате мы вернулись к исходному выражению и фактически вошли
в бесконечный цикл элементов вычисления, применяемых к выражению, хотя
сами элементы при этом друг на друга не ссылаются. Вместо этого они вы-
зывают меру, которая ссылается на элементы вычисления. К счастью, движок
DAX достаточно интеллектуален, чтобы определить возникновение такой си-
туации, и выдаст ошибку.
Косвенная рекурсия может приводить к написанию очень запутанных вы-
ражений, непростых для понимания и потенциально ведущих к неправиль-
ГЛАВА9 Группы вычислений 345
ным расчетам. Сложность использования элементов вычисления совместно
с косвенной рекурсией проявляется в случаях, когда меры внутренне при-
меняют элементы вычисления при помощи функции CALCULATE, а пользо-
ватель меняет выбор элементов в интерфейсе - например, в срезах отчета
в Power BL
Мы советуем ограничить использование косвенной рекурсии в коде DAX до
предела, даже если это приведет к появлению повторений в формулах. Косвен-
ную рекурсию можно безопасно использовать только в скрытых группах вы-
числений, чтобы пользователь никак не мог повлиять на результат. Помните,
что в Power BI пользователи могут определять собственные меры в отчетах,
и без должного понимания сложных концепций вроде рекурсии они рискуют
наделать ошибок, даже не осознавая этого.
Два основных правила
Как мы уже отмечали в начале главы, есть всего два основных правила, кото-
рых необходимо придерживаться, чтобы не возникало проблем при использо-
вании элементов вычисления:
используйте элементы вычисления для модификации выражений, пред-
ставляющих собой ссылку на меру. И никогда не используйте их с более
сложными конструкциями;
-- Как надо
SalesPerWd :=
CALCULATE (
[Sales Amount]; -- Ссылка на меру. Это правильно
'Tine Intelligence'[Tine calc] = "YTD"
)
-- Как не надо. Никогда так не делайте!
SalesPerWd :=
CALCULATE (
SUMX ( Custoner; [Sales Anount] ); -- Сложное выражение, а не ссылка на
меру
'Tine Intelligence'[Tine calc] = "YTD"
)
избегайте создания косвенной рекурсии в доступных пользователям
группах вычислений. Безопасно использовать рекурсию можно исклю-
чительно в скрытых группах. Если вы все же решили применить эту кон-
цепцию в своей модели данных, внимательно проследите за тем, чтобы
косвенная рекурсия не превратилась в полноценную, иначе возникнет
ошибка.
346 ГЛАВА 9 Группы вычислений
Заключение
Группы вычислений представляют собой очень мощный инструмент, позволя-
ющий упростить модель данных. Возможность создавать множество вариаций
одних и тех же мер при помощи групп вычислений позволяет существенно
сократить количество дублирующегося кода в обычных мерах, количество ко-
торых иначе могло бы составить несколько сотен. Кроме того, пользователям
нравятся группы вычислений за возможность создания собственных сочета-
ний вычислений.
Будучи разработчиком DAX, вы должны хорошо понимать преимущества
и недостатки групп вычислений. Вот основные принципы, которые мы стара-
лись донести до вас в данной главе:
группа вычислений представляет собой набор из элементов вычисления;
элемент вычисления является разновидностью меры. Использование
функции SELECTEDMEASURE позволяет корректировать ход вычисления;
элемент вычисления способен переопределять исходное выражение
и строку форматирования текущей меры;
если в модели присутствует несколько групп вычислений, разработчик
обязан определить очередность применения их элементов, чтобы исклю-
чить неоднозначность их поведения;
элементы вычисления применяются к ссылкам на меры, а не к выраже-
ниям. Использование элементов вычисления с выражениями, не состо-
ящими из одной меры, может привести к неожиданным результатам.
Таким образом, лучше всего применять элементы исключительно к вы-
ражениям, представляющим единственную ссылку на меру;
разработчик вправе использовать косвенную рекурсию при определении
элементов вычисления, но это может серьезно усложнить выражение
в целом. Следует ограничить использование косвенной рекурсии скры-
тыми группами вычислений и никогда не применять эту концепцию
в группах, видимых пользователю;
следование советам из этой главы облегчит жизнь разработчикам и по-
зволит избежать чрезмерного усложнения сценариев, что характерно для
использования групп вычислений.
Помните, что группы вычислений являются одной из новинок в языке DAX.
Это очень мощная концепция, которую мы, по сути, только начинаем изучать.
Мы будем постоянно обновлять контент страницы в интернете, указанной
в данной главе, на которой вы сможете ознакомиться с новыми статьями и пос-
тами в блоге по этой интересной и важной теме.
ГЛАВА 10
Работа с контекстом фильтра
В предыдущих главах вы научились создавать контексты фильтра для прове-
дения сложных вычислений. Например, в главе, посвященной работе с датой
и временем, вы узнали, как можно сочетать функции логики операций со вре-
менем для сравнения разных временных интервалов. В предыдущей главе вы
научились использовать группы вычислений для упрощения кода на DAX и об-
легчения работы пользователю. В настоящей главе мы познакомим вас со мно-
жеством функций для чтения текущего состояния контекста фильтра, при по-
мощи которых вы сможете корректировать поведение формул в зависимости
от настройки фильтров и текущего выбора пользователя. Это очень мощные
функции, хотя используются они не так часто. И все же хорошее понимание их
работы просто необходимо для создания полноценных мер, которые будут ра-
ботать в любых отчетах, а не только там, где вы использовали их в первый раз.
Формула может работать или нет в зависимости от того, какой контекст
фильтра установлен в данный момент. Например, вы можете написать форму-
лу, которая будет прекрасно функционировать на уровне месяца, но при этом
выдавать неправильные результаты по годам. Еще один пример - ранжирова-
ние покупателей. Формула будет работать корректно, если в текущем контек-
сте фильтра будет выбран один покупатель, а для множественного выбора по-
кажет неверные цифры. Следовательно, чтобы работать в любых отчетах, мера
должна проверять текущее состояние контекста фильтра и только после этого
вычислять значение и возвращать результат. Если контекст фильтра удовле-
творяет требованиям формулы, она должна возвращать осмысленное значе-
ние. В противном случае, когда текущий контекст фильтра содержит фильтры,
несовместимые с кодом, лучше всего будет вернуть пустое значение.
Внимательно относитесь к тому, чтобы ни одна формула никогда не воз-
вращала неправильный результат. Всегда лучше вернуть пустое значение, чем
некорректные цифры. Пользователь будет крутить вашу модель данных, не об-
ладая знаниями о том, как внутренне устроен ваш код. И как разработчик DAX
вы ответственны за то, чтобы меры работали в любых условиях.
Для каждой функции, с которой вы познакомитесь в данной главе, мы пока-
жем сценарии, где она наиболее применима. Но ваши собственные сценарии,
конечно, будут отличаться от наших демонстраций. Читая об этих функциях,
думайте о том, как они могут улучшить вашу модель данных.
Также в этой главе мы коснемся двух важных концепций: привязки данных
(data lineage) и применения функции TREATAS. До этого вы уже использовали
концепцию привязки данных, даже не догадываясь об этом и не зная всех ее
особенностей. В данной главе мы коснемся этой темы более подробно и пред-
ставим несколько сценариев, в которых можно использовать эти концепции.
348 ПЛАВАЮ Работа с контекстом фильтра
Использование функций HASONEVALUE
и SELECTEDVALUE
Как мы уже сказали во вводной части главы, осмысленность результатов не-
которых вычислений напрямую зависит от текущего выбора пользователя.
В качестве примера рассмотрим следующую формулу, вычисляющую сумму
продаж нарастающим итогом с начала квартала:
QTD Sales :=
CALCULATE (
[Sales Anount];
DATESQTD ( 'Date'[Date] )
)
Как видно по рис. 10.1, мера прекрасно работает для месяцев и кварталов, но
за 2007 год выводит значение 2 731 424,16.
Calendar Year Sales Amount QTD Sales
CY 2007 11,309,946.12 2,731,424.16
Q1-2007 2,646,673.39 2,646,673.39
January 794,248.24 794,248.24
February 891,135.91 1,685,384.15
March 961,289.24 2,646,673.39
Q2-2007 3,046,602.02 3,046,602.02
April 1,128,104.82 1,128,104.82
May 936,192.74 2,064,297.56
June 982,304.46 3,046,602.02
Q3-2007 2,885,246.55 2,885,246.55
July 922,542.98 922,542.98
August 952,834.59 1,875,377.57
September 1,009,868.98 2,885,246.55
Q4-2007 2,731,424.16 2,731,424.16
October 914,273.54 914,273.54
November 825,601.87 1,739,875.41
December 991,548.75 2,731,424.16
Рис. 10.1 Мера QTD Sales вычисляется на уровне лет,
но ее результат многих может удивить
На самом деле на уровне лет мера QTD Sales показывает актуальное значе-
ние для последнего квартала этого года, что на уровне месяцев соответствует
показателю за декабрь. Кто-то скажет, что для года значение этой меры вообще
не несет никакого смысла. Если быть точнее, мера не должна рассчитываться
и для уровня квартала. То есть наша мера имеет осмысленное значение толь-
ко на уровне месяца и ниже. Иными словами, в нашем случае мера должна
выводить значения по месяцам, а на уровне кварталов и лет ячейки должны
оставаться пустыми.
ГЛАВА 10 Работа с контекстом фильтра 349
В этом случае вам на помощь придет функция HASONEVALUE. Например,
для того чтобы очистить ячейки на уровне кварталов и лет в нашем отчете, до-
статочно определить, что в выборке находится несколько месяцев. Это будет
соответствовать кварталам и годам, тогда как на уровне месяца в текущей вы-
борке будет находиться только один месяц. Таким образом, добавив условие
в нашу формулу, мы добьемся желаемого результата, как показано ниже:
QTD Sales :=
IF (
HASONEVALUE ( 'Date'[Month] );
CALCULATE (
[Sales Amount];
DATESQTD ( 'Date'[Date] )
)
)
Результат вычисления этой меры показан на рис. 10.2.
Calendar Year Sales Amount QTD Sales
CY 2007 11,309,946.12
Q1-2007 2,646,673.39
January 794,248.24 794,248.24
February 891,135.91 1,685,384.15
March 961,289.24 2,646,673.39
Q2-2007 3,046,602.02
April 1,128,104.82 1,128,104.82
May 936,192.74 2,064,297.56
June 982,304.46 3,046,602.02
Q3-2007 2,885,246.55
July 922,542.98 922 542 98 Рис- Защита меры QTD Sales
August 952,834.59 ->-7-7 r-7 при помощи функции HASONEVALUE
позволила оставить пустыми ячейки
September 1,009,868.98 2,885,246.55 с нежелательными значениями
Этот простой пример на самом деле очень важен. Вместо того чтобы оста-
вить формулу как есть, мы пошли на шаг дальше и добавили проверку, позво-
лившую выводить значения только там, где они имеют смысл. Если есть риск,
что формула может выдавать неверное значение в определенных состояниях
контекста фильтра, необходимо производить определенную проверку на ми-
нимальные требования и действовать соответствующе.
В главе 7 вы уже видели подобный сценарий при работе с функцией RANKX.
Там мы производили ранжирование покупателей и использовали функцию
HASONEVALUE для гарантии того, что в текущем контексте фильтра выбран
единственный покупатель.
Также функция HASONEVALUE часто используется в сценариях с логикой
операций со временем из-за большого количества агрегаций, которые спо-
собны выдавать осмысленные результаты только на определенных уровнях
350 ПЛАВАЮ Работа с контекстом фильтра
контекста фильтра. Во всех остальных случаях ячейки в отчетах желательно
оставлять пустыми.
Еще одним распространенным случаем использования функции HASONE-
VALUE является извлечение одного выбранного значения из текущего контек-
ста фильтра. Есть множество сценариев, в которых это может пригодиться, но
с появлением групп вычислений их количество существенно уменьшится. Мы
опишем один сценарий с применением разновидности анализа «что, если».
В подобных ситуациях разработчик обычно создает таблицу параметров, по-
зволяя пользователю выбрать в срезе одно значение, после чего этот параметр
используется в коде для изменения хода вычисления.
Представим, что вы оцениваете сумму продаж путем корректировки значе-
ний предыдущих лет в соответствии с уровнем инфляции. Для осуществления
анализа пользователь должен выбрать годовой уровень инфляции, который
будет использоваться для всех дат транзакций вплоть до сегодняшнего дня.
Уровень инфляции представлен здесь как параметр алгоритма. Для начала
нам необходимо построить таблицу с инфляцией, из которой пользователь бу-
дет делать выбор. В нашем примере достаточно будет значений от 0 % до 20 %
с шагом 0,5 %. Фрагмент этой таблицы вы можете видеть на рис. 10.3.
Inflation
0.00%
0.50%
1.00%
1.50%
2.00%
2.50%
3.00%
3.50%
4.00%
4.50%
Рис. 10.3 В столбце Inflation
содержатся значения от 0 % до 20 %
с шагом 0,5 %
Пользователь выбирает желаемое значение при помощи среза, после чего
формула пересчитывается для всех лет вплоть до сегодняшнего дня. Если поль-
зователь не сделал выбор или выбрал больше одного значения, формула долж-
на использовать значение инфляции по умолчанию, равное 0 %.
Итоговый отчет показан на рис. 10.4.
Примечание Параметр «что, если» (What-If) в Power Bl создает та блицу и срез с исполь-
зованием той же техники, что описана в данном разделе.
Несколько важных замечаний по поводу этого отчета:
пользователь может выбрать желаемый уровень инфляции в срезе, рас-
положенном вверху слева;
ГЛАВА 10 Работа с контекстом фильтра 351
вверху справа в отчете показан год, используемый для корректировки. За
основу берется дата последней продажи в модели данных;
в мере Inflation Adjusted Sales сумма продаж анализируемого года умно-
жается на коэффициент, зависящий от выбранного пользователем уров-
ня инфляции;
в строке итогов в формуле должны использоваться разные коэффициен-
ты для разных лет.
Inflation
з.оо% v Reporting year: 2009
Calendar Year Sales Amount Inflation Adjusted Sales
CY 2007 11,309,946.12 11,998,721.84
Q1-2007 2,646,673.39 2,807,855.80
January 794,248.24 842,617.96
February 891,135.91 945,406.09
March 961,289.24 1,019,831.75
Q2-2007 3,046,602.02 3,232,140.08
April 1,128,104.82 1,196,806.40
May 936,192.74 993,206.88
June 982,304.46 1,042,126.80
Q3-2007 2,885,246.55 3,060,958.07
July 922,542.98 978,725.85
August 952,834.59 1,010,862.21
September 1,009,868.98 1,071,370.00
Q4-2007 2,731,424.16 2,897,767.89
October 914,273.54 969,952.80
November 825,601.87 875,881.02
December 991,548.75 1,051,934.07
CY 2008 9,927,582.99 10 225 410 48 Рис. 10.4 Параметр Inflation
управляет множителем
Total 30,591,343.98 31,577,947.19 по показателям предыдущих лет
Простейшей формулой является определение отчетного года. Здесь мы из-
влекаем максимальную дату заказа из таблицы Sales:
Reporting year := "Reporting year: " & YEAR ( MAX ( Sales[Order Date] ) )
Выбранный пользователем уровень инфляции можно получить при помо-
щи функций MIN или МАХ, поскольку при выборе единственного показателя
они будут возвращать одно и то же значение, являющееся выбранным уровнем
инфляции. При этом пользователь может не выбрать уровень инфляции вовсе
или выбрать сразу несколько значений. В этом случае формула должна отраба-
тывать корректно, используя значение по умолчанию.
Лучшим способом проверить, что пользователь выбрал единственное зна-
чение в списке уровней инфляции, является использование функции HASO-
352 ПЛАВАЮ Работа с контекстом фильтра
NEVALUE. Соответствующий фрагмент кода может выглядеть следующим
образом:
User Selected Inflation :=
IF (
HASONEVALUE ( 'Inflation Rate'[Inflation] );
VALUES ( 'Inflation Rate'[Inflation] );
0
)
Из-за частого использования такого шаблона в языке DAX была введена
специальная функция SELECTEDVALUE, позволяющая значительно упростить
предыдущий код:
User Selected Inflation := SELECTEDVALUE ( 'Inflation Rate'[Inflation]; 0 )
Функция SELECTEDVALUE принимает два параметра. Вторым из них явля-
ется значение по умолчанию, которое будет возвращено, если в столбце, пере-
данном в качестве первого параметра, выбрано более одного элемента.
Добавив меру User Selected Inflation в модель данных, необходимо опреде-
литься со множителем инфляции для выбранного года. Если в качестве года для
корректировки инфляции использовать последний год в модели, то при расче-
те множителя нам необходимо будет пройти по всем годам между последним
годом и выбранным и перемножить выражения 1+Inflation для каждого из них:
Inflation Multiplier :=
VAR ReportingYear =
YEAR ( CALCULATE ( MAX ( Sales[Order Date] ); ALL ( Sales ) ) )
VAR CurrentYear =
SELECTEDVALUE ( 'Date'[Calendar Year Nunber] )
VAR Inflation = [User Selected Inflation]
VAR Years =
FILTER (
ALL ( 'Date'[Calendar Year Nunber] );
AND (
'Date'[Calendar Year Nunber] >= CurrentYear;
'Date'[Calendar Year Nunber] < ReportingYear
)
)
VAR Multiplier =
MAX ( PRODUCTX ( Years; 1 + Inflation ); 1 )
RETURN
Multiplier
Остается только суммировать данные по продажам с учетом полученного
множителя по годам. Код меры Inflation Adjusted Sales представлен ниже:
Inflation Adjusted Sales :=
SUMX (
VALUES ( 'Date'[Calendar Year] );
[Sales Anount] * [Inflation Multiplier]
)
ГЛАВА 10 Работа с контекстом фильтра 353
Использование функций ISFILTERED
и ISCROSSFILTERED
Иногда задача заключается не в выборе одного значения из контекста фильтра,
а в определении того, что столбец или таблица включены в активный фильтр
в текущем контексте. Поводом для контроля включения в фильтр обычно яв-
ляется проверка на то, что все значения из данного столбца видимы в отчете.
Дело в том, что в присутствии фильтра некоторые значения могут оказаться
скрыты, а значит, и результат может быть неточным.
Столбец может быть отфильтрован как по причине непосредственного при-
менения к нему фильтра, так и в результате фильтрации другого столбца, спо-
собствующей наложению косвенного фильтра на интересующий нас столбец.
Рассмотрим эту ситуацию на следующем примере:
RedColors :=
CALCULATE (
[Sales Amount];
'Product'[Color] = "Red"
)
Во время вычисления меры Sales Amount внешняя функция CALCULATE при-
меняет фильтр к столбцу Product[Color]. Таким образом, этот столбец оказыва-
ется отфильтрованным. В языке DAX есть специальная функция ISFILTERED,
позволяющая определить, включен ли проверяемый столбец в фильтр. Функ-
ция ISFILTERED возвращает TRUE или FALSE в зависимости от того, наложен ли
прямой фильтр на столбец, переданный в качестве параметра. Если функции
ISFILTERED передать целую таблицу, она вернет TRUE только в том случае, если
на любой из столбцов этой таблицы наложен прямой фильтр. В противном слу-
чае результатом будет FALSE.
Несмотря на то что в рассматриваемом нами случае непосредственный фильтр
применяется только к столбцу Product[Color], все остальные столбцы таблицы
Product также окажутся косвенно отфильтрованными. Например, в столбце Brand
будут показываться только те бренды, в которых есть хотя бы один красный то-
вар. Если под определенным брендом красные товары не производятся, он оста-
нется невидимым по причине фильтра, наложенного на столбец Product[Color].
Такие косвенные фильтры распространяются на все столбцы в таблице Product,
кроме напрямую отфильтрованного столбца Product [Color]. А значит, количество
видимых элементов в этих столбцах будет ограничено. Иными словами, к ним
применена кросс-фильтрация (cross-filtering) или перекрестная фильтрация.
Говорят, что столбец включен в перекрестную фильтрацию, если существует
фильтр, способный ограничить количество его видимых элементов как напря-
мую, так и косвенно. Функция ISCROSSFILTERED как раз и предназначена для
определения того, попадает ли тот или иной столбец в кросс-фильтрацию.
Важно отметить, что если столбец включен в фильтр, он автоматически счи-
тается включенным и в кросс-фильтрацию. Обратное не является истиной:
столбец может включаться в перекрестную фильтрацию, но при этом не быть
отфильтрованным напрямую. Функция ISCROSSFILTERED может работать как
354 ПЛАВАЮ Работа с контекстом фильтра
со столбцами, так и с таблицами. На самом же деле если один столбец в таблице
участвует в перекрестной фильтрации, то в ней участвуют и все остальные. Так
что эту функцию стоит применять именно с таблицами, а не со столбцами. Вы
по-прежнему можете встретить код, в котором в качестве параметра функции
ISCROSSFILTERED передается столбец. Просто изначально эта функция рабо-
тала исключительно со столбцами, а со временем была расширена до таблиц.
Поэтому иногда можно встретить ее использование по-старому.
Поскольку фильтры распространяются на всю модель данных, установка
фильтра на таблицу Product автоматически распространится на все связанные
таблицы. К примеру, если отфильтровать столбец Product[Color], ограничение
будет также наложено и на таблицу Sales. Таким образом, все столбцы таблицы
Sales будут участвовать в кросс-фильтрации, образовавшейся посредством на-
ложения фильтра на столбец Product[Color].
Чтобы продемонстрировать поведение этих функций, мы используем мо-
дель данных, несколько отличающуюся от той, которая рассматривается на
протяжении всей книги. Мы удалили некоторые таблицы, а связь между таб-
лицами Sales и Product сделали двунаправленной. С получившейся моделью
данных можно ознакомиться по рис. 10.5.
Рис. 10.5 В этой модели данных связь между таблицами Sales и Product двунаправленная
Напишем для этой модели следующий набор мер:
Filter Gender := ISFILTERED ( Customer[Gender] )
Cross Filter Customer := ISCROSSFILTERED ( Customer )
Cross Filter Sales := ISCROSSFILTERED ( Sales )
Cross Filter Product := ISCROSSFILTERED ( 'Product' )
Cross Filter Store := ISCROSSFILTERED ( Store )
ГЛАВА 10 Работа с контекстом фильтра 555
Далее вынесем эти меры в столбцы матрицы, а в строки отправим Custo-
mer[Continent] и CustomerfGender]. Результат можно видеть на рис. 10.6.
Continent Filter Gender Cross Filter Customer Cross Filter Sales Cross Filter Store Cross Filter Product
Asia False True True False True
True True True False True
F True True True False True
М True True True False True
Europe False True True False True
True True True False True
F True True True False True
M True True True False True
North America False True True False True
True True True False True
F True True True False True
M True True True False True
Total False False False False False
Рис. 10.6 Матрица, демонстрирующая поведение функций ISFILTERED и ISCROSSFILTERED
Некоторые комментарии по результатам отчета:
столбец CustomerfGender] напрямую отфильтрован только в тех строках,
где есть активный фильтр по этому столбцу. На уровне континентов, где
фильтр установлен лишь на Customer[Continent], столбец Customer[Gender]
не отфильтрован;
вся таблица Customer подвержена кросс-фильтрации, когда установлен
фильтр по столбцам Customer[Continent] или Customer [Gender];
то же самое касается и таблицы Sales. Присутствие фильтра по любому
столбцу таблицы Customer автоматически накладывает кросс-фильт-
рацию на таблицу Sales, поскольку она находится на стороне «многие»
в связи с таблицей Customer;
таблица Store не подпадает под перекрестную фильтрацию, поскольку
фильтр на таблице Sales не распространяется на Store, а связь между эти-
ми таблицами однонаправленная;
так как связь между таблицами Sales и Product двунаправленная, фильтр,
установленный на таблицу Sales, автоматически распространяется на
Product. Следовательно, таблица Product будет подвержена кросс-фильт-
рации при наложении фильтра на любую другую таблицу этой модели
данных.
Функции ISFILTERED и ISCROSSFILTERED не так часто используются в выра-
жениях DAX. Они применяются при выполнении продвинутой оптимизации
для проверки набора фильтров по столбцам, чтобы позволить коду выполнять-
ся по-разному в зависимости от установленных фильтров. Еще одна область
применения этих функций - работа с иерархиями, с которыми мы познако-
мимся в главе 11.
Учтите, что нельзя полагаться на наличие фильтра при определении того,
все ли значения столбца будут видимы. На самом деле столбец может быть от-
356 ПЛАВАЮ Работа с контекстом фильтра
фильтрован и участвовать в кросс-фильтрации, но при этом все его значения
будут видимы. Продемонстрируем это на примере простой меры:
Test :=
CALCULATE (
ISFILTERED ( Customer[City] );
Customer[City] <> "DAX"
)
В таблице Customer нет города с названием «DAX». Следовательно, фильтр
не окажет на исходную таблицу никакого влияния - все ее строки останутся
видимыми. Получается, что в столбце Customer[City] показываются все сущест-
вующие значения, несмотря на активный фильтр по этому столбцу и то, что
мера Test возвращает значение TRUE.
Для определения того, все ли возможные значения видимы в столбце или
таблице, лучше воспользоваться подсчетом количества строк под действием
разных контекстов. В этом способе есть определенные нюансы, о которых мы
поговорим в следующей главе.
Понимание разницы между функциями VALUES
и FILTERS
Функция FILTERS очень похожа на VALUES, но у нее есть одно важное отличие.
Если VALUES возвращает значения, видимые в текущем контексте фильтра, то
FILTERS - значения, отфильтрованные в текущем контексте фильтра.
И хотя эти определения также выглядят похожими, на самом деле они отли-
чаются. К примеру, вы можете отфильтровать при помощи среза четыре цве-
та товаров, скажем черный, коричневый, лазурный и синий. Но из-за других
наложенных фильтров видимыми в отчете могут оказаться только два цвета,
если остальные не присутствуют в выборке. В таком сценарии функция VALUES
вернет два цвета, тогда как FILTER - четыре. Этот пример хорошо подходит для
демонстрации различий между этими двумя функциями.
В данном случае мы будем использовать файл Excel, подключенный к мо-
дели данных Power BI. Причина этого в том, что на момент написания данной
книги функция FILTERS работает не так, как ожидается, в связке с SUMMARIZE-
COLUMNS, которая используется в Power BI для осуществления запросов к мо-
дели. Так что этот пример не будет работать в Power BL
Примечание Компания Microsoft осведомлена об этой проблеме использования функ-
ции FILTERS в Power Bl, и, вероятно, в будущих версиях продукта она будет решена. Но
чтобы продемонстрировать этот пример в книге, мы вынуждены были использовать Excel
в качестве клиентского приложения, поскольку он не использует функцию SUMMARIZE-
COLUMNS.
\________________________________________________________________________________)
В главе 7 мы показывали пример использования функции CONCATENATEX
для размещения метки в отчете с указанием выбранных при помощи среза
ПЛАВАЮ Работа с контекстом фильтра 357
цветов. Там мы в результате пришли к сложной формуле с использованием
итераторов и переменных. Здесь мы прибегнем к помощи более простой вер-
сии кода:
Selected Colors :=
"Showing " &
CONCATENATEX (
VALUES ( 'Product1[Color] );
'Product'[Color];
II II ,
'Product'[Color];
ASC
) & " colors."
Рассмотрим отчет с двумя срезами: в первом выбрана только одна катего-
рия, а во втором - несколько цветов, как показано на рис. 10.7.
Row Labels i Safes Amount
Black 1,150,180.50
Brc ’. 652,59 79
Grand Total 1,802,772.29
Seiected Colors
Show Б ack. Brown co ors.
Рис. 10.7 Хотя в срезе выбрано четыре цвета, мера Selected Colors
показывает только два из них
Несмотря на то что в срезе мы выбрали четыре различных цвета, мера Se-
lected Colors вернула только два из них. Причина в том, что функция VALUES
возвращает значения столбца в рамках текущего контекста фильтра. А в нашей
модели данных не оказалось товаров из категории «TV and Video» лазурного
(Azure) и синего (Blue) цветов. Таким образом, хотя в контекст фильтра и вклю-
чены четыре цвета, функция VALUES возвращает только два из них.
Если в мере заменить функцию VALUES на FILTERS, будут возвращены все
отфильтрованные значения вне зависимости от того, есть ли в текущем кон-
тексте фильтра товары, представляющие эти значения:
Selected Colors :=
"Showing " &
CONCATENATEX (
FILTERS ( 'Product'[Color] );
'Product'[Color];
II II ,
'Product'[Color];
358 ГЛАВА 10 Работа с контекстом фильтра
ASC
) & " colors."
С новой версией меры Selected Colors в отчете в качестве выбранных показы-
ваются все четыре цвета, что видно по рис. 10.8.
Category
Audio
Cameras and сэгг-сог...
Cell ohones
Computers
Cannes and Toys
Home Appliances
Music, Moves and A...
TV and Video
Row Labels т Sales Amount
PI ack 1Д50Д80.50
Brown______________________652,591.79
Grand Total 1.802,772.29
Selected Colors
Sncwmg Azure, В ack, Blue- Б nwn co ors.
Рис. 10.8 С использованием функции FILTERS мера Selected Colors
возвращает все четыре цвета
Наряду с HASONEVALUE DAX предлагает еще одну функцию для определе-
ния того, что на столбец наложен только один активный фильтр: HASONEFIL-
TER. Синтаксис и применение этой функции очень похожи на HASONEVALUE.
Единственным отличием между ними является то, что функция HASONEFIL-
TER может возвращать значение TRUE при наличии единственного активного
фильтра, в то время как HASONEVALUE вернет FALSE, поскольку значение, хоть
и отфильтровано, не является видимым.
Понимание разницы между ALLEXCEPT
и ALL/VALUES
В предыдущем разделе мы представили вам функции ISFILTERED и ISCROSS-
FILTERED, предназначенные для определения присутствия фильтра. Но одного
факта наличия фильтра недостаточно, чтобы понять, видимы ли все значения
из столбца или таблицы в отчете. Лучше всего для этого подсчитать коли-
чество строк в текущем контексте фильтра и сравнить с количеством строк без
фильтров.
Взгляните на рис. 10.9. Мера Filtered Gender проверяет наличие фильтра по
столбцу Customer[Gender] при помощи функции ISFILTERED, тогда как в поле
NumOfCustomers просто выводится количество строк в таблице Customer:
NumOfCustoners := COUNTROWS ( Customer )
Как видите, когда покупателем является компания, его пол, как и ожидается,
будет пустым значением. Во второй строке матрицы фильтр по столбцу Gender
ГЛАВА 10 Работа с контекстом фильтра 359
активен, а значит, в поле Filtered Gender возвращается значение TRUE. В то же
время фильтр, по сути, ничего не фильтрует, потому что в столбце Gender есть
только одно значение, и оно видимо.
Customer Type Filtered Gender NumOfCustomers
Company False True 385 385
Person False 18,484
F True 9,133
М True 9,351
Total False 18,869
Рис. 10.9 Несмотря на наличие фильтра по столбцу Customer[Gender],
все покупатели видимы
Наличие фильтра еще не означает, что таблица на самом деле будет отфильт-
рована. Оно лишь указывает на активное состояние фильтра. Чтобы проверить,
все ли покупатели видимы, лучше полагаться на простой подсчет строк. Если
количество строк в таблице с наложенным и снятым фильтром по столбцу Gen-
der будет одинаковым, значит, фильтр, несмотря на свою активность, по сути
своей ничего не фильтрует.
Проводя подобные вычисления, необходимо обращать внимание на детали
контекста фильтра и поведения функции CALCULATE. Есть два способа прове-
рить одно и то же условие:
посчитать покупателей с наложенной функцией ALL на поле Gender;
посчитать покупателей с тем же типом (Company или Person).
Несмотря на то что в отчете на рис. 10.9 два вычисления возвращают оди-
наковый результат, если изменить столбец, используемый в матрице, цифры
будут разными. К тому же у каждого вычисления есть свои за и против, и об
этом стоит помнить при выборе решения для определенного сценария. Нач-
нем с простейшего:
All Gender :=
CALCULATE (
[NunOfCustoners];
ALL ( Custoner[Gender] )
)
Функция ALL удаляет все фильтры по столбцу Gender, оставляя все прочие
фильтры неизменными. В результате происходит подсчет количества покупа-
телей в текущем контексте фильтра без учета половой принадлежности. Вы
можете видеть результат на рис. 10.10 вместе с мерой All customers visible, срав-
нивающей два расчета.
В мере All Gender выводятся правильные результаты. Ее недостатком явля-
ется жесткое указание в коде того, что мы снимаем фильтр со столбца Gender.
Например, если использовать эту меру в матрице со срезом по континенту,
360 ПЛАВАЮ Работа с контекстом фильтра
результат будет не таким, как мы ожидаем. Вы можете видеть это на рис. 10.11,
где мера A// customers visible всегда возвращает значение TRUE.
Customer Туре Filtered Gender NumOfCustomers All Gender All customers visible
Company False 385 385 True
True 385 385 True
Person False 18,484 18,484 True
F True 9,133 18,484 False
М True 9,351 18,484 False
Total False 18,869 18,869 True
Рис. 10.10 Во второй строке мера All customers visible возвращает True,
несмотря на то что столбец Gender отфильтрован
Customer Type Filtered Gender NumOfCustomers All Gender All customers visible
Company False 385 385 True
Asia False 67 67 True
Europe False 42 42 True
North America False 276 276 True
Person False 18,484 18,484 True
Asia False 3,591 3,591 True
Europe False 5,504 5,504 True
North America False 9,389 9,389 True
Total False 18,869 18,869 True
Рис. 10.11 Фильтруя таблицу по континентам, мы получаем неправильные результаты
При этом нельзя сказать, что мера неправильно считает значения. Она все
правильно считает, если срез в отчете выполнен по полю Gender. Чтобы обес-
печить мере независимость от этого поля, необходимо пойти другим путем,
а именно удалить все фильтры с таблицы Customer, за исключением столбца
Customer Туре.
Удаление всех фильтров, кроме одного, выглядит не самой сложной задачей.
Но здесь есть одна ловушка, о которой стоит помнить. Первой функцией, кото-
рая приходит на ум, является ALLEXCEPT. Но, к сожалению, в данном сценарии
использование этой функции может привести к неожиданным результатам.
Рассмотрим следующую формулу:
AllExcept Туре :=
CALCULATE (
[NumOfCustomers];
ALLEXCEPT ( Customer; Customer[Customer Type] )
)
Функция ALLEXCEPT удаляет все существующие фильтры с таблицы Cus-
tomer, за исключением фильтра по столбцу Customer Туре. При использовании
в предыдущем отчете мера покажет правильные результаты, как видно по
рис. 10.12.
ГЛАВА 10 Работа с контекстом фильтра 361
Customer Type NumOfCustomers AIIExceptType All customers visible
Company 385 385 True
Asia 67 385 False
Europe 42 385 False
North America 276 385 False
Person 18,484 18,484 True
Asia 3,591 18,484 False
Europe 5,504 18,484 False
North America 9,389 18,484 False
Total 18,869 18,869 True
Рис. 10.12 Функция ALLEXCEPT снимает зависимость от пола
и работает с любыми столбцами
Эта мера будет работать не только с полем Continent. Поменяв континент на
пол в отчете, мы все равно увидим правильные результаты, что показано на
рис. 10.13.
Customer Type NumOfCustomers AIIExceptType All customers visible
Company 385 385 True
385 385 True
Person 18,484 18,484 True
F 9,133 18,484 False
M 9,351 18,484 False
Total 18,869 18,869 True
Рис. 10.13 Функция ALLEXCEPT работает и с полом тоже
Несмотря на видимую корректность, в этой формуле есть скрытая ловуш-
ка. Функции ALL* при использовании в качестве аргумента фильтра функции
CALCULATE работают как модификаторы, о чем мы рассказывали в главе 5.
Модификаторы не возвращают таблицу, использованную в качестве фильтра.
Вместо этого они просто удаляют фильтры из контекста фильтра.
Обратите внимание на строку с пустым полом. В этой группе 385 поку-
пателей, и все это компании. Если удалить столбец Customer Туре из отчета,
единственным столбцом в контексте фильтра останется пол. Пустое значение
в этом столбце говорит о том, что в текущем контексте фильтра видимы только
компании. При этом отчет, показанный на рис. 10.14, выглядит неожиданно -
мера AllExcept Туре показывает одинаковые значения для всех строк, а именно
общее количество покупателей.
Gender NumOfCustomers AIIExceptType
385 18,869 Рис. 10.14 Функция ALLEXCEPT
F 9,133 18,869 дает неожиданные результаты,
M 9,351 18,869 если поле Customer Туре
Total 18,869 18,869 не представлено в отчете
362 ПЛАВАЮ Работа с контекстом фильтра
Функция ALLEXCEPT удалила все фильтры с таблицы Customer, за исключе-
нием фильтра по столбцу Customer Туре. Но у нас в отчете нет поля Customer
Туре, по которому можно было бы сохранить фильтр. Таким образом, един-
ственным фильтром в контексте фильтра остался фильтр по полю Gender, ко-
торый был успешно удален функцией ALLEXCEPT.
Столбец Customer Туре участвует в кросс-фильтрации, но не в прямой фильт-
рации. Так что в распоряжении функции ALLEXCEPT нет ни одного столбца,
фильтр по которому можно было бы сохранить, а значит, ее действие будет
эквивалентно действию функции ALL по всей таблице. В данном случае пра-
вильно будет воспользоваться парой функций ALL и VALUES вместо ALLEX-
CEPT. Взгляните на следующую формулу:
All Values Type :=
CALCULATE (
[NumOfCustomers];
ALL ( Customer );
VALUES ( Customer[Customer Type] )
)
Несмотря на внешнюю схожесть с предыдущим выражением, семантически
эта формула значительно отличается. Функция ALL удаляет все фильтры с таб-
лицы Customer. В то же время функция VALUES составляет список значений
столбца Customer[Customer Туре] в текущем контексте фильтра. Как мы уже ска-
зали, по типу покупателя прямого фильтра у нас нет, но это поле участвует
в кросс-фильтрации. Следовательно, функция VALUES возвратит только зна-
чения, видимые в текущем контексте фильтра вне зависимости от того, какой
столбец участвует в фильтре, создающем кросс-фильтрацию по типу покупате-
ля. Результат вы можете видеть на рис. 10.15.
Gender NumOfCustomers AllExcept Type All Values Type
385 18,869 385
F 9,133 18,869 18,484
М 9,351 18,869 18,484
Total 18.869 18.869 18.869
Рис. 10.15 Совместное использование функций ALL и VALUES
дало ожидаемый результат
Из этого урока вы должны вынести четкое понимание отличий между ис-
пользованием функции ALLEXCEPT и сочетания функций ALL и VALUES в ка-
честве аргумента фильтра в CALCULATE. Причина этих отличий в том, что се-
мантика функций ALL* предполагает именно удаление фильтров. Функции
этой группы никогда не добавляют фильтры к существующему контексту, они
только удаляют их.
Разница между нюансами добавления и удаления фильтров во многих сце-
нариях бывает не важна. Но есть случаи, когда эта разница имеет решающее
значение, как в примере выше.
ГЛАВА 10 Работа с контекстом фильтра 565
Здесь, как и на протяжении всей книги, мы пытались показать вам, насколь-
ко важно точно подходить к формулировке выражений на языке DAX. Исполь-
зование функции ALLEXCEPT без полного понимания всех ее нюансов может
привести к неожиданным результатам. DAX скрывает большую часть слож-
ностей, предлагая интуитивно понятное поведение функций в большинстве
случаев. Но эти сложности, пусть и тщательно скрытые, никуда не исчезают.
И вам необходимо досконально разбираться в тонкостях контекста фильтра
и функции CALCULATE, чтобы в полной мере овладеть искусством языка DAX.
Использование функции ALL для предотвращения
преобразования контекста
На данном этапе чтения книги вы уже хорошо знакомы с процедурой преоб-
разования контекста. Это очень мощная концепция языка, которую мы не раз
использовали для произведения полезных вычислений. Но есть случаи, ког-
да необходимо не допустить выполнения преобразования контекста или, по
крайней мере, ограничить его действие. И чтобы этого добиться, вам приго-
дятся те же самые функции из группы ALL*.
Важно помнить, что функция CALCULATE выполняется в строго определен-
ной последовательности. Сначала оцениваются аргументы фильтра, затем,
если функция выполняется в рамках контекста строки, происходит преобразо-
вание контекста, следом за чем применяются модификаторы, и только после
этого функция CALCULATE применяет результат аргументов фильтра к кон-
тексту фильтра. Вы можете воспользоваться этим строгим порядком действий
в своих интересах, помня о том, что модификаторы функции CALCULATE, к ко-
торым относятся и функции группы ALL*, применяются после преобразова-
ния контекста. А это означает, что фильтрующий модификатор способен пере-
определить действие преобразования контекста.
Рассмотрим следующий фрагмент кода:
SUMX (
Sales;
CALCULATE (
ALL ( Sales )
)
)
Функция CALCULATE вызывается в контексте строки, созданном итератором
SUMX, проходящим по таблице Sales. Следовательно, здесь неизбежно прои-
зойдет преобразование контекста. Но поскольку в функции CALCULATE при-
сутствует модификатор ALL ( Sales ), движок DAX понимает, что все фильтры
с таблицы Sales должны быть удалены.
Описывая функцию CALCULATE, мы говорили, что при ее выполнении сна-
чала будет произведено преобразование контекста, то есть наложены фильт-
ры на таблицу Sales, а затем функция ALL удалит эти фильтры. Однако опти-
364 ПЛАВАЮ Работа с контекстом фильтра
мизатор DAX не так глуп. Зная, что впоследствии будут удалены все фильтры
с таблицы Sales при помощи функции ALL, он просто отказывается от вы-
полнения преобразования контекста, при этом удаляя все существующие
контексты строки.
Такое поведение движка можно использовать в самых разных сценариях,
и в частности при работе с вычисляемыми столбцами. В вычисляемых столб-
цах всегда присутствует контекст строки. Это означает, что любая мера, упомя-
нутая в коде такого столбца, будет вычислена в контексте фильтра только для
текущей строки.
Представьте, что вам необходимо в вычисляемом столбце рассчитать про-
цент суммы продаж по конкретному товару относительно всех товаров в моде-
ли данных. Получить сумму продаж по текущему товару в вычисляемом столб-
це очень просто - достаточно вычислить меру Sales Amount. Преобразование
контекста гарантирует вычисление меры исключительно для одной текущей
строки. При этом в знаменателе нам необходимо указать продажи по всем то-
варам. Но как это сделать при действующем преобразовании контекста? Все
очень просто - можно предотвратить операцию преобразования контекста
путем указания модификатора ALL в функции CALCULATE, как показано в сле-
дующем примере:
'Product' [Global-Pet] =
VAR SalesProduct = [Sales Amount]
VAR SalesAllProducts =
CALCULATE (
[Sales Amount];
ALL ( 'Product' )
)
VAR Result =
DIVIDE ( SalesProduct; SalesAllProducts )
RETURN
Result
Еще раз напоминаем, что функция ALL способна предотвратить операцию
преобразования контекста, поскольку является модификатором функции CAL-
CULATE, а значит, применяется после преобразования контекста.
Если необходимо рассчитать процент продаж относительно заданной кате-
гории товаров, код придется немного переписать:
'Product'[CategoryPct] =
VAR SalesProduct = [Sales Amount]
VAR SalesCategory =
CALCULATE (
[Sales Amount];
ALLEXCEPT ( 'Product'; 'Product'[Category] )
)
VAR Result
DIVIDE ( SalesProduct; SalesCategory )
RETURN
Result
Результат вычисления этих двух мер виден на рис. 10.16.
ГЛАВА 10 Работа с контекстом фильтра 365
Product Name
Global Pct CategoryPct
Adventure Works 26й 720p LCD HDTV M140 Silver 4.26% 29.68%
A. Datum SLR Camera X137 Grey 2.37% 10.09%
Contoso Telephoto Conversion Lens X400 Silver 2.24% 9.51%
SV 16xDVD M360 Black 1.19% 8.30%
Contoso Projector 1080p X980 White 0.84% 3.81%
Contoso Washer & Dryer 21 in E210 Pink 0.60% 1.90%
Fabrikam Independent filmmaker 1/3" 8.5mm X200 White 0.54% 2.30%
Proseware Projector 1080p LCD86 Silver 0.53% 2.38%
NT Washer & Dryer 27in L2700 Blue 0.50% 1.58%
Contoso Washer & Dryer 21 in E210 Green 0.49% 1.58%
Fabrikam Laptop19 M9000 Black 0.47% 2.14%
NT Washer & Dryer 27in L2700 Green 0.45% 1.43%
Рис. 10.16 Меры GlobalPct и CategoryPct
используют модификаторы ALL и ALLEXCEPT для предотвращения
выполнения преобразования контекста
Использование функции ISEMPTY
Функция ISEMPTY используется для определения того, является ли таблица,
переданная в качестве параметра, пустой, что означает, что в ней нет видимых
значений в текущем контексте фильтра. Функцию ISEMPTY можно заменить
выражением с подсчетом количества строк в табличном выражении:
COUNTROWS ( VALUES ( 'Product'[Color] ) ) = 0
Но использование функции ISEMPTY позволяет сделать код более элегант-
ным:
ISEMPTY ( VALUES ( 'Product'[Color] ) )
С точки зрения производительности всегда лучше использовать в подобных
случаях функцию ISEMPTY, поскольку она сообщает движку DAX, что именно
нужно проверить. Функция СОUNTROWSтребует полного сканирования табли-
цы для подсчета строк, тогда как для выполнения функции ISEMPTY в этом нет
необходимости.
Представьте, что вам нужно определить количество покупателей, никогда
не приобретавших определенный товар. Эту задачу можно решить следующим
образом:
NonBuylngCustomers :=
VAR SelectedCustomers =
CALCULATETABLE (
DISTINCT ( Sales[CustomerKey] );
ALLSELECTED ()
366 ПЛАВАЮ Работа с контекстом фильтра
)
VAR CustonersWithoutSales =
FILTER (
SelectedCustoners;
ISEMPTY ( RELATEDTABLE ( Sales ) )
)
VAR Result =
COUNTROWS ( CustonersWithoutSales )
RETURN
Result
На рис. 10.17 показан отчет с двумя выведенными мерами.
Brand
Sales Amount NumOfCustomers NonBuyingCustomers
A. Datum 2,096,184.64 1,144 9,897
Adventure Works 4,011,112.28 2,587 8,454
Contoso 7,352,399.03 4,346 6,695
Fabrikam 5,554,015.73 526 10,515
Litware 3,255,704.03 994 10,047
Northwind Traders 1,040,552.13 1,002 10,039
Proseware 2,546,144.16 495 10,546
Southridge Video 1,384,413.85 5,200 5,841
Tailspin Toys 325,042.42 4,278 6,763
The Phone Company 1,123,819.07 318 10,723
Wide World Importers 1,901,956.66 517 10,524
Total 30,591,343.98 11,041
Рис. 10.17 В мере NonBuyingCustomers подсчитывается количество покупателей,
никогда не приобретавших выбранные товары
Пользоваться функцией ISEMPTYдовольно просто. В этом примере мы ис-
пользовали ее, чтобы продемонстрировать читателю одну особенность. В пре-
дыдущем фрагменте кода мы сохраняем список покупателей в переменную
и позже проходим по ней при помощи функции FILTER, чтобы проверить на
пустоту результат выполнения функции RELATEDTABLE.
Если содержимым таблицы в переменной SelectedCustomers является список
ключей покупателей (Sales[CustonerKey]), как DAX может узнать о связях этих
значений с таблицей Sales? Код покупателя как значение ничем не отличается
от количества проданных товаров. Число есть число. Разница состоит только
в значении этого числа. Представляя код покупателя, число 120 будет указы-
вать на покупателя с кодом 120, тогда как в качестве количества то же самое
число будет означать уже количество проданных товаров.
По сути, список чисел не обладает выраженным смыслом, будучи исполь-
зованным в качестве фильтра. DAX умеет хранить информацию об источнике
значений в столбце путем привязки данных, о чем мы расскажем в следующем
разделе.
ГЛАВА 10 Работа с контекстом фильтра 367
Привязка данных и функция TREATAS
Как мы заметили в предыдущем разделе, список значений сам по себе не об-
ладает смыслом, если неизвестно, что представляют собой эти данные. Пред-
ставьте, что у вас есть анонимная таблица со значениями Red (Красный) и Blue
(Синий):
{ "Red", "Blue" }
Мы прекрасно понимаем, что речь идет о цветах. Более того, на этом эта-
пе чтения книги мы даже можем предположить, что, скорее всего, здесь под-
разумеваются цвета товаров. Но для DAX это не более чем две строки. От-
фильтровать таблицу по этим строкам движок не может. Поэтому следующее
выражение всегда будет возвращать общую сумму продажи:
Test :=
CALCULATE (
[Sales Amount];
{ "Red"; "Blue" }
)
Примечание Предыдущая мера не выдаст ошибку. Указанный аргумент фильтра будет
применен к анонимной таблице без оказания влияния на физические таблицы в модели
данных.
\____________________________________________________________________________)
На рис. 10.18 видно, что результат выполнения меры везде равен значению
самой меры Sales Amount, поскольку функция CALCULATE не выполняет ника-
кой фильтрации.
Чтобы значение могло фильтровать модель, движку DAX необходимо знать,
к каким данным привязано это значение. Говорят, что значение, представляю-
щее столбец в модели, обладает привязкой данных (data lineage) к этому столбцу.
И наоборот, значение, никак не привязанное к данным в модели, называется
анонимным (anonymous value). В предыдущем примере в мере Test была ис-
пользована анонимная таблица в качестве аргумента фильтра функции CAL-
CULATE, которая не могла отфильтровать итоговый набор данных.
В следующем фрагменте показан рабочий пример использования аноним-
ной таблицы в качестве фильтра. Заметьте, что мы использовали развернутый
синтаксис указания аргумента функции CALCULATE исключительно в образо-
вательных целях - простого предиката для фильтрации столбца Product[Color]
было бы более чем достаточно:
Test :=
CALCULATE (
[Sales Amount];
FILTER (
ALL ( 'Product'[Color] );
'Product'[Color] IN { "Red"; "Blue" }
)
)
368 ПЛАВАЮ Работа с контекстом фильтра
Color Sales Amount Test
Azure 97,389.89 97,389.89
Black 5,860,066.14 5,860,066.14
Blue 2,435,444.62 2,435,444.62
Brown 1,029,508.95 1,029,508.95
Gold 361,496.01 361,496.01
Green 1,403,184.38 1,403,184.38
Grey 3,509,138.09 3,509,138.09
Orange 857,320.28 857,320.28
Pink 828,638.54 828,638.54
Purple 5,973.84 5,973.84
Red 1,110,102.10 1,110,102.10
Silver 6,798,560.86 6,798,560.86
Silver Grey 371,908.92 371,908.92
Transparent 3,295.89 3,295.89
White 5,829,599.91 5,829,599.91
Yellow 89,715.56 89,715.56
Total 30,591,343.98 30,591,343.98
Рис. 10.18 Фильтр с использованием
анонимной таблицы не оказывает
влияния на результат
Привязка данных здесь выполняется следующим образом: функция ALL воз-
вращает таблицу, содержащую все цвета товаров. При этом результат содержит
значения из исходного столбца, так что DAX известна трактовка этих значений.
Функция FILTER сканирует таблицу, содержащую все цвета, на предмет вхож-
дения в список значений анонимной таблицы (Red и Blue). В результате функ-
ция FILTER возвращает таблицу, содержащую значения столбца ProductfColor],
так что функция CALCULATE знает, что фильтр необходимо применить именно
к столбцу Product[Color].
Привязку данных можно представить как своеобразный ярлык или тег, при-
крепленный к каждому столбцу и однозначно идентифицирующий его пози-
цию в модели данных.
Обычно вам не следует беспокоиться о привязке данных, поскольку всю
сложную работу DAX берет на себя, и внешне все выглядит просто и понятно.
Например, когда табличное значение присваивается переменной, DAX выпол-
няет привязку данных, которая впоследствии незримо используется во всех
выражениях, где присутствует эта переменная.
Причиной же для изучения процесса привязки данных является то, что вы
вправе менять эти связи при необходимости. В каких-то ситуациях важно под-
держивать привязку данных, настроенную по умолчанию, но иногда может по-
надобиться изменить привязку того или иного столбца.
Функция, призванная менять привязку данных по столбцам, называется
TREATAS. В качестве первого параметра она принимает таблицу, после чего
следуют ссылки на столбцы в модели данных. В результате функция TREATAS
обновляет привязку переданной таблицы, последовательно соединяя ее столб-
цы с переданными в качестве параметров ссылками. Например, предыдущая
мера Test может быть переписана следующим образом:
ГЛАВА 10 Работа с контекстом фильтра 369
Test :=
CALCULATE (
[Sales Amount];
TREATAS ( { "Red"; "Blue" }; 'Product'[Color] )
)
Функция TREATAS вернет таблицу, содержащую значения с привязкой
к столбцу Product[Color]. Таким образом, новая мера Test включит в себя про-
дажи только по красным и синим товарам, что видно по рис. 10.19.
Color Sales Amount Test
Azure 97,389.89 3,545,546.72
Black 5,860,066.14 3,545,546.72
Blue 2,435,444.62 3,545,546.72
Brown 1,029,508.95 3,545,546.72
Gold 361,496.01 3,545,546.72
Green 1,403,184.38 3,545,546.72
Grey 3,509,138.09 3,545,546.72
Orange 857,320.28 3,545,546.72
Pink 828,638.54 3,545,546.72
Purple 5,973.84 3,545,546.72
Red 1,110,102.10 3,545,546.72
Silver 6,798,560.86 3,545,546.72
Silver Grey 371,908.92 3,545,546.72
Transparent 3,295.89 3,545,546.72
White 5,829,599.91 3,545,546.72
Yellow 89,715.56 3,545,546.72
Total 30,591,343.98 3,545,546.72
Рис. 10.19 Функция TREATAS позволила переопределить привязку данных
в анонимной таблице, что привело к возможности выполнить фильтрацию
Правила выполнения привязки данных очень просты. Обычная ссылка на
столбец поддерживает привязку к таблице, тогда как выражение всегда будет
анонимным. По сути, выражение генерирует ссылку на анонимный столбец.
В следующем примере возвращается таблица с двумя столбцами, содержащи-
ми одинаковые значения. Разница между ними будет состоять лишь в том, что
первый столбец будет поддерживать привязку данных к исходной таблице,
тогда как второй - нет, поскольку является новым столбцом:
ADDCOLUMNS (
VALUES ( 'Product'[Color] );
"Color without lineage"; 'Product'[Color] & ""
)
Функция TREATAS может пригодиться, если необходимо обновить привязку
данных для одного или нескольких столбцов в табличном выражении. Пример,
370 ПЛАВАЮ Работа с контекстом фильтра
который мы рассмотрели до этого, был ознакомительным. Сейчас же мы уви-
дим более интересный вариант использования привязки данных в сценарии
работы с датой и временем. В главе 8 мы написали следующую меру для рас-
чета даты с использованием функции LASTNONBLANK применительно к полу-
аддитивному вычислению:
LastBalancelndividualCustomer :=
SUMX (
VALUES ( Balances[Name] );
CALCULATE (
SUM ( Balances[Balance] );
LASTNONBLANK (
'Date'[Date];
COUNTROWS ( RELATEDTABLE ( Balances ) )
)
)
)
Данная мера работает, но при этом страдает от одного существенного не-
достатка: она содержит в себе две итерации, и оптимизатор вряд ли справит-
ся с задачей нахождения идеального плана выполнения этого запроса. Было
бы лучше создать таблицу, содержащую имена клиентов и последние даты их
балансов, после чего использовать эту таблицу в качестве аргумента фильтра
функции CALCULATE для фильтрации дат для каждого клиента. Оказывается,
что в этом случае очень полезной может оказаться функция TREATAS:
LastBalancelndividualCustoner Optimized :=
VAR LastCustomerDate =
ADDCOLUMNS (
VALUES ( Balances[Name] );
"LastDate"; CALCULATE (
MAX ( Balances[Date] );
DATESBETWEEN ( 'Date' [Date]; BLANKQ; MAX ( Balances[Date] ) )
)
)
VAR FilterCustomerDate =
TREATAS (
LastCustomerDate;
Balances[Name];
'Date'[Date]
)
VAR SumLastBalance =
CALCULATE (
SUM ( Balances[Balance] );
FilterCustomerDate
)
RETURN
SumLastBalance
Данная мера работает следующим образом:
в табличной переменной LastCustomerDate сохраняются последние даты
с наличием информации по каждому клиенту. В результате мы полу-
ГЛАВА 10 Работа с контекстом фильтра 371
чим таблицу из двух столбцов: первый представляет ссылку на столбец
Balances[Name], а второй является анонимным, поскольку вычисляется
в выражении;
табличные переменные FilterCustomerDate и LastCustomerDate заполня-
ются одним и тем же содержимым. При этом при формировании пере-
менной FilterCustomerDate была использована функция TREATAS, что
позволило осуществить привязку ее столбцов к данным в модели. Та-
ким образом, первый столбец представляет собой ссылку на столбец
Balances[Name], а второй - на Date[Date];
на заключительном шаге мы используем табличную переменную Fil-
terCustomerDate в качестве аргумента фильтра функции CALCULATE.
Поскольку теперь таблица корректно привязана к данным в модели,
функция CALCULATE осуществляет фильтрацию модели данных таким
образом, чтобы для каждого клиента осталась одна дата. Это и будет по-
следняя дата, на которую есть данные об этом клиенте в таблице Balances.
В подавляющем большинстве случаев функция TREATAS используется для
изменения привязки данных в таблицах, состоящих из одного столбца. Здесь
же мы показали более сложный пример использования этой функции, где
привязка осуществлялась сразу по двум столбцам. При этом привязка данных
в таблице, являющейся результатом выражения на DAX, может включать столб-
цы из разных таблиц. Когда такая таблица применяется к контексту фильтра,
это часто ведет к образованию так называемого фильтра произвольной фор-
мы, о чем мы поговорим в следующем разделе.
Фильтры произвольной формы
Фильтры в контексте могут быть двух разновидностей: простым фильтром или
фильтром произвольной формы (arbitrarily shaped filter). Все фильтры, которые
мы рассматривали в книге до сих пор, обладали простой формой. В данном
разделе мы поговорим о фильтрах произвольной формы и способах их при-
менения в коде. Фильтры произвольной формы могут быть созданы в свод-
ной таблице в Excel или путем написания кода меры на языке DAX, тогда как
в пользовательском интерфейсе Power BI на данный момент необходимо ис-
пользовать для их создания специальные элементы визуализации. В этом раз-
деле мы расскажем о фильтрах произвольной формы и работе с ними в DAX.
Начнем с описания различий между простым фильтром и фильтром произ-
вольной формы в контексте фильтра:
фильтр по столбцу представляет собой список значений из одного
столбца. Например, список из трех цветов - красного, синего и зелено-
го - является фильтром по столбцу. В следующем выражении мы исполь-
зуем функцию CALCULATE для создания фильтра по столбцу в контексте
фильтра, влияющего только на столбец Product [Color]:
CALCULATE (
[Sales Amount];
372 ПЛАВАЮ Работа с контекстом фильтра
'Product'[Color] IN { "Red"; "Blue"; "Green" }
простой фильтр - это фильтр по одному и более столбцам, представляю-
щий собой набор из нескольких фильтров по столбцу. Почти все фильтры,
которые мы использовали в данной книге до сих пор, являлись просты-
ми. Простые фильтры создаются с использованием нескольких аргумен-
тов фильтра в функции CALCULATE:
CALCULATE (
[Sales Anount];
'Product'[Color] IN { "Red"; "Blue" };
'Date'[Calendar Year Nunber] IN { 2007; 2008; 2009 }
Этот код также может быть записан с использованием простого фильтра
с двумя столбцами:
CALCULATE (
[Sales Anount];
TREATAS (
( "Red"; 2007 );
( "Red";
( "Red";
( "Blue";
( "Blue";
2008 );
2009 );
2007 );
2008 );
( "Blue"; 2009 )
};
'Product'[Color];
'Date'[Calendar Year Nunber]
Поскольку простой фильтр содержит в себе все возможные сочетания
значений двух столбцов, проще выражать его с использованием двух
фильтров по столбцу;
фильтр произвольной формы - это любой фильтр, который не может
быть выражен через простой фильтр. Взгляните на следующее выраже-
ние:
CALCULATE (
[Sales Anount];
TREATAS (
( "CY 2007"; "Decenber" );
( "CY 2008"; "January" )
};
'Date'[Calendar Year];
'Date'[Month]
ГЛАВА 10 Работа с контекстом фильтра 373
Фильтр по году и месяцу не является фильтром по столбцу, поскольку
в него вовлечены сразу два столбца. Более того, этот фильтр не включает
все возможные комбинации этих столбцов в модели данных. Фактиче-
ски невозможно фильтровать годы и месяцы отдельно. Здесь мы имеем
дело со ссылками на два года и два месяца, и в таблице Date есть четыре
комбинации для предложенных значений, но фильтр включает в себя
только две из них. Иными словами, если бы мы использовали фильтры
по столбцу, то результирующий контекст фильтра также включал бы зна-
чения Январь 2007 и Декабрь 2008, которые не описаны в предыдущем
примере. А значит, перед нами фильтр произвольной формы.
Фильтр произвольной формы - это не просто фильтр со множеством столб-
цов. Конечно, фильтр со множеством столбцов может быть произвольной
формы, но он также может представлять собой и простой фильтр. В следую-
щем примере у нас как раз простой фильтр, хоть он и состоит из нескольких
столбцов:
CALCULATE (
[Sales Amount];
TREATAS (
{
( "CY 2007"; "December" );
( "CY 2008"; "December" )
};
'Date'[Calendar Year];
'Date'[Month]
)
)
Этот фрагмент кода может быть переписан с использованием двух фильтров
по столбцу следующим образом:
CALCULATE (
[Sales Amount];
'Date'[Calendar Year] IN { "CY 2007"; "CY 2008" };
'Date'[Month] = "December"
)
Кажется, что выражения с использованием фильтров произвольной формы
писать непросто, но они могут быть достаточно легко определены в пользо-
вательском интерфейсе Excel и Power BI. На момент написания книги в Power
BI такие фильтры можно строить только при помощи специального элемента
визуализации Иерархический срез (Hierarchy Slicer), который позволяет опреде-
лить фильтры, базируясь на иерархии по нескольким столбцам. Например, на
рис. 10.20 видно, как этот элемент визуализации отображает разные месяцы
из 2007 и 2008 годов.
В Microsoft Excel фильтр произвольной формы строится при помощи встро-
енного инструмента фильтрации, как показано на рис. 10.21.
Фильтры произвольной формы использовать в DAX довольно сложно по
причине производимых в них изменений в рамках контекста фильтра функ-
цией CALCULATE. По сути, когда функция CALCULATE применяет фильтр
374 ПЛАВАЮ Работа с контекстом фильтра
к столбцу, она удаляет ранее наложенные фильтры на этот столбец, заменяя
их новыми. Это обычно приводит к потере произвольным фильтром своей из-
начальной формы. В результате формулы начинают выдавать неправильные
цифры, и поддерживать их становится очень сложно. Чтобы продемонстри-
ровать эту проблему, мы будем усложнять код шаг за шагом, пока проблема
не проявится.
Calendar Year
I—I JUiy
□ August
E September
E October
E November
E December
- □ CY 2008
£ January
E February
E March
□ April
□ May
□ June
□ July
□ August
Calendar Year Sales Amount
CY 2007 3,741,293.14
September 1,009,868.98
October 914,273.54
November 825,601.87
December 991,548.75
CY 2008 1,816,385.21
January 656,766.69
February 600,080.00
March 559,538.52
Total 5,557,678.35
Рис. 10.20 При фильтрации иерархии
может образоваться фильтр произвольной формы
Row Labels
Sales Amount
Filter!
Year
)9,868.98
914,273.54
825,601.87
991,548.75
656,766.69
600,080.00
4,998,139.83
Рис. 10.21 В Microsoft Excel фильтры произвольной формы
создаются при помощи встроенных средств фильтрации
ГЛАВА 10 Работа с контекстом фильтра 375
Представьте, что вы определили простую меру, перезаписывающую год на
2007-й:
Sales Amount 2007 :=
CALCULATE (
[Sales Amount];
'Date'[Calendar Year] = "CY 2007"
)
Функция CALCULATE перезаписывает фильтр по году, но при этом не трогает
фильтр по месяцу. Будучи использованной в отчете, мера может показывать
неожиданные результаты, что видно по рис. 10.22.
Calendar Year Sales Amount Sales Amount 2007
CY 2007 3,741,293.14 3,741,293.14
September 1,009,868.98 1,009,868.98
October 914,273.54 914,273.54
November 825,601.87 825,601.87
December 991,548.75 991,548.75
CY 2008 1,816,385.21 2,646,673.39
January 656,766.69 794,248.24
February 600,080.00 891,135.91
March 559,538.52 961,289.24
Total 5,557,678.35 6,387,966.53
Рис. 10.22 2007 год перезаписал предыдущий фильтр по году
В 2007 году цифры в обоих столбцах одинаковые, тогда как в 2008-м мера
Sales Amount 2007 по-прежнему выводит данные по 2007 году, притом что ме-
сяц остался без изменений. Таким образом, в ячейке по январю 2008 года по-
казаны продажи за январь 2007-го. То же самое касается февраля и марта. Важ-
но отметить при этом, что в исходный фильтр не входили первые три месяца
2007 года, а после замены фильтра по году формула начала показывать эти
цифры. Как видим, пока никаких аномалий нет.
Ситуация станет более запутанной, если вы захотите узнать среднемесячные
продажи. Одним из решений является запуск итераций по месяцам и агреги-
рование промежуточных значений при помощи функции AVERAGEX:
Monthly Avg :=
AVERAGEX (
VALUES ( 'Date'[Month] );
[Sales Amount]
)
Результат работы этой меры виден на рис. 10.23. Итоговые цифры на этот раз
оказались чересчур большими.
Понять проблему в данном случае сложнее, чем решить ее. Давайте сосредо-
точимся на ячейке, выдающей неправильное значение, - общем итоге по мере
Monthly Avg. Контекст фильтра в строке итогов у нас следующий:
376 ПЛАВАЮ Работа с контекстом фильтра
TREATAS (
{
( "CY 2007"; "September" );
( "CY 2007; "October" );
( "CY 2007"; "November" );
( "CY 2007"; "December" );
( "CY 2008"; "January" );
( "CY 2008"; "February" );
( "CY 2008"; "March" )
};
'Date'[Calendar Year];
'Date'[Month]
Calendar Year Calendar Year Sales Amount Monthly Avg
L_J JLJiy □ August CY 2007 3,741,293.14 935,323.29
E September September 1,009,868.98 1,009,868.98
E October £ November E December J D CY 2008 October November December 914,273.54 825,601.87 991,548.75 914,273.54 825,601.87 991,548.75
£ January CY 2008 1,816,385.21 605,461.74
E February January 656,766.69 656,766.69
£ March О April □ May О June February March Total 600,080.00 559,538.52 5,557,678.35 600,080.00 559,538.52 1,709,342.92
Рис. 10.23 Итоговые значения явно не являются средними по месяцам,
они гораздо больше
Чтобы проследить за выполнением формулы в этой ячейке, напишем пол-
ное выражение с указанием соответствующего контекста фильтра в функции
CALCULATE, вычисляющей меру Monthly Avg. Также мы раскроем и код меры
Monthly Avg, чтобы провести полноценную симуляцию запуска формулы:
CALCULATE (
AVERAGEX (
VALUES ( 'Date'[Month] );
CALCULATE (
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
)
);
TREATAS (
{
( "CY 2007"; "September" );
( "CY 2007"; "October" );
( "CY 2007"; "November" );
ГЛАВА 10 Работа с контекстом фильтра 377
( "CY 2007"; "December" );
( "CY 2008"; "January" );
( "CY 2008"; "February" );
( "CY 2008"; "March" )
};
'Date'[Calendar Year];
'Date'[Month]
)
)
Ключом к решению создавшейся проблемы является понимание того, что
происходит во время выполнения выделенной жирным шрифтом функции
CALCULATE. Эта функция вызывается в рамках контекста строки, проходящего
по столбцу DatefMonth]. Следовательно, происходит преобразование контек-
ста, в результате чего текущий месяц добавляется к контексту фильтра. То есть
в конкретном месяце, скажем в январе, этот месяц будет добавлен к контексту
фильтра, при этом все ранее наложенные фильтры по месяцу будут удалены,
тогда как другие фильтры останутся нетронутыми.
При выполнении функции AVERAGEX на уровне января контекст фильтра
будет включать в себя январь 2007 и 2008 годов, поскольку исходный контекст
фильтровал сразу два года. Таким образом, на каждой итерации DAX рассчиты-
вает сумму продажи по одному и тому же месяцу в двух разных годах. Именно
поэтому результат получается завышенным по сравнению с месячными про-
дажами.
Изначальная форма произвольного фильтра оказалась утеряна, поскольку
функция CALCULATE переопределила фильтр по одному из столбцов, участву-
ющих в общем фильтре. В результате мы получили неправильные цифры.
Решить возникшую проблему гораздо проще, чем вы думаете. На самом деле
достаточно будет проводить итерации по столбцу с гарантированно уникаль-
ными значениями. Так что если мы будем проходить не по столбцу с месяца-
ми, в котором значения не уникальны, а по столбцу Calendar Year Month, соче-
тающему в себе год и месяц, то формула будет возвращать правильные цифры:
Monthly Avg :=
AVERAGEX (
VALUES ( 'Date'[Calendar Year Month] );
[Sales Amount]
)
Используя эту версию меры Monthly Avg, мы будем на каждой итерации за
счет преобразования контекста переопределять фильтр по столбцу Calendar
Year Month, в котором объединены вместе год и месяц. В результате мы всегда
будем получать сумму продаж за один конкретный месяц, что нам и требова-
лось. Отчет с обновленной мерой показан на рис. 10.24.
Если в нашем распоряжении нет столбца с уникальными значениями, под-
ходящего для итератора в отношении кратности, можно для решения пробле-
мы воспользоваться функцией KEEPFILTERS. Показанная ниже альтернатив-
ная версия меры работает корректно, поскольку вместо замены предыдущего
фильтра просто добавляет фильтр по месяцу к существующему произвольному
фильтру, что позволяет сохранить его изначальную форму:
378 ПЛАВАЮ Работа с контекстом фильтра
Monthly Avg KeepFilters :=
AVERAGEX (
KEEPFILTERS ( VALUES ( 'Date'[Month] ) );
[Sales Amount]
)
Calendar Year
---1—глтгу------
□ August
E September
E October
E November
E December
J □ CY 2008
E January
E February^
£ March
□ April
□ May
П June
Calendar Year
CY 2007
September
October
November
December
CY 2008
January
February
March
Total
Sales Amount
3J41f293.14
1,009,868.98
914,273.54
825,601.87
991,548.75
1,816,385.21
656,766.69
600,080.00
559,538.52
5,557,678.35
Monthly Avg
935,323.29
1,009,868.98
914,273.54
825,601.87
991,548.75
605,461.74
656,766.69
600,080.00
559,538.52
793,954.05
Рис. 10.24 Итерации по столбцу с уникальными значениями
позволили прийти к верным расчетам
Фильтры произвольной формы встречаются в реальных отчетах не так часто.
Но пользователи имеют вполне законное право их создавать по своему усмот-
рению. Чтобы обеспечить правильный расчет меры в присутствии фильтра
произвольной формы, необходимо следовать двум простым правилам:
осуществляя итерации по столбцу, убедитесь в том, что он содержит уни-
кальные значения на том уровне гранулярности, на котором производят-
ся вычисления. Например, в таблице Date больше 12 месяцев, а значит,
для подобных итераций лучше будет использовать столбец YearMonth;
если первое правило выполнить невозможно, допустимо воспользо-
ваться функцией KEEPFILTERS для гарантии сохранения формы произ-
вольного фильтра в контексте фильтра. Помните при этом, что функция
KEEPFILTERS способна изменить семантику вычисления. В связи с этим
необходимо внимательно отнестись к тому, чтобы она не внесла ошибоч-
ные расчеты в меру.
Если вы будете следовать этим простым правилам, ваш код всегда будет
должным образом защищен в присутствии фильтров произвольной формы.
Заключение
В данной главе вы познакомились с несколькими функциями для анализа со-
держимого контекста фильтра и/или изменения поведения мер в зависимости
от контекста. Мы также рассмотрели некоторые важные техники управления
контекстом фильтра и описали его возможные состояния. Вот наиболее важ-
ные концепции, о которых было рассказано в главе:
ГЛАВА 10 Работа с контекстом фильтра 579
столбец может быть отфильтрован напрямую, а может входить в состав
перекрестного фильтра, когда действие на него распространяется вслед-
ствие прямой фильтрации другого столбца или таблицы. Вы можете про-
верить, является ли столбец участником обычной или кросс-фильтрации
при помощи функций ISFILTERED и ISCROSSFILTERED соответственно;
функция HASONEVALUE проверяет, одно ли значение из указанного
столбца видимо в текущем контексте фильтра. Это бывает полезно перед
извлечением этого значения при помощи функции VALUES. Функция SE-
LECTEDVALUE, в свою очередь, призвана упростить использование шаб-
лона HASONEVALUE/ VALUES;
использование функции ALLEXCEPT не эквивалентно применению пары
функций ALL и VALUES. В присутствии кросс-фильтра второй вариант яв-
ляется более безопасным, поскольку учитывает в процессе вычисления
наличие перекрестных фильтров;
функция ALL и ее аналоги, начинающиеся с ALL*, могут быть использова-
ны в выражениях для предотвращения преобразования контекста. Фак-
тически применение функции ALL в формуле вычисляемого столбца или
в любом другом контексте строки вынуждает DAX отказаться от опера-
ции преобразования контекста;
каждый столбец в таблице обладает так называемой привязкой данных.
Привязка данных позволяет DAX применять фильтры и использовать
связи. Привязка данных сохраняется при прямой ссылке на столбец таб-
лицы и утрачивается при использовании выражений;
привязка данных к одному или нескольким столбцам в модели может
быть осуществлена при помощи функции TREATAS;
не все фильтры являются простыми. Пользователь вправе создавать бо-
лее сложные фильтры либо посредством интерфейса, либо в самом коде.
Наиболее сложным видом фильтров являются фильтры произвольной
формы. Сложность при их использовании напрямую связана со взаимо-
действием с функцией CALCULATE и преобразованием контекста.
Возможно, вам не удастся сразу запомнить все функции и концепции, опи-
санные в данной главе. Но важно то, что вы познакомились с ними уже на этом
этапе изучения языка DAX. С приобретением опыта вы, безусловно, будете
сталкиваться на практике с ситуациями, рассмотренными в данной главе. Но
вы всегда можете вернуться к этим страницам книги и освежить в памяти спо-
собы решения тех или иных проблемных сценариев.
В следующей главе мы будем использовать многие функции, с которыми вы
познакомились здесь, для осуществления вычислений в рамках иерархий. Как
вы узнаете, работа с иерархиями по большей части основана на понимании
формы текущего контекста фильтра.
ГЛАВА 11
Работа с иерархиями
Иерархии (hierarchy) очень часто присутствуют в моделях данных с целью об-
легчения работы пользователя с заранее известными вложенностями. При
этом в DAX нет специальных функций для осуществления вычислений внут-
ри иерархий. В результате проведение простейших расчетов вроде получения
того или иного показателя в процентном отношении к его подгруппе требует
написания сложного кода на DAX, да и поддержка и сопровождение вычисле-
ний в рамках иерархий - задача не из простых.
Однако изучать методы и принципы работы с иерархиями крайне необходи-
мо, поскольку сценарии с их использованием очень распространены. В данной
главе мы покажем, как создавать базовые вычисления в присутствии иерархий
и как использовать язык DAX для преобразования иерархии типа родитель/по-
томок в обычную иерархию.
Вычисление процентов внутри иерархии
Распространенной задачей при работе с иерархиями является создание меры,
которая будет вести себя по-разному в зависимости от уровня выбранного эле-
мента. Примером такой меры может служить доля показателя по отношению
к родительскому элементу. При этом мера должна работать на любом уровне
иерархии и показывать процент по отношению к группе, являющейся для него
непосредственным родителем.
Представьте иерархический справочник товаров, в котором уровнями будут
категории, подкатегории и собственно товары. Наша мера должна для катего-
рии показывать долю относительно итогов, для подкатегории - относительно
соответствующей категории, а для товара - относительно его подкатегории.
Таким образом, в зависимости от уровня иерархии вычисления будут разными.
Пример отчета с иерархиями показан на рис. 11.1.
В Excel можно создать подобное вычисление в сводной таблице при помощи
опции Дополнительные вычисления (Show Values As), чтобы переложить
бремя расчетов на Excel. Но если вы хотите использовать вычисление вне за-
висимости от используемого клиентского приложения, то лучше всего будет
написать собственную меру, которая будет рассчитываться непосредственно
в модели данных. К тому же изучение этой техники может пригодиться вам
и в других сценариях.
К сожалению, в DAX вычисление доли показателя относительно родитель-
ского элемента - задача не самая простая. Здесь мы сталкиваемся с первым
ГЛАВА 11 Работа с иерархиями 381
серьезным ограничением языка DAX, не позволяющим создать универсальную
меру, которая работала бы с произвольным количеством столбцов в отчете.
Причина этого в том, что DAX не знает, как был построен тот или иной отчет
или как использовалась иерархия в клиентских инструментах. DAX не имеет
ни малейшего понятия о том, как именно пользователь собирает отчет. Движок
просто получает запрос, в котором не указано, что будет вынесено в строки,
что - в столбцы и какие фильтры и срезы будут использоваться при построе-
нии отчета.
Category ▲ Sales Amount PercOnParent
Audio 21.544.69 2.60%
Bluetooth Headphones 4.444.69 20.63%
NT Bluetooth Stereo Headphones E52 Pink 904.29 20.35%
NT Wireless Bluetooth Stereo Headphones E302 Pink 324.40 7.30%
WWI Wireless Bluetooth Stereo Headphones M170 Pink 1,560.00 35.10%
WWI Wireless Bluetooth Stereo Headphones M270 Pink 1,656.00 37.26%
MP4&MP3 5.846.40 27.14%
Contoso 16GB New Generation MP5 Player M1650 Pink 5,846.40 100.00%
Recording Pen 11.253.60 52.23%
WWI 1GB Digital Voice Recorder Pen E100 Pink 2,995.20 26.62%
WWI 4GB Video Recording Pen X200 Pink 8,258.40 73.38%
Cameras and camcorders 364.444.58 43.98%
Cameras & Camcorders Accessories 3.940.47 1.08%
Contoso Carrying Case E312 Pink 1,351.42 34.30%
Contoso Conversion Lens M55O Pink 184.50 4.68%
Contoso Cyber Shot Digital Cameras Adapter ЕЗО6 Pink 1,937.52 49.17%
Contoso Lens Cap Keeper E314 Pink 467.04 11.85%
Рис. 11.1 Мера PercOnParent помогает лучше понять значения в отчете
Несмотря на то что универсальная формула не может быть создана, вы впол-
не можете написать меру, которая будет возвращать корректные значения при
правильном использовании. Поскольку наша иерархия насчитывает три уров-
ня (категории, подкатегории и товары), мы начнем с создания трех разных мер
для каждого из них:
PercOnSubcategory :=
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Anount];
ALLSELECTED ( Product[Product Nane] )
)
)
PercOnCategory :=
DIVIDE (
[Sales Anount];
CALCULATE (
[Sales Anount];
382 ГЛАВА 11 Работа с иерархиями
ALLSELECTED ( Product[Subcategory] )
)
)
PercOnTotal :=
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Amount];
ALLSELECTED ( Product[Category] )
)
)
Эти три меры прекрасно справляются со своими расчетами. На рис. 11.2
представлен отчет с использованием всех трех мер.
Category ▲ Sales Amount PercOnTotal PercOnCategory PercOnSubcategory
Audio 21.544.69 2.60% 100.00% 100.00%
Bluetooth Headphones 4,444.69 100.00% 20.63% 100.00%
NT Bluetooth Stereo Headphones E52 Pink 904.29 100.00% 100.00% 20.35%
NT Wireless Bluetooth Stereo Headphones E3O2 Pink 324.40 100.00% 100.00% 7.30%
WWI Wireless Bluetooth Stereo Headphones M170 Pink 1,560.00 100.00% 100.00% 35.10%
WWI Wireless Bluetooth Stereo Headphones M270 Pink 1,656.00 100.00% 100.00% 37.26%
MP4&MP3 5,846.40 100.00% 27.14% 100.00%
Contoso 16GB New Generation MP5 Player M1650 Pink 5,846.40 100.00% 100.00% 100.00%
Recording Pen 11,253.60 100.00% 52.23% 100.00%
WWI 1GB Digital Voice Recorder Pen E100 Pink 2,995.20 100.00% 100.00% 26.62%
WWI 4GB Video Recording Pen X200 Pink 8,258.40 100.00% 100.00% 73.38%
Cameras and camcorders 364,444.58 43.98% 100.00% 100.00%
Cameras & Camcorders Accessories 3,940.47 100.00% 1.08% 100.00%
Contoso Carrying Case E312 Pink 1,351.42 100.00% 100.00% 34.30%
Contoso Conversion Lens M55O Pink 184.50 100.00% 100.00% 4.68%
Contoso Cyber Shot Digital Cameras Adapter E3O6 Pink 1,937.52 100.00% 100.00% 49.17%
Рис. 11.2 Меры правильно работают только на своих уровнях
Несложно заметить, что созданные нами меры показывают корректные
результаты только на соответствующих им уровнях. В остальных случаях они
возвращают значение 100 %, в чем мало пользы. Более того, создание трех мер
не входит в наши планы, нам хотелось бы уместить все расчеты в одной мере.
И мы сделаем это на следующем шаге.
Начнем с очистки значений 100 % для меры PercOnSubcategory. Мы хотели бы
избежать произведения расчетов, если в выбранной строке отсутствует столбец
Product Name. А значит, нам необходимо проверить, входит ли столбец Product
Name в фильтр запроса, формирующего матрицу. Для этой цели есть специаль-
ная функция ISINSCOPE. Функция ISINSCOPE возвращает значение TRUE, если
столбец, переданный ей в качестве аргумента, входит в состав фильтра и при-
надлежит к столбцам, используемым для выполнения группировки. Таким об-
разом, формула может быть изменена следующим образом:
PercOnSubcategory :=
IF (
ГЛАВА 11 Работа с иерархиями 383
ISINSCOPE ( Product[Product Name] );
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Amount];
ALLSELECTED ( Product[Product Name] )
)
)
)
На рис. 11.3 показан отчет с использованием этой меры.
Category ▲ Sales Amount P ercOnTotal PercOnCategory PercC )nSubcategory
Audio 21.544.69 2.60% 100.00%
Bluetooth Headphones 4,444.69 100.00% 20.63%
NT Bluetooth Stereo Headphones E52 Pink 904.29 100.00% 100.00% 20.35%
NT Wireless Bluetooth Stereo Headphones E302 Pink 324.40 100.00% 100.00% 7.30%
WWI Wireless Bluetooth Stereo Headphones M170 Pink 1,560.00 100.00% 100.00% 35.10%
WWI Wireless Bluetooth Stereo Headphones M270 Pink 1,656.00 100.00% 100.00% 37.26%
MP4&MP3 5,846.40 100.00% 27.14%
Contoso 16GB New Generation MP5 Player M1650 Pink 5,846.40 100.00% 100.00% 100.00%
Recording Pen 11,253.60 100.00% 52.23%
WWI 1GB Digital Voice Recorder Pen E100 Pink 2,995.20 100.00% 100.00% 26.62%
WWI 4GB Video Recording Pen X200 Pink 8,258.40 100.00% 100.00% 73.38%
Cameras and camcorders 364,444.58 43.98% 100.00%
Cameras & Camcorders Accessories 3,940.47 100.00% 1.08%
Contoso Carrying Case E312 Pink 1,351.42 100.00% 100.00% 34.30%
Contoso Conversion Lens M55O Pink 184.50 100.00% 100.00% 4.68%
Contoso Cyber Shot Digital Cameras Adapter E306 Pink 1,937.52 100.00% 100.00% 49.17%
Рис. 11.3 Используя функцию ISINSCOPE, мы убрали бесполезные значения 100 %
из меры PercOnSubcategory
Та же техника может быть использована для удаления значений 100 % из
других мер. Обратите внимание, что в мере PercOnCategory мы должны прове-
рять, что столбец Subcategory входит в область видимости, a Product Name - нет.
Причина этого в том, что когда в отчете применяется срез по столбцу Prod-
uct Name с использованием иерархии, автоматически производится срез и по
столбцу Subcategory, выводя при этом наименование товара, а не подкатего-
рии. Во избежание дублирования кода для этих условий лучше будет написать
единую меру, которая будет производить разные вычисления в зависимости от
видимого уровня иерархии, основываясь на результате сканирования иерар-
хии при помощи функции ISINSCOPE с нижнего уровня до верхнего. Представ-
ляем вам код получившейся меры PercOnParent:
PercOnParent :=
VAR Currentsales = [Sales Amount]
VAR SubcategorySales =
CALCULATE (
[Sales Amount];
ALLSELECTED ( Product[Product Name] )
)
VAR CategorySales =
384 ГЛАВА 11 Работа с иерархиями
CALCULATE (
[Sales Anount];
ALLSELECTED ( Product[Subcategory] )
)
VAR TotalSales =
CALCULATE (
[Sales Anount];
ALLSELECTED ( Product[Category] )
)
VAR RatioToParent =
IF (
ISINSCOPE ( Product[Product Nane] );
DIVIDE ( CurrentSales; SubcategorySales );
IF (
ISINSCOPE ( Product[Subcategory] );
DIVIDE ( CurrentSales; CategorySales );
IF (
ISINSCOPE ( Product[Category] );
DIVIDE ( CurrentSales; TotalSales )
)
)
)
RETURN RatioToParent
Использование меры PercOnParent, как и ожидалось, позволило получить
правильные результаты, что видно по рис. 11.4.
Category ж Sales Amount PercOnParent
Audio 21,544.69 2.60%
Bluetooth Headphones 4,444.69 20.63%
NT Bluetooth Stereo Headphones E52 Pink 904.29 20.35%
NT Wireless Bluetooth Stereo Headphones ЕЗО2 Pink 324.40 7.30%
WWI Wireless Bluetooth Stereo Headphones M170 Pink 1,560.00 35.10%
WWI Wireless Bluetooth Stereo Headphones M270 Pink 1,656.00 37.26%
MP4&MP3 5,846.40 27.14%
Contoso 16GB New Generation MP5 Player M1650 Pink 5,846.40 100.00%
Recording Pen 11,253.60 52.23%
WWI 1GB Digital Voice Recorder Pen E100 Pink 2,995.20 26.62%
WWI 4GB Video Recording Pen X200 Pink 8,258.40 73.38%
Cameras and camcorders 364,444.58 43.98%
Cameras & Camcorders Accessories 3,940.47 1.08%
Contoso Carrying Case E312 Pink 1,351.42 34.30%
Contoso Conversion Lens M550 Pink 184.50 4.68%
Contoso Cyber Shot Digital Cameras Adapter E306 Pink 1,937.52 49.17%
Contoso Lens Cap Keeper E314 Pink 467.04 11.85%
Рис. 11.4 Мера PercOnParent объединила три столбца в один
Теперь нам не нужны те три меры, которые мы написали в начале главы.
Единая мера PercOnParent проводит все вычисления и выводит результаты на
соответствующих уровнях иерархии.
ГЛАВА 11 Работа с иерархиями 385
Примечание Порядок, в котором перечислены условные операторы IF, важен. Мы на-
чинаем проверку с нижнего уровня иерархии и постепенно поднимаемся выше. Если
изменить порядок обхода иерархии, результаты вычислений окажутся неправильными.
Важно помнить, что при фильтрации подкатегории в иерархии автоматически фильтру-
ется и категория.
\____________________________________________________________________________/
Мера PercOnParent будет работать корректно только в том случае, если поль-
зователь вынесет правильную иерархию в строки. Например, если категорию
товаров заменить на цвет, полученные результаты понять будет непросто. Так
что эта мера применима только для иерархии товаров вне зависимости от того,
выбраны ли в отчете соответствующие поля.
Работа с иерархиями типа родитель/потомок
Внутренне DAX не поддерживает в чистом виде иерархии типа родитель/пото-
мок, характерные для баз данных Multidimensional в Analysis Services. При этом
в языке DAX есть специальные функции, служащие для выравнивания (flatten)
иерархий типа родитель/потомок в обычные иерархии на основании столбцов.
Этих функций достаточно для большинства сценариев, хотя вам и придется де-
лать прикидку на этапе разработки модели о максимальной глубине иерар-
хии. В данном разделе мы научим вас при помощи функций DAX создавать
иерархии типа родитель/потомок (parent/child hierarchy), иногда называемые
иерархиями Р/С.
Типичный пример такой иерархии изображен на рис. 11.5.
Рис. 11.5 Графическое представление иерархии типа родитель/потомок
Иерархии типа родитель/потомок обладают следующими характерными
особенностями:
количество уровней может быть неодинаковым внутри иерархии. К при-
меру, путь от Аннабель (Annabel) к Майклу (Michael) вмещает в себя два
уровня, в то время как в той же иерархии путь от Билла (Bill) к Крису
(Chris) включает три уровня;
иерархия обычно хранится в одной таблице со ссылками на родитель-
ский элемент в каждой строке.
Традиционный вариант хранения данных об иерархии типа родитель/пото-
мок показан на рис. 11.6.
586 ГЛАВА 11 Работа с иерархиями
PersonKey Name ParentKey
▲
1 Bill
2 Brad 1
3 Julie 1
4 Chris 2
5 Vincent 2
6 Annabel
7 Catherine 6
8 Harry 6
_________9 Michael____________6
Рис. 11.6 Таблица содержит информацию
об иерархии типа родитель/потомок
Несложно догадаться, что в столбце ParentKey указан ключ родительского
элемента. Например, у Кэтрин (Catherine) в этом поле стоит цифра 6, являю-
щаяся ключом для Аннабель. Проблема с такой моделью данных состоит в том,
что в данный момент таблица связана сама с собой, то есть две таблицы, участ-
вующие в отношении, фактически являются одной и той же таблицей в модели
данных.
Табличные модели данных не поддерживают связи таблицы самой с собой.
Следовательно, мы должны изменить модель данных таким образом, чтобы
иерархия типа родитель/потомок преобразовалась в обычную иерархию, где
каждый столбец представляет свой уровень иерархии.
Перед тем как погрузиться в детали иерархий типа родитель/потомок, стоит
сделать еще одно замечание. Взгляните на рис. 11.7, на котором изображена
таблица со значениями, которые мы хотим агрегировать с использованием
иерархии.
PersonKey Name Amount
2 Brad 200
2 Brad 200
3 Julie 300
4 Chris 400
5 Vincent 500
6 Annabel 600
7 Catherine 600
7 Catherine 600
8 Harry 400
8 Harry 400
9 Michael 300
9 Michael 300
Рис. 11.7 Таблица с данными
для иерархии типа родитель/потомок
В строках таблицы фактов содержатся ссылки как на элементы конечно-
го уровня (leaf-level), так и на промежуточные элементы иерархии. Возьмем,
к примеру, строку с Аннабель. В этой строке содержится числовое значение, но
не стоит забывать, что у Аннабель есть три дочерних элемента. Таким образом,
ГЛАВА 11 Работа с иерархиями 387
при суммировании данных по Аннабель формула должна учитывать как эту
строку, так и все дочерние элементы.
На рис. 11.8 показан результат, которого мы хотим добиться.
Level 1 Annabel Annabel FinalFormula 3,200 600
Catherine 1,200
Harry 800
Michael 600
Bill 1,600
Brad 1,300
Brad 400
Chris 400
Vincent 500
Julie 300 Рис. 11.8 Результат просмотра иерархии
Total 4,800 ПрИ помощи матрицы
Чтобы прийти к конечной цели, нам необходимо проделать существенный
путь. Первым шагом после загрузки таблицы в модель данных будет создание
вычисляемого столбца, в котором будет храниться путь для достижения каж-
дого элемента. Поскольку мы не можем использовать традиционные связи
между таблицами, придется призвать на помощь специальные функции DAX
для работы с иерархиями типа родитель/потомок.
В новом вычисляемом столбце с именем FullPath мы воспользуемся функ-
цией PATH:
Persons[FullPath] = PATH ( Persons[PersonKey]; Persons[ParentKey] )
Функция PATH принимает два параметра. Первым является ключ таблицы
(в нашем случае это Persons[PersonKey])f а вторым - ключ родительского эле-
мента. Функция рекурсивно проходит по таблице и для каждого элемента вы-
числяет путь, выраженный в последовательности ключей, разделенных верти-
кальной чертой (|). На рис. 11.9 видна функция FullPath в действии.
Сам по себе столбец FullPath не представляет большого интереса. В то же
время он обладает исключительной важностью, являясь основой для расчета
других вычисляемых столбцов на пути построения иерархии. На следующем
шаге мы создадим еще три вычисляемых столбца, по одному для каждого
уровня иерархии:
Persons[Levell] = LOOKUPVALUE(
Persons[Name];
Persons[PersonKey]; PATHITEM ( Persons[FuUPath]; 1; INTEGER )
)
Persons[Level2] = LOOKUPVALUE(
388 ГЛАВА 11 Работа с иерархиями
Persons[Name];
Persons[PersonKey]; PATHITEM ( Persons[FuUPath]; 2; INTEGER )
)
Persons[Level3] = LOOKUPVALUE(
Persons[Name];
Persons[PersonKey]; PATHITEM ( Persons[FuUPath]; 3; INTEGER )
)
PersonKey Name FullPath
1 Bill 1
2 Brad 1|2
3 Julie 1|3
4 Chris 1|2|4
5 Vincent 1|2|5
6 Annabel 6
7 Catherine 6|7
8 Harry 6|8
9 Michael 6|9
Рис. 11.9 В столбце FullPath
содержится полный путь к элементу
Вычисляемые столбцы названы Level 1, Level2 и Levels, а единственное от-
личие при их создании кроется во втором параметре, переданном функции
PATHITEM, который принимает значения 1,2 и 3 соответственно. При создании
вычисляемых столбцов была использована функция LOOKUPVALUE для поис-
ка строки, в которой значение поля PersonKey эквивалентно результату функ-
ции PATHITEM. Сама функция PATHITEM возвращает указанный во втором
параметре элемент из столбца, построенного при помощи функции PATH, или
пустое значение, если в качестве второго параметра передано число, превы-
шающее длину пути. Получившаяся таблица показана на рис. 11.10.
PersonKey Name ▲ FullPath Level 1 Level2 Level 3
1 Bill 1 Bill
2 Brad 1|2 Bill Brad
3 Julie 1|3 Bill Julie
4 Chris 1|2|4 Bill Brad Chris
5 Vincent 1|2|5 Bill Brad Vincent
6 Annabel 6 Annabel
7 Catherine 6|7 Annabel Catherine
8 Harry 6|8 Annabel Harry
9 Michael 6I9 Annabel Michael
Рис. 11.10 В столбцах Level содержатся значения соответствующих уровней иерархии
В этом примере мы использовали три столбца - такова максимальная глуби-
на нашей иерархии. В реальных примерах вам придется рассчитывать макси-
ГЛАВА11 Работа с иерархиями 389
мальную вложенность и создавать соответствующее количество вычисляемых
столбцов. Таким образом, несмотря на то что количество уровней в иерархии
типа родитель/потомок не является фиксированным, чтобы реализовать по-
добную иерархию в модели данных, придется определиться заранее с ее мак-
симальной глубиной. Всегда лучше добавить пару лишних уровней для буду-
щего расширения иерархии без необходимости обновлять модель данных.
Теперь нам нужно преобразовать набор столбцов в иерархию. Также, по-
скольку остальные поля не несут никакой полезной информации, мы скро-
ем их от клиентских приложений. На этом этапе мы можем построить отчет
с иерархией, вынесенной на строки, и суммами в области значений, но резуль-
тат нас не удовлетворит. На рис. 11.11 показан вывод отчета в виде матрицы.
С этим отчетом есть пара проблем:
под строкой с именем Аннабель есть две пустые строки с указанием сум-
мы по самой Аннабель;
под Кэтрин есть пустая строка также со значением по Кэтрин. То же самое
можно сказать и о других строках.
В иерархии всегда показываются три уровня вложенности, даже для путей
с максимальной глубиной, равной двум, как в случае с Гарри (Harry), у которого
нет детей.
Levell Amount
Annabel 3200 600 600
Catherine 1200 1200
Harry 800 800
Michael 600 600
Bill 1600
Brad 1300 400
Chris 400
Vincent 500
Julie 300 300 Рис. 11.11 Иерархия выглядит не так,
Total 4800 как мы ожидали, - в ней слишком много строк
Эти проблемы связаны с визуализацией результатов. В остальном цифры
считаются правильно, поскольку под строкой с Аннабель находятся все ее до-
черние элементы. Важным аспектом этого решения было то, что нам удалось
имитировать связь таблицы самой с собой (также известную как рекурсивную
связь (recursive relationship)), используя функцию PATH для создания вычис-
390 ГЛАВА 11 Работа с иерархиями
ляемого столбца. Остальные сложности касались отображения результатов, но
мы, по крайней мере, продвинулись к правильному решению.
Следующей нашей задачей будет удаление пустых значений. Например, во
второй строке вывода значение 600 должно принадлежать Аннабель, а не пус-
той ячейке. Мы можем решить эту проблему, исправив формулу для уровней.
Избавимся от пустых значений путем вывода элемента предыдущего уровня,
если достигли конца пути. Ниже представлен измененный код для меры Level2:
PC[Level2] =
IF ( PATHLENGTH ( Persons[FuUPath] ) >= 2;
LOOKUPVALUE(
Persons[Nane];
Persons[PersonKey]; PATHITEM ( Persons[FullPath]; 2; INTEGER )
);
Persons[Levell]
)
Формула для Levell в изменениях не нуждается, поскольку первый уровень
всегда существует. Формулу для Levels мы изменили, руководствуясь тем же
шаблоном, что и для Level2. С обновленными формулами таблица выглядит
так, как показано на рис. 11.12.
Person Key Name ▲ FullPath Levell Level 2 Level3
1 Bill 1 Bill Bill Bill
2 Brad 1|2 Bill Brad Brad
3 Julie 1|3 Bill Julie Julie
4 Chris 1|2|4 Bill Brad Chris
5 Vincent 1|2|5 Bill Brad Vincent
6 Annabel 6 Annabel Annabel Annabel
7 Catherine 6|7 Annabel Catherine Catherine
8 Harry 6|8 Annabel Harry Harry
9 Michael 6|9 Annabel Michael Michael
Рис. 11.12 С измененными формулами в столбцах Level
никогда не будет пустых значений
Как видите, пустые ячейки исчезли из отчета. Но строк в выводе все равно
слишком много. На рис. 11.13 выделены две строки.
Обратите внимание на вторую и третью строки отчета. В них мы видим одно
и то же значение из иерархии, а именно Аннабель. Мы могли бы смириться
с показом второй строки, поскольку она выводит значение для Аннабель, но
третья строка тут точно лишняя, она не дает нам никакой новой информации.
Как видите, решение о том, показывать или скрывать строку, базируется на
глубине вложенности элемента. Мы можем позволить пользователю опустить-
ся до второго уровня в иерархии Аннабель, но третий ему видеть ни к чему.
Мы вполне можем хранить в вычисляемом столбце длину пути для достиже-
ния строки. Длина пути покажет, что Аннабель является корневым элементом
ГЛАВА 11 Работа с иерархиями 391
иерархии. И правда, это ведь элемент первого уровня с путем, содержащим толь-
ко одно значение. Кэтрин, к примеру, является элементом второго уровня с дли-
ной пути, равной двум, поскольку она - дочь Аннабель. Кроме того, хоть это и не
так очевидно, но Кэтрин видима также и на первом уровне иерархии, ведь ее
значение агрегируется в родительском элементе, то есть в Аннабель. Имя Кэтрин
показывается по причине того, что в столбце Levell для нее указана Аннабель.
Levell Amount
Annabel 3200
Annabel 600
Annabel 600
Catherine 1200
Catherine 1200
Harry 800
Harry 800
Michael 600
Michael 600
Рис. 11.13 В новом отчете
пустых значений нет
Зная уровень каждого элемента в иерархии, мы можем определить, что этот
элемент должен быть видим, когда в отчете показана иерархия до этого уров-
ня. На более глубоких уровнях иерархии в отчете этот элемент должен быть
скрыт. Чтобы реализовать этот алгоритм, нам необходимы еще два параметра:
глубина вложенности каждого элемента. Это фиксированная величина
для каждой строки иерархии, а значит, она может храниться в вычисля-
емом столбце;
текущая глубина просмотра визуального элемента отчета. Эта величина
является динамической и зависит от текущего контекста фильтра. Она
должна быть выражена при помощи меры, поскольку ее значение зависит
от отчета, и для каждой строки отчета оно будет свое. Например, Аннабель
представляет элемент первого уровня, но она появляется в трех строках по
причине того, что текущая глубина отчета содержит три разных значения.
Глубину вложенности элемента рассчитать несложно. Добавим новый вы-
числяемый столбец с простой формулой к таблице Persons:
Persons[NodeDepth] = PATHLENGTH ( Persons[FullPath] )
Функция PATHLENGTH вычисляет количество элементов в строке, возвра-
щаемой функцией PATH. На рис. 11.14 показан отчет с новым вычисляемым
столбцом.
Глубину вложенности вычислить было весьма просто. Сложнее определить
глубину просмотра отчета, поскольку это необходимо делать в мере. При этом
сама логика расчета будет несложной - похожую технику мы уже использова-
ли при работе с обычными иерархиями. В мере будет использована функция
ISINSCOPE для определения того, какие столбцы в иерархии входят в фильтр.
392 ГЛАВА 11 Работа с иерархиями
PersonKey Name ▲ FullPath Levell Level2 Level 3 NodeDepth
1 Bill 1 Bill Bill Bill 1
2 Brad 1|2 Bill Brad Brad 2
3 Julie 1|3 Bill Julie Julie 2
4 Chris 1|2|4 Bill Brad Chris 3
5 Vincent 1|2|5 Bill Brad Vincent 3
6 Annabel 6 Annabel Annabel Annabel 1
7 Catherine 6|7 Annabel Catherine Catherine 2
8 Harry 6|8 Annabel Harry Harry 2
9 Michael 6|9 Annabel Michael Michael 2
Рис. 11.14 В столбце NodeDepth хранится величина уровня вложенности элемента
Кроме того, в этой формуле мы воспользуемся тем, что значения типа Boo-
lean могут легко конвертироваться в целочисленные величины, где TRUE равно
1, a FALSE-0:
BrowseDepth :=
ISINSCOPE ( Persons[Levell] ) +
ISINSCOPE ( Persons[Level2] ) +
ISINSCOPE ( Persons[Level3] )
Таким образом, если в фильтр включен только Level 1, значением меры
BrowseDepth будет 1. Если столбцы Levell и Level2 отфильтрованы, a Level3 - нет,
вернется 2, и т. д. На рис. 11.15 представлено вычисление меры BrowseDepth.
Levell Amount BrowseDepth
Annabel 3200 1
Annabel 600 2
Annabel 600 3
Catherine 1200 2
Catherine 1200 3
Harry 800 2
Harry 800 3
Michael 600 2 Рис. 11.15 В мере BrowseDepth
Michael 600 3 содержится глубина просмотра отчета
Мы постепенно приближаемся к окончательному решению нашего сцена-
рия. Последнее, что нам нужно знать, - это то, что по умолчанию в отчете будут
скрыты строки, в которых каждая вычисленная мера возвращает пустое значе-
ние. Мы воспользуемся этой особенностью для скрытия нежелательных строк.
Преобразуя значение меры Amount в BLANK для строк, которые мы не хотим
видеть в отчете, можно скрыть их в матрице. Таким образом, в своем решении
мы будем использовать:
ГЛАВА 11 Работа с иерархиями 393
уровень вложенности каждого элемента, хранящийся в вычисляемом
столбце NodeDepth;
глубину просмотра текущей ячейки в отчете, выраженную в мере Brow-
seDepth;
скрытие нежелательных строк путем установки в них пустых значений.
Самое время объединить всю эту информацию в одной мере, как показано
ниже:
PC Amount :=
IF (
MAX (Persons[NodeDepth]) < [BrowseDepth];
BLANK ();
SUM(Sales[Amount])
)
Чтобы понять, как работает эта мера, взгляните на отчет, показанный на
рис. 11.16. В нем есть все необходимое, чтобы уловить суть этой формулы.
Levell Amount BrowseDepth MaxNodeDepth PC Amount
Annabel 3200 1 2 3,200
Annabel 600 2 1
Annabel 600 3 1
Catherine 1200 2 2 1,200
Catherine 1200 3 2
Harry 800 2 2 800
Harry 800 3 2
Michael 600 2 2 600
Michael 600 3 2
Bill 1600 1 3 1,600
Bill 2 1
Bill 3 1
Рис. 11.16 В отчете показаны все промежуточные величины,
использующиеся в формуле
Если вы посмотрите на первую строку с Аннабель, то увидите, что Brow-
seDepth здесь равен 1, поскольку это корневой элемент иерархии. При этом
в столбце MaxNodeDepth, определенном как МАХ ( Persons[NodeDepth] ), указа-
но значение 2, сообщая о том, что для текущего элемента должны быть по-
казаны данные не только на первом уровне, но и на втором. Таким образом,
для текущего элемента будет показана также информация о детях, а значит,
он должен быть видимым. Во второй строке по Аннабель в столбцах Brow-
seDepth и MaxNodeDepth указаны 2 и 1 соответственно. Дело в том, что контекст
фильтра отбирает все строки, где в Levell и Level2 указано значение Аннабель,
а в иерархии есть только одна такая строка, соответствующая самой Аннабель.
394 ГЛАВА 11 Работа с иерархиями
Но у Аннабель поле NodeDepth равно 1, а поскольку глубина просмотра отчета
здесь у нас равна 2, необходимо скрыть эту строку. Так что в мере PC Amount
для нее мы вернем пустое значение.
Будет полезно, если для остальных строк вы проведете подобный анализ са-
мостоятельно. Так вы лучше поймете, как на самом деле работает эта формула.
И хотя вы можете просто вернуться к этой главе и скопировать формулу, когда
у вас появится такая необходимость, будет лучше, если вы разберетесь, как она
работает. Это даст вам возможность понять, как контекст фильтра взаимодей-
ствует с различными частями формулы.
И в заключение нам нужно убрать из отчета все вспомогательные столбцы,
оставив только PC Amount. Теперь отчет приобрел тот вид, который мы и хоте-
ли получить, что видно по рис. 11.17.
Levell PC Amount
Annabel 3,200
Catherine 1,200
Harry 800
Michael 600
Bill 1,600
Brad 1,300
Chris 400
Vincent 500
Julie . _ Рис. 11.17 Оставив в отчете 300 единственную меру, мы тем самым
Total 4,800 скрыли нежелательные строки
Главным недостатком примененного нами подхода является то, что такой
же шаблон необходимо будет использовать для каждой меры, которую поль-
зователь захочет добавить в отчет с готовой иерархией. Если мера для нежела-
тельных строк не будет возвращать пустые значения, строки просто не скро-
ются и будут портить весь шаблон отчета.
На данном этапе нас удовлетворяет полученный результат. Но есть небольшая
проблема. Если взглянуть на итоговую строку по Аннабель, мы увидим значение
3200. Сумма значений по ее детям составляет 2600. Потерялось еще одно значе-
ние 600, принадлежащее самой Аннабель. Кому-то такой визуализации отчета
будет достаточно, поскольку значение по родителю легко получить путем вы-
читания суммы по детям из итога по самому элементу. Но если сравнить отчет
с нашей изначальной целью, несложно заметить, что необходимо разместить
родительский элемент и в качестве дочернего для себя самого. На рис. 11.18
представлено сравнение полученного нами результата и желательного.
Сейчас мы уже хорошо представляем себе технику дальнейших действий.
Чтобы показать данные по Аннабель, необходимо найти условие, которое по-
зволит нам идентифицировать этот элемент как видимый. Но в подобном слу-
чае условие будет не таким простым. Нам нужно показать элементы, не яв-
ляющиеся конечными (то есть имеющие потомков) и при этом обладающие
собственными значениями. При этом мы должны показать эти элементы на
ГЛАВА 11 Работа с иерархиями 395
вложенном уровне. Остальные элементы (являющиеся конечными или не об-
ладающие собственными значениями) должны оставаться скрытыми.
Levell PC Amount FinalFormula
Annabel Annabel 3,200 3,200 600
Catherine 1,200 1,200
Harry 800 800
Michael 600 600
Bill 1,600 1,600
Brad Brad 1,300 1,300 400
Chris 400 400
Vincent Julie 500 300 500 Рис. 11.18 Требуемый результат 300 не достигнут, нужно показать еще
Total 4,800 4,800 несколько строк
Первое, что нам нужно сделать, - это создать вычисляемый столбец с отобра-
жением того, является ли элемент конечным, то есть не имеющим потомков.
В DAX написать такое условие не составит труда: конечными являются эле-
менты, не являющиеся родителем ни для одного из других элементов. Чтобы
проверить это, нам достаточно посчитать элементы, для которых исследуемый
узел будет родительским. Если мы получим нулевое значение, значит, элемент
расположен на конечном уровне. Следующий код вполне подойдет для опи-
санной процедуры:
Persons[IsLeaf] =
VAR CurrentPersonKey = Persons[PersonKey]
VAR PersonsAtParentLevel =
CALCULATE (
COUNTROWS ( Persons );
ALL ( Persons );
Persons[ParentKey] = CurrentPersonKey
)
VAR Result = ( PersonsAtParentLevel = 0 )
RETURN Result
На рис. 11.19 показан вычисляемый столбец IsLeaf, добавленный к модели
данных.
Теперь, когда мы определили, какие элементы нашей иерархии являются
конечными, пришло время написать итоговую формулу для работы с нашей
иерархией:
FinalFormula =
VAR TooDeep = [MaxNodeDepth] + 1 < [BrowseDepth]
VAR AdditionalLevel = [MaxNodeDepth] + 1 = [BrowseDepth]
VAR Amount =
SUM ( Sales[Amount] )
396 ГЛАВА 11 Работа с иерархиями
VAR HasData =
NOT ISBLANK ( Anount )
VAR Leaf =
SELECTEDVALUE (
Persons[IsLeaf];
FALSE
)
VAR Result =
IF (
NOT TooDeep;
IF (
AdditionalLevel;
IF (
NOT Leaf && HasData;
Anount
);
Anount
)
)
RETURN
Result
PersonKey Name ParentKey FullPath Levell Level2 Level3 NodeDepth IsLeaf
1 Bill 1 Bill Bill Bill 1 False
2 Brad 1 1|2 Bill Brad Brad 2 False
3 Julie 1 1|3 Bill Julie Julie 2 True
4 Chris 2 112|4 Bill Brad Chris 3 True
5 Vincent 2 1|2|5 Bill Brad Vincent 3 True
6 Annabel 6 Annabel Annabel Annabel 1 False
7 Catherine 6 6|7 Annabel Catherine Catherine 2 True
8 Harry 6 6|8 Annabel Harry Harry 2 True
9 Michael 6 6I9 Annabel Michael Michael 2 True
Рис. 11.19 В столбце IsLeaf для конечных элементов указано значение True
Использование переменных позволило сделать формулу более легкой для
восприятия. Приведем некоторые замечания по работе этого кода:
в переменной TooDeep проверяется, превышает ли глубина просмотра
отчета максимальный уровень вложенности элемента, увеличенный на
единицу. Таким образом выполняется проверка на предельную глубину
просмотра;
переменная AdditionalLevel содержит результат проверки на то, что теку-
щая глубина просмотра является дополнительным уровнем для элемен-
тов, обладающих собственным значением и не являющихся при этом ко-
нечными;
в переменной HasData мы проверяем, есть ли у элемента собственное
значение;
ГЛАВА 11 Работа с иерархиями 397
переменная Leaf проверяет, является ли элемент конечным;
переменная Result хранит итоговый результат формулы, что облегчает
вывод промежуточных значений на этапе написания кода.
Оставшаяся часть кода в основном состоит из вложенных условий IF, служа-
щих для поддержания логики формулы.
Понятно, что если бы в моделях данных была встроенная возможность ра-
боты с иерархиями типа родитель/потомок, нам не пришлось бы проделывать
всю эту работу. В конце концов, формула получилась не самая легкая для вос-
приятия, к тому же она требует хорошего понимания контекстов вычисления
и моделирования данных в целом.
Важно Если уровень совместимости (compatibility level) вашей модели данных равен
1400, вы можете воспользоваться специальным свойством Hide Members. Это свойство по-
зволяет автоматически скрывать пустые значения. По состоянию на апрель 2019 года это
свойство недоступно в Power Bl и Power Pivot. Подробное описание того, как использовать
это свойство в табличных моделях данных, можно найти по адресу: https://docs.rnicrosoft.
com/en-us/sql/analysis-services/what-s-new-in-sql-server-analysis-services-2017?view=sql-
server-2017. Если используемый вами инструмент поддерживает свойство Hide Members,
мы настоятельно рекомендуем воспользоваться им, вместо того чтобы писать сложный код
на DAX, показанный выше, для скрытия уровней в несбалансированной иерархии.
\________________________________________________________________________________)
Заключение
В данной главе вы узнали, как проводить вычисления в модели данных при на-
личии иерархий. Как обычно, пройдемся по основным концепциям, которые
здесь были предложены:
иерархии не являются составной частью языка DAX. Они могут быть по-
строены в модели данных, но DAX не умеет ссылаться на иерархии и ис-
пользовать их в своих вычислениях;
чтобы определить уровень иерархии, пригодится специальная функция
ISINSCOPE. Эта функция не определяет уровень просмотра. Вместо этого
она идентифицирует лишь наличие фильтра по столбцу;
для расчета долей внутри родительского элемента необходимо уметь
анализировать текущий уровень иерархии и подбирать подходящий на-
бор фильтров для воссоздания фильтра родительского элемента;
с иерархиями типа родитель/потомок в DAX можно работать, используя
предопределенную функцию PATH и создавая набор вспомогательных
столбцов - по одному для каждого уровня иерархии;
унарные операторы (unary operators), часто использующиеся в иерархиях
типа родитель/потомок, могут стать настоящим испытанием. С их упро-
щенными разновидностями (только +/-) можно работать путем написа-
ния довольно сложного кода на DAX. Решение действительно комплекс-
ных сценариев потребует написания еще более сложного кода на DAX, но
эта тема выходит за границы данной главы.
ГЛАВА 12
Работа с таблицами
Таблицы играют важную роль в формулах DAX. В предыдущих главах вы на-
учились осуществлять итерации по таблицам, создавать вычисляемые таблицы
и выполнять некоторые вычисления, требующие навыков работы с таблицами.
Более того, аргументы фильтра функции CALCULATE также представляют со-
бой таблицы, и при написании сложных выражений умение строить правиль-
ные таблицы фильтров является немаловажным фактором. DAX предлагает
богатый набор функций для работы с таблицами. В данной главе мы познако-
мимся с теми из них, которые предназначены для создания таблиц и манипу-
лирования ими.
Описание большинства новых функций мы будем сопровождать примера-
ми, которые будут полезны как в работе с самими таблицами, так и в качестве
пособия по написанию сложных мер.
Функция CALCULATETABLE
Первой функцией для работы с таблицами, с которой мы познакомимся, будет
CALCULATETABLE. Да, мы уже не раз использовали ее в данной книге. Здесь
же мы дадим более полное описание работы функции и поделимся советами
о том, когда ее стоит использовать.
Функция CALCULATETABLE работает точно так же, как и ее аналог CALCU-
LATE, за исключением того, что ее результатом является таблица, тогда как на
выходе CALCULATE всегда будет скалярная величина, будь то целое число или
строка. К примеру, если вам необходимо создать таблицу, в которой будут со-
держаться только красные товары, вы можете сделать это так:
CALCULATETABLE (
'Product';
'Product'[Color] = "Red"
)
Часто задаваемым вопросом в этом случае является следующий: а какая раз-
ница между функциями CALCULATETABLE и FILTER? Ведь предыдущее выра-
жение можно записать и так:
FILTER (
'Product';
'Product'[Color] = "Red"
)
ГЛАВА 12 Работа с таблицами 399
Несмотря на полную идентичность записи, за исключением названия са-
мой функции, семантически две эти функции очень сильно отличаются. При
выполнении функции CALCULATETABLE сначала происходит изменение кон-
текста фильтра, а затем вычисляется выражение. Функция FILTER, напротив,
осуществляет итерации по таблице, переданной в качестве первого параметра,
извлекая при этом строки, удовлетворяющие условию. Иными словами, функ-
ция FILTER не изменяет контекст фильтра.
Различия между этими функциями можно увидеть на следующем примере:
Red Products CALCULATETABLE =
CALCULATETABLE (
ADDCOLUMNS (
VALUES ( 'Product'[Color] );
"Nun of Products"; COUNTROWS ( 'Product' )
);
'Product'[Color] = "Red"
)
Результат выполнения данного кода показан на рис. 12.1.
Color Num of Products Рис 12л в базе данных Contoso
Red 99 находится 99 красных товаров
При использовании функции CALCULATETABLE контекст фильтра, в котором
выполняются функции ADDCOLUMNS и COUNTROWS, отфильтрован по красно-
му цвету. Соответственно, в результате мы получили одну строку, содержащую
красный цвет с указанием количества товаров этого цвета. Иными словами,
функция COUNTROWS подсчитывает уже только красные товары, не требуя пре-
образования контекста от строки, сгенерированной функцией VALUES.
Если заменить функцию CALCULATETABLE на FILTER, результат будет дру-
гим. Взгляните на следующий пример:
Red Products FILTER external =
FILTER (
ADDCOLUMNS (
VALUES ( 'Product'[Color] );
"Nun of Products"; COUNTROWS ( 'Product' )
);
'Product'[Color] = "Red"
)
На этот раз результат будет уже не 99. Вместо этого мы увидим общее коли-
чество товаров в модели данных, как показано на рис. 12.2.
Color Num of Products
Red 2517
Рис. 12.2 Несмотря на единственную строку с указанием красного цвета,
значение в столбце Num of Products соответствует общему количеству товаров
400 ГЛАВА 12 Работа с таблицами
В итоговой таблице также присутствует только одна строка с указанием крас-
ного цвета, но теперь количество товаров равно 2517, а не 99, что соответствует
общему количеству товаров в базе. Причина в том, что функция FILTER не меня-
ет контекст фильтра. Более того, она вычисляется после функции ADDCOLUMNS.
Следовательно, функция ADDCOLUMNS проходит по всем товарам, и COUN-
TROWS подсчитывает их общее количество из-за отсутствия преобразования
контекста. И только затем функция FILTER выбирает строку с красным цветом.
Если вы хотите использовать функцию FILTER вместо CALCULATETABLE, вы-
ражение должно быть написано иначе, чтобы функция CALCULATE иницииро-
вала преобразование контекста:
Red Products FILTER internal =
ADDCOLUMNS (
FILTER (
VALUES ( 'Product'[Color] );
'Product'[Color] = "Red"
);
"Nun of Products"; CALCULATE ( COUNTROWS ( 'Product' ) )
)
Теперь результат вновь будет 99. Чтобы получить тот же итог, что и при вы-
зове функции CALCULATETABLE, нам пришлось изменить порядок выполне-
ния формулы. Здесь функция FILTER запускается первой, после чего подсчет
строк полагается на операцию преобразования контекста, чтобы заставить
контекст строки от функции ADDCOLUMNS стать контекстом фильтра для
функции COUNTROWS.
Функция CALCULATETABLE в процессе выполнения меняет контекст фильт-
ра. Это очень мощная функция, поскольку она распространяет свое действие на
множество других функций в выражении DAX. Но есть у этой функции и свои
ограничения, связанные с типом фильтра, который она создает. К примеру,
функция CALCULATETABLE способна накладывать фильтры только на столбцы,
принадлежащие модели данных. Если вам нужно будет получить список по-
купателей с суммой продаж, превышающей один миллион, функция CALCU-
LATETABLE вам не подойдет, поскольку Sales Amount является мерой. Функция
CALCULATETABLE не может устанавливать фильтр на меру, тогда как FILTER -
может. Это показано в следующем выражении - здесь заменить функцию FIL-
TER на CALCULATETABLE нельзя, будет синтаксическая ошибка:
Large Customers =
FILTER (
Customer;
[Sales Amount] > 1000000
)
Функция CALCULATETABLE, как и CALCULATE, в процессе выполнения осу-
ществляет преобразование контекста и может быть дополнена всеми моди-
фикаторами, применимыми к функции CALCULATE: ALL, USERELATIONSHIPS,
CROSSFILTER и многими другими. Это делает функцию CALCULATETABLE более
мощной по сравнению с FILTER. Но это отнюдь не означает, что вы должны
перестать использовать функцию FILTER и полностью переходить на CALCULA-
ГЛАВА12 Работа с таблицами 401
TETABLE. У каждой из этих функций есть свои достоинства и недостатки, и вы-
бор должен быть осознанным.
Как правило, функцию CALCULATETABLE следует использовать, когда вам
достаточно будет ограничиться применением фильтров к столбцам в модели
данных или когда необходимо воспользоваться ее дополнительной функцио-
нальностью вроде преобразования контекста или использования модификато-
ров контекста фильтра.
Манипулирование таблицами
DAX предлагает сразу несколько функций для манипулирования таблицами.
Эти функции могут быть использованы для создания вычисляемых таблиц,
таблиц для осуществления итераций или применения их результатов в ка-
честве аргументов фильтра функции CALCULATE. В данном разделе мы пого-
ворим обо всех этих функциях и рассмотрим полезные примеры. Существуют
также и другие табличные функции, которые главным образом применяются
в запросах. О них мы расскажем в главе 13.
Функция ADDCOLUMNS
Функция ADDCOLUMNS представляет собой итератор, возвращающий все
строки и столбцы табличного выражения из первого аргумента и добавляю-
щий к итоговому результату вновь созданные столбцы. Например, на выходе
следующего выражения будет таблица со всеми цветами товаров, присутству-
ющими в модели данных, и суммой продаж по каждому из них:
ColorsWithSales =
ADDCOLUMNS (
VALUES ( 'Product'[Color] );
"Sales Amount"; [Sales Amount]
)
Результат показан на рис. 12.3.
Будучи итератором, функция ADDCOLUMNS вычисляет значения столбцов
в контексте строки. В нашем примере мы получили суммы продаж по това-
рам конкретных цветов, поскольку в столбце Sales Amount используется мера.
Следовательно, меру Sales Amount тут же окружает функция CALCULATE, ини-
циируя преобразование контекста. Если вместо меры используется обычное
выражение, чаще всего функция CALCULATE указывается явно как раз для вы-
полнения преобразования контекста.
Функция ADDCOLUMNS нередко используется совместно с функцией FILTER
для наложения фильтра на временный вычисляемый столбец. Например, если
вам необходимо получить список товаров с суммой продаж, равной или пре-
вышающей 150 000, вы можете воспользоваться следующей конструкцией:
HighSalesProducts =
VAR ProductsWithSales =
402 ГЛАВА 12 Работа с таблицами
ADDCOLUMNS (
VALUES ( 'Product'[Product Name] );
"Product Sales"; [Sales Anount]
)
VAR Result =
FILTER (
ProductsWithSales;
[Product Sales] >= 150000
)
RETURN Result
Color Sales Amount
Silver 6,798,560.86
Blue 2,435,444.62
White 5,829,599.91
Red 1,110,102.10
Black 5,860,066.14
Green 1,403,184.38
Orange 857,320.28
Pink 828,638.54
Yellow 89,715.56
Purple 5,973.84
Brown 1,029,508.95
Grey 3,509,138.09
Gold 361,496.01
Azure 97,389.89
Silver Grey 371,908.92 Рис. 12.3 Результат содержит все цвета товаров
Transparent 3,295.89 и суммы продаж по ним
Результат показан на рис. 12.4.
Product Name
Product Sales
Adventure Works 26" 720р LCD HDTV М140 Silver 1,303,983.46
SV 16xDVD M360 Black 364,714.41
Proseware Projector 1080p LCD86 Silver 160,627.05
Contoso Projector 1080p X980 White 257,154.75
A. Datum SLR Camera X137 Grey 725,840.28
Fabrikam Independent filmmaker 1/3" 8.5mm X200 White 165,594.00
Contoso Telephoto Conversion Lens X400 Silver 683,779.95
NT Washer & Dryer 27in L2700 Blue 151,427.53
Contoso Washer 8t Dryer 21 in E210 Green 151,265.40
Contoso Washer & Dryer 21 in E210 Pink 182,094.12
Рис. 12.4 В результирующий набор включены названия товаров и суммы продаж
Того же результата можно добиться самыми разными способами, даже не
используя функцию ADDCOLUMNS. Код, приведенный ниже, гораздо проще,
ГЛАВА 12 Работа с таблицами 403
чем предыдущий, хотя здесь в итоговый результат мы не включили столбец
Product Sales:
FILTER (
VALUES ( 'Product'[Product Name] );
[Sales Amount] >= 150000
)
Функция ADDCOLUMNS бывает полезна при вычислении сразу нескольких
столбцов или если необходимо произвести какие-то дополнительные вычис-
ления со столбцами. Представьте, что вам необходимо выделить набор това-
ров, общая сумма продаж по которым составляет 15 % от всех продаж компа-
нии. Это не самая простая задача, и здесь нам потребуется целый алгоритм
действий.
1. Вычислить сумму продаж по каждому товару.
2. Рассчитать сумму продаж нарастающим итогом, агрегируя каждый то-
вар с теми товарами, сумма продаж по которым превышает текущую.
3. Перевести нарастающие итоги в проценты относительно общего итога
по продажам.
4. Вернуть только те товары, процент по которым меньше или равен 15.
Решить эту задачу за один шаг было бы довольно сложно, но если разбить ее
на четыре этапа, все станет гораздо проще:
Top Products =
VAR TotalSales = [Sales Amount]
VAR ProdsWithSales =
ADDCOLUMNS (
VALUES ( 'Product'[Product Name] );
"Productsales"; [Sales Amount]
)
VAR ProdsWithRT =
ADDCOLUMNS (
ProdsWithSales;
"RunningTotal";
VAR SalesOfCurrentProduct = [Productsales]
RETURN
SUMX (
FILTER (
ProdsWithSales;
[Productsales] >= SalesOfCurrentProduct
);
[Productsales]
)
)
VAR ToplSPercent =
FILTER (
ProdsWithRT;
[RunningTotal] / TotalSales <= 0.15
)
RETURN ToplSPercent
404 ГЛАВА 12 Работа с таблицами
Результат можно видеть на рис. 12.5.
Product Name ProductSales RunningTotal
Adventure Works 26" 720p LCD HDTV M140 Silver 1,303,983.46 1,303,983.46
SV 16xDVD M360 Black 364,714.41 3,078,318.10
Fabrikam Laptop19 M9000 Black 144,133.85 4,290,614.80
Proseware Projector 1080p LCD86 Silver 160,627.05 3,843,788.02
Contoso Projector 1080p X980 White 257,154.75 3,335,472.85
A. Datum SLR Camera X137 Grey 725,840.28 2,029,823.74
Fabrikam Independent filmmaker 1/3" 8.5mm X200 White 165,594.00 3,683,160.97
Contoso Telephoto Conversion Lens X400 Silver 683,779.95 2,713,603.69
NT Washer & Dryer 27in L2700 Blue 151,427.53 3,995,215.55
NT Washer & Dryer 27in L2700 Green 137,605.92 4,428,220.72
Contoso Washer & Dryer 21 in E210 Green 151,265.40 4,146,480.95
Contoso Washer & Dryer 21 in E210 Pink 182,094.12 3,517,566.97
Litware Refrigerator 24.7CuFt X980 White 135,039.58 4,563,260.30
Рис. 12.5 Самые популярные товары, суммарные продажи по которым
дали компании 15 % общих продаж
В этом примере мы реализовали решение при помощи вычисляемой таб-
лицы, но существуют и другие варианты. Например, вы могли бы пройти по
табличной переменной Topl5Percent при помощи итератора SUMX, чтобы соз-
дать меру, вычисляющую сумму продаж по этим товарам.
Как и применительно к большинству функций в DAX, вы можете рассматри-
вать функцию ADDCOLUMNS в качестве одного из кирпичиков. Только научив-
шись сочетать эти кирпичики при построении действительно сложных выра-
жений, вы сможете в полной мере овладеть языком DAX.
Функция SUMMARIZE
Функция SUMMARIZE является одной из наиболее часто используемых в языке
DAX. Эта функция сканирует таблицу, переданную ей в качестве первого аргу-
мента, и объединяет столбцы этой или связанных таблиц в одну или несколько
групп. Главным образом функция SUMMARIZE используется для получения ис-
комой комбинации значений вместо извлечения полного списка.
Для примера подсчитаем количество уникальных цветов товаров, по кото-
рым были продажи. На основании полученных данных мы построим отчет,
в котором выведем общее количество цветов и количество цветов, по которым
была как минимум одна продажа. Следующие две меры обеспечат нам желае-
мый результат:
Num of colors :=
COUNTROWS (
VALUES ( 'Product'[Color] )
)
Num of colors sold :=
COUNTROWS (
ГЛАВА 12 Работа с таблицами 405
SUMMARIZE ( Sales; 'Product'[Color] )
Результат вычисления этой меры в разрезе брендов можно видеть на
рис. 12.6.
Calendar Year Month
February 2007
Brand
Num of colors Num of colors sold
A. Datum 10 7
Adventure Works 7 6
Contoso 15 9
Fabrikam 12 7
Litware 12 7
Northwind Traders 9 2
Proseware 7 6
Southridge Video 10 5
Tailspin Toys 11 3
The Phone Company 6 4
Wide World Importers 12 6
Total 16 13
Рис. 12.6 В мере Num of colors sold используется функция SUMMARIZE
для подсчета количества цветов, по которым были продажи
В данном случае мы использовали функцию SUMMARIZE для группировки
таблицы продаж по столбцу Product[Color], после чего подсчитали количество
строк в результирующей таблице. Поскольку функция SUMMARIZE выполня-
ет операцию группировки по заданной таблице, в итоговый результат будут
включены только цвета, на которые есть ссылки в таблице Sales. В то же время
выражение VALUES ( Product [Color] ) возвращает список уникальных цветов
в модели данных вне зависимости от того, были по ним продажи или нет.
Используя функцию SUMMARIZE, вы можете группировать таблицу по лю-
бому количеству столбцов с учетом того, что они доступны из нее по связям
«многие к одному» или «один к одному». Например, чтобы рассчитать средне-
дневные продажи по товарам, можно написать следующее выражение:
AvgDailyQty :=
VAR ProductsDatesWithSales =
SUMMARIZE (
Sales;
'Product'[Product Name];
'Date'[Date]
)
VAR Result =
AVERAGEX (
406 ГЛАВА 12 Работа с таблицами
ProductsDatesWithSales;
CALCULATE (
SUM ( Sales[Quantity] )
)
)
RETURN Result
Результат вычисления этой меры показан на рис. 12.7.
Brand CY 2007 CY 2008 CY 2009 Total
A. Datum 17.68 13.76 15.78 15.93
Adventure Works 24.07 13.77 17.85 18.93
Contoso 19.88 20.41 31.37 23.99
Fabrikam 12.02 14.13 15.69 13.91
Litware 9.67 12.99 18.50 13.99
Northwind Traders 24.24 12.84 13.87 16.99
Proseware 10.28 13.38 16.70 13.41
Southridge Video 28.07 17.28 22.86 23.56
Tailspin Toys 12.33 20.24 35.85 22.44
The Phone Company 11.32 12.77 13.13 12.37
Wide World Importers 11.79 15.19 16.70 14.78
Total 17.20 16.37 22.79 18.75
Рис. 12.7 В отчете показаны среднедневные продажи товаров
по годам и брендам
Здесь мы использовали функцию SUMMARIZE для сканирования таблицы
Sales и ее группировки по наименованиям товаров и датам продажи. В резуль-
тирующую таблицу при этом попадут только строки с днями, когда были про-
изведены продажи. Затем функция AVERAGEX проходит по временной табли-
це, возвращенной функцией SUMMARIZE, и подсчитывает средние значения.
Если по конкретному товару не было продажи в какой-то день, то он не будет
включен в таблицу.
Функция SUMMARIZE также может использоваться по подобию функции
ADDCOLUMNS, добавляя столбцы к результирующей таблице. Например, код
предыдущей меры может быть записан так:
AvgDallyQty :=
VAR ProductsDatesWlthSalesAndQuantlty =
SUMMARIZE (
Sales;
'Product'[Product Name];
'Date'[Date];
"Daily qty"; SUM ( Sales[Quantity] )
)
VAR Result =
AVERAGEX (
ProductsDatesWithSalesAndQuantity;
[Daily qty]
ГЛАВА 12 Работа с таблицами 407
)
RETURN Result
В этом случае функция SUMMARIZE возвращает временную таблицу с наиме-
нованием товара, датой продажи и вновь созданным столбцом Daily qty. Позже
именно по этому столбцу будет рассчитано среднее значение функцией AVERA-
GEX. При этом использовать функцию SUMMARIZE для создания дополни-
тельных столбцов во временной таблице крайне не рекомендуется, посколь-
ку в этом случае одновременно создаются контекст строки и контекст фильтра.
По этой причине итоговые результаты могут оказаться сложными для понима-
ния, если будет инициировано преобразование контекста либо путем включения
в выражение меры, либо за счет явного указания функции CALCULATE. Если вам
необходимо добавить столбцы в таблицу, созданную функцией SUMMARIZE, луч-
ше всего использовать связку функций ADDCOLUMNS и SUMMARIZE:
AvgDallyQty :=
VAR ProductsDatesWlthSales =
SUMMARIZE (
Sales;
'Product'[Product Name];
'Date'[Date]
)
VAR ProductsDatesWithSalesAndQuantIty =
ADDCOLUMNS (
ProductsDatesWithSales;
"Daily qty"; CALCULATE ( SUM ( Sales[Quantity] ) )
)
VAR Result =
AVERAGEX (
ProductsDatesWithSalesAndQuantity;
[Daily qty]
)
RETURN Result
Несмотря на то что код получился чуть более многословным, читать и пи-
сать его проще из-за наличия единственного контекста строки, на который
действует преобразование контекста. Этот контекст строки создается функ-
цией ADDCOLUMNS во время итераций по временной таблице, возвращенной
функцией SUMMARIZE. Примененный шаблон более прост для понимания
и в большинстве случаев будет работать быстрее.
У функции SUMMARIZE есть и другие необязательные параметры, которые
можно использовать. Они служат для подсчета промежуточных итогов и до-
бавления столбцов к результирующей таблице. Мы намеренно решили не оста-
навливаться на этих опциях, чтобы вы лучше могли уяснить наш главный по-
сыл: функция SUMMARIZE предназначена для группировки таблиц и не должна
использоваться для вычисления дополнительных столбцов. И хотя в чужих фор-
мулах вы зачастую можете встретить вариант использования функции SUM-
MARIZE с созданием дополнительных столбцов, всегда помните, что это да-
леко не самая лучшая практика, и в таких случаях всегда лучше пользоваться
связкой функций ADDCOLUMNS/SUMMARIZE.
408 ГЛАВА 12 Работа с таблицами
Функция CROSSJOIN
Функция CROSSJOIN производит перекрестное соединение двух таблиц, воз-
вращая декартово произведение (cartesian product) двух исходных таблиц. Ины-
ми словами, эта функция возвращает все возможные комбинации значений из
таблиц. Например, следующее выражение вернет все сочетания наименований
товаров и годов:
CROSSJOIN (
ALL ( 'Product'[Product Name] );
ALL ( 'Date'[Calendar Year] )
)
Если в модели данных содержится 1000 наименований товаров, а кален-
дарь насчитывает пять календарных лет, результирующая таблица вернет
5000 строк. Функция CROSSJOIN чаще используется в запросах, нежели в фор-
мулах мер. Но существуют сценарии, в которых применение этой функции
крайне оправдано по причине ее высокой производительности.
Рассмотрим случай использования условной функции OR применительно
к двум столбцам в аргументе фильтра функции CALCULATE. Поскольку в функ-
ции CALCULATE аргументы фильтра объединяются посредством операции пе-
ресечения (intersection), использование здесь функции OR нужно рассмотреть
более подробно. Вот один из вариантов применения функции CALCULATE для
фильтрации всех товаров, которые принадлежат категории Audio или выпол-
нены в черном (Black) цвете:
AudioOrBlackSales :=
VAR CategoriesColors =
SUMMARIZE (
'Product';
'Product'[Category];
'Product'[Color]
)
VAR AudioOrBlack =
FILTER (
CategoriesColors;
OR (
'Product'[Category] = "Audio";
'Product'[Color] = "Black"
)
)
VAR Result =
CALCULATE (
[Sales Amount];
AudioOrBlack
)
RETURN Result
Этот код выдает правильные результаты и выполняется оптимально с точки
зрения производительности. Функция SUMMARIZE сканирует таблицу Product,
в которой, как предполагается, не так много строк. А значит, ее фильтрация не
займет много времени.
ГЛАВА 12 Работа с таблицами 409
Но если нам необходимо будет фильтровать столбцы из разных таблиц, на-
пример цвета товаров и годы, ситуация изменится. Давайте немного услож-
ним предыдущий пример, взяв столбцы из двух разных таблиц, чтобы функ-
ция SUMMARIZE вынуждена была сканировать таблицу Sales:
AudioOr2007 Sales :=
VAR CategoriesYears =
SUMMARIZE (
Sales;
'Product'[Category];
'Date'[Calendar Year]
)
VAR Audio2007 =
FILTER (
CategoriesYears;
OR (
'Product'[Category] = "Audio";
'Date'[Calendar Year] = "CY 2007"
)
)
VAR Result =
CALCULATE (
[Sales Amount];
Audio2007
)
RETURN Result
Таблица Sales довольно объемная, в ней могут содержаться сотни миллио-
нов строк. Таким образом, сканирование этой таблицы на предмет извлечения
всех возможных комбинаций категорий товаров и годов будет очень затратной
операцией. В то же время результирующий фильтр должен оказаться не таким
большим - в нем будет содержаться всего несколько категорий и годов. Но для
его создания движку придется сканировать всю исходную таблицу продаж.
В таких случаях мы рекомендуем строить небольшую временную таблицу,
содержащую все комбинации категорий товаров и годов, а затем фильтровать
ее, как показано в коде ниже:
AudioOr2007 Sales :=
VAR CategoriesYears =
CROSSJOIN (
VALUES ( 'Product'[Category] );
VALUES ( 'Date'[Calendar Year] )
)
VAR Audio2007 =
FILTER (
CategoriesYears;
OR (
'Product'[Category] = "Audio";
'Date'[Calendar Year] = "CY 2007"
)
)
VAR Result =
410 ГЛАВА 12 Работа с таблицами
CALCULATE (
[Sales Amount];
Audio2007
)
RETURN Result
Полное перекрестное соединение из категорий товаров и годов будет содер-
жать несколько сотен строк, и вычисление этой меры займет гораздо меньше
времени, чем в предыдущем случае.
Функция CROSSJOIN применяется не только для ускорения выполнения рас-
четов. Иногда бывает нужно извлечь элементы из таблицы даже в случае от-
сутствия операций по ним. Например, используя функцию SUMMARIZE для
сканирования продаж по категориям товаров и странам, мы получим пере-
сечение только по тем элементам, по которым были продажи определенных
товаров. Это нормальное поведение функции SUMMARIZE, так что здесь нет
ничего удивительного. Но иногда отсутствие события бывает важнее его при-
сутствия. Допустим, перед вами стоит задача определить, по каким брендам не
было продаж в определенных регионах. В этом случае вам необходимо будет
писать сложное выражение с использованием функции CROSSJOIN, чтобы в ре-
зультирующий набор были включены все комбинации значений. В следующей
главе мы рассмотрим больше примеров с участием функции CROSSJOIN.
Функция UNION
UNION является функцией для работы со множествами, объединяющей две
таблицы. Возможность объединения содержимого двух таблиц может быть по-
лезной в самых разных обстоятельствах. Чаще эта функция используется при
работе с вычисляемыми таблицами и реже - с мерами. Например, в следующей
таблице будут объединены все страны из таблиц Customer и Store:
AllCountryRegions =
UNION (
ALL ( Customer[CountryRegion] );
ALL ( Store[CountryRegion] )
)
Результат этого выражения показан на рис. 12.8.
CountryRegion
Australia
Australia
United States
United States
Canada
Canada
Germany
Germany
United Kingdom
United Kingdom
Рис. 12.8 Функция UNION
не удаляет дубликаты
ГЛАВА 12 Работа с таблицами 411
Функция UNION не выполняет удаление дублирующихся элементов перед
возвращением результата. Таким образом, Австралия (Australia), которая
встречается в обеих таблицах, попала в итоговый набор дважды. Если необхо-
димо, вы можете воспользоваться функцией DISTINCT для удаления дублика-
тов.
На протяжении книги мы уже не раз применяли функцию DISTINCT для
получения списка уникальных значений из столбца, как он видим в текущем
контексте фильтра. Функция DISTINCT также может быть использована с таб-
личным выражением в качестве параметра, и в этом случае она вернет список
уникальных строк из таблицы. Следующий вариант идеально подойдет для
удаления возможных дубликатов по странам:
DistinctCountryRegions =
VAR CountryRegions =
UNION (
ALL ( Customer[CountryRegion] );
ALL ( Store[CountryRegion] )
)
VAR UniqueCountryRegions =
DISTINCT ( CountryRegions )
RETURN UniqueCountryRegions
Результат выполнения этого выражения показан на рис. 12.9.
Country Reg ion
Australia
United States
Canada
Germany
United Kingdom
France
the Netherlands
Greece
Switzerland
Рис. 12.9 Функция DISTINCT
удалила дубликаты из таблицы
Функция UNION поддерживает привязку данных в исходных таблицах, если
она выполнена в них одинаково. В предыдущей формуле в результирующей
таблице функции DISTINCT привязки данных нет, поскольку в первой таблице
у нас был столбец CustomerfCountryRegion], а во второй - Store[CountryRegion].
А раз привязка данных в исходных таблицах была разная, в результирующей
таблице будет создана абсолютно новая привязка, не относящаяся ни к одному
из существующих столбцов в модели данных. Поэтому в следующем выраже-
нии для всех стран будет выведена одна и та же сумма продажи:
DistinctCountryRegions =
VAR CountryRegions =
UNION (
ALL ( Custoner[CountryRegion] );
ALL ( Store[CountryRegion] )
412 ГЛАВА 12 Работа с таблицами
)
VAR UniqueCountryRegions =
DISTINCT ( CountryRegions )
VAR Result =
ADDCOLUMNS (
UniqueCountryRegions;
"Sales Amount"; [Sales Amount]
)
RETURN Result
Результирующая таблица показана на рис. 12.10.
CountryRegion Sales Amount
Australia 30,591,343.98
United States 30,591,343.98
Canada 30,591,343.98
Germany 30,591,343.98
United Kingdom 30,591,343.98
France 30,591,343.98
the Netherlands 30,591,343.98
Greece 30,591,343.98
Рис. 12.10 Столбец CountryRegion не представлен в модели данных,
а значит, он не будет фильтровать меру Sales Amount
Если в вычисляемой таблице должны быть выведены как сумма продаж, так
и количество магазинов, включая все страны покупателей и магазинов, для
фильтрации можно применить более сложное выражение:
DistinctCountryRegions =
VAR CountryRegions =
UNION (
ALL ( Customer[CountryRegion] );
ALL ( Store[CountryRegion] )
)
VAR UniqueCountryRegions =
DISTINCT ( CountryRegions )
VAR Result =
ADDCOLUMNS (
UniqueCountryRegions;
"Customer Sales Amount";
VAR CurrentRegion = [CountryRegion]
RETURN
CALCULATE (
[Sales Amount];
Customer[CountryRegion] = CurrentRegion
);
"Number of stores";
VAR CurrentRegion = [CountryRegion]
RETURN
CALCULATE (
ГЛАВА 12 Работа с таблицами 413
COUNTROWS ( Store );
Store[CountryReglon] = CurrentReglon
)
)
RETURN Result
Результат показан на рис. 12.11.
CountryRegion Customer Sales Amount Number of stores
Australia 7,638,059.94 3
United States 10,312,118.25 198
Canada 885,208.07 11
Germany 2,519,890.80 12
United Kingdom 3,621,032.16 15
France 1,109,665.43 8
the Netherlands 191,358.54 1
Greece 162,284.00 1
Switzerland 174,910.99 1
Ireland 130,595.28 1
Portugal 184,888.06 1
Spain 107,124.20 1
Italy 115,086.61 5
Рис. 12.11 При помощи сложного выражения с применением функции CALCULATE
нам удалось отфильтровать и магазины, и продажи
В этом примере функция CALCULATE применяет фильтр к таблицам продаж
и магазинов, используя значение из текущей итерации функции ADDCOLUMN
по табличному выражению, являющемуся результатом функции UNION. Еще
одним способом получить тот же результат является восстановление привязки
данных при помощи уже известной нам функции TREATAS, с которой мы по-
знакомились в главе 10. Код такого выражения может выглядеть следующим
образом:
DlstlnctCountryReglons =
VAR CountryReglons =
UNION (
ALL ( Customer[CountryReglon] );
ALL ( Store[CountryReglon] )
)
VAR UnlqueCountryReglons =
DISTINCT ( CountryReglons )
VAR Result =
ADDCOLUMNS (
UnlqueCountryReglons;
"Customer Sales Amount"; CALCULATE (
[Sales Amount];
TREATAS (
{ [CountryReglon] };
Customer[CountryReglon]
414 ГЛАВА 12 Работа с таблицами
)
);
"Number of stores"; CALCULATE (
COUNTROWS ( Store );
TREATAS (
{ [CountryRegion] };
Store[CountryRegion]
)
)
)
RETURN Result
Результат выполнения двух последних выражений будет одинаковым. От-
личаются они лишь техникой, использованной для распространения фильтра
с нового столбца на существующий в модели данных. Кроме того, в последнем
примере мы применили конструктор таблиц: при помощи фигурных скобок
мы преобразовали CountryRegion в таблицу, которую использовали в качестве
параметра функции TREATAS.
Поскольку функция UNION при объединении информации из разных столб-
цов утрачивает их исходную привязку данных, для ее восстановления удобно
применять функцию TREATAS. Также стоит отметить, что функция TREATAS
игнорирует значения, отсутствующие в целевом столбце.
Функция INTERSECT
Функция INTERSECT так же, как и UNION, предназначена для работы со мно-
жествами, но, в отличие от UNION, она не объединяет данные из двух таблиц,
а возвращает их пересечение, то есть только строки, присутствующие в обеих
таблицах. Эта функция была очень популярна до появления функции TREA-
TAS, поскольку она позволяет применять результат табличного выражения
в качестве фильтра к другим таблицам и столбцам. После появления TREATAS
функция INTERSECT стала использоваться гораздо реже.
Если вам, к примеру, необходимо получить список покупателей, приобре-
тавших товары как в 2007 году, так и в 2008-м, можно сделать это следующим
образом:
CustomersBuyingInTwoYears =
VAR Customers2007 =
CALCULATETABLE (
SUMMARIZE ( Sales; Customer[Customer Code] );
'Date'[Calendar Year] = "CY 2007"
)
VAR Customers2008 =
CALCULATETABLE (
SUMMARIZE ( Sales; Customer[Customer Code] );
'Date'[Calendar Year] = "CY 2008"
)
VAR Result =
INTERSECT ( Customers2007; Customers2008 )
RETURN Result
ГЛАВА 12 Работа с таблицами 415
Что касается привязки данных, функция INTERSECT сохраняет ее только для
первой таблицы. В предыдущем примере обе таблицы обладают одинаковой
привязкой данных. Если функции INTERSECT будут переданы таблицы с раз-
ной привязкой данных, в итоговой таблице сохранится привязка только для
первой из них. Например, страны, в которых есть и магазины, и покупатели,
можно получить следующим образом:
INTERSECT (
ALL ( Store[CountryRegion] );
ALL ( Customer[CountryRegion] )
)
В итоговой таблице сохранится привязка данных к столбцу Store[Coimtry-
Region]. Так что если попытаться получить сумму продаж по полученным го-
родам, то фильтр будет наложен по столбцу Store[CountryRegion], но не по Cus-
tomer[CountryRegion]:
SalesStoresInCustomersCountri.es =
VAR CountriesWithStoresAndCustomers =
INTERSECT (
ALL ( Store[CountryRegion] );
ALL ( Customer[CountryRegion] )
)
VAR Result =
ADDCOLUMNS (
CountriesWithStoresAndCustomers;
"StoresSales"; [Sales Amount]
)
RETURN Result
Результат можно видеть на рис. 12.12.
CountryRegion
United States
United Kingdom
France
Australia
Canada
Germany
Turkmenistan
Thailand
China
Kyrgyzstan
StoresSales
11,195,063.06
8,670,581.01
10,725,699.91
Рис. 12.12 Столбец StoresSales
заполнен только по городам магазинов,
а не по городам покупателей
В последнем примере столбец StoresSales содержит лишь продажи, относя-
щиеся к городу, где расположен магазин.
416 ГЛАВА 12 Работа с таблицами
Функция EXCEPT
EXCEPT - последняя функция для работы со множествами, которую мы вам
представим в данном разделе. Функция EXCEPT удаляет из первой таблицы
строки, присутствующие во второй. Таким образом, она, по сути, вычитает
одно множество из другого. Например, если вам необходимо получить список
покупателей, приобретавших товары в 2007 году, но не купивших ни одного
товара в 2008-м, это можно сделать так:
CustomersBuyingIn2007butNotIn2008 =
VAR Customers2007 =
CALCULATETABLE (
SUMMARIZE ( Sales; Customer[Customer Code] );
'Date'[Calendar Year] = "CY 2007"
)
VAR Customers2008 =
CALCULATETABLE (
SUMMARIZE ( Sales; Customer[Customer Code] );
'Date'[Calendar Year] = "CY 2008"
)
VAR Result =
EXCEPT ( Customers2007; Customers2008 )
RETURN Result
Первые несколько строк с кодами покупателей показаны на рис. 12.13.
Customer Code
11005
11006
11007
11008
Рис. 12.13 Ограниченный список покупателей,
приобретавших товары в 2007 году, но не в 2008-м
Как обычно, вы можете использовать это вычисление в качестве аргумента
фильтра функции CALCULATE, чтобы рассчитать суммы продаж по этим поку-
пателям. Функция EXCEPT часто используется при анализе потребительского
поведения. Например, с ее помощью вы можете проводить вычисления, свя-
занные с приходом, возвращением и уходом покупателей.
Существуют разные реализации одних и тех же вычислений в этой области,
каждая из которых ориентирована на конкретную модель данных. Представ-
ленная ниже реализация не всегда будет наиболее оптимальной, но она об-
ладает достаточной гибкостью и простотой для понимания. Для расчета коли-
чества покупателей, приобретавших товары в этом году, но не в прошлом, мы
вычтем множество клиентов с покупками в прошлом году из общего списка
покупателей:
SalesOfNewCustomers :=
VAR Currentcustomers =
VALUES ( Sales[CustomerKey] )
VAR CustomersLastYear =
ГЛАВА 12 Работа с таблицами 417
CALCULATETABLE (
VALUES ( Sales[CustomerKey] );
DATESINPERIOD ( 'Date'[Date]; MIN ( 'Date'[Date] ) - 1; -1; YEAR )
)
VAR CustomersNotlnLastYear =
EXCEPT ( Currentcustomers; CustomersLastYear )
VAR Result =
CALCULATE ( [Sales Amount]; CustomersNotlnLastYear )
RETURN Result
Реализация этого кода в виде меры будет работать с любыми фильтрами
и предоставляет широкие возможности для осуществления срезов по любым
столбцам. При этом стоит помнить, что данная реализация определения но-
вых покупателей будет не самой оптимальной в плане производительности.
Мы привели этот пример только для демонстрации работы функции EXCEPT.
Позже в данной главе мы рассмотрим более быстрый, хоть и более сложный
вариант этого вычисления.
Что касается привязки данных, то функция EXCEPT, как и INTERSECT, со-
храняет ее только для первой таблицы. Например, следующее выражение
вычисляет продажи покупателям, живущим в городах, где нет наших мага-
зинов:
SalesInCountriesWithNoStores :=
VAR CountriesWithActiveStores =
CALCULATETABLE (
SUMMARIZE ( Sales; Store[CountryRegion] );
ALL ( Sales )
)
VAR CountriesWithSales =
SUMMARIZE ( Sales; Customer[CountryRegion] )
VAR CountriesWithNoStores =
EXCEPT ( CountriesWithSales; CountriesWithActiveStores )
VAR Result =
CALCULATE (
[Sales Amount];
CountriesWithNoStores
)
RETURN Result
Результат функции EXCEPT фильтрует столбец Customer[CountryRegion], по-
скольку таблица, построенная с использованием этого столбца, указана в функ-
ции EXCEPT в качестве первого аргумента.
Использование таблиц в качестве фильтров
Функции для манипулирования таблицами часто используются с целью по-
строения сложных фильтров в рамках функции CALCULATE. В данном разде-
ле мы разберем полезные примеры, каждый из которых позволит вам сделать
еще один шаг в освоении языка DAX.
418 ГЛАВА 12 Работа с таблицами
Применение условных конструкций OR
Первым примером, в котором вам пригодится умение манипулировать табли-
цами, будет следующий. Представьте, что вам необходимо реализовать услов-
ную конструкцию OR между двумя выборами в двух разных срезах, а не AND,
предлагаемую по умолчанию клиентскими инструментами Excel и Power BL
Отчет, показанный на рис. 12.14, содержит два среза. По умолчанию Power
BI выполнит пересечение двух выборов. В результате цифры в отчете покажут
продажи бытовой техники (Home Appliances) покупателям, окончившим сред-
нюю школу (High School).
Category Audio Cameras and camcorders Cell phones Month CY 2007 CY 2008 CY 2009 Total
January February 9,948.27 6,155.43 30,973.91 4,639.68 40,922.18 10,795.11
Computers March 19,947.99 5,508.77 1,858.14 27,314.90
Games and Toys April 37,120.39 73,178.22 110,298.61
| Home Appliances May 13,040.21 12,076.70 18,901.77 44,018.68
Music Movies and Audio Books TV and Video June July 2,373.00 11,822.85 1,790.10 1,998.00 21,341.58 1,331.88 25,504.68 15,152.73
Education August 28,309.82 3,596.40 31,906.22
(Blank) Bachelors Graduate Degree High School September 11,695.95 23,922.27 35,618.22
October November 17,096.80 17,617.44 24,700.44 10,077.12 7,959.60 336.00 49,756.84 28,030.56
Partial College December 29,998.16 10,101.60 9,943.68 50,043.44
Partial High School Total 205,126.31 198,966.81 65,269.05 469,362.16
Рис. 12.14 По умолчанию выполняется пересечение выборов в срезах,
так что все выбранные фильтры применяются одновременно
Но иногда вместо пересечения двух условий вам может понадобиться вы-
полнить их объединение. Иными словами, желаемые цифры должны отобра-
жать как продажи бытовой техники, так и продажи покупателям, окончившим
среднюю школу. Поскольку Power BI не умеет объединять выбранные фильтры
по условию OR, нам придется выполнить это объединение вручную - посред-
ством написания кода на языке DAX.
Необходимо помнить о том, что в каждой ячейке отчета контекст фильтра
свой, и он включает как фильтр по категории товаров, так и фильтр по образо-
ванию. И нам нужно заменить оба этих фильтра. Есть множество способов это
сделать. Мы покажем три из них.
Первое и, возможно, самое простое выражение, способное помочь нам
в этой ситуации, приведено ниже:
OR 1 : =
VAR CategoriesEducations =
CROSSJOIN (
ALL ( 'Product'[Category] );
ALL ( Custoner[Education] )
ГЛАВА 12 Работа с таблицами 419
)
VAR CategoriesEducationsSelected =
FILTER (
CategoriesEducations;
OR (
'Product'[Category] IN VALUES ( 'Product'[Category] );
Customer[Education] IN VALUES ( Customer[Education] )
)
)
VAR Result =
CALCULATE (
[Sales Amount];
CategoriesEducationsSelected
)
RETURN Result
Сначала мы выполняем перекрестное соединение всех возможных катего-
рий товаров и уровней образования. После создания таблицы на нее наклады-
вается фильтр, убирающий значения, не соответствующие выбранным усло-
виям, а затем результирующий набор строк используется в качестве аргумента
фильтра в функции CALCULATE. В итоге функция CALCULATE переопределяет
текущие фильтры по категориям товаров и уровню образования, что ведет
к формированию отчета, показанного на рис. 12.15.
Category Month CY 2007 CY 2008 CY 2009 Total
Audio January 262,512.08 260,921.71 262,686.39 786,120.18
Cell phones February 225,024.45 181,594.36 203,901.13 610,519.94
Computers March 242,516.02 235,588.38 155,285.26 633,389.66
Games and Toys April 432,992.41 510,691.71 237,981.77 1,181,665.89
| Home Appliances May 258,641.80 406,915.71 481,154.70 1,146,712.21
Music, Movies and Audio Books TV and Video June July 201,855.29 251,993.38 350,851.26 407,779.81 339,850.92 361,962.95 892,557.47 1,021,736.13
Education August 287,874.68 338,909.58 245,665.31 872,449.58
(Blank) September 186,553.63 342,886.85 224,487.70 753,928.18
Bachelors Graduate Degree И High School October November 306,477.40 235,081.21 307,138.26 306,800.23 370,054.52 178,155.71 983,670.18 720,037.15
Partial College December 330,435.87 385,719.06 262,530.19 978,685.13
Partial High School Total 3,221,958.21 4,035,796.92 3,323,716.54 10,581,471.68
Рис. 12.15 В отчет включены продажи по категории «Бытовая техника»
или (OR) покупателям с соответствующим уровнем образования
Как мы и говорили, первый вариант решения задачи оказался очень прос-
тым для реализации и понимания. Однако если в таблицах, использующихся
в качестве фильтров, будет достаточно много строк или условий будет боль-
ше двух, результирующая временная таблица может стать недопустимо боль-
шой. В этом случае можно попытаться уменьшить ее размер, применив вместо
CROSSJOIN функцию SUMMARIZE, что мы и сделали во второй реализации этой
меры:
420 ГЛАВА 12 Работа с таблицами
OR 2 : =
VAR CategoriesEducations =
CALCULATETABLE (
SUMMARIZE (
Sales;
'Product'[Category];
Customer[Education]
);
ALL ( 'Product'[Category] );
ALL ( Customer[Education] )
)
VAR CategoriesEducationsSelected =
FILTER (
CategoriesEducations;
OR (
'Product'[Category] IN VALUES ( 'Product'[Category] );
Customer[Education] IN VALUES ( Customer[Education] )
)
)
VAR Result =
CALCULATE (
[Sales Amount];
CategoriesEducationsSelected
)
RETURN Result
По своей логике вторая реализация меры очень похожа на первую, а единст-
венным отличием является присутствие функции SUMMARIZE вместо CROSS-
JOIN. Также стоит отметить, что функция SUMMARIZE должна быть выполнена
в контексте фильтра, очищенном от фильтров по столбцам Category и Education.
В противном случае текущий выбор в срезе окажет нежелательное влияние на
результат функции SUMMARIZE, что сведет на нет действие фильтра.
Есть и третий вариант решения этого сценария. Потенциально он наиболее
быстрый, хотя и не такой простой для понимания. Здесь мы принимаем допу-
щение о том, что если категория товара присутствует в списке выбранных ка-
тегорий, то нам подойдет любой уровень образования покупателя. То же самое
справедливо и для уровней образования применительно к категориям това-
ров. Таким образом, мы пришли к третьему варианту реализации нашей меры:
OR 3 : =
VAR Categories =
CROSSJOIN (
VALUES ( 'Product'[Category] );
ALL ( Customer[Education] )
)
VAR Educations =
CROSSJOIN (
ALL ( 'Product'[Category] );
VALUES ( Customer[Education] )
)
VAR CategoriesEducationsSelected =
ГЛАВА 12 Работа с таблицами 421
UNION ( Categories; Educations )
VAR Result =
CALCULATE (
[Sales Amount];
CategoriesEducationsSelected
)
RETURN Result
Как видите, один и тот же расчет можно выполнить самыми разными спо-
собами. Разница лишь в скорости выполнения кода и простоте восприятия.
Способность создавать разные реализации одной и той же формулы очень
пригодится вам при чтении заключительных глав книги, посвященных опти-
мизации, где вы научитесь оценивать производительность различных версий
кода и выбирать из них наиболее оптимальную.
Ограничение расчетов постоянными покупателями
с первого года
В качестве еще одного примера манипулирования таблицами рассмотрим
анализ продаж по годам, но только по тем покупателям, которые приобретали
у нас товары в первый год выбранного периода. Иными словами, мы вычисля-
ем список клиентов, покупавших наши товары в первый год, отображенный
в элементе визуализации, и анализируем продажи по ним в следующие годы,
игнорируя тех покупателей, которые пришли к нам позже.
Наше вычисление можно условно разделить на три шага.
1. Определяем первый год продаж по товарам.
2. Сохраняем список покупателей, приобретавших товары в этот год, в пе-
ременную, игнорируя все остальные фильтры.
3. Рассчитываем сумму продаж по покупателям из второго шага в выбран-
ном периоде.
В представленном ниже коде реализован этот алгоритм с использованием
переменных для хранения промежуточных результатов:
SalesOfFirstYearCustomers :=
VAR FirstYearWithSales =
CALCULATETABLE (
FIRSTNONBLANK (
'Date'[Calendar Year];
[Sales Amount]
);
ALLSELECTED ()
)
VAR CustomersFirstYear =
CALCULATETABLE (
VALUES ( Sales[CustomerKey] );
FirstYearWithSales;
ALLSELECTED ()
)
VAR Result =
422 ГЛАВА 12 Работа с таблицами
CALCULATE (
[Sales Amount];
KEEPFILTERS ( CustomersFirstYear )
)
RETURN Result
В переменной FirstYearWithSales мы сохранили первый год, в котором были
продажи. Обратите внимание, что функция FIRSTNONBLANK возвращает таб-
лицу с привязкой данных к столбцу Date[Calendar Year]. В переменную Cus-
tomersFirstYear мы извлекаем список всех покупателей, приобретавших товары
в первый год. Заключительный шаг самый простой - здесь мы просто приме-
няем фильтр по покупателям. Таким образом, в каждой ячейке отчета будут
отображаться продажи исключительно по покупателям, найденным на вто-
ром шаге. Модификатор KEEPFILTERS позволит дополнительно отфильтровать
этих покупателей, скажем, по странам.
Результат вычисления меры показан на рис. 12.16. Из этого отчета понят-
но, что покупатели, пришедшие к нам в 2007 году, с каждым годом делают все
меньше покупок.
Category CY 2007 CY 2008 CY 2009 Total
Audio 102,722.07 61,558.94 27,853.52 192,134.53
Cameras and camcorders 3,274,847.26 481,456.84 733.10 3,757,037.20
Cell phones 477,451.74 105,582.66 7,268.40 590,302.81
Computers 2,660,318.87 397,317.15 6,258.20 3,063,894.22
Games and Toys 89,860.07 53,309.30 43,023.91 186,193.27
Home Appliances 2,347,281.80 975,860.25 177,173.74 3,500,315.79
Music, Movies and Audio Books 87,874.44 26,083.81 700.80 114,659.05
TV and Video 2,269,589.88 248,960.74 38,640.26 2,557,190.88
Total 11,309,946.12 2,350,129.69 301,651.93 13,961,727.74
Рис. 12.16 Продажи по годам среди покупателей, пришедших к нам в 2007 году
Этот пример очень важно понять. Существует множество сценариев, в ко-
торых необходимо установить фильтр по дате, определить по нему набор дан-
ных, после чего проанализировать поведение этого набора, будь то покупате-
ли, товары или магазины, по другим временным периодам. С помощью этого
шаблона можно легко реализовать анализ повторных продаж и другие вычис-
ления с похожими требованиями.
Вычисление новых покупателей
В предыдущем разделе этой главы, во время изучения функции EXCEPT, мы
показали вам способ вычисления новых покупателей. Здесь мы представим
вам более эффективный метод определения новых покупателей с использова-
нием табличных функций.
Идея этого алгоритма следующая. Сначала мы определим самую раннюю
дату продажи для каждого покупателя. После этого проверим, входит ли эта
дата для конкретного клиента в выбранный нами период. Если да, значит, это-
го покупателя можно считать новым относительно текущего периода.
ГЛАВА 12 Работа с таблицами 423
Представляем вам код меры:
New Customers :=
VAR CustomersFirstSale =
CALCULATETABLE (
ADDCOLUMNS (
VALUES ( Sales[CustomerKey] );
"FirstSale"; CALCULATE (
MIN ( Sales[Order Date] )
)
);
ALL ( 'Date' )
)
VAR CustomersWithlstSaleInCurrentPeriod =
FILTER (
CustomersFirstSale;
[FirstSale] IN VALUES ( 'Date'[Date] )
)
VAR Result =
COUNTROWS ( CustomersWithlstSaleInCurrentPeriod )
RETURN Result
В формуле переменной CustomersFirstSale необходимо применить функцию
ALL к таблице Date, чтобы можно было сканировать таблицу продаж за период,
предшествующий выбранному. Результат выражения показан на рис. 12.17.
Calendar Year Num of Customers New Customers
CY 2007
7,999 7,999
January 1,375 1,375
February 1,153 1,037
March 1,038 900
April 1,197 960
May 1,049 774
June 643 436
July 823 592
August 630 423
September 675 436
October 489 268
November 693 397
December 689 401
Рис. 12.17 В отчете отображено общее количество покупателей
и количество новых клиентов за 2007 год
Если пользователь решит добавить фильтр к отчету, скажем, по категориям
товаров, то к числу новых покупателей будут относиться те, которые впервые
за выбранный период приобрели товар выбранной категории. Таким образом,
в зависимости от установленных фильтров один и тот же покупатель может
считаться новым или постоянным. Добавляя другие модификаторы функции
424 ГЛАВА 12 Работа с таблицами
CALCULATE к первой переменной, можно получить различные вариации этой
формулы. Например, добавив ALL ( Product ), мы укажем формуле при опре-
делении нового покупателя учитывать любые товары, а не только выбранные.
Добавление ALL ( Store ) позволит не принимать в расчет конкретные мага-
зины.
Использование IN, CONTAINSROW и CONTAINS
В предыдущем примере, как и во многих других, мы использовали ключевое слово IN для
определения того, присутствует ли значение в таблице. При выполнении кода //V транс-
формируется в вызов функции CONTAINSROW,так что следующие две инструкции будут
эквивалентными:
Product[Color] IN { "Red"; "Blue"; "Yellow" }
CONTAINSROW ( { "Red"; "Blue"; "Yellow" }; Product[Color] )
Этот синтаксис также работает и со множеством столбцов:
( 'Date'[Year]; 'Date'[MonthNunber] ) IN { ( 2018; 12 ); ( 2019; 1 ) }
CONTAINSROW ( { ( 2018; 12 ); ( 2019; 1 ) }; 'Date'[Year]; 'Date'[MonthNunber] )
В прежних версиях DAX ключевые слова IN и CONTAINSROW были недоступны. Аль-
тернативой им была функция CONTAINS, требующая на вход пару из столбца и значе-
ния для поиска в таблице. При этом функция CONTAINS является менее эффективной
по сравнению с IN и CONTAINSROW. А поскольку в прежних версиях DAX не было также
и конструкторов таблиц, функцию CONTAINS приходилось использовать с более много-
словным синтаксисом:
VAR Colors =
UNION (
ROW ( "Color"; "Red" );
ROW ( "Color"; "Blue" );
ROW ( "Color"; "Yellow" )
)
RETURN
CONTAINS ( Colors; [Color]; Product[Color] )
На момент написания книги использование ключевого слова IN является наиболее
оптимальным способом поиска значений в таблице. Выражения с IN очень просты для
пониманиям в плане производительности они не уступают формулам с функцией CON-
TAINSROW.
Повторное использование табличных выражений
при помощи функции DETAILROWS
В сводных таблицах в Excel есть возможность извлечь исходные данные, на
основании которых было рассчитано значение в ячейке. В интерфейсе Excel
для этого нужно выбрать пункт Show Details (Показать детали) в контекст-
ном меню - технически эта операция называется детализацией данных (drill-
through). Это может вводить в заблуждение, поскольку в Power BI термин де-
тализация относится к возможности переходить с одной страницы отчета на
другую по задуманным автором отчета правилам. Именно поэтому свойство,
ГЛАВА 12 Работа с таблицами 425
позволяющее управлять детализацией, было названо выражением строк де-
тализации (Detail Rows Expression) в модели Tabular и представлено в SQL
Server Analysis Services 2017. По состоянию на апрель 2019 года эта особенность
недоступна в Power BI, но может быть включена в будущих релизах.
Выражение строк детализации представляет собой табличное выражение
DAX, ассоциированное с мерой и вызываемое в момент показа деталей. Это
выражение вычисляется в контексте фильтра меры. Идея состоит в том, что
если мера изменяет контекст фильтра в процессе вычисления переменной,
в выражении строк детализации должны быть произведены такие же транс-
формации контекста фильтра.
Рассмотрим меру Sales YTD, вычисляющую другую меру Sales Amount нарас-
тающим итогом с начала года:
Sales YTD :=
CALCULATE (
[Sales Amount];
DATESYTD ( 'Date'[Date] )
)
Выражение строк детализации для этой меры должно использовать функ-
цию CALCULATETABLE, изменяющую контекст фильтра соответствующим об-
разом. Например, следующее выражение будет возвращать все столбцы табли-
цы Sales с начала года, участвующего в вычислении:
CALCULATETABLE (
Sales;
DATESYTD ( 'Date'[Date] )
)
Клиентский инструмент DAX запускает это выражение при помощи специ-
альной функции DETAILROWS, указывая в качестве параметра меру, которой
принадлежит выражение:
DETAILROWS ( [Sales YTD] )
Функция DETAILROWS осуществляет вызов табличного выражения, ассоци-
ированного с мерой. А значит, вы можете создать скрытые меры для хранения
длинных табличных выражений, зачастую используемых в качестве аргумен-
тов фильтра в других мерах DAX. Рассмотрим меру Cumulative Total с ассоции-
рованным выражением, извлекающим все даты раньше самой поздней даты
в текущем контексте фильтра:
-- Выражение строк детализации для меры Cumulative Total
VAR LastDateSelected = MAX ( 'Date'[Date] )
RETURN
FILTER (
ALL ( 'Date'[Date] );
'Date'[Date] <= LastDateSelected
)
Это выражение можно использовать в других мерах, применяя функцию DE-
TAILROWS:
426 ГЛАВА 12 Работа с таблицами
Cumulative Sales Amount :=
CALCULATE (
[Sales Amount];
DETAILROWS ( [Cumulative Total] )
)
Cumulative Total Cost :=
CALCULATE (
[Total Cost];
DETAILROWS ( [Cumulative Total] )
)
Больше примеров по данной теме можно найти по адресу: https://www.sql.bi.
com/articLes/creating-tabLe-functions-in-dax-using-detaiLrows/. При этом стоит пом-
нить, что использование функции DETAILROWS для запуска ассоциированных
выражений является лишь обходным путем, за неимением в DAX пользова-
тельских функций, а значит, может приводить к проблемам с производитель-
ностью. Многие примеры применения функции DETAILROWS можно перепи-
сать с помощью групп вычислений, и эта техника уйдет в прошлое, когда в DAX
появятся меры, возвращающие таблицы, или пользовательские функции.
Создание вычисляемых таблиц
Все табличные функции, о которых мы рассказывали в предыдущих разделах,
могут быть использованы либо в качестве табличных фильтров в функции CAL-
CULATE, либо для создания вычисляемых таблиц и запросов. Ранее мы в ос-
новном описывали функции, более пригодные для использования в качестве
фильтра, сейчас же поговорим о функциях, чаще применяющихся для создания
вычисляемых таблиц. Есть и другие функции, главным образом использующие-
ся в запросах, но о них мы расскажем в следующей главе. В то же время необхо-
димо помнить, что функции DAX не ограничиваются какой-то одной областью
применения. Ничто не мешает вам использовать функции DATATABLE, SELECT-
COLUMNS или GENERATESERIES (о них мы поговорим позже) в коде меры или
в качестве табличного фильтра. Это лишь вопрос удобства применения - неко-
торые функции удобнее использовать в определенных ситуациях.
Функция SELECTCOLUMNS
Функцию SELECTCOLUMNS удобно использовать для ограничения количества
выбираемых столбцов из таблицы. Кроме того, она обладает возможностью до-
бавлять новые столбцы, подобно функции ADDCOLUMNS. На практике функ-
ция SELECTCOLUMNS служит для осуществления выборки данных из столбцов,
как оператор SELECT в языке SQL.
Наиболее частым использованием этой функции является сканирование
таблицы и возвращение ограниченного набора столбцов. Например, следую-
щее выражение возвращает из таблицы покупателей только столбцы с образо-
ванием и полом:
ГЛАВА 12 Работа с таблицами 427
SELECTCOLUMNS (
Customer;
"Education"; Customer[Education];
"Gender"; Customer[Gender]
)
Результат выражения co множеством дублирующихся строк показан на
рис. 12.18.
Education Gender
Partial College M
Partial College F
Partial College F
Partial College M
Partial College F
Partial College M
Partial College M
Partial College M
Рис. 12.18 Функция SELECTCOLUMNS
возвращает таблицу с дубликатами
Функция SELECTCOLUMNS сильно отличается от SUMMARIZE. Если функция
SUMMARIZE осуществляет группировку результирующего набора, то SELECT-
COLUMNS просто ограничивает количество столбцов в выводе. Следовательно,
результирующая таблица на выходе функции SELECTCOLUMNS может содер-
жать повторяющиеся строки, а в случае с SUMMARIZE - нет. Для ограничения
количества столбцов на вход функции SELECTCOLUMNS необходимо подать
пары значений с наименованием столбца и выражением. Кроме того, в резуль-
тирующем наборе могут оказаться не только существующие столбцы, но и но-
вые. Например, следующая формула добавляет к таблице покупателей новый
столбец Customer, в котором указаны имя покупателя и в скобках его код:
SELECTCOLUMNS (
Customer;
"Education"; Customer[Education];
"Gender"; Customer[Gender];
"Customer"; Customer[Name] & " (" & Customer[Customer Code] & ")"
)
Результирующую таблицу можно видеть на рис. 12.19.
Education Gender Customer
Partial College M Xie, Russell (11024)
Partial College F Russell, Jennifer (11036)
Partial College F Carter, Amanda (11041)
Partial College M Simmons, Nathan (11043)
Partial College F Morris, Isabella (11928)
Partial College M Alexander, Seth (11938)
Partial College M Garcia, Joseph (11954)
Partial College M Green, Gabriel (11955)
Рис. 12.19 Функция SELECTCOLUMNS
может добавлять новые столбцы
к таблице, подобно функции
ADDCOLUMNS
428 ГЛАВА 12 Работа с таблицами
Функция SELECTCOLUMNS сохраняет привязку данных для тех столбцов, ко-
торые выбраны путем простого указания ссылки на исходный столбец, тогда
как для столбцов, образованных при помощи выражений, будет выполнена
новая привязка. Например, в следующем примере вернется таблица из двух
столбцов: первый будет привязан к столбцу Customer[Name] в модели данных,
а для второго будет создана новая привязка, несмотря на то что значения
в этих столбцах будут одинаковые:
SELECTCOLUMNS (
Customer;
"Наименование покупателя с привязкой данных"; Customer[Name],
"Наименование покупателя без привязки данных"; Customer[Name] & ""
)
Создание статических таблиц при помощи функции ROW
ROW - одна из самых простых функций, она возвращает таблицу с одной стро-
кой. На вход функции ROW подаются пары с наименованием столбца и его зна-
чением, а на выходе мы получаем таблицу с одной строкой и заданным коли-
чеством столбцов. Например, следующее выражение вернет таблицу из одной
строки с двумя столбцами, заполненными суммой и количеством продаж:
ROW (
"Sales"; [Sales Amount];
"Quantity"; SUM ( Sales[Quantity] )
)
Результат этого выражения показан на рис. 12.20.
Sales Quantity
30,591,343.98 140,180
Рис. 12.20 Функция ROW
создает та блицу с одной строкой
После появления в DAX конструктора таблиц функция ROW стала использо-
ваться гораздо реже. Например, предыдущее выражение можно записать так:
{
( [Sales Amount]; SUM ( Sales[Quantity] ) )
}
В этом случае наименования столбцов будут сгенерированы автоматически,
что показано на рис. 12.21.
Valuel Value2
30,591,343.98 140,180
Рис. 12.21 Конструктор таблиц генерирует
наименования столбцов автоматически
При использовании конструктора таблиц строки разделяются точкой с запя-
той. Для создания нескольких столбцов необходимо использовать скобки для
объединения их в строку. Главным отличием между функцией ROW и синтак-
ГЛАВА12 Работа с таблицами 429
сисом с фигурными скобками является то, что при использовании первой вы
можете задать имена столбцов, тогда как второй вариант этого сделать не по-
зволяет, что осложняет обращение к столбцам в дальнейшем.
Создание статических таблиц при помощи функции
DATATABLE
Использование функции ROW ограничивается созданием таблицы с един-
ственной строкой. Если же вам необходимо, чтобы в созданной таблице было
несколько строк, вам придется воспользоваться функцией DATATABLE. При
этом функция DATATABLE позволяет задать не только имена столбцов, но и их
типы данных с содержимым. Например, если вам понадобится таблица из трех
строк для выполнения кластеризации по ценам, самым простым способом соз-
дать ее будет следующий:
DATATABLE (
"Segment"; STRING;
"Min"; DOUBLE;
"Max"; DOUBLE;
{
{ "LOW"; 0; 20 };
{ "MEDIUM"; 20; 50 };
{ "HIGH"; 50; 99 }
}
)
Результат этого выражения показан на рис. 12.22.
Segment Min Max
LOW 0.00 20.00
MEDIUM 20.00 50.00 Рис. 12.22 Таблица, сгенерированная
HIGH 50.00 99.00 функцией DATATABLE
В качестве типа данных столбцов можно указывать одно из следующих зна-
чений: INTEGER, DOUBLE, STRING, BOOLEAN, CURRENCY или DATETIME. Син-
таксис этой функции в отношении использования скобок сильно отличается от
конструктора таблиц. В функции DATATABLE фигурные скобки используются
для разделения строк, тогда как в конструкторе таблиц применяются круглые
скобки, а фигурными выделяется вся таблица в целом.
Серьезным ограничением функции DATATABLE является то, что все значе-
ния в таблице должны быть константами. Любые выражения на языке DAX
здесь недопустимы. Это делает функцию DATATABLE не столь популярной. Те
же конструкторы таблиц дают разработчику гораздо больше гибкости.
Можно использовать функцию DATATABLE для определения простых вычис-
ляемых таблиц с неизменными значениями. В SQL Server Data Tools (SSDT) для
Analysis Services Tabular функция DATATABLE используется, когда разработчик
430 ГЛАВА 12 Работа с таблицами
вставляет данные из буфера обмена в модель, тогда как Power BI для опреде-
ления таблиц с константами использует Power Query. Это еще одна причина,
по которой функция DATATABLE не пользуется большой популярностью среди
пользователей Power BL
Функция GENERATESERIES
Функция GENERATESERIES является служебной и предназначена для создания
списка значений по переданным параметрам нижней и верхней границ, а так-
же шага. Например, следующее выражение сгенерирует список из 20 чисел от
1 до 20:
GENERATESERIES ( 1; 20; 1 )
Тип данных будет зависеть от введенных значений и может быть либо чис-
ловым, либо DateTime. Например, если вам понадобится таблица, содержа-
щая время, следующее выражение поможет вам быстро создать таблицу из
86 400 строк - по одной на каждую секунду:
Tine =
GENERATESERIES (
TIME ( 0; 0; 0 ); -- Начальное значение
TIME ( 23; 59; 59 ); -- Конечное значение
TIME ( 0; 0; 1 ) -- Шаг: 1 секунда
)
Изменив шаг и добавив дополнительные столбцы, можно создать неболь-
шую таблицу, которая может служить в качестве измерения, например для осу-
ществления среза продаж по времени:
Tine =
SELECTCOLUMNS (
GENERATESERIES (
TIME ( 0; 0; 0 );
TIME ( 23; 59; 59 );
TIME ( 0; 30; 0 )
);
"Tine"; [Value];
"HH:MM AMPM"; FORMAT ( [Value]; "HH:MM AM/PM" );
"HH:MM"; FORMAT ( [Value]; "HH:MM" );
"Hour"; HOUR ( [Value] );
"Minute"; MINUTE ( [Value] )
)
Результат этого выражения можно видеть на рис. 12.23.
Использовать функцию GENERATESERIES в мерах не принято, она чаще при-
меняется для создания простых табличек, которые можно применять в качест-
ве срезов, чтобы пользователь имел возможность выбрать нужный параметр.
Например, в Power BI функция GENERATESERIES используется для добавления
параметров при выполнении анализа «что, если».
ГЛАВА 12 Работа с таблицами 431
Time HH:MM AMPM HH:MM Hour Minute
12:00:00 AM 12:00 AM 00:00 0 0
12:30:00 AM 12:30 AM 00:30 0 30
01:00:00 AM 01:00 AM 01:00 1 0
01:30:00 AM 01:30 AM 01:30 1 30
02:00:00 AM 02:00 AM 02:00 2 0
02:30:00 AM 02:30 AM 02:30 2 30
03:00:00 AM 03:00 AM 03:00 3 0
03:30:00 AM 03:30 AM 03:30 3 30
Рис. 12.23 Воспользовавшись функциями GENERATESERIES и SELECTCOLUMNS,
можно легко смастерить таблицу со временем
Заключение
В данной главе вы познакомились со множеством табличных функций. А в сле-
дующей встретитесь еще с несколькими. Здесь мы главным образом сосредо-
точили внимание на функциях, применяющихся для создания вычисляемых
таблиц и сложных аргументов фильтра в функциях CALCULATE и CALCULA-
TETABLE. Помните, что в книге мы приводим примеры того, что возможно
сделать при помощи языка DAX. Реальные же решения конкретных сценариев
вам придется разрабатывать самостоятельно.
Функции, с которыми вы познакомились в данной главе:
ADDCOLUMNS - для добавления новых столбцов в исходную таблицу;
SUMMARIZE - для выполнения группировки после сканирования табли-
цы;
CROSSJOIN - для получения декартова произведения двух таблиц;
UNION, INTERSECT и EXCEPT - для выполнения базовых операций со
множествами применительно к таблицам;
SELECTCOLUMNS - для выбора определенных столбцов из таблицы;
ROW, DATATABLE и GENERATESERIES - для создания таблиц с постоянны-
ми величинами в качестве вычисляемых таблиц.
В следующей главе мы расскажем еще о нескольких табличных функциях
и главным образом сосредоточимся на построении сложных запросов и слож-
ных вычисляемых таблиц.
ГЛАВА 15
Создание запросов
В данной главе мы продолжим наше путешествие по миру DAX и изучим еще
несколько полезных табличных функций. В основном мы сконцентрируемся
на функциях, применяемых при подготовке запросов и создании вычисляе-
мых таблиц, а не при написании мер. Помните о том, что большинство функ-
ций, с которыми вы познакомитесь в этой главе, вполне можно применять
и при написании мер, пусть и с некоторыми ограничениями, на которых мы
также подробно остановимся.
Для каждой функции будет приведен пример запроса, использующего ее.
При написании главы мы поставили себе две цели: познакомить вас с новы-
ми функциями и показать несколько рабочих шаблонов, которые вы сможете
адаптировать к своей модели данных.
Все демонстрационные материалы в главе представлены в виде текстовых
файлов с запросами для выполнения в DAX Studio с подключением к общему
файлу с данными Power BL Этот файл, в свою очередь, содержит модель данных
Contoso, с которой мы работаем на протяжении всей книги.
Знакомство с DAX Studio
DAX Studio - это бесплатный инструмент, доступный по адресу www.daxstudio.
org, помогающий при написании запросов, отладке кода и измерении произ-
водительности запросов.
Это живой продукт с постоянно обновляющейся функциональностью. Вот
список наиболее важных особенностей DAX Studio:
возможность подключаться к Analysis Services, Power BI и Power Pivot для
Excel;
полноценный редактор запросов для написания сложного кода;
автоматическое форматирование исходного кода при помощи сервиса
daxformatter.com;
автоматическое определение мер для отладки или тонкой настройки
производительности;
детализированная информация о производительности ваших запросов.
Хотя есть и другие инструменты для проверки и написания запросов на язы-
ке DAX, мы настоятельно рекомендуем нашим читателям скачать и установить
DAX Studio. Если вы еще сомневаетесь, представьте, что весь код, показанный
в данной книге, был написан нами именно в DAX Studio. А ведь мы работаем
с DAX дни напролет и хотим, чтобы наш код был максимально эффективным.
ГЛАВА 13 Создание запросов 433
Полная документация по DAX Studio доступна по адресу http://daxstudio.org/
documentation/.
Инструкция EVALUATE
EVALUATE является специальной инструкцией DAX, необходимой для вы-
полнения запросов. Ключевое слово EVALUATE, за которым следует название
таблицы, возвращает результат табличного выражения. При этом одной или
нескольким инструкциям EVALUATE может предшествовать целый ряд допол-
нительных определений локальных таблиц, столбцов, мер и переменных, об-
ласть видимости которых распространяется на весь пакет инструкций EVALU-
ATE, выполняемый как единое целое.
Например, следующий запрос, в котором функция CALCULATETABLE следует
за ключевым словом EVALUATE, вернет список красных товаров:
EVALUATE
CALCULATETABLE (
'Product';
'Product'[Color] = "Red"
)
Прежде чем углубиться в изучение продвинутых табличных функций, мы
познакомимся с дополнительными опциями инструкции EVALUATE, которые
будем активно использовать при написании сложных запросов.
Введение в синтаксис EVALUATE
Инструкцию EVALUATE можно условно разделить на три части:
раздел определений: начинается с ключевого слова DEFINE и включа-
ет в себя список определений локальных сущностей, таких как таблицы,
столбцы, переменные и меры. Можно написать один раздел определе-
ний для всего запроса, в то время как в запросе может присутствовать
несколько инструкций EVALUATE',
выражение запроса: начинающееся с инструкции EVALUATE, выраже-
ние запроса представляет собой, по сути, табличное выражение на языке
DAX, возвращающее результат. В запросе может быть множество выра-
жений запроса, каждое из которых должно начинаться ключевым словом
EVALUATE и обладать своим набором модификаторов результата;
модификаторы результата: это необязательный раздел в рамках ин-
струкции EVALUATE, начинающийся с ключевого слова ORDER BY. Слу-
жит для выполнения сортировки результата, а также для вывода ограни-
ченного набора столбцов при помощи инструкции START АТ.
Первая и третья секции EVALUATE являются опциональными. Так что прос-
тейшим вариантом использования ключевого слова EVALUATE будет указание
следом за ним имени таблицы из модели данных. Однако, используя EVALU-
ATE таким образом, разработчик добровольно отказывается от массы полез-
434 ГЛАВА 13 Создание запросов
ных особенностей этой мощной инструкции, которые не только можно, но
и нужно изучать.
Ниже представлен пример запроса:
DEFINE
VAR MininumAnount = 2000000
VAR MaxinumAnount = 8000000
EVALUATE
FILTER (
ADDCOLUMNS (
SUMMARIZE ( Sales; 'Product'[Category] );
"CategoryAnount"; [Sales Amount]
);
AND (
[CategoryAnount] >= MininumAnount;
[CategoryAnount] <= MaxinumAnount
)
)
ORDER BY [CategoryAnount]
Этот запрос вернет результат, показанный на рис. 13.1.
Category CategoryAmount
TV and Video 4,392,768.29
Computers 6,741,548.73
Cameras and camcorders 7,192,581.95
Рис. 13.1 В итоговый набор включены категории с суммой продаж
в интервале между 2 000 000 и 8 000 000
В этом примере мы объявили две переменные для хранения нижней и верх-
ней границ суммы продаж. После этого запрос извлекает все категории то-
варов, по которым сумма продаж входит в выбранный интервал. И наконец,
выполняется сортировка результатов по сумме продаж. Синтаксис очень прос-
той, но при этом довольно мощный, и в следующих разделах мы подробнее
расскажем о каждом разделе инструкции EVALUATE. Но стоит помнить, что все
эти особенности годятся только при написании запросов. Если вы собираетесь
свой код в дальнейшем использовать в вычисляемой таблице, мы советуем вам
отказаться от использования ключевых слов DEFINE и ORDER BY и сосредото-
читься исключительно на выражении запроса. Вычисляемая таблица опреде-
ляется не запросом DAX, а табличным выражением.
Использование VAR внутри DEFINE
В разделе определений допустимо использовать ключевое слово VAR для ини-
циализации переменных. Переменные, определенные в запросе, в отличие
от выражений DAX, не должны заканчиваться разделом RETURN. По сути, за
формирование результата отвечает инструкция EVALUATE. В дальнейшем для
различия между переменными, использующимися в выражениях, и перемен-
ГЛАВА 13 Создание запросов 435
ними из раздела DEFINE в запросах мы будем называть первые переменными
выражений (expression variables), а вторые - переменными запросов (query vari-
ables).
Как и переменные выражений, переменные запросов могут хранить как ска-
лярные величины, так и таблицы. Например, предыдущий запрос может быть
переписан следующим образом с использованием табличной переменной:
DEFINE
VAR MinimumAmount = 2000000
VAR MaxinunAnount = 8000000
VAR CategoriesSales =
ADDCOLUMNS (
SUMMARIZE ( Sales; 'Product'[Category] );
"CategoryAmount"; [Sales Anount]
)
EVALUATE
FILTER (
CategoriesSales;
AND (
[CategoryAmount] >= MininunAnount;
[CategoryAmount] <= MaxinunAnount
)
)
ORDER BY [CategoryAmount]
Переменные запросов действуют в области видимости всего пакета инструк-
ций EVALUATE, которые выполняются как единое целое. Это означает, что пос-
ле инициализации переменная может использоваться в любом из следующих
далее запросов. Единственным ограничением является то, что к переменной
можно обращаться только после ее объявления. Если в предыдущем примере
переменную CategoriesSales объявить раньше переменных MinimumAmount или
Maximum Amount, будет выдана синтаксическая ошибка, поскольку в Categories-
Sales вы пытаетесь ссылаться на переменные, которые еще не объявлены. Это
простое ограничение очень полезно и помогает избежать образования цикли-
ческих зависимостей. Такое же ограничение действует и на переменные вы-
ражений, так что в этом плане наблюдается полная преемственность.
Если в запросе содержится несколько секций EVALUATE, объявленные ранее
переменные будут доступны во всех из них. Например, в генерируемых Power
BI запросах секция DEFINE используется для хранения фильтров срезов в пе-
ременных, после чего идут несколько блоков EVALUATE для разных расчетов
внутри визуального элемента.
Переменные также могут быть объявлены и внутри секции EVALUATE. В этом
случае это будут переменные выражения, и их область видимости сузится до
конкретного табличного выражения. Например, предыдущий пример может
быть переписан следующим образом:
EVALUATE
VAR MinimumAmount = 2000000
VAR MaxinunAnount = 8000000
VAR CategoriesSales =
436 ГЛАВА 13 Создание запросов
ADDCOLUMNS (
SUMMARIZE ( Sales; 'Product'[Category] );
"CategoryAnount"; [Sales Anount]
)
RETURN
FILTER (
CategoriesSales;
AND (
[CategoryAnount] >= MininunAnount;
[CategoryAnount] <= MaxinunAnount
)
)
ORDER BY [CategoryAnount]
Как видите, теперь переменные стали неотъемлемой частью табличного вы-
ражения, а значит, для определения результата необходимо использовать клю-
чевое слово RETURN. Область видимости этих переменных в данном случае
будет ограничена этой секцией RETURN.
Выбор между переменными выражений и переменными запросов зависит
от конкретной задачи - у обоих видов переменных есть свои преимущества
и недостатки. Если вы хотите использовать переменную в следующих опреде-
лениях таблиц или столбцов, то ваш выбор - переменная запроса. Если же в об-
ращении к переменной в последующих определениях (или секциях EVALUATE)
нет необходимости, лучше ограничиться переменной выражения. Фактически
если переменная является составной частью выражения, будет гораздо проще
использовать выражение для расчета вычисляемой таблицы или включения
в меру. В противном случае вам придется менять синтаксис запроса для пре-
образования его в выражение.
Для выбора между двумя типами переменных можно воспользоваться сле-
дующим простым правилом. Используйте переменные выражений всегда,
когда это возможно, а переменные запросов - только в случаях крайней необ-
ходимости. Одним из ограничений переменных запросов является то, что их
достаточно проблематично повторно использовать в других формулах.
Использование MEASURE внутри DEFINE
Еще одной сущностью, которую можно объявить локально внутри запроса, яв-
ляется мера. Это можно сделать при помощи ключевого слова MEASURE. Меры
в запросах ведут себя приблизительно так же, как и обычные меры, за исклю-
чением того, что они существуют только в рамках запроса. При определении
меры обязательно нужно указывать имя таблицы, в которой она будет разме-
щена. Приведем пример такой меры:
DEFINE
MEASURE Sales[LargeSales] =
CALCULATE (
[Sales Anount];
Sales[Net Price] >= 200
)
ГЛАВА 13 Создание запросов 437
EVALUATE
ADDCOLUMNS (
VALUES ( 'Product'[Category] );
"Large Sales"; [LargeSales]
)
Результат запроса можно видеть на рис. 13.2.
Category
Audio
Cameras and camcorders
Cell phones
Computers
Games and Toys
Home Appliances
Large Sales
85,029.32
6,424,083.52
1,110,860.57
5,571,044.77
8,167,467.64
Рис. 13.2 Мера запроса LargeSales вычисляется для каждой категории товаров
в столбце Large Sales
Меры в запросах используются для двух целей. Во-первых, что весьма оче-
видно, для написания более сложных выражений, которые могут многократно
использоваться в рамках запроса. Второе предназначение мер в запросах состо-
ит в их использовании для отладки кода и настройке производительности. Дело
в том, что если мера в запросе и мера в модели данных называются одинаково,
приоритет внутри запроса будет у первой. Иными словами, ссылки на меру по
имени внутри запроса будут обращаться к локальной мере, а не к глобальной,
объявленной в модели. В то же время все другие ссылки на эту меру в модели
данных продолжат обращаться к мере, существующей вне запроса. И для того
чтобы оценить поведение запроса при изменении нужной меры в модели дан-
ных, вам достаточно включить меру с точно таким же именем в сам запрос.
Тестируя поведение меры, лучше всего будет написать запрос, использую-
щий ее, добавить локальную копию этой меры и заняться отладкой или оп-
тимизацией кода. По окончании процесса код меры в модели данных можно
смело заменить на отлаженный код из запроса. В DAX Studio для этой цели
есть специальная функция: инструмент предлагает разработчику автомати-
чески добавить все инструкции DEFINE MEASURE в запрос с целью ускорения
процесса.
Реализация распространенных шаблонов запросов
в DAX
Теперь, когда вы познакомились с синтаксисом инструкции EVALUATE, мы го-
товы представить вам ряд функций, полезных при написании запросов. Для
наиболее распространенных из них мы также покажем примеры, которые по-
могут лучше понять использование этих функций.
438 ГЛАВА 13 Создание запросов
Использование функции ROW для проверки мер
Представленная в предыдущей главе функция ROW обычно используется для
извлечения значения из меры или проведения анализа плана ее выполнения.
Инструкция EVALUATE принимает таблицу на вход и возвращает также табли-
цу. Если все, что вам нужно, - это получить значение меры, EVALUATE просто
не примет ее в качестве аргумента, потому что ожидает на вход таблицу. Обой-
ти это ограничение можно при помощи функции ROW, способной превратить
любую скалярную величину в таблицу, как показано в следующем примере:
EVALUATE
ROW ( "Result"; [Sales Anount] )
Результат выполнения этого запроса показан на рис. 13.3.
Result
30,591,343.98
Рис. 13.3 Функция ROW возвращает таблицу
с одной строкой
Такого же результата можно добиться, используя конструктор таблиц:
EVALUATE
{ [Sales Anount] }
На рис. 13.4 можно видеть результат выполнения запроса.
Value
30,591,343.98
Рис. 13.4 Конструктор таблиц
возвращает таблицу с одной строкой
и столбцом с именем Value
Функция ROW позволяет разработчику самому задать наименование столб-
ца, тогда как конструктор таблиц такой возможности не дает. Кроме того,
с помощью функции ROW можно создать таблицу с несколькими столбцами,
каждому из которых дать свое имя и выражение. Если есть необходимость
смоделировать присутствие среза, можно воспользоваться функцией CALCU-
LATETABLE, как показано ниже:
EVALUATE
CALCULATETABLE (
ROW (
"Sales"; [Sales Anount];
"Cost"; [Total Cost]
);
'Product'[Color] = "Red"
)
Результат выполнения запроса показан на рис. 13.5.
Sales Cost
1,110,102.10 545,018.43
Рис. 13.5 Функция ROW может использоваться для создания
таблицы с несколькими столбцами, при этом значения в ячейках
вычисляются в рамках текущего контекста фильтра
ГЛАВА 13 Создание запросов 439
Функция SUMMARIZE
Мы уже использовали функцию SUMMARIZE в предыдущих главах книги. Тог-
да мы говорили, что эта функция способна группировать строки по столбцам
и добавлять новые значения. И если операцию группирования данных при
помощи функции SUMMARIZE можно считать совершенно безопасной, то до-
бавление столбцов способно приводить к неожиданным результатам, которые
бывает трудно понять и исправить.
И хотя добавление столбцов при помощи функции SUMMARIZE - это плохая
идея, мы представим вам два способа использования этой функции для вы-
полнения данной операции. Нам важно, чтобы читатели не растерялись, если
увидят подобный код, написанный кем-то другим. Но мы еще раз повторим,
что использовать функцию SUMMARIZE для добавления столбцов, агре-
гирующих значения, нежелательно!
Если кто-то использует функцию SUMMARIZE для подсчета значений, спе-
шим напомнить, что вы также можете снабжать результирующую таблицу до-
полнительными строками с подытогами. Для этого в функции SUMMARIZE
есть специальный модификатор с именем ROLLUP, который меняет функцию
агрегирования по столбцам таким образом, чтобы в итоговый набор были до-
бавлены предварительные итоги. Взгляните на следующий запрос:
EVALUATE
SUMMARIZE (
Sales;
ROLLUP (
'Product'[Category];
'Date'[Calendar Year]
);
"Sales"; [Sales Amount]
)
ORDER BY
'Product'[Category];
'Date'[Calendar Year]
Модификатор ROLLUP указывает функции SUMMARIZE не только подсчиты-
вать суммы продаж в таблице Sales по категориям товаров и годам, но также
добавлять строки в результирующий набор с пустыми значениями в столбце
с годом, в которых будет содержаться подытог по конкретной категории то-
варов. А поскольку столбец с категорией тоже помечен как ROLLUP, в наборе
также появится строка с пустыми значениями в обоих группируемых столбцах,
в которой будет содержаться общий итог. Результат выполнения этого запроса
показан на рис. 13.6.
В строках, созданных при помощи ROLLUP, группируемые столбцы содержат
пустые значения. Если в исходном столбце есть пустые значения, то в итого-
вом наборе окажется сразу две строки с пустым значением в этом столбце:
одна с агрегированным значением по пустой категории, а вторая - с подыто-
гом. Чтобы лучше отличать их и облегчить пометку строк с подытогами, можно
добавить специальные столбцы с функцией ISSUBTOTAL в выражении, как по-
казано ниже:
440 ГЛАВА 13 Создание запросов
EVALUATE
SUMMARIZE (
Sales;
ROLLUP (
'Product'[Category];
'Date'[Calendar Year]
);
"Sales"; [Sales Amount];
"Subtotalcategory"; ISSUBTOTAL ( 'Product'[Category] );
"SubtotalYear"; ISSUBTOTAL ( 'Date'[Calendar Year] )
)
ORDER BY
'Product'[Category];
'Date'[Calendar Year]
Category Audio Audio Calendar Year Sales
CY 2007 30,591,343.98 384,518.16 102,722.07
Audio CY 2008 105,363.42
Audio CY 2009 176,432.67
Cameras and camcorders 7,192,581.95
Cameras and camcorders CY 2007 3,274,847.26
Рис. 13.6 Модификатор ROLLUP
создает дополнительные строки с подытогами
при выполнении функции SUMMARIZE
Последние два столбца будут заполнены булевыми значениями: если в стро-
ке содержатся подытоги по соответствующему столбцу, будет указано TRUE,
если нет - FALSE, что видно по рис. 13.7.
Category Calendar Year Sales Subtotalcategory SubtotalYear
30,591,343.98 True True
Audio 384,518.16 False True
Audio CY 2007 102,722.07 False False
Audio CY 2008 105,363.42 False False
Audio CY 2009 176,432.67 False False
Cameras and camcorders 7,192,581.95 False True
Cameras and camcorders CY 2007 3,274,847.26 False False
Рис. 13.7 Функция ISSUBTOTAL возвращает True,
если в строке представлен подытог
С использованием функции ISSUBTOTAL можно легко отличать строки с дан-
ными от предварительных итогов.
ГЛАВА 13 Создание запросов 441
Важно Функция SUMMARIZE не должна использоваться для добавления столбцов в ре-
зультирующий набор. Примеры с применением связки функций ROLLUP и ISSUBTOTAL мы
представили, чтобы наш читатель не терялся, если встретит такую комбинацию в чужом
коде. Вы не должны применять в своих выражениях функцию SUMMARIZE с этой целью -
лучше использовать функцию SUMMARIZECOLUMNS или связку ADDCOLUMNS и SUMMA-
RIZE, когда применить SUMMARIZECOLUMNS невозможно.
Функция SUMMARIZECOLUMNS
SUMMARIZECOLUMNS - очень мощная и универсальная функция примени-
тельно к запросам. В одной этой функции, по сути, уместилось все, что необ-
ходимо для работы с запросами. Судите сами, функция SUMMARIZECOLUMNS
позволяет вам задать:
набор столбцов для осуществления группировки по подобию функции
SUMMARIZE, с опциональным выводом подытогов;
набор новых столбцов в результирующей таблице - как связка функций
SUMMARIZE и ADDCOLUMNS;
набор фильтров для применения к модели данных перед выполнением
группировки, подобно функции CALCULATETABLE.
И наконец, функция SUMMARIZECOLUMNS удаляет из результата строки,
в которых значения всех добавленных столбцов пустые. Неудивительно, что
Power BI использует функцию SUMMARIZECOLUMNS для выполнения почти
всех запросов.
Вот простой пример использования функции SUMMARIZECOLUMNS:
EVALUATE
SUMMARIZECOLUMNS (
'Product'[Category];
'Date'[Calendar Year];
"Anount"; [Sales Anount]
)
ORDER BY
'Product'[Category];
'Date'[Calendar Year]
Этот запрос группирует информацию по категориям товаров и годам, рас-
считывая сумму продаж в рамках контекста фильтра, содержащего текущую
категорию и год, для каждой строки. Результат выполнения запроса показан
на рис. 13.8.
Годы, в которые не было продаж (например, 2005-й), не включаются в итого-
вый вывод. Причина в том, что для этих строк результат добавленного столбца
с суммой продаж является пустым значением, а этого достаточно для его ис-
ключения из итогового набора. Если разработчик хочет, чтобы строки с пустыми
значениями остались в результирующей таблице, он может использовать моди-
фикатор IGNORE, как показано в измененной версии предыдущего запроса:
EVALUATE
SUMMARIZECOLUMNS (
442 ГЛАВА 13 Создание запросов
'Product'[Category];
'Date'[Calendar Year];
"Amount"; IGNORE ( [Sales Amount] )
)
ORDER BY
'Product'[Category];
'Date'[Calendar Year]
Category Calendar Year Amount
Audio CY 2007 102,722.07
Audio CY 2008 105,363.42
Audio CY 2009 176,432.67
Cameras and camcorders CY 2007 3,274,847.26
Cameras and camcorders CY 2008 2,184,189.54
Cameras and camcorders CY 2009 1,733,545.15
Cell phones CY 2007 477,451.74
Cell phones CY 2008 462,713.47
Cell phones CY 2009 664,445.05
Рис. 13.8 Таблица содержит сгруппированные категории и годы,
а также суммы продаж по этим комбинациям
Как результат функция SUMMARIZECOLUMNS проигнорирует тот факт, что
в столбце Sales Amount содержится пустое значение, и включит его в набор.
В результате мы получим таблицу с пустыми значениями в столбце с продажа-
ми, как видно по рис. 13.9.
Category Calendar Year Amount
Audio CY 2005
Audio CY 2006
Audio CY2007 102,722.07
Audio CY2008 105,363.42
Audio CY2009 176,432.67
Audio CY 2010
Audio CY 2011
Cameras and camcorders CY 2005
Cameras and camcorders CY 2006
Cameras and camcorders CY 2007 3,274,847.26
Cameras and camcorders CY 2008 2,184,189.54
Cameras and camcorders CY 2009 1,733,545.15
Рис. 13.9 Использование модификатора IGNORE позволяет сохранять
в результирующем наборе строки с пустыми значениями
Если функция SUMMARIZECOLUMNS добавляет к набору несколько столбцов,
разработчик вправе выбирать, по каким из них игнорировать пустые значения
(при помощи модификатора IGNORE), а по каким осуществлять проверку. На
практике же обычно принято удалять все строки с пустыми значениями.
ГЛАВА 13 Создание запросов 443
Функция SUMMARIZECOLUMNS позволяет также подсчитывать промежу-
точные итоги, используя функции ROLLUPADDISSUBTOTAL и ROLLUPGROUP.
Если бы вам при написании предыдущего запроса потребовалось выводить
подытог по годам, вы могли бы пометить столбец Date [Calendar Year] при помо-
щи функции ROLLUPADDISSUBTOTAL, указав также имя дополнительного столб-
ца, в котором будет выводиться информация о том, подытог в строке или нет:
EVALUATE
SUMMARIZECOLUMNS (
'Product'[Category];
ROLLUPADDISSUBTOTAL (
'Date'[Calendar Year];
"YearTotal"
);
"Amount"; [Sales Amount]
)
ORDER BY
'Product'[Category];
'Date'[Calendar Year]
Теперь в результирующий набор будут включены строки с подытогами по
годам, а также дополнительный столбец YearTotal, в котором для строк с про-
межуточными итогами будет стоять значение TRUE. На рис. 13.10 показан вы-
вод этого запроса с выделенными строками итогов.
Category Calendar Year YearTotal Amount
Audio True 384,518.16
Audio CY 2007 False 102,722.07
Audio CY 2008 False 105,363.42
Audio CY 2009 False 176,432.67
Cameras and camcorders True 7,192,581.95
Cameras and camcorders CY 2007 False 3,274,847.26
Cameras and camcorders CY 2008 False 2,184,189.54
Cameras and camcorders CY 2009 False 1,733,545.15
Cell phones True 1,604,610.26
Cell phones CY 2007 False 477,451.74
Cell phones CY 2008 False 462,713.47
Cell phones CY 2009 False 664,445.05
Рис. 13.10 Функция ROLLUPADDISSUBTOTAL добавляет строки с подытогами
и создает дополнительный столбец с информацией о них
Группируя таблицу по множеству столбцов, вы можете пометить функци-
ей ROLLUPADDISSUBTOTAL сразу несколько из них. Это позволит создать не-
сколько уровней группировки. Например, следующий запрос подводит проме-
жуточные итоги по категориям товаров для всех годов, а также по годам для
всех категорий:
EVALUATE
SUMMARIZECOLUMNS (
444 ГЛАВА 13 Создание запросов
ROLLUPADDISSUBTOTAL (
'Product'[Category];
"CategoryTotal"
);
ROLLUPADDISSUBTOTAL (
'Date'[Calendar Year];
"YearTotal"
);
"Anount"; [Sales Anount]
)
ORDER BY
'Product'[Category];
'Date'[Calendar Year]
Строки с подытогами по годам без категорий и по категориям без разделе-
ния на годы выделены на рис. 13.11.
Category Calendar Year CategoryTotal True YearTotal Amount
True 30,591,343.98
CY 2007 True False 11,309,946.12
CY 2008 True False 9,927,582.99
CY 2009 True False 9,353,814.87
Audio False True 384,518.16
Audio CY 2007 False False 102,722.07
Audio CY 2008 False False 105,363.42
Audio CY 2009 False False 176,432.67
Cameras and camcorders False True 7,192,581.95
Cameras and camcorders CY 2007 False False 3,274,847.26
Cameras and camcorders CY 2008 False False 2,184,189.54
Cameras and camcorders CY 2009 False False 1,733,545.15
Рис. 13.11 Функция ROLLUPADDISSUBTOTAL способна группировать таблицу
по нескольким колонкам
Если вам нужно вывести подытоги для группы столбцов, вам придет на по-
мощь модификатор ROLLUPGROUP. В следующем запросе добавляется объеди-
ненный подытог по категориям и годам, в результате чего в таблице появляет-
ся только одна дополнительная строка:
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
'Product'[Category];
'Date'[Calendar Year]
);
"CategoryYearTotal"
);
"Anount"; [Sales Anount]
ГЛАВА 13 Создание запросов 445
)
ORDER BY
'Product'[Category];
'Date'[Calendar Year]
На рис. 13.12 показан результат с одной строкой с итогами.
Category Calendar Year CategoryYearTotal Amount
True 30,591,343.98
Audio CY 2007 False 102,722.07
Audio CY 2008 False 105,363.42
Audio CY 2009 False 176,432.67
Cameras and camcorders CY 2007 False 3,274,847.26
Cameras and camcorders CY 2008 False 2,184,189.54
Cameras and camcorders CY 2009 False 1,733,545.15
Cell phones CY 2007 False 477,451.74
Cell phones CY 2008 False 462,713.47
Рис. 13.12 Функция ROLLUPADDISSUBTOTAL создает дополнительную строку
и столбец с подытогами
Последней особенностью функции SUMMARIZECOLUMNS является способ-
ность фильтровать результат по подобию функции CALCULATETABLE. Вы мо-
жете указать один или несколько фильтров, используя таблицы в качестве до-
полнительных аргументов. Например, следующий запрос извлекает продажи
только по покупателям с определенным уровнем образования (High School).
Результат будет таким же, как на рис. 13.12, но с меньшими суммами:
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
'Product'[Category];
'Date'[Calendar Year]
);
"CategoryYearTotal"
);
FILTER (
ALL ( Custoner[Education] );
Custoner[Education] = "High School"
);
"Anount"; [Sales Anount]
)
Заметим, что в функции SUMMARIZECOLUMNS использование лаконичного
синтаксиса аргументов фильтра в виде предикатов, как в функциях CALCULATE
и CALCULATETABLE, запрещено. А значит, следующий код выдаст ошибку:
EVALUATE
SUMMARIZECOLUMNS (
446 ГЛАВА 13 Создание запросов
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
'Product'[Category];
'Date'[Calendar Year]
);
"CategoryYearTotal"
);
Customer[Education] = "High School"; -- Такой синтаксис недопустим
"Amount"; [Sales Amount]
)
Причина в том, что аргументы фильтра в функции SUMMARIZECOLUMNS
должны быть выражены в виде таблиц, и никакие сокращения в этом случае
неприемлемы. Самый простой и компактный способ добавить фильтр к функ-
ции SUMMARIZECOLUMNS - использовать функцию TREATAS:
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
'Product'[Category];
'Date'[Calendar Year]
);
"CategoryYearTotal"
);
TREATAS ( { "High School" }; Customer[Education] );
"Amount"; [Sales Amount]
)
Как вы заметили, функция SUMMARIZECOLUMNSявляется очень мощной, но
у нее есть свои серьезные ограничения. Дело в том, что она не может быть вы-
звана, если было произведено преобразование внешнего контекста фильтра.
Именно поэтому эта функция так полезна в запросах, но не может полноцен-
но заменить связку функций ADDCOLUMNS и SUMMARIZE в мерах, поскольку
просто не будет работать в большинстве отчетов. Меры часто используются
в таких элементах визуализации, как матрица или график, где они внутренне
вычисляются в контексте строки для каждого значения, выведенного в отчет.
Чтобы еще лучше продемонстрировать ограничения функции SUMMARIZE-
COLUMNS в рамках контекста строки, рассмотрим следующий запрос для вы-
числения суммарных продаж по товарам с использованием неэффективного,
но допустимого способа:
EVALUATE
{
SUMX (
VALUES ( 'Product'[Category] );
CALCULATE (
SUMX (
ADDCOLUMNS (
VALUES ( 'Product'[Subcategory] );
"SubcategoryTotal"; [Sales Amount]
ГЛАВА 13 Создание запросов 447
);
[SubcategoryTotal]
)
)
)
}
Если заменить функцию ADDCOLUMNS на SUMMARIZECOLUMNS, запрос вы-
даст ошибку по причине того, что функция SUMMARIZECOLUMNS была вызва-
на в контексте, преобразованном функцией CALCULATE. А значит, следующий
запрос не выполнится:
EVALUATE
{
SUMX (
VALUES ( 'Product'[Category] );
CALCULATE (
SUMX (
SUMMARIZECOLUMNS (
'Product'[Subcategory];
"SubcategoryTotal''; [Sales Anount]
);
[SubcategoryTotal]
)
)
)
}
В основном функция SUMMARIZECOLUMNS неприменима в мерах, посколь-
ку меры обычно рассчитываются в рамках сложных запросов, сгенерирован-
ных клиентскими инструментами. Такие запросы с большой степенью вероят-
ности используют концепцию преобразования контекста, а значит, функции
SUMMARIZECOLUMNS в них просто не место.
Функция TOPN
Функция TOPN позволяет отсортировать таблицу и вернуть набор из заданно-
го количества строк. Это бывает полезно, когда нужно ограничить количество
возвращаемых строк. Например, когда Power BI показывает содержимое таб-
лицы, вся таблица целиком не извлекается. Вместо этого из таблицы берется
несколько первых строк для заполнения экранного пространства. Оставшаяся
часть таблицы извлекается по требованию, когда пользователь осуществляет
прокрутку в элементе визуализации. Также функцию TOPN можно использо-
вать для получения ограниченного списка лучших элементов по тому или ино-
му показателю, например лучших покупателей, товаров и т. д.
Три товара с максимальной суммой продаж можно получить при помощи
следующего запроса, вычисляющего меру Sales Amount для каждой строки
в таблице Product:
EVALUATE
TOPN (
448 ГЛАВА 13 Создание запросов
3;
'Product';
[Sales Anount]
)
В результирующей таблице будут содержаться все столбцы из исходной таб-
лицы. Но обычно при выполнении подобных запросов пользователя не ин-
тересуют все столбцы, так что лучше предварительно ограничить исходную
таблицу нужными столбцами. В следующем примере показана версия запроса
с уменьшенным количеством столбцов. Результат выполнения запроса отобра-
жен следом на рис. 13.13:
EVALUATE
VAR ProductsBrands =
SUMMARIZE (
Sales;
'Product'[Product Nane];
'Product'[Brand]
)
VAR Result =
TOPN (
3;
ProductsBrands;
[Sales Anount]
)
RETURN Result
ORDER BY 'Product'[Product Nane]
Product Name
A. Datum SLR Camera X137 Grey
Brand
A. Datum
Adventure Works 26" 720p LCD HDTV M140 Silver Adventure Works
Contoso Telephoto Conversion Lens X400 Silver Contoso
Рис. 13.13 Функция TOPN фильтрует исходную таблицу
на основании значений столбца Sales Amount
Вероятно, вам может понадобиться включить в итоговый набор и саму меру
Sales Amount, чтобы можно было правильно отсортировать результаты. В таком
случае лучше всего будет добавить нужный столбец к предварительно сгруп-
пированной исходной таблице, после чего выполнять функцию TOPN. Таким
образом, один из самых распространенных шаблонов применения функции
TOPNпоказан в следующем примере:
EVALUATE
VAR ProductsBrands =
SUMMARIZE (
Sales;
'Product'[Product Nane];
'Product'[Brand]
)
ГЛАВА 13 Создание запросов 449
VAR ProductsBrandsSales =
ADDCOLUMNS (
ProductsBrands;
"Product Sales"; [Sales Amount]
)
VAR Result =
TOPN (
3;
ProductsBrandsSales;
[Product Sales]
)
RETURN Result
ORDER BY [Product Sales] DESC
Результат выполнения этого запроса показан на рис. 13.14.
Product Name Brand Product Sales
Adventure Works 26" 720p LCD HDTV M140 Silver Adventure Works 1,303,983.46
A. Datum SLR Camera X137 Grey A. Datum 725,840.28
Contoso Telephoto Conversion Lens X400 Silver Contoso 683,779.95
Рис. 13.14 Функция TOPN возвращает первые N строк таблицы,
отсортированной по выражению
Исходная таблица может быть отсортирована как по возрастанию, так и по
убыванию значения для отбора. По умолчанию сортировка будет выполне-
на по убыванию, чтобы возвращать строки с максимальными значениями
фильтруемого столбца. Четвертый необязательный параметр функции TOPN
как раз и призван устанавливать направление сортировки. Значения 0 или
FALSE (по умолчанию) отсортируют таблицу по убыванию, а 1 или TRUE - по
возрастанию.
Важно Не путайте сортировку исходных данных при помощи функции TOPN с сортиров-
кой результирующего набора, осуществляемой посредством ключевого слова ORDER BY
инструкции EVALUATE. Параметр функции TOPN отвечает исключительно за сортировку
таблицы, генерируемой этой функцией.
ч________________________________________________________________________________)
Если в исходной таблице присутствуют строки с одинаковыми значениями
фильтруемого столбца, функция TOPN не гарантирует возвращения запро-
шенного количества строк. Вместо этого она вернет все строки с одинаковыми
значениями. Например, в следующем запросе мы запросили четыре ведущих
бренда по суммам продаж, при этом искусственно создав дубликаты за счет
использования функции округления MROUND:
EVALUATE
VAR SalesByBrand =
ADDCOLUMNS (
VALUES ( 'Product'[Brand] );
"Product Sales"; MROUND ( [Sales Amount]; 1000000 )
450 ГЛАВА 13 Создание запросов
)
VAR Result =
TOPN (
4;
SalesByBrand;
[Product Sales]
)
RETURN Result
ORDER BY [Product Sales] DESC
В результирующий набор было включено пять строк, а не четыре, как мы
запросили, поскольку у брендов Litware и Proseware оказались одинаковые
продажи, округленные до миллиона, по 3 000 000. Обнаружив дублирующие-
ся значения, функция TOPN вернула обе записи, не зная, какой из них отдать
предпочтение, как видно по рис. 13.15.
Brand
Contoso
Fabrikam
Adventure Works
Litware
Proseware
Product Sales
7,000,000.00
6,000,000.00
4,000,000.00
3,000,000.00
3,000,000.00
Рис. 13.15 В присутствии одинаковых
значений функция TOPN может вернуть
больше строк, чем мы запросили
Во избежание этого в функцию TOPN можно передать дополнительные
столбцы для сортировки. Фактически вместо одного третьего параметра мож-
но использовать целый перечень столбцов. Например, чтобы выбрать первые
четыре бренда по продажам и при этом в случае равенства значений отдать
предпочтение тому из них, который стоит выше в алфавитном порядке, можно
использовать следующую запись:
EVALUATE
VAR SalesByBrand =
ADDCOLUMNS (
VALUES ( 'Product'[Brand] );
"Product Sales"; MROUND ( [Sales Amount]; 1000000 )
)
VAR Result =
TOPN (
4;
SalesByBrand;
[Product Sales]; 0;
'Product'[Brand]; 1
)
RETURN Result
ORDER BY [Product Sales] DESC
В результате, показанном на рис. 13.16, исчез бренд Proseware, поскольку
конкурирующий с ним Litware находится выше в алфавитном порядке. Заметь-
те, что в запросе мы использовали сортировку по убыванию для суммы продаж
и по возрастанию - для наименования брендов.
ГЛАВА 13 Создание запросов 451
Brand Product Sales
Contoso Fabrikam 7,000,000.00 6,000,000.00
Adventure Works 4,000,000.00
Litware 3,000,000.00
Рис. 13.16 Используя дополнительные параметры сортировки,
можно избавиться от конфликтов одинаковых значений в итоговой таблице
Помните, что даже добавление дополнительных параметров сортировки не
гарантирует вам извлечения строго запрошенного количества строк. Функция
TOPN все равно может возвращать больше строк в случае полной идентич-
ности фильтруемых столбцов. Дополнительные параметры сортировки лишь
снижают вероятность появления одинаковых значений. Если же вам необхо-
димо гарантированно получить указанное количество строк из таблицы, стоит
добавить к порядку сортировки поле с уникальными значениями, что исклю-
чит вероятность появления дубликатов.
Рассмотрим более сложный пример использования функции TOPN с при-
менением функций для работы со множествами и переменных. Нам необхо-
димо сформировать отчет по десяти лучшим товарам, исходя из суммы про-
даж, и при этом добавить в вывод дополнительную строку Others (Остальные),
в которой будут объединены все оставшиеся позиции. Возможная реализация
этого примера показана ниже:
EVALUATE
VAR NumOfTopProducts = 10
VAR ProdsWithSales =
ADDCOLUMNS (
VALUES ( 'Product'[Product Nane] );
"Product Sales"; [Sales Anount]
)
VAR TopNProducts =
TOPN (
NunOfTopProducts;
ProdsWithSales;
[Product Sales]
)
VAR RenainingProducts =
EXCEPT ( ProdsWithSales; TopNProducts )
VAR OtherRow =
ROW (
"Product Nane"; "Others";
"Product Sales"; SUMX (
RenainingProducts;
[Product Sales]
)
)
VAR Result =
UNION ( TopNProducts; OtherRow )
RETURN Result
ORDER BY [Product Sales] DESC
452 ГЛАВА 13 Создание запросов
Сначала мы сохраняем в переменной ProdsWithSales таблицу по всем това-
рам с продажами. Затем выбираем из этого списка первые десять позиций,
записывая результат в переменную TopNProducts. В переменной Remaining-
Products мы воспользовались функцией EXCEPT для извлечения всех товаров,
не вошедших в десять лучших. Разбив исходный набор товаров на две группы
(TopNProducts и RemainingProducts), мы создаем таблицу из одной строки с име-
нем товара Others, в которой подсчитана сумма продаж по всем оставшимся то-
варам из переменной RemainingProducts. После этого мы используем функцию
UNION для объединения наборов из десяти лучших товаров с агрегированной
строкой из остальных. Результат выполнения запроса показан на рис. 13.17.
Product Name
Product Sales
Others 26,444,863.03
Adventure Works 26" 720p LCD HDTV M140 Silver 1,303,983.46
A. Datum SLR Camera X137 Grey 725,840.28
Contoso Telephoto Conversion Lens X400 Silver 683,779.95
SV 16xDVD M360 Black 364,714.41
Contoso Projector 1080p X980 White 257,154.75
Contoso Washer & Dryer 21 in E210 Pink 182,094.12
Fabrikam Independent filmmaker 1/3" 8.5mm X200 White 165,594.00
Proseware Projector 1080p LCD86 Silver 160,627.05
NT Washer & Dryer 27in L2700 Blue 151,427.53
Contoso Washer & Dryer 21 in E210 Green 151,265.40
Рис. 13.17 В дополнительной строке Others собраны все товары,
не включенные в первую десятку
Результаты в отчете выводятся правильные, но внешний вид немного сму-
щает. Строка с остальными товарами выводится первой в списке и может рас-
полагаться в любом месте отчета в зависимости от значения в ней. Вам же,
скорее всего, захочется разместить эту строку в конце списка, тогда как первые
десять товаров должны находиться вверху с сортировкой по сумме продаж по
убыванию.
Эту задачу можно решить путем добавления столбца для сортировки, кото-
рый отправит строку с остальными товарами в нижнюю часть списка:
EVALUATE
VAR NumOfTopProducts = 10
VAR ProdsWithSales =
ADDCOLUMNS (
VALUES ( 'Product'[Product Name] );
"Product Sales"; [Sales Amount]
)
VAR TopNProducts =
TOPN (
NumOfTopProducts;
ProdsWithSales;
[Product Sales]
)
ГЛАВА 13 Создание запросов 453
VAR RemainingProducts =
EXCEPT ( ProdsWithSales; TopNProducts )
VAR RankedTopProducts =
ADDCOLUMNS(
TopNProducts;
"SortColumn"; RANKX ( TopNProducts; [Product Sales] )
)
VAR OtherRow =
ROW (
"Product Name"; "Others";
"Product Sales"; SUMX (
RemainingProducts;
[Product Sales]
);
"SortColumn"; NumOfTopProducts + 1
)
VAR Result =
UNION ( RankedTopProducts; OtherRow )
RETURN
Result
ORDER BY [SortColumn]
Результат выполнения этого запроса показан на рис. 13.18.
Product Name Product Sales SortColumn
Adventure Works 26" 720p LCD HDTV M140 Silver 1,303,983.46 1
A. Datum SLR Camera X137 Grey 725,840.28 2
Contoso Telephoto Conversion Lens X400 Silver 683,779.95 3
SV 16xDVD M360 Black 364,714.41 4
Contoso Projector 1080p X980 White 257,154.75 5
Contoso Washer & Dryer 21 in E210 Pink 182,094.12 6
Fabrikam Independent filmmaker 1/3" 8.5mm X200 White 165,594.00 7
Proseware Projector 1080p LCD86 Silver 160,627.05 8
NT Washer & Dryer 27in L2700 Blue 151,427.53 9
Contoso Washer & Dryer 21 in E210 Green 151,265.40 10
Others 26,444,863.03 11
Рис. 13.18 При наличии столбца SortColumn
разработчик может свободно управлять сортировкой результирующей таблицы
Функции GENERATE и GENERATEALL
GENERATE является очень мощной функцией, реализующей логику инструк-
ции OUTER APPLY из языка SQL. В качестве параметров функция GENERATE
принимает таблицу и выражение. Функция проходит по таблице, вычисляет
выражение в контексте строки итерации и затем объединяет строку текущей
итерации с таблицей, возвращенной переданным выражением. Поведение
этой функции похоже на объединение, но вместо объединения с таблицей про-
исходит связка с результатом выражения, выполненного для каждой строки.
Это очень универсальная функция.
454 ГЛАВА 13 Создание запросов
Чтобы продемонстрировать поведение функции GENERATE, вернемся к пре-
дыдущему примеру с TOPN и расширим его. На этот раз вместо подсчета луч-
ших товаров за всю историю работы компании мы будем выделять первую
тройку за каждый год. Эту задачу можно разбить на два шага: создать выраже-
ние для подсчета первых трех товаров, а затем вычислить его для каждого года.
Одним из способов расчета первых трех товаров является следующий:
EVALUATE
VAR ProductsSold =
SUMMARIZE (
Sales;
'Product'[Product Name]
)
VAR ProductsSales =
ADDCOLUMNS (
ProductsSold;
"Product Sales"; [Sales Amount]
)
VAR Top3Products =
TOPN (
3;
ProductsSales;
[Product Sales]
)
RETURN
Top3Products
ORDER BY [Product Sales] DESC
В результате, показанном на рис. 13.19, выведены только три товара.
Product Name
Product Sales
Adventure Works 26" 720p LCD HDTV M140 Silver 1,303,983.46
A. Datum SLR Camera X137 Grey 725,84028
Contoso Telephoto Conversion Lens X400 Silver 683,779.95
Рис. 13.19 Функция TOPN возвращает три самых прибыльных товара
за всю историю компании
Если предыдущий запрос выполнить в рамках контекста фильтра, включаю-
щего конкретный год, результат будет иным. Формула вернет самые продава-
емые товары за этот год. И здесь приходит на помощь функция GENERATE. Мы
используем ее для осуществления итераций по годам и вычисления выраже-
ния с функцией TOPN для каждой итерации. Для каждого года функция TOPN
вернет по три товара, после чего функция GENERATE объединит полученные
результаты по итерациям. Вот как может выглядеть этот запрос:
EVALUATE
GENERATE (
VALUES ( 'Date*[Calendar Year] );
CALCULATETABLE (
VAR ProductsSold =
ГЛАВА 13 Создание запросов 455
SUMMARIZE ( Sales; 'Product'[Product Name] )
VAR ProductsSales =
ADDCOLUMNS ( ProductsSold; "Product Sales"; [Sales Anount] )
VAR Top3Products =
TOPN ( 3; ProductsSales; [Product Sales] )
RETURN Top3Products
)
)
ORDER BY
'Date'[Calendar Year];
[Product Sales] DESC
Результат выполнения запроса показан на рис. 13.20.
Calendar Year Product Name
Product Sales
CY 2007 Adventure Works 26" 720р LCD HDTV М140 Silver 1,289,602.38
CY 2007 A. Datum SLR Camera X137 Grey 716,435.28
CY 2007 Contoso Telephoto Conversion Lens X400 Silver 675,449.95
CY 2008 Litware Refrigerator 24.7CuFt X980 White 135,039.58
CY 2008 Litware Refrigerator 24.7CuFt X980 Blue 100,479.69
CY 2008 Litware Refrigerator 24.7CuFt X980 Grey 93,759.71
CY 2009 Fabrikam Refrigerator 24.7CuFt X9800 White 109,759.66
CY 2009 Fabrikam Refrigerator 24.7CuFt X9800 Grey 89,599.72
CY 2009 Contoso Projector 1080p X980 White 71,374.50
Рис. 13.20 Функция GENERATE объединяет годы
с тремя самыми продаваемыми товарами по каждому из них
Если вам необходимо выделить по три товара в рамках категорий товаров,
достаточно обновить таблицу для итераций в функции GENERATE, как показа-
но ниже:
EVALUATE
GENERATE (
VALUES ( 'Product'[Category] );
CALCULATETABLE (
VAR ProductsSold =
SUMMARIZE ( Sales; 'Product'[Product Nane] )
VAR ProductsSales =
ADDCOLUMNS ( ProductsSold; "Product Sales"; [Sales Anount] )
VAR Top3Products =
TOPN ( 3; ProductsSales; [Product Sales] )
RETURN Top3Products
)
)
ORDER BY
'Product'[Category];
[Product Sales] DESC
Как видно по рис. 13.21, теперь в отчете выведены первые тройки товаров
в рамках каждой категории.
456 ГЛАВА 13 Создание запросов
Category Product Name Product Sales
Audio Contoso 4G MP3 Player E400 Silver 47,952.41
Audio NT Bluetooth Stereo Headphones E52 Blue 22,820.17
Audio WWI 2GB Pulse Smart pen M100 Silver 17,655.59
Cameras and camcorders A. Datum SLR Camera X137 Grey 725,840.28
Cameras and camcorders Contoso Telephoto Conversion Lens X400 Silver 683,779.95
Cameras and camcorders Fabrikam Independent filmmaker 1/3" 8.5mm X200 White 165,594.00
Cell phones The Phone Company Touch Screen Phone 1600 TFT-1.4" L250 Grey 32,400.89
Cell phones The Phone Company PDA Handheld 4.7 inch L650 Silver 29,953.00
Cell phones The Phone Company PDA Phone 4.7 inches L360 White 29,888.70
Рис. 13.21 Проходя по категориям, мы можем выделить по три самых популярных товара
в каждой из них
Если выражение, переданное функции GENERATE в качестве второго пара-
метра, возвращает пустую таблицу, эта строка исключается из итогового на-
бора. Если же необходимо оставить их в результирующей таблице, можно ис-
пользовать функцию GENERATEALL. Например, в 2005 году продаж не было,
а значит, для этого года и не может быть самых продаваемых товаров. Функция
GENERATE не включит этот год в итоговый результат, тогда как GENERATEALL
оставит его в выводе, как показано ниже:
EVALUATE
GENERATEALL (
VALUES ( 'Date'[Calendar Year] );
CALCULATETABLE (
VAR ProductsSold =
SUMMARIZE ( Sales; 'Product'[Product Nane] )
VAR ProductsSales =
ADDCOLUMNS ( ProductsSold; "Product Sales"; [Sales Anount] )
VAR Top3Products =
TOPN ( 3; ProductsSales; [Product Sales] )
RETURN Top3Products
)
)
ORDER BY
'Date'[Calendar Year];
[Product Sales] DESC
Результат выполнения этого запроса показан на рис. 13.22.
Функция ISONORAFTER
ISONORAFTER является специальной функцией и часто используется Power BI
и другими инструментами для постраничного вывода отчетов. В то же время
разработчики довольно редко прибегают к ее помощи при написании запро-
сов и мер. Когда пользователь формирует отчет в Power BI, движок извлекает из
источника ровно столько строк, сколько необходимо для размещения на одной
странице. Для этого он использует уже знакомую нам функцию TOPN.
ГЛАВА 13 Создание запросов 457
Calendar Year Product Name Product Sales
CY 2005 CY 2006 CY 2007 Adventure Works 26" 720p LCD HDTV M140 Silver 1,289,60238
CY 2007 A. Datum SLR Camera X137 Grey 716,435.28
CY 2007 Contoso Telephoto Conversion Lens X400 Silver 675,449.95
CY 2008 Litware Refrigerator 24.7CuFt X980 White 135,039.58
CY 2008 Litware Refrigerator 24.7CuFt X980 Blue 100,479.69
CY 2008 Litware Refrigerator 24.7CuFt X980 Grey 93,759.71
CY 2009 Fabrikam Refrigerator 24.7CuFt X9800 White 109,759.66
CY 2009 Fabrikam Refrigerator 24.7CuFt X9800 Grey 89,599.72
CY 2009 CY 2010 CY 2011 Contoso Projector 1080p X980 White 71,374.50
Рис. 13.22 Функция GENERATEALL оставляет в наборе годы,
по которым не было продаж, a GENERATE - нет
При просмотре таблицы с товарами пользователь каждый раз располагает-
ся на определенной странице. Например, на рис. 13.23 последней строкой на
странице является товар Stereo Bluetooth Headphones New Gen, а стрелкой обо-
значена относительная позиция страницы в списке.
Category Color Product Name Sales Amount л
Audio White WWI 2GB Pulse Smart pen M100 White 13,206.70
Audio White WWI 2GB Spy Video Recorder Pen M300 White и W Ml 1 ЙЬ
Audio White WWI Stereo Bluetooth Headphones E1000 W... 2 2^46
Audio White WWI Wireless Bluetooth Stereo Headphones ... 612.00
Audio White WWI Wireless Bluetooth Stereo Headphones ... 1,012.00
Audio White WWI Wireless Transmitter and Bluetooth Hea... 9,112.14
Audio Yellow Contoso 4GB Portable MP3 Player M450 Yellow 1,247.35
Audio Yellow Contoso 8GB MP3 Player new model M820 Ye... 1,782.20
Audio Yellow NT Bluetooth Stereo Headphones E52 Yellow 385.35
Audio Yellow NT Wireless Bluetooth Stereo Headphones E3... 1,986.95
Audio Yellow WWI 4GB Video Recording Pen X200 Yellow 6,541.60
Audio Yellow WWI Stereo Bluetooth Headphones New Gen... 1,861.86
Total 30,591,343.98 *
Рис. 13.23 При сканировании таблицы Product пользователь достиг определенной точки
Прокручивая отчет ниже, пользователь может дойти до последней строки,
которая была извлечена в рамках предыдущего страничного запроса. В этот
момент Power BI выполняет новый запрос, извлекая таким образом следующие
строки. При этом снова будет использована функция TOPN, поскольку Power BI
каждый раз извлекает определенное количество строк из модели данных. Но
важно, чтобы это были следующие строки за просмотренными. Именно здесь
на помощь приходит функция ISONORAFTER. Полный запрос, выполняемый
Power BI во время прокрутки отчета, показан ниже, а результат вывода - на
рис. 13.24:
458 ГЛАВА 13 Создание запросов
EVALUATE
TOPN (
501;
FILTER (
KEEPFILTERS (
SUMMARIZECOLUMNS (
'Product'[Category];
'Product'[Color];
'Product'[Product Name];
"Sales_Amount"; 'Sales'[Sales Anount]
)
);
ISONORAFTER (
'Product'[Category]; "Audio"; ASC;
'Product'[Color]; "Yellow"; ASC;
'Product*[Product Name]; "WWI Stereo Bluetooth Headphones New Generation M370
Yellow"; ASC
)
);
'Product'[Category]; 1;
'Product'[Color]; 1;
'Product'[Product Nane]; 1
)
ORDER BY
'Product'[Category];
'Product'[Color];
'Product'[Product Nane]
Category Color Product Name Sales_Amount
Audio Yellow WWI Stereo Bluetooth Headphones New Generation M370 Yellow 1,861.86
Cameras and camcorders Azure A. Datum Advanced Digital Camera M300 Azure 2,723.83
Cameras and camcorders Azure A. Datum All in One Digital Camera M200 Azure 6,504.80
Cameras and camcorders Azure A. Datum Bridge Digital Camera M300 Azure 10,242.12
Cameras and camcorders Azure A. Datum Compact Digital Camera M200 Azure 7,301.40
Cameras and camcorders Azure A. Datum Consumer Digital Camera E100 Azure 6,406.80
Cameras and camcorders Azure A. Datum Consumer Digital Camera M300 Azure 7,121.70
Рис. 13.24 Следующая страница co строками,
начиная с последней строки в предыдущем наборе
В начале кода запускается функция TOPN501 по результату функции FILTER.
FILTER используется для удаления ранее просмотренных строк, а для того что-
бы получить следующую страницу, прибегает к помощи функции ISONORAFT-
ER. То же самое условие могло быть выражено и через обычную булеву логику.
Фактически фрагмент с функцией ISONORAFTER здесь можно заменить на сле-
дующий код:
'Product'[Category] > "Audio"
|| ( 'Product'[Category] = "Audio" && 'Product'[Color] > "Yellow" )
|| ( 'Product'[Category] = "Audio"
&& 'Product'[Color] = "Yellow"
&& 'Product'[Product Nane]
ГЛАВА 13 Создание запросов 459
WWI Stereo Bluetooth Headphones New Generation M370 Yellow
)
Но функцию ISONORAFTER применять гораздо лучше. Во-первых, код будет
читаться легче, во-вторых, план выполнения запроса с большой степенью ве-
роятности окажется более эффективным.
Функция ADDMISSINGITEMS
ADDMISSINGITEMS - еще одна специальная функция, часто используемая
Power BI и редко - самими разработчиками. Она призвана добавлять в набор
строки, которые могли быть пропущены функцией SUMMARIZECOLUMNS. На-
пример, следующее выражение использует функцию SUMMARIZECOLUMNS
для группирования данных по годам. Результат запроса показан на рис. 13.25.
Calendar Year Amt
CY 2007 11,309,946.12
CY 2008 9,927,582.99
CY 2009 9,353,814.87
Рис. 13.25 Функция SUMMARIZECOLUMNS не включает в вывод годы
с отсутствующими продажами,то есть когда в столбце Amt находится пустое значение
Годы без продаж не включаются в итоговый набор функцией SUMMARIZE-
COLUMNS. Чтобы извлечь строки, проигнорированные функцией SUMMARIZE-
COLUMNS, можно использовать функцию ADDMISSINGITEMS:
EVALUATE
ADDMISSINGITEMS (
'Date'[Calendar Year];
SUMMARIZECOLUMNS (
'Date'[Calendar Year];
"Amt"; [Sales Amount]
);
'Date'[Calendar Year]
)
ORDER BY 'Date'[Calendar Year]
Результат выполнения этого запроса показан на рис. 13.26, где мы выделили
строки, возвращенные функцией SUMMARIZECOLUMNS. Строки с пустым зна-
чением в столбце Amt были добавлены функцией ADDMISSINGITEMS.
Calendar Year Amt CY 2005 CY 2006
2Y 2007 11,309,946.12
2Y 2008 9,927,582.99
2Y 2009 9,353,814.87
CY 2010
CY 2011
Рис. 13.26 Функция ADDMISSINGITEMS добавляет
в результат строки с пустыми значениями
в столбце Amt
460 ГЛАВА 13 Создание запросов
Функция ADDMISSINGITEMS также может принимать различные модифика-
торы и параметры для лучшего контроля над подытогами и фильтрами.
Функция TOPNSKIP
Функция TOPNSKIP преимущественно используется Power BI для вывода не-
скольких строк из объемных исходных данных в области представления дан-
ных. Другие инструменты вроде Power Pivot и SQL Server Data Tools используют
другие техники для быстрого просмотра и фильтрации исходных необработан-
ных данных. Причина для их использования состоит в возможности быстро
просмотреть фрагмент данных без необходимости ожидать материализации
всего набора данных. Функция TOPNSKIP и другие техники подробно описаны
по адресу: http://www.sqLbi.com/articLes/querying-raw-data-to-tabuLar/.
Функция GROUPBY
Функция GROUPBY применяется для выполнения группировки таблицы по
одному или нескольким столбцам, агрегируя значения подобно связке функ-
ций ADDCOLUMNS и SUMMARIZE. Главным отличием функций SUMMARIZE
и GROUPBY является то, что функция GROUPBY умеет группировать столбцы,
для которых привязка данных не соответствует столбцам в модели данных,
тогда как SUMMARIZE допустимо использовать только со столбцами, опреде-
ленными в модели. Кроме того, в столбцах, добавленных к таблице функци-
ей GROUPBY, необходимо использовать итерационные функции вроде SUMX
и AVERAGEX ддя агрегирования данных.
Рассмотрим пример группирования таблицы продаж по году и месяцу
с агрегированием значения суммы продаж. Это возможно сделать при помощи
следующего запроса, результат которого показан на рис. 13.27:
EVALUATE
GROUPBY (
Sales;
1 Date1[Calendar Year];
'Date1[Month];
'Date'[Month Nunber];
"Ant"; AVERAGEX (
CURRENTGROUP ();
Sales[Quantity] * Sales[Net Price]
)
)
ORDER BY
'Date'[Calendar Year];
'Date'[Month Nunber]
В плане производительности функция GROUPBY может проседать приме-
нительно к большим наборам данных - начиная от нескольких десятков тысяч
записей и выше. Фактически функция приступает к осуществлению группиров-
ки только после завершения процесса материализации таблицы, а значит, она
не слишком применима к большим наборам данных. Кроме того, в большин-
ГЛАВА13 Создание запросов 461
стве случаев запросы легче выразить через сочетание функций ADDCOLUMNS
и SUMMARIZE. Предыдущий пример можно записать следующим образом:
EVALUATE
ADDCOLUMNS (
SUMMARIZE (
Sales;
1 Date1[Calendar Year];
'Date1[Month];
'Date'[Month Number]
);
"Amt"; AVERAGEX (
RELATEDTABLE ( Sales );
Sales[Quantity] * Sales[Net Price]
)
)
ORDER BY
'Date'[Calendar Year];
'Date'[Month Number]
Calendar Year Month Month Number Amt
CY 2007 January 1 285.19
CY 2007 February 2 329.44
CY 2007 March 3 355.51
CY 2007 April 4 394.44
CY 2007 May 5 327.68
CY 2007 June 6 398.66
Рис. 13.27 Использование функции GROUPBY
для подсчета средних продаж по годам и месяцам
Примечание Стоит отметить, что в предыдущем запросе на выходе из функции SUMMA-
RIZE будет таблица со столбцами из таблицы Date. Так что когда позже функция AVERAGEX
осуществляет итерации по результату функции RELATEDTABLE, в таблице, возвращенной
функцией RELATEDTABLE, будут год и месяц из текущей итерации функции ADDCOLUMNS
по результирующей таблице из функции SUMMARIZE. Помните, что привязка данных
в этом случае сохранится. Таким образом, на выходе функции SUMMARIZE мы получим
таблицу с привязкой данных.
Одним из преимуществ функции GROUPBYявляется ее способность группи-
ровать столбцы, добавленные к запросу функциями ADDCOLUMNS или SUM-
MARIZE. Ниже приведен пример, где функция SUMMARIZE не может быть ис-
пользована в качестве альтернативы GROUPBY:
EVALUATE
VAR AvgCustomerSales =
AVERAGEX (
Customer;
[Sales Amount]
462 ГЛАВА 13 Создание запросов
)
VAR ClassifiedCustomers =
ADDCOLUMNS (
VALUES ( CustomerfCustomer Code] );
"Customer Category"; IF (
[Sales Amount] >= AvgCustomerSales;
"Above Average"; -- выше среднего
"Below Average" -- ниже среднего
)
)
VAR GroupedResult =
GROUPBY (
ClassifiedCustomers;
[Customer Category];
"Number of Customers"; SUMX (
CURRENTGROUP ();
1
)
)
RETURN GroupedResult
ORDER BY [Customer Category]
Результат выполнения запроса можно видеть на рис. 13.28.
Customer Category Number of Customers
Above Average 807
Below Average 18,062
Рис. 13.28 Функция GROUPBY умеет группировать столбцы,
добавленные в процессе выполнения запроса
Предыдущий пример демонстрирует одновременно и преимущества, и не-
достатки функции GROUPBY. Сначала в коде создается новый столбец в табли-
це с покупателями, в котором вычисляется, приобрел ли данный покупатель
товаров за все время на сумму выше среднего или ниже. В результате мы вы-
полняем группировку по созданному временному столбцу и считаем количест-
во покупателей в обеих категориях.
Выполнение группировки по временному столбцу - очень полезная особен-
ность функции GROUPBY. Но при этом нам приходится использовать итера-
ционную функцию SUMX с проходом по CURRENTGROUP с использованием
константы 1. Причина в том, что столбцы, добавленные в таблицу функци-
ей GROUPBY, обязательно должны вычисляться путем прохождения по CLZR-
RENTGROUP. Простое выражение вроде COUNTROWS ( CURRENTGROUP () ) здесь не
сработает.
Существует не так много сценариев, где функция GROUPBY может оказать-
ся полезной. В основном это случаи, когда необходимо группировать столбцы,
созданные непосредственно в запросе. Стоит также помнить, что столбец, по
которому будет осуществляться группировка, должен обладать низкой крат-
ностью. Иначе вы можете столкнуться с проблемами производительности
и перерасхода памяти.
ГЛАВА 13 Создание запросов 463
Функции NATURALINNERJOIN и NATURALLEFTOUTERJOIN
Движок DAX использует связи в модели данных автоматически всякий раз,
когда разработчик запускает запрос на выполнение. Но иногда бывает полез-
но объединить в запросе две таблицы, не связанные физически. Например, вы
можете объявить табличную переменную и затем связать вычисляемую табли-
цу с этой переменной.
Представьте, что вам необходимо рассчитать средние продажи по кате-
гориям и построить отчет с показом категорий с продажами ниже среднего,
примерно средними и выше среднего. Столбец легко вычислить при помощи
функции SWITCH. Однако если результирующий набор должен быть отсорти-
рован конкретным способом, нам придется одновременно получать описание
категории и порядок сортировки (новый столбец) с использованием похожего
кода.
Но можно поступить иначе - рассчитать только одно из двух значений, а за-
тем использовать временную таблицу со связью для извлечения описания ка-
тегории. Именно такой подход показан в следующем запросе:
EVALUATE
VAR AvgSales =
AVERAGEX (
VALUES ( 1 Product1[Brand] );
[Sales Anount]
)
VAR LowerBoundary = AvgSales * 0.8
VAR UpperBoundary = AvgSales * 1.2
VAR Categories =
DATATABLE (
"Cat Sort"; INTEGER;
"Category"; STRING;
{
{ 0; "Below Average" }; -- ниже среднего
{ 1; "Around Average" }; -- около среднего
{ 2; "Above Average" } -- выше среднего
}
)
VAR BrandsClassified =
ADDCOLUMNS (
VALUES ( 1 Product1[Brand] );
"Sales Ant"; [Sales Anount];
"Cat Sort"; SWITCH (
TRUE ();
[Sales Anount] <= LowerBoundary; 0;
[Sales Anount] >= UpperBoundary; 2;
1
)
)
VAR JoinedResult =
NATURALINNERJOIN (
Categories;
BrandsClassified
464 ГЛАВА 13 Создание запросов
)
RETURN JoinedResult
ORDER BY
[Cat Sort];
'Product1[Brand]
Перед описанием запроса полезно будет взглянуть на его результат, пока-
занный на рис. 13.29.
Cat Sort Category 0 Below Average 0 Below Average 0 Below Average 0 Below Average 0 Below Average 0 Below Average Brand Sales Amt A. Datum 2,096,184.64 Northwind Traders 1,040,552.13 Southridge Video 1,384,413.85 Tailspin Toys 325,042.42 The Phone Company 1,123,819.07 Wide World Importers 1,901,956.66
1 Around Average Litware 3,255,704.03
1 Around Average Proseware 2,546,144.16
2 Above Average AdventureWorks 4,011,112.28
2 Above Average Contoso 7,352,399.03
2 Above Average Fabrikam 5,554,015.73
Рис. 13.29 Столбец Cat Sort должен быть использован
в аргументе «столбец для сортировки» в Category
Сначала мы создаем таблицу с брендами, суммами продаж и столбцом
со значением между 0 и 2. Это значение будет использовано в переменной
Categories в качестве ключа для извлечения описания категории. Финальная
связка между временной таблицей и переменной осуществляется при помо-
щи функции NATURALINNERJOIN, при этом связь устанавливается по столбцу
Cat Sort.
Функция NATURALINNERJOIN выполняет связь между таблицами на основа-
нии столбцов, имеющих одинаковые имена в обеих таблицах. Функция NATU-
RALLEFTOUTERJOIN пелает то же самое, но вместо внутренней связи осуществ-
ляет левое внешнее соединение таблиц. Таким образом, из первой таблицы
сохраняются строки даже в том случае, если для них нет соответствующих строк
во второй таблице.
Если обе таблицы физически определены в модели данных, они могут
быть объединены только посредством связи. Связи помогают получить объ-
единенную информацию из таблиц, как это происходит в SQL. Обе функции -
и NATURALINNERJOIN, и NATURALLEFTOUTERJOIN - используют связи между
таблицами, если они имеются. В противном случае для объединения таблиц
необходимо, чтобы таблицы обладали одинаковой привязкой данных.
Например, следующий запрос возвращает все строки из таблицы Sales,
имеющие соответствующие им строки в таблице Product, при этом в резуль-
тирующий набор будут включены все неповторяющиеся столбцы из обеих
таблиц:
ГЛАВА 13 Создание запросов 465
EVALUATE
NATURALINNERJOIN ( Sales; Product )
Следующий запрос вернет все строки из таблицы Product, включая те товары,
по которым не было записей в таблице Sales:
EVALUATE
NATURALLEFTOUTERJOIN ( Product; Sales )
В обоих случаях столбец, по которому выполняется объединение, будет лишь
раз включен в итоговый набор, в котором будут присутствовать все столбцы из
обеих таблиц.
Серьезным ограничением этих функций является то, что они не могут уста-
навливать соответствие между двумя столбцами с отсутствием связи и разной
привязкой данных. На практике это часто означает, что две таблицы с одним
или более столбцами с одинаковыми именами и без наличия связи не могут
быть объединены. В качестве обходного пути можно использовать знакомую
нам функцию TREATAS для переопределения привязки данных. В статье по
адресу https://www.sqLbi.com/articLes/from-sqL-to-dax-joining-tabLes/ это ограниче-
ние и способы его обхода описаны более подробно.
И все же функции NATURALINNERJOIN и NATURALLEFTOUTERJOIN не так
часто употребляются в языке DAX - гораздо реже, чем аналогичные инструк-
ции в SQL.
Важно Функции NATURALINNERJOIN и NATURALLEFTOUTERJOIN могут оказаться полез-
ными для объединения временных таблиц, в которых привязка данных для столбцов не
соответствует физическим столбцам в модели данных. Чтобы объединить таблицы в мо-
дели, между которыми нет связи, необходимо прибегнуть к помощи функции TREATAS
для переопределения привязки данных столбцов, которые будут использоваться в связке.
Функция SUBSTITUTEWITHINDEX
Функция SUBSTITUTEWITHINDEX служит для замены в наборе строк столб-
цов, соответствующих заголовкам матрицы, на их порядковые номера.
Разработчики не так часто используют эту функцию из-за ее повышенной
сложности. При этом функция SUBSTITUTEWITHINDEX могла бы подойти
при создании динамического пользовательского интерфейса для написания
запросов на DAX. Power BI, например, использует эту функцию при работе
с матрицами.
Представим, что у нас есть матрица в Power BI, показанная на рис. 13.30.
Результатом выполнения запроса на DAX всегда является таблица. Каждая
ячейка в матрице отчета соответствует одной строке в таблице, возвраща-
емой запросом. Чтобы корректно отобразить данные в отчете, Power BI ис-
пользует функцию SUBSTITUTEWITHINDEX для преобразования имен столб-
цов матрицы (CY 2007, CY 2008 и CY 2009) в последовательность чисел для
облегчения заполнения матрицы во время считывания результатов. Приве-
дем упрощенную версию запроса на DAX, сгенерированного для предыдущей
матрицы:
466 ГЛАВА 13 Создание запросов
DEFINE
VAR SalesYearCategory =
SUMMARIZECOLUMNS (
'Product'[Category];
'Date'[Calendar Year];
"Sales_Amount"; [Sales Anount]
)
VAR MatrixRows =
SUMMARIZE (
SalesYearCategory;
'Product'[Category]
)
VAR MatrixColumns =
SUMMARIZE (
SalesYearCategory;
'Date'[Calendar Year]
)
VAR SalesYearCategorylndexed =
SUBSTITUTEWITHINDEX (
SalesYearCategory;
"Columnindex"; MatrixColumns;
'Date'[Calendar Year]; ASC
)
-- Первый результирующий набор: заголовки столбцов матрицы
EVALUATE
MatrixColumns
ORDER BY 'Date'[Calendar Year]
-- Второй результирующий набор: строки матрицы и их содержание
EVALUATE
NATURALLEFTOUTERJOIN (
MatrixRows;
SalesYearCategorylndexed
)
ORDER BY
'Product'[Category];
[Columnindex]
Category CY 2007 CY 2008 CY 2009
Audio 102,722.07 105,363.42 176,432.67
Cameras and camcorders 3,274,847.26 2,184,189.54 1,733,545.15
Cell phones 477,451.74 462,713.47 664,445.05
Computers 2,660,318.87 2,066,341.75 2,014,888.11
Games and Toys 89,860.07 105,738.23 165,054.51
Home Appliances 2,347,281.80 3,962,572.24 3,290,603.00
Music, Movies and Audio Books 87,874.44 120,717.83 105,614.47
TV and Video 2,269,589.88 919,946.50 1,203,231.91
Рис. 13.30 Матрица в Power Bl, построенная при помощи запроса
с использованием функции SUBSTITUTEWITHINDEX
ГЛАВА 13 Создание запросов 467
В запросе содержится два блока EVALUATE. Первый из них возвращает со-
держимое заголовков столбцов, как показано на рис. 13.31.
Calendar Year
CY 2007
CY 2008
CY 2009
Рис. 13.31 Результат извлечения заголовков столбцов
из матрицы в Power Bl
Второй блок EVALUATE возвращает оставшуюся часть матрицы, собирая по
строке для каждой ячейки ее содержимого. В результирующем наборе каждая
строка будет содержать столбцы, необходимые для заполнения заголовков
строк матрицы, следом за которыми будут идти цифры для отображения, а за-
тем столбец с индексом, созданный при помощи функции SUBSTITUTEWITH-
INDEX. Эта таблица показана на рис. 13.32.
Category Sales.Amount Columnindex
Audio 102,722.07 0
Audio 105,363.42 1
Audio 176,432.67 2
Cameras and camcorders 3,274,847.26 0
Cameras and camcorders 2,184,189.54 1
Cameras and camcorders 1,733,545.15 2
Cell phones 477,451.74 0
Cell phones 462,713.47 1
Рис. 13.32 Содержимое строк матрицы в Power Bl,
сгенерированное при помощи функции SUBSTITUTEWITHINDEX
Функция SUBSTITUTEWITHINDEX главным образом используется для по-
строения визуальных элементов в Power BI, таких как матрица.
Функция SAMPLE
Функция SAMPLE предназначена для извлечения ограниченной выборки строк
из таблицы. В качестве аргументов функция принимает требуемое количество
записей, имя таблицы и порядок сортировки. В итоговую выборку функции
SAMPLE включаются первая и последняя строки таблицы, а также недостающее
количество строк до требуемого с равномерным распределением по таблице.
Например, следующий запрос возвращает набор из десяти товаров, пред-
варительно отсортировав таблицу по столбцу Product Name:
EVALUATE
SAMPLE (
10;
ADDCOLUMNS (
VALUES ( 1 Product1[Product Name] );
"Sales"; [Sales Amount]
468 ГЛАВА 13 Создание запросов
);
'Product1[Product Nane]
)
ORDER BY 1 Product1[Product Nane]
Результат выполнения запроса показан на рис. 13.33.
Product Name A. Datum Advanced Digital Camera M300 Azure AdventureWorks Laptop16 M1601 Red Contoso DVD 9-lnch Player Portable M300 White Contoso Rubberized Skin BlackBerry E100 Black Fabrikam Independent Filmmaker 1/3” 8.5mm X200 Blue Litware Home Theater System 2.1 Channel E212 Silver MGS Rise of Nations: Gold Edition 2009 E143 Proseware Projector 720p LCD56 Black The Phone Company PDA Phone Unlocked 3.7 inches M510 Black WWI Wireless Transmitter and Bluetooth Headphones X250 White Sales 2,723.83 25,445.52 1,119.93 8,152.01 69,156.00 18,866.71 3,311.00 14,189.70 8,175.30 9,112.14
Рис. 13.33 Функция SAMPLE возвращает ограниченный набор данных
из таблицы с равномерным распределением
Функция SAMPLE активно используется клиентскими инструментами DAX
для заполнения данными осей на графиках. Также эта функция может приго-
диться при выполнении статистических расчетов на основании ограниченно-
го набора данных из таблицы.
Автоматическая проверка существования данных
в запросах DAX
Многие функции языка DAX поддерживают поведение, известное как авто-
матическая проверка существования (auto-exists). Этот механизм задействует-
ся при объединении в функции двух таблиц. При написании запросов очень
важно помнить об этой особенности DAX, и хотя в большинстве случаев такое
поведение функций является интуитивно понятным, иногда оно может стать
причиной неожиданных результатов.
Рассмотрим следующее выражение:
EVALUATE
SUMMARIZECOLUMNS (
'Product'[Category];
'Product'[Subcategory]
)
ORDER BY
'Product'[Category];
'Product'[Subcategory]
ГЛАВА 13 Создание запросов 469
Результатом выполнения этого запроса может быть как полное перекрест-
ное соединение категорий и подкатегорий, так и список всех существующих
комбинаций значений этих двух столбцов. На самом деле каждая категория
содержит ограниченный список подкатегорий, так что в списке существующих
комбинаций двух столбцов будет меньше строк, чем в их перекрестном соеди-
нении.
Чисто интуитивно кажется, что функция SUMMARIZECOLUMNS должна вы-
давать как раз список комбинаций двух полей. Именно так и происходит на
самом деле, и причина подобного поведения функции кроется в автомати-
ческой проверке существования. В результирующей таблице, показанной на
рис. 13.34, для категории выведены только три подкатегории, а не перечень из
всех возможных подкатегорий.
Category
Audio
Audio
Audio
Cameras and camcorders
Cameras and camcorders
Cameras and camcorders
Cameras and camcorders
Cell phones
Subcategory
Bluetooth Headphones
MP4&MP3
Recording Pen
Camcorders
Cameras & Camcorders Accessories
Digital Cameras
Digital SLR Cameras
Cell phones Accessories
Рис. 13.34 Функция SUMMARIZECOLUMNS возвращает список
из существующих комбинаций значений
Автоматическая проверка существования вступает в силу всякий раз, когда
в запросе осуществляется группировка по столбцам из одной и той же табли-
цы. И когда этот механизм задействован, в результирующий набор автомати-
чески включаются только существующие комбинации значений столбцов. Это
приводит к уменьшению количества строк в выводе и использованию более
эффективного плана выполнения запроса. Если же в запросе применяются
столбцы из разных таблиц, результат будет другим. В этом случае функция
SUMMARIZECOLUMNS выдаст полное перекрестное соединение двух столбцов.
Это видно на примере следующего запроса, результат выполнения которого
показан на рис. 13.35:
EVALUATE
SUMMARIZECOLUMNS (
'Product1[Category];
'Date'[Calendar Year]
)
ORDER BY
'Product1[Category];
'Date'[Calendar Year]
Несмотря на то что обе таблицы объединены посредством связей с таблицей
Sales и в модели данных присутствуют годы, в которые не было транзакций, ме-
470 ГЛАВА 13 Создание запросов
ханизм автоматической проверки существования не активируется, поскольку
столбцы в выражении используются из разных таблиц.
Category Calendar Year
Audio CY 2005
Audio CY 2006
Audio CY 2007
Audio CY 2008
Audio CY 2009
Audio CY 2010
Audio CY 2011
Cameras and camcorders CY 2005
Cameras and camcorders CY 2006
Cameras and camcorders CY 2007
Cameras and camcorders CY 2008
Cameras and camcorders CY 2009
Cameras and camcorders CY 2010
Cameras and camcorders CY 2011
CpH nhnne^; rv 7ППЧ
Рис. 13.35 При участии в выражении столбцов из разных таблиц
создается их полное перекрестное соединение
Помните о том, что функция SUMMARIZECOLUMNS удаляет строки, в кото-
рых во всех дополнительных столбцах агрегированные значения возвраща-
ют пустоту. Таким образом, если в предыдущий запрос добавить меру Sales
Amount, функция SUMMARIZECOLUMNS исключит из результирующего набора
годы и категории, по которым не было продаж, как показано на рис. 13.36:
DEFINE
MEASURE Sales[Sales Amount] =
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
EVALUATE
SUMMARIZECOLUMNS (
'Product1[Category];
1 Date1[Calendar Year];
"Sales"; [Sales Amount]
)
ORDER BY
'Product1[Category];
'Date'[Calendar Year]
Поведение предыдущего запроса не согласуется с механизмом автоматиче-
ской проверки существования, поскольку его выполнение основывается на ре-
зультате вычисления, включающего агрегацию. Константные выражения при
этом игнорируются. Например, если вместо пустого значения мы будем выво-
дить 0, будет сформирован список из всех категорий по всем годам. Результат
выполнения следующего запроса показан на рис. 13.37:
ГЛАВА 13 Создание запросов 471
DEFINE
MEASURE Sales[Sales Amount] =
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
EVALUATE
SUMMARIZECOLUMNS (
'Product1[Category];
1 Date1[Calendar Year];
"Sales"; [Sales Amount] + 0 -- Возвращает 0 вместо пустого значения
)
ORDER BY
'Product'[Category];
'Date'[Calendar Year]
Category Calendar Year Sales
Audio CY 2007 102,722.07
Audio CY 2008 105,363.42
Audio CY 2009 176,432.67
Cameras and camcorders CY 2007 3,274,847.26
Cameras and camcorders CY 2008 2,184,189.54
Cameras and camcorders CY 2009 1,733,545.15
Cell nhnne<? CY 2007 477 451 74
Рис. 13.36 Присутствие столбца с агрегацией
привело к исключению строк с пустыми значениями
Category Calendar Year Sales
Audio CY 2005 0.00
Audio CY 2006 0.00
Audio CY 2007 102,722.07
Audio CY 2008 105,363.42
Audio CY 2009 176,432.67
Audio CY 2010 0.00
Audio CY 2011 0.00
Cameras and camcorders CY 2005 0.00
Cameras and camcorders CY 2006 0.00
Cameras and camcorders CY 2007 3,274,847.26
Cameras and camcorders CY 2008 2,184,189.54
Cameras and camcorders CY 2009 1,733,545.15
Cameras and camcorders CY 2010 0.00
Cameras and camcorders CY 2011 0.00
Cell ohones CY 2005 0.00
Рис. 13.37 При возвращении нуля вместо пустого значения
функция SUMMARIZECOLUMNS выводит все строки
В то же время такой подход для вывода всех строк не сработает, если столбцы
в функции будут браться из одной таблицы. В этом случае всегда будет приме-
472 ГЛАВА 13 Создание запросов
няться механизм автоматической проверки существования. Например, в сле-
дующем примере в выводе останутся только существующие комбинации по-
лей Category и Subcategory, несмотря на то что в мере мы по-прежнему выводим
О вместо пустого значения:
DEFINE
MEASURE Sales[Sales Amount] =
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
EVALUATE
SUMMARIZECOLUMNS (
'Product'[Category];
'Product'[Subcategory];
"Sales"; [Sales Amount] + 0
)
ORDER BY
'Product'[Category];
'Product'[Subcategory]
Результат выполнения этого запроса показан на рис. 13.38.
Category Audio Subcategory Sales Bluetooth Headphones 124,450.79
Audio MP4&MP3 170,194.00
Audio Recording Pen 89,873.37
Cameras and camcorders Camcorders 3,157,075.19
Cameras and camcorders Cameras & Camcorders Accessories 800,534.42
Cameras and camcorders Digital Cameras 784,935.68
Cameras and camcorders Digital SLR Cameras 2,450,036.66
CpH nhnnpc CpH nhnnpc Дггрссппрс 774 ПДР ПЗ
Рис. 13.38 Функция SUMMARIZECOLUMNS применяет автоматическую проверку
существования к столбцам из одной таблицы, даже если агрегация возвращает О
Важно также учитывать особенности работы механизма автоматической
проверки существования применительно к функции ADDMISSINGITEMS. Фак-
тически функция ADDMISSINGITEMS добавляет в итоговый результат строки,
которые были удалены функцией SUMMARIZECOLUMNS по причине наличия
пустых значений. При этом функция ADDMISSINGITEMS не возвращает в ре-
зультирующий набор строки, исключенные в результате применения авто-
матической проверки существования к столбцам из одной таблицы. Так что
в результате выполнения следующего запроса будет возвращен тот же набор
данных, показанный на рис. 13.38, что и в предыдущем примере:
DEFINE
MEASURE Sales[Sales Amount] =
SUMX (
Sales;
ГЛАВА 13 Создание запросов 473
Sales[Quantity] * SalesfNet Price]
)
EVALUATE
ADDMISSINGITEMS (
'Product'[Category];
'Product'[Subcategory];
SUMMARIZECOLUMNS (
'Product1[Category];
1 Product1[Subcategory];
"Sales"; [Sales Amount] + 0
);
'Product'[Category];
'Product'[Subcategory]
)
ORDER BY
'Product'[Category];
'Product'[Subcategory]
О механизме автоматической проверки существования важно помнить
всегда, когда вы используете функцию SUMMARIZECOLUMNS. В то же время
поведение функции SUMMARIZE отличается. Эта функция обязательно требует
указать в качестве параметра таблицу, которую будет использовать как мост
(bridge), соединяющий столбцы. Этот мост обеспечивает поведение, схожее
с автоматической проверкой существования. Например, в следующем фраг-
менте кода мы получим комбинацию из категорий товаров и годов, по кото-
рым есть движения в таблице Sales. Результат выполнения этого запроса по-
казан на рис. 13.39:
EVALUATE
SUMMARIZE (
Sales;
'Product'[Category];
'Date'[Calendar Year]
)
Category
Audio
Audio
Audio
TV and Video
TV and Video
TV and Video
Computers
Computers
Computers
Calendar Year
CY 2007
CY 2008
CY 2009
CY 2007
CY 2008
CY 2009
CY 2007
CY 2008
CY 2009
Рис. 13.39 Функция SUMMARIZE возвращает
комбинацию категорий товаров и годов,
по которым были продажи
Причина того, что в таблице выводятся только сочетания столбцов с прода-
жами, заключается в том, что функция SUMMARIZE использует таблицу Sales
474 ГЛАВА 13 Создание запросов
в качестве отправной точки при выполнении группировки. Таким образом,
категории товаров и годы, на которые нет ссылок в таблице Sales, автомати-
чески исключаются из результатов. Так что даже если результаты и получатся
одинаковыми при выполнении функций SUMMARIZE и SUMMARIZECOLUMNS,
получены они будут совершенно разными способами.
Кроме того, стоит помнить об особенностях выполнения запросов в разных
клиентских инструментах. Например, если пользователь выберет в Power BI
категорию товара и год, не включив при этом меру, отчет выдаст список су-
ществующих комбинаций этих столбцов в таблице Sales. И причина не в том,
что здесь был применен механизм автоматической проверки существования.
Просто Power BI добавляет свои правила к существующей логике DAX. В ре-
зультате простой отчет с годом и категорией товаров превращается в сложный
запрос, показанный ниже:
EVALUATE
TOPN (
501;
SELECTCOLUMNS (
KEEPFILTERS (
FILTER (
KEEPFILTERS (
SUMMARIZECOLUMNS (
1 Date1[Calendar Year];
'Product1[Category];
"CountRowsSales"; CALCULATE ( COUNTROWS ( 'Sales' ) )
)
);
OR (
NOT ( ISBLANK ( 'Date'[Calendar Year] ) );
NOT ( ISBLANK ( 'Product1[Category] ) )
)
)
);
"'Date'[Calendar Year]"; 'Date'[Calendar Year];
"'Product'[Category]"; 'Product'[Category]
);
'Date'[Calendar Year]; 1;
'Product'[Category]; 1
)
Подсвеченная строка показывает, что Power BI добавляет скрытый расчет
количества строк в таблице Sales. А поскольку функция SUMMARIZECOLUMNS
удаляет из набора все строки с пустыми агрегациями, это, по сути, имитирует
применение механизма автоматической проверки существования при сочета-
нии столбцов из одной таблицы.
Power BI добавляет этот скрытый расчет только в случае отсутствия мер
в отчете, используя при этом таблицу, с которой все таблицы в функции SUM-
MARIZECOLUMNS объединены связью «один ко многим». Как только в запросе
появится мера, Power BI удалит этот скрытый расчет и будет опираться на зна-
чение добавленной меры, а не на количество строк в таблице Sales.
ГЛАВА 13 Создание запросов 475
Чаще всего поведение функций SUMMARIZECOLUMNS и SUMMARIZE яв-
ляется интуитивно понятным. Однако в сложных сценариях, например при
наличии связей типа «многие ко многим», результаты могут оказаться не-
ожиданными. В этом коротком разделе мы лишь поверхностно познакоми-
лись с механизмом автоматической проверки существования в DAX. Более по-
дробное описание этой особенности с примерами сложных сценариев можно
найти в статье Understanding DAX Auto-Exist («Понимание механизма автома-
тической проверки существования в DAX») по адресу: https:// www.sqLbi.com/
articLes/understanding-dax-auto-exist/. В этой статье также показаны случаи, ког-
да этот механизм становится причиной появления неожиданных результатов
в отчете.
Заключение
В данной главе мы познакомились с функциями, полезными при написании
запросов. Помните, что все эти функции, за исключением SUMMARIZECOL-
UMNS и ADDMISSINGITEMS, можно применять и при написании мер. Но для
того чтобы писать действительно сложные запросы, вам понадобится опыт со-
четания всех этих функций.
Вот самые важные моменты, которые мы рассмотрели в главе:
некоторые функции DAX более полезны при применении в запросах.
А есть и такие, которые в большинстве случаев применяются даже не раз-
работчиками в процессе написания выражений, а самими клиентскими
инструментами при создании запросов. Но знать об этих функциях все
же необходимо. Когда-нибудь вам придется столкнуться с ними в чужом
коде, и базовые знания в этот момент не помешают;
запросы начинаются с ключевого слова EVALUATE. Используя эту ин-
струкцию, вы можете определять переменные и меры, область видимо-
сти которых будет ограничена конкретным запросом;
инструкцию EVALUATE нельзя использовать для создания вычисляемых
таблиц. Они создаются при помощи выражений. Таким образом, при на-
писании запроса для вычисляемой таблицы вы не можете создавать ло-
кальные меры и столбцы;
функция SUMMARIZE полезна для группировки данных и зачастую ис-
пользуется совместно с функцией ADDCOLUMNS;
функция SUMMARIZECOLUMNS является поистине универсальной. Ее
мощь может пригодиться при написании действительно сложных запро-
сов - недаром она активно используется инструментом Power BI. В то же
время функция SUMMARIZECOLUMNS не может быть использована при
преобразовании контекста. Это серьезно ограничивает ее применение
в мерах;
функция TOPN полезна при извлечении лучших (или худших) представи-
телей в той или иной классификации;
функция GENERATE реализует в DAX логику инструкции OUTER APPLY
из языка SQL. Она применима, когда вам необходимо построить таблицу
476 ГЛАВА 13 Создание запросов
с двумя наборами столбцов: первый выступает в качестве фильтра, а зна-
чения во втором напрямую зависят от первого;
остальные функции из этого раздела в основном используются различ-
ными инструментами при создании запросов.
Также стоит понимать, что все табличные функции, описанные в преды-
дущих главах, могут быть использованы и при написании запросов. Так что
инструментарий создания запросов на языке DAX отнюдь не ограничен функ-
циями, продемонстрированными в данной главе.
ГЛАВА 14
Продвинутые концепции
языка DAX
До этого момента мы рассказали вам все, что знаем сами, об основах DAX, его
фундаментальных принципах, базирующихся на контексте строки, контексте
фильтра и преобразовании контекста. В предыдущих главах мы не раз упо-
минали загадочную главу 14, в которой вы будете посвящены в тайны языка
DAX. Должно быть, вам захочется прочитать эту главу несколько раз, чтобы как
следует усвоить написанное. По опыту можем сказать, что первое прочтение
может вызвать у разработчика вопрос вроде «Почему же это все так сложно?».
Но, изучив концепции, описанные в данной главе, вы начнете понимать, что
у многих трудностей, с которыми вы сталкиваетесь при изучении языка, есть
один общий знаменатель. И как только вы осмыслите это, все станет гораздо
проще.
Ранее мы говорили, что в этой главе вы сможете выйти на качественно но-
вый уровень. И если каждую главу книги рассматривать как очередной уровень
в игре, то сейчас вам предстоит сразиться с боссом! В самом деле, концепции
расширенных таблиц и неявных контекстов фильтра - не самые простые темы
для усвоения. Но когда вы разберетесь, что к чему, то сможете взглянуть на
весь пройденный ранее материал по-новому. Мы бы даже порекомендовали
вам перечитать книгу с начала после окончания этой главы. Второе прочтение
позволит вам докопаться до сути того, что могло ускользнуть от вашего по-
нимания при первом ознакомлении. Мы понимаем, что решение прочитать
книгу повторно требует немалых усилий. Но мы лишь обещали, что эта книга
поможет вам стать настоящим гуру в мире DAX. Никто не говорил, что это бу-
дет легко...
Знакомство с расширенными таблицами
Первая и наиболее важная концепция, которую вам предстоит освоить в дан-
ной главе, - это расширенные таблицы (expanded tables). В DAX каждая табли-
ца имеет свою расширенную версию. Эта версия включает в себя все родные
столбцы таблицы плюс все столбцы из таблиц, находящихся на стороне «один»
в связях типа «многие к одному» с исходной таблицей.
Рассмотрим модель данных, представленную на рис. 14.1.
Расширение таблиц происходит со стороны «многие» к стороне «один». Та-
ким образом, чтобы построить расширенную таблицу, мы начинаем двигаться
478 ГЛАВА 14 Продвинутые концепции языка DAX
от конкретной таблицы и добавляем столбцы из всех остальных таблиц, свя-
занных с текущей и находящихся в этих связях на стороне «один». Например,
таблицы Sales и Product объединены связью «многие к одному», поэтому рас-
ширенная версия таблицы Sales будет включать в себя все столбцы из таблицы
Product. В то же время расширенная версия таблицы Product Category не будет
содержать никаких дополнительных столбцов. И правда, единственной свя-
занной с ней таблицей является Product Subcategory, но она находится в этой
связи на стороне «многие». Таким образом, расширение может происходить
только от таблицы Product Subcategory к Product Category, но не наоборот.
Рис. 14.1 Модель данных для описания концепции расширенных таблиц
Расширение таблиц не ограничивается одним уровнем связей. Например, от
таблицы Sales мы можем легко добраться до таблицы Product Category, проходя
при этом исключительно по связям типа «многие к одному». Таким образом,
расширенная таблица Sales будет включать в себя столбцы из таблиц Product,
Product Subcategory и Product Category. А поскольку таблица Sales также связана
и с Date, в ее расширенную версию войдут и все столбцы из календаря. Иными
словами, расширенная таблица Sales включает в себя всю модель данных.
Но таблице Date мы уделим чуть больше внимания. Фактически она может
быть отфильтрована при помощи таблицы Sales, поскольку связь между этими
двумя таблицами обозначена в модели данных как двунаправленная. Но при
этом она имеет тип «один ко многим», а не «многие к одному». Таким образом,
расширенная версия таблицы Date будет включать только свои столбцы, не-
смотря на возможность осуществления фильтрации со стороны таблиц Sales,
Product, Product Subcategory и Product Category. Дело в том, что механизм фильт-
рации и механизм расширения таблиц никак не связаны друг с другом. Дву-
направленная фильтрация инициируется в коде DAX с использованием совер-
шенно иного механизма, описание которого выходит за рамки данной главы.
Особенности распространения двунаправленных фильтров мы будем более
подробно обсуждать в главе 15.
ГЛАВА 14 Продвинутые концепции языка DAX 479
Повторив те же действия по расширению таблиц, которые мы проделали
с Sales, с остальными таблицами в модели данных, мы получим полное их опи-
сание, представленное в табл. 14.1.
ТАБЛИЦА 14.1 Расширенные версии таблиц
Таблица Расширенная версия
Date Date
Sales Все таблицы в модели данных
Product Product, Product Subcategory, Product Category
Product Subcategory Product Subcategory, Product Category
Product Category Product Category
В модели данных могут присутствовать связи трех разных типов: «один
к одному», «один ко многим» и «многие ко многим». И правило здесь прос-
тое: расширение таблиц всегда выполняется к стороне «один». Следующие
простые примеры помогут вам лучше разобраться с этой концепцией. Пред-
ставьте, что у вас есть модель данных, показанная на рис. 14.2. С точки зрения
моделирования она - далеко не идеал, но в образовательных целях вполне
сгодится.
Рис. 14.2 В этой модели данных обе связи («один к одному» и «многие ко многим»)
двунаправленные
Мы намеренно использовали такие сложные связи в этом примере. В таб-
лице Product Category содержится по одной строке для Subcategory, так что для
каждой категории в этой таблице будет несколько строк, а столбец Product-
CategoryKey будет содержать неуникальные значения. Обе связи при этом
помечены в модели данных как двунаправленные. Связь между таблицами
Product и Product Details имеет тип «один к одному», а связь между Product
и Product Category - «многие ко многим», также именуемый слабой связью
(weak relationship). Но правило для всех одно: расширение таблиц выполня-
ется только к стороне «один» вне зависимости от того, с какой стороны оно
началось.
Соответственно, в представленной модели данных таблицы Product Details
и Product будут взаимно расширяться, и их расширенные версии будут абсо-
лютно идентичными. В то же время таблицы Product Category и Product не рас-
ширяют друг друга, поскольку обе они находятся в связи на стороне «многие».
В таких случаях расширения таблиц не происходит. Когда обе таблицы распо-
ложены в связи на стороне «многие», эта связь автоматически становится сла-
бой. Это не значит, что такой способ соединения таблиц обладает какими-то
480 ГЛАВА 14 Продвинутые концепции языка DAX
слабостями или недостатками - слабые связи, как и двунаправленная фильтра-
ция, работают и выполняют свои задачи, просто они никак не связаны с рас-
ширением таблиц.
Понимание концепции расширенных таблиц важно само по себе. Кроме того,
оно помогает лучше усвоить принципы распространения контекста фильтра
в формулах DAX. Если фильтр применен к столбцу, все расширенные таблицы,
содержащие в себе этот столбец, также будут отфильтрованы. Это утверждение
требует дополнительных пояснений.
Мы представили концепцию расширенных таблиц модели данных, показан-
ной на рис. 14.1, в виде диаграммы, изображенной на рис. 14.3.
Lt ge 'd
Native Columns
Related Columns
Рис. 14.3 Представление модели данных в виде диаграммы упрощает визуализацию
расширенных таблиц
В строках диаграммы перечислены все столбцы, присутствующие в нашей
модели данных, а в столбцах - таблицы. Заметьте, что некоторые названия
столбцов присутствуют на схеме дважды. Дублирование наименований лишь
отражает тот факт, что в модели данных допустимо использовать одинаковые
имена столбцов в разных таблицах. Также мы закрасили области примене-
ния столбцов на пересечении с таблицами, чтобы можно было легко отличить
собственные столбцы таблиц от столбцов их расширений. У нас есть два типа
столбцов:
родные столбцы (native columns) представляют собой столбцы, при-
надлежащие исходной таблице, и на пересечениях диаграммы отмечены
темно-серым цветом;
связанные столбцы (related columns) - это столбцы, добавленные в рас-
ширенную версию исходной таблицы по связям, - на диаграмме отмече-
ны светло-серым цветом.
Такая диаграмма помогает определить, какие таблицы фильтруются по
определенному столбцу. Например, в следующей мере используется функция
CALCULATE для применения фильтра по столбцу Product[Color]:
RedSales :=
CALCULATE (
ГЛАВА 14 Продвинутые концепции языка DAX 481
SUM ( Sales[Quantity] );
'Product'[Color] = "Red"
)
Мы можем использовать нашу диаграмму для поиска таблиц, содержащих
столбец Product [Color]. Глядя на рис. 14.4, можно легко заметить, что наш выбор
затрагивает две таблицы: Product и Sales.
Product Category Product Subcategory Product Sales Date
Category Product CategoryKey
ProauctCategoryKry
Subt jtegory ProductSubcatego'vKev
ProductSubcarego^Key
Product Name
Manufacturer
Color
ProduetKey
ProouctKey
Unit Price
Quantity
Oder Dare
Date
">ate <ey
Calendar Year
Month
Legend Native Columns Related Columns
Рис. 14.4 Подсветка строки с цветом товара позволяет определить,
какие таблицы будут отфильтрованы
Можно использовать эту же диаграмму и для проверки того, как контекст
фильтра распространяется по связям. После наложения фильтра на любой
столбец, находящийся в связи на стороне «один», все остальные таблицы,
в расширенные версии которых входит этот столбец, также будут отфильтро-
ваны. В этот список включаются все таблицы, находящиеся в соответствующих
связях на стороне «многие».
Если мыслить категориями расширенных таблиц, будет гораздо легче по-
нять принципы распространения контекста фильтра в целом. Фактически
контекст фильтра распространяется на все расширенные таблицы, содержа-
щие фильтруемые столбцы. Рассуждая таким образом, вам больше не нужно
учитывать наличие связей между таблицами. Расширение таблиц выполняется
по связям. После расширения таблицы связь, по сути, включается в саму рас-
ширенную таблицу, и думать о ней больше не нужно.
Примечание Заметьте, что фильтр по столбцу с цветом товара также распространяется
и на таблицу Date, хотя чисто технически атрибут Color не входит в расширенную версию
этой таблицы. Здесь мы имеем дело с эффектом двунаправленной фильтрации. Важно от-
метить, что фильтр, установленный по полю Color, достигает таблицы Date не через расши-
ренные таблицы. Внутренне DAX запускает специальный код для выполнения фильтрации
посредством двунаправленных связей, тогда как фильтрация при помощи расширенных
таблиц осуществляется автоматически. Разница при этом скрыта от посторонних глаз, но
ее очень важно понимать.То же самое касается и слабых связей: они используют не рас-
ширенные таблицы, а свой механизм фильтрации.
482 ГЛАВА 14 Продвинутые концепции языка DAX
Функция RELATED
Ссылаясь на таблицу в DAX, вы всегда имеете дело именно с расширенной таб-
лицей. С точки зрения семантики функция RELATED не выполняет никаких
действий. Вместо этого она всего лишь обеспечивает доступ извне к связан-
ным столбцам расширенной таблицы. В следующем примере столбец UnitPrice
принадлежит расширенной таблице Sales, и функция RELATED просто предо-
ставляет доступ к нему посредством контекста строки в таблице Sales:
SUMX (
Sales;
Sales[Quantity] * RELATED ( 1 Product1[Unit Price] )
)
В отношении расширенных таблиц очень важно понимать, что расширение
происходит в момент определения таблиц, а не в момент обращения к ним.
Рассмотрим следующий запрос:
EVALUATE
VAR SalesA =
CALCULATETABLE (
Sales;
USERELATIONSHIP ( Sales[Order Date]; 'Date'[Date] )
)
VAR SalesB =
CALCULATETABLE (
Sales;
USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[Date] )
)
RETURN
GENERATE (
VALUES ( 'Date1[Calendar Year] );
VAR CurrentYear = 'Date'[Calendar Year]
RETURN
ROW (
"Sales Fron A"; COUNTROWS (
FILTER (
SalesA;
RELATED ( 'Date'[Calendar Year] ) = CurrentYear
)
);
"Sales Fron B"; COUNTROWS (
FILTER (
SalesB;
RELATED ( 'Date'[Calendar Year] ) = CurrentYear
)
)
)
)
В переменных SalesA и SalesB хранятся две копии таблицы Sales, вычислен-
ные в контекстах фильтра с двумя разными активными связями: в таблице
ГЛАВА 14 Продвинутые концепции языка DAX 483
SalesA используется связь между столбцами Order Date и Date, а в SalesB - меж-
ду Delivery Date и Date.
После вычисления этих двух переменных функция GENERATE начинает осу-
ществлять итерации по годам, создавая при этом два дополнительных столбца,
которые будут заполнены значениями, равными количеству строк в таблицах
SalesA и SalesB с применением фильтра по текущему году к столбцу RELATED
( ' Date' [Calendar Year] ). Мы вынуждены были написать такой витиеватый
запрос, чтобы избежать возникновения преобразования контекста, и как раз
в функции GENERATE подобного преобразования не происходит.
В целом же вопрос здесь состоит в понимании того, что именно происходит
во время вызова функций RELATED в строках, выделенных жирным шрифтом.
Если не мыслить категориями расширенных таблиц, ответить на этот вопрос
будет затруднительно. На момент вызова функции RELATED активной являет-
ся связь между столбцами Sales[Order Date] и Date[Date], поскольку обе пере-
менные уже были вычислены ранее и оба модификатора USERELATIONSHIP
выполнили свою работу. В то же время переменные SalesA и SalesB хранят
расширенные таблицы, и их расширения были сделаны в момент активности
разных связей. А поскольку функция RELATED дает доступ только к столбцу
в расширенной таблице, значит, во время осуществления итераций по таблице
SalesA мы получим доступ посредством этой функции к году заказа, а при про-
ходе по SalesB - к году поставки.
Разница заметна в выводе, показанном на рис. 14.5. Если бы не расширен-
ные таблицы, мы могли бы ожидать одинаковых значений по годам в обоих
столбцах.
Calendar Year Sales From A Sales From В
CY 2005
CY 2006
CY 2007 31,682 30,918
CY 2008 28,756 28,759
CY 2009 39,793 39,580
CY 2010 974
CY 2011
Рис. 14.5 В двух расчетах фильтруются разные годы
Использование функции RELATED в вычисляемых
столбцах
Как мы уже говорили, функция RELATED позволяет получить доступ к связан-
ным столбцам в расширенной таблице. В то же время расширение таблицы
происходит в момент определения таблицы, а не обращения к ней. Это приво-
дит к тому, что изменение связей в вычисляемых столбцах становится проб-
лематичным.
Взгляните на фрагмент модели данных, изображенный на рис. 14.6, с двумя
связями между таблицами Sales и Date.
484 ГЛАВА 14 Продвинутые концепции языка DAX
Sales
Order Date
Delivery Date
Order N umber
Order Line Number
Date
Date
Рис. 14.6 Между таблицами Soles и Dote есть две связи,
но в каждый момент времени активна только одна из них
DateKey
Calendar Year Numb*
Ca endar Year
Допустим, нам понадобилось создать вычисляемый столбец в таблице Sales
для проверки того, была ли осуществлена поставка товара в том же кварта-
ле, в котором был сделан заказ. В таблице Date есть столбец Date[Calendar Year
Quarter], который может быть использован для выполнения сравнения. К со-
жалению, получить квартал, в котором была осуществлена поставка, будет го-
раздо труднее, чем извлечь квартал с датой заказа.
Если использовать в выражении вычисляемого столбца конструкцию RELATED
( 'Date'[Calendar Year Quarter] ), будет применена активная в данный момент
связь, что позволит нам получить квартал, в котором был создан заказ. И даже
следующая формула не позволит нам использовать другую связь для вычис-
ления:
Sales[DeliveryQuarter] =
CALCULATE (
RELATED ( 'Date'[Calendar Year Quarter] );
USERELATIONSHIP (
Sales[Delivery Date];
'Date'[Date]
)
)
Здесь есть сразу несколько проблем. Первая из них заключается в том, что
функция CALCULATE удаляет контекст строки, но при этом она нужна, что-
бы изменить активную связь для вызова функции RELATED. Следовательно,
функцию RELATED нельзя использовать здесь в качестве аргумента фильтра
функции CALCULATE, поскольку она требует наличия контекста строки. Есть
и еще одна любопытная проблема. Дело в том, что функция RELATED в любом
случае не сработала бы, потому что контекст строки для вычисляемого столбца
создается в момент определения таблицы. Этот контекст генерируется автома-
тически, так что таблица всегда расширяется с использованием связи, опреде-
ленной по умолчанию.
Более того, не существует идеального решения данной проблемы. Лучше
всего здесь будет воспользоваться функцией LOOKUPVALUE. Это функция по-
иска, извлекающая значение из таблицы, в которой определенный столбец со-
ответствует переданному посредством параметра значению. Квартал поставки
по заказу можно вычислить следующим образом:
Sales[DeliveryQuarter] =
LOOKUPVALUE (
ГЛАВА 14 Продвинутые концепции языка DAX 485
'Date1[Calendar Year Quarter]; -- Возвращает квартал года,
1 Date1[Date]; -- где значение в столбце Date[Date]
Sales[Delivery Date] -- соответствует значению Sales[Delivery Date]
)
Функция LOOKUPVALUE ищет значение по точному совпадению. Более слож-
ные условия в ней недопустимы. Если необходимо, можно написать выражение
посложнее с использованием функции CALCULATE. Более того, поскольку здесь
мы используем функцию LOOKUPVALUE в вычисляемом столбце, контекст
фильтра будет пустым. Но даже если бы контекст фильтра активно фильтровал
модель данных, функция LOOKUPVALUE проигнорировала бы это. Эта функция
всегда выполняет поиск строки в таблице, игнорируя любые контексты фильт-
ра. Кроме того, в качестве последнего аргумента в функцию LOOKUPVALUE
можно передать значение по умолчанию, которое будет использоваться при
отсутствии совпадений.
Разница между фильтрами по таблице
и фильтрами по столбцу
В DAX существует огромная разница между фильтрацией по таблице и по
столбцу. Табличные фильтры являются мощнейшим инструментом в руках
опытных разработчиков, но при неправильном использовании могут приво-
дить к неожиданным результатам. И начнем мы как раз со сценария, в котором
применение табличного фильтра дает неверные расчеты. Позже в этом раз-
деле мы приведем пример правильного использования табличных фильтров
в сложных сценариях.
Новичок в языке DAX, скорее всего, скажет, что эти два выражения дадут
одинаковый результат:
CALCULATE (
[Sales Amount];
Sales[Quantity] > 1
)
CALCULATE (
[Sales Amount];
FILTER (
Sales;
Sales[Quantity] > 1
)
)
На самом деле между этими двумя формулами есть огромная разница.
В первой фильтр применяется к столбцу, а во второй - к таблице. Несмотря
на то что в некоторых сценариях эти выражения выдадут одинаковые цифры,
в целом они серьезно отличаются. И чтобы продемонстрировать эти отличия,
объединим данные выражения в одном запросе:
486 ГЛАВА 14 Продвинутые концепции языка DAX
EVALUATE
ADDCOLUMNS (
VALUES ( 1 Product1[Brand] );
"FilterCol"; CALCULATE (
[Sales Amount];
SalesfQuantity] > 1
);
"FilterTab"; CALCULATE (
[Sales Amount];
FILTER (
Sales;
Sales[Quantity] > 1
)
)
)
Результат выполнения этого запроса, показанный на рис. 14.7, удивит
многих.
В столбце FilterCol показаны правильные значения, тогда как в соседнем Fil-
terTab цифры повторяются и составляют итог по всем брендам. И чтобы по-
нять, как это получилось, необходимо снова включить мышление категориями
расширенных таблиц.
Рассмотрим в деталях поведение вычисления FilterTab. Аргумент фильтра
функции CALCULATE осуществляет итерации по таблице Sales, возвращая при
этом строки, в которых количество проданных товаров превышает единицу.
Результатом вызова функции FILTER является ограниченный набор строк из
таблицы Sales. А мы помним, что в DAX любое обращение к таблице подразуме-
вает ее расширенную версию. Поскольку таблица Sales объединена связью
с таблицей Product, ее расширенная версия будет также включать все столбцы
из таблицы Product. И в числе прочих здесь будет и столбец Product[Brand].
Brand FilterCol FilterTab
Contoso 3,149,599.81 13,021,408.33
Wide World Importers 814,205.21 13,021,408.33
Northwind Traders 403,528.07 13,021,408.33
Adventure Works 1,706,002.29 13,021,408.33
Southridge Video 598,872.55 13,021,408.33
Litware 1,380,558.35 13,021,408.33
Fabrikam 2,365,917.61 13,021,408.33
Proseware 1,078,439.49 13,021,408.33
A. Datum 906,209.66 13,021,408.33
The Phone Company 479,841.37 13,021,408.33
Tailspin Toys 138,233.93 13,021,408.33
Рис. 14.7 В первом столбце показаны правильные результаты,
а во втором значения повторяются и равны общему итогу по столбцу
Аргументы фильтра функции CALCULATE вычисляются в исходном кон-
тексте фильтра, игнорируя результат будущего преобразования контекста.
ГЛАВА 14 Продвинутые концепции языка DAX 487
В то же время фильтр по столбцу Brand вступает в силу уже после операции
преобразования контекста. Следовательно, результирующий набор на выходе
функции FILTER будет включать значения по всем брендам, соответствующим
строкам с количеством проданных товаров, большим единицы. В самом деле,
во время осуществления итераций функцией FILTER никаких фильтров на
столбец Pro duct [Brand] наложено не было.
При создании нового контекста фильтра функция CALCULATE выполняет
два последовательных шага:
1) осуществляет преобразование контекста;
2) применяет аргументы фильтра.
Таким образом, аргументы фильтра могут переопределить результат преоб-
разования контекста. Поскольку функция ADDCOLUMNS выполняет итерации
по брендам, преобразование контекста в каждой строке должно приводить
к установке фильтра по конкретному бренду. Но так как в результирующем
наборе на выходе функции FILTER также содержится информация о бренде,
она будет обладать большим приоритетом по сравнению с итогами преобра-
зования контекста. В результате мы в каждой строке будем видеть итоговое
значение по мере Sales Amount для всех транзакций с количеством проданных
товаров, превышающим единицу, вне зависимости от выбранного бренда.
Использование табличных фильтров существенно затруднено из-за особен-
ностей функционирования расширенных таблиц. Применяя фильтр к таблице,
мы, по сути, применяем его к ее расширенной версии, что может приводить
к неожиданным побочным эффектам. Отсюда следует золотое правило: пы-
тайтесь избегать применения табличных фильтров, когда это возможно. Рабо-
тая со столбцами, вы сможете писать более простые и понятные выражения,
тогда как фильтрация целых таблиц может доставлять проблемы.
Примечание. Рассмотренный здесь пример не может быть с легкостью применен в мерах,
определенных в модели данных. Причина в том, что мера всегда неявно вычисляется
внутри функции CALCULATE, выполняющей преобразование контекста. Рассмотрим сле-
дующую меру:
Multiple Sales :=
CALCULATE (
[Sales Amount];
FILTER (
Sales;
Sales[Quantity] > 1
)
)
При использовании меры в отчете возможный запрос на языке DAX мог бы выглядеть
примерно так:
EVALUATE
ADDCOLUMNS (
VALUES ( 1 Product1[Brand] );
"FilterTabMeasure"; [Multiple Sales]
)
\,
488 ГЛАВА 14 Продвинутые концепции языка DAX
Расширение таблиц приведет к такому преобразованию запроса:
EVALUATE
ADDCOLUMNS (
VALUES ( 'Product1[Brand] );
"FilterTabMeasure"; CALCULATE (
CALCULATE (
[Sales Amount];
FILTER (
Sales;
SalesfQuantity] > 1
)
)
)
)
Вызов первой функции CALCULATE приведет к преобразованию контекста, что повлияет
на оба аргумента второй функции CALCULATE, включая функцию FILTER. И даже если ито-
говый результат будет соответствовать нашему исходному столбцу FilterCol, использова-
ние табличного фильтра непременно скажется на производительности вычисления. Так
что наш вам совет: используйте фильтры по столбцам всегда, когда это возможно.
\__________________________________________________________________________________________J
Использование табличных фильтров в мерах
В предыдущем разделе мы продемонстрировали первый пример, в котором
понимание концепции расширенных таблиц помогло нам правильно интер-
претировать результат. Но есть и другие сценарии, в которых могут пригодить-
ся расширенные таблицы. К тому же в предыдущих главах мы не раз использо-
вали эту концепцию, просто не объясняли толком, что к чему.
Например, в главе 5 при описании процедуры удаления фильтров, наложен-
ных на модель данных, мы использовали следующий код в отчете, к которому
был применен срез по категориям товаров:
Pct All Sales :=
VAR CurrentCategorySales =
[Sales Amount]
VAR AllSales =
CALCULATE (
[Sales Amount];
ALL ( Sales )
)
VAR Result =
DIVIDE (
CurrentCategorySales;
AllSales
)
RETURN
Result
Почему же выражение ALL ( Sales ) удаляет все наложенные фильтры? Если
не мыслить категориями расширенных таблиц, функция ALL здесь должна
ГЛАВА 14 Продвинутые концепции языка DAX 489
удалять фильтры только с таблицы Sales, оставляя все остальные фильтры не-
тронутыми. Но по факту применение функции ALL к таблице Sales привело
к фильтрации всей расширенной таблицы продаж. А поскольку расширение
таблицы Sales распространяется на все связанные таблицы, включая Product,
Customer, Date, Store и остальные, эта операция позволила снять все наложен-
ные фильтры с модели данных.
В большинстве случаев такое поведение является желаемым и интуитивно
понятным. Но это не умаляет важности досконального понимания тонкостей
поведения расширенных таблиц. Без полноценного осознания этой концеп-
ции нетрудно допустить неточности в ключевых вычислениях. В следующем
примере мы покажем, как простое вычисление может стать настоящей проб-
лемой при отсутствии понимания принципов расширения таблиц. Мы узнаем,
почему не стоит применять табличные фильтры с функцией CALCULATE, если
только разработчик не собирается намеренно воспользоваться преимущества-
ми побочных эффектов от расширения таблиц. Об этом мы расскажем в сле-
дующих разделах.
Посмотрите на отчет, представленный на рис. 14.8. Слева у нас есть срез по
столбцу Category, а в матрице отображаются данные по продажам по подка-
тегориям товаров с указанием процента относительно итогового показателя.
Category Subcategory Sales Amount Pct
Audio Computers Accessories 341,362.15 5.06%
Cameras and camcorders Desktops 1,017,127.27 15.09%
Cell phones
| Computers Laptops 1,925,105.28 28.56%
Games and Toys Monitors 604,386.23 8.97%
Home Appliances Printers, Scanners & Fax 505,519.67 7.50%
Music Movies and Audio Books
TV and Video Projectors & Screens 2,348,048.13 34.83%
Total 6,741,548.73 100.00%
Рис. 14.8 В столбце Pct показана доля продаж
по подкатегориям относительно итога
Поскольку в столбце с процентной долей продаж нам необходимо разделить
текущее значение меры Sales Amount на ее значение по всем подкатегориям
в рамках выбранной категории, первым (и неверным) решением может быть
следующий код:
Pct : =
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Amount];
ALL ( 'Product Subcategory' )
)
)
Идея была в том, чтобы удалить фильтр со столбца Product Subcategory и оста-
вить по столбцу Category, чтобы получить корректный результат. Однако ре-
зультат, показанный на рис. 14.9, оказался не таким, как мы ожидали.
490 ГЛАВА 14 Продвинутые концепции языка DAX
Subcategory Sales Amount 341,362.15 Pct 1.12%
Category Audio Computers Accessories
Cameras and camcorders Cell phones Desktops 1,017,127.27 3.32%
Ц Computers Laptops 1,925,105.28 6.29%
Games and Toys Monitors 604,386.23 1.98%
Home Appliances Music Movies and Audio Books Printers, Scanners & Fax 505,519.67 1.65%
TV and Video Projectors & Screens 2,348,048.13 7.68%
Total 6,741,548.73 22.04%
Рис. 14.9 Первая реализация меры Pct дала неверный расчет процентов
Проблема в том, что выражение ALL ( 'Product Subcategory1 ) удаляет фильтры
с расширенной версии таблицы Product Subcategory, а не с исходной. Таблица
Product Subcategory расширяется за счет Product Category. Следовательно, функ-
ция ALL удаляет фильтры не только с таблицы Product Subcategory, но и с Product
Category. Таким образом, в знаменателе у нас будет рассчитан итог по всем ка-
тегориям товаров, что приведет к неправильному расчету процентов.
Здесь есть сразу несколько решений, и мы вычислим требуемую нам меру,
используя разные подходы. К примеру, в следующей мере Pct Of Categories мы
рассчитаем процент по выбранным подкатегориям в сравнении со связанны-
ми категориями. Удалив фильтр с расширенной таблицы Product Subcategory,
мы затем восстанавливаем фильтр по таблице Product Category путем вызова
функции VALUES:
Pct Of Categories :=
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Amount];
ALL ( 'Product Subcategory' );
VALUES ( 'Product Category' )
)
)
Еще один способ вычислить правильные проценты мы покажем на приме-
ре меры Pct Of Visual Total, в которой используем функцию ALLSELECTED без
аргументов. Функция ALLSELECTED восстанавливает контекст фильтра по сре-
зам, находящимся за пределами визуального элемента, при этом разработчику
даже не надо беспокоиться о расширенных таблицах:
Pct Of Visual Total :=
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Amount];
ALLSELECTED ()
)
)
Использование функции ALLSELECTED, конечно, привлекает своей просто-
той. Но далее в разделе мы познакомим вас с неявными контекстами фильтра
ГЛАВА 14 Продвинутые концепции языка DAX 491
(shadow filter contexts), что позволит вам в полной мере разобраться в прин-
ципах работы загадочной функции ALLSELECTED. Эта функция невероятно
мощная, но в сложных выражениях использовать ее следует с большой осто-
рожностью.
Еще один способ решить нашу проблему заключается в использовании
функции ALLEXCEPT, которая поможет сопоставить значения по выбранным
подкатегориям со значениями по категориям, отмеченным в срезе:
Pct : =
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Amount];
ALLEXCEPT ( 'Product Subcategory'; 'Product Category' )
)
)
В этой формуле используется вариант синтаксиса функции ALLEXCEPT, ко-
торый мы не применяли ранее, а именно с передачей в качестве параметров
двух таблиц, а не таблицы и списка столбцов.
Функция ALLEXCEPTудаляет все фильтры с переданной таблицы, за исклю-
чением столбцов, указанных в параметрах со второго и далее. Этот список
может включать в себя любые столбцы (или таблицы), принадлежащие рас-
ширенной версии таблицы, переданной в качестве первого аргумента. А по-
скольку расширенная таблица Product Subcategory полностью включает в себя
таблицу Product Category, результат вычисления будет правильным. Фактиче-
ски это выражение удалит фильтры с полной расширенной таблицы Product
Subcategory, не затронув при этом столбцы расширенной таблицы Product Ca-
tegory.
Здесь стоит отметить, что расширенные таблицы могут доставлять немало
проблем, если ваша модель данных неправильно денормализована. На про-
тяжении большей части книги мы используем версию модели данных Contoso,
в которой Category и Subcategory хранятся просто как столбцы в таблице Prod-
uct, а не как обособленные таблицы. Иначе говоря, мы денормализовали ка-
тегорию и подкатегорию в таблице товаров. В правильно денормализованной
модели данных процесс расширения таблиц между Sales и Product проходит
более естественным образом. Как часто это бывает, чем лучше спроектирована
модель данных, тем проще будет код на DAX.
Введение в активные связи
Еще один важный аспект, который стоит учитывать при работе с расширенны-
ми таблицами, - это активность связей в модели данных. Обычно в моделях
с большим количеством связей между таблицами легко запутаться. И в этом
разделе мы покажем пример, когда присутствие множества связей является
настоящей проблемой.
Представьте, что вам необходимо рассчитать меры Sales Amount и Delivered
Amount. Эти меры легко вычислить, воспользовавшись полезной функцией
USERELATIONSHIP. В следующем примере показан вариант создания этих мер:
492 ГЛАВА 14 Продвинутые концепции языка DAX
Sales Anount :=
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
Delivered Anount :=
CALCULATE (
[Sales Anount];
USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[Date] )
)
Результат вычисления этих мер показан на рис. 14.10.
Calendar Year Sales Amount Delivered Amount
CY 2007 11,309,946 11,034,860
CY 2008 9,927,583 9,901,408
CY 2009 9,353,815 9,442,286
CY 2010 212,790
Total 30,591,344 30,591,344
Рис. 14.10 При создании мер Sales Amount и Delivered Amount
были использованы разные связи
А вот вариант написания меры Delivered Amount, который не сработает по
причине использования табличного фильтра:
Delivered Anount =
CALCULATE (
[Sales Anount];
CALCULATETABLE (
Sales;
USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[Date] )
)
)
Эта мера выдает пустые значения, что видно по рис. 14.11.
Calendar Year Sales Amount Delivered Amount
CY 2007 CY 2008 CY 2009 Total 11,309,946 9,927,583 9,353,815 30,591,344
Рис. 14.11 Использование табличного фильтра
в мере Delivered Amount привело к выводу пустых значений
Разберемся, почему мера стала выдавать пустые значения. Наверняка здесь
не обошлось без участия расширенных таблиц. И правда, на выходе функции
ГЛАВА 14 Продвинутые концепции языка DAX 493
CALCULATETABLE мы получили расширенную версию таблицы Sales, в которой
помимо остальных таблиц включена также таблица Date. В момент вычисле-
ния функции CALCULATETABLE активной связью является связь по столбцу
Sales [Delivery Date]. Следовательно, функция CALCULATETABLE вернет все за-
казы, поставка которых была осуществлена в указанном году, в виде расши-
ренной таблицы.
Когда функция CALCULATETABLE используется в качестве аргумента фильт-
ра другой функции CALCULATE, ее результат фильтрует таблицы Sales и Date
посредством расширенной таблицы Sales, которая использует связь между
столбцами Sales [Delivery Date] и Date[Date]. Но по окончании действия функ-
ции CALCULATETABLE в силу вновь вступает связь по умолчанию по столбцам
Sales [Order Date] и Date[Date]. Таким образом, фильтр по датам вновь устанав-
ливается на дату заказа, а не дату поставки. Иными словами, таблица с дата-
ми поставки используется для фильтрации дат заказа. После этой операции
видимыми будут только те строки, где значения столбцов Sales[Order Date]
и Sales [Delivery Date] равны, то есть поставка произошла в день заказа. Но в мо-
дели данных нет таких строк, а значит, и результат будет пустым.
Для еще лучшего разъяснения этой концепции представьте, что в таблице
Sales у нас есть всего пара строк, как показано в табл. 14.2.
ТАБЛИЦА 14.2 Пример таблицы Sales с двумя строками
Order Date Delivery Date Quantity
12/31/2007 01/07/2008 100
01/05/2008 01/10/2008 200
Если в отчете выбран 2008 год, функция CALCULATETABLE вернет расширен-
ную версию таблицы Sales, которая, помимо прочих, будет включать столбцы,
показанные в табл. 14.3.
ТАБЛИЦА 14.3 Результатом вызова функции CALCULATETABLE является
расширенная таблица Sales, включающая поле Date[Date], пришедшее по связи
с Sales[Delivery Date]
Order Date Delivery Date Quantity Date
12/31/2007 01/07/2008 100 01/07/2008
01/05/2008 01/10/2008 200 01/10/2008
При применении в качестве фильтра столбец Date[Date] использует актив-
ную связь, то есть связь между столбцами Date[Date] и Sales[Order Date]. В этот
момент расширенная таблица Sales выглядит так, как показано в табл. 14.4.
ТАБЛИЦА 14.4 Расширенная таблица Sales с использованием связи по
умолчанию по столбцу Sa les [Order Date]
Order Date Delivery Date Quantity Date
12/31/2007 01/07/2008 100 12/31/2007
01/05/2008 01/10/2008 200 01/05/2008
494 ГЛАВА 14 Продвинутые концепции языка DAX
В результате строки, видимые в табл. 14.3, пытаются отфильтровать таблицу,
представленную в табл. 14.4. Но для всех строк значения в столбце Date будут
разными, а значит, все строки из результирующей таблицы будут удалены.
В общем случае в итоговый набор попадут только строки с одинаковыми
значениями в столбцах Sales[Order Date] и Sales [Delivery Date], поскольку зна-
чения поля Date[Date] будут идентичными в обеих расширенных таблицах, по-
строенных с использованием разных связей. В этот раз фильтрующий эффект
исходил от активной связи. Изменение связи внутри CALCULATE оказывает ло-
кальное действие только внутри этой функции, но за ее пределами активной
вновь становится связь по умолчанию.
Как обычно, отметим, что такое поведение является вполне корректным.
Понять его непросто, но это не делает его неправильным. Повторим, что ис-
пользования табличных фильтров необходимо избегать, когда это возможно.
Применяя их, можно получать корректные результаты, а можно погрязнуть
в чрезмерно сложном и непредсказуемом сценарии. Более того, меры, исполь-
зующие фильтры по столбцам вместо табличных фильтров, прекрасно работа-
ют и легки для восприятия.
Разработчики, не следующие золотому правилу не использовать табличные
фильтры, обычно платят дважды: первый раз - пытаясь понять, как работают
их фильтры, а второй - осознавая чудовищное падение производительности
их расчетов.
Разница между расширением таблиц и фильтрацией
Как мы уже упоминали ранее, расширение таблиц выполняется по связям от
стороны «многие» к стороне «один». Рассмотрим модель данных, представлен-
ную на рис. 14.12, в которой мы намеренно сделали все связи двунаправлен-
ными.
Е i Date
И Product
Brand
3 CoJor
О P'oduct Name
3 ProductKey
3 P'cductSutKategoryKej
И Product Subcategory
ГЗ ProductCategoryKey
Г" PrcductSubcategoryKey
СП S'ubcategory
□ Asia Season
Calendar Year
□ Calendar Year Montn
E Calendar vear Month Num ber
□ Calendar Year Nu'nbr
П C alendar Year Quaner
•3 Calendar Year Quarter Number
Date
SP Salts
SP bales
Delivery Date
Net Pr.ce
Order Date
ProductKey
Quantity
Pet
Sales A/rou.it
Я Product Category
3 Category
3 Prcou ctCate gory Key
Рис. 14.12 Все связи в этой модели данных - двунаправленные
ГЛАВА 14 Продвинутые концепции языка DAX 495
Несмотря на двунаправленный характер связи между таблицами Product
и Product Subcategory, расширенная версия таблицы с товарами будет включать
подкатегории, тогда как расширенная версия подкатегорий не будет включать
товары.
Движок DAX выполняет специальный фильтрующий код в выражениях, соз-
давая эффект обоюдного расширения таблиц в модели данных. Похожее пове-
дение наблюдается при использовании функции CROSSFILTER. Так что в боль-
шинстве случаев меры будут работать так, как если бы участвующие в связи
таблицы расширялись в обоих направлениях. Но не стоит забывать, что в дей-
ствительности расширения таблиц по связям в сторону «многие» не проис-
ходит.
Эта разница оказывается важна при использовании функций SUMMARIZE
или RELATED. Если разработчик применяет функцию SUMMARIZE для выпол-
нения группировки таблицы, основываясь на столбцах связанной с ней табли-
цы, он должен воспользоваться столбцами расширенной версии таблицы. На-
пример, следующее выражение с применением функции SUMMARIZE работает
прекрасно:
EVALUATE
SUMMARIZE (
'Product';
'Product Subcategory'[Subcategory]
)
В то же время обратная операция с попыткой сгруппировать категории то-
варов по цвету потерпит неудачу:
EVALUATE
SUMMARIZE (
'Product Subcategory';
'Product'[Color]
)
Ошибка с описанием «Столбец с именем Color, указанный в функции SUM-
MARIZE, не присутствует в исходной таблице» говорит сама за себя - расши-
ренная версия таблицы Product Subcategory действительно не содержит столбец
ProductfColor]. Как и SUMMARIZE, функция RELATED также работает исключи-
тельно со столбцами, принадлежащими расширенной таблице.
Также, как и в предыдущем примере, таблицу Date нельзя сгруппировать по
столбцам, принадлежащим другим таблицам, несмотря на то что она объеди-
нена с Sales двунаправленной связью:
EVALUATE
SUMMARIZE ( 'Date'; 'Product'[Color] )
Есть только один случай взаимного расширения двух таблиц - когда они
объединены посредством связи типа «один к одному». В таком варианте рас-
ширенные версии обеих таблиц будут включать друг друга. Причина в том, что
связь «один к одному» делает две таблицы семантически идентичными: каж-
дая строка в одной таблице напрямую связана со строкой в другой таблице.
496 ГЛАВА 14 Продвинутые концепции языка DAX
Следовательно, можно представить эти таблицы как одну общую, разбитую на
два набора столбцов.
Преобразование контекста в расширенных таблицах
Расширение таблиц также оказывает влияние на преобразование контекста.
Контекст строки преобразуется в эквивалентный контекст фильтра для всех
столбцов, принадлежащих расширенной версии таблицы. Рассмотрим следую-
щий запрос, возвращающий категорию товара, используя при этом две разные
техники: функцию RELATED в контексте строки и функцию SELECTEDVALUE
с преобразованием контекста:
EVALUATE
SELECTCOLUMNS (
'Product';
"Product Key"; 'Product'[ProductKey];
"Product Nane"; 'Product'[Product Nane];
"Category RELATED"; RELATED ( 'Product Category'[Category] );
"Category Context Transition"; CALCULATE (
SELECTEDVALUE ( 'Product Category'[Category] )
)
)
ORDER BY [Product Key]
Результат запроса будет включать два столбца Category RELATED и Category
Context Transition с идентичными значениями, что видно по рис. 14.13.
Product Key Product Name Category RELATED Category Context Transition
113 WWI Wireless Transmitter and Bluetooth Headphones X2S0 White Audio Audio
114WWI Wireless Transmitter and Bluetooth Headphones X250 Red Audio Audio
115 WWI Wireless Transmitter and Bluetooth Headphones X250 Silver Audio Audio
116 Adventure Works 20'* CRT TV El 5 Silver TV and Video TV and Video
117 Adventure Works 20” CRT TV El 5 Black TV and Video TV and Video
118 Adventure Works 20” CRT TV El5 White TV and Video TV and Video
Рис. 14.13 Категория товара в двух столбцах получена разными способами
В столбце Category RELATED отображается название категории выбранно-
го в текущей строке товара. Это значение может быть извлечено при помо-
щи функции RELATED, если нам доступен контекст строки в таблице Product.
При вычислении значения столбца Category Context Transition используется со-
вершенно иная техника. Здесь мы имеем дело с преобразованием контекста,
выполненным функцией CALCULATE. В результате преобразованный контекст
фильтрует не только таблицу Product, но и связанные с ней таблицы Product
Subcategory и Product Category по выбранному товару. А поскольку в этот момент
в контексте фильтра находится только одна строка из таблицы Product Category,
функция SELECTEDVALUE вернет единственное значение столбца Category из
этой таблицы.
И хотя этот побочный эффект расширенных таблиц хорошо известен, не
стоит полагаться на такое поведение при извлечении связанных столбцов. Не-
ГЛАВА 14 Продвинутые концепции языка DAX 497
смотря на то что результат может оказаться правильным, есть вероятность, что
производительность пострадает. Решение с преобразованием контекста может
оказаться менее эффективным в случае оперирования со множеством строк
в таблице Product. Операция преобразования контекста обычно обходится не-
дешево. Далее в данной книге мы еще не раз упомянем, что для оптимизации
кода желательно снизить количество преобразований контекста. Таким обра-
зом, в этом конкретном случае лучшим способом обращения к категории това-
ра будет использование функции RELATED. Так вы сможете избежать преобра-
зования контекста, что необходимо для применения функции SELECTEDVALUE.
Функция ALLSELECTED и неявные контексты
фильтра
ALLSELECTED - очень удобная и полезная функция, скрывающая в себе огром-
ную ловушку. По нашему мнению, это самая сложная функция в языке DAX,
хотя выглядит она очень безобидно. В данном разделе мы посвятим вас во все
технические подробности реализации функции ALLSELECTED, а также дадим
несколько советов по поводу того, когда стоит ее использовать, а когда лучше
обойтись без нее.
ALLSELECTED, как и любая другая функция из группы ALL'", может быть
использована двумя способами: как табличная функция и как модификатор
функции CALCULATE. И ее поведение в этих двух сценариях будет серьезно от-
личаться. Более того, это единственная функция в языке DAX, прибегающая
к помощи так называемых неявных контекстов фильтра (shadow filter contexts).
Сначала мы изучим поведение функции ALLSELECTED, затем расскажем, что
из себя представляют загадочные контексты фильтра, недаром именуемые не-
явными, а в конце раздела дадим пару советов по оптимальному использова-
нию функции ALLSELECTED.
Функцию ALLSELECTED можно использовать чисто интуитивно. Рассмотрим
следующий отчет, представленный на рис. 14.14.
Brand
A Datum
Ц Adventure Works
Ц Contoso
Ц Fabrikam
Ц Litware
Ц Northwind Traders
В Proseware
Southridge Video
Tailspin Toys
The Phone Company
Wide World Importers
Brand Sales Amount Pct
Adventure Works 4,011,112.28 16.88%
Contoso 7,352,399.03 30.94%
Fabrikam 5,554,015.73 23.38%
Litware 3,255,704.03 13.70%
Northwind Traders 1,040,552.13 4.38%
Proseware 2,546,144.16 10.72%
Total 23,759,927.34 100.00%
Рис. 14.14 В отчете показаны продажи по различным брендам с процентными долями
В отчете используется срез для осуществления фильтрации по брендам.
В строках показывается сумма продажи по каждому бренду и процент от об-
498 ГЛАВА 14 Продвинутые концепции языка DAX
щих продаж по всем выбранным брендам. Формула для вычисления меры
с процентами очень проста:
Pct : =
DIVIDE (
[Sales Amount];
CALCULATE (
[Sales Amount];
ALLSELECTED ( 1 Product1[Brand] )
)
)
Интуитивно можно понять, что функция ALLSELECTED вернет значения
брендов, выбранных за пределами текущего элемента визуализации, - в на-
шем случае это все бренды от Adventure Works до Proseware включительно. Но
ведь Power BI посылает запрос движку DAX, который понятия не имеет о наших
элементах визуализации.
Как же DAX узнает о том, что выбрано в срезе, а что - в самой матрице? От-
вет прост - он об этом и не узнает. Функция ALLSELECTED на самом деле и не
возвращает значения столбцов (или таблиц), отфильтрованных за пределами
нашей визуализации. Она выполняет совершенно иное действие, в качестве
побочного эффекта возвращая в большинстве случаев тот результат, который
нам и нужен. Правильное определение функции ALLSELECTED включает в себя
два утверждения:
будучи использованной в качестве табличной функции, ALLSELECTED
возвращает набор значений, видимый в последнем неявном контексте
фильтра;
при использовании функции ALLSELECTED в качестве модификатора
функции CALCULATE она восстанавливает последний неявный контекст
фильтра по переданному параметру.
Конечно, эти утверждения нуждаются в дополнительном пояснении.
Знакомство с неявными контекстами фильтра
Прежде чем знакомиться с неявными контекстами фильтра, полезно будет
взглянуть на запрос, сгенерированный Power BI для вывода результата, пока-
занного на рис. 14.14:
DEFINE
VAR _DS0FilterTable =
TREATAS (
{
"Adventure Works";
"Contoso";
"Fabrikam";
"Litware";
"Northwind Traders";
"Proseware"
};
ГЛАВА 14 Продвинутые концепции языка DAX 499
'Product1[Brand]
)
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
'Product1[Brand];
"IsGrandTotalRowTotal"
);
__DS0FilterTable;
"Sales_Amount"; 'Sales'[Sales Amount];
"Pct"; 'Sales'[Pct]
);
[IsGrandTotalRowTotal]; 0;
'Product'[Brand]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Product'[Brand]
Этот запрос проанализировать будет не так просто, и не столько из-за его
сложности, сколько по причине того, что он был автоматически сгенерирован
движком и в принципе не предназначен для чтения. Мы преобразовали его
в запрос, близкий по концепции к оригиналу, но более пригодный для анализа:
EVALUATE
VAR Brands =
FILTER (
ALL ( 'Product'[Brand] );
'Product'[Brand]
IN {
"Adventure Works";
"Contoso";
"Fabrikam";
"Litware";
"Northwind Traders";
"Proseware"
}
)
RETURN
CALCULATETABLE (
ADDCOLUMNS (
VALUES ( 'Product'[Brand] );
"Sales_Amount"; [Sales Amount];
"Pct"; [Pct]
);
Brands
)
Результат этого запроса очень похож на тот, что мы видели ранее, за исклю-
чением того, что здесь нет строки итогов. Вывод показан на рис. 14.15.
500 ГЛАВА 14 Продвинутые концепции языка DAX
Brand Sales.Amount Pct
Contoso 7,352,399.03 30.94%
Northwind Traders 1,040,552.13 4.38%
Adventure Works 4,011,112.28 16.88%
Litware 3,255,704.03 13.70%
Fabrikam 5,554,015.73 23.38%
Proseware 2,546,144.16 10.72%
Рис. 14.15 Результат почти такой же, как в предыдущем отчете,
за исключением отсутствия итогов
Сделаем несколько важных замечаний по поводу этого запроса:
внешняя функция CALCULATETABLE создает контекст фильтра, состоя-
щий из шести брендов;
функция ADDCOLUMNS осуществляет итерации по шести брендам, види-
мым внутри CALCULATETABLE;
меры Sales Amount и Pct вычисляются внутри итерации. Перед их вычис-
лением происходит преобразование контекста, так что в контекст фильт-
ра каждой из них включается только один текущий бренд;
мера Sales Amount не изменяет контекст фильтра, тогда как мера Pct ис-
пользует функцию ALLSELECTED для модификации контекста фильтра;
после изменения контекста фильтра функцией ALLSELECTED внутри
меры Pct измененный контекст будет вновь насчитывать шесть брендов
вместо одного текущего.
Последний пункт этого перечня наиболее важен для понимания того, что
из себя представляет неявный контекст фильтра и как на самом деле DAX ис-
пользует функцию ALLSELECTED. Ключом к происходящему является итератор
ADDCOLUMNS, проходящий по шести выбранным брендам, из которых в ре-
зультате преобразования контекста видимым остается только один, и функции
ALLSELECTED необходимо каким-то образом восстановить контекст фильтра,
содержащий все шесть изначальных брендов.
Давайте более детально разберемся, как в действительности выполняется
запрос. Здесь - на третьем шаге - мы впервые встретимся с неявным контекс-
том фильтра.
1. Функция CALCULATETABLE создает контекст фильтра, состоящий из
шести брендов.
2. Функция VALUES возвращает шесть видимых брендов функции ADD-
COLUMNS.
3. Будучи итератором, функция ADDCOLUMNS создает неявный контекст
фильтра, содержащий результат выполнения функции VALUES, непо-
средственно перед началом осуществления итераций:
• неявный контекст фильтра похож на обычный, за исключением того,
что он создается неактивным и никак не влияет на вычисления;
• активировать неявный контекст фильтра можно лишь при помощи
функции ALLSELECTED, что мы скоро увидим. Пока достаточно будет
запомнить, что в неявном контексте фильтра находятся все шесть вы-
бранных брендов;
ГЛАВА 14 Продвинутые концепции языка DAX 501
• чтобы отличать обычный контекст фильтра от неявного, в данном раз-
деле будем именовать его явным (explicit filter context).
4. Во время итерации преобразование контекста выполняется для отдель-
но взятой строки. Таким образом, в результате этого преобразования
создается явный контекст фильтра, содержащий единственный бренд.
5. Когда при вычислении меры Pct движок DAX встречает функцию ALLSE-
LECTED, происходит следующее: функция ALLSELECTED восстанавли-
вает последний неявный контекст фильтра по столбцу или табли-
це, переданной в качестве параметра, либо по всем столбцам, если
функция ALLSELECTED вызвана без параметров (поведение функции
ALLSELECTED без параметров описывается в следующем разделе). По-
скольку в последнем неявном контексте фильтра содержится шесть вы-
бранных брендов, все они вновь становятся видимыми.
Этот несложный пример позволил нам объяснить, что из себя представляет
неявный контекст фильтра. Предыдущий запрос демонстрирует, как функция
ALLSELECTED использует неявный контекст фильтра для извлечения контек-
ста фильтра за пределами текущего элемента визуализации. Заметьте, что
в описании процесса мы ни разу не упомянули какие-либо визуализации, ха-
рактерные для Power BI. Фактически движок DAX ничего не знает о том, какой
именно элемент используется для отображения информации. Он просто при-
нимает запрос DAX, ничего более.
Чаще всего функция ALLSELECTED извлекает правильный контекст фильтра.
Дело в том, что все элементы визуализации в Power BI и большинство элемен-
тов отображения в других инструментах генерируют похожие запросы. Эти
автоматически сгенерированные запросы всегда включают в себя итерацион-
ную функцию верхнего уровня, создающую неявный контекст фильтра по ото-
бражаемым элементам. Именно поэтому создается ощущение, что функция
ALLSELECTED восстанавливает контекст фильтра, находящийся за пределами
текущей визуализации.
Теперь, когда вы лучше узнали предназначение и принципы работы функ-
ции ALLSELECTED, пришло время рассказать об условиях, необходимых для ее
корректного использования:
запрос должен содержать итерационную функцию. Без итератора не бу-
дет создан неявный контекст фильтра, а значит, функция ALLSELECTED
отработает неправильно;
если перед вызовом функции ALLSELECTED стоит несколько итераторов,
она восстановит только последний из созданных контекстов фильтра.
Иными словами, использование функции ALLSELECTED внутри итера-
тора, выполняющегося в коде меры, с большой долей вероятности будет
приводить к непредсказуемым результатам, поскольку мера почти всегда
вычисляется в рамках другого итератора в запросе, созданном клиент-
ским инструментом;
если столбцы, переданные функции ALLSELECTED в качестве парамет-
ров, не содержатся в неявном контексте фильтра, функция ничего не бу-
дет делать.
Как видите, функция ALLSELECTED оказалась далеко не такой простой, как
представлялось. Разработчики в основном предпочитают использовать ее для
502 ГЛАВА 14 Продвинутые концепции языка DAX
извлечения внешнего контекста фильтра в визуализации. Ранее в данной кни-
ге мы уже использовали функцию ALLSELECTED с этой целью, но каждый раз
дважды проверяли, чтобы были выполнены все необходимые условия для ее
корректной работы, пусть и не объясняли досконально, что происходит на са-
мом деле.
В целом семантика функции ALLSELECTED напрямую связана с извлечени-
ем неявных контекстов фильтра, и, по счастливой случайности (на самом деле
именно так и было задумано), ее применение ведет к извлечению контекста
фильтра, созданного за пределами текущего элемента визуализации.
Опытные разработчики прекрасно понимают, как работает функция ALLSE-
LECTED, и используют ее только в тех сценариях, где это допустимо. Злоупо-
требление данной функцией в условиях, непригодных для ее корректного ис-
пользования, может привести к нежелательным результатам, и винить в этом
нужно будет разработчика, а не какую-то функцию ALLSELECTED...
Золотое правило использования функции ALLSELECTED звучит так: функ-
ция ALLSELECTED может быть использована для извлечения внешнего
контекста фильтра только в рамках меры, представленной в матрице
или другом элементе визуализации. Разработчик может не ждать коррект-
ного поведения от меры с ALLSELECTED внутри, использованной в рамках ите-
рационной функции, что мы продемонстрируем в следующих разделах. Имен-
но поэтому мы как разработчики DAX всегда предпочитаем следовать одному
простому правилу: если в коде меры содержится функция ALLSELECTED, эту
меру ни в коем случае не стоит вызывать из другой меры. Дело в том, что в це-
почку вызовов мер легко может закрасться итерационная функция, в рамках
которой может быть вызвана мера, включающая в себя функцию ALLSELECTED.
ALLSELECTED возвращает строки из итераций
Чтобы еще лучше продемонстрировать поведение функции ALLSELECTED, не-
много изменим предыдущий запрос. Вместо того чтобы осуществлять итера-
ции по выражению VALUES ( Product [Brand] ), пройдемся при помощи функции
ADDCOLUMNS по ALL ( Product [Brand ] ):
EVALUATE
VAR Brands =
FILTER (
ALL ( 'Product1[Brand] );
1 Product1[Brand]
IN {
"Adventure Works";
"Contoso";
"Fabrikan";
"Litware";
"Northwind Traders";
"Proseware"
}
)
RETURN
CALCULATETABLE (
ГЛАВА 14 Продвинутые концепции языка DAX 503
ADDCOLUMNS (
ALL ( 'Product'[Brand] );
"SaleS-Anount"; [Sales Amount];
"Pct"; [Pct]
);
Brands
)
В этом обновленном сценарии неявный контекст фильтра, созданный функ-
цией ADDCOLUMNS до начала итераций, содержит все имеющиеся бренды,
а не только выбранные. Таким образом, во время вычисления меры Pct функ-
ция ALLSELECTED восстановит этот неявный контекст фильтра, тем самым сде-
лав видимыми все бренды. Результат, показанный на рис. 14.16, отличается от
того, что мы видели на рис. 14.15.
Brand Sales.Amount Pct
Contoso 7,352,399.03 24.03%
Wide World Importers 1,901,956.66 6.22%
Northwind Traders 1,040,552.13 3.40%
Adventure Works 4,011,112.28 13.11%
Southridge Video 1,384,413.85 4.53%
Litware 3,255,704.03 10.64%
Fabrikam 5,554,015.73 18.16%
Proseware 2,546,144.16 8.32%
A. Datum 2,096,184.64 6.85%
The Phone Company 1,123,819.07 3.67%
Tailspin Toys 325,042.42 1.06%
Рис. 14.16 Функция ALLSELECTED восстанавливает значения
из текущего цикла итераций, а не предыдущий контекст фильтра
Как видите, все бренды, как и ожидалось, стали видимыми, но при этом циф-
ры по ним отличаются, несмотря на то что наши изменения вычислений не ка-
сались. Поведение функции ALLSELECTED в этом сценарии вполне корректное.
Разработчикам может показаться, что она ведет себя несколько неожиданно,
поскольку при вычислении меры Pct полностью игнорируется контекст фильт-
ра, созданный в переменной Brands. Но функция ALLSELECTED делает ровно
то, что ей предписано, - возвращает последний неявный контекст фильтра,
а в нашей версии кода в этом контексте будут находиться все бренды, а не толь-
ко выбранные. Функция ADDCOLUMNS создает неявный контекст фильтра со
строками, по которым будут осуществляться итерации, и здесь это все бренды,
присутствующие в модели данных.
Если вам необходимо восстановить предыдущий контекст фильтра, одной
функции ALLSELECTED будет недостаточно. Вам придется использовать моди-
фикатор KEEPFILTERS функции CALCULATE, который призван восстанавливать
предыдущий контекст фильтра. Интересно, какой результат выдаст формула
с использованием этого модификатора:
504 ГЛАВА 14 Продвинутые концепции языка DAX
EVALUATE
VAR Brands =
FILTER (
ALL ( 'Product1[Brand] );
1 Product1[Brand]
IN {
"Adventure Works";
"Contoso";
"Fabrikam";
"Litware";
"Northwind Traders";
"Proseware"
}
)
RETURN
CALCULATETABLE (
ADDCOLUMNS (
KEEPFILTERS ( ALL ( 'Product'[Brand] ) );
"Sales_Amount"; [Sales Amount];
"Pct"; [Pct]
);
Brands
)
Будучи использованной в качестве модификатора итерационной функции,
KEEPFILTERS не изменяет результат таблицы, по которой осуществляется про-
ход. Вместо этого она дает команду итератору применить KEEPFILTERS как
неявный модификатор функции CALCULATE в процессе преобразования кон-
текста при осуществлении итераций. В результате функция ALL возвращает
все бренды, и в созданном неявном контексте фильтра также будут находиться
все бренды. Но при преобразовании контекста будет сохранен предыдущий
фильтр, примененный внешней функцией CALCULATETABLE с переменной
Brand. Таким образом, запрос вернет все бренды компании, но значения будут
рассчитаны только для тех из них, которые были выбраны в фильтре, что видно
по рис. 14.17.
Brand Sales_Amount Pct
Contoso 7,352,399.03 30.94%
Wide World Importers Northwind Traders 1,040,552.13 4.38%
Adventure Works 4,011,112.28 16.88%
Southridge Video Litware 3,255,704.03 13.70%
Fabrikam 5,554,015.73 23.38%
Prose ware A. Datum The Phone Company Tailspin Toys 2,546,144.16 10.72%
Рис. 14.17 Функция ALLSELECTED
с использованием модификатора
KEEPFILTERS дала совсем другой
результате большим количеством
пустых ячеек
ГЛАВА 14 Продвинутые концепции языка DAX 505
Применение функции ALLSELECTED без параметров
Как ясно из названия, ALLSELECTED принадлежит к группе функций ALL*.
А значит, при использовании в качестве модификатора функции CALCULATE
она будет удалять ранее установленные фильтры. Если столбец, переданный
функции в качестве параметра, присутствует в каком-либо из неявных кон-
текстов фильтра, будет произведено восстановление последнего из неявных
контекстов по этому столбцу. Если неявных контекстов фильтра нет, функция
ALLSELECTED не будет выполнять никаких действий.
Будучи использованной в качестве модификатора CALCULATE, функция
ALLSELECTED, как и ALL, также может не принимать параметров. В этом слу-
чае она восстановит последний неявный контекст фильтра по любому столбцу.
Но это произойдет только в том случае, если столбец присутствует в каком-ли-
бо неявном контексте. Если столбец отфильтрован только при помощи явных
фильтров, ситуация с фильтрацией по нему не изменится.
Функции группы ALL*
По причине повышенной сложности функций группы ALL* в данном разделе
мы представим вам сводный обзор по ним. Все функции из этой группы ведут
себя по-разному, и для овладения ими в полной мере потребуется немало опы-
та. Здесь же мы познакомим вас с основными концепциями применения этих
полезных функций.
В группу ALL* входят следующие функции: ALL, ALLEXCEPT, ALLNOBLANK-
ROW, ALLCROSSFILTERED и ALLSELECTED. Все перечисленные функции могут
быть использованы как в качестве обычных табличных функций, так и в роли
модификаторов функции CALCULATE. При этом в первом случае понять их
поведение бывает намного легче. Будучи использованными в качестве мо-
дификаторов CALCULATE, эти функции могут производить неожиданные ре-
зультаты, поскольку в процессе выполнения они удаляют ранее наложенные
фильтры.
В табл. 14.5 мы свели воедино краткое описание функций из группы ALL*.
В оставшейся части раздела мы поговорим о каждой из них более подробно.
Колонка «Табличная функция» в табл. 14.5 относится к сценариям, в которых
функции группы ALL* используются внутри выражений DAX, тогда как в ко-
лонке «Модификатор функции CALCULATE» приведены принципы их работы
при использовании в качестве функций верхнего уровня в аргументах фильтра
функции CALCULATE.
Еще одно существенное различие между двумя типами использования этих
функций заключается в том, что когда вы извлекаете результат их работы че-
рез инструкцию EVALUATE, в него включаются только столбцы из исходной
таблицы, а не из ее расширенной версии. При этом все внутренние вычисле-
ния, включая преобразование контекста, всегда используют соответствующие
расширенные таблицы. В следующих примерах мы покажем разные варианты
использования функции ALL. Эти же концепции могут быть применены к лю-
бой функции из группы ALL*.
506 ГЛАВА 14 Продвинутые концепции языка DAX
ТАБЛИЦА 14.5 Сводный обзор функций из группы ALL*
Функция Табличная функция Модификатор функции CALCULATE
ALL Возвращает уникальные значения из столбца или таблицы Удаляет любые ранее наложенные фильтры со столбцов или расширенных таблиц. Никогда не добавляет фильтры, а только удаляет их
ALLEXCEPT Возвращает уникальные значения из таблицы, игнорируя при этом фильтры по некоторым столбцам расширенной таблицы Удаляет ранее наложенные фильтры с расширенной таблицы, за исключением столбцов (или таблиц), переданных в качестве аргументов
ALLNOBLANKROW Возвращает уникальные значения из столбца или таблицы, игнорируя пустые строки, добавленные для недействительных связей Удаляет любые ранее наложенные фильтры со столбцов или расширенных таблиц. Помимо этого, добавляет фильтр, удаляющий пустые строки.Таким образом, даже если фильтров в таблице нет, функция добавляет один фильтр к контексту
ALLSELECTED Возвращает уникальные значения из столбца или таблицы, как они видны в последнем созданном неявном контексте фильтра Восстанавливает последний неявный контекст фильтра в таблицах или столбцах, если таковой имеется. Иначе не выполняет никаких действий. Функция всегда добавляет фильтры, даже если текущий фильтр включает все значения
ALLCROSSFILTERED Недоступна для использования в качестве табличной функции Удаляет ранее наложенные фильтры с расширенной таблицы, включая таблицы, доступные напрямую или косвенно через двунаправленную фильтрацию. Функция ALLCROSSFILTERED никогда не добавляет фильтры, а только удаляет их
Давайте сначала используем ALL в качестве стандартной табличной функ-
ции:
SUMX (
ALL ( Sales ); -- ALL - это табличная функция
Sales[Quantity] * Sales[Net Price]
)
В следующем примере мы напишем сразу две формулы, использующие ите-
рации. В обоих случаях обращение к мере Sales Amount выполняет преобразо-
вание контекста применительно к расширенной таблице. При использовании
в качестве табличной функции ALL возвращает всю расширенную таблицу.
FILTER (
Sales;
[Sales Amount] > 100
)
-- Преобразование контекста выполняется
-- по расширенной таблице
FILTER (
ГЛАВА 14 Продвинутые концепции языка DAX 507
ALL ( Sales ); -- ALL - табличная функция
[Sales Anount] > 100 -- Преобразование контекста все равно
-- выполняется по расширенной таблице
)
В следующем примере применим функцию ALL в качестве модификатора
функции CALCULATE для удаления любых фильтров с расширенной версии
таблицы Sales:
CALCULATE (
[Sales Anount];
ALL ( Sales ) -- ALL - модификатор CALCULATE
)
Последний пример, хоть и будет очень похож на предыдущий, на самом деле
сильно отличается. Здесь функция ALL используется не в качестве модифика-
тора CALCULATE - вместо этого она применяется как аргумент функции FIL-
TER. В этом случае ALL будет вести себя как обычная табличная функция, воз-
вращая целую расширенную таблицу Sales.
CALCULATE (
[Sales Anount];
FILTER ( ALL ( Sales ); Sales[Quantity] > 0 ) -- ALL - табличная функция
-- Контекст фильтра все равно принимает
-- в качестве фильтра расширенную таблицу
)
Далее мы приведем более детальное описание функций, входящих в груп-
пу ALL*. Все они выглядят очень просто, но в действительности не так прос-
ты в использовании. В большинстве случаев они будут вести себя так, как вы
и предполагаете, но в пограничных ситуациях могут выдавать неожиданные
результаты. Бывает непросто запомнить все эти правила и особенности их по-
ведения. Надеемся, табл. 14.5 еще не раз пригодится вам при использовании
функций из группы ALL*.
Функция ALL
В качестве табличной функции ALL применять очень просто. Она возвращает
все уникальные значения по одному или нескольким столбцам либо по всей
таблице. При использовании внутри функции CALCULATE в качестве модифи-
катора ALL начинает вести себя как гипотетическая функция REMOVEFILTER.
Если какой-либо из столбцов включен в фильтр, функция удалит эту фильтра-
цию. Важно пояснить при этом, что если столбец отфильтрован при помощи
перекрестной фильтрации, функция ALL его не затронет. Данная функция уда-
ляет только фильтры, установленные напрямую. Таким образом, использова-
ние выражения ALL ( Product[Color] ) в качестве модификатора функции CAL-
CULATE может оставить активным кросс-фильтр на столбце Product/Color], если
установлен фильтр на другом столбце таблицы Product. Функция ALL опериру-
ет расширенными таблицами. Именно поэтому выражение ALL ( Sales ) удалит
фильтры со всех таблиц в модели данных: расширенная таблица Sales, как мы
508 ГЛАВА 14 Продвинутые концепции языка DAX
уже говорили, включает в себя всю модель данных. Применение функции ALL
без аргументов удалит все фильтры со всей модели данных.
Функция ALLEXCEPT
Будучи использованной в качестве табличной функции, ALLEXCEPT воз-
вращает все уникальные значения столбцов из таблицы, за исключением
столбцов, указанных в качестве аргументов. При использовании функции
в качестве фильтра в результат будет включена вся расширенная таблица.
Применение функции ALLEXCEPT как аргумента фильтра в CALCULATE при-
ведет к аналогичному поведению функции ALL, за исключением того, что
фильтры не будут удалены со столбцов, переданных в качестве аргументов.
Важно помнить, что использование функции ALLEXCEPT и связки ALL/VAL-
UES не равносильно. Функция ALLEXCEPT просто удаляет фильтры, тогда как
в сочетании ALL/VALUES функция ALL удаляет фильтры, a VALUES сохраняет
кросс-фильтрацию путем добавления нового фильтра. Это очень тонкое, но
существенное различие.
Функция ALLNOBLANKROW
При использовании в качестве табличной функции ALLNOBLANKROW ведет
себя как функция ALL, за исключением того, что не возвращает пустые стро-
ки, которые могут появиться из-за присутствия недействительных связей.
При этом функция ALLNOBLANKROW может возвращать пустые строки, если
они присутствуют в исходной таблице. Гарантированно будут удалены толь-
ко те строки, которые были автоматически добавлены движком для устране-
ния недействительных связей. Будучи примененной в качестве модификатора
CALCULATE, функция ALLNOBLANKROW заменяет все существующие фильтры
новыми, удаляющими пустые строки. Таким образом, по всем столбцам будет
выполняться фильтрация на наличие пустых значений.
Функция ALLSELECTED
В качестве табличной функции ALLSELECTED возвращает значения из табли-
цы (или столбца) так, как они представлены в последнем созданном неявном
контексте фильтра. Как модификатор функции CALCULATE, ALLSELECTED вос-
станавливает последний неявный контекст фильтра по каждому столбцу. Если
столбцы присутствуют в разных неявных контекстах, функция восстанавлива-
ет последний контекст для каждого столбца.
Функция ALLCROSSFILTERED
Функция ALLCROSSFILTERED может быть использована исключительно как
модификатор функции CALCULATE, но не как самостоятельная табличная
функция. У этой функции есть только один аргумент, представляющий собой
таблицу. Функция ALLCROSSFILTERED удаляет все фильтры с расширенной
таблицы (подобно функции ALL), а также со столбцов и таблиц, попавших под
ГЛАВА 14 Продвинутые концепции языка DAX 509
перекрестную фильтрацию из-за наличия двунаправленных связей, прямо
или косвенно объединяющих их с расширенной таблицей.
Использование привязки данных
В главе 10 мы познакомились с концепцией привязки данных и научились
контролировать ее при помощи функции TREATAS. В главах 12 и 13 мы так-
же показали, как конкретные табличные функции способны манипулировать
привязкой данных результирующего набора. В этом разделе мы подведем ито-
ги всего изученного материала по данной теме, а также уточним некоторые
нюансы, о которых не говорили ранее.
Представляем вам базовые правила работы концепции привязки данных:
каждый столбец в модели обладает своей уникальной привязкой данных;
когда модель фильтруется при помощи контекста фильтра, фактически
фильтрация распространяется на столбцы с той же привязкой данных,
что и у столбцов в текущем контексте фильтра;
поскольку фильтр является результатом таблицы, важно знать, как таб-
личные функции могут влиять на привязку данных в результирующем
наборе:
• в основном столбцы, используемые для осуществления группировки,
сохраняют в итоговом наборе свою привязку данных;
• для столбцов с результатами агрегирования всегда создается новая
привязка данных;
• для столбцов, созданных при помощи функций ROW и ADDCOLUMNS,
также создается новая привязка;
• столбцы, созданные функцией SELECTEDCOLUMNS, сохраняют свою
привязку данных в случае, если их выражение включает в себя только
ссылку на столбец. В остальных случаях будет создана новая привязка.
В следующем примере мы попытались сформировать таблицу, в которой для
каждого цвета должна быть выведена общая сумма продаж по товарам этого
цвета. Однако поскольку столбец С2 был создан функцией ADDCOLUMNS, его
привязка данных не соответствует столбцу Product[Color] из модели данных,
несмотря на одинаковое содержимое. Обратите внимание, что в нашем приме-
ре мы действовали пошагово: сначала создали столбец С2, а затем выбрали из
таблицы только его. Если бы в итоговой таблице оставались и другие столбцы,
результат был бы совсем иным.
DEFINE
MEASURE Sales[Sales Amount] =
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
EVALUATE
VAR NonBlueColors =
FILTER (
ALL ( 'Product1[Color] );
1 Product1[Color] <> "Blue"
510 ГЛАВА 14 Продвинутые концепции языка DAX
)
VAR AddC2 =
ADDCOLUMNS (
NonBlueColors;
"[C2]"; 'Product'[Color]
)
VAR Select0nlyC2 =
SELECTCOLUMNS ( AddC2; "C2"; [C2] )
VAR Result =
ADDCOLUMNS ( Select0nlyC2; "Sales Anount"; [Sales Anount] )
RETURN Result
ORDER BY [C2]
В итоге мы получили столбец с цветами, но мера Sales Amount для каждого из
них выдает одинаковую сумму, равную итоговым продажам по таблице Sales.
Вывод запроса показан на рис. 14.18.
С2 Sales Amount
Azure 30,591,343.98
Black 30,591,343.98
Brown 30,591,343.98
Gold 30,591,343.98
Green 30,591,343.98
Grey 30,591,343.98
Orange 30,591,343.98
Pink 30,591,343.98
Purple 30,591,343.98
Red 30,591,343.98
Silver 30,591,343.98
Silver Grey 30,591,343.98
Transparent 30,591,343.98
White 30,591,343.98
Yellow 30,591,343.98
Рис. 14.18 Привязка данных нового столбца С2
не совпадает с привязкой столбца Product[Color]
из модели
Для изменения привязки данных можно использовать функцию TREATAS.
В следующем варианте запроса мы устанавливаем привязку данных нового
столбца к столбцу ProductfColor] в модели, что позволяет функции ADDCOL-
UMNS рассчитывать меру Sales Amount с использованием преобразования кон-
текста по столбцу Color:
DEFINE
MEASURE Sales[Sales Anount] =
SUMX ( Sales; SalesfQuantity] * Sales[Net Price] )
EVALUATE
VAR NonBlueColors =
FILTER (
ALL ( 'Product'[Color] );
'Product'[Color] <> "Blue"
)
ГЛАВА 14 Продвинутые концепции языка DAX 511
VAR AddC2 =
ADDCOLUMNS (
NonBlueColors;
"[C2]"; 'Product'[Color]
)
VAR Select0nlyC2 =
SELECTCOLUMNS ( AddC2; "C2"; [C2] )
VAR TreatAsColor =
TREATAS ( SelectOnlyCZ; 'Product'[Color] )
VAR Result =
ADDCOLUMNS ( TreatAsColor; "Sales Anount"; [Sales Anount] )
RETURN Result
ORDER BY 'Product'[Color]
В качестве побочного эффекта функция TREATAS также переименовала
столбец С2 в Color, что мы учли в инструкции ORDER BY. Результат выполнения
исправленного запроса показан на рис. 14.19.
Color Sales Amount
Azure 97,389.89
Black 5,860,066.14
Brown 1,029,508.95
Gold 361,496.01
Green 1,403,184.38
Grey 3,509,138.09
Orange 857,320.28
Pink 828,638.54
Purple 5,973.84
Red 1,110,102.10
Silver 6,798,560.86
Silver Grey 371,908.92
Transparent 3,295.89
White 5,829,599.91
Yellow 89,715.56
Рис. 14.19 Теперь привязка данных
в столбце Color соответствует
привязке столбца ProductfColor]
Заключение
В данной главе мы изучили две важные концепции: расширенные таблицы
и неявные контексты фильтра.
Расширенные таблицы являются фундаментом DAX. Вам может понадобить-
ся какое-то время, чтобы начать мыслить категориями расширенных таблиц.
Но как только вы поймете все нюансы этой концепции, вам будет намного лег-
че работать со связями. Разработчику приходится работать с расширенными
таблицами напрямую не так часто, но знать об их существовании нужно обя-
зательно, ведь зачастую только они позволяют досконально понять, почему
результат того или иного выражения получился именно таким.
512 ГЛАВА 14 Продвинутые концепции языка DAX
В этом смысле неявные контексты фильтра сильно напоминают расширен-
ные таблицы: эту концепцию нелегко понять и осознать, но иногда только она
способна пролить свет на то, почему мы получили те или иные цифры в отчете.
Без полного понимания неявных контекстов фильтра за написание сложных
мер с использованием функции ALLSELECTED можно даже не браться.
Кроме того, обе концепции являются настолько сложными, что мы совету-
ем вам избегать их использования там, где это возможно. В следующей главе
мы покажем несколько примеров, в которых расширенные таблицы придутся
очень кстати. Что касается неявных контекстов фильтра, то их использование
в коде не имеет смысла. Скорее, это техническое средство языка DAX, позволя-
ющее разработчикам рассчитывать итоги на уровне элемента визуализации.
Избежать задействования расширенных таблиц можно путем использова-
ния в аргументах фильтра функции CALCULATE фильтров по столбцам, а не по
таблицам. Так вы значительно упростите свой код. Чаще всего от использова-
ния расширенных таблиц можно отказаться, если речь не идет о какой-нибудь
специфической мере.
Чтобы избежать использования неявных контекстов фильтра, следует в пер-
вую очередь отказаться от применения мер с функцией ALLSELECTED внутри
итераторов. Единственной итерацией перед вызовом функции ALLSELECTED
должна быть итерация, созданная движком запросов, - в большинстве случа-
ев это Power BI. Обращение к мерам, использующим функцию ALLSELECTED,
внутри итераций - верный путь к излишнему усложнению ваших вычислений.
Если вы будете следовать этим двум советам, ваш код на языке DAX станет
простым и понятным. Конечно, эксперты способны оценить по достоинству
сложность кода, но в то же время они прекрасно понимают, когда стоит избе-
гать излишнего нагромождения. Отказ от использования табличных фильтров
и мер с ALLSELECTED внутри итераций не сделает вас менее образованным
в глазах окружающих. Более того, таким образом вы еще на шаг приблизитесь
к экспертам, желающим, чтобы их код работал как можно более гладко и без-
отказно.
ГЛАВА 15
Углубленное изучение связей
На этом этапе мы раскрыли вам все секреты DAX. В предыдущих главах мы рас-
сказали вам все, что было возможно, о синтаксисе и функциональности языка.
Но впереди еще длинный путь. Вам предстоит прочитать еще две главы, по-
священные непосредственно языку DAX, после чего мы погрузимся в вопросы
оптимизации. В следующей главе мы представим вам несколько идей относи-
тельно продвинутых вычислений с использованием DAX, а в этой расскажем,
как при помощи DAX создавать более сложные типы связей между таблица-
ми. К таким типам связей относятся вычисляемые физические и виртуальные
связи. Затем мы подробнее поговорим о различных видах физических связей,
включая связи типа «один к одному», «один ко многим» и «многие ко мно-
гим». Каждый из этих типов связей достоин отдельного рассмотрения. Также
мы посвятим достаточно времени вопросам неоднозначности моделей дан-
ных. В моделях данных могут встречаться неоднозначности, и вы должны быть
в курсе этого, чтобы правильно и вовремя реагировать.
В конце данной главы мы посвятим время теме, больше касающейся моде-
лирования данных, нежели самого языка DAX. Речь идет о связях на разных
уровнях гранулярности. При проектировании сложных моделей данных для
расчета бюджета и продаж вы неминуемо столкнетесь с таблицами с разными
уровнями гранулярности и должны уметь грамотно с ними обращаться.
Реализация вычисляемых физических связей
Первый набор связей, который мы рассмотрим, - это вычисляемые физические
связи (calculated physical relationships). Бывают случаи, когда традиционными
связями в модели данных воспользоваться не получается. Например, у вас мо-
жет просто не быть ключевого столбца в одной из таблиц, или вам необходимо
использовать в связи поле, вычисленное по сложной формуле. В таких сцена-
риях лучше всего будет прибегнуть к созданию связей с использованием вы-
числяемых столбцов. В результате у вас будет полноценная физическая связь,
и единственным ее отличием от обычной связи будет то, что ключевым столб-
цом в ней будет выступать вычисляемый столбец, а не физический столбец
в модели данных.
Создание связей по нескольким столбцам
Табличная модель данных предусматривает возможность создания связей
между таблицами исключительно по одному столбцу. Такая модель не позво-
514 ГЛАВА 15 Углубленное изучение связей
ляет использовать несколько столбцов на одной стороне связи. И все же связи
по нескольким столбцам могут оказаться очень полезными в моделях данных,
не подверженных изменениям. Вот два способа создания таких связей:
определить вычисляемый столбец, содержащий сочетание двух или бо-
лее ключей, и использовать его в качестве нового ключа для связи;
денормализовать столбцы целевой таблицы (находящейся в связи «один
ко многим» на стороне «один») при помощи функции LOOKUPVALUE.
Представьте, что мы вводим в модели данных Contoso акцию «Товары дня»,
суть которой состоит в том, что в разные дни мы будем давать определенную
скидку на конкретные товары. Соответствующая модель данных изображена
на рис. 15.1.
Рис. 15.1 Таблицу Discounts необходимо объединить с таблицей Sales по двум столбцам
В таблице Discounts содержится три столбца: Date, ProductKey и Discount. Если
разработчику понадобятся эти данные для вычисления общей суммы скидок,
он столкнется с серьезной проблемой, состоящей в том, что для каждой отдель-
ной продажи скидка будет зависеть от значений столбцов ProductKey и Order
Date. Получается, что между таблицами Sales и Discounts невозможно создать
связь: для этого нам потребовалось бы объединить таблицы по двум столбцам,
a DAX позволяет создавать связи только по одному полю.
Первым способом решения задачи может быть создание сопоставимых вы-
числяемых столбцов в обеих таблицах, сочетающих значения из двух других
столбцов:
Sales[DiscountKey] =
COMBINEVALUES (
II II
Sales[Order Date],
Sales[ProductKey]
)
ГЛАВА 15 Углубленное изучение связей 515
Discounts[DiscountKey] =
COMBINEVALUES(
II II
9
Discounts[Date],
Discounts[ProductKey]
)
В этих вычисляемых столбцах мы прибегли к помощи функции COMBINE-
VALUES. Функция COMBINEVALUES принимает в качестве параметров раз-
делитель и список выражений, которые будут объединены вместе как строки
с указанным разделителем. Для выполнения этой операции можно было вос-
пользоваться обычной конкатенацией строк, но функция COMBINEVALUES об-
ладает определенными преимуществами. Эта функция оказывается чрезвы-
чайно полезной при создании связей на основании вычисляемых столбцов,
если в модели данных используется режим DirectQuery. Функция COMBINE-
VALUES предполагает - но не утверждает, - что если входные данные различ-
ны, то и строки на выходе будут отличаться. С учетом этого предположения
использование функции COMBINEVALUES при создании вычисляемых столб-
цов для последующего объединения таблиц в режиме DirectQuery позволяет
генерировать наиболее оптимальные условия во время выполнения запроса.
Примечание Более подробно о методах оптимизации COMBINEVALUES совместно с Di-
rectQuery читайте по адресу: https://www.sqLbi.com/articles/using-combinevalues-to-opti-
mize-directquery-performance/.
После создания столбцов можно приступать к объединению таблиц при по-
мощи связи. В моделях данных связи на основании вычисляемых столбцов ра-
ботают так же безопасно, как и обычные связи.
Это достаточно прямолинейное решение, и оно годится в большинстве слу-
чаев. Однако существуют сценарии, когда такой вариант будет неоптималь-
ным из-за необходимости создавать два дополнительных столбца с большим
количеством значений. Как вы узнаете из последующих глав по оптимизации,
это может негативно сказаться на размере модели данных и ее производитель-
ности.
Второй способ решения этой задачи состоит в использовании функции
LOOKUPVALUE. Применив эту функцию, вы можете денормализовать скидку
в таблице Sales, определив для нее новый вычисляемый столбец:
Sales[Discount] =
LOOKUPVALUE (
Discounts[Discount];
Discounts[ProductKey]; Sales[ProductKey];
Discounts[Date];
Sales[Order Date]
)
В этом случае вам не придется создавать новую связь. Вместо этого вы прос-
то денормализуете значение скидки из таблицы Discount в таблице Sales при
помощи функции поиска.
516 ГЛАВА 15 Углубленное изучение связей
Оба варианта вполне применимы, и выбор между ними зависит от конкрет-
ной задачи. Если от этой связки вам необходимо получить только одно зна-
чение скидки, то способ с денормализацией столбца подойдет вам лучше по
причине своей простоты. К тому же он не так требователен к памяти компью-
тера, поскольку мы фактически создаем только один вычисляемый столбец
с меньшим количеством уникальных значений по сравнению с двумя столб-
цами в первом варианте.
Но если в таблице Discounts содержится несколько столбцов, информация из
которых может понадобиться нам в расчетах, то придется создавать не одно
денормализованное поле в таблице Sales для хранения всех нужных нам дан-
ных. Это приведет к пустой трате памяти и увеличению времени, требуемого
для обработки данных. В этом случае лучше подойдет вариант со связями по-
средством составных ключей.
Показанный в данном разделе пример был очень важен, поскольку с его по-
мощью мы смогли продемонстрировать возможность создавать связи на ос-
нове вычисляемых столбцов. Таким образом, пользователь может наладить
любые необходимые ему связи в модели данных, при условии что он сумеет
вычислить и материализовать требуемые составные ключи в вычисляемых
столбцах. В следующем примере мы покажем, как создавать связи на основе
статических диапазонов. Расширив эту концепцию, вы сможете устанавливать
самые разные связи между таблицами.
Реализация связей на основе диапазонов
Чтобы вы лучше усвоили пользу от вычисляемых физических связей, мы рас-
смотрим сценарий со статической сегментацией (static segmentation) товаров
по цене. Цена за единицу товара - очень вариативный показатель со множест-
вом возможных значений, и анализ на основе конкретных значений цены нам
ничего не даст. В таких случаях обычно применяется техника разбиения цен по
сегментам с использованием конфигурационной таблицы, подобной той, что
показана на рис. 15.2.
PriceRangeKey PriceRange MinPrice MaxPrice
1 Very low 10
2 Low 10 30
3 Medium 30 80
4 High 80 150
5 Very high 150 99,999
Рис. 15.2 Таблица Configuration для хранения диапазонов цен на товары
Как и в предыдущем примере, мы не можем создать прямую связь между
таблицами Sales и Configuration. Причина в том, что в конфигурационной таб-
лице ключ зависит от целого диапазона значений, а в DAX связи на основе це-
лого спектра значений не поддерживаются. Мы могли бы вычислить значение
ключа в таблице Sales при помощи вложенных операторов IF, но в этом случае
нам пришлось бы включать значения из конфигурационной таблицы прямо
в формулу, как показано ниже, чего нам, конечно, хотелось бы избежать:
ГЛАВА 15 Углубленное изучение связей 517
Sales[PriceRangeKey] =
SWITCH (
TRUE (),
Sales[Net Price] <= 10; 1;
Sales[Net Price] <= 30; 2;
Sales[Net Price] <= 80; 3;
Sales[Net Price] <= 150; 4;
5
)
Приемлемое решение не должно основываться на указании конкретных це-
новых диапазонов внутри формулы. Вместо этого код должен быть напрямую
связан с конфигурационной таблицей, чтобы при ее изменении обновлялась
вся модель.
В таком случае лучше всего будет денормализовать в таблице Sales названия
диапазонов при помощи вычисляемого столбца. Шаблон кода в этом случае бу-
дет похож на предыдущий, но сама формула будет отличаться, поскольку вос-
пользоваться функцией LOOKUPVALUE здесь мы не сможем:
Sales[PriceRange] =
VAR FilterPriceRanges =
FILTER (
PriceRanges;
AND (
PriceRanges[MinPrice] <= Sales[Net Price];
PriceRanges[MaxPrice] > Sales[Net Price]
)
)
VAR Result =
CALCULATE (
VALUES ( PriceRanges[PriceRange] );
FilterPriceRanges
)
RETURN
Result
Заметьте, что функция VALUES здесь используется для извлечения одинар-
ного значения. В общем виде эта функция возвращает таблицу, но, как мы рас-
сказывали в главе 3, если в этой таблице всего одна строка и один столбец,
то она может быть преобразована в скалярную величину, в случае если того
требует выражение.
Функция FILTER здесь всегда будет возвращать одну строку из конфигура-
ционной таблицы, так что на вход функции VALUES гарантированно будет по-
даваться таблица из одной строки и одного столбца. Соответственно, резуль-
татом вычисления функции CALCULATE будет описание ценового диапазона
товара из текущей строки в таблице Sales. Если конфигурационная таблица
построена корректно, это выражение всегда будет возвращать одно значение.
В случае если диапазоны будут пересекаться или, наоборот, иметь разрывы,
функция VALUES может вернуть несколько строк, что приведет к ошибке всего
выражения.
518 ГЛАВА 15 Углубленное изучение связей
Показанная техника позволяет выполнять денормализацию описаний цено-
вых диапазонов товаров в таблице Sales. Если пойти еще дальше, можно денор-
мализовать не описание, а ключ, чтобы можно было на основании этого вы-
числяемого столбца построить физическую связь. Но на этом шаге нужно быть
особенно внимательными. Простого изменения названия столбца PriceRange
для извлечения ключа будет достаточно, но для построения связи этого не хва-
тит. В следующем фрагменте кода добавлена обработка возникновения ошиб-
ки и возврат пустого значения в этом случае:
Sales[PriceRangeKey] =
VAR FilterPriceRanges =
FILTER (
PriceRanges;
AND (
PriceRanges[MinPrice] <= Sales[Net Price];
PriceRanges[MaxPrice] > Sales[Net Price]
)
)
VAR Result =
CALCULATE (
IFERROR (
VALUES ( PriceRanges[PriceRangeKey] );
BLANK ()
);
FilterPriceRanges
)
RETURN
Result
Теперь в вычисляемом столбце PriceRangeKey всегда будет находиться кор-
ректное значение. К сожалению, при попытке создать связь между таблица-
ми PriceRanges и Sales по столбцу PriceRangeKey возникает ошибка, связанная
с циклической зависимостью. При связывании вычисляемых столбцов и таб-
лиц такая ошибка появляется довольно часто.
В нашем случае исправить ситуацию довольно легко: достаточно использо-
вать функцию DISTINCT вместо VALUES в выделенной жирным шрифтом стро-
ке формулы. После этого связь будет создана успешно. Результат вычисления
этой формулы показан на рис. 15.3.
PriceRange Sales Amount
Very low 139,686.63
Low 495,522.26
Medium 855,390.66
High 2,130,309.01
Very high 26,970,435.41
Total 30,591,343.98
Рис. 15.3 Теперь мы легко можем делать срезы
по ценовому диапазону товаров
Замена функции VALUES на DISTINCT позволила нам избавиться от ошибки,
связанной с циклической зависимостью. Механизмы, лежащие в основе этих
ГЛАВА 15 Углубленное изучение связей 519
зависимостей, достаточно сложны. В следующем разделе мы подробно расска-
жем о причинах, приводящих к возникновению циклических зависимостей
при создании связей между вычисляемыми столбцами и таблицами, а также
поясним, почему использование функции DISTINCT решило проблему.
Циклические зависимости
в вычисляемых физических связях
В предыдущем примере мы создали вычисляемый столбец, на основании ко-
торого было выполнено объединение двух таблиц. Это привело к появлению
ошибки, связанной с циклической зависимостью. Работая с вычисляемыми
физическими связями, вы будете достаточно часто сталкиваться с такого рода
ошибками, так что нелишним будет потратить немного времени, чтобы разо-
браться с источником их возникновения. А заодно научитесь их избегать.
Давайте восстановим формулу вычисляемого столбца в сокращенном виде:
Saies[PriceRangeKey] =
CALCULATE (
VALUES ( PriceRanges[PriceRangeKey] );
FILTER (
PriceRanges;
AND (
PriceRanges[MinPrice] <= Sales[Net Price];
PriceRanges[MaxPrice] > Sales[Net Price]
)
)
)
Значения в вычисляемом столбце PriceRangeKey зависят от содержимого кон-
фигурационной таблицы PriceRanges. Если диапазоны в PriceRanges изменят-
ся, должны пересчитаться и значения вычисляемого столбца в таблице Sales.
В этой формуле есть сразу несколько упоминаний таблицы PriceRanges, так что
зависимость столбца от нее абсолютно очевидна. И гораздо менее очевидно то,
что создание связи между этим столбцом и таблицей PriceRanges само по себе
создает обратную зависимость.
В главе 3 мы упоминали, что движок DAX автоматически создает пустую
строку на стороне «один», если связь недействительная. Так что если табли-
ца находится в связи на стороне «один», ее содержимое напрямую зависит от
правильности связи, которая, в свою очередь, зависит от значений в столбце,
используемом для создания этой связи.
В нашем сценарии при создании связи между таблицами Sales и PriceRang-
es на основании столбца Sales[PriceRangeKey] в таблице PriceRanges могут как
присутствовать пустые строки, так и нет - в зависимости от значений столбца
Sales[PriceRangeKey]. Иначе говоря, когда значение в столбце Sales[PriceRangeKey]
изменяется, содержимое таблицы PriceRanges также может поменяться. В то же
время при изменении таблицы PriceRanges может потребоваться обновление
столбца Sales[PriceRangeKey], даже если добавленная пустая строка и не будет
использоваться. Именно это и является причиной обнаружения движком DAX
520 ГЛАВА 15 Углубленное изучение связей
циклической зависимости. Человеческому глазу трудно усмотреть здесь несо-
ответствия, но алгоритмы DAX легко их обнаруживают.
Если бы инженеры-разработчики языка DAX не позаботились об этой проб-
леме, мы бы не смогли создавать связи на основе вычисляемых столбцов. Но
они снабдили движок особой логикой, которая может пригодиться в подобных
случаях.
Результатом этих усилий стали два вида зависимостей, которые присутствуют
в DAX: зависимость по формуле (formula dependency) и зависимость по пустым
строкам (blank row dependency). Для нашего примера справедливо следующее:
столбец SalesfPriceRangeKey] зависит от таблицы PriceRanges как по фор-
муле (в которой есть ссылки на таблицу PriceRanges), так и по пустым
строкам (в нем используется функция VALUES, которая может возвра-
щать дополнительную пустую строку);
таблица PriceRanges зависит от столбца Sales[PriceRangeKey] только по
пустым строкам. Изменение значения в столбце Sales[PriceRangeKey] не
приведет к изменению содержимого таблицы PriceRanges, оно может по-
влиять только на присутствие в ней пустой строки.
Чтобы разорвать замкнутый круг циклической зависимости, достаточно ис-
ключить зависимость столбца Sales[PriceRangeKey] от присутствия пустой стро-
ки в таблице PriceRanges. Для этого необходимо, чтобы все функции, исполь-
зуемые в формуле, не зависели от пустых строк. Функция VALUES, к примеру,
оставляет в результирующем наборе пустую строку, если таковая присутствует
в исходной таблице. А значит, функция VALUES зависит от пустых строк. В то
же время функция DISTINCT исключает из вывода пустые строки, присутству-
ющие в источнике. Следовательно, она не зависит от наличия пустых строк.
Если использовать функцию DISTINCT вместо VALUES, столбец Sales[Price-
RangeKey] также не будет зависеть от пустых строк. В результате две сущности
(таблица и столбец) сохранят зависимость друг от друга, но природа этой зави-
симости изменится. Таблица PriceRanges будет зависеть от столбца Sales[Price-
RangeKey] по пустым строкам, тогда как обратная зависимость будет исключи-
тельно по формуле. А поскольку эти две зависимости не связаны друг с другом,
замкнутый круг циклической зависимости будет разорван, и мы сможем соз-
дать связь, как планировали.
Создавая вычисляемые столбцы, которые предполагается использовать
в будущем для построения связей, необходимо следовать простым правилам:
использовать функцию DISTINCT вместо VALUES;
использовать функцию ALLNOBLANKROW вместо ALL;
не использовать в функции CALCULATE фильтры с компактным синтак-
сисом.
Первые два замечания вполне понятны. А пункт с функцией CALCULATE мы
поясним на следующем примере. Рассмотрим такое выражение:
CALCULATE (
МАХ ( Custoner[YearlyIncone] );
Custoner[Education] = "High school"
)
ГЛАВА 15 Углубленное изучение связей 521
На первый взгляд, эта формула никак не зависит от наличия пустых строк
в таблице Customer. На самом же деле это не так. Причина в том, что DAX авто-
матически расширяет синтаксис функции CALCULATE, если аргументы фильт-
ра в ней указаны в компактной форме, следующим образом:
CALCULATE (
МАХ ( CustomerfYearlyIncome] );
FILTER (
ALL ( Customer[Education] );
Customer[Education] = "High school"
)
)
Выделенная жирным шрифтом строка содержит функцию ALL, что создает
зависимость по пустым строкам. Такие моменты бывает непросто обнаружить,
но если вы понимаете базовые принципы образования циклических зависи-
мостей, то с легкостью сможете устранить причины такого поведения. Преды-
дущий пример может быть переписан следующим образом:
CALCULATE (
МАХ ( CustomerfYearlyIncome] );
FILTER (
ALLNOBLANKROW ( Customer[Education] );
Customer[Education] = "High school"
)
)
Использовуя функцию ALLNOBLANKROWявным образом, мы избавились от
зависимости по пустым строкам в таблице Customer.
Стоит отметить, что в коде зачастую прячутся функции, полагающиеся на
пустые строки. Для примера рассмотрим фрагмент кода из предыдущего раз-
дела, где мы создавали вычисляемую физическую связь на основании диапа-
зона цен. Вот оригинальная формула:
Sales[PriceRangeKey] =
CALCULATE (
VALUES ( PriceRanges[PriceRangeKey] );
FILTER (
PriceRanges;
AND (
PriceRangesfMinPrice] <= SalesfNet Price];
PriceRangesfMaxPrice] > Sales[Net Price]
)
)
)
Присутствие функции VALUES в этой формуле вполне объяснимо. Но есть
еще один способ написать похожее вычисление, используя вместо VALUES
функцию SELECTEDVALUE, чтобы формула не возвращала ошибку в случае ви-
димости сразу нескольких строк:
522 ГЛАВА 15 Углубленное изучение связей
Sales[PriceRangeKey] =
VAR FilterPriceRanges =
FILTER (
PriceRanges;
AND (
PriceRanges[MinPrice] <= Sales[Net Price];
PriceRanges[MaxPrice] > Sales[Net Price]
)
)
VAR Result =
CALCULATE (
SELECTEDVALUE ( PriceRanges[PriceRangeKey] );
FilterPriceRanges
)
RETURN Result
К сожалению, при попытке создать связь этот код выдаст уже знакомую нам
ошибку, связанную с наличием циклических зависимостей, несмотря на то что
мы вроде не использовали функцию VALUES. И все же в неявном виде эта функ-
ция здесь присутствует. Дело в том, что функция SELECTEDVALUE внутренне
преобразуется в следующий синтаксис в выражении:
Sales[PriceRangeKey] =
VAR FilterPriceRanges =
FILTER (
PriceRanges;
AND (
PriceRanges[MinPrice] <= Sales[Net Price];
PriceRanges[MaxPrice] > Sales[Net Price]
)
)
VAR Result =
CALCULATE (
IF (
HASONEVALUE ( PriceRanges[PriceRangeKey] );
VALUES ( PriceRanges[PriceRangeKey] );
BLANK ()
);
FilterPriceRanges
)
RETURN
Result
После раскрытия полной версии кода присутствие функции VALUES стало
гораздо более очевидным. А отсюда и зависимость от пустых строк, повлекшая
за собой ошибку, связанную с наличием циклической зависимости.
Реализация виртуальных связей
В предыдущих разделах мы рассказали, как создавать вычисляемые физиче-
ские связи на основании вычисляемых столбцов. Но существуют сценарии,
ГЛАВА 15 Углубленное изучение связей 523
в которых использование физических связей будет нелучшим решением.
И тогда на помощь приходят связи виртуальные. Виртуальная связь (virtual re-
lationship) имитирует связь физическую. С точки зрения пользователя такая
связь ничем не отличается от обычной, за исключением того, что зрительно
в модели данных она не присутствует. А поскольку связи в модели нет, ответ-
ственность за распространение фильтров от одной таблице к другой ложится
на разработчика DAX.
Распространение фильтров в DAX
Одной из самых мощных особенностей DAX является возможность распро-
странять установленные фильтры между таблицами по связям. Но бывают
ситуации, когда физическую связь между двумя сущностями наладить очень
трудно, если не невозможно. В этом случае на выручку приходят выражения
DAX, позволяющие разными способами имитировать физически построенные
связи. В данном разделе мы познакомимся с различными техниками распро-
странения фильтров на примере идеально подходящего для этого сценария.
Компания Contoso размещает свою рекламу в газетах и интернете, выбирая
для этого один или несколько брендов каждый месяц. Информация о реклами-
руемых брендах хранится в таблице Advertised Brands с указанием года и меся-
ца. Фрагмент этой таблицы вы можете видеть на рис. 15.4.
Calendar Year Month Brand
СУ 2007 February A. Datum
CY 2007 February Tailspin Toys
CY 2007 March A. Datum
CY 2007 March Northwind Traders
CY 2007 March Proseware
CY 2007 March Southridge Video
CY 2007 March Tailspin Toys
CY 2007 March The Phone Company
CY 2007 March Wide World Importers
CY 2007 April A. Datum
CY 2007 April Contoso
CY 2007 April Proseware
CY 2007 May Adventure Works
Рис. 15.4 В таблице содержится по одной строке для каждого бренда по тем месяцам,
когда эти бренды входят в рекламную кампанию
Сразу отметим, что в этой таблице нет ключевого столбца с уникальными
значениями. Несмотря на то что все строки в ней являются уникальными,
в каждом столбце присутствует масса дубликатов. Следовательно, в связи эта
таблица никак не может находиться на стороне «один». И этот факт обретет
немалую важность, когда мы подробнее опишем задачу.
А задача состоит в том, чтобы создать меру для подсчета суммы продаж по
товарам только за период времени, когда они были включены в рекламную
524 ГЛАВА 15 Углубленное изучение связей
кампанию. Чтобы решить этот сценарий, необходимо для начала определить,
был ли тот или иной бренд включен в рекламную кампанию в конкретном
месяце. Если бы мы могли создать связь между таблицами Sales и Advertised
Brands, написать код не составило бы труда. К сожалению, связь здесь наладить
не так-то просто (так было задумано в образовательных целях).
Одним из возможных решений может быть создание в обеих таблицах вы-
числяемого столбца, сочетающего в себе год, месяц и бренд. Таким образом,
мы могли бы последовать технике создания связи на основании нескольких
столбцов, описанной в предыдущем разделе. Но в данном случае есть и другие
способы решения задачи - без необходимости создавать вычисляемые поля.
Одно из решений (далеко не самое оптимальное) включает в себя исполь-
зование итерационных функций. Можно пройти по таблице Sales построчно
и проверить, входил ли бренд текущего товара в текущем месяце в рекламную
кампанию. Таким образом, следующая мера решит нашу проблему, пусть и да-
леко не самым эффективным способом:
Advertised Brand Sales :=
SUMX (
FILTER (
Sales;
CONTAINS (
'Advertised Brands1;
'Advertised Brands'[Brand]; RELATED ( 'Product'[Brand] );
'Advertised Brands'[Calendar Year]; RELATED ( 'Date'[Calendar Year] );
'Advertised Brands'[Month]; RELATED ( 'Date'[Month] )
)
);
Sales[Quantity] * Sales[Net Price]
)
В мере используется функция CONTAINS, осуществляющая поиск строки
в таблице. Функция CONTAINS принимает в качестве первого параметра таб-
лицу для осуществления поиска. Следом параметры идут парами: столбец для
поиска и значение для поиска. В нашем примере функция CONTAINS вернет
значение True, если в таблице Advertised Brands будет как минимум одна строка
с текущим годом, месяцем и брендом, где слово текущий означает актуальную
итерацию функции FILTER по таблице Sales.
Эта мера правильно вычисляет результат, как показано на рис. 15.5, но при
этом в ней есть целый ряд проблем.
Вот две наиболее серьезные проблемы, характерные для предыдущего кода:
функция FILTER осуществляет итерации по таблице Sales, которая сама по
себе является очень объемной, и для каждой строки вызывает функцию
CONTAINS. И как бы быстро ни выполнялась функция CONTAINS, мил-
лионы ее вызовов способны обрушить производительность любой меры;
в мере не используется ранее созданная мера Sales Amount, рассчитываю-
щая сумму продаж. В данном простом примере это не столь важно, но
если бы в мере Sales Amount была заложена более серьезная математика,
такой подход был бы неприемлем из-за лишнего дублирования пропи-
санной ранее логики.
ГЛАВА 15 Углубленное изучение связей 525
Calendar Year
Sales Amount Advertised Brand Sales
CY 2007 30,591,343.98 2,670,647.22
Audio 384,518.16 22,607.34
Cameras and camcorders 7,192,581.95 1,031,119.78
Cell phones 1,604,610.26 133,897.59
Computers 6,741,548.73 499,697.11
Games and Toys 360,652.81 22,971.36
Home Appliances 9,600,457.04 561,845.41
Music, Movies and Audio Books 314,206.74 10,591.25
TV and Video 4,392,768.29 387,917.40
CY 2008 30,591,343.98 2,861,643.84
Audio 384,518.16 29,084.79
Cameras and camcorders 7,192,581.95 349,467.40
Рис. 15.5 Мера Advertised Brand Sales показывает сумму продаж по брендам,
входившим на момент продажи в рекламную кампанию
Наиболее оптимальным способом решения этой задачи будет использо-
вание функции CALCULATE для распространения фильтров с таблицы Adver-
tised Brands на таблицы Product (используя в качестве фильтра бренд) и Date
(используя год и месяц). Это можно сделать разными способами, которые мы
и опишем в следующих разделах.
Распространение фильтра с использованием функции
TREATAS
Первым и лучшим вариантом решения этого сценария будет использование
функции TREATAS для распространения фильтра с Advertised Brands на осталь-
ные таблицы. Как мы уже говорили в предыдущих главах, функция TREATAS
предназначена для изменения привязки данных в таблице таким образом,
чтобы содержащиеся в ней столбцы могли быть применены в качестве фильт-
ров к другим столбцам в модели данных.
Таблица Advertised Brands не содержит ни единой связи с другими таблица-
ми в модели данных. Таким образом, в обычных условиях содержащиеся в ней
столбцы не могут быть использованы в качестве фильтра. Но функция TREATAS
дает нам возможность изменить привязку данных в таблице Advertised Brands
так, что ее содержимое можно будет использовать в аргументах фильтра функ-
ции CALCULATE и распространять их действие на всю модель данных. В следу-
ющей вариации меры мы используем этот прием:
Advertised Brand Sales TreatAs :=
VAR AdvertisedBrands =
SUMMARIZE (
'Advertised Brands';
'Advertised Brands'[Brand];
'Advertised Brands'[Calendar Year];
'Advertised Brands'[Month]
)
526 ГЛАВА 15 Углубленное изучение связей
VAR FilterAdvertisedBrands =
TREATAS (
AdvertisedBrands;
'Product'[Brand];
'Date'[Calendar Year];
'Date'[Month]
)
VAR Result =
CALCULATE ( [Sales Amount]; KEEPFILTERS ( FilterAdvertisedBrands ) )
RETURN
Result
Функция SUMMARIZE извлекает бренды, годы и месяцы из таблицы с рек-
ламными кампаниями. Затем функция TREATAS принимает получившуюся
таблицу и изменяет в ней привязку данных таким образом, чтобы столбцы ас-
социировались с брендами, годами и месяцами в модели данных. В результи-
рующей таблице FilterAdvertisedBrands все данные четко привязаны к модели,
так что мы можем использовать ее в качестве фильтра, чтобы видимыми оста-
лись бренды, годы и месяцы из рекламных кампаний.
Стоит отдельно отметить, что нам пришлось использовать в функции CAL-
CULATE модификатор KEEPFILTERS. Если этого не сделать, функция CALCU-
LATE переопределит фильтры по бренду, году и месяцу, а нам это не нужно.
В таблице Sales должны сохраняться фильтры, пришедшие из элемента визуа-
лизации (например, мы можем формировать отчет по одному бренду и году),
и добавляться фильтры из таблицы Advertised Brands. Таким образом, нам по-
надобилось использовать модификатор KEEPFILTERS для получения коррект-
ного результата.
Этот вариант намного лучше предыдущего, с итерациями по таблице про-
даж. Во-первых, здесь мы повторно используем меру Sales Amount, что позво-
лило нам избежать повторного написания кода. Во-вторых, не осуществляем
итерации по объемной таблице Sales для поиска, а сканируем лишь небольшую
по размерам таблицу Advertised Brands, после чего применяем полученный
фильтр к модели данных и вычисляем необходимую нам меру Sales Amount.
Несмотря на то что эта мера может быть менее понятной интуитивно, в плане
эффективности она будет значительно превосходить меру с использованием
функции CONTAINS.
Распространение фильтра с использованием функции
INTERSECT
Еще одним способом добиться того же результата является использование
функции INTERSECT. Логика здесь будет примерно такой же, как и в примере
с TREATAS, но в плане производительности этот метод будет немного уступать
первому. В следующем коде мы реализовали концепцию распространения
фильтров с использованием функции INTERSECT:
Advertised Brand Sales Intersect :=
VAR SelectedBrands =
ГЛАВА 15 Углубленное изучение связей 527
SUMMARIZE (
Sales;
'Product'[Brand];
'Date'[Calendar Year];
'Date'[Month]
)
VAR AdvertisedBrands =
SUMMARIZE (
'Advertised Brands';
'Advertised Brands'[Brand];
'Advertised Brands'[Calendar Year];
'Advertised Brands'[Month]
)
VAR Result =
CALCULATE (
[Sales Amount];
INTERSECT (
SelectedBrands;
AdvertisedBrands
)
)
RETURN
Result
Функция INTERSECT сохраняет привязку данных из таблицы, переданной
первым параметром. Так что результирующая таблица сохранит способность
фильтровать таблицы Product и Date. На этот раз использование модификатора
KEEPFILTERS нам не понадобилось, поскольку на выходе первой функции SUM-
MARIZE и так будут только видимые бренды и месяцы; функция INTERSECT
лишь удаляет из этого списка сочетания, которые не включались в рекламную
кампанию.
При выполнении этого кода нам понадобилось просканировать таблицу
Sales, чтобы извлечь комбинации брендов и месяцев, а позже произвести еще
одно сканирование для вычисления суммы продаж. В этом данная мера усту-
пает своему аналогу с применением функции TREATAS. Но изучить эту техни-
ку просто необходимо, поскольку она может пригодиться при использовании
других функций для работы со множествами, таких как UNION и EXCEPT. Функ-
ции этой группы могут объединяться для создания более сложных фильтров
и написания комплексных мер без особых усилий.
Распространение фильтра с использованием функции
FILTER
Третьей альтернативой для разработчиков DAX в вопросе распространения
фильтров по таблицам является совместное использование функций FILTER
и CONTAINS. В данном случае код будет похож на первую версию с SUMX, за
тем лишь исключением, что в нем будет использоваться функция CALCULATE
вместо SUMX, и никаких итераций по таблице Sales мы проводить не будем.
Следующий код реализует этот вариант:
528 ГЛАВА 15 Углубленное изучение связей
Advertised Brand Sales Contains :=
VAR SelectedBrands =
SUMMARIZE (
Sales;
'Product'[Brand];
'Date'[Calendar Year];
'Date'[Month]
)
VAR FilterAdvertisedBrands =
FILTER (
SelectedBrands;
CONTAINS (
'Advertised Brands';
'Advertised Brands'[Brand]; 'Product'[Brand];
'Advertised Brands'[Calendar Year]; 'Date'[Calendar Year];
'Advertised Brands'[Month]; 'Date'[Month]
)
)
VAR Result =
CALCULATE (
[Sales Amount];
FilterAdvertisedBrands
)
RETURN
Result
В функции FILTER, присутствующей здесь в качестве аргумента фильтра CAL-
CULATE, используется та же техника с применением функции CONTAINS, что
и в первом примере. Но на этот раз итерации выполняются не по всей табли-
це Sales, а по результирующему набору, полученному из функции SUMMARIZE.
Как мы объясняли в главе 14, использовать таблицу Sales как аргумент фильтра
функции CALCULATE было бы неправильно из-за расширенных таблиц. Лучше
подвергать фильтру только три столбца. На выходе функции SUMMARIZE дан-
ные обладают корректной привязкой к столбцам в модели. Кроме того, нам нет
необходимости использовать модификатор KEEPFILTERS, поскольку на выходе
функции SUMMARIZE и так будут только выбранные значения для бренда, года
и месяца.
С точки зрения производительности этот вариант худший из трех, хотя он
и быстрее изначальной меры с функцией SUMX. Также стоит отметить, что
у всех техник с использованием функции CALCULATE есть одно общее пре-
имущество, состоящее в отсутствии необходимости дублировать бизнес-логи-
ку вычисления в мере Sales Amount, чем не могла похвастаться наша первая
попытка с итератором SUMX.
Динамическая сегментация с использованием
виртуальных связей
Во всех предыдущих примерах мы использовали код на DAX для расчета зна-
чений и распространения фильтров в отсутствие связей, хотя вполне могли ре-
шить задачу путем создания физической связи. Но бывают случаи, когда нала-
ГЛАВА 15 Углубленное изучение связей 529
дить физическую связь просто не представляется возможным, как в примере,
который мы рассмотрим в данном разделе.
Задачу, связанную со статической сегментацией данных, которую мы рас-
смотрели ранее в данной главе, мы решили при помощи создания виртуальной
связи. В случае со статической сегментацией распределение продаж по катего-
риям выполняется посредством вычисляемого столбца. Если говорить о дина-
мической сегментации (dynamic segmentation), то здесь классификация данных
выполняется «на лету» - она основывается не на вычисляемом столбце, как
в примере с ценовыми группами, а на динамическом вычислении вроде сум-
мы продаж. Для выполнения динамической сегментации у нас должны быть
определенные критерии для фильтра. В нашем примере мы будем выполнять
фильтрацию покупателей на основании значения меры Sales Amount.
В данном примере конфигурационная таблица будет содержать наименова-
ния сегментов и их границы, как показано на рис. 15.6.
Segment MinSale MaxSale
Very Low 0 75
Low 75 100
Medium 100 500
High 500 1,000
Very High 1,000 99,999,999
Рис. 15.6 Конфигурационная таблица
для выполнения динамической сегментации
Если клиент совершил покупки на сумму от 75 до 100 долларов, мы отнесем
его к сегменту Low (Низкий), как видно из представленной конфигурационной
таблицы. Важный нюанс заключается в том, что значение меры, которое мы
сверяем с нашей таблицей, напрямую зависит от пользовательского выбора
в отчете. Например, если пользователь выберет в срезе один цвет, то динами-
ческая сегментация покупателей будет выполнена исключительно по товарам
выбранного цвета. А поскольку у нас выполняются динамические расчеты, фи-
зическую связь между таблицами мы построить просто не можем. Взгляните
на отчет, показанный на рис. 15.7, на котором продемонстрировано распреде-
ление покупателей по сегментам с разбивкой по годам, причем учет ведется
исключительно по выбранным категориям товаров.
Category Ц Audio Segment CY 2007 CY 2008 CY 2009 Total
Cameras and camcorders Very Low 210 1 3 213
| Cell phones Low 258 33 3 289
Computers Games and Toys Medium 427 133 88 641
Home Appliances High 28 13 29 63
Music, Movies and Audio Books Very High 49 78 119 231
TV and Video Total 972 258 242 1,437
Рис. 15.7 Каждый покупатель входит в свой сегмент,
при этом в разные годы сегмент у него может быть разным
Принадлежность покупателя к тому или иному сегменту может меняться
из года в год. Например, в 2008 году он может попасть в категорию Very Low
530 ГЛАВА 15 Углубленное изучение связей
(Очень низкий), а годом позже подняться до уровня Medium (Средний). Более
того, при изменении выбора в фильтре по категориям товаров будут меняться
и данные в отчете.
Фактически у пользователя должно сложиться ощущение, что связь в моде-
ли данных на самом деле существует и каждый покупатель принадлежит свое-
му сегменту. При этом физическую связь мы создать здесь никак не можем.
И причина как раз в том, что один покупатель может быть отнесен к разным
сегментам в разных ячейках отчета. В этом случае поставленную задачу можно
решить только при помощи DAX.
Нам необходимо написать меру, подсчитывающую количество покупателей,
принадлежащих тому или иному сегменту. Иными словами, мера вычисляет,
сколько покупателей входит в каждый сегмент, основываясь на данных из те-
кущего контекста фильтра. Формула выглядит довольно просто, но при этом
нуждается в определенных пояснениях:
CustlnSegment :=
SUMX (
Segments;
COUNTROWS (
FILTER (
Customer;
VAR SalesOfCustomer = [Sales Amount]
VAR IsCustomerlnSegment =
AND (
SalesOfCustomer > Segments[MinSale];
SalesOfCustomer <= Segments[MaxSale]
)
RETURN
IsCustomerlnSegment
)
)
)
За исключением итогов, все строки отчета, показанного на рис. 15.7, выпол-
няются в контексте фильтра, включающем один конкретный сегмент. Таким
образом, функция SUMX будет проходить только по одной строке. Использова-
ние этой функции облегчает извлечение границ сегмента из конфигурацион-
ной таблицы (MinSale и MaxSale) и позволяет правильно рассчитать значения
в присутствии фильтров. Внутри итератора SUMX функция COUNTROWS счита-
ет клиентов с суммой покупок (для повышения производительности, сохранен-
ной в переменной SalesOfCustomer), входящей в интервал текущего сегмента.
Полученная мера будет аддитивной по сегментам и покупателям и неад-
дитивной по другим фильтрам. Вы могли заметить, что в итоговой колонке
по первой строке отчета стоит значение 213, хотя сумма значений по строке
дает 214. Причина в том, что в итоговой ячейке мера подсчитывает количест-
во покупателей из этого сегмента по трем годам. Похоже, один из учтенных
клиентов совершил за три года столько покупок, что в итоге был переведен
в следующую категорию.
И хотя такое поведение меры может быть не слишком понятно интуитивно,
на самом деле неаддитивность вычислений по времени может оказаться очень
ГЛАВА 15 Углубленное изучение связей 531
полезной. Чтобы сделать меру аддитивной по годам, необходимо изменить
формулу, добавив к расчетам временную характеристику. Например, следую-
щая мера будет аддитивной по оси времени. Но при этом она потеряет в гиб-
кости, поскольку теперь ее не получится использовать без включения в отчет
измерения с годами:
CustlnSegment Additive :=
SUMX (
VALUES ( 'Date'[Calendar Year] );
SUMX (
Segments;
COUNTROWS (
FILTER (
Customer;
VAR SalesOfCustomer = [Sales Amount]
VAR IsCustomerInSegment =
AND (
SalesOfCustomer > Segments[MinSale];
SalesOfCustomer <= Segments[MaxSale]
)
RETURN
IsCustomerInSegment
)
)
)
)
Как видно по рис. 15.8, значения по строкам теперь корректно суммируются,
хотя общий итог (сумма по всем годам и сегментам) по-прежнему может по-
казывать неправильные цифры.
Category Segment CY 2007 CY 2008 CY 2009 Total
Ц Audio
Cameras and camcorders Very Low 210 1 3 214
I Cell phones Low 258 33 3 294
Computers Medium 427 133 88 648
Games and Toys
Home Appliances High 28 13 29 70
Music, Movies and Audio Books Very High 49 78 119 246
TV and Video Total 972 258 242 1,472
Рис. 15.8 Теперь суммы по строкам вычисляются правильно,
но с общим итогом могут быть проблемы
Отдав предпочтение правильному подсчету итогов по сегментам, мы вы-
нуждены были принести в жертву общий итог по сегментам и годам. Напри-
мер, конкретный покупатель мог принадлежать к сегменту Very Low (Очень
низкий) в 2009 году и к сегменту Very High (Очень высокий) в 2008-м. Таким об-
разом, в общем итоге этот клиент будет учтен дважды. Ячейка с общим итогом
на рис. 15.8 содержит значение 1472, тогда как общее количество покупателей
составило 1437, как видно на предыдущем рис. 15.7.
532 ГЛАВА 15 Углубленное изучение связей
К сожалению, в подобных расчетах аддитивность мер часто является настоя-
щей проблемой. По своей природе такие меры являются неаддитивными. Жела-
ние сделать их аддитивными на первый взгляд кажется вполне естественным, но
чаще всего это будет приводить к путанице в расчетах. Всегда важно обращать
внимание на такие нюансы, и обычно мы советуем не пытаться всеми силами
делать меру аддитивной без должного понимания возможных последствий.
Реализация физических связей в DAX
Связь может быть сильной (strong) или слабой (weak). При использовании силь-
ной связи движок DAX точно знает, что на стороне «один» этой связи гаран-
тированно будут уникальные значения. Если движку не удается убедиться
в уникальности ключей, он классифицирует связь как слабую. При этом слабая
связь может образоваться либо по причине того, что движку не удалось удосто-
вериться в уникальности значений, либо из-за технических сложностей, о ко-
торых мы расскажем далее в этом разделе, или же в результате намеренных
действий разработчика. Слабые связи не входят в состав расширенных таблиц,
о которых мы говорили в главе 14.
Начиная с 2018 года Power BI допускает создание составных моделей данных
(composite model). В таких моделях разрешено сочетать данные в режиме Ver-
tiPaq (копия данных из источника предварительно загружается и кешируется
в памяти) и в режиме DirectQuery (обращение к источнику данных происходит
только в момент запроса). Подробно режимы DirectQuery и VertiPaq будут опи-
саны в главе 17.
Единая модель данных может содержать какое-то количество таблиц, со-
храненных в режиме VertiPaq, и какое-то - в режиме DirectQuery. Более того,
таблицы DirectQuery могут происходить из разных источников, образуя так на-
зываемые острова данных (data island) DirectQuery.
Чтобы лучше различать данные, хранящиеся в режимах VertiPaq и DirectQue-
ry, мы будем говорить о них как о континенте (continent) (VertiPaq) и островах
(источники данных DirectQuery), как показано на рис. 15.9.
Рис. 15.9 В составной модели данных таблицы распределены по островам
ГЛАВА 15 Углубленное изучение связей 533
Хранилище VertiPaq представляет собой не что иное, как еще один остров
данных. Мы называем его континентом только потому, что этот остров наи-
более востребован при работе.
Связь объединяет две таблицы. Если таблицы принадлежат одному острову,
связь между ними будет именоваться внутриостровной, иначе - межостров-
ной. Последние всегда представляют собой слабые связи. Таким образом, рас-
ширенные таблицы никогда не пересекают острова.
Связи между таблицами характеризуются кратностью (cardinality), которая
бывает трех типов. И разница между ними есть как в техническом плане, так
и в области семантики. Здесь мы не будем излишне глубоко вдаваться во все
нюансы этих разновидностей, поскольку для этого потребовалось бы сделать
ряд отступлений, что не входит в наши планы. Вместо этого мы остановимся
на технических подробностях физических связей и посмотрим, какое влияние
они оказывают на код DAX.
Всего существует три типа кратности связи:
кратность связи «один ко многим»: это наиболее распространенный
тип кратности связи. На стороне «один» в связи располагается столбец
с уникальными значениями, тогда как на стороне «многие» могут быть
(и часто бывают) дубликаты. В некоторых клиентских инструментах де-
лаются различия между связями «один ко многим» и «многие к одному».
Но по своей сути это одно и то же. Все зависит от порядка расположения
таблиц: связь «один ко многим» между таблицами Product и Sales можно
представить и как связь «многие к одному» между Sales и Product}
кратность связи «один к одному»: этот тип кратности связи встречает-
ся довольно редко. Здесь на обеих сторонах связи должны быть столбцы
с уникальными значениями. Более точным названием такого типа крат-
ности связи было бы «ноль или один к нулю или одному», поскольку при-
сутствие записи в одной таблице не обязательно должно означать при-
сутствие соответствующей строки в другой;
кратность связи «многие ко многим»: в этом случае на обеих сторонах
связи столбцы могут содержать дублирующиеся значения. Эта кратность
была представлена в 2018 году, и, к сожалению, для нее было выбрано не
самое удачное название. В сущности, в теории моделирования данных
термин «многие ко многим» относится к другой реализации, использу-
ющей сочетание связей «один ко многим» и «многие к одному». Важно
понимать, что в этом случае мы говорим не о связи «многие ко многим»,
а именно о кратности «многие ко многим».
Чтобы избежать неоднозначности трактовки и не путаться с канонической
терминологией моделирования данных, в которой связь «многие ко многим»
означает совсем другую реализацию, мы будем использовать следующие абб-
ревиатуры для описания кратности связи:
«один ко многим»: мы будем именовать такую кратность SMR, от Single-
Many-Relationship (один-многие-связь);
«один к одному»: здесь мы остановимся на аббревиатуре SSR, от Single-
Single-Relationship (один-один-связь);
«многие ко многим»: такой тип кратности мы будем называть MMR, от
Many-Many-Relationship (многие-многие-связь).
534 ГЛАВА 15 Углубленное изучение связей
Еще одной важной деталью является то, что связь «многие ко многим» всегда
будет слабой - вне зависимости от того, одному или разным островам принад-
лежат таблицы. Если разработчик обе стороны связи обозначит как «многие»,
связь автоматически будет трактоваться как слабая, без возможности расши-
рения таблиц.
Вдобавок каждая связь характеризуется направлением кросс-фильтрации.
Это направление используется для распространения контекста фильтра. Кросс-
фильтрация может принимать два значения:
однонаправленная (Single): контекст фильтра всегда распространяется
в одном направлении. В связи «один ко многим» это направление будет
от стороны «один» к стороне «многие». Это стандартное и наиболее же-
лаемое поведение;
двунаправленная (Both): контекст фильтра распространяется в обоих
направлениях. Такой тип распространения фильтра называется также
двунаправленной кросс-фильтрацией (bidirectional cross-filter), а иногда -
двунаправленной связью. В связи «один ко многим» контекст фильтра
продолжит распространяться от стороны «один» к стороне «многие», но
также получит и новое направление - от стороны «многие» к стороне
«один».
Доступные направления кросс-фильтрации зависят от типа связи:
в SMR всегда доступен выбор между однонаправленной и двунаправлен-
ной кросс-фильтрацией;
в SSR всегда используется двунаправленная кросс-фильтрация. Посколь-
ку обе стороны связи характеризуются как «один», а сторон «многие»
просто нет, единственным вариантом является двунаправленная кросс-
фильтрация;
в MMR обе стороны связи помечены как «многие». Это сценарий, про-
тивоположный SSR: обе стороны связи могут быть как источником, так
и целью распространения контекста фильтра. В этом случае разработчик
может остановить выбор на двунаправленной кросс-фильтрации, чтобы
контекст фильтра распространялся в обе стороны. Либо он может выбрать
однонаправленную кросс-фильтрацию, при этом указав, от какой табли-
цы к какой будет осуществляться распространение контекста фильтра.
Как и в случае с другими связями, однонаправленная кросс-фильтрация
будет лучшим выбором. Позже в данной главе мы поговорим об этом бо-
лее подробно.
В табл. 15.1 мы подытожили информацию о разных типах связей, доступных
направлениях кросс-фильтрации, их влиянии на распространение контекста
фильтра и вариантах создания слабой/сильной связи.
Когда две таблицы объединены сильной связью, в таблице, расположенной
на стороне «один», может содержаться дополнительная пустая строка, в случае
если связь оказалась недействительной. Таким образом, если в таблице на сто-
роне «многие» содержатся значения, не присутствующие в таблице на стороне
«один», к последней будет добавлена пустая строка. Эту особенность мы объ-
ясняли в главе 3. Дополнительная пустая строка никогда не появляется, если
связь между таблицами слабая.
ГЛАВА 15 Углубленное изучение связей 535
ТАБЛИЦА 15.1 Различные типы связей
Тип связи Направление кросс- фильтрации Распространение контекста фильтра Слабая/сильная связь
SMR Однонаправленная От стороны «один» к стороне «многие» Слабая, если межостровная, иначе сильная
SMR Двунаправленная В обе стороны Слабая, если межостровная, иначе сильная
SSR Двунаправленная В обе стороны Слабая, если межостровная, иначе сильная
MMR Однонаправленная Нужно выбрать источник Всегда слабая
MMR Двунаправленная В обе стороны Всегда слабая
Ранее мы уже говорили, что не будем касаться темы выбора разработчиком
того или иного типа связи. Этот выбор должен сделать специалист по моде-
лированию данных, основываясь при этом на доскональном понимании се-
мантики конкретной модели. С точки зрения DAX каждая связь ведет себя по-
разному, и важно понимать отличия между типами связей и их влияние на код
DAX.
В следующих разделах мы подробно остановимся на этих различиях и да-
дим несколько советов по выбору типов связей в модели.
Использование двунаправленной
кросс-фильтрации
Двунаправленная кросс-фильтрация (bidirectional crossfilter) может быть реа-
лизована двумя способами: непосредственно в модели данных или в коде
DAX с использованием модификатора CROSSFILTER в функции CALCULATE,
о чем мы рассказывали в главе 5. Как правило, двунаправленную кросс-
фильтрацию не стоит включать в модели данных без особой необходимости.
Причина в том, что наличие таких фильтраций в модели значительно услож-
няет процесс распространения контекста фильтра вплоть до полной его не-
предсказуемости.
В то же время существуют сценарии, в которых двунаправленная кросс-
фильтрация может оказаться чрезвычайно полезной. Взгляните на отчет,
изображенный на рис. 15.10, он построен на базе модели данных Contoso со
связями, установленными в режим однонаправленной кросс-фильтрации.
Слева в отчете мы видим два среза: по брендам, который распространяется на
столбец Product[Brand], и по странам с фильтрацией столбца Customer[Country-
Region]. Несмотря на то что в Армении (Armenia) не продавались товары бренда
Northwind Traders, в срезе CountryRegion есть упоминание этой страны.
Причина в том, что контекст фильтра на столбце Product[Brand] оказывает
влияние на таблицу Sales из-за установленной связи типа «один ко многим»
между таблицами Product и Sales. Но от таблицы Sales фильтр не распространя-
ется на таблицу Customer, поскольку она находится на стороне «один» в связи
типа «один ко многим» между таблицами Customer и Sales. Именно поэтому
536 ГЛАВА 15 Углубленное изучение связей
в срезе CountryRegion остаются доступными для выбора все страны вне зави-
симости от того, были ли в них продажи товаров выбранного бренда. Иными
словами, два представленных элемента визуализации со срезами не синхро-
низированы друг с другом. При этом в матрице строка с Арменией не выво-
дится, поскольку значение меры Sales Amount по ней дает пустоту, а по умолча-
нию в матрицах не показываются строки с пустыми значениями в выведенных
в отчет мерах.
Brand CountryRegion CountryRegion Sales Amount
A Datum Armenia
Adventure Works Australia Australia 375,091.54
Contoso Bhutan Bhutan 13,598.64
Fabrikam Canada
Litware China Canada 52,201.68
| Northwind Traders France China 1,135.40
Prose ware Germany France 29,783.07
Southridge Video Greece
Ta il spin Toys India Germany 94,998.72
The Phone Company Iran Greece 5,551.82
Wide World Importers Ireland India 721.05
Italy
Iran 9,519.41
Рис. 15.10 В срезе CountryRegion содержатся страны с нулевыми продажами
Если для вас важно, чтобы срезы были синхронизированы между собой, мож-
но включить двунаправленную кросс-фильтрацию между таблицами Customer
и Sales, что приведет к образованию модели данных, показанной на рис. 15.11.
Рис. 15.11 Кросс-фильтрация для связи между таблицами Customer и Soles
стала двунаправленной
Установка двунаправленной кросс-фильтрации приведет к тому, что в сре-
зе по CountryRegion останутся только значения, для которых есть соответствия
в таблице Sales. По рис. 15.12 видно, что срезы стали синхронизированными,
что может понравиться пользователю.
Двунаправленная кросс-фильтрация может оказаться полезной для отче-
тов, но за все приходится платить. Двунаправленные связи могут негативно
ГЛАВА 15 Углубленное изучение связей 537
сказаться на производительности модели данных в целом, поскольку контекст
фильтра при их использовании должен распространяться по связям в обе сто-
роны. К тому же фильтрация таблиц от стороны «один» к стороне «многие»
происходит гораздо быстрее, чем в обратном направлении. Так что если дер-
жать в уме эффективность модели данных, от использования двунаправленных
связей стоит отказаться. Более того, такие связи способны создавать предпо-
сылки для образования неоднозначностей в модели. Этой темы мы коснемся
далее в данной главе.
Brand CountryRegion CountryRegion Sales Amount
A. Datum Australia
Adventure Works Bhutan Australia 375,091.54
Contoso Canada Bhutan 13,598.64
Fabrikam China
Litware France Canada 52,201.68
Ц Northwind Traders Germany China 1,135.40
Proseware Greece France 29,783.07
Southridge Video India
Tailspin Toys Iran Germany 94,998.72
The Phone Company Japan Greece 5,551.82
Wide World Importers Kyrgyzstan India 721.05
Singapore
Iran 9,519.41
Рис. 15.12 Включение режима двунаправленной кросс-фильтрации
позволило синхронизировать срезы в отчете
Примечание Используя фильтры уровня визуализации, можно сократить количество
видимых элементов в визуальном элементе Power Bl без применения двунаправленной
фильтрации в связи. К сожалению, на апрель 2019 года в Power Bl еще не поддерживают-
ся фильтры уровня визуализации. Когда они станут доступны для срезов, использование
двунаправленной кросс-фильтрации для ограничения количества видимых элементов
в фильтре останется в прошлом.
Связи типа «один ко многим»
Связи типа «один ко многим» (one-to-many relationships) являются наиболее
распространенными в моделировании данных. Например, именно такой тип
связи используется для объединения таблиц Product и Sales. Такая связь гово-
рит о том, что для одного товара может присутствовать множество записей
в таблице продаж, тогда как одна строка в таблице продаж соответствует толь-
ко одному товару. Таким образом, таблица Product в этой связи будет распола-
гаться на стороне «один», a Sales - на стороне «многие».
Более того, при анализе данных у пользователя должна быть возможность
осуществлять фильтрацию по атрибутам товаров и вычислять соответству-
ющие значения в таблице Sales. По умолчанию контекст фильтра будет рас-
пространяться от таблицы Product (сторона «один») к таблице Sales (сторона
538 ГЛАВА 15 Углубленное изучение связей
«многие»). При необходимости можно изменить поведение распространения
контекста, установив двунаправленный тип кросс-фильтрации для связи меж-
ду этими таблицами.
При наличии сильной связи типа «один ко многим» расширение таблицы
всегда выполняется в направлении, обратном распространению фильтра, -
от стороны «многие» к стороне «один». В случае если связь недействительна,
в таблице на стороне «один» может появиться дополнительная пустая строка.
С точки зрения семантики слабая связь типа «один ко многим» ведет себя так
же, как сильная, за исключением того, что пустая строка не появляется. Кро-
ме того, запросы, построенные с использованием слабых связей типа «один
ко многим», в большинстве случаев будут отличаться худшей производитель-
ностью.
Связи типа «один к одному»
Связи типа «один к одному» (one-to-one relationships) используются при моде-
лировании данных крайне редко. По сути, две таблицы, объединенные таким
типом связи, представляют собой единую таблицу, разделенную надвое. При
правильном проектировании такие таблицы должны быть объединены перед
загрузкой в модель данных.
Таким образом, самой правильной моделью обращения с таблицами, объ-
единенными связью «один к одному», будет их слияние. Исключением из пра-
вила является сценарий, в котором данные поступают в одну сущность из раз-
ных источников, которые должны обновляться отдельно друг от друга. В этом
случае лучше предпочесть загрузку таблиц в модель данных отдельно во из-
бежание сложных и дорогостоящих преобразований на этапе обновления. Так
или иначе, при работе со связями типа «один к одному» пользователю необхо-
димо уделять особое внимание следующим аспектам:
кросс-фильтрация для связи типа «один к одному» всегда будет двуна-
правленной. Для такой связи просто нет возможности установить одно-
направленную фильтрацию. Таким образом, фильтр, примененный к од-
ной таблице, всегда будет распространяться на вторую, и наоборот, если
связь не деактивирована при помощи функции CROSSFILTER или физи-
чески в модели данных;
как уже было сказано в главе 14, если две таблицы объединены сильной
связью типа «один к одному», расширенная версия каждой из них будет
полностью включать в себя другую таблицу. Иначе говоря, наличие силь-
ной связи «один к одному» ведет к созданию двух одинаковых расширен-
ных таблиц;
поскольку обе таблицы в связи представляют собой сторону «один», если
связь между ними будет одновременно сильной и недействительной (то
есть если в ключевом столбце одной из них будут присутствовать зна-
чения, которых не будет во второй), в обеих таблицах могут появиться
пустые строки. Более того, значения в столбцах обеих таблиц, которые
используются для создания связи, должны быть уникальными.
ГЛАВА 15 Углубленное изучение связей 539
Связи типа «многие ко многим»
Связи типа «многие ко многим» (many-to-many relationships) представляют со-
бой очень мощный инструмент моделирования данных и встречаются гораздо
чаще, чем связи типа «один к одному». Работать с такими связями не так-то
просто, но научиться обращаться с ними стоит по причине их огромного ана-
литического потенциала.
Связь типа «многие ко многим» образуется в модели данных всякий раз,
когда две сущности не удается объединить посредством обычной связи «один
ко многим». Существует два типа таких связей и несколько способов решения
этих двух сценариев. В следующих разделах мы представим разные техники
для работы со связями типа «многие ко многим».
Реализация связи «многие ко многим» через таблицу-мост
Следующий пример мы позаимствовали из банковской сферы. Банк хранит
расчетные счета в одной таблице, а клиентов - в другой. При этом один счет
может принадлежать разным клиентам, а у одного клиента может быть не-
сколько расчетных счетов. Таким образом, мы не можем хранить информацию
о клиенте непосредственно в таблице расчетных счетов, так же как не можем
учитывать расчетные счета прямо в таблице клиентов. Следовательно, этот
сценарий не может быть воспроизведен при помощи обычных связей между
счетами и клиентами.
Традиционным решением подобной задачи является создание дополни-
тельной таблицы, в которой будут храниться соответствия между различны-
ми клиентами и их расчетными счетами. Такая таблица обычно именуется
таблицей-мостом (bridge table), а ее пример показан в модели данных на
рис. 15.13.
Рис. 15.13 Таблица AccountsCustomers связана одновременно и с таблицей Accounts,
и с Customers
540 ГЛАВА 15 Углубленное изучение связей
В данном случае связь типа «многие ко многим» между таблицами Accounts
и Customers реализована посредством создания таблицы-моста с именем Ас-
countsCustomers. Каждая строка в этой таблице соответствует одной связке кли-
ента с расчетным счетом.
Сейчас созданная нами модель данных пока не работает. Отчет со срезом по
таблице Account формируется правильно, поскольку таблица Accounts фильт-
рует таблицу Transactions, находясь в этой связи на стороне «один». В то же
время если в срез поместить таблицу Customers, отчет сломается, ведь фильтр
из таблицы Customers распространяется на таблицу-мост AccountsCustomers,
а таблицы Accounts уже не достигает по причине однонаправленности кросс-
фильтрации между этими таблицами. При этом в связи между таблицами Ac-
counts и AccountsCustomers на стороне «один» должна располагаться первая из
них, поскольку в столбце AccountKey соблюдается условие уникальности дан-
ных, а в таблице AccountsCustomers могут присутствовать дубликаты.
На рис. 15.14 видно, что значения в поле CustomerName не применяют ника-
ких фильтров к мере Amount, отображенной в матрице.
Account Luke Mark Paul Robert Total
Luke 800.00 800.00 800.00 800.00 800.00
Mark 800.00 800.00 800.00 800.00 800.00
Mark-Paul 1,000.00 1,000.00 1,000.00 1,000.00 1,000.00
Mark-Robert 1,000.00 1,000.00 1,000.00 1,000.00 1,000.00
Paul 700.00 700.00 700.00 700.00 700.00
Robert 700.00 700.00 700.00 700.00 700.00
Total 5,000.00 5,000.00 5,000.00 5,000.00 5,000.00
Рис. 15.14 Таблица Accounts, вынесенная на строки,
фильтрует значения, тогда как таблица Customers на столбцах - нет
Этот сценарий можно решить, установив двунаправленную кросс-фильт-
рацию между таблицами AccountsCustomers и Accounts либо непосредственно
в модели данных, либо используя функцию CROSSFILTER, как показано ниже:
- - Версия с использованием функции CROSSFILTER
SumOfAmt CF :=
CALCULATE (
SUM ( Transactions[Amount] );
CROSSFILTER (
AccountsCustomers[AccountKey];
Accounts[AccountKey];
BOTH
)
)
Теперь наш отчет показывает более осмысленную информацию, что видно
по рис. 15.15.
Установка двунаправленной кросс-фильтрации непосредственно в модели
данных может быть полезна тем, что обеспечивает автоматическое примене-
ние фильтров ко всем вычислениям, включая неявные меры, создаваемые кли-
ГЛАВА 15 Углубленное изучение связей 541
ентскими инструментами, такими как Excel или Power BL Однако присутствие
подобных связей в модели данных существенно усложняет процесс распро-
странения фильтров и может негативно сказаться на производительности вы-
числений в мерах, которые не должны затрагиваться этими фильтрами. Более
того, если позже в модель данных будут добавлены новые таблицы, наличие
в ней двунаправленной кросс-фильтрации может создать неоднозначность,
которую можно будет устранить только путем изменения фильтрации. А это,
в свою очередь, может нарушить работу уже существующих отчетов. Так что
перед тем как включить режим двунаправленной кросс-фильтрации для той
или иной связи между таблицами, дважды подумайте о возможных послед-
ствиях таких действий.
Account Luke Mark Paul Robert Total
Luke 800.00 800.00
Mark 800.00 800.00
Mark-Paul 1,000.00 1,000.00 1,000.00
Mark-Robert 1,000.00 1,000.00 1,000.00
Paul 700.00 700.00
Robert 700.00 700.00
Total 800.00 2,800.00 1,700.00 1,700.00 5,000.00
Рис. 15.15 Включив двунаправленную кросс-фильтрацию,
мы добились от меры правильных результатов
Конечно, вы вольны допускать присутствие в своей модели данных связей
с двунаправленной кросс-фильтрацией. Но причин, перечисленных в этой
книге, а также нашего личного опыта, которым мы с вами делимся, должно
быть достаточно, чтобы отказаться от создания таких связей в модели. Мы
предпочитаем работать с простыми и надежными моделями данных и являем-
ся сторонниками использования в мерах функции CROSSFILTER всегда, когда
это необходимо. С точки зрения производительности варианты с включением
двунаправленной кросс-фильтрации в модели данных и применением функ-
ции CROSSFILTER в DAX практически идентичны.
Также нашу задачу можно решить и с помощью довольно сложного кода на
DAX. Несмотря на всю свою сложность, эта формула даст нам определенную
гибкость. Одним из вариантов написания меры SumOfAmt без использования
функции CROSSFILTER является применение результата функции SUMMARIZE
в качестве аргумента фильтра в CALCULATE, как показано ниже:
- - Версия с использованием функции SUMMARIZE
SumOfAmt SU :=
CALCULATE (
SUM ( Transactions[Amount] );
SUMMARIZE (
AccountsCustomers;
Accounts[AccountKey]
)
)
542 ГЛАВА 15 Углубленное изучение связей
Функция SUMMARIZE возвращает столбец с привязкой данных к Accounts
[AccountKey], тем самым фильтруя таблицу Accounts, а следом и Transactions. Та-
кого же результата можно добиться и при помощи функции TREATAS:
- - Версия с использованием функции TREATAS
SumOfAmt ТА :=
CALCULATE (
SUM ( Transactions[Amount] );
TREATAS (
VALUES ( AccountsCustomers[AccountKey] );
Accounts[AccountKey]
)
)
В этом случае функция VALUES возвращает значения столбца Accounts Custo-
mers [AccountKey], отфильтрованные по таблице Customers, а функция TREATAS
меняет привязку данных таким образом, чтобы была отфильтрована таблица
Accounts, а следом за ней и Transactions.
Наконец, можно произвести похожее вычисление и при помощи более прос-
той формулы с применением расширенных таблиц. Здесь стоит заметить, что
расширение таблицы-моста выполняется и в сторону Customers, и в сторону
Accounts, что позволяет добиться почти такого же результата, как в предыду-
щих примерах. При этом данный код получился чуть короче:
- - Версия с использованием расширенных таблиц
SumOfAmt ЕТ :=
CALCULATE (
SUM ( Transactions[Amount] );
AccountsCustomers
)
Несмотря на множество вариаций, все эти решения могут быть сгруппиро-
ваны по единственному общему признаку:
с использованием двунаправленной кросс-фильтрации в DAX;
с подстановкой таблицы в качестве аргумента фильтра функции CALCU-
LATE.
Формулы из этих двух групп будут вести себя по-разному, в случае если связь
между таблицами Transactions и Accounts станет недействительной. Мы знаем,
что в такой ситуации в таблицу, находящуюся на стороне «один», добавляется
пустая строка. Если в таблице Transactions будут содержаться ссылки на рас-
четные счета, которых нет в таблице Accounts, связь между таблицами Transac-
tions и Accounts будет считаться недействительной, и в таблицу Accounts будет
добавлена пустая строка. Этот эффект не распространится на таблицу Custom-
ers. Таким образом, пустая строка не будет добавлена в таблицу Customers, она
будет присутствовать только в таблице Accounts.
Следовательно, осуществление среза таблицы Transactions по столбцу Account
покажет пустую строку, тогда как фильтрация Transactions по CustomerName не
обнаружит записей, связанных с пустой строкой. Такое поведение может при-
водить в замешательство, и для демонстрации этого примера мы добавили
строку в таблицу Transactions с несоответствующим значением в поле Account-
ГЛАВА 15 Углубленное изучение связей 543
Key и суммой 10 000,00. Разница в выводе показана на рис. 15.16, где в матрице
слева отображен срез по столбцу Account, а справа - по CustomerName. В ка-
честве меры использовался расчете применением функции CROSSFILTER.
Account SumOfAmt CF CustomerName SumOfAmt CF
10,000.00 Luke 800.00
Luke 800.00 Mark 2,800.00
Mark 800.00 Paul 1,700.00
Mark-Paul 1,000.00 Robert 1,700.00
Mark-Robert 1,000.00 Total 15,000.00
Paul 700.00
Robert 700.00
Total 15,000.00
Рис. 15.16 В столбце CustomerName пустая строка отсутствует,
и итоговое значение в правой матрице кажется неправильным
Когда матрица фильтруется по столбцу Account, в ней появляется пустая
строка с суммой счета, равной 10 000,00. В то же время срез по столбцу Custom-
erName пустую строку не выводит. Фильтр начинает свое действие со столбца
CustomerName в таблице Customers, но в таблице AccountsCustomers нет зна-
чений, которые могли бы включить в фильтр пустую строку из таблицы Ac-
counts. В результате значение, ассоциированное с пустой строкой, оказалось
включено только в общий итог, поскольку на этом уровне контекст фильтра не
включает в себя значение по столбцу CustomerName. Таким образом, на уров-
не итогов таблица Accounts не включается в перекрестную фильтрацию - все
ее строки, включая пустую, становятся активными, и вычисление меры дает
сумму 15 000,00.
Заметьте, что мы в качестве примера использовали пустую строку, но та-
кой же сценарий возник бы и в случае, если бы в таблице Accounts появилась
строка, не относящаяся ни к одному из клиентов. Эти значения в результате
будут включены только в общий итог по причине того, что фильтр по клиентам
удаляет счета, не связанные ни с одним клиентом. Это замечание очень важно,
поскольку результат, который вы видели на рис. 15.16, может быть и не связан
с наличием недействительной связи. Например, если бы транзакция на сумму
10 000,00 была произведена по служебному аккаунту (Service), присутствую-
щему в таблице Accounts, но не имеющему соответствий в таблице Customers,
название счета из столбца Account появилось бы в отчете, несмотря на отсут-
ствие соответствий с таблицей клиентов. Эта ситуация показана в отчете на
рис. 15.17.
Примечание Сценарий, изображенный на рис. 15.17, никоим образом не нарушает ссы-
лочную целостность реляционной базы данных, как это было в случае с отчетом, пока-
занным на рис. 15.16. В базе данных может быть реализована дополнительная логика для
отслеживания возникновения подобных ситуаций.
544 ГЛАВА 15 Углубленное изучение связей
Account SumOfAmt CF CustomerName SumOfAmt CF
Luke 800.00 Luke 800.00
Mark 800.00 Mark 2,800.00
Mark-Paul 1,000.00 Paul 1,700.00
Mark-Robert 1,000.00 Robert 1,700.00
Paul 700.00 Total 15,000.00
Robert 700.00
Service 10,000.00
Total 15,000.00
Рис. 15.17 Счет Service (служебный) не связан ни с одним значением
из столбца CustomerNome
Если мы воспользуемся техникой вычисления меры, полагающейся не на
функцию CROSSFILTER, а на фильтрацию таблицы в функции CALCULATE,
результат может оказаться иным. Строки, недостижимые из таблицы-моста,
всегда исключаются из фильтра. А поскольку поддержка фильтра здесь обеспе-
чивается функцией CALCULATE, эти значения не будут включены даже в общий
итог. Иначе говоря, фильтр всегда будет оставаться активным. Получившийся
результат можно видеть на рис. 15.18.
Account SumOfAmt ET CustomerName SumOfAmt ET
Luke 800.00 Luke 800.00
Mark 800.00 Mark 2,800.00
Mark-Paul 1,000.00 Paul 1,700.00
Mark-Robert 1,000.00 Robert 1,700.00
Paul 700.00 Total 5,000.00
Robert 700.00
Total 5,000.00
Рис. 15.18 Использование техники с табличными фильтрами
привело к скрытию пустой строки и исключению
дополнительных значений из общего итога
Здесь мы не только лишились добавки к общему итогу - пустая строка также
пропала и из отчета с фильтром по столбцу Account, она просто была отсеяна
табличным фильтром функции CALCULATE.
Ни одно из полученных нами значений нельзя считать полностью верным
или неверным. Более того, если таблица-мост будет включать ссылки на все
строки из таблицы Transactions, соответствующие Customers, то две меры пока-
жут одинаковый результат. Разработчики вольны сами выбирать технику рас-
четов в зависимости от своих требований.
ГЛАВА 15 Углубленное изучение связей 545
Примечание В плане производительности решение с использованием таблиц в ка-
честве аргумента фильтра функции CALCULATE всегда будет проигрывать из-за необ-
ходимости сканировать таблицу-мост (AccountsCustomers). Это означает, что в любом
отчете, использующем меру без фильтра по таблице Customers, падение эффективности
будет максимальным, что окажется абсолютно бесполезным, если для каждого счета
будет определен как минимум один клиент. Таким образом, по умолчанию лучше всегда
останавливать выбор на мерах с использованием двунаправленной кросс-фильтрации,
если целостность данных позволяет гарантировать одинаковые результаты. Также не
забывайте, что решения, основанные на расширении таблиц, будут работать только при
наличии сильных связей, а значит, если в вашем случае таблицы объединены при помо-
щи слабых связей, лучше предпочесть технику с двунаправленной кросс-фильтрацией.
Подробнее о факторах, влияющих на выбор решения, можно почитать в статье по
адресу https://www.sqlbi.com/articles/many-to-many-relationships-in-povver-bi-and-ex-
cel-2016/.
-
Реализация связи «многие ко многим»
через общее измерение
В данном разделе мы покажем вам еще один сценарий применения связи
«многие ко многим», которая, однако, с технической точки зрения таковой во-
все не является. Здесь мы определим связь между двумя сущностями на уровне
гранулярности, отличном от первичного ключа.
Этот пример мы взяли из области бюджетирования, где информация о бюд-
жете хранится в таблице, содержащей страну, бренд и бюджет в расчете на один
год. Модель данных, которой мы будем пользоваться, показана на 15.19.
Рис. 15.19 В таблице Budget содержатся столбцы CountryRegion, Brand и Budget
546 ГЛАВА 15 Углубленное изучение связей
Если мы хотим выводить в одном отчете цифры по продажам и бюджету,
нам необходимо иметь возможность одновременно фильтровать таблицы Bud-
get и Sales. В таблице Budget есть столбец Country Region, который также при-
сутствует и в таблице Customer. Однако значения в этом столбце неуникальны
в обеих таблицах. Столбец Brand из таблицы по бюджетированию присутствует
также и в таблице Product, и значения в нем тоже неуникальны. Можно было бы
написать простую меру Budget Amt, в которой выполнялось бы обычное сумми-
рование по столбцу Budget в одноименной таблице.
Budget Amt :=
SUM ( Budget[Budget] )
Матрица co срезом по столбцу Customer[CountryRegion] выведет результат,
показанный на рис. 15.20. Мера Budget Amt во всех строках показывает одина-
ковые цифры, а именно сумму по столбцу Budget.
CountryRegion
China
Germany
United States
Total
Sales in 2009
4,606,828.52
3,715,974.54
32,296,069.79
40,618,872.86
Budget Amt
39,004,512.00
39,004,512.00
39,004,512.00
39,004,512.00
Рис. 15.20 Мера Budget Amt не фильтруется
по столбцу Customer[CountryRegion]
и всегда показывает одинаковый результат
Существует несколько решений этого сценария. Первое из них связано с соз-
данием виртуальной связи с использованием одной из описанных ранее в дан-
ной главе техник, что позволит объединить обе таблицы единым фильтром.
Например, использование функции TREATAS поможет решить вопрос с рас-
пространением фильтра с таблиц Customer и Product на Budget, как показано
в следующем коде:
Budget Amt :=
CALCULATE (
SUM ( Budget[Budget] );
TREATAS (
VALUES ( Customer[CountryRegion] );
Budget[CountryRegion]
);
TREATAS (
VALUES ( 'Product'[Brand] );
Budget[Brand]
)
)
Теперь мера Budget Amt правильно использует фильтр по таблицам Customer
и/или Product, что видно по рис. 15.21.
ГЛАВА 15 Углубленное изучение связей 547
Для этого решения характерны следующие ограничения:
если в таблице Budget появится новый бренд, не присутствующий в таб-
лице Product, информация по нему не попадет в отчет. Как результат,
цифры в отчете будут неправильными;
вместо использования самого надежного варианта с распространением
фильтра по физической связи мы предпочли фильтровать таблицу Budget
при помощи кода DAX. В объемных моделях данных это может негативно
сказаться на производительности отчета.
CountryRegion Sales in 2009 Budget Amt
China
Germany
United States
Total
4,606,828.52
3,715,974.54
32,296,069.79
40,618,872.86
4,393,380.00
3,631,310.00
30,979,822.00
39,004,512.00
Рис. 15.21 Мера Budget Amt
фильтруется по столбцу Customer[CountryRegion]
Лучшее решение этого сценария потребует от нас незначительного изме-
нения модели данных с добавлением таблицы, которая будет выступать в ка-
честве единого фильтра для таблиц Budget и Customer. Создать ее можно легко
и просто при помощи вычисляемых таблиц непосредственно в коде DAX:
CountryRegions =
DISTINCT (
UNION (
DISTINCT ( Budget[CountryRegion] );
DISTINCT ( Customer[CountryRegion] )
)
)
В этой формуле происходит извлечение всех значений из столбца Coun-
tryRegion таблиц Customer и Budget и объединение их в единую таблицу с уда-
лением дубликатов. В результате получим новую таблицу, содержащую все
значения CountryRegion без дубликатов из таблиц Budget и Customer. Таким же
образом можно объединить таблицы Product и Budget, оперируя со столбцами
ProductfBrand] и Budget[Brand].
Brands =
DISTINCT (
UNION (
DISTINCT ( 'Product'[Brand] );
DISTINCT ( Budget[Brand] )
)
)
После того как эти вспомогательные вычисляемые таблицы созданы, оста-
ется лишь соединить их связями с существующими таблицами в нашей модели
данных, как показано на рис. 15.22.
548 ГЛАВА 15 Углубленное изучение связей
Рис. 15.22 В модели данных появились две дополнительные таблицы:
CountryRegions и Brands
В обновленной модели данных таблица Brands будет фильтровать табли-
цы Product и Budget, a CountryRegions сможет легко распространять фильтр на
таблицы Customer и Budget. Следовательно, нам не нужно будет использовать
функцию TREATAS, как в предыдущем примере. Простой функции SUM будет
достаточно для извлечения корректных значений из таблиц Budget и Sales, как
показано в следующем коде меры Budget Amt. В отчете, внешний вид которого
уже был показан на рис. 15.21, мы при этом должны использовать столбцы из
таблиц CountryRegions и Brands.
Budget Amt :=
SUM ( Budget[Budget] )
Если установить двунаправленную кросс-фильтрацию для связей между
таблицами Customer и CountryRegions, а также между Product и Brands, можно
и вовсе скрыть таблицы CountryRegions и Brands от глаз пользователя, распро-
страняя фильтры от таблиц Customer и Product на Budget без написания допол-
нительного кода на DAX. В результате мы получим модель данных, показанную
на рис. 15.23, в которой между таблицами Customer и Budget существует логиче-
ская связь на уровне гранулярности столбца CountryRegion. То же самое можно
сказать и о таблицах Product и Budget, но в этом случае уровень гранулярности
будет установлен по столбцу Brand.
Результат отчета будет таким же, как было показано на рис. 15.21. Обратите
внимание, что связь между таблицами Customer и Budget была образована за
счет комбинации связей «многие к одному» и «один ко многим». Установка дву-
ГЛАВА 15 Углубленное изучение связей 549
направленной кросс-фильтрации для связи между таблицами Customer и Coun-
tryRegions обеспечила распространение контекста фильтра с таблицы Customer
на Budget, но не наоборот. Если бы двунаправленная кросс-фильтрация была
установлена и для связи между таблицами CountryRegions и Budget, модель ста-
ла бы до определенной степени неоднозначной, и это помешало бы применить
такой же шаблон к связям между таблицами Product и Budget.
Примечание Модель, показанная на рис. 15.23, страдает от тех же ограничений, что и ее
предшественница с рис. 15.19: если в таблице с бюджетом появятся бренды или страны,
не учтенные в таблицах Product или Customer соответственно, значения по ним могут не
показываться в отчете. Более подробно мы коснемся этой проблемы в следующем раз-
деле.
Ч________________________________________________________________________________X
Рис. 15.23 После установки двунаправленной кросс-фильтрации
вспомогательные таблицы могут быть скрыты
Заметим, что чисто технически созданные нами связи не являются связями
типа «многие ко многим». В этой модели данных мы связали таблицы Product
и Budget (то же самое и с Customer) на уровне гранулярности, отличном от кон-
кретных товаров, а именно на уровне брендов. Такого же результата можно
было добиться и более простым, но при этом менее эффективным способом,
используя слабые связи, что будет описано в следующем разделе. Кроме того,
объединение таблиц по альтернативным уровням гранулярности таит в себе
определенные нюансы и сложности, о которых мы поговорим далее в данной
главе.
550 ГЛАВА 15 Углубленное изучение связей
Реализация связи «многие ко многим» через слабые связи
В предыдущем примере мы объединили таблицы Products и Budget посред-
ством вспомогательной таблицы. В октябре 2018 года мы получили возмож-
ность создавать в DAX так называемые слабые связи между таблицами, с по-
мощью которых можно проще решить подобный сценарий.
Слабая связь будет установлена между таблицами в случае наличия дубли-
рующихся значений в обоих столбцах, использующихся для объединения таб-
лиц. Иными словами, модель данных, подобная той, что была показана на
рис. 15.23, может быть создана и путем непосредственного соединения таблиц
Budget и Product по столбцу ProductfBrand] - без использования вспомогатель-
ной таблицы Brands, как это было в предыдущем разделе. В результате мы по-
лучим модель, изображенную на рис. 15.24.
Рис. 15.24 Таблица Budget напрямую объединена с таблицами Customer и Product
при помощи слабых связей
Создавая слабые связи, разработчик имеет возможность выбрать направле-
ние распространения контекста фильтра. Как и в случае со связью «один ко
многим», слабая связь может быть как однонаправленной, так и двунаправ-
ленной. В нашем примере связи должны быть однонаправленными, и контекст
фильтра должен распространяться от таблиц Customer и Product к таблице Bud-
get. Установка двунаправленных связей внесет в модель данных неоднознач-
ность.
Таблицы на обоих концах слабых связей представляют сторону «многие».
А значит, в столбцах, использующихся для объединения таблиц, могут присут-
ГЛАВА 15 Углубленное изучение связей 551
ствовать дублирующиеся значения. Обновленная модель данных будет рабо-
тать точно так же, как модель, представленная на рис. 15.23, и получение кор-
ректных результатов не потребует от нас написания дополнительного кода на
DAX в мерах или вычисляемых таблицах.
Однако в представленной модели данных присутствует определенная ло-
вушка, о которой читатель должен знать. По причине слабости связи между
таблицами ни в одной из них не будет появляться дополнительная пустая стро-
ка в случае недействительности соединения. Иными словами, если в таблице
Budget появятся страна или бренд, не представленные в таблицах Customer или
Product, значения по ним будут скрыты в отчете, как и в случае с моделью, по-
казанной на рис. 15.24.
Чтобы продемонстрировать такое поведение, мы немного модифицируем
содержимое таблицы Budget - заменим Германию (Germany) на Италию (Italy).
В нашей модели нет ни одного покупателя из Италии. Результат отчета, пред-
ставленный на рис. 15.25, может вас удивить.
CountryRegion Sales in 2009 Budget Amt
China 4,606,828.52 4,393,380.00
Germany 3,715,974.54
United States 32,296,069.79 30,979,822.00
Total 40,618,872.86 39,004,512.00
Рис. 15.25 Если связь между таблицами Budget и Customer недействительна,
результаты отчета могут быть неожиданными
В строке по Германии наша мера выдает пустое значение. И это вполне ес-
тественно, поскольку мы перекинули весь бюджет Германии на Италию. Но вот
что интересно:
в таблице отсутствует строка с бюджетом по Италии;
итоговое значение по бюджетам превышает сумму двух представленных
в столбце значений.
Фильтр, установленный по столбцу Customer [CountryRegion], распространя-
ется на таблицу Budget посредством слабой связи. В результате в таблице Budget
остаются видимыми только выбранные страны. А поскольку Италия не пред-
ставлена в столбце Customer[CountryRegion], значение по ней не выводится. Од-
нако, когда по столбцу Customer[CountryRegion] фильтр не установлен, таблица
Budget также оказывается освобождена от фильтров. А значит, в общий итог
будут включены бюджеты по всем ее строкам, включая Италию.
Таким образом, мера Budget Amt напрямую зависит от присутствия фильтра
по столбцу Customer [CountryRegion], а если связь станет недействительной, ре-
зультаты могут оказаться очень неожиданными.
Слабые связи являются достаточно мощным инструментом, способствую-
щим проектированию сложных моделей данных без необходимости создавать
вспомогательные таблицы. Но особенность этих связей, состоящая в том, что
таблицы не дополняются пустой строкой для отсутствующих значений, может
приводить к непредсказуемым результатам в отчетах. Сначала мы показали бо-
552 ГЛАВА 15 Углубленное изучение связей
лее сложную технику с созданием вспомогательных таблиц, после чего проде-
монстрировали решение с применением слабых связей. В целом эти варианты
служат одной цели, разница лишь в том, что в случае с созданием дополнитель-
ных таблиц значения, присутствующие только в одной из двух связанных таб-
лиц, будут показаны в отчете, что может быть полезно в некоторых сценариях.
Если мы выполним такую же замену Германии на Италию в модели данных
с таблицами Brands и CountryRegions, которая была представлена на рис. 15.23,
вывод отчета будет более понятным.
CountryRegion Sales in 2009 Budget Amt
China
Germany
Italy
United States
Total
4,606,828.52
3,715,974.54
32,296,069.79
40,618,872.86
4,393,380.00
3,631,310.00
30,979,822.00
39,004,512.00
Рис. 15.26 Замена Германии на Италию привела к выводу в отчете
обеих стран с корректными значениями
Выбор правильного типа для связи
При создании комплексных моделей данных могут быть использованы связи
разных типов. Работая со сложными сценариями, вы то и дело сталкиваетесь
с непростым выбором между созданием физической связи и виртуальной.
Если говорить в общем, эти разновидности связей служат одной цели: обес-
печить распространение контекста фильтра с одной таблицы на другую. Но
с точки зрения производительности и реализации могут быть варианты:
физическая связь определяется в модели данных, тогда как вирту-
альная существует только в коде на DAX. На диаграмме модели данных
показаны все физические связи, которыми объединены таблицы. Чтобы
добраться до виртуальных связей, нужно внимательно изучить выраже-
ния DAX, используемые в мерах, вычисляемых столбцах и вычисляемых
таблицах. При необходимости использовать логическую связь в разных
мерах вам придется каждый раз дублировать ее код, если речь идет не об
элементах в группах вычислений. С физическими связями работать куда
проще, и они реже становятся причиной ошибок;
физические связи накладывают ограничение на таблицу, представ-
ляющую сторону «один». Связи типа «один ко многим» и «один к одно-
му» требуют, чтобы в столбце, находящемся на стороне «один», не было
дубликатов и пустых строк. Если это условие будет нарушено, операция
обновления данных завершится ошибкой. Здесь есть серьезное отличие
по сравнению с ограничениями на внешние ключи в реляционной базе
данных, где столбец на стороне «многие» должен включать в себя только
значения, присутствующие на другой стороне связи. В табличной модели
данных такого ограничения нет;
ГЛАВА 15 Углубленное изучение связей 553
физическая связь быстрее виртуальной. При создании физической
связи движок строит дополнительную структуру данных, помогающую
ускорить выполнение запросов за счет привлечения движка хранилища
данных. Создание виртуальной связи всегда требует дополнительной ра-
боты отдвижка формул, который уступает в скорости подсистеме храни-
лища данных. Различия между движком формул и движком хранилища
данных будут подробно описаны в главе 17.
В большинстве случаев лучшим выбором будет физическая связь. При этом
в плане производительности нет никакой разницы между обычной связью (ос-
нованной на столбцах в источнике данных) и вычисляемой физической связью
(базирующейся на вычисляемых столбцах). Движок рассчитывает значения
в вычисляемых столбцах в момент обработки (когда данные обновляются), так
что сложность выражения большой роли не играет - связь является физиче-
ской, и движок может использовать все свои ресурсы для расчетов.
Виртуальная связь представляет собой абстрактную концепцию. Фактиче-
ски каждый раз, когда происходит распространение контекста фильтра с од-
ной таблицы на другую в коде на DAX, между ними создается виртуальная
связь. Такие связи вычисляются в момент выполнения запроса, и у движка нет
никаких дополнительных ресурсов в виде структур, создаваемых для физиче-
ских связей, чтобы как-то оптимизировать план выполнения запроса. Поэтому
всегда, когда у вас есть такая возможность, отдавайте предпочтение физиче-
ским связям в сравнении с виртуальными.
Связи типа «многие ко многим» занимают промежуточную позицию между
физическими связями и виртуальными. Можно определить связь «многие ко
многим» в модели данных при помощи двунаправленной фильтрации или
расширенных таблиц. Чаще всего присутствие связи в модели данных будет
более выгодным решением по сравнению с подходом, основывающимся на
использовании расширенных таблиц, поскольку в этом случае движок полу-
чает дополнительные рычаги при оптимизации плана выполнения запроса за
счет отключения распространения фильтров там, где они не нужны. В то же
время варианты с расширением таблиц и использованием двунаправленной
кросс-фильтрации при активном фильтре будут иметь примерно одинаковую
эффективность, хотя чисто технически генерируют совершенно разные планы
выполнения запроса.
С точки зрения производительности вы должны расставлять приоритеты
при выборе типа связи следующим образом:
физические связи типа «один ко многим» будут обладать наибольшей
эффективностью и по максимуму задействуют движок VertiPaq. Вычис-
ляемые физические связи будут работать с той же скоростью, что и связи,
основанные на физических столбцах;
связи с использованием двунаправленной кросс-фильтрации, связи
«многие ко многим» с расширенными таблицами и слабые связи долж-
ны идти на втором месте в списке приоритетов. Они обладают хорошей
производительностью и активно используют движок, пусть и не по мак-
симуму;
виртуальные связи замыкают наш список приоритетов по причине
возможной низкой производительности. Заметьте, что вы можете и не
554 ГЛАВА 15 Углубленное изучение связей
столкнуться с этими проблемами, если будете соблюдать все меры пре-
досторожности и выполнять требования оптимизации, о которых мы по-
говорим в следующих главах.
Управление гранулярностью
Как мы уже упоминали в предыдущих разделах, при помощи вспомогатель-
ных таблиц или слабых связей можно осуществить связь между таблицами на
уровне гранулярности ниже первичного ключа. В последнем примере мы свя-
зывали таблицу Budget с таблицами Product и Customer. При этом связь с табли-
цей Product была создана на уровне брендов, а с таблицей Customer - на уровне
стран.
Если в модели данных присутствуют связи со сниженным уровнем грану-
лярности, необходимо проявлять осторожность при написании мер, использу-
ющих эти связи. На рис. 15.27 представлена исходная модель данных с двумя
слабыми связями типа «многие ко многим» между таблицами Customer и Prod-
uct с одной стороны и Budget - с другой.
Рис. 15.27 Таблицы Customer, Product и Budget объединены слабыми связями
Контекст фильтра распространяется по слабым связям от таблицы к таблице
в соответствии с выбранным уровнем гранулярности. Это утверждение спра-
ведливо для любых связей. Фактически между таблицами Customer и Sales кон-
текст фильтра также распространяется на уровне гранулярности столбца, по
которому построена связь. В случае если связь базируется на столбце, являю-
щемся первичным ключом в таблице, ее поведение будет интуитивно понят-
ным. Если же гранулярность связи будет ниже, как в случае со слабыми свя-
зями, очень легко будет прийти к вычислениям, смысл которых понять будет
затруднительно.
Рассмотрим для примера таблицу Product. Связь между ней и таблицей Bud-
get установлена на уровне бренда. Таким образом, мы можем построить матри-
цу со срезом меры Budget Amt по столбцу Brand и получить осознанные резуль-
таты, показанные на рис. 15.28.
ГЛАВА 15 Углубленное изучение связей 555
Brand Budget Amt
A. Datum 1,777,784.00
Adventure Works 4,985,172.00
Contoso 7,127,903.00
Fabrikam 8,667,819.00
Litware 4,284,028.00
Northwind Traders 911,918.00
Prose ware 3,192,659.00
Southridge Video 1,643,555.00
Tailspin Toys 600,524.00
The Phone Company 2,233,721.00
Wide World Importers 3,579,429.00
Total 39,004,512.00
Рис. 15.28 Срез таблицы с бюджетом
по бренду выдал правильные результаты
Ситуация осложнится, если в анализ будут вовлечены другие столбцы из таб-
лицы Product. В отчете, показанном на рис. 15.29, мы добавили срез, чтобы от-
фильтровать вывод по нескольким цветам товаров, и вывели цвет в столбцы
матрицы. Результат оказался неожиданным.
Color Brand Black Blue Green Total
(Blank) Azure- A. Datum 1,777,784.00 1,777,784.00 1,777,784.00 1,777,784.00
Ц Black Adventure Works 4,985,172.00 4,985,172.00 4,985,172.00
Blue Contoso 7,127,903.00 7,127,903.00 7,127,903.00 7,127,903.00
Brown Gold Fabrikam 8,667,819.00 8,667,819.00 8,667,819.00 8,667,819.00
| Green Litware 4,284,028.00 4,284,028.00 4,284,028.00 4,284,028.00
Grey Northwind Traders 911,918.00 911,918.00 911,918.00 911,918.00
Orange Pink Proseware 3,192,659.00 3,192,659.00 3,192,659.00 3,192,659.00
Purple Southridge Video 1,643,555.00 1,643,555.00 1,643,555.00
Red Tailspin Toys 600,524.00 600,524.00 600,524.00 600,524.00
Silver Silver Grey Transparent The Phone Company Wide World Importers 2,233,721.00 3,579,429.00 3,579,429.00 3,579,429.00 2,233,721.00 3,579,429.00
White Total 39,004,512.00 36,770,791.00 30,142,064.00 39,004,512.00
Yellow
Рис. 15.29 Срез бюджета по бренду и цвету товаров привел к неожиданным результатам
Обратите внимание, что по брендам значения - там, где они есть, - выво-
дятся одинаковые вне зависимости от фильтра по цвету. При этом итоговые
значения по цветам отличаются, а общий итог явно не соответствует сумме
итогов по цветам.
Чтобы понять, что произошло с цифрами, построим упрощенную версию
матрицы без учета брендов. На рис. 15.30 показан отчет, в котором выведена
мера Budget Amt со срезом только по столбцу Product[Color].
Взгляните на цифру бюджета по товарам синего цвета (Blue). В начале вы-
числения этой ячейки контекст фильтра по таблице Product содержал значе-
ние Blue. Но у нас не во всех брендах присутствуют синие товары. Например,
в бренде The Phone Company не представлено ни одного товара синего цвета,
556 ГЛАВА 15 Углубленное изучение связей
как видно по рис. 15.29. Таким образом, столбец Product[Brand] попал под дей-
ствие перекрестного фильтра по столбцу Product/Color], и в результирующий
набор вошли все бренды, за исключением бренда The Phone Company. Распро-
странение контекста фильтра на таблицу Budget производится на уровне гра-
нулярности Brand. Таким образом, после применения фильтра таблица Budget
будет включать все бренды, за исключением The Phone Company.
Color Budget Amt
Black 39,004,512.00
Blue 36,770,791.00
Green 30,142,064.00
Total 39,004,512.00
Рис. 15.30 Срез только по цвету позволит лучше понять,
что происходите ячейками в отчете
Получившееся значение является суммой по всем брендам, кроме The Phone
Company. При переходе по связи информация о цвете товаров была утеряна.
Связь между Color и Brand была использована во время перекрестной фильтра-
ции Brand по Color, но при этом фильтр таблицы Budget выполняется исклю-
чительно по столбцу Brand. Иначе говоря, в каждой ячейке мы видим сумму
по всем брендам, в которых есть как минимум один товар выбранного цвета.
Такое поведение отчета может понадобиться очень редко. Есть несколько сце-
нариев, когда такие расчеты будут восприниматься как правильные, но в боль-
шинстве случаев пользователь будет ожидать несколько иные цифры.
Проблема будет проявляться всякий раз, когда пользователь будет анали-
зировать агрегацию значений на уровне гранулярности, не соответствующем
уровню гранулярности связи. Хорошей практикой является скрытие значений
в случаях, когда выбранная гранулярность не поддерживается связью. И здесь
возникает вопрос: как определить, что выводимые значения в отчете находят-
ся на правильном уровне гранулярности? Чтобы ответить на него, создадим
еще несколько мер.
Мы начали с построения матрицы, содержащей бренды (правильная гра-
нулярность) и цвета товаров (неправильная гранулярность). Добавим в отчет
также меру NumOfProducts, показывающую количество строк в таблице Product
в рамках текущего контекста фильтра:
NumOfProducts :=
COUNTROWS ( 'Product' )
Результат можно видеть в отчете, показанном на рис. 15.31.
Ключом к решению этого сценария является дополнительная мера NumOf-
Products. На строке с брендом A. Datum мера NumOfProducts показывает коли-
чество видимых товаров 132, что соответствует общему количеству товаров
бренда A. Datum. При дальнейшей фильтрации отчета по цвету товара (или
любому другому столбцу) количество видимых товаров будет уменьшаться.
Значения из таблицы Budget при этом имеют смысл, только если все 132 то-
вара видимы. Если товаров в выборе меньше, значение меры Budget Amt бу-
дет бессмысленным. Следовательно, нужно скрывать меру Budget Amt, когда
количество видимых товаров в точности не соответствует количеству товаров
в выбранном бренде.
ГЛАВА 15 Углубленное изучение связей 557
Brand Budget Amt NumOfProducts
A. Datum 1,777,784.00 132
Azure 1,777,784.00 14
Black 1,777,784.00 18
Blue 1,777,784.00 4
Gold 1,777,784.00 4
Green 1,777,784.00 14
Grey 1,777,784.00 18
Orange 1,777,784.00 18
Pink 1,777,784.00 18
Silver 1,777,784.00 18
Silver Grey 1,777,784.00 6
Adventure Works 4,985,172.00 192
Black 4,985,172.00 54
Blue 4,985,172.00 12
Brown 4,985,172.00 15
Рис. 15.31 Значение меры Budget Amt правильно вычисляется для брендов
и неправильно - для цветов
Мера, в которой подсчитываются товары на уровне гранулярности брендов,
представлена ниже:
NumOfProducts Budget Grain :=
CALCULATE (
[NumOfProducts];
ALL ( 'Product' );
VALUES ( 'Product'[Brand] )
)
В данном случае необходимо использовать связку функций ALL/VALUES
вместо ALLEXCEPT. Разницу между этими двумя подходами мы описывали
в главе 10. Теперь достаточно перед выводом меры Budget Amt сравнить две
наши новые меры. Если их значения будут равны, можно выводить меру Budget
Amt, если нет - пустое значение, в результате чего строка будет скрыта в отче-
те. Реализация этого плана показана в мере Corrected Budget:
Corrected Budget :=
IF (
[NumOfProducts] = [NumOfProducts Budget Grain];
[Budget Amt]
)
На рис. 15.32 представлен полный отчет со всеми созданными мерами. При
этом в мере Corrected Budget значения скрыты в строках, где гранулярность от-
чета не соответствует гранулярности таблицы Budget.
Этот же шаблон может быть применен и к таблице Customer с установкой
гранулярности на уровне столбца CountryRegion. Подробнее об этом шаблоне
можно почитать по адресу https://www.daxpatterns.com/budget-patterns/.
558 ГЛАВА 15 Углубленное изучение связей
Brand Budget Amt NumOfProducts NumOfProducts Budget Grain Corrected Budget
A. Datum 1,777,784.00 132 132 1,777,784.00
Azure 1,777,784.00 14 132
Black 1,777,784.00 18 132
Blue 1,777,784.00 4 132
Gold 1,777,784.00 4 132
Green 1,777,784.00 14 132
Grey 1,777,784.00 18 132
Orange 1,777,784.00 18 132
Pink 1,777,784.00 18 132
Silver 1,777,784.00 18 132
Silver Grey 1,777,784.00 6 132
Adventure Works 4,985,172.00 192 192 4,985,172.00
Black 4,985,172.00 54 192
Blue 4,985,172.00 12 192
Brown 4,985,172.00 15 192
Рис. 15.32 Значения в мере Corrected Budget скрыты,
когда ячейка показана на несовместимом уровне гранулярности
Всякий раз, когда вы используете связи, построенные на основании уров-
ня гранулярности, отличного от первичного ключа таблицы, необходимо осу-
ществлять дополнительную проверку в мерах, чтобы не показывать их зна-
чения на несовместимых уровнях гранулярности. Наличие в модели данных
слабых связей требует повышенного внимания к таким нюансам.
Возникновение неоднозначностей в связях
Говоря о связях, не стоит забывать о возможном появлении неоднозначности
(ambiguity) в модели данных. Неоднозначность возникает в случае, если от
одной таблицы к другой можно добраться несколькими путями, и, к сожале-
нию, в больших моделях данных такие ситуации бывает очень трудно рас-
познать.
Легче всего получить неоднозначность в модели данных можно, создав
более одной связи между двумя таблицами. Например, в таблице Sales у нас
хранятся две даты: дата заказа и дата поставки. Если создать между таблица-
ми Date и Sales две связи на основании двух этих столбцов, одна из них будет
неактивной. На рис. 15.33 такая связь между таблицами Date и Sales показана
пунктирной линией.
Если бы обе связи одновременно были активными, модель данных стала бы
неоднозначной. Иными словами, движок DAX не знал бы, по какой из двух свя-
зей распространять фильтры при переходе от таблицы Date к Sales.
Когда речь идет всего о двух таблицах, неоднозначность модели данных
очень легко обнаружить и понять. Но с ростом количества таблиц делать это
становится все труднее. Движок сам следит за тем, чтобы при создании модели
данных в ней не было никаких неоднозначностей. При этом он пользуется до-
ГЛАВА 15 Углубленное изучение связей 559
вольно сложными алгоритмами, которые человеку понять не так просто. В ре-
зультате иногда движок не усматривает неопределенностей в моделях, в кото-
рых они на самом деле присутствуют.
□ Sales
ProdjrtXey
Older Date
Deliver/ Date
Order Jpe Number
S Quantity
Unit Price
Unit Discount
Unit Ccst_________
Рис. 15.33 Между двумя таблицами активной может быть только одна связь
Давайте рассмотрим модель данных, показанную на рис. 15.34. Перед тем
как читать дальше, внимательно вглядитесь в эту модель и ответьте на вопрос,
является ли она неоднозначной.
Рис. 15.34 Есть ли в этой модели данных неоднозначность?
Сможет ли разработчик создать такую модель, или движок выдаст ошибку?
Ответ на этот вопрос сам по себе неоднозначный и звучит так: эта модель
содержит неоднозначность для человека, но не для движка DAX. И все же это
не самая удачная модель данных, поскольку ее достаточно сложно анализиро-
вать. Но для начала разберемся, где в ней скрывается неоднозначность.
560 ГЛАВА 15 Углубленное изучение связей
Заметьте, что для связи между таблицами Product и Sales установлена двуна-
правленная кросс-фильтрация, а это значит, что контекст фильтра может быть
передан от таблицы Sales к Product и далее к Receipts. Теперь посмотрите на
таблицу Date. Мы можем легко распространить контекст фильтра от нее через
Sales и Product на таблицу Receipts. В то же время фильтр из таблицы Date может
добраться до Receipts и по непосредственной связи между этими двумя табли-
цами. Таким образом, эта модель данных будет считаться неоднозначной, по-
скольку в ней существует больше одного пути для распространения контекста
фильтра между двумя таблицами.
Несмотря на это, создавать и использовать подобные модели данных в DAX
вполне допустимо, поскольку движок располагает определенными правилами
для снижения количества неоднозначностей, встречаемых в моделях. В дан-
ном случае будет применено правило о распространении контекста фильтра
между этими двумя таблицами по кратчайшему пути. Так что DAX позволит
создать такую модель данных, невзирая на наличие в ней неоднозначности.
Но это отнюдь не означает, что нужно создавать и работать с такими моделя-
ми. Наоборот, это является плохой практикой, и мы настоятельно советуем
вам отказаться от использования моделей данных, в которых есть неодно-
значности.
Но ситуация с неоднозначностями может быть еще более запутанной. Не-
однозначности в моделях могут появляться как в результате создания связей
между таблицами, так и при выполнении кода DAX, если разработчик исполь-
зует в функции CALCULATE такие модификаторы, как, например, USERELA-
TIONSHIP или CROSSFILTER. У вас может быть мера, которая прекрасно ра-
ботает. Но при обращении к ней внутри другой меры, использующей для
активации нужной связи функцию CROSSFILTER, она вдруг начинает выдавать
неправильные цифры. Причиной может быть неоднозначность, появившаяся
в модели данных как раз из-за применения функции CROSSFILTER. Но мы не
собираемся пугать наших читателей, мы просто хотим предостеречь их в связи
с теми сложностями, которые могут возникать, когда речь заходит о неодно-
значностях в модели данных.
Появление неоднозначностей в активных связях
Наш первый пример будет базироваться на модели данных, представленной
на рис. 15.34. В матрице со срезом по годам мы выведем меры Sales Amount
и Receipts Amount (простые вычисления с использованием итератора SUMX).
Результат этого отчета показан на рис. 15.35.
Calendar Year Sales Amt Receipts Amt
CY 2007 11,309,946.12 92,929,563.18
CY 2008 9,927,582.99 88,287,767.29
CY 2009 9,353,814.87 79,908,559.19
Total 30,591,343.98 261,125,889.66
Рис. 15.35 Столбец Calendar Year фильтрует таблицу Receipts,
но с использованием каких связей?
ГЛАВА 15 Углубленное изучение связей 561
От таблицы Date до Receipts можно добраться двумя способами:
напрямую (по связи, объединяющей эти две таблицы);
в обход, используя путь от Date к Sales, далее от Sales к Product и наконец
от Product к Receipts.
Представленная модель данных не считается неоднозначной, поскольку
движок DAX всегда может выбрать кратчайший путь для распространения кон-
текста фильтра между таблицами. Располагая прямой связью между Date и Re-
ceipts, движок игнорирует все остальные пути между этими таблицами. Если же
кратчайший путь оказывается недоступен, приходится задействовать обходные
пути. Посмотрите, что произойдет, если создать новую меру, вычисляющую меру
Receipts Amt после деактивации прямой связи между таблицами Date и Receipts:
Rec Amt Longer Path :=
CALCULATE (
[Receipts Amt];
CROSSFILTER ( 'Date'[Date]; Receipts[Sale Date]; NONE )
)
В мере Rec Amt Longer Path связь, установленная между таблицами Date и Re-
ceipts, разрывается, в результате чего движку приходится идти обходным пу-
тем. Результат вычисления новой меры показан на рис. 15.36.
Calendar Year Sales Amt
Receipts Amt Rec Amt Longer Path
CY 2007 11,309,946.12 92,929,563.18 155,636,856.07
CY 2008 9,927,582.99 88,287,767.29 172,390,011.89
CY 2009 9,353,814.87 79,908,559.19 159,020,856.51
Total 30,591,343.98 261,125,889.66 261,125,889.66
Рис. 15.36 В мере Rec Amt Longer Poth используется длинный путь
для фильтрации таблицы Receipts из таблицы Dote
Остановитесь и подумайте, что будут характеризовать цифры, полученные
при вычислении меры Rec Amt Longer Path. Пока не сделаете предположение, не
читайте дальше, поскольку в следующих абзацах мы дадим правильный ответ.
Фильтр стартует из таблицы Date и переходит в таблицу Sales. Оттуда он рас-
пространяется на Product. В результате этой фильтрации мы получим список
товаров, которые продавались в выбранные даты. Иначе говоря, для 2007 года
в фильтр попадут только те товары, которые продавались в этом году. После
этого фильтр переходит в таблицу Receipts. Таким образом, результат вычис-
ления данной меры будет отражать общую сумму по таблице Receipts по всем
товарам, которые продавались в конкретном году. Далеко не самое интуитив-
но понятное значение.
Наиболее сложным нюансом в приведенной выше формуле является уста-
новка в функции CROSSFILTER режима NONE. Вам может показаться, что этот
код просто деактивирует имеющуюся связь. На самом деле деактивация одной
связи автоматически активирует альтернативный путь. Так что в этой мере
не просто удаляется связь между таблицами, но и активируются другие связи,
явно не указанные в формуле.
562 ГЛАВА 15 Углубленное изучение связей
В этом сценарии неоднозначность в модель данных вносится по причине
установки двунаправленной кросс-фильтрации для связи между таблицами
Product и Sales. Наличие двунаправленных связей в модели данных - очень
скользкий путь, часто ведущий к образованию неоднозначностей, которые
фиксируются движком DAX, но могут остаться незамеченными для разработ-
чика. После многих лет работы с DAX мы можем ответственно заявить, что на-
личия двунаправленной кросс-фильтрации в модели данных стоит избегать
любыми способами, если только плюсы от ее установки не перевешивают все
известные минусы. А в тех редких сценариях, где присутствие таких связей
оправдано, мы настоятельно рекомендуем несколько раз проверить модель
данных на наличие неоднозначностей. Более того, эту проверку стоит повто-
рять каждый раз, когда в модели появляется новая таблица или связь. А прово-
дить такую проверку в модели данных с количеством таблиц, превышающим
50, достаточно утомительно. Избежать же подобной участи можно, избавив-
шись от присутствия связей с двунаправленной кросс-фильтрацией на этапе
проектирования модели.
Устранение неоднозначностей в неактивных связях
Несмотря на то что двунаправленные связи действительно являются главной
причиной возникновения неоднозначностей в моделях данных, не они одни
виноваты в их появлении. Фактически разработчик может спроектировать
идеальную модель данных без намеков на присутствие неоднозначностей, а во
время выполнения запросов эти неоднозначности начнут себя обнаруживать.
Взгляните на модель данных, представленную на рис. 15.37. В ней нет ника-
ких неоднозначностей.
Рис. 15.37 В этой модели неоднозначностей нет, поскольку потенциально опасная связь
деактивирована
Обратите внимание на таблицу Date. Она фильтрует таблицу Sales посред-
ством единственной активной связи (между столбцами Date[Date] и Sales[Date]).
При этом всего между этими таблицами может быть две связи, одна из которых
ГЛАВА 15 Углубленное изучение связей 563
была деактивирована, чтобы избежать образования неоднозначности. Также
в модели присутствует связь между таблицами Date и Customer на основании
столбца Customer[FirstSale], и эту связь пришлось сделать неактивной. Если поз-
же мы активируем ее, то получим второй путь для распространения контекста
фильтра между таблицами Date и Sales, что автоматически внесет элемент не-
однозначности в модель данных. Таким образом, наша модель данных работает
корректно, поскольку использует исключительно активные связи. Но что про-
изойдет, если явным образом активировать одну или более неактивных связей
внутри функции CALCULATE? Модель тут же станет неоднозначной. Например,
в следующей мере мы активируем связь между таблицами Date и Customer:
First Date Sales :=
CALCULATE (
[Sales Amount];
USERELATIONSHIP ( Customer[FirstSale]; 'Date'[Date] )
)
Поскольку модификатор USERELATIONSHIP делает связь между календарем
и покупателями активной, внутри функции CALCULATE модель становится не-
однозначной. А поскольку движок DAX не может работать с моделью, в кото-
рой присутствует неоднозначность, он вынужден деактивировать другие свя-
зи. В результате он отказывается от выполнения фильтрации по кратчайшему
пути, которым является прямая связь между таблицами Date и Sales. Получа-
ется, что во избежание проявления неоднозначностей движок по умолчанию
использует кратчайший путь при выполнении фильтрации, но при явном
указании активировать связь между таблицами Customer и Date при помощи
функции USERELATIONSHIP решает деактивировать связь между таблицами
Date и Sale.
Применение функции USERELATIONSHIP привело к тому, что движок отка-
зался от использования прямой связи между таблицами Date и Sales. Вместо
этого он распространил контекст фильтра сначала с таблицы Date на Customer,
а затем добрался до таблицы Sales. Соответственно, при выборе покупателя
и периода эта мера будет показывать сумму всех транзакций по нему, но толь-
ко в строке с датой первой покупки этого клиента. Вывод данной меры показан
на рис. 15.38.
Customer Alberto Sales Amount First Date Sales
1,000 1,000
02/01/2018 500 1,000
03/01/2018 500
Daniele 2,000 2,000
03/01/2018 2,000 2,000
Marco 300 300
01/01/2018 100 300
02/01/2018 100 Рис. 15.38 Мера First Dote Soles
03/01/2018 100 показывает все продажи клиенту,
Total 3,300 3,300 располагая их на дате первой покупки
564 ГЛАВА 15 Углубленное изучение связей
В мере First Date Sales всегда будет показываться сумма продаж из таблицы
Sales по конкретному клиенту, при этом значения в датах, не соответствующих
дате первой его покупки, останутся пустыми. В плане бизнес-аналитики эта
мера пригодится для выполнения проекции по будущим продажам конкрет-
ному клиенту на дату его прихода. И хотя кто-то найдет смысл в таком разрезе
анализа, вряд ли этот отчет будет отвечать вашим требованиям.
Здесь мы не ставим себе цель разобраться в том, как именно движок реша-
ет проблемы с возникновением неоднозначности в модели данных. Правила,
которыми руководствуется DAX в этом случае, никогда не были документи-
рованы, а значит, со временем они могут измениться. Настоящей проблемой
является то, что неоднозначность может проявляться в моделях данных, изна-
чально лишенных всяких намеков на неоднозначность, при активации ранее
неактивных связей. Ну а понимание того, какой именно путь для распростра-
нения контекста фильтра выберет движок, чтобы устранить неоднозначность,
связано больше с догадками, чем с точными науками.
Когда речь заходит о связях и неоднозначностях, лучше всего отдавать пред-
почтение максимальной простоте. В DAX заложены сложные алгоритмы устра-
нения неоднозначностей в моделях данных, и движку по силам решить эту
задачу почти для каждой модели. Для того чтобы вызвать ошибку времени вы-
полнения, связанную с неоднозначностью модели данных, достаточно одно-
временно использовать несколько функций USERELATIONSHIP. Только в этом
случае движок выдаст ошибку. Например, в следующий код меры изначально
заложена неоднозначность:
First Date Sales ERROR :=
CALCULATE (
[Sales Amount];
USERELATIONSHIP ( Customer[FirstSale]; 'Date'[Date] );
USERELATIONSHIP ( 'Date'[Date]; Sales[Date] )
)
В данном случае, активировав обе связи, DAX просто не сможет избавить
модель от неоднозначности и выдаст ошибку. Несмотря на это, сама мера мо-
жет быть без проблем определена в модели данных. Ошибка проявится только
в момент вычисления меры с фильтром по дате.
В данном разделе мы не старались описать все доступные опции модели-
рования данных. Мы лишь хотели обратить ваше внимание на проблемы, ко-
торые могут проявляться в случае использования неправильно спроектиро-
ванной модели. Построить идеальную модель данных очень нелегко. Но важно
помнить, что использование двунаправленной кросс-фильтрации и неактив-
ных связей без полного понимания возможных последствий - это первый шаг
к проектированию непредсказуемой модели данных.
Заключение
Связи являются важной частью моделирования данных. В табличной модели
данных представлены три типа связей: «один ко многим», «один к одному»
ГЛАВА 15 Углубленное изучение связей 565
и слабые связи «многие ко многим». При этом название «многие ко многим»,
применяемое в пользовательских интерфейсах некоторых программ, может
сбивать с толку и идти вразрез с концепцией моделирования данных. Каждая
связь может способствовать распространению фильтра от таблицы к таблице
как в одном направлении, так и в обоих. Исключение составляют связи «один
к одному», по которым контекст фильтра всегда передается в обе стороны.
Существующие инструменты могут быть расширены в области логического
моделирования данных за счет построения вычисляемых физических связей
или виртуальных связей с помощью функций TREATAS и SUMMARIZE, а также
расширения таблиц. Связи типа «многие ко многим» между сущностями могут
быть реализованы посредством использования таблиц-мостов и полагаться на
двунаправленную кросс-фильтрацию, примененную к связям в цепочке.
Все описанные в данной главе концепции являются очень мощными, но при
этом таят в себе опасность. Создание связей между таблицами требует повы-
шенного внимания. Разработчик должен постоянно тщательно проверять мо-
дель данных на предмет наличия неоднозначностей, а также следить за тем,
чтобы неоднозначности не появлялись вследствие использования функций
USERELATIONSHIP и CROSSFILTER.
Чем больше модель данных, тем больше вероятность допущения ошибок.
Если в модели присутствуют неактивные связи, вы должны четко понимать,
почему именно они неактивны и что произойдет в момент их активации.
Тщательная работа на этапе проектирования модели позволит вам в будущем
облегчить написание выражений на языке DAX, тогда как плохо продуманная
схема данных будет доставлять разработчику в процессе взаимодействия с ней
немало проблем.
ГЛАВА 16
Вычисления повышенной
сложности в DAX
В последней главе, посвященной языку DAX, и перед тем, как перейти к вопро-
сам оптимизации, мы хотим показать вам несколько примеров реализации
сложных вычислений. Здесь, как и раньше, мы не ставим себе цель показать
работающие шаблоны, которые вы можете без изменений использовать в сво-
их проектах, - такие шаблоны можно найти по адресу https://www.daxpatterns.
com. Вместо этого мы продемонстрируем вам процесс написания формул раз-
ной степени сложности - это позволит вам еще на шаг приблизиться к тому,
чтобы думать на языке DAX.
DAX требует от разработчика творческого мышления. Теперь, когда вы узна-
ли все секреты этого языка, пришло время применить свои знания на практи-
ке. Начиная со следующей главы мы будем подробно говорить об оптимизации
вычислений, а здесь затронем тему производительности меры и посмотрим,
как можно определить сложность той или иной формулы.
При этом пока мы не будем стремиться к идеальной производительности
наших мер, поскольку для этого необходимо обладать знаниями, которые вы
приобретете в следующих главах. Здесь же мы попробуем получать одни и те
же результаты, используя при этом разные формулы, и параллельно оцени-
вать их сложность. Когда мы начнем разбираться с оптимизацией кода, умение
формулировать одни и те же вычисления по-разному очень вам пригодится.
Подсчет количества рабочих дней
между двумя датами
Если у вас есть две даты, вы легко и просто можете узнать разницу между ними
в днях при помощи обычной операции вычитания. В таблице Sales у нас есть
два столбца с датами: в одном хранится дата заказа, в другом - дата поставки.
Среднее количество дней, требуемое для поставки товара заказчику, можно
вычислить по следующей формуле:
Avg Delivery :=
AVERAGEX (
Sales;
INT ( Sales[Delivery Date] - Sales[Order Date] + 1)
)
ГЛАВА 16 Вычисления повышенной сложности в DAX 567
Поскольку внутренне даты хранятся в виде целых чисел, представляющих
дни, формула покажет правильный результат. При этом будет несправедливо
говорить, что на поставку заказа, который был оформлен в пятницу, а приве-
зен в понедельник, ушло три дня, если суббота и воскресенье были выходными
днями. Фактически в этом случае на поставку ушел всего один день, как если
бы заказ был оформлен в понедельник, а доставлен во вторник. Так что пра-
вильнее было бы говорить о разнице в рабочих днях между двумя датами. Мы
представим вам сразу несколько версий подобного расчета и попытаемся вы-
брать лучший из них в плане эффективности и гибкости.
В Excel существует специальная функция для этих целей: ЧИСТРАБДНИ
(NETWORKDAYS). В DAX, к сожалению, аналога этой функции не существует.
Зато в этом языке представлено множество других функций, которые можно
использовать как строительные блоки при написании сложных вычислений,
подобных этому. Для начала решим эту задачу путем простого подсчета коли-
чества рабочих дней между двумя датами, исключив выходные дни:
Avg Delivery WD :=
AVERAGEX (
Sales;
VAR RangeOfDates =
DATESBETWEEN (
'Date'[Date];
Sales[Order Date];
Sales[Delivery Date]
)
VAR WorkingDates =
FILTER (
RangeOfDates;
NOT ( WEEKDAY ( 'Date'[Date] ) IN { 1; 7 } )
)
VAR NumberOfWorkingDays =
COUNTROWS ( WorkingDates )
RETURN
NumberOfWorkingDays
)
Для каждой строки в таблице Sales формула создает временную таблицу
в переменной RangeOfDates, где хранятся все даты между датой заказа и да-
той поставки. После этого происходит отсев субботних и воскресных дней
с записью результата в таблицу WorkingDates, а затем в переменную NumberOf-
WorkingDays помещается результат подсчета строк в получившейся таблице. На
рис. 16.1 показан график, на котором отчетливо видна разница между средни-
ми сроками поставки товаров с учетом и без учета рабочих дней.
Мера работает правильно, но в ней есть сразу несколько недостатков. Во-
первых, она не учитывает праздничные дни. Например, даже если исключить
из расчета субботы и воскресенья, 1 января все равно будет считаться рабо-
чим днем, если он не выпадает на выходные. То же самое можно сказать и об
остальных праздниках в году. Кроме того, и производительность этой меры
оставляет желать лучшего.
568 ГЛАВА 16 Вычисления повышенной сложности в DAX
Чтобы учитывать в расчете праздничные дни, необходимо прежде всего об-
ладать информацией о том, какие дни в году являются праздничными. И лучше
всего ее хранить в вычисляемом столбце Is Holiday прямо в таблице Date. После
этого новый столбец можно включить в нашу формулу следующим образом:
Avg Delivery WD DT :=
AVERAGEX (
Sales;
VAR RangeOfDates =
DATESBETWEEN (
'Date'[Date];
Sales[Order Date];
Sales[Delivery Date]
)
VAR NunberOfWorkingDays =
CALCULATE (
COUNTROWS ( 'Date' );
RangeOfDates;
NOT ( WEEKDAY ( 'Date'[Date] ) IN { 1; 7 } );
'Date'[Is Holiday] = 0
)
RETURN
NunberOfWorkingDays
)
Чтобы не использовать слишком много условий в выражении, можно объ-
единить информацию о том, является ли конкретный день рабочим, в одном
столбце со значениями Workday или Weekend. Это снизит сложность меры
и сместит логику ближе к данным, что повысит гибкость формулы.
Что касается процесса выполнения формулы, то он состоит из следующих
шагов:
запуск итераций по таблице Sales;
ГЛАВА 16 Вычисления повышенной сложности в DAX 569
создание для каждой строки в таблице Sales временной таблицы со всеми
датами в промежутке между датой заказа и датой поставки.
Если в таблице Sales миллион записей, а средний срок поставки равен семи
дням, сложность меры исчисляется примерно семью миллионами. Это значит,
что движку DAX необходимо в процессе вычисления формулы миллион раз
создавать таблицу с семью строками.
Сложность расчета можно снизить, либо уменьшив количество итераций
во внешней функции AVERAGEX, либо снизив количество строк во временной
таблице с рабочими днями. Стоит отметить, что нет никакой необходимости
производить это вычисление для каждой строки в таблице заказов, посколь-
ку у всех заказов с одинаковыми комбинациями значений в столбцах Order
Date и Delivery Date будет один и тот же срок поставки. Так что можно сначала
сгруппировать таблицу Sales по этим столбцам, после чего вычислить значение
для каждой пары значений. Тем самым мы сможем снизить число итераций во
внешней функции AVERAGEX, но при этом утратим информацию о том, сколь-
ко именно заказов представлено в каждой комбинации дат. Эту проблему мож-
но решить, если считать не обычные средние значения, а средневзвешенные,
используя количество заказов в качестве веса.
Эта идея может быть реализована следующим образом:
Avg Delivery WD WA :=
VAR NumOfAllOrders =
COUNTROWS ( Sales )
VAR CombinationsOrderDeliveryDates =
SUMMARIZE (
Sales;
Sales[Order Date];
Sales[Delivery Date]
)
VAR DeliveryWeightedByNumOfOrders =
SUMX (
CombinationsOrderDeliveryDates,
VAR RangeOfDates =
DATESBETWEEN (
'Date'[Date];
Sales[Order Date];
Sales[Delivery Date]
)
VAR NumOfOrders =
CALCULATE (
COUNTROWS ( Sales )
)
VAR WorkingDays =
CALCULATE (
COUNTROWS ( 'Date' );
RangeOfDates;
NOT ( WEEKDAY ( 'Date'[Date] ) IN { 1; 7 } );
'Date'[Is Holiday] = 0
)
VAR NumberOfWorkingDays = NumOfOrders * WorkingDays
570 ГЛАВА 16 Вычисления повышенной сложности в DAX
RETURN
NumberOfWorkingDays
)
VAR AverageWorkingDays =
DIVIDE (
DeliveryWeightedByNumOfOrders;
NumOfAllOrders
)
RETURN
AverageWorkingDays
Этот код уже не так прост для восприятия. И здесь возникает резонный во-
прос: стоит ли так усложнять код ради повышения его производительности?
Как и всегда, ответ на него зависит от множества факторов. Перед тем как
приступать к оптимизации, всегда полезно провести несколько тестов, чтобы
определить, на самом ли деле количество итераций в формуле снизилось. В на-
шем случае для этого достаточно запустить следующий запрос, возвращающий
общее количество продаж и число уникальных комбинаций значений столб-
цов Order Date и Delivery Date:
EVALUATE
{ (
COUNTROWS ( Sales ),
COUNTROWS (
SUMMARIZE (
Sales,
Sales[Order Date],
Sales[Delivery Date]
)
)
) }
- - Результат:
- - Valuel | Value2
- - 100231 | 6073
В нашей демонстрационной модели данных оказалась 100 231 строка в таб-
лице Sales и всего 6073 уникальные комбинации из даты заказа и даты постав-
ки. Таким образом, в наиболее сложной части формулы меры Avg Delivery WD
WA нам удалось более чем на порядок снизить количество обрабатываемых
записей. А значит, написание этого кода будет вполне оправданным шагом
в плане оптимизации. В следующих главах вы научитесь точно определять, как
то или иное улучшение влияет на время выполнения запроса. Сейчас же мы
будем говорить только о сложности кода.
В нашем случае сложность меры Avg Delivery WD WA зависит от количества
комбинаций даты заказа и даты поставки, а также от среднего срока поставки
заказов. Если последний показатель будет находиться в пределах нескольких
дней, наша мера будет вычисляться очень быстро. И наоборот, если средний
срок поставки заказов у нас исчисляется годами, снижение эффективности
ГЛАВА 16 Вычисления повышенной сложности в DAX 571
меры будет налицо, ведь функция DATESBETWEEN в нашей формуле будет воз-
вращать таблицы из нескольких сотен строк.
Поскольку выходных дней в календаре гораздо меньше, чем рабочих, можно
считать именно их. С этим допущением алгоритм может быть таким:
1) вычислить разницу в днях между двумя датами;
2) подсчитать количество нерабочих дней в интервале;
3) вычесть из результата, полученного на первом шаге, результат второго.
Этот алгоритм можно реализовать следующим образом:
Avg Delivery WD NWD :=
VAR NonWorkingDays =
CALCULATETABLE (
VALUES ( 'Date'[Date] );
WEEKDAY ( 'Date'[Date] ) IN { 1; 7 };
ALL ( 'Date' )
)
VAR NumOfAllOrders =
COUNTROWS ( Sales )
VAR CombinationsOrderDeliveryDates =
SUMMARIZE (
Sales;
Sales[Order Date];
Sales[Delivery Date]
)
VAR DeliveryWeightedByNumOfOrders =
CALCULATE (
SUMX (
CombinationsOrderDeliveryDates;
VAR NumOfOrders =
CALCULATE (
COUNTROWS ( Sales )
)
VAR NonWorkingDaysInPeriod =
FILTER (
NonWorkingDays;
AND (
'Date'[Date] >= Sales[Order Date];
'Date'[Date] <= Sales[Delivery Date]
)
)
VAR NumberOfNonWorkingDays =
COUNTROWS ( NonWorkingDaysInPeriod )
VAR DeliveryWorkingDays =
Sales[Delivery Date] - Sales[Order Date] - NumberOfNonWorkingDays + 1
VAR NumberOfWorkingDays =
NumOfOrders * DeliveryWorkingDays
RETURN
NumberOfWorkingDays
)
)
VAR AverageWorkingDays =
572 ГЛАВА 16 Вычисления повышенной сложности в DAX
DIVIDE (
DeliveryWeightedByNumOfOrders;
NumOfAUOrders
)
RETURN
AverageWorkingDays
В модели данных, используемой для этой книги, представленный код вы-
полняется медленнее, чем предыдущий. При этом он может быть более эффек-
тивным в других моделях, где сроки поставки заказов больше. Решить, какой
подход лучше использовать в конкретном случае, можно только эмпирически.
Почему в переменной NonWorkingDays используется ALL?
В последнем примере при вычислении переменной NonWorkingDays применяется функ-
ция ALL к таблице Date. При этом ранее мы не прибегали к этой функции в похожих
таблицах, которые позже использовались в качестве фильтров. Причина в том, что в пре-
дыдущих вариантах меры мы пользовались функцией DATESBETWEEN, которая сама по
себе игнорирует контекст фильтра.
При формировании матрицы таблица Date может быть отфильтрована для анализа
сокращенного периода. В этом случае для заказов с датой оформления за пределами
выбранного интервала значения будут рассчитаны неверно. Чтобы этого избежать, мы
перед подсчетом нерабочих дней избавляемся от текущего контекста фильтра по табли-
це Date.
При этом стоит отметить, что использование функции ALL здесь вовсе не обязательно.
Давайте рассмотрим следующий вариант переменной:
VAR NonWorkingDays =
CALCULATETABLE (
VALUES ( 'Date'[Date] );
NOT ( WEEKDAY ( 'Date'[Date] ) IN { 1; 7 } )
)
Здесь мы избавились от применения в CALCULATE функции ALL. И все же ALL присут-
ствует в этом выражении в неявном виде, и это легко заметить, если расширить формулу
до ее полного варианта:
VAR NonWorkingDays =
CALCULATETABLE (
VALUES ( 'Date'[Date] );
FILTER (
ALL ( 'Date'[Date] );
NOT ( WEEKDAY ( 'Date'[Date] ) IN { 1; 7 } )
)
)
Поскольку таблица Date помечена в модели данных как таблица с датами, движок
будет автоматически применять к ней функцию ALL.
И хотя мы могли бы оставить в коде такое выражение, мы бы не хотели, чтобы наша
формула выглядела таинственной и была сложной для понимания. Поэтому предпочли
более многословный, но более легкий для чтения вариант.
Также стоит помнить, что одним из лучших средств оптимизации является
предварительный расчет значений. Мы знаем, что количество рабочих дней
ГЛАВА 16 Вычисления повышенной сложности в DAX 573
между двумя датами всегда будет одинаковым вне зависимости от алгорит-
ма расчета. Мы также знаем, что в нашей модели данных есть порядка шести
тысяч уникальных комбинаций даты заказа и даты поставки. И ничто не ме-
шает нам заранее рассчитать количество рабочих дней в этих комбинациях,
сохранив результат в физической скрытой таблице. Таким образом мы сможем
избавиться от необходимости проводить этот расчет во время выполнения за-
проса - достаточно будет простой операции поиска.
Скрытую таблицу, о которой мы сказали, можно создать следующим об-
разом:
WD Delta =
ADDCOLUMNS (
SUMMARIZE (
Sales;
Sales[Order Date];
Sales[Dellvery Date]
);
"Duration"; [Avg Delivery WD WA]
)
Создав физическую таблицу, мы можем воспользоваться всеми бонусами
предварительно рассчитанных значений, изменив нашу формулу так:
Avg Delivery WD WA Precomp :=
VAR NumOfAllOrders =
COUNTROWS ( Sales )
VAR CombinationsOrderDeliveryDates =
SUMMARIZE (
Sales;
Sales[Order Date];
Sales[Delivery Date]
)
VAR DeliveryWeightedByNumOfOrders =
SUMX (
CombinationsOrderDeliveryDates;
VAR NumOfOrders =
CALCULATE (
COUNTROWS ( Sales )
)
VAR WorkingDays =
LOOKUPVALUE (
'WD Delta*[Duration];
'WD Delta*[Order Date]; Sales[Order Date];
*WD Delta*[Delivery Date]; Sales[Delivery Date]
)
VAR NumberOfWorkingDays = NumOfOrders * WorkingDays
RETURN
NumberOfWorkingDays
)
VAR AverageWorkingDays =
DIVIDE (
DeliveryWeightedByNumOfOrders;
574 ГЛАВА 16 Вычисления повышенной сложности в DAX
NumOf AUOrders
)
RETURN
AverageWorkingDays
Трудно представить, что такой серьезный уровень оптимизации может по-
требоваться при простом расчете разницы в рабочих днях между двумя дата-
ми. Но мы и не пытались как-то особенно оптимизировать эту меру. Вместо
этого мы хотели показать вам разные способы достижения одних и тех же ре-
зультатов: от наиболее интуитивно понятного до сложного, с элементами оп-
тимизации, который вряд ли пригодится вам в большинстве сценариев.
Данные о продажах и бюджетировании
в одном отчете
Рассмотрим модель данных, содержащую сведения о бюджетировании на те-
кущий год вместе с фактическими продажами. В начале года нам известны
только бюджеты по месяцам. С течением года начинают появляться цифры
фактических продаж, и нам становится интересно, во-первых, сравнить их
сданными прогнозов, а во-вторых, скорректировать цифры на следующие ме-
сяцы с учетом накопленной информации по продажам.
Чтобы смоделировать сценарий, мы удалили все продажи в базе после 15 ав-
густа 2009 года и создали таблицу Budget, содержащую цифры по бизнес-пла-
нированию на весь год. Посмотреть результат можно в отчете, показанном на
рис. 16.2.
Month Budget Amt Sales Amount
January 3,312,711.98 1,984,496.21
February 2,992,126.95 2,424,777.23
March 3,312,711.98 2,072,712.51
April 3,205,850.30 4,259,638.78
May 3,312,711.98 4,073,469.82
June 3,205,850.30 5,081,121.32
July 3,312,711.98 3,297,393.79
August 3,312,711.98 1,632,927.46
September 3,205,850.30
October 3,312,711.98
November 3,205,850.30
December 3,312,711.98
Total 39,004,512.00 24,826,537.11
Рис. 16.2 Продажи остались по август,
а прогноз сделан на весь год
Зададимся следующим вопросом. Фактические продажи с начала года по
15 августа составили около 24 млн. Какими будут итоговые продажи за год,
если принять во внимание как фактические данные, так и прогнозируемые?
ГЛАВА 16 Вычисления повышенной сложности в DAX 575
Учтите, что данные о продажах у нас есть только по 15 августа, а значит, в этом
месяце нам придется сочетать факт с прогнозом.
Сначала запишем в переменную дату последней продажи в модели данных.
Использовать простую функцию TODAY здесь не получится, поскольку данные
о продажах у нас содержатся в базе не по текущий день. Правильнее будет най-
ти последнюю дату продаж в таблице Sales. Функция МАХ прекрасно сработает,
но при этом важно помнить, что на результат может повлиять пользователь-
ский выбор. Например, рассмотрим следующую меру:
LastDateWithSales := МАХ ( 'Sales'[OrderDateKey] )
По разным брендам, а в общем случае и по любым другим выбранным
фильтрам эта мера должна показывать свою дату. Это видно на рис. 16.3.
Brand LastDateWithSales
A. Datum 20090814
Adventure Works 20090815
Contoso 20090814
Fabrikam 20090814
Litware 20090814
Northwind Traders 20090809
Prose ware 20090814
Southridge Video 20090814
Tailspin Toys 20090815
The Phone Company 20090814
Wide World Importers 20090814
Total 20090815
Рис. 16.3 По каждому бренду
есть своя последняя дата продажи
Правильно будет удалять все наложенные фильтры перед расчетом послед-
ней даты продаж. Таким образом мы сможем получить нужную нам дату 15 ав-
густа 2009 года. Если по какому-либо из брендов не было продаж 15 августа,
будет использоваться нулевое значение, а не бюджет по последней существу-
ющей дате продажи для этого дня. Таким образом, корректной формулой для
расчета меры LastDateWithSales будет следующая:
LastDateWithSales :=
CALCULATE (
MAX ( 'Sales'[OrderDateKey] );
ALL ( Sales )
)
Удалив фильтры с таблицы Sales (то есть с ее расширенной версии), мы тем
самым сказали мере игнорировать любые фильтры, приходящие из запроса,
а значит, возвращаемым значением в нашем случае всегда будет 15 августа
2009 года. Теперь нам необходимо написать формулу, которая будет возвращать
значение меры Sales Amount для всех дат, предшествующих последней дате про-
дажи, и Budget Amt - для последующих. Вот простая реализация такой меры:
576 ГЛАВА 16 Вычисления повышенной сложности в DAX
Adjusted Budget :=
VAR LastDateWithSales =
CALCULATE (
MAX ( Sales[OrderDateKey] );
ALL ( Sales )
)
VAR AdjustedBudget =
SUMX (
'Date';
IF (
'Date'[DateKey] <= LastDateWithSales;
[Sales Amount];
[Budget Amt]
)
)
RETURN AdjustedBudget
На рис. 16.4 показан вывод новой меры Adjusted Budget.
Month Budget Amt Sales Amount Adjusted Budget
January 3,312,711.98 1,984,496.21 1,984,496.21
February 2,992,126.95 2,424,777.23 2,424,777.23
March 3,312,711.98 2,072,712.51 2,072,712.51
April 3,205,850.30 4,259,638.78 4,259,638.78
May 3,312,711.98 4,073,469.82 4,073,469.82
June 3,205,850.30 5,081,121.32 5,081,121.31
July 3,312,711.98 3,297,393.79 3,297,393.79
August 3,312,711.98 1,632,927.46 3,342,714.28
September 3,205,850.30 3,205,850.30
October 3,312,711.98 3,312,711.98
November 3,205,850.30 3,205,850.30
December 3,312,711.98 3,312,711.98
Total 39,004,512.00 24,826,537.11 39,573,448.50
Рис. 16.4 В мере Adjusted Budget используется факт или план в зависимости от даты
На данном этапе мы можем вычислить сложность полученной меры. Внеш-
ние итерации в формуле выполняются посредством функции SUMX по таблице
Date. За год проходит 365 циклов. На каждой итерации формула проходит в за-
висимости от даты либо по таблице Sales, либо по Budget, сопровождая свои
действия преобразованием контекста. Было бы неплохо уменьшить общее
количество итераций, а заодно и число повторных преобразований контекста
и/или расчетов агрегаций в больших по размеру таблицах Sales и Budget.
На самом деле нам нет необходимости проходить по датам. Единственной
причиной для этого является то, что код будет лучше восприниматься. Немно-
го измененный алгоритм может выглядеть следующим образом.
1. Разделяем текущий выбор по таблице Date на два набора: до и после по-
следней даты продаж.
ГЛАВА 16 Вычисления повышенной сложности в DAX 577
2. Рассчитываем сумму продаж за предыдущий период.
3. Рассчитываем бюджет на будущее.
4. Суммируем значения, полученные на двух предыдущих шагах.
К тому же мы не должны рассчитывать сумму продаж только за период до
последней даты продаж. После этой даты все равно продаж нет, а значит, нет
необходимости фильтровать даты при расчете суммы продаж. Единственный
расчет, который нужно ограничить, - это бюджет. Иными словами, в формуле
может вычисляться общая сумма продаж и прибавляться вычисленный бюд-
жет по датам, превышающим последнюю дату продаж. Таким образом, мы
приходим к измененной формуле меры Adjusted Budget:
Adjusted Budget Optimized :=
VAR LastDateWithSales =
CALCULATE (
MAX ( Sales[OrderDateKey] );
ALL ( Sales )
)
VAR SalesAnount = [Sales Amount]
VAR BudgetAmount =
CALCULATE (
[Budget Amt];
KEEPFILTERS ( 'Date'[DateKey] > LastDateWithSales )
)
VAR AdjustedBudget = SalesAnount + BudgetAmount
RETURN
AdjustedBudget
Результат вычисления меры Adjusted Budget Optimized получился идентич-
ным мере Adjusted Budget, но ее сложность при этом уменьшилась. В обновлен-
ной версии нам необходимо всего по разу сканировать таблицы Sales и Budget,
причем последнюю с дополнительным ограничивающим фильтром по табли-
це Date. Заметим также, что здесь нам пришлось использовать модификатор
KEEPFILTERS. В противном случае фильтр по таблице Date переопределил бы
значения в текущем контексте фильтра, что привело бы к неправильным рас-
четам. В результате финальная версия оказалась менее простой для восприя-
тия и понимания, но с точки зрения производительности она серьезно выиг-
рала.
Как и в случае с предыдущими примерами, один и тот же алгоритм можно
реализовать в формулах совершенно по-разному. Поиск оптимального вари-
анта требует немалого опыта и понимания внутреннего устройства движка
DAX. Но и простых рассуждений о кратности выражений бывает достаточно,
чтобы провести первый этап оптимизации.
Расчет сопоставимых продаж по магазинам
Сценарий, который мы рассмотрим в данном разделе, представляет собой
довольно обширное семейство вычислений. Компания Contoso располагает
множеством магазинов по всему миру, в каждом из которых есть несколько
578 ГЛАВА 16 Вычисления повышенной сложности в DAX
отделов, специализирующихся на продаже конкретных категорий товаров.
При этом список отделов постоянно меняется: какие-то из них закрываются,
какие-то обновляются, появляются и новые. И при выполнении анализа про-
даж очень важно сравнивать только сопоставимые отделы. В противном случае
легко сделать вывод, что отдел является неприбыльным, если не учесть, что
какое-то время в выбранном периоде он просто не работал.
Концепция сравнения сопоставимых сущностей распространяется на всю
бизнес-аналитику. В нашем случае мы будем анализировать исключительно
магазины и категории товаров, по которым были продажи за все выбранные
годы. При этом в разрезе категории товаров по году должны учитываться
только те магазины, в которых осуществлялись продажи в этом году. В данном
случае вы можете использовать в качестве уровня гранулярности сравнения
месяцы или недели, при этом вам не придется целиком менять приведенный
здесь алгоритм.
Рассмотрим отчет, показанный на рис. 16.5, в котором анализируются про-
дажи по одной категории товаров (Audio) в Германии за три календарных года.
Category Audio Store Name CY 2007 CY 2008 CY 2009 Total
Contoso Baumholder Store 3,920.40 539.20 2,589.64 7,049.24
Contoso Berlin Store 1,199.92 2,754.90 3,954.82
Contoso Dusseldorf Store 1,915.07 1,999.00 3,914.07
Contoso Giebelstadt Store 994.10 1,224.00 1,499.90 3,718.00
Contoso Hofheim Store 1,019.35 3,219.84 4,239.19
Contoso koln No.1 Store 1,179.49 1,340.00 2,960.00 5,479.49
Contoso koln No.2 Store 836.05 1,204.70 2,040.75
Contoso Landstuhl Store 478.77 421.96 900.73
Contoso Munich Store 377.44 310.61 688.05
Contoso obamberg Store 3,491.21 2,131.20 1,783.72 7,406.13
Contoso Ramstein Store 1,925.48 1,654.80 3,580.28
Total 16,137.35 8,511.08 18,322.31 42,970.74
Рис. 16.5 Не все магазины были открыты в выбранный период,
что мешает проведению анализа
Магазин в Берлине (Berlin) был закрыт на протяжении всего 2007 года,
а в одном из двух магазинов Кельна (Koln) в 2008 году был ремонт, так что он
полноценно работал только два года из трех. Чтобы сравнение продаж отра-
жало истинную картину происходящего, необходимо ограничить анализ теми
магазинами, которые работали постоянно.
И хотя правила определения сопоставимости в зависимости от конкретных
требований могут быть самыми разными, хорошей практикой является сохра-
нение статусов сопоставимости сущностей в отдельной таблице. В этом случае
изменения в бизнес-логике не будут негативно сказываться на скорости вы-
полнения запросов - обновляться будет только таблица со статусами. В нашем
ГЛАВА 16 Вычисления повышенной сложности в DAX 579
примере мы создадим таблицу StoresStatus и будем хранить в каждой строке
комбинацию из года, категории товаров и магазина вместе с указанием ста-
туса, который будет принимать два значения: Open (Открыто) или Closed (За-
крыто). На рис. 16.6 показан вывод статусов по немецким магазинам, при этом
показаны только статусы Open, a Closed скрыты для лучшего восприятия.
Category
Audio
Store Name CY 2007 CY 2008 CY 2009 Total
Contoso Baumholder Store Open Open Open Open
Contoso Berlin Store Open Open
Contoso Dusseldorf Store Open Open
Contoso Giebelstadt Store Open Open Open Open
Contoso Hofheim Store Open Open
Contoso koln No.1 Store Open Open Open Open
Contoso koln No.2 Store Open Open
Contoso Landstuhl Store Open Open
Contoso Munich Store Open Open
Contoso obamberg Store Open Open Open Open
Contoso Ramstein Store Open Open
Рис. 16.6 В таблице StoresStatus хранится информация о том,
была ли в конкретном магазине открыта продажа данной категории товаров в этот год
Наибольший интерес представляет последний столбец, из которого понят-
но, что на протяжении всех трех лет были открыты всего четыре магазина.
А значит, по категории товаров Audio можно проводить сравнительный анализ
исключительно по этим четырем магазинам. При изменении выбора дат будут
обновлены и статусы. Например, если в текущем выборе оставить только 2007
и 2008 годы, статусы магазинов изменятся, что видно по рис. 16.7.
Мера для определения сопоставимых продаж должна делать следующее:
определять список магазинов, которые были открыты во все отчетные
годы и по всем категориям товаров;
использовать результат вычисления первого шага для фильтрации меры
Amount, тем самым ограничивая значения только теми магазинами и ка-
тегориями товаров, по которым были продажи в отчетные годы.
Прежде чем двигаться дальше, необходимо провести тщательный анализ
модели данных. Диаграмма модели представлена на рис. 16.8.
Давайте сделаем несколько замечаний по этой модели данных:
таблицы Date и StoreStatus объединены слабой связью «многие ко мно-
гим» по столбцу Year, при этом кросс-фильтрация направлена в сторо-
ну таблицы StoreStatus. Таким образом, таблица Date фильтрует таблицу
StoreStatus, но не наоборот;
связь между таблицами Product Category и StoreStatus - обычная, типа
«один ко многим»;
580 ГЛАВА 16 Вычисления повышенной сложности в DAX
все остальные связи в модели данных также «один ко многим» с однона-
правленной кросс-фильтрацией, как и в большинстве примеров из этой
книги;
в таблице StoreStatus содержится по одной строке для каждой комбина-
ции из магазина, категории товаров и года. При этом статус может при-
нимать два значения: Open или Closed. Иными словами, в этой таблице
нет никаких пропусков. Это важный факт для снижения сложности фор-
мулы.
Category
Audio
Store Name CY 2007 CY 2008 Total
Contoso Baumholder Store Open Open Open
Contoso Berlin Store Open
Contoso Dusseldorf Store Open
Contoso Giebelstadt Store Open Open Open
Contoso Hofheim Store Open
Contoso koln No.1 Store Open Open Open
Contoso koln No.2 Store Open
Contoso Landstuhl Store Open Open Open
Contoso Munich Store Open
Contoso obamberg Store Open Open Open
Contoso Ramstein Store Open Open Open
Рис. 16.7 В столбце Total учитываются статусы магазина по всем выбранным годам
Рис. 16.8 В таблице StoreStatus хранятся статусы для всех комбинаций годов
и категорий товаров
ГЛАВА 16 Вычисления повышенной сложности в DAX 581
На первом шаге определим, какие отделы были открыты на протяжении всех
выбранных в отчете лет. Для этого необходимо отфильтровать таблицу Stores-
Status по указанной категории товаров и всем выбранным годам. Если после
этого во всех оставшихся строках статус будет иметь значение Open, значит,
отдел был открыт на протяжении всего выбранного периода. Если же среди
статусов будут попадаться значения Closed, это будет означать, что в какие-то
периоды времени анализируемый отдел не работал. Следующий запрос позво-
ляет выполнить это вычисление:
EVALUATE
VAR StatusGranularity =
SUMMARIZE (
Receipts,
Store[Store Name],
'Product Category'[Category]
)
VAR Result =
FILTER (
StatusGranularity,
CALCULATE (
SELECTEDVALUE ( StoresStatus[Status] ),
ALLSELECTED ( 'Date'[Calendar Year] )
) = "Open"
)
RETURN
Result
В запросе выполняются итерации по комбинациям магазин/категория,
и для каждого сочетания проверяется значение статуса. В случае если столбец
StoreStatus[Status] будет содержать больше одного статуса, функция SELECTED-
VALUE вернет пустое значение, что автоматически исключит текущую пару из
результата фильтра.
Определившись с отделами, которые работали на протяжении всех лет в от-
четном периоде, мы можем использовать полученный набор данных в качест-
ве аргумента фильтра в функции CALCULATE следующим образом:
OpenStoresAmt :=
VAR StatusGranularity =
SUMMARIZE (
Receipts;
Store[Store Name];
'Product Category'[Category]
)
VAR OpenStores =
FILTER (
StatusGranularity;
CALCULATE (
SELECTEDVALUE ( StoresStatus[Status] );
ALLSELECTED ( 'Date'[Calendar Year] )
) = "Open"
)
582 ГЛАВА 16 Вычисления повышенной сложности в DAX
VAR AmountLikeForLike =
CALCULATE (
[Amount];
OpenStores
)
RETURN
AmountLikeForLike
При выводе в матрицу отчета эта мера даст результат, показанный на
рис. 16.9.
Category
CountryRegion Store Type
Audio
Germany
Store
Store Name CY 2007 CY 2008 CY 2009 Total
Contoso Baumholder Store 3,920.40 539.20 2,589.64 7,049.24
Contoso Giebelstadt Store 994.10 1,224.00 1,499.90 3,718.00
Contoso koln No.1 Store 1,179.49 1,340.00 2,960.00 5,479.49
Contoso obamberg Store 3,491.21 2,131.20 1,783.72 7,406.13
Total 9,585.19 5,234.40 8,833.26 23,652.85
Рис. 16.9 Мера OpenStoresAmt позволяет получить результат только для магазинов,
которые были открыты на протяжении всего отчетного периода
Как видите, магазины, которые работали на протяжении выбранного интер-
вала с перебоями, пропали из отчета. Вам очень важно освоить эту технику,
поскольку она является одной из самых мощных в DAX. Возможность создать
временную таблицу с отфильтрованными данными и затем использовать ее
для ограничения итоговых расчетов является основой многих сложных вычис-
лений в этом языке.
В нашем примере мы использовали вспомогательную таблицу, в которой со-
хранили информацию о статусе магазинов по годам. Мы могли добиться тако-
го же результата, анализируя таблицу Receipts и делая выводы о статусе магази-
нов по их фактическим продажам. Если продажи были, можно предположить,
что магазин был открыт. К сожалению, обратное утверждение будет верно не
всегда. Отсутствие продаж за определенный период времени еще не говорит
о том, что соответствующий отдел был закрыт. В худшем для магазина сцена-
рии нулевые продажи могут свидетельствовать о полном отсутствии спроса на
эту категорию товаров, несмотря на то что отдел все это время работал.
Последнее замечание скорее относится к области моделирования данных,
чем к DAX, но мы посчитали нужным об этом сказать. Если вы хотите извле-
кать информацию о статусах магазинов непосредственно из таблицы Receipts,
нужно еще немного поработать над запросом.
Ниже мы приведем версию меры OpenStoresAmt без создания вспомога-
тельной таблицы StoresStatus. Для каждой пары магазина и категории товаров
в формуле выполняется проверка на равенство между количеством лет, в ко-
торые были продажи, и количеством выбранных лет в отчете. Если продажи
ГЛАВА 16 Вычисления повышенной сложности в DAX 583
были только для двух годов из трех, мы будем считать, что в этот год отдел был
закрыт. Так может выглядеть реализация этого вычисления:
OpenStoresAmt Dynamic :=
VAR SelectedYears =
CALCULATE (
DISTINCTCOUNT ( 'Date'[Calendar Year] );
CROSSFILTER ( Receipts[SaleDateKey]; 'Date'[DateKey]; BOTH );
ALLSELECTED ()
)
VAR StatusGranularity =
SUMMARIZE (
Receipts;
Store[Store Name];
'Product Category'[Category]
)
VAR OpenStores =
FILTER (
StatusGranularity;
VAR YearsWithSales =
CALCULATE (
DISTINCTCOUNT ( 'Date'[Calendar Year] );
CROSSFILTER ( Receipts[SaleDateKey]; 'Date'[DateKey]; BOTH );
ALLSELECTED ( 'Date'[Calendar Year] )
)
RETURN
YearsWithSales = SelectedYears
)
VAR AmountLikeForLike =
CALCULATE (
[Amount];
OpenStores
)
RETURN
AmountLikeForLike
Сложность этой версии меры получилась выше, чем у предыдущей. Факти-
чески мы вынуждены распространять контекст фильтра с таблицы Receipts на
таблицу Date для каждой категории товаров, чтобы вычислить количество лет
с продажами. Чаще всего в таблице Receipts строк будет гораздо больше, чем
во вспомогательной таблице со статусами магазинов, а значит, представлен-
ная мера будет вычисляться медленнее, чем версия с использованием таблицы
StoresStatus. Но, по сути, единственное отличие между этими двумя версиями
меры состоит в содержимом функции FILTER. Вместо того чтобы анализиро-
вать содержимое вспомогательной таблицы, мы сканируем физическую табли-
цу Receipts. Однако в целом шаблон вычисления остался прежним.
Еще одной важной деталью применительно к этой мере является способ вы-
числения переменной SelectedYears. Здесь нам не подойдет выбор всех отме-
ченных лет при помощи функции DISTINCTCOUNT, ведь нам нужно получить
не просто все годы, а только те из них, в которые были продажи. Например,
если в таблице Date представлено десять лет, а продажи были только в трех из
584 ГЛАВА 16 Вычисления повышенной сложности в DAX
них, функция DISTINCTCOUNT посчитала бы все годы без продаж и вернула по
ним пустые значения.
Нумерация последовательности событий
В данном разделе мы рассмотрим на удивление распространенный шаблон,
состоящий в необходимости проведения нумерации событий с возможностью
быстрого поиска первого из них, последнего или предшествующего текуще-
му. Представим, что нам необходимо пронумеровать заказы в рамках каждого
покупателя в базе Contoso. Результатом наших усилий должен стать дополни-
тельный вычисляемый столбец с единицей для первого заказа конкретного
покупателя, двойкой для второго и т. д. Для всех покупателей значение 1 в этом
столбце будет означать первый заказ.
Предупреждение Начнем описание решения этой задачи с важного предупреждения
о том, что некоторые из перечисленных формул могут выполняться довольно долго. Мы
показываем вам различные варианты решения сценариев, обсуждаем их и приходим
к оптимальному выбору. Если вы захотите опробовать некоторые из представленных фор-
мул в модели данных, приготовьтесь к томительному ожиданию результатов. Речь может
идти о нескольких часах и десятках гигабайт использованной оперативной памяти при-
менительно к нашей демонстрационной модели данных. Если же вы не желаете ждать
так долго, просто читайте этот раздел, в конце которого мы приведем пример наиболее
эффективной формулы.
Ожидаемый результат показан на рис. 16.10.
Name Order Number Order Position
Hill, Wyatt 20080518711016 1
Hill, Wyatt 20080821711016 2
Hill, Wyatt 20081104711016 3
Murphy, Jesse 20070316811040 1
Murphy, Jesse 20080518711040 2
Murphy, Jesse 20080821711040 3
Murphy, Jesse 20081104711040 4
Young, Chloe 20070823711015 1
Young, Chloe 20080331711015 2
Young, Chloe 20080821711015 3
Young, Chloe 20081104711015 4
Рис. 16.10 В столбце Order Position содержатся порядковые номера заказов
в разрезе покупателей
В первом варианте решения задачи мы будем опираться на подсчет коли-
чества заказов покупателя, предшествующих по дате формирования текуще-
ГЛАВА 16 Вычисления повышенной сложности в DAX 585
му заказу. К сожалению, гранулярность дня мы здесь использовать не можем,
поскольку один и тот же покупатель может разместить два заказа в день, а это
собьет нашу последовательность. Но есть и хорошая новость: у каждого заказа
существует свой уникальный номер, который увеличивается от заказа к заказу.
Таким образом, мы можем использовать столбец Order Number для подсчета
количества заказов по конкретному клиенту, предшествующих текущему.
Следующий код реализует эту логику:
Sales[Order Position] =
VAR CurrentOrderNumber = Sales[Order Number]
VAR Position =
CALCULATE (
DISTINCTCOUNT ( Sales[Order Number] );
Sales[Order Number] <= CurrentOrderNumber;
ALLEXCEPT (
Sales;
Sales[CustomerKey]
)
)
RETURN
Position
Несмотря на свою внешнюю простоту, эта формула довольно сложная.
В функции CALCULATE применяется фильтр по номеру заказа и преобразова-
ние контекста, инициированное вычисляемым столбцом. Для каждой строки
в таблице Sales движок, по сути, вынужден сканировать ту же таблицу Sales.
Таким образом, сложность формулы исчисляется возведенным в квадрат
количеством строк в таблице продаж. Например, если в таблице Sales будет
100 000 записей, формуле придется 100 000 раз пройти по 100 000 строк, что
выливается в 10 млрд итераций. В результате эта мера может вычисляться ча-
сами и способна поставить на колени любые серверные мощности.
В главе 5 мы уже говорили об использовании функции CALCULATE с сопут-
ствующим преобразованием контекста применительно к объемным табли-
цам. Всякий разработчик должен стремиться к отказу от применения этой
техники - в противном случае есть большой риск свести эффективность фор-
мул к нулю.
Можно предложить и гораздо лучшую реализацию той же идеи. Вместо ис-
пользования функции CALCULATE с дорогостоящим преобразованием кон-
текста можно создать временную таблицу со всеми комбинациями значений
столбцов CustomerKey и Order Number. После этого можно применить к получен-
ной таблице ту же логику с подсчетом количества заказов с номерами, мень-
шими текущего в рамках одного покупателя. Ниже представлена формула:
Sales[Order Position] =
VAR CurrentCustomerKey = Sales[CustomerKey]
VAR CurrentOrderNumber = Sales[Order Number]
VAR CustomersOrders =
ALL (
Sales[CustomerKey];
Sales[Order Number]
586 ГЛАВА 16 Вычисления повышенной сложности в DAX
)
VAR PreviousOrdersCurrentCustomer =
FILTER (
CustomersOrders;
AND (
Sales[CustomerKey] = CurrentCustomerKey;
Sales[Order Number] <= CurrentOrderNumber
)
)
VAR Position =
COUNTROWS ( PreviousOrdersCurrentCustomer )
RETURN
Position
Эта формула будет выполняться гораздо быстрее. Количество уникальных
комбинаций значений из столбцов CustomerKey и Order Number составляет
26 000, что намного меньше, чем прежние 100 000. Кроме того, отказ от преоб-
разования контекста позволит оптимизатору построить более эффективный
план выполнения запроса.
Вычислительная сложность этой формулы по-прежнему высока, да и код
получился не самым простым для восприятия. Лучше всего подобные задачи
с нумерацией решать при помощи специальной функции RANKX. Эта функция
применяется для выполнения ранжирования значений в таблицах и наиболее
эффективно нумерует строки. Фактически порядковый номер заказа в нашем
случае совпадает с возрастающим рангом заказа в рамках каждого отдельного
покупателя.
Ниже представлена реализация предыдущей формулы с использованием
функции RANKX:
Sales[Order Position] =
VAR CurrentCustomerKey = Sales[CustomerKey]
VAR CustomersOrders =
ALL (
Sales[CustomerKey];
Sales[Order Number]
)
VAR OrdersCurrentCustomer =
FILTER (
CustomersOrders;
Sales[CustomerKey] = CurrentCustomerKey
)
VAR Position =
RANKX (
OrdersCurrentCustomer;
Sales[Order Number];
Sales[Order Number];
ASC;
DENSE
)
RETURN
Position
ГЛАВА 16 Вычисления повышенной сложности в DAX 587
Функция RANKX является очень хорошо оптимизированной. В ней заложен
внутренний алгоритм сортировки, быстро работающий даже с большими на-
борами данных. Применительно к демонстрационной модели данных разни-
ца в скорости выполнения между двумя последними запросами оказалась не
слишком велика, при этом глубокий анализ плана выполнения запросов по-
казал, что вариант с использованием функции RANKX является наиболее оп-
тимальным. Анализу планов выполнения запросов мы посвятим следующие
главы данной книги.
На этом примере мы вновь показали, что одну и ту же задачу можно решить
совершенно разными способами. Вариант с применением функции RANKX
может показаться наименее очевидным для новичка в DAX, поэтому мы пред-
ложили разные техники решения этого сценария. Это может стать неплохой
пищей для размышлений.
Вычисление продаж по предыдущему году
до определенной даты
В данном разделе мы немного расширим пример с использованием логики
операций со временем. Допустим, нам необходимо сравнить продажи текуще-
го и предыдущего годов и при этом за предыдущий год учитывать только даты,
предшествующие указанной. Чтобы продемонстрировать этот пример, мы
удалили из демонстрационной модели данных все продажи позже 15 августа
2009 года. Таким образом, 2009 год остался неполным, как и август этого года.
По отчету, показанному на рис. 16.11, видно, что значения по продажам пос-
ле августа 2009 года остались пустыми.
Month CY 2007 CY 2008 CY 2009 Total
January 794,248.24 656,766.69 580,901.05 2,031,915.98
February 891,135.91 600,080.00 622,581.14 2,113,797.05
March 961,289.24 559,538.52 496,137.87 2,016,965.62
April 1,128,104.82 999,667.17 678,893.22 2,806,665.20
May 936,192.74 893,231.96 1,067,165.23 2,896,589.93
June 982,304.46 845,141.60 872,586.20 2,700,032.26
July 922,542.98 890,547.41 1,068,396.58 2,881,486.97
August 952,834.59 721,560.95 338,971.06 2,013,366.60
September 1,009,868.98 963,437.23 1,973,306.21
October 914,273.54 719,792.99 1,634,066.53
November 825,601.87 1,156,109.32 1,981,711.19
December 991,548.75 921,709.14 1,913,257.89
Total 11,309,946.12 9,927,582.99 5,725,632.34 26,963,161.45
Рис. 16.11 После августа 2009 года продажи не заполнены
Когда на строках присутствуют месяцы, все ясно и понятно. Пользователь
сразу поймет, что 2009 год не завершен, а значит, сравнивать итоговые циф-
ры по нему с предыдущими годами бессмысленно. В то же время разработчи-
588 ГЛАВА 16 Вычисления повышенной сложности в DAX
ки иногда создают отчеты, которые при всей своей полезности могут вводить
пользователей в заблуждение. Давайте рассмотрим следующие две меры:
PY Sales :=
CALCULATE (
[Sales Amount];
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
Growth :=
DIVIDE (
[Sales Amount] - [PY Sales];
[PY Sales]
)
Пользователь легко может применить эти меры при построении отчета, по-
казанного на рис. 16.12, и с грустью констатирует существенное падение про-
даж по всем брендам.
Calendar Year Brand Sales Amount PY Sales Growth
CY 2009 v A. Datum 282,029.42 463,721.61 -39.18%
Adventure Works 423,63936 892,674.52 -52.54%
Contoso 1,478,194.20 2,369,167.68 -37.61%
Fabrikam 1,111,065.95 1,993,123.48 -44.26%
Litware 765,737.20 1,487,846.74 -48.53%
Northwind Traders 87,281.65 469,827.70 -81.42%
Proseware 546,032.88 763,586.23 -28.49%
Southridge Video 241,796.89 294,635.04 -17.93%
Tail spin Toys 90,391.24 97,193.87 -7.00%
The Phone Company 298,658.25 355,629.36 -16.02%
Wide World Importers 400,805.30 740,176.76 -45.85%
Total 5,725,632.34 9,927,582.99 -42.33%
Рис. 16.12 Отчет показывает снижение продаж по всем брендам
Как вы понимаете, в этом отчете 2008 и 2009 годы сравниваются неправиль-
но. В текущем 2009 году продажи учитываются только по 15 августа, тогда как
в предыдущем в расчет берется весь год целиком, включая сентябрь и после-
дующие месяцы.
Чтобы сравнивать годы корректно, необходимо учитывать в каждом из них
продажи исключительно до 15 августа - только в этом случае можно будет по-
лагаться на показатели роста или падения продаж. В общем случае все пре-
дыдущие годы должны быть ограничены последней датой продажи в текущем
году.
Как обычно, эту задачу можно решить самыми разными способами, и в этом
разделе мы рассмотрим некоторые из них. Первый подход заключается в из-
менении меры PY Sales таким образом, чтобы она принимала в расчет только
даты, предшествующие последней дате продаж. Вот один из способов написать
такое вычисление:
ГЛАВА 16 Вычисления повышенной сложности в DAX 589
PY Sales :=
VAR LastDatelnSales =
CALCULATETABLE (
LASTDATE ( Sales[Order Date] );
ALL ( Sales )
)
VAR LastDatelnDate =
TREATAS (
LastDatelnSales;
'Date'[Date]
)
VAR PreviousYearLastDate =
SAMEPERIODLASTYEAR ( LastDatelnDate )
VAR PreviousYearSales =
CALCULATE (
[Sales Amount];
SAMEPERIODLASTYEAR ( 'Date'[Date] );
'Date'[Date] <= PreviousYearLastDate
)
RETURN
PreviousYearSales
В первой переменной сохраняется последнее значение из столбца Order Date
по всем продажам. В нашем случае это будет дата 15 августа 2009 года. В сле-
дующей переменной (LastDatelnDate) мы будем хранить полученное ранее
значение с измененной привязкой данных к столбцу Date[Date]. Этот шаг нам
необходим, поскольку все функции логики операций со временем работают
исключительно с датами. Использование их со столбцами других типов может
привести к неожиданным результатам, и мы продемонстрируем это позже.
Итак, в переменной LastDatelnDate у нас содержится дата 15 августа 2009 года
с правильной привязкой данных. Функция SAMEPERIODLASTYEAR перенесет
эту дату на год назад. И наконец, функция CALCULATE рассчитает нужное нам
значение по предыдущему году с применением двух фильтров: текущего вы-
бора, сдвинутого на год назад, и всех дат ранее 15 августа 2008 года.
Результат вычисления этой меры представлен на рис. 16.13.
Calendar Year Brand Sales Amount PY Sales Growth
CY 2009 A. Datum 282,029.42 281,929.56 0.04%
Adventure Works 423,639.36 548,902.82 -22.82%
Contoso 1,478,194.20 1,486,074.44 -0.53%
Fabrikam 1,111,065.95 1,073,377.56 3.51%
Litware 765,737.20 754,046.93 1.55%
Northwind Traders 87,281.65 298,321.72 -70.74%
Proseware 546,032.88 421,903.10 29.42%
Southridge Video 241,796.89 176,612.65 36.91%
Tailspin Toys 90,391.24 63,602.42 42.12%
The Phone Company 298,658.25 221,633.71 34.75%
Wide World Importers 400,805.30 415,097.96 -3.44%
Total 5,725,632.34 5,741,502.86 -0.28%
Рис. 16.13 Теперь, когда мы взяли сопоставимые периоды,
результаты можно сравнивать
590 ГЛАВА 16 Вычисления повышенной сложности в DAX
Очень важно понимать, зачем нам в предыдущем примере понадобилось
использовать функцию TREATAS. Малоопытный разработчик DAX мог бы на-
писать эту меру следующим образом, без использования функции TREATAS:
PY Sales Wrong :=
VAR LastDatelnSales =
CALCULATETABLE (
LASTDATE ( Sales[Order Date] );
ALL ( Sales )
)
VAR PreviousYearLastDate =
SAMEPERIODLASTYEAR ( LastDatelnSales )
VAR PreviousYearSales =
CALCULATE (
[Sales Amount];
SAMEPERIODLASTYEAR ( 'Date'[Date] );
'Date'[Date] <= PreviousYearLastDate
)
RETURN
PreviousYearSales
Плохо то, что в нашей демонстрационной модели данных эти две меры вы-
дают абсолютно одинаковые результаты, а это лишний раз показывает, что
ошибка в последней формуле может оказаться не столь очевидной с первого
взгляда. В чем же здесь проблема? На выходе функция SAMEPERIODLASTYEAR
выдает таблицу, состоящую из одного столбца с такой же привязкой данных,
как в исходном столбце. Если функции SAMEPERIODLASTYEAR передать стол-
бец с привязкой к столбцу Sales [Order Date] в модели данных, то она должна
вернуть значения, входящие в список возможных значений из этого столбца.
Но, будучи столбцом в таблице Sales, Order Date может не содержать все воз-
можные даты. Например, если в уик-энд не было продаж, то эти даты не будут
присутствовать в списке возможных значений столбца Sales[Order Date]. В этом
случае функция SAMEPERIODLASTYEAR вернет пустое значение.
По рис. 16.14 видно, что произойдет с отчетом, если удалить из таблицы Sales
все транзакции за 15 августа 2008 года.
Calendar Year
CY 2009
Brand Sales Amount PY Sales Wrong Growth
A. Datum 282,029.42
Adventure Works 423,639.36
Contoso 1,478,194.20
Fabrikam 1,111,065.95
Litware 765,737.20
Northwind Traders 87,281.65
Proseware 546,032.88
Southridge Video 241,796.89
Tailspin Toys 90,391.24
The Phone Company 298,658.25
Wide World Importers 400,805.30
Total 5,725,632.34
Рис. 16.14 В выделенном фрагменте показан столбец с мерой PYSales Wrong
и пустыми значениями
ГЛАВА 16 Вычисления повышенной сложности в DAX 591
Поскольку последней датой продаж в нашей модели является 15 августа
2009 года, мы от нее смещаемся на год назад, получая дату 15 августа 2008 го-
да, которая не присутствует в столбце Sales[Order Date]. В результате функция
SAMEPERIODLASTYEAR вернет пустое значение. После этого в действие всту-
пит второй фильтр функции CALCULATE, который должен будет вернуть дату,
меньшую или равную пустому значению. Понятно, что таких дат просто не су-
ществует, в результате чего мера PY Sales Wrong ожидаемо вернет пустое зна-
чение.
В нашем примере мы намеренно удалили транзакции за 15 августа 2008 года
из таблицы Sales. На практике эта проблема может проявиться для любой даты,
если в соответствующий ей день предыдущего года не было продаж. Всегда
помните, что функции логики операций со временем призваны работать ис-
ключительно с таблицами дат. Использование их со столбцами других типов
может приводить к неожиданным результатам.
Как мы уже говорили, когда логика вычисления понятна, можно реализовать
ее самыми разными способами. Мы здесь показали только одну технику, дру-
гие варианты вы можете придумать сами.
Также хочется заметить, что такие сценарии лучше решать при наличии до-
ступа к изменению модели данных. В самом деле, рассчитывать последнюю
дату продаж каждый раз, когда необходимо провести вычисление, и смещать
ее на год (или какой угодно другой срок) назад - это довольно банальное ре-
шение, не защищенное от ошибок. Гораздо лучше рассчитать заранее, должна
ли та или иная дата участвовать в сравнении, и хранить эту информацию не-
посредственно в таблице Date.
Для этого можно создать вычисляемый столбец в таблице дат, показываю-
щий, должна текущая дата включаться в сравнение с предыдущим годом или
нет. В нашем случае все даты до 15 августа получат значение TRUE, а после
15 августа - FALSE.
Формула для расчета вычисляемого столбца может быть такой:
'Date'[IsConparable] =
VAR LastDatelnSales =
MAX ( Sales[Order Date] )
VAR LastMonthlnSales =
MONTH ( LastDatelnSales )
VAR LastDaylnSales =
DAY ( LastDatelnSales )
VAR LastDateCurrentYear =
DATE ( YEAR ( 'Date'[Date] ); LastMonthlnSales; LastDaylnSales )
VAR DatelncludedlnConpare =
'Date'[Date] <= LastDateCurrentYear
RETURN
DatelncludedlnConpare
Создав вычисляемый столбец, мы можем изменить формулу для меры PY
Sales следующим образом:
PY Sales :=
CALCULATE (
[Sales Anount];
592 ГЛАВА 16 Вычисления повышенной сложности в DAX
SAMEPERIODLASTYEAR ( 'Date'[Date] );
'Date'[IsConparable] = TRUE
)
Этот код не только легче читать и отлаживать, он и работать будет гораз-
до быстрее предыдущей реализации. Причина в том, что нам больше не надо
каждый раз тратить время на поиск последней даты продажи в таблице Sales,
смещать на год назад и применять к модели данных в качестве фильтра. Об-
новленный код меры состоит из простой функции CALCULATE, проверяющей
в одном из фильтров булево значение. На этом примере мы хотели показать,
что иногда сложную логику из фильтра можно переместить в вычисляемый
столбец, расчет которого происходит на этапе обновления данных, а не во вре-
мя ожидания пользователем формирования отчета.
Заключение
В данной главе вы не познакомились ни с одной новой функцией языка DAX.
Вместо этого мы поставили себе цель донести до вас, что любую задачу можно
решить множеством способов. При этом мы не касались внутреннего устрой-
ства движка - важной темы, усвоение которой необходимо для перехода к во-
просам оптимизации. Но даже беглого анализа кода и симуляции его поведе-
ния часто бывает достаточно для выбора более эффективной формулы расчета.
Помните, что эта глава не была посвящена готовым шаблонам. Вы можете
пользоваться этими наработками в своих проектах, но не стоит считать пред-
ставленные здесь формулы оптимальными. Мы лишь хотели, чтобы вы взгля-
нули на одни и те же сценарии под разными углами.
В следующих главах вы узнаете, что выработать универсальные и уникаль-
ные шаблоны в DAX практически невозможно. Код, с поразительной быстро-
той работающий в одной модели данных, может быть далеко не самым опти-
мальным в другой модели или даже в той же самой, но с иным распределением
данных.
Если вы всерьез решили заняться оптимизацией кода на DAX, приготовьтесь
к погружению в недра его движка и знакомству со всеми его хитросплетения-
ми. Увлекательное и захватывающее путешествие уже ждет вас, переворачи-
вайте страницу - и в путь!
ГЛАВА 17
Движки DAX
До сих пор нашей главной целью было научить вас премудростям языка DAX.
Теперь, набравшись практики, вы должны сместить свои акценты с написания
кода, который работает, на код, который работает быстро и эффективно. А это
потребует от вас глубокого знания внутреннего устройства движка DAX. Сле-
дующие главы данной книги будут посвящены искусству измерения и улучше-
ния производительности кода на DAX.
Если говорить более конкретно, мы рассмотрим внутреннюю архитектуру
движков, отвечающих за выполнение запросов на языке DAX. Фактически за-
просы могут выполняться как в модели данных, целиком загруженной в па-
мять, так и находящейся в исходном источнике данных или сочетающей в себе
оба состояния.
Начиная с данной главы мы немного отклонимся от темы DAX и рассмотрим
низкоуровневые технические нюансы реализации продуктов, использующих
в своей основе DAX. Это очень важная тема, но вы должны понимать, что дета-
ли реализации продуктов часто меняются. Мы сделали все возможное, чтобы
рассказать о том пласте информации, который вряд ли скоро изменится, со-
блюдая при этом баланс между детальными сведениями и пользой в тех об-
ластях, которые меньше подвержены изменениям с течением времени. И все
же при современном темпе роста технологий никто не гарантирует, что наша
информация не устареет в ближайшие годы. Наиболее актуальные сведения,
как водится, можно отыскать в сети - в статьях и постах в блогах.
Новые версии движков выходят каждый месяц, и оптимизатор запросов так-
же не стоит на месте в плане построения эффективных планов выполнения за-
просов. Мы собираемся рассказать вам о том, как работают движки, а не пере-
числять правила написания эффективного кода на DAX, которые скоро могут
устареть. Иногда мы будем советовать вам тот или иной подход к решению за-
дачи, но вы всегда должны проверять, подходит ли это для вашего конкретного
сценария.
Знакомство с архитектурой движков DAX
Язык DAX используется во множестве продуктов от Microsoft, основанных на
технологии Tabular. При этом некоторые специфические возможности доступ-
ны только в определенных версиях или при особых условиях лицензирования.
Модель Tabular использует в качестве языков запросов как DAX, так и MDX.
В данном разделе мы опишем общую архитектуру модели Tabular без оглядки
на конкретные языки запросов и ограничения тех или иных версий продуктов.
594 ГЛАВА 17 Движки DAX
При формировании отчета запрос в формате языка DAX или MDX направля-
ется в модель Tabular. Обрабатывая запросы, вне зависимости от используемо-
го языка модель обращается к двум движкам:
движку формул (formula engine), обрабатывающему поступивший за-
прос, генерирующему план выполнения запроса и запускающему его;
движку хранилища данных (storage engine), извлекающему данные из
модели Tabular в ответ на запросы от движка формул. При этом движок
хранилища данных имеет две реализации:
• VertiPaq хранит в памяти периодически обновляемую из источника
копию данных;
• DirectQuery перенаправляет каждый поступивший запрос источнику
данных. При этом DirectQuery не создает копию данных.
На рис. 17.1 изображена схема обработки запросов DAX или MDX.
ЗАПРОС
(DAX/MDX)
Модель Tabular
ДВИЖОК
ВЫЧИСЛЕНИЙ
DAX
(SQL,...)
данных
Движок формул Движок хранилища данных
Рис. 17.1 Запросы обрабатываются при помощи движка формул
и движка хранилища данных
Движок формул представляет собой высокоуровневый функциональный мо-
дуль (execution unit) движка запросов модели Tabular. Он может выполнять все
операции, предписанные функциями DAX и MDX, и вычислять сложные выра-
жения на этих языках запросов. При этом во время извлечения данных из со-
ответствующих таблиц движок формул перенаправляет часть запросов движку
хранилища данных.
Запросы, поступающие на вход движка формул, могут варьироваться от прос-
того извлечения необработанных табличных данных до выполнения сложных
операций с агрегированием данных и объединением таблиц. Движок хранили-
ща данных может взаимодействовать только с движком формул. Возвращает
результат движок хранилища в несжатом формате вне зависимости от исход-
ного формата данных.
Обычно в модели Tabular для хранения данных используется либо движок
VertiPaq, либо DirectQuery. Однако сложные модели данных способны ис-
пользовать оба этих движка одновременно для одних и тех же таблиц. Выбор
в пользу того или иного типа хранения данных принимается движком на ос-
новании запроса.
Эта книга посвящена исключительно языку DAX. Но стоит помнить, что при
запросах к модели Tabular MDX использует ту же самую архитектуру. В данной
главе мы расскажем о разных типах движков хранилища данных, доступных
ГЛАВА 17 Движки DAX 595
в модели Tabular, но основное внимание уделим движку VertiPaq, поскольку он
является родным для DAX и наиболее быстрым.
Введение в движок формул
Движок формул является базовым звеном выполнения выражений на DAX.
При этом он также умеет работать и с языком MDX. По сути, движок формул
строит на основании запросов на языках DAX и MDX планы их выполнения,
представляющие собой пошаговую инструкцию физических операций с дан-
ными. В свою очередь, движок хранилища данных модели Tabular даже не зна-
ет о том, что запросы поступили из модели, поддерживающей DAX.
Каждый шаг в плане выполнения запроса соответствует определенной
операции, выполняемой движком формул. Обычно операции, выполняемые
движком формул, включают в себя объединение таблиц, фильтрацию по слож-
ным условиям, агрегацию и поиск. Чаще всего при выполнении этих операций
происходит обращение к данным в столбцах модели. В таких случаях движок
формул перенаправляет часть запроса движку хранилища данных и получает
в ответ кеш данных (datacache). Кеш данных представляет собой временную об-
ласть хранилища, созданную движком хранилища данных и предназначенную
для чтения движком формул.
Примечание Кеш данных хранится в несжатом виде. По сути, это обычные таблицы в па-
мяти, хранящиеся в несжатом формате вне зависимости оттого, каким движком хранили-
ща данных были созданы.
Движок формул всегда работает с кешем данных, возвращенным движком
хранилища, или со структурами данных, вычисленными другими операторами
движка формул. Результаты операций, выполненных движком формул, не со-
храняются в памяти для других операций даже в рамках одной сессии. Напро-
тив, кеши данных находятся в памяти и могут быть повторно использованы
другими запросами. Движок формул не располагает системой кеширования
для обмена результатами между запросами. DAX целиком и полностью пола-
гается на систему кеширования движка хранилища данных.
Наконец, движок формул является однопоточным (single-threaded). Это
означает, что операции, выполняемые движком формул, используют только
один поток и одно ядро процессора вне зависимости оттого, сколько ядер есть
в наличии. Движок формул посылает запросы движку хранилища данных по-
следовательно, по одному за раз. До некоторой степени принципы паралле-
лизма (parallelism) могут быть использованы внутри запросов, направленных
движку хранилища данных, обладающему другой архитектурой и способному
воспользоваться всеми преимуществами многопроцессорной обработки дан-
ных. Об этом мы поговорим в следующих разделах.
Введение в движок хранилища данных
В задачи движка хранилища данных входит сканирование базы данных Tabular
и создание наборов кешированных данных, необходимых для функциониро-
596 ГЛАВА 17 Движки DAX
вания движка формул. Движок хранилища данных не зависит от DAX. Напри-
мер, DirectQuery, работающий с SQL Server, использует SQL в качестве языка
движка хранилища данных. При этом язык SQL появился гораздо раньше DAX.
Хотя это может показаться странным, встроенный в модель Tabular движок
хранилища данных, именуемый VertiPaq, также не зависит от DAX. В целом же
архитектура является очень простой и понятной. Движок хранилища данных
выполняет только те запросы, которые может в соответствии со своим набо-
ром операторов. В зависимости от используемого движка хранилища данных
набор доступных операторов может варьироваться от очень ограниченного
(в случае с VertiPaq) до очень богатого (для SQL). Это оказывает влияние на
производительность запросов и выбор оптимизации, применяемой при ана-
лизе планов выполнения запросов.
Разработчик волен сам определять, какой движок хранилища данных будет
использоваться для работы с той или иной таблицей, исходя из трех доступных
вариантов:
импорт (Import): также называемый «в памяти» или VertiPaq. Содержи-
мое таблиц сохраняется движком VertiPaq с применением копирования
и реорганизации данных из источника во время обновления данных;
DirectQuery: содержимое таблиц извлекается из источника данных в мо-
мент запроса и не сохраняется в памяти локально во время обновления
данных;
смешанный (Dual): в этом режиме к таблице можно обращаться как по-
средством VertiPaq, так и при помощи DirectQuery. Во время обновления
данных таблица загружается в память, а в момент выполнения запроса
может быть также использован режим DirectQuery для загрузки самой ак-
туальной информации.
Кроме того, таблица в модели Tabular может использоваться в качестве ис-
точника агрегирования для другой таблицы. Агрегирование позволяет опти-
мизировать запросы движка хранилища данных, но не помогает при опти-
мизации слабых мест, характерных для движка формул. Агрегаты могут быть
определены как в VertiPaq, так и в DirectQuery, хотя чаще для повышения про-
изводительности они используются именно в VertiPaq.
Движок хранилища данных использует в своей работе принципы паралле-
лизма. При этом отдвижка формул запросы к нему приходят последовательно,
один за другим. Таким образом, движок формул ожидает окончания выпол-
нения одного запроса движком хранилища данных и только после этого по-
сылает следующий. В связи с этим ограничением использование принципов
параллелизма в движке хранилища данных может быть сведено на нет.
Движок хранилища данных VertiPaq
Движок хранилища данных VertiPaq является родным для DAX низкоуровне-
вым функциональным модулем. В каких-то инструментах он был официально
назван xVelocity In-Memory Analytical Engine. Но его общепринятым названи-
ем, использованным еще на этапе разработки, является именно VertiPaq. Дви-
жок VertiPaq сохраняет копию данных из источника в памяти в сжатом виде,
основываясь на структуре хранения данных в столбцах.
ГЛАВА 17 Движки DAX 597
Запросы VertiPaq используют в своей основе язык, напоминающий SQL
и именующийся xmSQL. xmSQL не является по своей сути полноценным язы-
ком запросов. Это, скорее, текстовое представление запроса движка хранили-
ща данных. Язык xmSQL призван дать разработчику представление о том, как
именно движок формул обращается к VertiPaq. Сам по себе движок VertiPaq
обладает очень ограниченным набором операторов, и если внутри запроса
сканирования данных необходимо произвести более сложные вычисления,
VertiPaq может повторно обратиться к движку формул.
Движок хранилища данных VertiPaq является многопоточным и очень эф-
фективно оперирует с данными, обладая возможностью задействовать при
этом несколько ядер процессора. При выполнении запроса движок хранилища
данных может использовать разные степени параллелизма вплоть до выделе-
ния одного потока на каждый сегмент таблицы. О сегментах мы поговорим
далее в этой главе. В связи с таким подходом к многопоточности со стороны
движка хранилища данных в полной мере воспользоваться преимуществами
параллелизма можно только в случае, если выполнение запроса подразумевает
обращение к разным сегментам. Иначе говоря, если у вас есть восемь запро-
сов к движку хранилища данных, обращающихся к одной небольшой таблице,
состоящей из одного сегмента, они будут запущены последовательно один за
другим, а не параллельно, из-за однопоточной природы взаимодействия меж-
ду движком формул и движком хранилища данных.
Система кеширования способствует сохранению результатов, произведен-
ных движком VertiPaq. При этом хранится ограниченное количество кешей
данных - обычно по 512 запросов в расчете на одну базу данных, но это число
может меняться в зависимости от версии движка. Когда движок получает за-
прос на языке xmSQL, результаты которого уже хранятся в кеше, он возвраща-
ет рассчитанные ранее данные, не выполняя при этом сканирования данных
в памяти. При этом кеширование никак не связано с вопросами безопасности
данных, поскольку система безопасности уровня строк управляет только по-
ведением движка формул, производя разные запросы xmSQL, в случае если
пользователю ограничен доступ к отдельным строкам в таблице.
Операция сканирования, производимая движком хранилища данных, обыч-
но выполняется быстрее по сравнению с аналогичной операцией от движка
формул даже при наличии доступа только к одному потоку. Причина кроется
в том, что движок хранилища данных лучше оптимизирован для выполнения
подобных операций. Кроме того, он выполняет итерации по сжатым данным,
тогда как движок формул располагает доступом лишь к кешу данных, храня-
щемуся в несжатом виде.
Движок хранилища данных DirectQuery
Движок хранилища данных DirectQuery описывает общую концепцию, при
которой данные остаются в исходном источнике, а не копируются в память,
как в случае использования движка VertiPaq. Когда движок формул посылает
запрос движку хранилища данных в режиме DirectQuery, тот пересылает его
непосредственно источнику данных на родном для него языке. В большинстве
случаев таким языком является SQL, но могут быть варианты.
598 ГЛАВА 17 Движки DAX
Движок формул знает о включении режима DirectQuery. В связи с этим он
строит совершенно другие планы выполнения запросов по сравнению с плана-
ми для VertiPaq, чтобы движок хранилища данных мог в полной мере восполь-
зоваться преимуществами языка запроса, характерного для источника данных.
Например, в языке SQL предусмотрены функции для работы с текстом вроде
UPPER и LOWER, тогда как движок VertiPaq не умеет манипулировать текстом.
Любые оптимизации движка хранилища данных с использованием режима
DirectQuery требуют проведения оптимизации и в самом источнике данных
вроде использования индексов в реляционной базе данных. Подробнее о движ-
ке DirectQuery и доступных методах оптимизации можно почитать по адресу
https://www.sqLbi.com/whitepapers/directquery-in-anaLysis-services-2O16/. Рассмат-
риваемые способы оптимизации применимы как к Power BI, так и к Analysis
Services, поскольку в их основе лежит один и тот же движок.
Процедура обновления данных
DAX работает в SQL Server Analysis Services (SSAS) Tabular, Azure Analysis Ser-
vices (применительно к данной книге это аналог SSAS), службе Power BI (как
на сервере, так и в клиентском приложении Power BI Desktop), а также в над-
стройке Power Pivot для Microsoft Excel. Чисто технически Power Pivot для Ex-
cel и Power BI используют адаптированную версию SSAS Tabular. Так что все
разговоры о разнице в движках в какой-то степени искусственны: Power Pivot
и Power BI являются аналогами SSAS, хотя SSAS работает в скрытом режиме.
В данной книге мы не будем делать различий между этими движками. Таким
образом, когда мы говорим об SSAS, читатель должен понимать, что все то же
самое относится и к Power Pivot с Power BL Различия, о которых стоит упомя-
нуть, мы перечислим в специальном разделе.
Когда SSAS загружает содержимое таблиц источника данных в память, мы
говорим, что он обрабатывает таблицу. Это происходит во время выполнения
операции обработки в SSAS и обновления данных - в Power Pivot для Excel
и Power BL Операция обработки таблицы в режиме DirectQuery просто очища-
ет внутренний кеш без обращения к источнику данных. Напротив, когда об-
работка данных выполняется в режиме VertiPaq, движок осуществляет чтение
данных из источника и преобразовывает их во внутреннюю структуру VertiPaq.
Обработка таблицы в режиме VertiPaq включает в себя следующие шаги:
1) чтение набора данных из источника и преобразование его в столбчатую
структуру VertiPaq с одновременным кодированием и сжатием каждого
столбца;
2) создание словарей и индексов для каждого столбца;
3) создание структур данных для связей;
4) расчет и компрессия значений всех вычисляемых столбцов и вычисляе-
мых таблиц.
Последние два шага совсем не обязательно должны выполняться в такой по-
следовательности. В действительности связь может базироваться на вычисля-
емом столбце, как и вычисляемый столбец - на связи в случае использования
функций RELATED или CALCULATE. Таким образом, SSAS создает сложную схе-
му зависимостей, позволяющую выполнить эти шаги в правильном порядке.
ГЛАВА 17 Движки DAX 599
В следующих разделах мы опишем эти шаги более детально. Также мы пого-
ворим о внутренних структурах, создаваемых SSAS в процессе преобразования
данных из источника в формат модели VertiPaq.
Принципы работы движка хранилища данных
VertiPaq
VertiPaq является наиболее распространенным движком хранилища данных
в моделях Tabular. Именно этот движок используется для таблиц с режимом
хранения Import (Импорт). Во многих моделях данных этот вариант хранения
таблиц является самым распространенным, а в Power Pivot для Excel - един-
ственно возможным. В сложных моделях данных для таблиц или агрегаций со
смешанным режимом хранения также предполагается использование движка
VertiPaq совместно с DirectQuery.
По этой причине понимание принципов работы движка VertiPaq крайне не-
обходимо для проведения оптимизации модели данных в плане используемой
памяти и скорости выполнения запросов. В этом разделе вы узнаете, что из
себя представляет и как работает движок хранилища данных VertiPaq.
Введение в столбчатые базы данных
VertiPaq представляет собой столбчатую базу данных (columnar database), со-
храненную в памяти. Это означает, что все данные, которыми оперирует мо-
дель, находятся в оперативной памяти компьютера. Но главной особенностью
базы данных VertiPaq является то, что в ее основе лежат не строки, а столб-
цы, а значит, она является столбчатой по своей природе. И чтобы разобрать-
ся в принципах работы VertiPaq, необходимо хорошо понимать концепцию
столбчатых баз данных.
Мы привыкли думать о таблицах как о наборах строк, где каждая строка раз-
бита на столбцы. Для примера рассмотрим таблицу Product, представленную
на рис. 17.2.
Думая о таблицах как о наборах строк, мы используем наиболее естественную
визуализацию табличной структуры. Технически такой тип хранения данных
называется строчным хранилищем (row store). В строчном хранилище данные
организованы по строкам. Если таблица сохранена в памяти, мы смеем пред-
полагать, что значение столбца Name из первой строки будет соседствовать со
значениями из столбцов ID и Color из той же строки. При этом значение Name
из второй строки будет достаточно далеко отстоять от аналогичного значения
из первой строки. И правда, между ними будут находиться значения столбцов
Color и Unit Price из первой строки, а также ID из второй. Давайте посмотрим на
схематическое размещение в памяти элементов строчного хранилища:
ID,Name,Color,Unit Price|1,Camcorder,Red,112.25|2,Camera,Red,97.50|3,Smartphone,
White,100.00|4,Console,Black,112.25|5,TV,Blue,1,240.85|6,CD,Red,39.99|7,
Touch screen,Blue,45.12|8,PDA,Black,120.25,9,Keyboard,Black,120.50
600 ГЛАВА 17 Движки DAX
Product
ID Name Color Unit Price
1 Camcorder Red 112.25
2 Camera Red 97.50
3 Smartphone White 100.00
4 Console Black 112.25
5 TV Blue 1,240.85
6 CD Red 39.99
7 Touch screen Blue 45.12
8 PDA Black 120.25
9 Keyboard Black 120.50
Рис. 17.2 Таблица Product,
состоящая из четырех столбцов
и девяти строк
Допустим, нам необходимо рассчитать сумму по столбцу Unit Price. Для этого
движок должен просканировать всю область памяти, отведенную под хранение
этой таблицы, и сложить внешне никак не связанные значения. Представьте,
как будет происходить последовательное сканирование памяти. Чтобы полу-
чить первое значение UnitPrice, движку необходимо прочитать (и пропустить)
поля ID, Name и Color из первой строки, и только затем мы попадаем в нужную
нам ячейку. И то же самое придется повторить для каждой строки. Получается,
что использование такого принципа хранения данных обязует движок читать
и игнорировать множество значений только для того, чтобы подсчитать сумму
по столбцу.
Чтение и пропуск значений занимает немало времени. Если же вы попроси-
те кого-нибудь визуально подсчитать итог по столбцу UnitPrice, он будет дей-
ствовать совсем иначе: просканирует первую строку на рис. 17.2, найдет по-
зицию столбца UnitPrice, а дальше пойдет глазами по столбцу вниз, складывая
значения в уме. Причина в том, что так делать легче, и мы экономим немало
времени, которое ушло бы на построчное сканирование таблицы.
Столбчатые базы данных изначально оптимизированы для вертикального
сканирования информации. Чтобы этого добиться, необходимо сделать так,
чтобы значения из одного столбца соседствовали друг с другом в памяти. На
рис. 17.3 таблица Product представлена в виде столбчатой базы данных.
В столбчатой базе данных каждый столбец представляет собой отдельную
структуру, физически отделенную от остальных. Так что все значения из столб-
ца Unit Price будут соседствовать друг с другом, а от столбцов Color, Name и ID
они будут находиться на расстоянии. Взгляните на схематическое расположе-
ние данных в памяти при хранении информации по столбцам:
10,1,2,3,4,5,6,7,8,9
Name,Camcorder,Camera,Smartphone,Console,TV,CD,Touch screen,PDA,Keyboard
Color,Red,Red,White,Black,Blue,Red,Blue,Black,Black
Unit Price,112.25,97.50,100.00,112.25,1240.85,39.99,45.12,120.25,120.50
В такой структуре данных вычислить сумму по столбцу Unit Price будет го-
раздо легче, ведь мы можем просто переместиться к строке, хранящей всю
информацию о ценах. В этой строке все необходимые нам значения распола-
ГЛАВА 17 Движки DAX 601
гаются бок о бок, и нам не придется сканировать всю таблицу, то есть читать
и игнорировать не нужные нам значения. Вместо этого мы за один проход по-
лучим все важные для нас цифры и сложим их.
Product Columns
ID Name Color Unit Price
1 Camcorder Red 112.25
2 Camera Red 97.50
3 Smartphone White 100.00
4 Console Black 112.25
5 TV Blue 1,240.85
6 CD Red 39.99
7 Touch screen Blue 45.12
8 PDA Black 120.25
9 Keyboard Black 120.50
Рис. 17.3 Здесь таблица Product организована по столбцам
В нашем следующем примере мы будем суммировать не все значения Unit
Price, а только те из них, которые относятся к красным (Red) товарам. Попро-
буйте выработать алгоритм самостоятельно, прежде чем читать дальше.
Это уже не такая простая задача - одним сканированием данных, принад-
лежащих столбцу Unit Price, тут не обойтись. Но разработчик мог бы проскани-
ровать данные по соседнему столбцу Color и для каждого вхождения значения
Red выбирать соответствующее ему значение из столбца Unit Price. В результа-
те мы получим все нужные нам цены из таблицы и сможем их сложить.
Хотя этот алгоритм весьма прост, он требует постоянного перескакивания
со строки на строку, и чтобы не сбиться, нам, возможно, придется держать па-
лец на последнем проверенном значении из столбца Color. Согласитесь, это
очень непохоже на оптимальный алгоритм. Дело в том, что движку при ска-
нировании постоянно придется прыгать от одной области памяти к другой,
что непременно скажется на производительности. Гораздо лучшим вариантом
(и в компьютерах такой алгоритм часто используется) будет просканировать
столбец Color, запомнить все индексы со значением Red, после чего пройти по
столбцу Unit Price и выбрать значения, находящиеся на позициях, выявленных
на первом шаге.
Этот алгоритм намного лучше предыдущего, поскольку в нем предусматри-
вается два сканирования столбцов, значения в которых располагаются рядом,
вместо того чтобы во время прохода по одному столбцу то и дело перескаки-
вать на второй и обратно. Последовательное чтение ячеек памяти выполняет-
ся гораздо быстрее, чем произвольное.
Для более сложных вычислений, таких как подсчет суммы по товарам си-
него (Blue) или черного (Black) цвета с ценой выше 50 долларов, потребуется
усложнить алгоритм. Здесь мы не сможем ограничиться сканированием од-
ного столбца для выяснения нужных нам индексов, поскольку обозначенный
602 ГЛАВА 17 Движки DAX
критерий зависит сразу от нескольких столбцов. Как обычно, лучше сначала
расписать последовательность шагов на листочке.
Простейший алгоритм в этом случае предусматривает сканирование табли-
цы не по столбцам, а по строкам. Обычно мы просматриваем таблицы имен-
но по строкам, но наше хранилище, как мы уже говорили, организовано по
столбцам. И в этом случае подсчитать сумму визуально будет гораздо проще,
чем разработать алгоритм, который к тому же будет очень «дорогим» в плане
расходования оперативной памяти. В процессе сканирования движку придет-
ся много раз перескакивать в произвольные области в таблице, и производи-
тельность пострадает в сравнении с обычным последовательным просмотром
таблицы.
Как видите, организация хранилища данных по столбцам имеет свои плю-
сы и минусы. В столбчатой базе данных значительно облегчен доступ к значе-
ниям одного столбца. Но если вам требуется провести вычисление с участием
нескольких столбцов, потребуется дополнительное время на реорганизацию
данных после сканирования столбца, чтобы можно было рассчитать нужный
результат. Даже такого простого примера оказалось достаточно, чтобы опреде-
лить основные характеристики столбчатого типа хранения данных:
доступ к одному столбцу осуществляется очень быстро: для этого требу-
ется последовательно прочитать стоящие рядом блоки памяти и выпол-
нить необходимую агрегацию;
если в выражении используется сразу несколько столбцов, алгоритм по-
иска нужного результата заметно усложнится, поскольку движку необ-
ходимо будет много раз обращаться к произвольным областям памяти
и хранить промежуточные результаты расчетов во временной таблице;
чем больше столбцов присутствует в выражении, тем сложнее будет ал-
горитм. В какой-то момент проще будет перестроить хранилище в строч-
ное из столбчатого, чтобы найти требуемое значение.
Столбчатая организация хранения данных призвана уменьшить время, тре-
буемое для чтения информации. Однако при необходимости извлекать значе-
ния сразу из нескольких столбцов одной таблицы может потребоваться реор-
ганизация данных, что негативно скажется на времени выполнения запроса.
Строчное хранилище, напротив, отличается более линейным алгоритмом ска-
нирования данных, но при этом происходит много ненужных операций чте-
ния. Как правило, снижение количества чтений из памяти ценой повышенной
нагрузки на центральный процессор считается хорошей практикой, поскольку
сегодня гораздо легче (и дешевле) увеличить быстродействие процессора, чем
снизить время ввода/вывода и доступа к памяти.
В следующих разделах вы узнаете, что при организации хранения данных по
столбцам существуют и другие способы снижения времени при сканировании
таблиц. И наиболее важной и полезной техникой, используемой движком Ver-
tiPaq, является сжатие данных.
Сжатие данных движком VertiPaq
Из предыдущего раздела вы узнали, что движок VertiPaq хранит каждый стол-
бец в виде отдельной структуры данных. Одного этого факта достаточно, что-
ГЛАВА17 Движки DAX 605
бы обеспечить данным необходимую компрессию и кодирование, о которых
рассказывается в этом разделе.
Примечание Подробности алгоритма сжатия данных от VertiPaq являются коммерче-
ской тайной, поэтому мы не можем опубликовать их в книге. Но то, о чем мы расскажем
в этом разделе, позволит вам лучше понять, что происходит внутри движка и как именно
осуществляется хранение данных.
к_____________________________________________________________________________________)
Сжатие данных позволяет движку VertiPaq экономить место в памяти, за-
нимаемое моделью данных, и это важно по двум причинам:
меньшие по размеру модели данных более эффективно используют мощ-
ности аппаратного обеспечения. Зачем тратить деньги на 1 Тбайт опера-
тивной памяти, если той же самой модели в сжатом виде вполне хватит
2S6 Гбайт? Всегда необходимо экономить оперативную память, когда это
возможно;
по меньшим по размеру моделям данных сканирование выполняется
быстрее. А этот аспект очень важен, если мы говорим о производитель-
ности. Если столбец хранится в сжатом виде, движку нужно будет проска-
нировать меньший объем оперативной памяти для чтения его содержи-
мого, что обеспечит лучшую производительность.
Кодирование на основе значений
Кодирование на основе значений (value encoding) - это первый из способов коди-
рования, который может применить движок VertiPaq для уменьшения объема
занимаемой столбцом памяти. Представьте, что в столбце хранится информа-
ция о ценах товаров в виде целочисленных значений. В столбце содержатся
разные значения, и нам нужно узнать, сколько битов понадобится, чтобы мог-
ли быть представлены все значения.
На рис. 17.4 слева представлен исходный столбец UnitPrice с максимальным
значением, равным 216. Для хранения целочисленных значений в диапазоне
от нуля до 216 потребуется 8 бит. Но простой математической операции будет
достаточно, чтобы снизить это требование до 5 бит.
В этом примере движок VertiPaq обнаружил, что вычитание из всех значений
в столбце минимального значения (194) приведет к уменьшению диапазона
хранимых чисел от нуля до 22. Для хранения значений в диапазоне от нуля до
22 требуется существенно меньше памяти, чем для хранения чисел вплоть до
216. И если вам кажется, что сэкономленные 3 бита - это не так много, умножь-
те их на количество строк в таблице, которое может исчисляться миллиардами,
и вы увидите, что экономия окажется существенной.
Но движок VertiPaq при кодировании значений в столбцах не ограничивает-
ся только вычитанием. Он способен выявлять более сложные математические
зависимости между значениями столбцов и производить соответствующие
действия для снижения объема требуемой памяти для хранения. Очевидно,
что при использовании значений из столбца движок выполняет обратную ма-
тематическую операцию для извлечения исходных значений. В зависимости
от типа преобразования это может быть сделано как до, так и после выполне-
604 ГЛАВА 17 Движки DAX
ния агрегирования. Это увеличивает нагрузку на центральный процессор, но
снижает количество операций чтения из памяти, что вполне приемлемо.
Снижение количества битов,
требуемых для хранения информации
Кодирование
по значению
Рис. 17.4 Использование простой математической операции
позволило снизить количество битов, требуемых для хранения столбца
Кодирование на основе значений применимо только к столбцам с целочис-
ленными значениями - для строк или чисел с плавающей запятой этот ме-
тод не подойдет. Помните, что движок VertiPaq хранит значения типа Currency
в DAX (также известного как Fixed Decimal Number) в виде целых чисел. Так что
к валютам, в отличие от чисел с плавающей запятой, тоже применимо кодиро-
вание на основе значений.
Кодирование при помощи хеш-таблиц
Кодирование при помощи хеш-таблиц (hash encoding), также известное как коди-
рование со словарем, - это еще одна техника, применяемая движком VertiPaq
для уменьшения памяти, требуемой для хранения значений из столбца. Она
заключается в создании словаря с уникальными значениями из кодируемого
столбца и их индексами и последующей замене исходных значений в таблице
на соответствующие индексы из словаря. На рис. 17.5 показан пример кодиро-
вания столбца Color, в котором хранятся текстовые данные, непригодные для
кодирования на основе значений.
При выполнении кодирования при помощи хеш-таблиц движок VertiPaq вы-
полняет следующие действия:
создает словарь с индексами и уникальными значениями из кодируемого
столбца;
заменяет исходные значения в столбце целочисленными значениями со-
ответствующих индексов из созданного словаря.
ГЛАВА 17 Движки DAX 605
Подмена типов данных индексами из словаря
Хеш-кодирование
ID Color
0 Red
1 White
2 Black
3 Blue
Рис 17.5 Кодирование при помощи хеш-таблиц заключается в создании словаря
и замене исходных значений на индексы
У этого метода есть ряд преимуществ:
все столбцы содержат целочисленные значения, что облегчает оптимиза-
цию внутреннего кода движка. Кроме того, это также означает, что дви-
жок VertiPaq не зависит от типов данных;
для значений в столбце потребуется минимальное количество битов, не-
обходимое для хранения индексов. В нашем примере с четырьмя уни-
кальными значениями хватит двух битов.
Эти два аспекта очень важны для понимания принципов работы движка Ver-
tiPaq. Не важно, значения какого типа хранятся в столбце: строковые, 64-бит-
ные целочисленные или числа с плавающей запятой. Все эти значения могут
быть закодированы при помощи хеш-таблиц, что приведет к одинаковому
времени, необходимому для сканирования этих столбцов, и одним и тем же
требованиям в отношении использования памяти. Единственным отличием
в этом случае будет требуемое место в памяти для хранения самого словаря.
Но в большинстве случаев объем словаря будет значительно меньше, чем объ-
ем исходного столбца.
Главным фактором при определении размера столбца является не тип дан-
ных, используемый в нем, а количество уникальных значений, иначе называ-
емое кратностью столбца. Важно помнить об этой концепции и повторять из
раза в раз: из всех характеристик столбца при проектировании модели данных
наиболее важной является его кратность.
Чем ниже кратность, тем меньше потребуется битов для хранения каждого
отдельного значения, а значит, и места в памяти столбец займет меньше. При
меньшей кратности вы не только сможете хранить больше информации, рас-
ходуя то же количество памяти, но и на сканирование таблицы при выполне-
нии агрегирования в выражениях DAX будет уходить меньше времени.
Кодирование на основе длин серий (RLE)
Кодирования при помощи хеш-таблиц и на основе значений представляют со-
бой очень мощные техники кодирования столбцов. Но есть еще одна допол-
606 ГЛАВА 17 Движки DAX
нительная техника, которую использует движок VertiPaq, - это кодирование на
основе длин серий (Run Length Encoding - RLE). Данный прием базируется на
снижении общего размера набора данных за счет исключения повторяющихся
значений. Для примера рассмотрим столбец в таблице Sales, хранящий назва-
ние квартала, в котором была сделана продажа. В этом столбце значение «01»
может повторяться подряд большое количество раз - для всех транзакций из
одного из первого квартала. В таком случае движок VertiPaq не будет хранить
в памяти все повторяющиеся значения. Вместо этого он заменит столбец на
чуть более сложную структуру данных, в которой значение будет повторяться
лишь раз, а вместе с ним будет храниться число его последовательных повто-
рений. Схематично этот тип кодирования показан на рис. 17.6.
Эффективность кодирования на основе длин серий напрямую зависит от
того, насколько часто в столбце значения повторяются подряд. В одних столб-
цах повторяемость значений очень высокая, что позволяет кодировать их
с приличным коэффициентом сжатия, в других значения характеризуются
большой изменчивостью, что не позволяет эффективно применить к ним этот
тип кодирования. На коэффициент сжатия серьезно влияет порядок сортиров-
ки столбца. Таким образом, поиск оптимального порядка сортировки является
важным шагом на этапе обновления данных, выполняемом движком VertiPaq.
Сокращение числа строк при помощи
кодирования на основе длин серий (RLE)
Count
Quarter
01 310
02 290
... ...
Рис. 17.6 Кодирование на основе длин серий заменяет повторяющиеся значения
на количество последовательных строк с этим значением
ГЛАВА 17 Движки DAX 607
Бывают и столбцы с большой степенью изменчивости данных, которые при
кодировании на основе длин серий могут занимать даже больше места в па-
мяти, чем в исходном виде. Примером такого столбца может служить первич-
ный ключ. В таком столбце каждая строка содержит уникальное значение, а это
означает, что сжатый по методу RLE столбец будет расходовать больше памяти,
чем оригинал. В подобных случаях движок VertiPaq не выполняет компрессию,
а хранит столбец в исходном виде. Таким образом, версия столбца, сохранен-
ная движком VertiPaq, никогда не превышает по размеру изначальную версию
столбца. В худшем случае их размеры будут идентичны.
В предыдущем примере мы показали действие кодирования на основе длин
серий на столбце Quarter, в котором хранятся текстовые данные. Но этот метод
может быть применен и к версии столбца, к которой ранее уже было примене-
но кодирование при помощи хеш-таблиц. Получается, что к каждому столбцу
может быть применено кодирование на основе длин серий, а также один из
двух методов кодирования: при помощи хеш-таблиц или на основе значений.
Версия таблицы, прошедшей кодирование при помощи хеш-таблиц и последу-
ющее кодирование методом RLE, будет состоять из двух отдельных сущностей:
словаря и строк с данными, как показано на рис. 17.7.
Хеш-кодирование
4 уникальных ID
Использовано 2 бита
Хранилище VertiPaq
Рис. 17.7 Метод RLE, примененный к столбцу, предварительно кодированному
при помощи хеш-таблиц
Движок VertiPaq также применяет метод RLE к столбцам, закодированным
на основе значений. В этом случае словарь будет отсутствовать, поскольку
столбец и так будет содержать закодированные на основе значений целые
числа.
608 ГЛАВА 17 Движки DAX
Перечислим факторы, влияющие на коэффициент сжатия модели Tabular,
в порядке убывания значимости.
1. Кратность столбца, что влияет на количество битов для хранения значе-
ний столбца.
2. Количество повторений, то есть распределение значений в столбце.
Столбец с большим числом повторений будет сжиматься лучше, чем
столбец с изменчивыми значениями.
3. Количество строк в таблице.
4. Тип данных столбца, влияющий только на размер словаря.
С учетом всех перечисленных факторов становится практически невозможно
предсказать степень сжатия того или иного столбца. И даже если разработчик
в силах повлиять на определенные характеристики таблицы, такие как количест-
во строк и тип данных, эти факторы все равно занимают последние места по
значимости. Как вы узнаете из следующей главы, над кратностью и количеством
повторений разработчик также может поработать. Это может положительно
сказаться на коэффициенте сжатия таблицы и быстродействии модели в целом.
Также стоит отметить, что уменьшение кратности столбца повышает ве-
роятность появления повторений значений. Например, если столбец со вре-
менем хранится на уровне гранулярности секунды, в нем будет содержаться
до 86 400 уникальных значений. Установка гранулярности на уровне часа по-
зволит не только уменьшить кратность столбца, но и приведет к появлению
большего количества повторяющихся значений. Фактически 3600 секунд будут
сконвертированы в одно значение часа, что приведет к повышению степени
сжатия столбца во время кодирования. В то же время изменение типа данных
столбца с DateTime на Integer или даже на String лишь незначительно повлияет
на размер столбца.
Перекодирование
SSAS должна принять решение о методе кодирования для каждого столбца
в модели данных. Более того, движку необходимо определить, какой способ ко-
дирования использовать: на основании значений или при помощи хеш-таблиц.
Для этого во время первого сканирования источника данных SSAS извлекает
порцию строк и выбирает метод кодирования, исходя из их содержимого.
Если тип данных обрабатываемого столбца не является целочисленным, вы-
бор очевиден - кодировать данные нужно при помощи хеш-таблиц. Для цело-
численных столбцов движок запускает эвристический алгоритм, подобный
следующему:
если значения в столбце растут равномерно, вероятно, это первичный
ключ, а значит, лучше будет применить к нему кодирование на основа-
нии значений;
если все числа в столбце укладываются в определенный диапазон значе-
ний, выбор также будет сделан в пользу кодирования на основании зна-
чений;
если числа из столбца попадают в довольно широкий диапазон значений
и при этом значительно отличаются друг от друга, лучше применить ме-
тод кодирования при помощи хеш-таблиц.
ГЛАВА 17 Движки DAX 609
Определившись с выбором, SSAS приступает к компрессии столбца выбран-
ным способом. К сожалению, иногда прямо во время кодирования - и даже на
поздних его стадиях - обнаруживается, что метод был выбран неверно. На-
пример, движок мог прочитать несколько миллионов строк, в которых значе-
ния в столбце укладывались в диапазон 100-201, что склонило бы его выбор
в пользу кодирования на основании значений. Но после нескольких миллио-
нов строк мог встретиться и так называемый выброс - допустим, значение
60 000 000. Получается, что первоначальный выбор метода кодирования ока-
зался неверным, ведь для хранения таких больших чисел потребуется немало
места. Что делать? Вместо того чтобы продолжать кодировать столбец заве-
домо неправильным способом, SSAS может принять решение изменить метод
кодирования. Это означает, что весь столбец будет перекодирован при помощи
хеш-таблиц. При этом сам процесс может занять немало времени, поскольку
SSAS придется все начинать заново.
Для больших наборов данных, где время обработки играет важную роль, луч-
ше всего, чтобы в первом анализируемом движком наборе строк распределе-
ние данных было примерно таким же, как и во всей таблице в целом. Чтобы
этого добиться, разработчик может первым разделом передать для анализа об-
разцовую выборку строк или сопроводить столбец специальным параметром
подсказки кодирования (encoding hint).
Примечание Свойство подсказки кодирования было введено только в Analysis Services
2017 и присутствует не во всех инструментах.
ч______________________________._________________________________________J
Поиск оптимального порядка сортировки
Как мы уже сказали, эффективность метода кодирования на основе длин серий
напрямую зависит от порядка сортировки таблицы. Все столбцы одной и той
же таблицы отсортированы одинаково для поддержания целостности данных
на уровне таблицы. В объемных таблицах очень важно определить оптималь-
ный порядок сортировкиданных для повышения эффективности кодирования
на основе длин серий и уменьшить занимаемое моделью место в памяти.
Во время чтения таблицы SSAS пробует разные способы сортировки данных
для повышения коэффициента сжатия. А при наличии множества столбцов это
может занимать немало времени. В связи с этим принято ограничивать SSAS
по времени, отведенному на поиск оптимального порядка сортировки. Зна-
чение этого ограничения по умолчанию может меняться в движке от версии
к версии. В момент написания данной книги ограничение в SSAS составляет
10 секунд в расчете на миллион строк. Изменить это значение можно в пара-
метре ProcessingTimeboxSecPerMRow конфигурационного файла службы SSAS.
В Power BI и Power Pivot доступа к этой настройке нет.
Примечание SSAS выполняет поиск оптимального порядка сортировки данных с ис-
пользованием эвристического алгоритма, в котором учтен и физический порядок строк на
входе.Так что хоть нам и не позволено напрямую влиять на порядок сортировки, который
в результате выберет VertiPaq для кодирования на основе длин серий, мы всегда можем
изначально отсортировать данные так, как нам угодно, и VertiPaq учтет наш порядок сор-
тировки при принятии решения.
610 ГЛАВА 17 Движки DAX
Чтобы добиться максимального коэффициента сжатия, можно установить
значение параметра ProcessingTimeboxSecPerMRow в 0. В этом случае SSAS бу-
дет продолжать искать оптимальный порядок сортировки, пока степень сжа-
тия данных не достигнет максимума. В плане экономии места в памяти это
оптимальный вариант, хотя операция обработки данных в этом случае может
продолжаться намного дольше, поскольку движок вынужден будет перебирать
все возможные варианты сортировки.
Как правило, разработчики должны помещать столбцы с наименьшим коли-
чеством уникальных значений на первое место в списке сортировки, поскольку
в таких столбцах будет много повторяющихся значений. При этом нужно пом-
нить, что поиск оптимального порядка сортировки - задача довольно сложная,
и тратить время на это необходимо только при работе с действительно объем-
ной моделью данных (не менее нескольких миллиардов строк). В противном
случае польза от такой оптимизации может оказаться не так велика.
После завершения кодирования столбцов SSAS приступает к построению
вычисляемых столбцов и таблиц, а также иерархий и связей. Иерархии и связи
представляют собой вспомогательные структуры данных, которые движок Ver-
tiPaq использует при выполнении запросов, тогда как вычисляемые столбцы
и таблицы добавляются в модель посредством выражений DAX.
Вычисляемые столбцы, как и все остальные, проходят процесс компрессии
после расчета значений. При этом между вычисляемыми столбцами и обыч-
ными есть существенная разница. Они кодируются на завершающем этапе об-
работки данных, когда работа со всеми остальными столбцами уже завершена.
Таким образом, движок VertiPaq не берет в расчет вычисляемые столбцы при
выборе оптимального порядка сортировки таблицы.
Представим, что у нас есть вычисляемый столбец типа Boolean. Посколь-
ку в этом столбце могут присутствовать только два значения, он может быть
очень эффективно сжат (для хранения значения типа Boolean достаточно всего
одного бита) и мог бы являться превосходным кандидатом на первое место
в порядке сортировки таблицы. В этом случае в столбце сначала расположи-
лись бы значения True, а следом за ними - False. Но на момент обработки это-
го вычисляемого столбца процесс поиска оптимального порядка сортировки
в таблице уже завершен, и может получиться так, что с установленным по-
рядком сортировки в нашем столбце типа Boolean значения будут очень часто
меняться (с True на False и обратно). В таком случае столбец вряд ли удастся
эффективно компрессировать.
Всякий раз, когда у вас есть выбор между вычислением значений столбца
в DAX или в источнике данных (включая Power Query), помните о том, что во
втором случае столбец может быть подвергнут более эффективному сжатию.
При этом многие другие факторы могут склонить ваш выбор в пользу вычисле-
ния столбца именно в DAX, а не в Power Query или SQL. Например, если вычис-
ляемый столбец в большой таблице зависит от значений столбца в маленькой
таблице, то при выборе DAX в случае частичного или полного обновления ма-
ленькой таблицы движок сможет пересчитать значения столбца в большой без
ее полной обработки, что будет невозможно при вычислении столбца силами
Power Query или SQL. Этот аспект также стоит учитывать при выборе опти-
мальной компрессии столбцов.
ГЛАВА 17 Движки DAX 611
Примечание Вычисляемая таблица сжимается так же, как и обычная, без побочных эф-
фектов, как в случае с вычисляемыми столбцами. При этом создание вычисляемых таблиц
может оказаться весьма дорогим удовольствием. Дело в том, что вычисляемой таблице
требуется немало места в памяти для хранения своей полной несжатой копии перед вы-
полнением компрессии. Дважды подумайте, прежде чем создавать большую вычисляе-
мую таблицу, ведь во время обновления модели она потребует приличных ресурсов.
Иерархии и связи
Как мы уже говорили в предыдущем разделе, по завершении процесса обра-
ботки таблицы SSAS приступает к построению двух вспомогательных структур
данных, представляющих собой иерархии и связи.
Существует два типа иерархий: иерархии атрибутов и пользовательские
иерархии. Эти структуры данных используются прежде всего для повышения
эффективности запросов MDX и ускорения операций поиска в DAX. Поскольку
концепция иерархий никак не представлена в языке DAX, мы не будем отдель-
но останавливаться на ней в данной книге.
Связи, напротив, играют очень важную роль в работе движка VertiPaq. И для
выполнения оптимизации модели данных необходимо хорошо понимать, как
они работают. В следующих главах мы еще коснемся роли связей при выполне-
нии запросов. Здесь же ограничимся определением связей в разрезе хранения
данных и поведения движка VertiPaq.
Связь представляет собой структуру данных для сопоставления идентифи-
каторов из одной таблицы с номерами строк в другой. Представим, что у нас
есть столбец ProductKey в таблице Sales и столбец с таким же именем в таблице
Product. Эти столбцы можно использовать для создания связи между данными
таблицами. Столбец Product[ProductKey] является первичным ключом, поэтому
движок применит к нему метод кодирования на основе значений или не будет
кодировать вовсе. И действительно, кодирование на основе длин серий не по-
зволило бы уменьшить размер столбца ввиду отсутствия в нем повторяющихся
значений. Что касается столбца Sales[ProductKey], он, скорее всего, будет зако-
дирован при помощи хеш-таблиц, поскольку в нем часто будут встречаться по-
вторы. Как видите, несмотря на одинаковое название и тип данных, внутрен-
ние структуры данных этих столбцов существенно отличаются.
Кроме того, поскольку эти столбцы используются для объединения двух
таблиц, движок VertiPaq может предположить, что они будут часто исполь-
зоваться в запросах, и при установке фильтра на таблицу Product пользова-
тель будет ожидать его распространения на таблицу Sales. Если бы движку
VertiPaq каждый раз при переносе фильтра с таблицы Product на Sales требо-
валось получать значения из столбца Product[ProductKey], искать их в словаре
для Sales[ProductKey] и извлекать соответствующие идентификаторы, процесс
сильно бы затянулся.
Для оптимизации скорости выполнения запросов движок VertiPaq хранит
связи в виде пар идентификатора и номера строки. Таким образом, располагая
идентификатором из таблицы Sales[ProductKey], движок может быстро найти
строки из таблицы Product, соответствующие связи. Связи хранятся в памяти,
как и все остальные структуры данных. На рис. 17.8 показано, как в движке Ver-
tiPaq хранится связь между таблицами Sales и Product.
612 ГЛАВА 17 Движки DAX
Sales
Amount ProductKey
25.00 1
12.50 2
2.25 3
2.50 3
14.00 4
25.00 5
Связь
Product
1 Coffee
2 Pasta
3 Tomato
BLANK BLANK
ProductKey Product
Sales[ProductKey] Product[Row Num]
Рис. 17.8 Хранение связи между таблицами Sales и Product
И хотя на первый взгляд такая структура данных кажется не самой интуи-
тивно понятной, далее в данной главе мы расскажем, как движок VertiPaq ис-
пользует связи и почему они хранятся именно в таком виде. После этого станет
очевидно, что такая структура была выбрана для повышения эффективности
выполнения запросов.
Сегментация и секционирование
Сжатие таблицы объемом в несколько миллиардов строк за один шаг - задача
очень затратная как с точки зрения расходования памяти, так и в плане затра-
чиваемого времени. Поэтому обработка таблиц происходит не одним блоком.
Вместо этого в процессе обработки SSAS разбивает таблицу по умолчанию на
сегменты (segment) по 8 млн строк каждый. Прочитав один сегмент таблицы,
движок приступает к его обработке, параллельно принимаясь за чтение следу-
ющего.
В SSAS можно настроить под себя количество строк в одном сегменте пу-
тем изменения параметра DefaultSegmentRowCount в конфигурационном фай-
ле службы (или в свойствах сервера в Management Studio). В Power BI Desktop
и Power Pivot размер сегмента установлен в 1 млн строк по умолчанию и не
может быть изменен.
Операция сегментации важна сразу по нескольким причинам, включая ис-
пользование принципов параллелизма при выполнении запросов и эффектив-
ность сжатия столбцов. Осуществляя запрос к таблице, движок VertiPaq исполь-
зует сегменты в качестве основы параллельного выполнения, задействуя при
сканировании одно ядро процессора на каждый сегмент. По умолчанию при
обработке таблицы размером меньше 8 млн строк SSAS всегда использует один
поток, в то время как принципы параллелизма начинают действовать при ска-
нировании более объемных таблиц.
ГЛАВА 17 Движки DAX 615
Чем больше размер сегмента, тем выше коэффициент сжатия. Используя
возможность анализа большого количества строк за один шаг, движок VertiPaq
может добиваться высокой эффективности сжатия. На объемных таблицах
очень важно проверять разные значения размера сегмента, контролируя при
этом расходуемую память, чтобы выйти на оптимальные показатели компрес-
сии. Помните, что увеличение размера сегмента может негативно сказаться
на времени обработки таблицы: чем больше сегмент, тем медленнее будет вы-
полняться обработка.
Хотя словарь, создаваемый во время кодирования столбца, единый для всей
таблицы, вопрос с выделением памяти для хранения его значений решается
на уровне каждого отдельного сегмента. Например, если столбец насчитывает
1000 уникальных значений, но в обрабатываемом сегменте уникальных значе-
ний только два, то при кодировании столбца в этом конкретном сегменте для
хранения значений будет использован 1 бит.
Если сегменты небольшие по размеру, во время выполнения запросов принци-
пы параллелизма будут использоваться активнее. Это не всегда хорошо. Да, опе-
рация сканирования будет выполняться быстрее при участии нескольких ядер
процессора, но по окончании сканирования движку VertiPaq понадобится боль-
ше времени, чтобы собрать промежуточные результаты, вычисленные в разных
потоках. Если сегменты будут слишком маленькими, итоговое время на переклю-
чение задач и финальную агрегацию может превысить время, необходимое для
сканирования, что негативно скажется на скорости выполнения запросов.
Если таблица состоит всего из одной секции (partition), к обработке ее пер-
вого сегмента движок будет подходить по-особенному. Фактически первый
сегмент таблицы может превышать по количеству строк значение, указанное
в параметре DefaultSegmentRowCount. Движок VertiPaq начинает разбивать таб-
лицу на сегменты только в случае, если количество строк в ней превышает
удвоенное значение параметра DefaultSegmentRowCount. Это правило не рас-
пространяется на секционированные таблицы (partitioned table). Если таблица
секционирована, размер сегментов в ней не должен превышать значение из
параметра по умолчанию. Например, в SSAS несекционированная таблица объ-
емом 10 млн строк будет сохранена как единый сегмент. В то же время таблица,
состоящая из 20 млн строк, будет разбита на три сегмента: два по 8 млн строк
и один с остатком из 4 млн строк. Применительно к Power BI Desktop и Power
Pivot движок VertiPaq использует операцию сегментирования для таблиц, объ-
ем которых превышает 2 млн строк.
Размер сегмента не может превышать размер секции. Если схема секциони-
рования, применяемая в модели данных, предусматривает секции размером
1 млн строк, то все сегменты в модели будут совпадать по размеру с секциями.
Новички часто допускают ошибку, связанную с чрезмерным секционировани-
ем таблиц, в попытке повышения производительности. Увы, эффект зачастую
получается обратный - слишком мелкие секции обычно снижают быстродей-
ствие модели данных.
Использование представлений динамического управления
SSAS может получать всю необходимую информацию о модели данных при
помощи представлений динамического управления (Dynamic Management
614 ГЛАВА 17 Движки DAX
Views - DMV). Из этих представлений можно почерпнуть информацию о сте-
пени компрессии модели, занимаемом разными столбцами и таблицами месте
в памяти, числе сегментов в таблице или количестве битов, используемых для
хранения данных в столбце в разных сегментах.
К представлениям динамического управления можно обращаться непосред-
ственно из SQL Server Management Studio, но мы настоятельно советуем вам
использовать DAX Studio. Этот инструмент предлагает упрощенный доступ
к представлениям без необходимости запоминать их названия или каждый
раз пользоваться этой книгой. Но наиболее эффективным инструментом об-
ращения к представлениям динамического управления является бесплатный
пакет VertiPaq Analyzer (http://www.sqLbi.com/tooLs/vertipaq-anaLyzer/), организу-
ющий информацию из представлений в виде удобных отчетов, как показано
на рис. 17.9.
Row Labels ’ Cardinality Table Size Columns Total Size Data Size Dictionary Size Columns Hlerai Encotfing
♦ ExchangeRate 773 63,144 63,064 6,224 45,520 11,320 Many
♦ Geography 674 155,624 141,736 2,640 127,736 11,360 Many
-Inventory 8,013,099 106,976,244 108,973,568 76,679,640 188,556 32,105,392 Many
Aging 7 15,760 14,312 1,372 96 HASH
CurrencyKey 1 1,476 64 1,348 64 HASH
Datekey 156 4,240,320 4,229,328 9,696 1,296 HASH
DaysinStock 115 7,126,300 7,122,512 2,828 960 HASH
ETLLoadID 1 1,476 64 1,348 64 HASH
InventoryKey 6,013,099 53,420,840 21,368,304 120 32,052,416 VALUE
Lo adD ate 1 1,416 64 1,288 64 HASH
MaxDayinStock 60 6,412,616 6,410,504 1,584 528 HASH
MinDaylnStock 55 6,412,484 6,410,440 1,564 480 HASH
Рис. 17.9 VertiPaq Analyzer в удобном виде отображает информацию о модели данных
Хотя в этих представлениях используется SQL-подобный язык, полноцен-
ный синтаксис SQL им недоступен. Представления динамического управления
не являются частью SQL Server. Они лишь предоставляют доступ в удобном
виде к информации о состоянии SSAS и моделей данных.
Существуют разные представления динамического управления, и в общем
виде их можно разделить на две категории:
представления SCHEMA: в этих представлениях хранится информация
о метаданных SSAS, включая названия баз данных, таблиц и столбцов.
Они используются для извлечения информации о типах данных, именах
объектов и т. д., включая статистику о количестве строк в таблице и уни-
кальных значений в столбце;
представления DISCOVER: эти представления дают информацию
о движке SSAS и/или статистические данные об объектах базы данных.
Например, можно использовать представления из этой категории для
перечисления ключевых слов DAX, определения количества соединений,
открытых сессий или запущенных трассировок.
Мы не будем детально разбирать все представления динамического управ-
ления, поскольку эта тема выходит за рамки данной книги. Подробную инфор-
мацию об этих представлениях можно найти в разделе документаций на сайте
Microsoft. Мы же покажем лишь пару примеров использования наиболее часто
ГЛАВА 17 Движки DAX 615
применяемых представлений, в которых содержится полезная информация
о базах данных, задействованных DAX.
Одним из самых полезных представлений для отслеживания расхода памяти
объектами SSAS является DISCOVEROBJECTMEMORY USAGE. Это представ-
ление возвращает информацию обо всех объектах во всех базах данных в эк-
земпляре SSAS. Область видимости представления DISCOVEROBJECTMEMO-
RY USAGE не ограничивается текущей базой данных. Например, следующий
запрос может быть запущен в DAX Studio или SQL Server Management Studio:
SELECT * FROM $SYSTEM.DISCOVER_OBJECT_MEMORY_USAGE
На рис. 17.10 показан фрагмент результата предыдущего запроса. Полный
вывод содержит намного больше строк и столбцов, так что на его анализ может
уйти немало времени.
OBJECT_PARENT_PATH OBJECT JD OBJECT_MEMORY_SHRINKABLE OBJECT_MEMORY_NONSHRINKABLE OBJECT.VER
GAPXAnalysisServicesWor... HSDaxBook Sales... 0 0
MessageManager French (France) 0 37084 13796’
Global TMPersistenceSQ... 0 368 10477:
Global 0 6357634
GAPXAnalysisServicesWor... ID_TO_POS 0 0
Рис. 17.10 Частичный вывод представления DISCOVER_OBJECT_MEMORY_USAGE
В результат этого представления включено очень много строк, что затрудня-
ет его анализ. Структура вывода организована в виде иерархии родитель/пото-
мок, начиная от имени экземпляра и заканчивая информацией о конкретных
столбцах. Поскольку воспринимать результат этого представления визуально
очень трудно, можно создать на основании данного запроса модель данных
в Power Pivot, также организовав иерархическую структуру родитель/потомок,
что позволит удобно просматривать информацию о состоянии памяти экземп-
ляра. Каспер Де Йонг (Kasper De Jonge) в своем блоге разместил рабочую книгу,
в которой реализован этот подход. Скачать ее можно по адресу http://www.pow-
erpivotbLog.nL/what-is-using-aLL-that-memory-on-my-anaLysis-server-instance/.
Также популярными представлениями для отслеживания текущего состоя-
ния движка Tabular являются DISCOVERSESSIONS, DISCOVER CONNECTIONS
и DISCOVERCOMMANDS. Эти представления динамического управления
предоставляют информацию об активных сессиях, соединениях и выполнен-
ных командах. Данными из этих представлений пользуется программа с от-
крытым исходным кодом SSAS Activity Monitor, которую можно скачать по
адресу https://github.com/RichieBzzzt/SSASActivityMonitor/tree/master/DownLoad.
Этот инструмент открывает доступ к расширенной информации в очень удоб-
ном виде.
Кроме того, существуют отдельные представления для отслеживания ин-
формации о распределении данных в столбцах и таблицах, а также о памяти,
используемой сжатыми столбцами. Это представления TMSCHEMA COLUMN-
STORAGES и DISCOVER STORAGE TABLE COLUMNS. Первое из них является бо-
лее новым, тогда как второе сохраняет свое место для совместимости с преды-
дущими версиями движка (уровень совместимости 1103 и ниже).
616 ГЛАВА 17 Движки DAX
Наконец, очень полезным представлением для анализа зависимостей выра-
жений является DISCOVER_CA.LC_DEPENDENCY. Это представление может быть
использовано для просмотра зависимостей между различными вычислениями
в модели данных, включая вычисляемые столбцы и таблицы, а также меры. На
рис. 17.11 представлен фрагмент результирующего набора этого представления.
OBJECT.TYPE TABLE OBJECT EXPRESSION REFERENCED.OBJECT.TYPE REFERENC ED.TABLE REFERENCED.OBJECT
MEASURE Sales Sales Amo . . SUMX ( Sales. SalesIQuantity] • SalesfNet Price]) COLUMN Sales Quantity
MEASURE Sales Sales Amo.. . SUMX ( Sales. Sales [Quantity] ’ Sales[Net Price]) COLUMN Sales Net Price
MEASURE Sales Total Cost SUMX ( Sales. SalesIQuantity] • Sales[Unit Cost]) TABLE Sales Sales
MEASURE Sales Total Cost SUMX ( Sales. SalesIQuantity] • Sales[Unit Cost]) COLUMN Sales Quantity
MEASURE Sales Total Cost SUMX ( Sales. SalesIQuantity] • Sales[Unit Cost]) COLUMN Sales Unit Cost
Рис. 17.11 Частичный вывод представления DISCOVER_CALC_DEPENDENCY
Использование связей в движке VertiPaq
Когда на основании выражения DAX генерируются запросы движку хранилища
данных VertiPaq, наличие связей в модели данных позволяет быстро распро-
странять текущий контекст фильтра с одной таблицы на другую. Внутреннюю
реализацию связей в движке VertiPaq знать просто необходимо, поскольку свя-
зи могут оказывать влияние на скорость выполнения запросов, несмотря на то
что большинство вычислений происходит в движке хранилища данных.
Чтобы понять, как работают связи, начнем с анализа запроса, включающего
только одну таблицу Sales:
EVALUATE
ROW (
"Result"; CALCULATE (
COUNTROWS ( Sales );
Sales[Quantity] > 1
)
)
-- Result
-- 20016
Разработчик, привыкший к работе с реляционными базами данных, может
предположить, что движок при выполнении этого запроса будет осуществлять
итерации по таблице Sales, проверять значение столбца Quantity для каждой
записи и увеличивать возвращаемое значение, если Quantity больше единицы.
Но движок VertiPaq действует лучше: он сканирует только столбец Quantity, по-
скольку этого достаточно для получения общего количества строк в таблице.
Таким образом, движок может ограничиться проходом всего по одному столб-
цу при выполнении данного запроса.
Если переписать запрос, задействуя в нем столбец из другой таблицы в ка-
честве фильтра, сканирования одного столбца будет недостаточно для полу-
чения результата. Взгляните на следующий запрос, в котором мы подсчитаем
количество строк в таблице Sales, относящихся к товарам бренда Contoso:
ГЛАВА 17 Движки DAX 617
EVALUATE
ROW (
"Result"; CALCULATE (
COUNTROWS ( Sales );
'Product'[Brand] = "Contoso"
)
)
- - Result
- - 37984
На этот раз мы задействовали уже две таблицы: Sales и Product. Вычисление
результата этого запроса потребует больших усилий. Поскольку фильтр мы на-
кладываем на таблицу Product, а значения агрегируем по таблице Sales, скани-
рованием одного столбца здесь будет не обойтись.
Если вы не очень хорошо знакомы со столбчатыми базами данных, то мо-
жете подумать, что для выполнения этого запроса движку необходимо будет
пройти итерациями по таблице Sales, на каждой строке проследовать по свя-
зи к таблице Product и добавить к итоговому результату единицу, если бренд
Contoso, и ноль, если нет. В общем, вы могли бы представить себе алгоритм,
подобный следующему выражению DAX:
EVALUATE
ROW (
"Result"; SUMX (
Sales;
IF ( RELATED ( 'Product'[Brand] ) = "Contoso"; 1; 0 )
)
)
- - Result
- - 37984
И хотя это довольно простой алгоритм, все же он несколько сложнее, чем
можно было ожидать. Помня о столбчатой природе движка VertiPaq, можно
предположить, что в этот запрос вовлечено три столбца:
столбец Product[Brand] используется для фильтрации таблицы Product;
столбец Product[ProductKey] служит для объединения таблиц Product
и Sales;
столбец Sales[ProductKey] задействован в связи на стороне таблицы Sales.
Проходить с итерациями по столбцу Sales[ProductKey], искать номер стро-
ки в таблице Product, сканируя столбец Product[ProductKey], а затем проверять
бренд в столбце Product[Brand] будет довольно долго и дорого. К тому же этот
алгоритм потребует множественных произвольных чтений памяти, что нега-
тивно скажется на производительности. Так что движок VertiPaq использует
совершенно иной алгоритм, оптимизированный для столбчатых баз данных.
Первым делом движок сканирует столбец Product[Brand], извлекая номе-
ра строк из таблицы Product, где Product[Brand] равен Contoso. Как видно по
рис. 17.12, движок сканирует словарь Brand (1), извлекает код для бренда Conto-
618 ГЛАВА 17 Движки DAX
so и переходит к сканированию сегментов (2) в поисках номеров строк в табли-
це товаров, где идентификатор равен нулю (что соответствует бренду Contoso),
возвращая в виде результата индексы найденных строк (3).
ProductfBrand]
Словарь
Рис. 17.12 Результатом сканирования брендов является список строк в таблице товаров
с брендом Contoso
Теперь движку VertiPaq известно, какие строки в таблице Product соответ-
ствуют товарам искомого бренда. Связь между таблицами Product и Sales по-
зволяет VertiPaq перевести номера строк из таблицы Product во внутренние
идентификаторы из столбца Sales[ProductKey]. Движок осуществляет поиск по
номерам строк и получает на выходе соответствующие значения из столбца
Sales[ProductKey], как показано на рис. 17.13.
Последним шагом необходимо применить фильтр к таблице Sales. Посколь-
ку у движка уже есть в наличии список значений столбца Sales[ProductKey], до-
статочно будет просканировать один столбец Sales[ProductKey], чтобы переве-
сти список значений в номера строк и в итоге подсчитать их. Если бы вместо
функции COUNTROWS VertiPaq должен был использовать функцию SUM, к ал-
горитму добавился бы еще один шаг по преобразованию номеров строк в зна-
чения по столбцу.
Важный вывод из этого раздела заключается в том, что производительность
связи напрямую зависит от кратности столбца, по которому осуществляется
связь. Несмотря на то что в запросе мы использовали фильтр только по од-
ному бренду, производительность связи завязывается на количество товаров
этого бренда. Чем ниже кратность связи, тем лучше. Если она будет превышать
миллион, пользователь может почувствовать спад производительности. Па-
дение быстродействия может стать ощутимым уже при 100 000 уникальных
значениях в связи. Можно смягчить влияние связей с высокой кратностью на
ГЛАВА 17 Движки DAX 619
производительность запросов при помощи предварительного агрегирования
данных на другом уровне гранулярности, что позволит избежать дорогостоя-
щих проходов по связям во время выполнения запросов. Позже в данной главе
мы кратко коснемся темы создания предварительных агрегаций.
Product[RowNumber]
Рис. 17.13 Движок VertiPaq ищет ключи товаров в связи с таблицей Sales
и получает идентификаторы транзакций по бренду Contoso
Sales[ProductKey]
Материализация
Теперь, когда вы знаете, как движок VertiPaq хранит данные в памяти, можно
перейти к понятию материализации. Материализация (materialization) явля-
ется одним из шагов выполнения запроса при работе со столбчатыми базами
данных, и очень важно понимать, когда и как она возникает.
Принцип материализации базируется на том, что каждый раз, когда движок
формул посылает запрос движку хранилища данных, ответ приходит в виде
несжатой таблицы, сгенерированной движком хранилища. Эта таблица име-
нуется кешем и представляет собой материализацию данных, которые будут
обработаны движком формул вне зависимости от того, какой именно движок
хранилища используется. И VertiPaq, и DirectQuery создают кеши данных.
Когда в результате выполнения одного запроса движок хранилища данных
генерирует большой кеш, говорят, что в этот момент происходит объемная
материализация. Объем материализации зависит от многих факторов. В част-
ности, когда движок хранилища данных не может выполнить все операции,
предусмотренные запросом DAX, движок формул берет на себя часть работы
с использованием копии данных, принадлежащих движку хранилища. Помни-
те, что движок формул не может обращаться к данным напрямую ни в режиме
VertiPaq, ни в режиме DirectQuery. Для извлечения данных в виде кеша движок
формул обязан обратиться с запросом к движку хранилища. Вид и объем ма-
териализации могут значительно варьироваться в зависимости от использу-
620 ГЛАВА 17 Движки DAX
емого движка хранилища данных. В этой книге мы остановимся на процессе
уменьшения объема материализации в случае с движком VertiPaq. При исполь-
зовании движка DirectQuery могут возникать разные нюансы в зависимости
от применяемого драйвера источника данных. При этом средства для оценки
материализации для DirectQuery будут такие же, как для VertiPaq.
В следующих главах мы подробно разберем, как измерять объем материа-
лизации для запроса DAX с использованием специальных инструментов
и метрик. В данном разделе мы лишь познакомим вас с базовым понятием
материализации данных и посмотрим, как она связана с результатами запро-
са. Кратность результата запроса DAX определяет оптимальную материали-
зацию. Например, следующий запрос подсчета строк в таблице возвращает
одну строку:
EVALUATE
ROW (
"Result"; COUNTROWS ( Sales )
)
-- Result
-- 100231
Оптимальной материализацией для этого запроса будет кеш данных, состоя-
щий из одной строки. Это будет означать, что вычисление полностью было
выполнено движком хранилища данных. Следующий запрос возвращает по
одной строке для каждого года, следовательно, оптимальная материализация
будет включать в себя три строки - по одной для каждого года с продажами:
EVALUATE
SUMMARIZECOLUMNS (
'Date'[Calendar Year];
"Sales Anount"; [Sales Anount]
)
-- Calendar Year I Sales Anount
-- CY 2007 | 11,309,946.12
-- CY 2008 | 9,927,582.99
-- CY 2009 | 9,353,814.87
Всякий раз, когда движок хранилища данных возвращает один кеш с той же
кратностью, что и результат запроса DAX, мы говорим о поздней материали-
зации (late materialization). Если же движок хранилища возвращает несколько
кешей данных и/или кеш содержит больше строк, чем в итоге мы видим на вы-
воде, мы имеем дело с ранней материализацией (early materialization). В случае
с поздней материализацией движок формул не обязан заниматься агрегиро-
ванием данных, тогда как при ранней материализации он вынужден произво-
дить такие операции, как объединение таблиц и группировка, что существенно
снижает скорость выполнения запроса.
Сказать заранее, какой будет материализация в том или ином случае, невоз-
можно без глубокого понимания принципов работы движка VertiPaq. Напри-
ГЛАВА 17 Движки DAX 621
мер, материализация следующего запроса будет оптимальной, поскольку все
вычисления будут выполнены на стороне движка хранилища данных:
EVALUATE
VAR LargeOrders =
CALCULATETABLE (
DISTINCT ( Sales[Order Number] );
Sales[Quantity] > 1
)
VAR Result =
ROW (
"Orders"; COUNTROWS ( LargeOrders )
)
RETURN
Result
- - Orders
- - 8388
А в случае co следующим запросом будет сгенерирована временная таблица,
содержащая уникальные комбинации покупателей и дат для транзакций, в ко-
торых было продано больше одной единицы товара (всего 6290 комбинаций):
EVALUATE
VAR LargeSalesCustomerDates =
CALCULATETABLE (
SUMMARIZE ( Sales; Sales[CustomerKey]; Sales[Order Date] );
Sales[Quantity] > 1
)
VAR Result =
ROW (
"CustomerDates"; COUNTROWS ( LargeSalesCustomerDates )
)
RETURN
Result
- - CustomerDates
- - 6290
В последнем примере материализация содержит 6290 строк, хотя в резуль-
тирующем наборе у нас только одна строка. При этом оба запроса похожи:
сначала мы вычисляем таблицу, а затем возвращаем количество строк в ней.
Разница в материализации обусловлена тем, что в первом запросе промежу-
точная таблица состоит всего из одного столбца. Движок хранилища данных
не может выполнить вычисление, требующее комбинации из двух столбцов
путем их простого сканирования. В общем случае у операций, затрагивающих
один столбец, будет гораздо больше шансов быть выполненными на стороне
движка хранилища данных. Но при этом не стоит считать, что выполнение вы-
числений с двумя и более столбцами всегда будет связано с проблемами. На-
пример, материализация следующего запроса будет оптимальной, несмотря
на то что в вычислении производится перемножение значений столбцов из
двух разных таблиц Sales и Product:
622 ГЛАВА 17 Движки DAX
DEFINE
MEASURE Sales[Sales Anount] =
SUMX (
Sales;
Sales[Quantity] * RELATED ( 'Product'[Unit Price] )
)
EVALUATE
ROW ( "Sales Amount"; [Sales Anount] )
- - Sales Amount
- - 33,690,148.51
При выполнении сложных запросов практически невозможно рассчитывать
на получение оптимальной поздней материализации. Но, выполняя оптими-
зацию запросов, необходимо всегда стремиться к уменьшению объема мате-
риализации и максимально возможному переносу вычислительной нагрузки
на движок хранилища данных.
Агрегирование
В модели данных может находиться сразу несколько таблиц, обращающихся
к одним и тем же исходным данным. Причина такого избыточного хранения
информации состоит в стремлении обеспечить движку хранилища альтерна-
тивные варианты доступа к интересующим нас данным для увеличения ско-
рости выполнения операции. Вспомогательные таблицы, служащие этой цели,
называются агрегированными.
Агрегированная таблица (aggregation) представляет собой не что иное, как
предварительно сгруппированную версию исходной таблицы. Выполняя пред-
варительное агрегирование, мы избавляемся от лишних столбцов (а значит,
и строк) и заменяем значения на их агрегаты.
В качестве примера рассмотрим таблицу Sales, изображенную на рис. 17.14,
в которой содержатся строки с комбинациями даты, товара и покупателя.
Если в запросе нам требуется получить сумму по столбцу Quantity или Amount
по значению Date, движку хранилища данных придется вычислять и агрегиро-
вать все строки по запрашиваемой дате. В VertiPaq эта операция выполнится
достаточно быстро благодаря компрессии и оптимизированным алгоритмам
сканирования памяти. Но в DirectQuery то же вычисление будет выполняться
гораздо дольше. Так или иначе, движку хранилища придется просканировать
миллиарды записей, вместо того чтобы ограничиться миллионами. И в таких
случаях полезно будет иметь под рукой вспомогательную маленькую таблицу,
к которой можно будет обращаться вместо исходной.
На рис. 17.15 показана версия таблицы Sales, агрегированная по столбцу
Date. В этой таблице содержится по одной строке для каждой даты, и при этом
значения в столбцах Quantity и Amount предварительно агрегированы.
В агрегированной таблице каждый столбец представляет собой либо стол-
бец группировки данных, либо столбец агрегирования. Если для выполнения
запроса движку хранилища достаточно будет воспользоваться данными из
ГЛАВА 17 Движки DAX 625
агрегированной таблицы, он вовсе не будет обращаться к исходной табли-
це, ограничившись сканированием ее предварительно рассчитанной версии.
В таблице Sales Agg Date, показанной на рис. 17.15, можно расписать роли каж-
дого столбца следующим образом:
Date: Группировка по Sales[Date];
Quantity: Сумма по Sales [Quantity];
Amount: Сумма по Sales [Amount].
Sales Agg Date
Date Quantity Amount
2018-09-01 8 1,255.35
2018-09-02 9 1,702.75
Рис. 17.15 В таблице Sales Адд Date
содержится по одной строке для каждой даты
Sales
Date Product Customer Quantity Amount
2018-09-01 AV010 C092 3 29.97
2018-09-01 AV022 C092 1 16.40
2018-09-01 AV010 C054 2 19.98
2018-09-01 FL892 C248 1 190.00
2018-09-01 GT400 C127 1 999.00
2018-09-02 AV010 C115 3 29.97
2018-09-02 FL580 C127 1 790.00
2018-09-02 AV022 C772 2 32.80
2018-09-02 KB723 C614 2 59.98
2018-09-02 FL580 C614 1 790.00
...
Рис. 17.14 В исходной таблице Sales
содержится большое количество строк
Для каждого столбца с агрегированными данными необходимо указать
тип вычисления. Доступны следующие виды агрегации: Count, Min, Max, Sum
и подсчет количества строк. При этом агрегирование можно выполнять ис-
ключительно по родным столбцам исходной таблицы, указывать вычисляемые
столбцы нельзя.
Важно Агрегирование не может быть использовано при оптимизации сложных вычис-
лений на DAX. Единственной целью создания агрегатов является сокращение времени
выполнения запроса движком хранилища данных. Использование агрегатов может быть
полезным при обращении к небольшим таблицам в движке DirectQuery, тогда как в Ver-
tiPaq вариант с применением агрегатов может быть рассмотрен только для больших таб-
лиц, количество записей в которых исчисляется миллиардами.
\_______________________________________________________________________)
624 ГЛАВА 17 Движки DAX
На основании одной таблицы в модели данных Tabular может быть созда-
но множество агрегатов со своими приоритетами на случай, если несколько
агрегатов окажутся совместимы с запросом движка хранилища. Более того, ис-
ходные таблицы и агрегаты на их основе могут храниться в разных движках.
Распространенной практикой является хранение в VertiPaq агрегированных
таблиц для повышения эффективности выполнения запросов к объемным таб-
лицам, доступ к которым осуществляется посредством DirectQuery. Но можно
создавать агрегаты и в рамках того же движка хранилища данных, где содер-
жится исходная таблица.
Примечание В зависимости от версии и типа лицензии используемого инструмента вы
можете столкнуться с разными ограничениями при работе с агрегированными и исход-
ными таблицами. В данном разделе мы лишь в общих чертах описали концепцию агре-
гирования, которая применяется при оптимизации выполнения запросов DAX, о чем мы
поговорим в следующих главах.
Агрегирование является очень мощным инструментом оптимизации за-
просов, но в то же время требует повышенного внимания в обращении. Не-
правильное определение агрегатов приводит к вычислению неправильных
результатов. Разработчик модели данных ответствен за то, чтобы запросы,
используемые в агрегированных таблицах, давали обобщенные результаты
по тем же данным, которые содержатся в исходных таблицах. Агрегирование
представляет собой инструмент оптимизации и должно использоваться, толь-
ко когда это действительно необходимо. Присутствие агрегированных таблиц
в модели данных требует от разработчика дополнительных усилий по поддер-
жанию целостности данных, а использовать их стоит только в том случае, если
они действительно дают прирост производительности.
Выбор аппаратного обеспечения для VertiPaq
Выбор аппаратного обеспечения критически важен для решений, основанных
на моделях данных Tabular с использованием движка VertiPaq. При этом более
высокая стоимость «железа» на практике отнюдь не всегда означает повыше-
ние производительности. В данном разделе мы поможем вам выбрать аппа-
ратную часть для эффективной работы с табличными моделями данных.
Начиная с появления Analysis Services 2012 мы консультировали множе-
ство компаний в вопросах интеграции моделей данных Tabular в их решения.
И часто бывало так, что, когда время доходило до запуска проекта, быстродей-
ствие оказывалось хуже, чем мы предполагали изначально. Более того, ино-
гда в плане производительности решение уступало даже его развертыванию
в тестовой среде. В большинстве случаев проблема заключалась в неправиль-
ном определении требований к техническим характеристикам аппаратных
средств, особенно когда сервер поднимался в виртуализированной среде. Как
вы узнаете позже, использование виртуальной машины само по себе не яв-
ляется ошибочным решением. Проблема чаще кроется в технической специ-
ГЛАВА17 Движки DAX 625
фикации аппаратного обеспечения. Подробное руководство по выбору аппа-
ратного комплекса для Analysis Services Tabular можно найти в технической
документации под названием «Hardware Sizing a Tabular Solution (SQL Server
Analysis Services)» по адресу http://msdn.microsoft.com/en-us/Library/jj874401.aspx.
Здесь же мы укажем на распространенные проблемы, с которыми сталкивают-
ся дата-центры при размещении у себя решений на основе моделей данных
Tabular. Пользователи Power Pivot и Power BI Desktop могут пропустить часть
раздела, касающуюся поддержки архитектуры неоднородного доступа к памя-
ти (Non-Uniform Memory Access - NUMA), но остальные рекомендации по вы-
бору аппаратного оборудования являются общими для всех.
Возможность выбора аппаратного обеспечения
Первый вопрос, на который стоит ответить, заключается в том, можете ли вы
влиять на выбор аппаратного обеспечения. Проблема выбора решения на ос-
нове виртуальной машины часто состоит в невозможности оказать влияние
на подбор «железа» - оно уже установлено и работает. Иногда можно скло-
нить компанию к выбору того или иного количества ядер процессора или
объема оперативной памяти на сервере. К сожалению, эти характеристики
являются далеко не главными для производительности системы. В услови-
ях ограниченного выбора необходимо собрать всю доступную информацию
о модели центрального процессора и его тактовой частоте как можно раньше.
Если такая информация недоступна, попросите запустить небольшую вир-
туальную машину на том же сервере, и в Диспетчере задач (Task Manager) на
вкладке Быстродействие (Performance) вы увидите все искомые характерис-
тики. Этой информации иногда достаточно, чтобы понять, что в отношении
быстродействия планируемое решение будет уступать даже средненькому
ноутбуку. К сожалению, очень многие разработчики время от времени ока-
зываются в подобном положении. В такие моменты необходимо собрать все
имеющиеся дипломатические навыки, чтобы попытаться убедить нужных
людей в том, что запускать модель данных Tabular на такой машине просто
бессмысленно. Если в качестве аппаратного обеспечения сервера сомневать-
ся не приходится, нужно избегать ловушек, связанных с запуском виртуаль-
ной машины на других узлах с поддержкой NUMA (позже мы расскажем об
этом подробнее).
Приоритеты при выборе аппаратного обеспечения
Если вы все же располагаете рычагами влияния на выбор технических харак-
теристик будущего сервера, советуем вам расположить свои приоритеты в сле-
дующем порядке:
1) модель и тактовая частота процессора: чем частота больше, тем луч-
ше;
2) быстродействие памяти: чем выше, тем лучше;
3) количество ядер процессора: чем больше, тем лучше. При этом мало
быстрых ядер лучше, чем много медленных;
4) объем памяти.
626 ГЛАВА 17 Движки DAX
Заметьте, что эффективности выполнения операций ввода-вывода нет
в этом списке. И действительно, если говорить о скорости выполнения запро-
сов, этот показатель не играет никакой роли, но он может оказаться полезным
при аварийном восстановлении данных. Есть лишь одна характеристика в от-
ношении системы ввода/вывода, которая может повлиять на быстродействие
комплекса. Это постраничная подкачка (paging), о которой мы поговорим поз-
же в этом разделе. При этом стоит отметить, что объем оперативной памяти
для сервера лучше выбирать такой, чтобы постраничная подкачка по причине
нехватки памяти вовсе не требовалась. Таким образом, мы советуем вам вло-
житься в процессор, а также в быстродействие и объем памяти и не тратить
время и средства на вопросы дискового ввода/вывода. В следующих разделах
мы обоснуем расставленные приоритеты.
Модель центрального процессора
Наибольшую важность для быстродействия модели данных Tabular с движком
VertiPaq имеют модель центрального процессора на сервере и его тактовая
частота. При этом разные модели процессоров при одинаковой частоте мо-
гут выдавать совершенно разные цифры, так что оценивать частоту в отрыве
от модели процессора не стоит. Лучшим способом протестировать процессор
является запуск тяжеловесного запроса, который как следует нагрузит движок
формул. Вот один из примеров таких запросов:
DEFINE
VAR tl =
SELECTCOLUMNS ( CALENDAR ( 1; 10000 ); "x"; [Date] )
VAR t2 =
SELECTCOLUMNS ( CALENDAR ( 1; 10000 ); "y"; [Date] )
VAR c =
CROSSJOIN ( tl; t2 )
VAR result =
COUNTROWS ( c )
EVALUATE
ROW ( "x"; result )
Этот запрос может быть запущен в DAX Studio или SQL Server Management
Studio с моделью данных Tabular. Выражение написано специально для теста
и не несет в себе никакого смысла. Конечно, лучше использовать запросы с ти-
пичной нагрузкой для конкретной модели данных, поскольку быстродействие
может варьироваться на разном оборудовании в зависимости от памяти, вы-
деляемой для материализации промежуточных результатов. И предыдущий
запрос по минимуму использует память. Если сравнивать различные модели
процессоров, то на Intel i7-4770K 3,5 ГГц этот запрос выполнился за 9,5 секун-
ды, а на Intel i7-6500U 2,5 ГГц - за 14,4 секунды. В первом случае это был на-
стольный компьютер, во втором - ноутбук. Не думайте, что сервер будет ра-
ботать быстрее. Необходимо всегда тестировать аппаратное обеспечение при
помощи одного и того же тестового запроса и с одинаковыми версиями движка
и анализировать результаты, которые могут вас удивить.
ГЛАВА 17 Движки DAX 627
Обычно на серверах используются процессоры Intel Хеоп линейки Е5 и Е7
с тактовой частотой в районе 2-2,4 ГГц даже при большом количестве ядер.
Лучше ориентироваться на тактовую частоту от 3 ГГц и выше. Также важны-
ми характеристиками являются объем кеша второго (L2 cache) и третьего (L3
cache) уровней: чем больше, тем лучше. Особое значение это имеет при работе
с большими таблицами и наличии связей между таблицами, основанных на
столбцах с количеством уникальных значений, превышающим миллион.
Причины, по которым такую важную роль при работе с движком VertiPaq
играют процессор и объем кеша, сведены в табл. 17.1. Здесь сравнивается ско-
рость доступа к данным, хранящимся на разном удалении от центрального
процессора. В правом столбце скорость доступа к данным для удобства пере-
ведена в сопоставимые единицы измерения, более понятные для человека.
ТАБЛИЦА 17.1 Скорость доступа к данным
Доступ Время доступа Понятные человеку показатели
1 такт центрального процессора 0,3 нс 1 с
Кеш первого уровня 0,9 нс 3 с
Кеш второго уровня 2,8 нс 9с
Кеш третьего уровня 12,9 нс 43 с
Оперативная память 120 нс 6 мин
Твердотельный диск 50-150 мкс 2-6 дн
Вращающийся жесткий диск 1-10 мс 1-12 мес
Как видно из представленной таблицы, самый быстрый доступ осуществля-
ется вовсе не к оперативной памяти, а к кешу процессора. Таким образом, ста-
новится понятно, что объем кеша второго уровня вкупе с тактовой частотой
процессора играет решающую роль в быстродействии модели данных. Также
эта таблица дает понять, почему выгоднее хранить данные в оперативной па-
мяти, а не на физических носителях.
Быстродействие памяти
Быстродействие памяти очень важно для движка VertiPaq. Каждая операция,
выполняемая движком хранилища, предполагает доступ к памяти на предельно
высокой скорости. В случае если пропускная способность оперативной памяти
является узким местом в системе, счетчики производительности переключа-
ются на показ использования процессора, а не ожидания системы ввода/выво-
да. К сожалению, нет датчиков, которые отслеживали бы время, затраченное на
ожидание отклика от оперативной памяти. При этом в работе с моделью дан-
ных Tabular этот показатель, который трудно измерить, играет важную роль.
Как правило, следует использовать память с частотой не ниже 1833 МГц, а ес-
ли позволяет архитектура, лучше отдать предпочтение частоте 2133 МГц и выше.
Количество ядер процессора
Движок VertiPaq разбивает запрос по потокам только при работе с таблицами,
занимающими более одного сегмента. По умолчанию в одном сегменте хра-
628 ГЛАВА 17 Движки DAX
нится 8 млн строк (для Power BI и Power Pivot - 1 млн строк). Таким образом,
процессор с восемью ядрами не будет задействовать их все, если обрабатывае-
мая таблица содержит меньше 64 млн записей (для Power BI и Power Pivot -
8 млн записей).
Получается, что важность масштабируемости по этому показателю начнет
проявляться только на действительно объемных таблицах. Фактически увели-
чение количества ядер процессора позволит увеличить производительность
в рамках одного запроса при работе с таблицами размером 200 млн строк
и более. В плане масштабируемости (количества одновременно подключенных
пользователей) большое количество ядер может не привести к повышению
производительности, если пользователи подключаются к тем же таблицам,
с которыми работали бы в совместно используемой памяти. Лучшим способом
увеличить количество одновременно подключенных пользователей будет ис-
пользование большего количества серверов в конфигурации с балансировкой
нагрузки.
Хорошей практикой является применение максимально доступного коли-
чества ядер процессора на одном разъеме, что позволит предельно повысить
тактовую частоту. Задействование двух и более разъемов процессоров на од-
ном сервере - не лучшая идея, несмотря на то что Analysis Services распознает
архитектуру NUMA. Эта архитектура предполагает использование довольно
затратного межразъемного взаимодействия всякий раз, когда поток, запущен-
ный на одном разъеме, обращается к памяти, выделенной другим разъемом.
Подробнее почитать об архитектуре NUMA можно в технической документа-
ции под названием «Hardware Sizing a Tabular Solution (SQL Server Analysis Ser-
vices)» по адресу http://msdn.microsoft.com/en-us/Library/jj874401.aspx.
Объем памяти
Весь объем данных, используемый в движке VertiPaq, должен быть загружен
в память. Дополнительная оперативная память может понадобиться для вы-
полнения операций (если для этого нет отдельного сервера) и запросов. Оп-
тимизированные запросы обычно не очень требовательны к оперативной
памяти, но при этом отдельные запросы могут материализовать временные
таблицы довольно большого размера. Таблицы из базы данных обладают вы-
сокой степенью компрессии, тогда как в процессе материализации промежу-
точных таблиц при выполнении запроса генерируются данные в несжатом
формате.
Наличие достаточного объема памяти гарантирует только то, что запрос по
окончании выполнения вернет результат. При этом увеличение доступной па-
мяти не повысит скорость выполнения запроса. Кеш данных, используемый
моделью Tabular, не увеличится в размерах, если оперативной памяти станет
больше. В то же время нехватка оперативной памяти может негативно сказать-
ся на быстродействии запросов, если сервер начнет выполнять постраничную
подкачку данных. Иными словами, памяти должно быть достаточно, чтобы
разместить в ней всю модель данных и избежать материализации во время
выполнения запросов. Установка дополнительной памяти может рассматри-
ваться как бесполезная трата ресурсов.
ГЛАВА 17 Движки DAX 629
Дисковый ввод/вывод и постраничная подкачка
Вы не должны выделять отдельный бюджет на систему ввода/вывода для ра-
боты Analysis Services Tabular. Табличная модель данных отличается от много-
мерной в том числе тем, что при выполнении запросов в ней не происходят
множественные операции с подсистемой ввода/вывода. Единственный случай,
когда это может понадобиться, - это если наблюдается нехватка памяти. Но
добавить память будет дешевле и эффективнее, чем пытаться увеличить про-
пускную способность системы ввода/вывода в условиях постоянной постра-
ничной подкачки, возникающей вследствие нехватки памяти.
Перед выбором аппаратного обеспечения для SSAS Tabular необходимо про-
извести замер быстродействия. Часто бывает, что на сервере модель данных
работает вдвое медленнее, чем на компьютере, на котором разрабатывалась,
даже если сервер совсем новый. Дело в том, что сервер, рассчитанный на мас-
штабируемость - в частности, под виртуальные машины, - может не лучшим
образом справляться с задачами, рассчитанными на выполнение в одном по-
токе. А такие задачи составляют большую часть функционала движка VertiPaq.
Вам понадобится провести углубленный анализ с использованием сравнитель-
ных тестов, чтобы убедить компанию в том, что «стандартный сервер» может
стать слабым звеном всей их системы бизнес-аналитики.
Заключение
В первой главе, посвященной оптимизации, мы описали внутреннюю архитек-
туру модели Tabular и рассказали о том, как хранятся данные в движке VertiPaq.
Как вы узнаете из следующих глав, эта информация крайне важна для оптими-
зации кода.
Как всегда, вспомним то, что вы усвоили из этой главы:
сервер модели Tabular располагает двумя движками: движком формул
и движком хранилища данных;
движок формул представляет собой движок запросов верхнего уровня.
Он очень мощный, но при этом имеет ограничение по скорости, посколь-
ку все вычисления выполняет в одном потоке;
существует два движка хранилища данных: VertiPaq и DirectQuery;
VertiPaq является столбчатой базой данных, хранящей информацию
в оперативной памяти. При этом данные хранятся в столбцах, что об-
легчает доступ к однородной информации. Использование нескольких
столбцов в одной формуле DAX может потребовать материализации дан-
ных;
движок VertiPaq хранит данные в столбцах в сжатом виде для уменьше-
ния времени сканирования памяти. Оптимизация модели данных под-
разумевает оптимизацию коэффициента сжатия путем уменьшения
кратности столбцов настолько, насколько это возможно;
движки хранилища VertiPaq и DirectQuery могут сосуществовать в одной
модели данных. Такая модель называется составной. Один запрос может
650 ГЛАВА 17 Движки DAX
использовать только VertiPaq, только DirectQuery или оба движка в зави-
симости от модели хранения таблиц, участвующих в запросе.
Теперь, когда вы располагаете базовыми знаниями о внутреннем устрой-
стве движка, можно приступать к изучению конкретных техник оптимизации
хранилища VertiPaq, направленных на уменьшение размера модели данных
и ускорение выполнения запросов. Этим мы и займемся в следующей главе.
ГЛАВА 18
Оптимизация движка VertiPaq
В предыдущей главе мы начали говорить о внутреннем устройстве движка
VertiPaq. Это знание просто необходимо для проектирования и оптимизации
моделей данных с целью ускорить выполнение запросов. И если предыдущая
глава была больше теоретической, здесь мы в основном будем обращать вни-
мание на практические аспекты. В данной главе мы постараемся дать наиболее
важные рекомендации относительно экономии памяти и повышения произ-
водительности модели данных. Главным подспорьем в создании высокоэф-
фективной модели данных является снижение кратности столбцов для умень-
шения размера словарей, повышения степени компрессии, а следовательно,
ускорения процесса фильтрации и осуществления итераций по таблице.
Конечная цель главы - оптимизация модели данных. Но сначала вам необ-
ходимо научиться правильно оценивать плюсы и минусы решений, связанных
со структурой модели. Не следует слепо следовать рекомендациям, не проверив
их эффективность на практике. Именно поэтому первая часть главы будет по-
священа способам оценки занимаемого места в памяти каждым объектом мо-
дели данных. Это важно при принятии решения относительно того или иного
подхода, исходя из их требований к памяти.
Перед тем как двигаться дальше, хотелось бы еще раз подчеркнуть, что вы
всегда должны самостоятельно оценивать эффективность применяемой техни-
ки в своей модели данных. Вопросы распределения данных играют важную роль
применительно к движку VertiPaq. Таблицы Sales с одинаковой структурой мо-
гут быть сжаты с разными коэффициентами в зависимости от распределения
данных, а значит, одна и та же техника оптимизации, примененная к этим таб-
лицам, может дать совершенно разные результаты. Не пытайтесь найти един-
ственно правильную технику. Вместо этого изучайте самые разные подходы
к оптимизации и помните, что выбор оптимального метода оптимизации за-
висит от модели данных.
Сбор информации о модели данных
Первым шагом на пути оптимизации модели данных должен быть сбор ин-
формации обо всех ее объектах. В этом разделе мы подробнее остановимся на
инструментах и техниках для сбора данных, которые будут использоваться при
выборе приоритетов во время оптимизации физической структуры модели.
В табл. 18.1 перечислена информация, которая представляет интерес в от-
ношении разных объектов модели данных.
652 ГЛАВА 18 Оптимизация движка VertiPaq
Размер объекта в основном зависит от количества уникальных значений
в столбцах, которые используются или на которые происходит ссылка. Именно
поэтому важнейшей информацией, характеризующей модель данных, являет-
ся количество уникальных значений или кратность столбцов.
ТАБЛИЦА 18.1 Собираемая информация для разных типов объектов
Объект Информация для сбора
Таблица Количество строк
Столбец Количество уникальных значений Размер словаря Объем данных (общий размер всех сегментов)
Иерархия Размер структуры иерархии
Связь Размер структуры связи
В главе 17 мы познакомились с представлениями динамического управ-
ления, помогающими извлекать информацию об объектах из движка храни-
лища VertiPaq. В следующих разделах этой главы мы расскажем, как нужно
интерпретировать важную информацию, полученную из этих представле-
ний, при помощи инструмента VertiPaq Analyzer, упрощающего сбор и анализ
данных.
Первое, на что необходимо обращать внимание при анализе служебной ин-
формации о модели данных, - это размер каждой таблицы в отношении крат-
ности (количества строк) и объема занимаемой памяти. На рис. 18.1 представ-
лен раздел анализа таблиц инструмента VertiPaq Analyzer для модели данных
Contoso в Power BI. Модель, используемая в этом примере, содержит больше
таблиц и данных, чем ее упрощенная версия, которую мы использовали на
протяжении большей части книги.
Row labels w Cardinality Table Size Columns Total Size Data Size Dictionary Size Columns Hierarchies Size
♦ Channel 4 52,368 52,368 56 51,936 376
♦ Currency 28 58,516 58,516 136 57,204 1,176
♦ Customer 18,869 3,202,854 3,202,214 361,688 2,236,374 604,152
♦ Date 2,556 510,280 510,280 38,400 404,584 67,296
♦ DateTableTemplate_ 1 35,268 35,172 56 34,828 288
♦ ExchangeRate 773 63,144 63,064 6,224 45,520 11,320
♦ Geography 674 155,624 141,736 2,640 127,736 11,360
♦ Inventory 8,013,099 108,978,244 108,973,588 76,679,640 188,556 32,105,392
♦• ITMachine 23,283 258,048 242,392 93,240 24,152 125,000
♦ ITS LA 4,925 832,404 814,660 78,200 611,252 125,208
♦ Machine 7,816 569,847 569,495 48,352 421,951 99,192
* OnlineSales 12,627,608 254,159,572 254,115,436 133,076,408 56,877,668 64,161,360
♦ Product 2,517 858,585 857,881 58,688 706,433 92,760
♦ Productcategory 8 52,980 52,980 56 52,436 488
♦ ProductSubcategory 44 78,834 78,826 232 76,834 1,760
♦ Promotion 28 95,816 95,816 256 94,192 1,368
.♦ Sales 3,406,089 75,214,220 75,208,028 50,401,856 9,408,932 15,397,240
♦ SalesQuota 7,465,911 196,733,872 196,729,392 72,794,816 81,569,824 42,364,752
♦ SalesTerritory 265 163,020 156,196 1,968 145,180 9,048
♦ Scenario 3 53,522 53,522 56 53,114 352
♦ Store 306 326,420 325,708 5,608 295,500 24,600
♦ StrategyPlan 2,750,628 130,468,816 130,468,728 16,393,680 86,726,368 27,348,680
Grand Total 34,325,435 772,922,254 772^05,998 350,042^56 240,210,574 182,553,168
Рис. 18.1 Подробные сведения о таблице, полученные из VertiPaq Analyzer
ГЛАВА 18 Оптимизация движка VertiPaq 655
В колонке Table Size показан объем памяти, необходимый для хранения
сжатых данных в движке VertiPaq, а в колонке Cardinality - количество строк
в таблице. Если развернуть таблицу значком плюса, можно увидеть подробную
информацию о ее столбцах. Здесь в колонке Cardinality будет указано уже ко-
личество уникальных значений в столбце. При этом значения в колонке Table
Size отсутствуют, а общий объем памяти, занимаемый столбцом, можно ви-
деть в колонке Columns Total Size. На рис. 18.2 раскрыта информация о столбцах
в одной из самых больших таблиц модели данных - SalesQuota. Заметьте, как
сильно отличаются размеры столбцов внутри одной таблицы.
Row Labels ’ Cardinality Table Size Columns Total Size Data Size Dictionary Size Columns Hierarchies Size Encoding
=i SalesQuota 7,465,911 196,733,872 196,729,392 72,794,816 81,569,824 42,364,752 Many
ChannelKey 4 1,624 184 1,360 80 HASH
CurrencyKey 1 1,476 64 1,348 64 HASH
Datekey 36 431,632 429,712 1,584 336 HASH
ETLLoadID 1 1,476 64 1,348 64 HASH
GrossMarginQuota 944,795 67,753,032 17,578,832 42,615,800 7,558,400 HASH
LoadDate 1 1,416 64 1,288 64 HASH
ProductKey 2,516 11,996,128 11,900,032 75,920 20,176 HASH
RowNumber-2662979B- 120 0 120 VALUE
Sa 1 esAmountQuota 613,799 60,602,464 16,854,808 38,837,224 4,910,432 HASH
Sa lesQuantity Quota 1,101 182,004 151,952 21,204 8,848 HASH
SalesQuotaKey 7,465,911 49,772,920 19,909,136 120 29,863,664 VALUE
ScenarioKey 3 2,212 792 1,356 64 HASH
StoreKey 306 5,981,472 5,969,112 9,864 2,496 HASH
UpdateDate 1 1,416 64 1,288 64 HASH
±SalesTerritory 265 163,020 156,196 1,968 145,180 9,048 Many
+i Scenario 3 53,522 53,522 56 53,114 352 Many
+i Store 306 326,420 325,708 5,608 295,500 24,600 Many
+iStrategyPlan 2,750,628 130,468,816 130,468,728 16,393,680 86,726,368 27,348,680 Many
Grand Total 34,325,435 772,922,254 772,805,998 350,042,256 240,210,574 182,553,168 Many
Рис. 18.2 Детальная информация о столбцах в таблице,
показанная инструментом VertiPaq Analyzer
Каждая колонка в этом отчете отражает специфическую информацию
о столбцах, назначение которой представлено ниже:
Cardinality: кратность объекта. Количество строк в таблице или уникаль-
ных значений в столбце - в зависимости от уровня детализации отчета;
Rows: количество строк в таблице. Эта метрика выводится в отчете по
столбцам (который будет показан на рис. 18.3), а не в отчете по таблицам
(представленном на рис. 18.2), и в ней представлена та же информация,
что и в метрике Cardinality на уровне таблиц;
Table Size: размер таблицы в байтах. В этой метрике указана сумма по
колонкам Columns Total Size, User Hierarchies Size и Relationships Size;
Columns Total Size: размер столбца в байтах. Эта метрика отражает сум-
му по колонкам Data Size, Dictionary Size и Columns Hierarchies Size;
Data Size: размер в байтах всех сжатых данных в сегментах и секциях.
Сюда не включаются словари иерархии по столбцам. Этот показатель
зависит от степени сжатия столбца, которая, в свою очередь, находится
в зависимости от количества уникальных значений и распределения дан-
ных в таблице;
Dictionary Size: размер структур словарей в байтах. Этот показатель ва-
жен только для столбцов, кодированных при помощи хеш-таблиц. Для
654 ГЛАВА 18 Оптимизация движка VertiPaq
столбцов с кодированием на основе значений здесь будет стоять неболь-
шое фиксированное число. Размер словаря зависит от количества уни-
кальных значений в столбце и средней длины строк в случае, если речь
идет о текстовых данных;
Columns Hierarchies Size: размер в байтах автоматически создавае-
мых иерархий атрибутов для столбца. Эти иерархии нужны для доступа
к столбцу в MDX, а в DAX они используются для оптимизации операций
фильтрации и сортировки;
Encoding: тип кодирования (при помощи хеш-таблиц или на основе зна-
чений), используемый для столбца. Выбирается для столбца автоматиче-
ски на основе алгоритма сжатия VertiPaq;
User Hierarchies Size: размер в байтах пользовательских иерархий. Эта
структура вычисляется только на уровне таблиц, и в VertiPaq Analyzer
значения по ней выводятся лишь по таблицам. Размер пользовательской
иерархии зависит от количества уникальных значений и средней длины
строк в столбцах, используемых в иерархии;
Relationship Size: размер связи между двумя таблицами в байтах. Размер
связи относится к таблице, находящейся в этой связи на стороне «мно-
гие». Этот размер зависит от кратности столбцов, вовлеченных в связь,
хотя чаще всего он составляет незначительную часть накладных расходов
таблицы;
Table Size %: отношение Columns Total Size к Table Size;
Database Size %: отношение Table Size к Database Size, равной сумме Table
Size для всех таблиц;
Segments #: количество сегментов. Для всех столбцов таблицы количест-
во сегментов будет одинаковым;
Partitions #: количество секций. Для всех столбцов таблицы количество
сегментов будет одинаковым;
Columns #: количество столбцов.
Иерархии атрибутов и кодирование столбцов
В двух колонках отчета VertiPaq Analyzer представлена информация, которая может быть
использована при оптимизации больших моделей данных. Ниже мы укажем ссылки на
соответствующую документацию, поскольку такая оптимизация выходит за рамки дан-
ной книги.
Размер иерархии атрибута, показанный в колонке Columns Hierarchies Size, зависит
от количества уникальных значений в столбце и средней длины строк, как и в случае
с размером словаря. В то же время иерархии атрибутов создаются для обоих типов ко-
дирования (при помощи хеш-таблиц или на основе значений),тогда как словари - только
для кодирования с хеш-таблицами. Создание иерархии атрибута может быть отключено,
если столбец используется только для агрегаций, а не для выполнения фильтрации или
группировки. Кроме того, оптимизация может потребовать дополнительных настроек.
Больше информации по отключению создания иерархии атрибута доступно по адресам:
https://docs.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular.column.
isavailableinmdx и https://blogs.msdn.microsoft.com/analysisservices/2018/06/08/new-
memory-options-for-analysis-services/.
ГЛАВА 18 Оптимизация движка VertiPaq 655
Параметр Encoding (Кодирование) для столбца в модели данных может быть изменен
разработчиком. При этом модель может подсказать оптимальный тип кодирования в кон-
кретном случае. Обычно VertiPaq выбирает вариант, при котором потребление памяти
будет минимальным. Но разработчик вправе выбрать другой тип кодирования, если он
отвечает его нуждам (например, для ускорения создания динамических агрегаций), даже
если этот вариант будет более затратным с точки зрения расходования памяти. Разница
в скорости выполнения запросов может быть заметна при обращении к таблицам с мил-
лиардами строк,тогда как даже на нескольких миллионах записей прирост вряд ли будет
ощутим. Более подробную информацию о подсказках при кодировании столбцов можно
почерпнуть по адресу: https://docs.microsoft.com/en-us/sql/analysis-services/what-s-new-
in-sql-server-analysis-services-2017?view=sql-server-2017#encoding-hints.
Первый шаг оптимизации, который можно предпринять, опираясь на дан-
ные VertiPaq Analyzer, состоит в удалении столбцов, которые не используются
в отчетах и при этом занимают много места в памяти. Например, по отчету, по-
казанному на рис. 18.2, видно, что одним из наиболее дорогостоящих столбцов
в таблице SalesQuota является SalesQuotaKey. Этот столбец не используется ни
в одном из отчетов и не является обязательным с точки зрения структуры мо-
дели данных, как в случае со столбцами, участвующими в связях. Фактически
от столбца SalesQuotaKey можно избавиться без вреда для отчетов и вычисле-
ний, что позволит сэкономить на времени обработки данных и используемой
памяти.
В процессе обнаружения наиболее дорогостоящих столбцов лучше пользо-
ваться другим отчетом VertiPaq Analyzer, представленным на рис. 18.3. В этом
отчете, называющемся Columns, показаны все столбцы сквозным списком, при
этом их имена складываются из названия таблицы и столбца, а данные отсор-
тированы в убывающем порядке по колонке Columns Total Size.
Tablecolumn ROWS Cardinality Columns Total Size Database Size %
StrategyPlan-Amount 2,750,628 2,042,832 108,871,288 14.09%
OnlineSales-OnlineSalesKey 12,627,608 12,627,608 84,154,472 10.89%
OnlineSales-SalesOrderNumber 12,627,608 1,674,320 79,176,940 10.25%
SalesQuota-GrossMargin Quota 7,465,911 944,795 67,753,032 8.77%
Sa lesQu ota-Sa lesAmountQuota 7,465,911 613,799 60,602,464 7.84%
Inventory-inventoryKey 8,013,099 8,013,099 53,420,840 6.91%
SalesQuota-SalesQuotaKey 7,455,911 7,465,911 49,772,920 6.44%
Sales- Sales Key 3,406,089 3,406,089 22,707,424 2.94%
StrategyPlan-StrategyPlanKey 2,750,628 2,750,628 18,337,680 2.37%
OnlineSales-SalesOrderUneNumber 12,627,608 4,972 16,544,600 2.15%
Sa les-GrossMargin 3,406,089 118,821 13,750,552 1.78%
Рис. 18.3 Подробный отчет о столбцах в VertiPaq Analyzer
Два из трех наиболее дорогостоящих столбцов в модели данных Contoso
(QnlineSalesKey и SalesOrderNumber из таблицы OnlineSales) редко используются
в отчетах на уровне агрегации. При этом каждый из этих столбцов при загрузке
в движок VertiPaq занимает порядка 10 % от общего объема модели данных. Из-
бавившись от этих двух столбцов, мы можем уменьшить размер базы данных
приблизительно на 20 %. Информация, полученная из этого отчета, помогает
разработчику принять решение о том, какие столбцы необходимо сохранить
636 ГЛАВА 18 Оптимизация движка VertiPaq
в модели данных, а какие представляют слишком большую обузу в сравнении
с их аналитической ценностью.
Заметьте, что в отчете на рис. 18.3 колонки Rows и Cardinality располагаются
по соседству, что помогает определить степень уникальности столбца в табли-
це. Если значения в этих колонках одинаковые или близкие, в создании укруп-
ненных данных по ним не будет особого смысла, за исключением случаев, когда
речь идет о чистых агрегатах наподобие столбца Amount из таблицы StrategyPlan.
Также важную информацию в VertiPaq Analyzer можно почерпнуть из отчета
по связям, называющегося Relationships, который показан на рис. 18.4. Этот
отчет помогает распознать дорогостоящие связи, присутствующие в модели
данных, хотя в показанном примере ничего критического не наблюдается.
Row Labels Relationships Size Max From Cardinality Max To Cardinality
±i Machine 352 303 306
'Machine'fStoreKey] -> 'Store'fStoreKey] 352 303 306
±i OnlineSales 44,136 18,869 18,869
'OnlineSales'[CurrencyKey] -> 'Currency'[CurrencyKey] 8 1 28
'OnlineSales'lCustomerKey] -> 'Customer'ICustomerKey] 38,304 18,869 18,869
'OnlineSales'[Datekey] -> 'Date'IDatekey] 1,760 1,096 2,556
'OnlineSales'IProductKey] -> 'Product'[ProductKey] 4,032 2,516 2,517
'OnlineSales'IPromotionKey] -> 'Promotion'IPromotionKey] 24 28 28
'OnlineSales'IStoreKey] -> 'Store'[StoreKey] 8 3 306
Grand Total 95,488 18,869 18,869
Рис. 18.4 Размер и кратность связей в отчете VertiPaq Analyzer
В VertiPaq связи с кратностью выше 1 млн уникальных значений являются
особенно дорогостоящими и негативно влияют на запросы. В общем случае
принято уделять повышенное внимание связям, кратность которых превы-
шает 100 000. Обычно наличие таких связей не доставляет больших неудобств
в плане быстродействия запросов, но, когда счет начинает идти на сотни мил-
лисекунд, все становится не так радужно, и при дальнейшем увеличении объ-
ема базы могут возникнуть проблемы. И хотя одна большая связь вряд ли спо-
собна негативно сказаться на скорости формирования отчета, ее присутствие
может повлиять на быстродействие более сложных вычислений и отчетов.
Наличие сведений о кратности таблиц и столбцов поможет вам при буду-
щем анализе быстродействия запросов DAX. И хотя эту информацию можно
получить путем выполнения простого запроса на DAX, гораздо быстрее и эф-
фективнее будет воспользоваться инструментами вроде VertiPaq Analyzer для
автоматического сбора данных и больше времени потратить на анализ полу-
ченных результатов.
Денормализация
Первый способ выполнения оптимизации модели данных заключается в ее де-
нормализации. Каждая связь сама по себе расходует память, кроме того, рас-
пространение фильтра с одной таблицы на другую также связано с определен-
ными накладными расходами. Если рассуждать исключительно с точки зрения
скорости выполнения запросов, идеальная модель данных должна состоять из
ГЛАВА 18 Оптимизация движка VertiPaq 657
одной таблицы. Но работать с такой моделью было бы крайне неудобно, а все
меры имели бы единый уровень гранулярности. Таким образом, оптимальной
будет модель со схемой «звезда», где таблицы располагаются вокруг других
таблиц, в которых определены меры с одной гранулярностью. Именно поэтому
стоит денормализовать некоторые таблицы с целью уменьшения количества
столбцов и связей в модели данных.
Такая необходимость в денормализации данных в DAX может показаться
нелогичной и противоестественной специалистам с опытом моделирования
реляционных баз данных. Давайте рассмотрим пример простой модели дан-
ных, где в таблице Payment содержится два столбца: Payment Code и Payment
Description. В реляционных базах данных принято в таких случаях создавать
отдельные таблицы со столбцами Code и Description, чтобы избежать дублиро-
вания значений поля Description в таблице Transactions. Таким образом, в таб-
лице Transactions обычно хранится только поле Payment Code, что позволяет
сэкономить место, занимаемое базой данных.
В табл. 18.2 показана денормализованная версия таблицы Transactions. В ней
содержится много повторяющихся значений «Credit Card» и «Cash» в столбце
Payment Type Description.
ТАБЛИЦА 18.2 Таблица Transactions с денормализованными типами платежа
в виде столбцов Code и Description
Date Amount Payment Type Code Payment Type Description
2015-06-21 100 00 Cash
2015-06-21 100 02 Credit Card
2015-06-22 200 02 Credit Card
2015-06-23 200 00 Cash
2015-06-23 100 03 Wire Transfer
2015-06-24 200 02 Credit Card
2015-06-25 100 00 Cash
Если типы платежей хранить в отдельной таблице, то таблице Transactions
можно оставить только поле Payment Type Code, как показано в табл. 18.3.
ТАБЛИЦА 18.5 Таблица Transactions с денормализованными типами платежа
в виде столбцов Code и Description
Date Amount Payment Type Code
2015-06-21 100 00
2015-06-21 100 02
2015-06-22 200 02
2015-06-23 200 00
2015-06-23 100 03
2015-06-24 200 02
2015-06-25 100 00
Описания типов платежей в этом случае будут содержаться в отдельной таб-
лице, показанной в табл. 18.4. Здесь будет по одной строке для каждого типа
658 ГЛАВА 18 Оптимизация движка VertiPaq
платежа. В реляционной базе данных такой способ хранения информации по-
зволяет уменьшить общий размер базы за счет отсутствия необходимости дуб-
лировать длинные названия типов платежей в таблице Transactions.
ТАБЛИЦА 18.4 Таблица Payment Туре, нормализующая столбцы Code и Description
Payment Type Code Payment Type Description
00 Cash
01 Debit Card
02 Credit Card
03 Wire Transfer
И хотя в реляционной базе данных подобный подход в большинстве случаев
оправдан, в модели данных DAX он может быть не всегда оптимальным. Дви-
жок VertiPaq автоматически создает словари для каждой таблицы, а это значит,
что для хранения дублирующихся описаний типов платежей в таблице Trans-
actions не понадобится лишнее место.
Примечание Техники компрессии данных, основанные на словарях, также доступны
в некоторых реляционных базах данных. Например, в Microsoft SQL Server это реализо-
вано посредством кластеризованных индексов Columnstore. Но по своей природе реля-
ционные базы данных призваны хранить информацию без использования сжатия данных
на основе словарей.
\________________________________________________________________________J
С точки зрения экономии места всегда лучше денормализовать один стол-
бец в отдельной таблице. Денормализация сразу нескольких столбцов в одной
таблице, как в случае с атрибутами в таблице Product, может оказаться более
дорогостоящей по сравнению с нормализованной схемой данных. К примеру,
мы можем сравнить использование памяти для нормализованной и денорма-
лизованной моделей.
Расход памяти для нормализованной модели:
столбец Transactions[Type Code];
столбец Payments [Type Code];
столбец Payments[Type Description];
связь Transactions [Type Code] - Payments[Type Code].
Расход памяти для денормализованной модели:
столбец Transactions[Type Code];
столбец Transactions [Type Description].
В схеме с применением денормализации данных из общего расхода памяти
исключены столбец Payments[Type Code] и связь по Transactions [Type Code]. При
этом расход памяти для столбца Type Description в таблицах Transactions и Pay-
ments может быть разным, и при очень больших объемах таблиц нормализо-
ванная схема может оказаться более выигрышной. Также стоит отметить, что
обычно агрегация по столбцу выполняется более эффективно, когда фильтр
накладывается на другой столбец той же таблицы, а не на столбец другой таб-
лицы, объединенной с нашей при помощи связи. Оправдывает ли это полную
ГЛАВА 18 Оптимизация движка VertiPaq 659
денормализацию модели данных с приведением ее к одной таблице? Конечно,
нет! Наибольшим удобством использования отличается схема «звезда», пред-
ставляющая наилучший компромисс между расходованием ресурсов и произ-
водительностью.
В схеме «звезда» содержатся отдельные таблицы для каждой сущности, на-
пример Customer и Product, и все атрибуты, относящиеся к конкретной сущ-
ности, полностью денормализованы в соответствующей таблице. Например,
в таблице Product могут присутствовать следующие атрибуты: Category, Sub-
category, Model и Color. Эта схема прекрасно работает, если кратность связи не
слишком большая. Как мы уже говорили ранее, миллион уникальных значений
можно считать порогом, после которого кратность связи считается большой,
однако уже при 100 000 уникальных записей стоит отнести связь к потенциаль-
но опасным с точки зрения скорости выполнения запросов.
Чтобы понять, почему кратность связи так важна при определении быстро-
действия запроса, полезно будет разобраться в том, что на самом деле про-
исходит при установке фильтра на столбец. Рассмотрим внимательнее схему
данных, представленную на рис. 18.5, на которой таблица Sales объединена
связями с таблицами Product, Customer и Date. Если в запросе к модели данных
будет присутствовать фильтрация таблицы покупателей по полу, движок рас-
пространит фильтр с таблицы Customer на Sales, выделив ключи покупателей,
принадлежащих полу, включенному в запрос. Если в вашей таблице Customer
10 000 покупателей, список, сгенерированный фильтром, не может содержать
больше строк. Но если покупателей 6 млн, то фильтр по одному выбранно-
му полу может сгенерировать список уникальных ключей размером порядка
3 млн строк. Большое количество ключей, используемых в связи, всегда сказы-
вается на производительности запросов, хотя быстродействие также зависит
от версии движка и аппаратного обеспечения (тактовой частоты процессора,
объема кеша и быстродействия памяти).
Рис. 18.5 Таблица Soles объединена связями с таблицами Product, Customer и Dote
640 ГЛАВА 18 Оптимизация движка VertiPaq
Что может быть сделано в плане оптимизации модели данных в случае
включения в связь миллионов уникальных значений? Если фактическое сни-
жение производительности не удовлетворяет требованиям к скорости выпол-
нения запроса, можно рассмотреть другие варианты денормализации данных
с целью снизить кратность связи или вовсе исключить необходимость исполь-
зования связи в определенных запросах. В предыдущем примере можно было
бы рассмотреть вариант денормализации столбца Gender в таблице Sales, если
речь идет об оптимизации конкретного запроса. Если же таких столбцов не-
сколько, можно создать отдельную таблицу с копиями столбцов из таблицы
Customer с пониженной кратностью и селективностью (selectivity), к которой
будут обращаться запросы.
Например, можно рассмотреть вариант создания таблицы Customer Info со
столбцами Gender, Occupation и Education. Если кратность этих столбцов будет
составлять 2, 5 и 5 соответственно, таблица из всех возможных их комбинаций
будет содержать 50 строк (2 * 5 х 5). Запрос к любому из этих столбцов будет вы-
полняться гораздо быстрее, поскольку фильтр, применяемый к таблице Sales,
будет содержать очень короткий список значений. При этом пользователь бу-
дет видеть две группы атрибутов для одной и той же сущности, соответству-
ющей двум таблицам: Customer и Customer Info. Это не идеальная ситуация,
и применять такой способ оптимизации следует только в случае крайней не-
обходимости, если того же результата невозможно добиться при помощи соз-
дания агрегатов в модели данных.
Важно Мы будем обсуждать агрегирование далее в главе. Эта концепция призвана
автоматизировать создание таблиц и связей, используемых исключительно для оптими-
зации быстродействия запросов к движку хранилища данных. По состоянию на апрель
2019 года агрегаты можно создавать только для таблиц в режиме DirectQuery, и они не
могут быть полноценной альтернативой описанной в этом разделе технике. Это будет воз-
можно, когда агрегаты можно будет создавать для таблиц, хранящихся в движке VertiPaq.
\_________________________________________________________________________________J
Важно отметить, что обе таблицы напрямую связаны с таблицей Sales, как
показано на рис. 18.6.
Столбец CustomerlnfoKey должен быть добавлен в таблицу Sales перед выпол-
нением операции загрузки данных, чтобы он воспринимался как родной стол-
бец этой таблицы. Как мы уже говорили в главе 17, родные столбцы таблицы
лучше сжимаются, чем вычисляемые. Но можно создать и вычисляемый стол-
бец CustomerlnfoKey при помощи следующего кода на DAX:
Sales[CustomerInfoKey] =
LOOKUPVALUE (
'Customer Info'[CustomerlnfoKey];
'Customer Info'[Gender]; RELATED ( Customer[Gender] );
'Customer Info'[Occupation]; RELATED ( Customer[Occupation] );
'Customer Info'[Education]; RELATED ( Customer[Education] )
)
Для удобства пользователя столбцы, денормализованные в таблице Custom-
er Info, должны быть скрыты в таблице Customer. Показ этих столбцов (Gender,
ГЛАВА 18 Оптимизация движка VertiPaq 641
Occupation и Education) в обеих таблицах может привести пользователя в за-
мешательство. Но, скрыв эти атрибуты в таблице Customer, невозможно будет,
к примеру, сформировать отчет о покупателях с определенным видом деятель-
ности (поле Occupation) без обращения к транзакциям в таблице Sales. Чтобы не
терять такой возможности, необходимо включить в модель данных неактив-
ную связь, которая может быть активирована по требованию. Для этого нам
потребуются специфические меры, как мы увидим позже на примере оптими-
зированной меры Sales Amount. На рис. 18.7 видно, что таблицы Customer Info
и Sales объединены при помощи активной связи, а между таблицами Customer
Info и Customer связь неактивна.
Рис. 18.6 Таблицы Customer и Customer Info объединены связью с Sales
Связь между таблицами Customer Info и Customer может быть активирована,
если на таблицу Customer наложен активный фильтр. Рассмотрим следующую
меру Sales Amount:
Sales Amount :=
IF (
ISCROSSFILTERED ( Customer[CustomerKey] );
CALCULATE (
[Sales Internal];
USERELATIONSHIP ( Customer[CustomerInfoKey]; 'Customer Info'[CustomerInfoKey] );
CROSSFILTER ( Sales[CustomerInfoKey]; 'Customer Info'[CustomerInfoKey]; NONE )
);
[Sales Internal]
)
642 ГЛАВА 18 Оптимизация движка VertiPaq
Рис. 18.7 Таблицы Customer и Customer Info объединены неактивной связью
Кросс-фильтрация в таблице Customer будет активна в случае, если наложен
фильтр на любой из ее столбцов, при условии что связь между таблицами Sales
и Customer не двунаправленная. При установленной кросс-фильтрации связь
между таблицами Customer и Customer Info активируется при помощи функции
USERELATIONSHIP, автоматически деактивируя связь между таблицами Custom-
er Info и Sales. На самом деле функцию CROSSFILTER в этой мере можно не пи-
сать, но лучше оставить ее в формуле как демонстрацию намерения отключить
распространение фильтра по связи между таблицами Customer Info и Sales. Идея
состоит в том, что, поскольку движок и так вынужден обрабатывать список зна-
чений CustomerKey, будет лучше снизить фильтрацию путем включения атри-
бутов, перенесенных в таблицу Customer Info. Но когда пользователь фильтрует
столбцы в таблице Customer Info, а не в Customer, активная связь по умолчанию
использует лучший вариант объединения таблиц - с меньшим количеством уни-
кальных значений. К сожалению, чтобы более оптимально использовать связь
Customer-Sales в модели данных, подобный шаблон DAX должен быть применен
ко всем мерам с участием атрибутов таблицы Customer Info. При использовании
агрегаций в этом нет необходимости, поскольку шаблон реализуется движком
автоматически без каких-либо вмешательств со стороны кода на DAX.
Еще один распространенный сценарий, в котором высокая кратность связи
должна быть денормализована, встречается при объединении двух объемных
таблиц. Рассмотрите модель данных с таблицами Sales Header и Sales Detail,
приведенную на рис. 18.8.
Это действительно очень частая ситуация, поскольку многие нормализо-
ванные реляционные базы данных построены по таким принципам. При этом
связь между таблицами Sales Header и Sales Detail таит в себе большую опас-
ГЛАВА 18 Оптимизация движка VertiPaq 645
ность для запросов из-за присутствия большого количества уникальных значе-
ний. Любой запрос, группирующий данные столбца Quantity (из таблицы Sales
Detail) по Customer[Gender], будет распространять фильтр с таблицы Sales Head-
er на Sales Detail по столбцу SalesOrderNumber. Лучше будет денормализовать
в таблице Sales Detail все столбцы, использующиеся в таблице Sales Header для
создания связей. По сути, модель данных в этом случае преобразуется в схе-
му, состоящую из двух «звезд» с общими измерениями. Единственной целью
выполнения такой денормализации является отказ от передачи фильтров по
связи между таблицами Sales Header и Sales Detail - эта связь вовсе исчезла из
обновленной схемы данных, представленной на рис. 18.9.
Рис. 18.8 Таблица Customer фильтрует транзакции в Sales Detail посредством
связи через таблицу Sales Header
Рис. 18.9 В модели данных появились прямые связи между таблицей Sales Detail
и таблицами Customer и Calendar
644 ГЛАВА 18 Оптимизация движка VertiPaq
Всегда используйте правильную степень денормализации модели данных
для повышения быстродействия запросов. Техники, описанные в этом разделе,
предлагают хороший баланс между простотой восприятия и производитель-
ностью.
Кратность столбцов
Кратностью (cardinality) столбца называется количество уникальных значе-
ний в нем. Этот показатель очень важен для уменьшения размера столбца, что
напрямую влияет на эффективность сканирования движка VertiPaq. Еще одной
причиной необходимости снижения кратности столбца до возможного мини-
мума является то, что время выполнения многих операций в DAX, таких как
фильтрация и осуществление итераций, зависит от этого показателя. Зачастую
кратность столбца играет более важную роль, чем количество строк в таблице,
содержащей этот столбец.
Разработчик модели данных должен иметь представление о кратности
столбца и рассмотреть возможные способы оптимизации при использовании
этого столбца в связях, фильтрах и вычислениях. Перечислим основные осо-
бенности столбцов в зависимости от их предназначения:
ключ для связи: кратность столбца не может быть изменена без изме-
нения кратности связанной таблицы. Смотрите раздел, посвященный де-
нормализации данных, ранее в этой главе;
числовые значения, агрегируемые в мере: не меняйте точность числа,
если оно представляет количество или сумму в финансовых транзакци-
ях. Если же число представляет меру с плавающей запятой, можно поду-
мать об избавлении от дробной части в случае, если она не играет боль-
шой роли. Например, собирая данные о температуре, можно применить
округление до ближайшего целого. Отброшенная часть, скорее всего, бу-
дет меньше, чем точность измерения;
текстовые описания с низкой кратностью: влияние на размер словаря
может быть оказано только в случае, если в столбце много уникальных
значений. Вынос столбца в отдельную таблицу не даст преимущества,
поскольку словарь останется таким же. Оставьте этот столбец в таблице,
если он нужен пользователям;
текстовые заметки с высокой кратностью: в таких столбцах потен-
циально могут присутствовать уникальные значения для каждой строки
в таблице, но это не будет большой проблемой, если в большинстве строк
будут находиться пустые значения;
изображения: такие столбцы предназначены для хранения изображе-
ний, показываемых в клиентских приложениях, например фотографий
товаров. Этот тип данных недоступен в Power BI. Лучшей альтернативой
в плане экономии памяти будет хранение ссылок на изображения в ин-
тернете, загружаемых динамически;
ID транзакции: такие столбцы характеризуются высокой кратностью
в больших таблицах. Если ID транзакции не нужен вам в запросе, лучше
ГЛАВА 18 Оптимизация движка VertiPaq 645
от него избавиться. При использовании его для детализации - например,
чтобы опуститься до уровня транзакции от агрегированного значения, -
рассмотрите вариант разделения числовых/строковых составляющих на
две и более частей таким образом, чтобы каждая из них характеризова-
лась меньшим количеством уникальных значений;
дата и время: рассмотрите вариант разделения столбца на две части.
Подробнее о хранении данных такого типа мы расскажем далее в этой
главе;
столбцы аудита: в таблицах реляционной базы данных часто предусмот-
рены специальные столбцы, используемые в целях аудита: например,
хранящие время и имя пользователя, выполнявшего последнюю опера-
цию. Нет никакой необходимости загружать эти столбцы в модель дан-
ных VertiPaq, если вы не планируете выводить в отчетах такую степень
детализации. В случае включения в модель подобных столбцов желатель-
но разделить их на части по примеру с датой и временем.
Запомните главное правило: снижение кратности столбцов позволяет сэко-
номить память и увеличить производительность запросов. Однако поскольку
снижение кратности столбцов неминуемо ведет к потере части информации
или ее точности, проводите такого рода оптимизацию с особой внимательно-
стью.
Работа с датой и временем
Почти в каждой модели данных присутствуют столбцы с датами. Время вооб-
ще представляет из себя очень интересное измерение для анализа. Чаще всего
данные в такие столбцы загружаются из полей источника данных типа Date-
time, и существует несколько вариантов оптимизации подобной информации.
Первое (и основное) правило гласит о том, что дату и время всегда желатель-
но разделять на два столбца без использования для этой цели вычисляемых
столбцов. Разделение должно происходить непосредственно во время чтения
данных из источника: часть с датой сохраняется в одном столбце, со време-
нем - в другом. Например, в запросе на чтение столбца TransactionExecution из
таблицы SQL Server можно применить простейший синтаксис языка T-SOL для
создания столбцов TransactionDate и TransactionTime:
CAST ( TransactionExecution AS DATE ) AS TransactionDate,
CAST ( TransactionExecution AS TIME ) AS TransactionTime,
Выполнять операцию разнесения даты и времени по разным столбцам очень
важно. В противном случае вы получите столбец в модели данных, размер сло-
варя и кратность которого будут расти с каждым днем. Кроме того, в модели
данных Tabular анализировать временные метки (timestamp) бывает очень
непросто. Таблице Date требуется точное совпадение с полем даты, и столбец
типа Datetime не будет корректно работать в связке с таблицей дат.
646 ГЛАВА 18 Оптимизация движка VertiPaq
Обычно столбец с датами характеризуется приличной гранулярностью: от-
резок времени в десять лет соответствует менее чем 3700 уникальным значе-
ниям, и даже со столетним отрезком можно работать очень комфортно. К тому
же специальные функции логики операций со временем требуют, чтобы ка-
лендарь был заполнен полностью, без пропусков. Так что удалять дни из ка-
лендаря (например, оставляя по одному дню в каждом месяце) с целью опти-
мизации не годится.
Со столбцом Time, напротив, можно поработать более активно. Здесь допус-
тимо рассмотреть вариант создания таблицы Time, в которой будет храниться
по одной строке для каждой точки выбранного уровня гранулярности. При этом
время в модели данных должно быть округлено до гранулярности, выбранной
для таблицы Time. Наличие отдельной таблицы Time позволит вам работать
с разными временными периодами: к примеру, утро, вечер или 15-минутные
интервалы. В зависимости отданных и требуемого анализа время может быть
округлено до часа или даже миллисекунды, хотя последний вариант может по-
требоваться очень редко. В табл. 18.5 показана кратность столбца в зависимо-
сти от выбранного уровня детализации хранения времени.
ТАБЛИЦЫ 18.5 Кратность в зависимости от уровня гранулярности столбца Time
Точность Кратность
1 час 24
15 минут 96
5 минут 288
1 минута 1440
1 секунда 86 400
1 миллисекунда 86 400 000
Хранить данные с точностью до миллисекунды обычно хуже всего, да и при
хранении с гранулярностью до секунды количество уникальных значений в таб-
лице будет довольно велико. Чаще всего требуемая точность хранения данных
будет находиться между часом и минутой. Кому-то может показаться, что гра-
нулярность до минуты будет оптимальной, поскольку в этом случае кратность
столбца будет относительно невысокой. Но стоит помнить, что степень комп-
рессии столбца зависит от присутствия дублирующихся значений в соседству-
ющих строках. Таким образом, переход от точности до минуты к точности до
15 минут может позволить серьезно повысить компрессию таблицы.
Выбор между округлением до ближайшей секунды/минуты и отсечением
части данных, не представляющей интереса для анализа, должен основывать-
ся на требованиях к аналитике. Представляем вам пример кода на языке T-SQL
для отсечения времени до необходимого уровня точности:
-- Отсекаем до секунды
DATEADD (
MILLISECOND,
- DATEPART ( MILLISECOND, CAST ( TransactionExecution AS TIME(3) ) ),
CAST ( TransactionExecution AS TIME(3) )
)
ГЛАВА 18 Оптимизация движка VertiPaq 647
-- Отсекаем до минуты
DATEADD (
SECOND,
- DATEPART (SECOND, CAST ( TransactionExecution AS TIME(0) ) ),
CAST ( TransactionExecution AS TIME(0) )
)
- - Отсекаем до 5 минут
поменять 5 на 15 для отсечения до 15 минут
поменять 5 на 60 для отсечения до часа
CAST (
DATEADD (
MINUTE,
( DATEDIFF (
MINUTE,
0,
DATEADD (
SECOND,
- DATEPART ( SECOND, CAST ( TransactionExecution AS TIME(0) ) ),
CAST ( TransactionExecution AS TIME(0) )
)
) / 5 ) * 5,
0
) AS TIME(0)
)
В следующем фрагменте кода на языке T-SQL выполняется округление чисел
вместо отсечения:
- - Округляем до секунды
CAST ( TransactionExecution AS TIME(0) )
- - Округляем до минуты
CAST ( DATEADD (
MINUTE,
DATEDIFF (
MINUTE,
0,
DATEADD ( SECOND, 30, CAST ( TransactionExecution AS TIME(0) ) )
),
0
) AS TIME ( 0 ) )
- - Округляем до 5 минут
поменять 5 на 15 для округления до 15 минут
поменять 5 на 60 для округления до часа
CAST ( DATEADD (
MINUTE,
( DATEDIFF (
MINUTE,
0,
DATEADD ( SECOND, 5 * 30, CAST ( TransactionExecution AS TIME(0) ) )
648 ГЛАВА 18 Оптимизация движка VertiPaq
) / 5 ) * 5,
0
) AS TIME ( 0 ) )
Похожие трансформации могут быть сделаны на этапе загрузки данных
в Power Query. Но для таблиц, насчитывающих миллионы строк, изменения
непосредственно в источнике данных будут выполнены быстрее.
Сохраняя миллионы новых строк в таблицах ежедневно, стоит помнить,
что вид этих данных может напрямую влиять на быстродействие запросов
и использование памяти. В то же время не стоит излишне заботиться об оп-
тимизации модели данных, не нуждающейся в высоком уровне компрессии.
В конце концов, уменьшение детализации хранимых данных ведет к удале-
нию информации, которую вы не сможете использовать для более подробных
отчетов.
Вычисляемые столбцы
В вычисляемых столбцах хранятся результаты выражений на DAX, рассчи-
танные построчно во время обновления таблицы. По этой причине их можно
использовать для проведения определенных мер по оптимизации времени
выполнения запросов. При этом вычисляемые столбцы таят в себе скрытые
расходы, так что использовать их для оптимизации можно только при соблю-
дении определенных условий.
В общем смысле рассматривать методы оптимизации применительно к вы-
числяемым столбцам можно, только если они хранят:
данные для группировки или фильтрации: если вычисляемый столбец
возвращает значения, которые предполагается использовать для группи-
ровки или фильтрации, нет иной альтернативы, кроме как создавать эти
значения перед загрузкой данных в модель. Например, по цене товары
могут классифицироваться на категории Low, Medium и High. Обычно это
строковые значения, особенно если пользователь может их выбирать;
предварительно рассчитанные сложные формулы: в вычисляемом
столбце можно хранить результат сложного предварительного вычисле-
ния, не чувствительный к выбранному во время запроса фильтру. При
этом бывает достаточно сложно понять, действительно ли такие вычис-
ления дают преимущество. Чтобы определить целесообразность исполь-
зования подобных столбцов в модели, необходимо измерить их эффек-
тивность непосредственно во время запросов.
Не стройте неверных предположений о том, что любой вычисляемый стол-
бец будет работать быстрее, чем аналогичное вычисление, произведенное «на
лету» во время запроса. Часто это будет не так. Иногда преимущество от на-
личия вычисляемого столбца будет столь незначительным, что его не хватит
даже для компенсирования затрат на создание самого столбца. Чтобы соз-
давать вычисляемый столбец в рамках оптимизации модели данных, нужно,
чтобы преимущество от него во время запроса было очень ощутимым. Есть
ГЛАВА 18 Оптимизация движка VertiPaq 649
и другие факторы, которые стоит рассматривать при оценке выгоды от соз-
дания вычисляемого столбца взамен эквивалентных расчетов, производимых
в момент запроса в мере.
Вычисляемые столбцы по своей природе являются не столь хорошо опти-
мизированными, как родные. Уровень компрессии в таких столбцах может
оказываться гораздо более низким по причине того, что они не участвуют
в эвристическом алгоритме, используемом VertiPaq для поиска оптимального
порядка сортировки данных в каждом сегменте. Лишь столбцы с очень малень-
ким количеством уникальных значений могут быть хорошо сжаты, но обычно
это является результатом логических условий, нежели выполнения числовых
выражений.
Рассмотрим для примера простой вычисляемый столбец:
Sales[Amount] = Sales[Quantity] * Sales[Price]
Если в столбце Quantity будет 100 уникальных значений, а в столбце Price -
1000, кратность результирующего столбца Amount может варьироваться между
1 и 100 000 уникальными значениями в зависимости от фактических значений
в исходных столбцах и их распределения по строкам. Как правило, чем больше
строк содержится в таблице, тем больше будет уникальных значений в столбце
Amount согласно закону статистического распределения. Если объем словаря
будет на один или два порядка превышать количество значений в исходных
столбцах, компрессия обычно будет ниже. А как насчет быстродействия запро-
сов? Здесь существует множество факторов, и нужно производить измерения
для каждого отдельного случая, сравнивая производительность вычисляемого
столбца с динамическим расчетом, базирующимся на мере.
Можно написать простую меру, суммирующую значения по вычисляемому
столбцу Amount:
TotalAmountCC := SUM ( Sales[Amount] )
В альтернативной динамической реализации того же самого расчета мы
воспользуемся итератором:
TotalAmountM := SUMX ( Sales; Sales[Quantity] * Sales[Price] )
Будет ли выгоднее просканировать один вычисляемый столбец Sales[Amount]
по сравнению со сканированием двух исходных столбцов Sales [Quantity]
и Sales[Price]? Ответить на этот вопрос заранее невозможно, так что придет-
ся проводить измерения. Чаще всего разница между этими двумя расчетами
будет очевидна только применительно к очень большим таблицам. В незна-
чительных по размеру таблицах показатели быстродействия окажутся очень
близкими, а значит, создавать накладный для памяти вычисляемый столбец
не имеет смысла.
В большинстве случаев вычисляемые столбцы, в которых происходит агре-
гирование значений, могут быть заменены простыми выражениями с исполь-
зованием итерационных функций SUMX и AVERAGEX. Мера TotalAmountM из
предыдущего примера динамически вычисляет то же значение, что и вычис-
ляемый столбец Amount с применением агрегирования в мере TotalAmountCC.
650 ГЛАВА 18 Оптимизация движка VertiPaq
Когда внутри итератора выполняется преобразование контекста, оценивать
производительность стоит уже несколько иначе. Рассмотрим следующую меру
для модели данных, в которой таблицы Sales Header и Sales Detail объединены
при помощи связи:
Averageorder :=
AVERAGEX (
'Sales Header';
CALCULATE (
SUMX (
'Sales Detail';
'Sales Detail'[Quantity] * 'Sales Detail'[Unit Price]
);
ALLEXCEPT ( 'Sales Detail'; 'Sales Header' )
)
В данном случае операция преобразования контекста с циклом может ока-
заться очень дорогостоящей, особенно если в таблице Sales Header будет не-
сколько миллионов записей. Создание вычисляемого столбца здесь, возможно,
позволит выиграть немало времени в момент выполнения запроса.
'Sales Header'[Anount] =
CALCULATE (
SUMX (
'Sales Detail';
'Sales Detail'[Quantity] * 'Sales Detail'[Unit Price]
)
)
AverageOrder :=
AVERAGEX (
'Sales Header';
'Sales Header'[Anount]
)
Мы не устанем повторять, что все приведенные примеры - не более чем
примеры. В каждом отдельном случае эффективность вычисляемых столбцов
может быть разной, и необходимо проводить подобный анализ на конкретной
модели данных.
Также отметим, что вместо создания вычисляемого столбца в модели дан-
ных можно было бы пойти по пути размещения того же значения в родном
столбце прямо в источнике данных при наполнении таблицы - например, при
помощи выражения SQL или преобразования в Power Query. Вычисляемый
столбец должен использовать средства движка VertiPaq для более эффективно-
го и гибкого подсчета значений по столбцу, чтобы лишний раз не обращаться
к таблице в источнике данных. Обычно это происходит, когда в выражении вы-
числяемого столбца агрегируются строки из таблицы, которой этот столбец не
принадлежит. Наш предыдущий пример со столбцом Amount из таблицы Sales
Header как раз удовлетворяет этому условию.
ГЛАВА 18 Оптимизация движка VertiPaq 651
Наконец, создание вычисляемого столбца увеличивает время обновления
модели данных, в частности из-за того, что эта операция не может выполнять-
ся в режиме многопоточности, что будет подробно описано далее в этом раз-
деле.
На данный момент вам должно быть понятно, что вычисляемые столбцы яв-
ляются довольно дорогим удовольствием, и причин этому две:
память: значения в вычисляемом столбце хранятся не в оптимально
сжатом виде;
длительность обновления: обработка вычисляемых столбцов выпол-
няется последовательно с использованием единственного потока, что не
позволяет ускорить процесс даже на более мощных серверах.
Несмотря на все сказанное, вычисляемые столбцы могут быть полезны во
множестве сценариев. Так что мы не хотели бы, чтобы у вас сложилось впечат-
ление, будто создания вычисляемых столбцов необходимо избегать. Нет, прос-
то нужно каждый раз трезво оценивать ситуацию и применять их только там,
где это будет эффективно. В следующем разделе мы приведем пример, в кото-
ром использование вычисляемых столбцов действительно улучшает произво-
дительность.
Оптимизация сложных фильтров при помощи булевых
вычисляемых столбцов
Стоит упомянуть один специфический сценарий, в котором оптимизация до-
стигается именно путем создания вычисляемых столбцов. Логическое выраже-
ние, используемое для фильтрации столбца с высокой кратностью, может быть
сведено в вычисляемый столбец, где будет храниться результат расчета.
Рассмотрим для начала следующую меру:
ExpensiveTransactions :=
COUNTROWS (
FILTER (
Sales;
VAR UnitPrice =
IF (
Sales[Unit Discount] > 0;
RELATED ( 'Product'[Unit Price] );
Sales[Net Price]
)
VAR IsLargeTransaction = UnitPrice * Sales[Quantity] > 100
VAR IsLargePrice = UnitPrice > 70
VAR IsExpensive = IsLargeTransaction || IsLargePrice
RETURN
IsExpensive
)
)
В таблице Sales, состоящей из миллионов строк, итерации внутри фильтра
могут оказаться очень дорогостоящими. Если выражение, использованное
в фильтре, не зависит от существующего контекста фильтра, как в данном слу-
652 ГЛАВА 18 Оптимизация движка VertiPaq
чае, результат выражения может быть сведен в вычисляемый столбец, на кото-
рый при необходимости может быть наложен фильтр в функции CALCULATE. На-
пример, предыдущее выражение может быть переписано следующим образом:
Sales[IsExpensive] =
VAR UnitPrice =
IF (
Sales[Unit Discount] > 0;
RELATED ( 'Product'[Unit Price] );
Sales[Net Price]
)
VAR IsLargeTransaction = UnitPrice * Sales[Quantity] > 100
VAR IsLargePrice = UnitPrice > 70
VAR IsExpensive = IsLargeTransaction || IsLargePrice
RETURN
IsExpensive
ExpensiveTransactions :=
CALCULATE (
COUNTROWS ( Sales );
Sales[IsExpensive] = TRUE
)
Вычисляемые столбцы с логическими значениями (TRUE или FALSE) обыч-
но хорошо сжимаются при хранении и не занимают места в памяти. Кроме
того, такой подход значительно повышает эффективность запросов во время
выполнения, поскольку к таблице Sales во время подсчета строк применяется
прямая фильтрация. Очевидно, что в этом случае запрос будет выполняться
быстрее. Единственное, о чем стоит подумать перед созданием таких вычис-
ляемых столбцов, - это об увеличении времени обработки таблицы. Если это
допустимо, никаких других препятствий не существует.
Обработка вычисляемых столбцов
Присутствие в таблице одного или нескольких вычисляемых столбцов нега-
тивно сказывается на времени обработки любой ее части, так или иначе свя-
занной с этими столбцами. В данном разделе мы поясним, с чем это связано,
а также расскажем о причинах снижения эффективности инкрементального
обновления (incremental refresh) таблиц при наличии вычисляемых столбцов.
Любые операции обновления таблицы требуют полного пересчета всех вы-
числяемых столбцов в модели данных, ссылающихся на столбцы обновляе-
мой таблицы. К примеру, при обновлении одной секции таблицы (как в случае
с инкрементальным обновлением) потребуется полное обновление всех вы-
числяемых столбцов в таблице. При этом данная операция будет выполняться
для всех без исключения строк в таблице, даже если обновление затрагивает
только одну секцию. Не важно, зависит ли выражение вычисляемого столбца
исключительно от значений в столбцах той же самой таблицы: пересчет будет
осуществляться по всей таблице, а не по одной секции.
Но выражение в вычисляемом столбце может также зависеть и от содержимого
других таблиц. В этом случае вычисляемые столбцы, ссылающиеся на частично
ГЛАВА 18 Оптимизация движка VertiPaq 655
обновленную таблицу, также должны быть пересчитаны для поддержания це-
лостности модели данных. Расходы на перерасчет вычисляемого столбца обычно
зависят от количества строк в таблице, которой принадлежит этот столбец.
Обработка вычисляемого столбца выполняется в однопоточном режиме
с построчным проходом по всей таблице и вычислением выражения. Если
в таблице присутствует несколько вычисляемых столбцов, они обрабатывают-
ся по очереди, в результате чего образуется узкое место при обновлении боль-
ших таблиц. Именно поэтому лучше отказаться от идеи создания вычисляемых
столбцов в таблицах с сотнями миллионов записей. Создание десятков вычис-
ляемых столбцов в объемной таблице может значительно увеличить время ее
обработки - счет в этом случае может идти на минуты.
Выбор столбцов для хранения
В предыдущем разделе мы рассказали, почему может быть невыгодно хранить
в таблице вычисляемые столбцы, требующие длительной построчной обработ-
ки с участием других столбцов этой таблицы. Те же доводы можно применить
и к родным столбцам таблицы. Выбирая, какие столбцы хранить в таблице,
всегда руководствуйтесь вопросами занимаемой памяти и быстродействия за-
просов. Правильная оценка в этой области может позволить провести эффек-
тивную оптимизацию в отношении распределения ресурсов в целом и памяти
в частности.
Мы различаем следующие типы столбцов в таблицах:
первичный или альтернативный ключ: в таком столбце содержатся
уникальные значения в каждой строке таблицы;
качественный атрибут: текстовый или числовой столбец, использую-
щийся для группировки и/или фильтрации строк в таблице. Например,
наименование, цвет, город или страна;
количественный атрибут: числовой столбец, использующийся как в ка-
честве фильтра (например, для отсечения значений меньше определен-
ного), так и в качестве аргумента в вычислении (например, цена, сумма
или количество);
описательный атрибут: текстовый столбец, содержащий вспомога-
тельную информацию о строке, при этом его содержимое никогда не ис-
пользуется для фильтрации или агрегации строк (например, заметки или
комментарии);
технический атрибут: информация, содержащаяся в базе данных из
технических соображений и не влияющая на бизнес-процессы. Это мо-
жет быть имя пользователя, внесшего изменения в таблицу последним,
временная метка или глобальный уникальный идентификатор (Globally
Unique Identifier - GUID), необходимый для осуществления репликаций.
Наличие первичного или альтернативного ключа необходимо, если таб-
лица будет объединена с одной или несколькими другими таблицами посред-
ством связей «один ко многим». Например, столбцы с кодом и ключом товара
в таблице Product нужны именно для этого. При этом в таблице не должно быть
654 ГЛАВА 18 Оптимизация движка VertiPaq
первичного или альтернативного ключа, если она не связана с другими табли-
цами по этим столбцам. Например, в исходной таблице Sales может присут-
ствовать идентификатор каждой отдельной продажи. Кратность этого столбца
соответствует количеству строк в таблице. При этом в модели данных таблице
Sales ключ не нужен, поскольку она не находится ни в одной связи на стороне
«один». Сам по себе этот столбец является очень затратным для хранения и не
должен загружаться в память. В составных моделях данных к таким столбцам
с высокой гранулярностью доступ может осуществляться только посредством
движка DirectQuery, без загрузки в память, как будет описано далее в этой главе.
В таблицах всегда должны присутствовать качественные атрибуты с низ-
кой кратностью, поскольку они хорошо сжимаются и могут быть полезными
для проведения анализа. Например, столбец с категорией товаров в таблице
Product характеризуется низкой кратностью. Напротив, столбцы с высокой
кратностью могут не загружаться в модель данных по причине большого рас-
ходования памяти. При этом высокая селективность (selectivity) столбца мо-
жет компенсировать его затратность, но вам необходимо будет следить, чтобы
в фильтрах запросов по этому столбцу присутствовало мало значений. Напри-
мер, мы можем включить в таблицу Sales информацию о номере партии, ко-
торую впоследствии допустимо использовать в фильтре запросов. Затраты на
хранение этого столбца могут быть оправданы требованиями бизнеса к полу-
чению данной информации при помощи установки фильтра.
Все количественные атрибуты обычно импортируются в модель данных
для выполнения необходимых расчетов, хотя мы вправе пропустить тот или
иной столбец во избежание появления избыточности данных. Рассмотрим
столбцы Quantity, Price и Amount в таблице Sales, где Amount содержит резуль-
тат произведения столбцов Quantity и Price. Возможно, нам захочется создать
меры, в которых агрегировались бы значения из этих столбцов. При этом по
цене необходимо вычислять средневзвешенное значение с учетом итогов по
сумме и количеству, а не просто среднее с одинаковым весом по всем транзак-
циям. Вот формулы для расчета таких мер:
Sun of Quantity := SUM ( Sales[Quantity] )
Sun of Anount := SUM ( Sales[Anount] )
Average Price := DIVIDE ( [Sun of Anount]; [Sun of Quantity] )
Глядя на эти формулы, можно подумать, что нам достаточно загрузить в мо-
дель данных только столбцы Quantity и Amount, тогда как в столбце Price нет ни-
какой необходимости, он никак не используется в этих мерах. Но если сделать
еще один шаги обратить внимание на кратности столбцов, появляются сомне-
ния. Если в столбце Quantity содержится 100 уникальных значений, а в столбце
Price - 10 000, мы потенциально можем получить до 1 млн уникальных значе-
ний в столбце Amount. На этом этапе мы можем рассмотреть вариант импорта
столбцов Quantity и Price и использовать следующие формулы для соответству-
ющих мер. Заметьте, что изменилась только формула для меры Sum of Amount,
остальные две остались прежними:
Sun of Quantity := SUM ( Sales[Quantity] )
Sun of Anount := SUMX ( Sales; Sales[Quantity] * Sales[Price] )
Average Price := DIVIDE ( [Sun of Anount]; [Sun of Quantity] )
ГЛАВА 18 Оптимизация движка VertiPaq 655
Новое определение меры Sum of Amount может оказаться более медленным,
поскольку для ее вычисления необходимо будет просканировать два столбца,
а не один. При этом эти столбцы могут быть меньше по размеру, чем исходный
столбец Amount. Сказать заранее, какой подход к созданию мер окажется более
эффективным, очень трудно, ведь для этого надо знать распределение данных
внутри таблиц, а не только их кратность. Мы предлагаем сначала измерить
быстродействие мер и расход памяти для обоих случаев и только после этого
принимать решение. Основываясь на своем опыте, можем сказать, что исклю-
чение столбца Amount из загрузки в небольших моделях данных может срабо-
тать для Power BI и Power Pivot. Чаще всего на персональных компьютерах уста-
новлено не так много памяти, как на серверах, к тому же вариант с меньшим
использованием памяти предполагает более быструю загрузку данных из не-
больших файлов. С другой стороны, в огромной таблице, насчитывающей мил-
лиарды строк и хранящейся в модели Analysis Services Tabular, временные за-
траты на перемножение значений в столбцах Quantity и Price могут превысить
время сканирования столбца Amount. В таком случае для повышения скорости
выполнения запросов можно закрыть глаза на высокую стоимость хранения
в памяти столбца Amount. Необходимо в каждом отдельном случае анализиро-
вать расход памяти и производительность запросов при выборе того или иного
подхода, поскольку распределение данных в таблицах играет одну из ключе-
вых ролей в плане компрессии информации.
Примечание Хранение столбцов Quantity и Price вместо Amoi/nt даст определенное пре-
имущество при использовании движка VertiPaq, но в моделях, применяющих режим Di-
rectQuery, мы бы не назвали такой подход приоритетным. Более того, если таблица, храня-
щаяся в движке VertiPaq, будет насчитывать миллиарды записей, импорт столбца Amount
может дать лучшие результаты в отношении быстродействия запросов, и это решение
будет совместимо с механизмом создания агрегатов в VertiPaq, о которых мы поговорим
далее в данной главе.
к>
По поводу описательных атрибутов таблицы необходимо принять реше-
ние - загружать их в модель данных или нет. Обычно они занимают довольно
много места в памяти, что связано с большим размером словарей. Примерами
таких атрибутов могут быть столбец Notes (Заметки) в таблице счетов или стол-
бец Description (Описание) в таблице Product. Чаще всего эти поля нужны для
сопровождения сущности дополнительной информацией. Пользователи вряд
ли будут применять их для выполнения фильтрации или группировки - обыч-
но их использование сводится к получению детальной информации. Един-
ственной проблемой, связанной с импортом таких столбцов в модель данных,
является их большая требовательность к памяти при хранении. Если в столбце
присутствует много пустых значений, а уникальных непустых значений не так
много, словарь будет относительно маленьким и хранение столбца в модели
будет оправданным. С другой стороны, хранить полные расшифровки разгово-
ров сотрудников колл-центра в таблице Service Calls, содержащей информацию
о дате, времени, длительности разговора и имени оператора, будет весьма рас-
точительно. В подобных случаях мы можем настроить доступ к таким столбцам
в режиме DirectQuery путем реализации составной модели данных.
656 ГЛАВА 18 Оптимизация движка VertiPaq
Некоторые описательные атрибуты предназначены для хранения инфор-
мации, доступ к которой должен осуществляться при помощи операции де-
тализации (drill-through). Например, номер счета или заказа представляет со-
бой атрибут с высокой кратностью, но эта информация может понадобиться
в определенных отчетах. В этом случае мы должны рассмотреть особый вари-
ант оптимизации, применяемый для столбцов детализации, о чем бы подроб-
но расскажем в следующем разделе.
Технические атрибуты, такие как временные метки, дата, время и имя
пользователя, внесшего изменения в таблицу последним, чаще всего нет не-
обходимости импортировать в модель данных. Эта информация главным об-
разом нужна для выполнения аудита и различных проверок. Если наша мо-
дель данных не предназначена для аудита, эти данные нам понадобятся вряд
ли - с точки зрения аналитики они не несут никакой смысловой нагрузки. При
этом технические атрибуты отлично подходят на роль кандидатов для доступа
исключительно в режиме DirectQuery в составной модели данных.
Оптимизация хранения столбцов
Лучшим способом оптимизации столбца является его удаление из таблицы.
И в предыдущем разделе мы подробно обсудили, в каких случаях и для ка-
ких типов столбцов такая крайняя мера допустима. Определившись с набором
столбцов, который будет присутствовать в нашей модели данных, мы все еще
можем применять к конкретным столбцам те или иные способы оптимизации,
направленные на уменьшение занимаемого места в памяти. При этом сразу
заметим, что у любой оптимизации есть свои побочные эффекты. Кроме того,
если мы имеем дело с составной моделью данных, в нашем распоряжении есть
дополнительная опция хранения столбца в источнике данных и обращения
к нему посредством движка DirectQuery.
Оптимизация при помощи разделения столбцов
Занимаемое столбцом место в памяти можно уменьшить путем снижения его
кратности. При определенных условиях этого можно добиться при помощи
разделения содержимого столбца на две или более частей. Делать это в вычис-
ляемом столбце нет никакого смысла, поскольку в этом случае исходный стол-
бец все равно будет полностью загружен в память. Мы покажем вам варианты
разделения столбцов на части при помощи языка запросов SQL, но похожего
эффекта можно достичь и с использованием любого инструмента преобразо-
вания данных, например Power Query.
Допустим, у нас есть столбец с именем TransactionlD (ID транзакции) и дли-
ной десять символов. Следующим образом мы можем разбить этот столбец
в источнике данных на два столбца (TransactionlDHigh и TransactionlD Low):
SELECT
LEFT ( TransactionlD, 5 ) AS TransactionID_High,
SUBSTRING ( TransactionlD, 6, LEN ( TransactionlD ) - 5 ) AS TransactionID_Low,
ГЛАВА 18 Оптимизация движка VertiPaq 657
Если в столбце хранятся целочисленные данные, можно применить опера-
цию деления и взятия остатка от деления, чтобы разделить столбец на две рав-
ные части. Если, к примеру, в целочисленном столбце TransactionlD хранятся
значения от 0 до 100 000 000, можно разделить их по границе 10 000, как по-
казано ниже:
SELECT
TransactionlD / 10000 AS TransactionID_High,
TransactionlD % 10000 AS TransactionID_Low,
Похожую технику допустимо использовать и применительно к дробным
значениям. Проще всего выделить целую часть и дробную, хотя в этом случае
разделение не будет равномерным. Например, можно следующим запросом
разделить столбец UnitPrice на целую часть (UnitPrice lnteger) и дробную (Unit-
Price_Decimal):
SELECT
FLOOR ( UnitPrice ) AS UnitPrice_Integer,
UnitPrice - FLOOR ( UnitPrice ) AS UnitPrice_Decimai,
Результаты таких разделений можно использовать в простых отчетах или
мерах, восстанавливающих исходное значение во время вычисления. Если по-
зволяет клиентский инструмент, можно настроить детализацию, показывая
пользователю исходный столбец и скрывая два столбца с разделенными дан-
ными.
Важно Разделение на целую и дробную части позволяет оптимизировать числовые
данные, агрегированные в мерах. Но помните, что во время агрегирования движку при-
дется сканировать два столбца вместо одного, а значит, возрастет и время выполнения
операции. При оптимизации производительности расход памяти может получиться не-
оптимальным, если не избавиться от словарей путем принудительного включения коди-
рования столбца на основании значений, а не при помощи хеш-таблиц для значений
целочисленного или валютного типа. Как и всегда, в этом случае необходимо провести
дополнительную проверку эффективности выбранного способа оптимизации.
\.
Оптимизация столбцов с высокой кратностью
Причины высокого расхода памяти на хранение столбцов с высокой кратно-
стью кроются в большом размере словаря и структуре иерархии, а также в низ-
кой степени сжатия. Структура иерархии атрибута бывает очень объемной, но
при определенных условиях ее можно отключить, о чем мы расскажем в сле-
дующем разделе.
Если отключить иерархию невозможно или этого оказывается недостаточно
для оптимизации памяти, можно рассмотреть вариант с разделением столбца
с высокой кратностью, используемого в мере. Эту оптимизацию можно скрыть
от глаз пользователя, соответствующим образом адаптировав вычисления
в мерах и не показывая ему разделенные столбцы. Например, если мы опти-
658 ГЛАВА 18 Оптимизация движка VertiPaq
мизируем столбец UnitPrice с использованием разделения на столбцы, можно
прописать код меры Sum of Amount следующим образом:
Sum of Amount :=
SUMX (
Sales;
Sales[Quantity] * ( Sales[UnitPrice_Integer] + Sales[UnitPrice_Decimal] )
)
Помните, что время вычисления выражения в этом случае может увеличить-
ся, и определиться с тем, применять оптимизацию с разделением столбцов
или нет в конкретной модели данных, можно только после проведения соот-
ветствующих измерений.
Отключение иерархий атрибутов
Структуры иерархий атрибутов необходимы запросам MDX, которые обраща-
ются к столбцам как к иерархиям атрибутов. Эта структура содержит отсор-
тированный список всех значений столбца, а ее создание может потребовать
немало времени при обновлении данных, включая инкрементальное обновле-
ние. Размер этой структуры выводится в VertiPaq Analyzer в колонке с названи-
ем Columns Hierarchies Size. Но если столбец используется только в мерах и дета-
лизациях и недоступен пользователю в качестве атрибута для фильтрации или
группировки данных, структуру иерархии атрибута можно не создавать вовсе,
поскольку она не будет использоваться.
Установив свойство столбца Available In MDX в значение False, вы тем самым
отключите создание для него структуры иерархии атрибута. По умолчанию это
свойство содержит значение True. В языке сценариев табличных моделей (Tabu-
lar Model Scripting Language - TMSL) и табличной объектной модели (Tabular
Object Model - ТОМ) имя этого свойства - isAvailablelnMdx. В зависимости от ин-
струмента разработки и уровня совместимости (compatibility level) модели дан-
ных это свойство может быть недоступно. Инструмент, в котором есть это свой-
ство, - это Tabular Editor: https://github.com/otykier/TabuLarEditor/reLeases/Latest.
Структура иерархии атрибута также используется в DAX для выполнения оп-
тимизации операций сортировки и фильтрации. Можно безопасно отключить
свойство isAvailablelnMdx, если столбец используется только в выражениях мер,
невидим для пользователя и никогда не используется для фильтрации и сор-
тировки данных. Документацию по этому свойству можно найти по адресу:
https://docs.microsoft.com/en-us/dotnet/api/microsoft.anaLysisservices.tabuLar.coL-
umn.isavaiLabLeinmdx.
Оптимизация атрибутов детализации
Если в столбце содержатся данные, используемые исключительно для выпол-
нения операций детализации, к нему можно применить два типа оптимиза-
ции. Первый состоит в методе разделения столбца, а второй - в организации
доступа к нему только в режиме DirectQuery при использовании составной мо-
дели данных.
ГЛАВА 18 Оптимизация движка VertiPaq 659
Если столбец не используется в мерах, не стоит волноваться о расходах,
связанных с материализацией исходных значений. Воспользовавшись вы-
ражением строк детализации (Detail Rows Expression), можно показать поль-
зователю исходный столбец в результатах детализации, скрыв от него факт
присутствия двух столбцов с разделенными данными. При этом вы не смо-
жете использовать исходные значения столбца для выполнения фильтрации
или группировки.
В составных моделях данных можно давать доступ к таблице только посред-
ством движка DirectQuery, при этом столбцы, используемые в связях и мерах,
могут быть включены во внутренние агрегаты в памяти, управляемые движ-
ком VertiPaq. Таким образом можно добиться наилучшей производительности
при агрегировании данных, тогда как время выполнения запроса при обраще-
нии к атрибутам детализации в режиме DirectQuery увеличится. В следующем
разделе мы более подробно расскажем об этой особенности.
Управление агрегированием VertiPaq
Движок хранилища VertiPaq может быть использован для управления агрегиро-
ванием при работе с источниками данных DirectQuery, а в будущем - и с боль-
шими таблицами VertiPaq. Агрегаты впервые были представлены в Power BI
в конце 2018 года. В будущем они должны появиться и в других продуктах.
Целью создания агрегатов является снижение расходов на выполнение за-
просов движком хранилища данных путем отказа от дорогостоящих запросов
DirectQuery, в случае если вся необходимая информация присутствует в более
мелких таблицах с рассчитанными агрегатами.
Агрегаты необязательно относятся исключительно к VertiPaq, их можно
определять и в модели данных DirectQuery для доступа к разным таблицам
в источнике данных в зависимости от гранулярности клиентского запроса. Од-
нако типичным является использование агрегатов в составных моделях дан-
ных, в которых таблицы могут характеризоваться тремя режимами хранения:
Import: таблица хранится в памяти и управляется движком хранилища
VertiPaq;
DirectQuery: данные хранятся в источнике, и при необходимости DAX
генерирует запросы непосредственно к источнику данных, в основном на
языке SOL;
Dual: таблица хранится в памяти движком VertiPaq, но может быть ис-
пользована и в режиме DirectQuery, обычно в объединениях с другими
таблицами, хранящимися в режиме DirectQuery или Dual.
Агрегаты существуют для того, чтобы у движка хранилища данных были аль-
тернативы при обработке поступающих запросов. Например, в таблице Sales
могут храниться все транзакции по товарам, покупателям и датам. Создавая
агрегаты по товарам и месяцам, мы приходим к таблицам гораздо меньшего
объема. При этом на основании таблицы Sales может быть построено несколь-
ко агрегатов - каждый со своим приоритетом (precedence), который исполь-
зуется в случае, если для выполнения запроса подходит не один агрегат. До-
660 ГЛАВА 18 Оптимизация движка VertiPaq
пустим, в нашей модели данных с таблицами Sales, Product, Date и Store могут
присутствовать следующие агрегаты:
Product и Date -приоритет 50;
Store и Date - приоритет 20.
Если нам необходимо получить итоги продаж по брендам и годам, может
быть использован первый агрегат. Этот же агрегат подойдет для выполнения
детализации до месяцев или дней. Фактически при помощи агрегатов, в ко-
торых хранятся данные по продажам на уровне гранулярности Product и Date,
можно обработать любой запрос, группирующий строки с использованием
атрибутов из этих таблиц. Пользуясь той же логикой, при обработке запросов
по странам магазинов (Store) и годам будет применен второй агрегат, создан-
ный на уровне гранулярности Store и Date. При этом запрос по бренду товара
и стране магазина не сможет использовать ни один из существующих агре-
гатов. При обработке таких запросов будет выполнено обращение к исходной
таблице Sales, поскольку ни одна из созданных агрегаций не содержит грану-
лярности, сопоставимой с запросом. Если же, наоборот, два и более агрегата
являются сопоставимыми с запросом, выбор будет сделан на основании прио-
ритетов, определенных при их создании: движок отдаст предпочтение агре-
гации с большим приоритетом. В табл. 18.6 сведены запросы и используемые
при их обработке агрегаты в нашем примере.
ТАБЛИЦА 18.6 Примеры агрегатов, используемых при обработке запросов
Запрос Используемая агрегация
Группировка по бренду и году Product и Date
Группировка по бренду и месяцу Product и Date
Группировка по стране магазина и году Store и Dote
Группировка по стране магазина и месяцу Store и Date
Группировка по году Product и Date (более высокий приоритет)
Группировка по месяцу Product и Date (более высокий приоритет)
Группировка по стране магазина и бренду Агрегации не используются, запрос пойдет к таблице Sales
Движок делает выбор в пользу того или иного агрегата, используя исключи-
тельно их приоритет и не принимая во внимание режим хранения агрегатов.
Фактически каждый агрегат представляет собой таблицу, которая может быть
сохранена как в режиме VertiPaq, так и в режиме DirectQuery. Здравый смысл
подсказывает, что лучше будет хранить агрегированные таблицы в режиме
VertiPaq, а не в DirectQuery. Но DAX следует исключительно правилу распреде-
ления приоритетов. Если у агрегата, хранящегося в режиме DirectQuery, будет
более высокий приоритет по сравнению с агрегатом VertiPaq и оба агрегата
смогут ускорить выполнение запроса, предпочтение будет отдано первому.
Установка приоритетов - зона ответственности разработчика.
Агрегирование может быть применено при обработке запроса, основываясь
на следующих характеристиках:
гранулярности связей, используемых в запросе движка хранилища дан-
ных;
ГЛАВА 18 Оптимизация движка VertiPaq 661
совпадении столбцов группировки (GroupBy) в агрегатах, использующих
суммирование;
суммировании, соответствующем простому агрегированию по одному
столбцу;
присутствии подсчета количества (Count) в исходной таблице.
Эти условия могут оказать влияние на схему модели данных. Модель дан-
ных, предполагающая загрузку всех таблиц в режиме VertiPaq, обычно разраба-
тывается с прицелом на минимизацию используемой памяти. Как мы уже упо-
минали в предыдущем разделе, хранения в модели столбцов Quantity и Price
вполне достаточно, чтобы рассчитывать Amount во время выполнения запроса,
используя следующую меру:
Sales Anount := SUMX ( Sales; Sales[Quantity] * Sales[Price] )
Эта версия меры Sales Amount не сможет воспользоваться агрегатом типа Sum,
поскольку такие агрегаты ссылаются только на один столбец. При этом сопо-
ставление с агрегатом было бы возможно, если бы по столбцам Sales [Quantity]
и Sales[Price] было выполнено агрегирование GroupBy, а по таблице Sales -
Count. Для сложных выражений бывает не так просто подобрать эффективную
агрегацию, и для этого может потребоваться изменить схему модели данных.
Давайте рассмотрим следующий пример. Если у нас есть два агрегата типа
Sum для столбцов Sales[Amount] и Sales[Cost], то мера Margin должна быть реа-
лизована с использованием разницы между двумя агрегатами (Marginl иМаг-
gin2), а не построчного накопления разницы (Margins).
Sales Amount := SUM ( Sales[Amount] ) -- Используется агрегат Sun
Total Cost := SUM ( Sales[Cost] ) -- Используется агрегат Sun
Marginl := [Sales Anount] - [Total Cost] -- Используется агрегат Sun
Margin2 := SUM ( Sales[Anount] ) - SUM ( Sales[Cost] ) -- Используется агрегат Sun
Margin3 := SUMX ( Sales; Sales[Anount] - Sales[Cost] ) --HE используется агрегат Sun
При этом мера Margins могла бы быть сопоставима с агрегатом GroupBy для
столбцов Sales[Amount] и Sales[Cost], включающим подсчет значений (Count) по
таблице Sales. Такой агрегат мог бы подойти и мерам Sales Amount с Total Cost,
хотя он и был бы менее эффективным в сравнении с агрегатом Sum по кон-
кретному столбцу.
По состоянию на апрель 2019 года агрегаты могут использоваться только
для таблиц в режиме DirectQuery. Функционал для применения агрегирования
к таблицам, сохраненным в памяти, может появиться в следующих версиях
движка, в результате чего будут доступны все перечисленные комбинации:
агрегирование DirectQuery для таблиц в режиме DirectQuery;
агрегирование VertiPaq для таблиц в режиме DirectQuery;
агрегирование VertiPaq для таблиц в режиме VertiPaq (недоступно по со-
стоянию на апрель 2019 года).
Возможность создавать агрегаты VertiPaq для таблиц, хранящихся в режиме
VertiPaq, позволит оптимизировать два сценария для моделей данных, загру-
женных в память: при работе с большими таблицами (насчитывающими мил-
лиарды строк) и со связями с высокой кратностью (с миллионами уникальных
662 ГЛАВА 18 Оптимизация движка VertiPaq
значений). С этими сценариями можно справиться и в ручном режиме путем
изменения модели данных или написания кода на DAX, как было описано ра-
нее в этой главе в разделе, посвященном денормализации. Агрегирование по
таблицам, сохраненным в режиме VertiPaq, позволит автоматизировать этот
процесс, что приведет к повышению производительности запросов, облегче-
нию их сопровождения и снижению расходов на разработку.
Заключение
В данной главе мы сосредоточились на способах оптимизации модели данных,
загруженной в память при помощи движка хранилища VertiPaq. Цель оптими-
зации заключается в снижении памяти, необходимой для хранения модели,
и, как результат, повышении производительности запросов. VertiPaq может
также использоваться для хранения агрегатов в составных моделях данных,
совместно применяющих режимы DirectQuery и VertiPaq в одной модели.
Основные выводы из этой главы:
импортируйте в память только столбцы, необходимые для анализа;
осуществляйте контроль над кратностью столбцов, принимая во внима-
ние то, что более низкая кратность обеспечит лучшее сжатие;
храните дату и время в разных таблицах и с необходимым для анализа
уровнем гранулярности. Хранение данных с большей гранулярностью,
чем необходимо (например, с детализацией до миллисекунд), приведет
к увеличению расхода памяти и снижению производительности запросов;
рассмотрите вариант создания агрегатов для доступа к данным, храня-
щимся в режиме DirectQuery в составных моделях.
ГЛАВА 19
Анализ планов выполнения
запросов DAX
DAX является функциональным языком с мощным движком запросов, способ-
ным использовать разные движки хранилища данных. Как и в случае с другими
языками запросов, в DAX можно получить одни и те же результаты, применяя
разные выражения, каждое из которых будет выполняться по-своему. Оптими-
зация меры или запроса подразумевает нахождение наиболее эффективного
пути решения задачи. А для того чтобы выявить оптимальный вариант нахож-
дения требуемого результата, для начала необходимо определить узкое место
в запросе.
В данной главе мы подробно рассмотрим составляющие части движка запро-
сов DAX и расскажем, как получить детальную информацию о планах выпол-
нения запросов и измерить их производительность при помощи DAX Studio.
Без этих знаний оптимизировать запросы на языке DAX просто невозможно.
Перехват запросов DAX
Чтобы приступить к анализу плана выполнения запроса, необходимо сначала
выполнить этот самый запрос. Отчеты в Power BI или Excel автоматически ге-
нерируют запросы, извлекающие меры, включенные в модель данных. Таким
образом, для выполнения оптимизации кода меры нужно проанализировать
и оптимизировать запрос на DAX, включающий эту меру. И перехват запросов,
сгенерированных для формирования отчетов, является первым шагом в про-
цессе оптимизации кода на DAX. Фактически один медленный отчет способен
создавать десятки запросов. И если вы прилежный разработчик, то должны вы-
делить самый медленный из всех выполняемых отчетом запросов и для начала
устранить это узкое место.
DAX Studio (http://daxstudio.org/) является свободно распространяемым про-
граммным продуктом с открытым исходным кодом и предлагает сразу не-
сколько удобных способов для перехвата и анализа запросов DAX. В следу-
ющем примере мы рассмотрим вариант подключения DAX Studio к модели
данных Power BI для перехвата запросов, генерируемых отчетом.
В отчете Power BI, показанном на рис. 19.1, содержится одна визуализация,
заполняющаяся медленнее остальных. Таблице в левом нижнем углу со столб-
цами Product Name и Customers требуется несколько секунд для обновления при
664 ГЛАВА 19 Анализ планов выполнения запросов DAX
открытии отчета и выборе пользователем другого континента в фильтре спра-
ва внизу. Мы знаем об этой особенности отчета и допустили ее намеренно.
Но как определить достоверно, какой элемент визуализации в отчете самый
медленный? В этом вам поможет DAX Studio.
(Blank) 20 45К 4450K 8.46K 1423K 876 65 1 Red
6.04К 3.79К 325.71 (Blank) 2.78K
Adnsuri Afcikr CoMuK f br* rr Lh*> "c Northwmd Traded1
22 75К 80.95К 95.83K 8216K 24.36K 9499
Pliняжг SoUthndor Vxfeo If1 Phnnr Company Mfcirtl MMe krpw1" , 1 Bhck
73.49К 81.42К 5 24K 37.71 К 94.08K
A. CWхт AArr-ui- Лк rV Г ftntfKT' M Un i Nnrtt I
1.93К 9.12K I764K 63.97K 14.78K 64.66K
П.чемм* SoutfmOsr VMBO takpai loy. 11к itrixh ллаиме io.pt, Blue
4.63К 1.42K 7.77K (Blank) 27.Л2К
Cont ine nt
Asia
Europe
North America
1 Product Name Customers
Adventure Worts 2fi 720p LCD HfTV M1 *0 Iver 270
SV16-DVD M360 Black 270
Д. Datum SLRC*nt4 XI37 G*«y 103
Total 21W
Мп
Рис. 19.1 Отчет в Power Bl с несколькими элементами визуализации,
один из которых работает дольше остальных
DAX Studio умеет подключаться к движку Power BI путем выбора файла, от-
крытого в данный момент в Power BI Desktop на том же самом компьютере. Эта
схема подключения показана на рис. 19.2.
Connect
Data Source
PowerPivot Medel 0
PBI / SSDT Mode Fl 9 31
Tabular Se iver
v Ad va need Opt on s
Connect Cancel
Рис. 19.2 DAX Studio может подключаться к разным типам моделей Tabular,
включая Power Bl
После подключения можно переключить DAX Studio в режим перехвата всех
запросов к движку Tabular. Для этого достаточно на вкладке Ноте нажать
кнопку All Queries в группе Traces. Этот выбор показан на рис. 19.3.
ГЛАВА 19 Анализ планов выполнения запросов DAX 665
Home Advanced l-etp
► 0 6" Run Carre. C!ear Output - СасЪе Query X. Cut It' Undo DAX A To Upoer . Comment P Find
f4 Copy Redo Paste Edit Format Query fl To Lower > I Swap Delimiters Fomat Uncomment CV Merge XML нь Replace Frnd
А-"
All Query Server
Queries Plan Timings
Traces
Рис. 19.3 Кнопка All Queries позволяет перехватывать все запросы,
адресованные движку Tabular
Любое действие в клиентском приложении будет генерировать один или не-
сколько запросов. Например, Power BI создаст как минимум по одному запросу
ЭАХдля каждого элемента визуализации. На рис. 19.4 показаны запросы, пере-
хваченные в отчете из рис. 19.1 при выборе азиатского континента в срезе.
и
StartTime Type Durat on Use Detacase Query*
05:51 56 DAX 2 379 marc о F19 01 DEFINEVAR _DSOFilterTable = TREATAS({"Asia"}
05:51 56 DAX 63 rra'cc F19 01 DEFINEVAR _DSOFilterTab e = TREATASffSoutH
05:51 56 DAX 51 marco F19 01 DEFINE VAR -DSOFilterTab e =TREATAS(( Prose
05:5 56 DAX 65 marco F19 01 DEFINE VAR _DSOFilterTab e = TREATAS^Advei
05:5 ' 56 DAX 76 matco F1S 01 DEFINE VAR —DSOFilterTable = TREATAS(( Ad\ er*
05:51 56 DAX 55 marco F19 01 DEF NEVAR _DSOFilterfob‘e * TREATAS^ Cortq
35:5 1 56 DAX 4- marco F19 01 DEFINE VAR _DSGFilterTabl e = TR EATAS({‘W ce
05:5 ’ 56 DAX 47 marco F19 01 DEFINE VAR __DSCFilterTsb!e - TREATASffVV ce
05:51 56 DAX 26 mi'cc F19 01 DEFINE VAR _DSCFilterTable = TREATAS^ A. Da
05:51 56 DAX 43 ma'cc FIS 31 DEF NEVAR _CSCFi terTf e x TREATAS(( Litwai
Output Result 5 Query - st or > All Queries
Рис. 19.4 На вкладке All Queries показаны все запросы, перехваченные DAX Studio
Примечание DAX Studio прослушивает все запросы, направленные серверу Tabular.
При подключении DAX Studio к Power Bl Desktop все запросы всегда будут выполняться
к одной базе данных и от одного пользователя. Разные файлы Power Bl требуют разных
подключений в отдельных окнах DAX Studio. При этом при подключении к Analysis Ser-
vices (для чего понадобятся права администратора) могут показываться запросы к раз-
ным базам данных от различных пользователей. Для клиентских приложений вроде Excel
тип генерируемых запросов всегда будет MDX. В колонке Duration отображается время
выполнения запроса в миллисекундах, а в колонке Query - полный текст запроса, выпол-
ненного на сервере.
\__________________________________________________________________________)
По рис. 19.4 видно, что первый запрос выполнялся почти три секунды. При
этом все остальные запросы выполнились очень быстро, а значит, они не тре-
буют нашего внимания. В реальном отчете вы наверняка увидите сразу не-
сколько медленных запросов. DAX Studio позволит вам легко выделить самые
медленные из них и сконцентрировать все свое внимание на них, не отвлека-
ясь на меры и запросы, рассчитывающиеся достаточно быстро.
Двойной щелчок мыши по строке запросов на вкладке All Queries приведет
к открытию запроса в главном окне редактора. На рис. 19.5 показан полный
666 ГЛАВА 19 Анализ планов выполнения запросов DAX
текст первого запроса из предыдущего списка. Нажав кнопку Format Query на
вкладке Format, можно воспользоваться веб-службой DAX Formatter для фор-
матирования запроса.
Идентифицировав медленный запрос, можно выполнить его в DAX Studio
несколько раз. Таким образом можно проанализировать план выполнения за-
проса и другие метрики для внесения изменений, которые позволят ускорить
его выполнение. В следующих разделах мы проанализируем простые запросы,
написанные в целях образования с нуля, но в своей работе вы можете исполь-
зовать DAX Studio для оптимизации реальных запросов.
Run Cancel Gear
’ Cache
Query
Query' сел* X
Ackarcec he'p
EH X Cut fcC' Undo
1 Copy "У Redo
Output
, ’ »ste
Edit
DAX
gi. ««Mill
Format
Query
A To Upper
fl 1c Lower
*; Swap Delimiters
Format
1 Comment
9l Uncomment
a. Merge XM1
Find
Replace
Find
Dax Studio
All
Queues
Metedcta ▼ Ц
И F1901
ft Model
Customer 4
t Date
t Proc-ct
Sales
[ Segments
1 DEFINE
2 var _jpsomtenTable *
3 TR.ZATAS C
4 {
5 "A51 a”
}.
7 ’custorer’[con-inent;
8 )
4 EVALUATE
10 10PN (
11 302,
12 SUMMARIZECOLUMNS (
13 rollupaduisSuutotal * product 1 [produce Nar^J, ”isGrandroralRo*rota'>"
14 .J5S0H1 terTable,
15 Custo-iers", '5г 1es'[Custoners]
16 ),
17 riserandTotalRo*Total], o,
13 rcustoTiers], 0,
19 Prod-c»’"Prodjcc Nave], 1
20 >
21 ‘IRDER BY
22 [isGrard-jia«R3..-uta'] desc,
23 LCu5taT.er5] DESC.
24 'Prodi cr‘Trodjcr NareJ
Рис. 19.5 Кнопка Format Query вызывает DAX Formatter для форматирования текста
запроса на DAX
Введение в планы выполнения запросов
Движок DAX расписывает подробности выполнения запросов при составле-
нии их планов. При этом сам термин «план выполнения запроса» является об-
щим и включает в себя два типа плана - логический (Logical Query Plan) и фи-
зический (Physical Query Plan), - а также список запросов движка хранилища
данных, используемых физическим планом выполнения. По умолчанию, если
не указано иное, общий термин план выполнения запроса описывает все пе-
речисленное. В этом разделе мы опишем базовые понятия, связанные с пла-
нами запросов, а далее в этой главе расскажем о процессе оптимизации более
подробно.
В главе 17 мы говорили, что движок запросов DAX состоит из двух уровней:
движка формул и движка хранилища данных. Каждый запрос DAX при выпол-
нении проходит следующие стадии.
ГЛАВА 19 Анализ планов выполнения запросов DAX 667
1. Создание дерева выражения (Expression Tree). Движок трансформи-
рует запрос из текста в дерево выражения, представляющее собой струк-
туру данных, пригодную для оптимизации.
2. Построение логического плана выполнения запроса. Движок гото-
вит список логических операций, требуемых для выполнения запроса.
Это дерево логических операторов напоминает исходный синтаксис за-
проса. Легко найти соответствие между функциями DAX и соответству-
ющими операторами в логическом плане выполнения запроса.
3. Построение физического плана выполнения запроса. Движок пере-
водит логический план выполнения запроса в набор физических опера-
ций. Физический план запроса по-прежнему представляет собой дерево
операторов, которое может серьезно отличаться от логического плана.
4. Выполнение физического плана выполнения запроса. На заключи-
тельном шаге движок отправляет на выполнение полученный физиче-
ский план запроса, извлекая данные из хранилища и вычисляя выраже-
ния запроса.
Первый шаг не представляет интереса для процесса оптимизации запроса.
Шаги 2 и 3 выполняются движком формул, тогда как на четвертом шаге вступа-
ет в действие движок хранилища данных. Чисто технически третий шаг явля-
ется наиболее важным в плане определения того, как именно работает запрос,
хотя физически план запроса становится доступным только после выполнения
запроса на шаге 4. Таким образом, необходимо дождаться окончания выпол-
нения запроса, перед тем как приступать к анализу физического плана выпол-
нения. При этом в процессе выполнения заключительного шага появляются
другие важные составляющие в виде запросов движка хранилища данных, ко-
торые прочитать легче, чем физический план запроса. Далее мы увидим, что
процесс анализа запроса часто начинается с оценки запросов движка хранили-
ща, сгенерированных на четвертом шаге.
Примечание Движок Tabular может обрабатывать запросы как на языке MDX, так и на
языке DAX, несмотря на то что родным для него является последний. При этом движок не
транслирует запросы MDX в DAX. Вместо этого при обработке запросов MDX создаются
как логический,так и физический планы выполнения запроса по образу и подобию DAX.
Помните, что аналогичные запросы, написанные на языках DAX и MDX, обычно генери-
руют разные планы выполнения, несмотря на одинаковые результаты. Здесь мы главным
образом сосредоточимся на языке DAX, но приведенной в этой главе информации будет
достаточно для понимания того, как движок Tabular обрабатывает запросы MDX.
\_________________________________________________________________/
Создание плана выполнения запроса
Как мы уже упоминали в предыдущем разделе, при обработке запроса на язы-
ке DAX создается два плана выполнения запроса: логический и физический.
В этих планах подробно описаны операции, выполняемые движком запросов.
К сожалению, планы запроса доступны только в текстовом виде, а не в графи-
ческом. Поскольку типичный план запроса бывает длинным и сложным, не-
обходимо предварительно использовать другие средства и техники оптимиза-
ции выражений DAX, прежде чем приступать к анализу текста плана запроса.
668 ГЛАВА 19 Анализ планов выполнения запросов DAX
При этом очень важно понимать основы синтаксиса планов выполнения за-
просов DAX, чтобы представлять поведение движка и быстро определять нали-
чие узких мест в длинных и сложных планах выполнения запросов. Сейчас мы
посмотрим на примере простых запросов, какие планы генерируются. Как вы
увидите, даже несложные запросы способны приводить к созданию довольно
запутанных планов выполнения.
Для начала рассмотрим простой пример запроса, запущенный в DAX Studio:
EVALUATE
{ SUM ( Sales[Quantity] ) }
Результатом этого табличного конструктора будет таблица, состоящая из
одной строки и одного столбца (Vd/ue), с суммой по столбцу Quantity по всем
строкам из исходной таблицы Sales, что видно по рис. 19.6.
Value
140180
Рис. 19.6 Результат запроса с простым табличным конструктором
с одной строкой и одним столбцом
В следующих разделах мы покажем планы выполнения, сгенерированные
и выполненные по этому запросу. Позже вы узнаете, как получать эту инфор-
мацию для любого запроса. На этом этапе сконцентрируйтесь на роли и струк-
туре планов выполнения запросов, а также на том, какую информацию они
предоставляют.
Логический план выполнения запроса
Логический план выполнения запроса является, по сути, представлением дерева
выражения запроса DAX. На рис. 19.7 показан логический план выполнения
предыдущего запроса.
Line Logical Query Plan
1 AddColumns: RelLogOp DependCnColsOO 0-0 RequiredCols(0)('(Value])
2 Sum.Vertipaq: ScaLogOp DependOnCols(X) Integer DominantValue=BLANK
3 Scan_Vertipaq: ReHogCp DepencOrColsOO 0-110 RequiredCols(86)('Sales [Quantity])
4 SalesIQuantity]: ScaLogOp DependOnCol$(86)(Sale$1Quantity]) integer DominantValue=NONE
Рис. 19.7 Логический план выполнения простого запроса
Каждая строка плана представляет собой оператор, а в следующих строках,
с отступами, указаны параметры оператора. Если проигнорировать эти пара-
метры, можно представить полученный план в упрощенном виде:
AddColumns:
Sum_Vertipaq:
Scan_Vertipaq:
'Sales'[Quantity]:
Внешним оператором является AddColumns. Он создает таблицу с одной
строкой и столбцом с именем Value, в которую будет помещен результат запро-
са DAX. Оператор Sum VertiPaq сканирует таблицу Sales и выполняет суммиро-
ГЛАВА 19 Анализ планов выполнения запросов DAX 669
вание по столбцу Sales [Quantity]. В двух строках внутри оператора SumVertipaq
находятся оператор ScanVertipaq и ссылка на сканируемый столбец.
На понятном человеку языке этот логический план запроса можно прочи-
тать так: «Создать таблицу со столбцом Value и поместить в нее результат опе-
рации суммирования, выполненной движком хранилища данных путем ска-
нирования столбца Quantity в таблице Sales».
Логический план выполнения запроса демонстрирует, что собирается де-
лать движок запросов DAX для вычисления результата. Неудивительно, что он
собирается просканировать таблицу Sales, агрегируя столбец Quantity при по-
мощи функции SUM. Разумеется, планы более сложных запросов расшифро-
вать будет не так просто.
Физический план выполнения запроса
Физический план выполнения запроса имеет тот же формат, что и план логиче-
ский. В каждой строке находится оператор, а на следующих строках с отступом
в один символ табуляции - его параметры. Если не считать это внешнее сход-
ство, в двух планах запросов используются совершенно разные операторы. На
рис. 19.8 показан физический план выполнения предыдущего запроса DAX.
Line Records Physical Query Plan
1 AddColumns: IterPhyCp LogOp=AddColurnns lterCols{0)( [Value])
2 SmgletonTab'e: IterPhyOp LogOp=AddColumns
3 1 SpoolLookup: LookupPhyOp LogCp=Sum_Vertipaq integer ~Records=1 *KeyCc
4 1 PrcjectionSpcol<ProjectFusion<Copy>>: SpoolPhyCp ^Records=1
5 Cache: IterPhyOp *FieldCoi$=0 ~ValueCols=1
Рис. 19.8 Физический план выполнения простого запроса
И снова представляем вам упрощенный синтаксис этого плана, без перечис-
ления всех параметров операторов:
AddColumns:
SingletonTable:
SpoolLookup: LookupPhyOp
ProjectionSpool<ProjectFusion<Copy»: SpoolPhyOp
Cache: IterPhyOp
Первый оператор AddColumns также служит для создания результирующей
таблицы. Первым ее параметром является SingletonTable - оператор, указы-
вающий на создание таблицы из одной строки, генерируемой табличным
конструктором. Второй параметр - оператор SpoolLookup - осуществляет по-
иск значения в кеше данных, полученном при помощи запроса, посланного
движку хранилища. Это самая сложная часть в планах выполнения запросов.
Физический план показывает, что он использует данные, ранее подкачанные
другими запросами движка хранилища, но не указывает, какими именно. Ины-
ми словами, мы не можем получить код запроса движка хранилища, прочитав
план выполнения запроса DAX. Можно извлечь запросы, посланные движку
хранилища данных, но сопоставить их с конкретными строками в плане вы-
полнения запроса получится только в простых запросах DAX. При выполнении
670 ГЛАВА 19 Анализ планов выполнения запросов DAX
более сложных и реалистичных запросов осуществление такого сопоставления
может потребовать более глубокого анализа.
Перед тем как двинуться дальше, необходимо отметить важную информа-
цию, заложенную в плане выполнения запроса:
ProjectionSpool<ProjectionFusion<Copy»: SpoolPhyOp #Records=l
Cache: IterPhyOp #FieldCols=0 #ValueCols=l
Примечание В прежних версиях движка Tabular, не поддерживавших составные модели
данных, операторы ProjectionSpool и Cache назывались AggregationSpool и VertiPaqResult
соответственно. Помимо изменения названий некоторых операторов, структура физиче-
ского плана выполнения запросов сильно не изменилась, и концепции, описанные в дан-
ной главе, могут быть легко применены к прошлым версиям движка.
Оператор ProjectionSpool представляет запрос, посланный движку хранили-
ща данных. В следующем разделе мы подробно коснемся темы запросов движ-
ка хранилища. Оператор ProjectionSpool осуществляет итерации по результа-
там запроса, демонстрируя общее количество строк в итерациях в параметре
#Records=l. Количество записей также представляет число строк, возвращен-
ных вложенным оператором Cache,
Количество строк важно по двум причинам:
с помощью него можно узнать размер (в строках) кеша данных, создан-
ного движком VertiPaq или DirectQuery. Большой кеш данных расходует
больше памяти во время выполнения запроса и требует большего време-
ни для сканирования;
итерации, выполняемые оператором ProjectionSpool в рамках движка
формул, осуществляются в однопоточном режиме. Если этот показатель
большой, а запрос при этом выполняется довольно долго, это может ука-
зывать на наличие узкого места.
Именно по причине высокой важности количества строк DAX Studio вы-
водит этот параметр отдельно в колонке Records плана выполнения запроса.
Иногда мы называем количество записей кратностью оператора.
Запросы движка хранилища данных
Ранее приведенный физический план выполнения запроса содержал оператор
ProjectionSpool, представляющий внутренний запрос, посланный движку хра-
нилища данных. Поскольку модель находится в режиме Import, DAX исполь-
зует движок хранилища VertiPaq, получающий запросы на языке xmSQL. Ниже
показан запрос xmSQL, сгенерированный во время выполнения запроса DAX
из предыдущего раздела:
SET DC_KIND="AUTO";
SELECT
SUM ( 'DaxBook Sales'[Quantity] )
FROM 'DaxBook Sales';
'Estimated size ( volume, marshalling bytes ) : 1, 16'
ГЛАВА 19 Анализ планов выполнения запросов DAX 671
Это упрощенная версия кода, показанная в DAX Studio, в которой удалены
детали, не имеющие отношения к анализу производительности запроса. Ис-
ходный код на xmSQL, видимый в SQL Server Profiler, выглядит так:
SET DC_KIND="AUTO";
SELECT
SUM([DaxBook Sales (905)].[Quantity (923)]) AS [$Measure0]
FROM [DaxBook Sales (905)];
[Estimated size (volume, marshalling bytes): 1, 16]
Этот запрос агрегирует все строки в таблице Sales, возвращая единственную
колонку с суммой по столбцу Quantity. Движок хранилища выполняет операцию
агрегирования, возвращая крошечный кеш данных, состоящий из одной строки
и одного столбца вне зависимости от размера таблицы Sales. Материализация,
необходимая для этого кеша, будет минимальной. При этом запрос обращается
только к структурам данных, хранящим столбец Quantity из таблицы Sales. Таким
образом, наличие в таблице Sales сотен других столбцов никак не скажется на
производительности запроса xmSQL. Движок хранилища VertiPaq SE сканирует
только столбцы, включенные в запрос xmSQL. Если бы модель данных использо-
вала режим DirectQuery, сгенерированный запрос на языке SQL выглядел бы так:
SELECT
SUM ( [Quantity] )
FROM Sales
Примечание Здесь и далее мы не будем подробно останавливаться на планах выпол-
нения запросов с использованием режима DirectQuery. Как мы уже говорили в главе 17,
оптимизация DirectQuery требует выполнения оптимизации источника данных. При этом
изменения в запросе DAX способны улучшить итоговый код на языке SOL, посылаемый
источнику данных DirectQuery, так что все техники для анализа планов выполнения за-
просов, описанные для VertiPaq, могут быть применены для DirectQuery, хотя быстродей-
ствие движков хранилища в этом случае нельзя будет сравнивать напрямую.
Позже в данной главе мы расскажем, почему так важно измерять время вы-
полнения каждого запроса хранилища данных для процесса оптимизации.
Помните, что производительность движка VertiPaq напрямую связана с разме-
ром столбцов, участвующих в запросе, а не только с количеством строк в табли-
це. Столбцы могут иметь разные коэффициенты сжатия и занимать в памяти
разное место, что может сказаться на времени сканирования.
Сбор информации для оптимизации
В предыдущем разделе мы познакомились с планами выполнения запросов
на DAX. Здесь мы остановимся на инструментах, позволяющих перехватывать
события, и расскажем, как измерять длительность этих событий, что является
первым шагом на пути оптимизации запросов.
672 ГЛАВА 19 Анализ планов выполнения запросов DAX
Движок DAX сформировался как часть Microsoft SQL Server Analysis Services.
Analysis Services предоставляет так называемые события трассировки (trace
events), которые могут быть перехвачены инструментом SQL Server Profiler,
как и расширенные события (xEvents). Другие продукты, такие как Power Pivot
и Power BI, используют тот же движок, хоть и не располагают всеми инструмен-
тами Analysis Services для перехвата событий трассировки или расширенных
событий. При этом в Power Pivot для Excel и Power BI Desktop присутствуют
опции диагностики, способные сохранять события трассировки в файлы, ко-
торые могут быть открыты при помощи SQL Server Profiler.
Однако события, генерируемые движком, нуждаются в дополнительной об-
работке, чтобы их можно было использовать для анализа производительности
запросов; SQL Server Profiler представляет собой универсальное средство, не
предназначенное конкретно для этой задачи. С другой стороны, DAX Studio
умеет читать и интерпретировать события Analysis Services, структурируя при
этом всю важную информацию. Именно поэтому мы рекомендуем использо-
вать DAX Studio в качестве основного инструмента для редактирования, про-
верки и оптимизации запросов и выражений на языке DAX. Далее в этой главе
мы расскажем подробнее об SQL Server Profiler, что будет полезно тем, кого
интересует внутреннее устройство этого инструмента. DAX Studio перехваты-
вает те же события, что и SQL Server Profiler, обрабатывая их и представляя
итоговую информацию в удобном для восприятия виде.
Использование DAX Studio
Как мы упоминали в начале данной главы, инструмент DAX Studio может быть
использован в том числе для перехвата запросов DAX, направленных движку
Tabular. Функционально DAX Studio умеет выполнять любые корректные за-
просы DAX, в том числе и те, что были перехвачены. Синтаксис запросов DAX
был описан в главе 13. DAX Studio собирает события трассировки, сгенериро-
ванные одним или несколькими запросами в процессе их выполнения внутри
DAX Studio, и отображает соответствующую информацию о планах выполне-
ния запросов и движке хранилища данных. DAX Studio обладает возможностью
подключения к Power BI, Analysis Services и Power Pivot для Excel.
Перед тем как приступить к анализу запросов в DAX Studio, необходимо ак-
тивировать опции Query Plan и Server Timings в группе Traces на вкладке
Ноте, как показано на рис. 19.9.
Р Find
з Replace
Find
Al j Query Server
Que' es Plan Timings
Traces
Q* Scan Го] Batc^
Cache LJ R grit Layout
Irtemal l_i Bottom Layout
Server Timngs
Рис. 19.9 Опции Query Plan и Server Timings
позволяют включить трассировку событий в DAX Studio
После активации этих опций в нижней области DAX Studio появятся вкладки
Query Plan и Server Timings рядом с Output и Results. DAX Studio подклю-
ГЛАВА 19 Анализ планов выполнения запросов DAX 673
чается к движку DAX в режиме протоколирования и перехватывает события
трассировки, которым мы посвятим внимание в следующем разделе. При этом
DAX Studio автоматически фильтрует только события, относящиеся к выпол-
ненным запросам, так что не стоит беспокоиться о присутствии на сервере
других пользователей.
На вкладке Query Plan отображаются два плана выполнения запроса, как
показано на рис. 19.10. Физический план расположен вверху, а логический -
внизу. Анализ физического плана представляет наибольшую важность при по-
иске узких мест выполнения запросов движком формул. По данной причине на
этой вкладке присутствует колонка Records с количеством записей, обрабаты-
ваемых во время операции подкачки данных (spool operation). Эта операция вы-
полняется движком формул, и итерации в основном осуществляются по кешу
данных. Таким образом, мы всегда можем узнать, какие операции затрагивают
большое количество строк в сложном плане выполнения запроса. Мы расска-
жем, как использовать эту информацию, в главе 20.
II ’ о х
Line Reco'ds Phys.cal Query Иап
1 4ddCokirv.s: terPhyOp LcgOp=AdOCoLmn5 te<ols(0?( [Va ue]i
2 S-g jto-Tsoe ter=hyCp LogOp-AchColumns
3 1 SpoolL&cItup: Lc«kjpPh)'Op LogOp=SuTi_Vertip*q ’tegcr*Records=1 sKeyCJs=11 = ,a ueCds=1 D«riinantValLie=3lANK
4 1 Project, onSpool<₽rcjectFu5iori<Copy> >: SpoolP^yCp *R*corcs=1
5 Cac-e ter'hyCa-F e£Co s=C ^Va ueCo s=1
Line Logical Query P.ar
1 AddCc ur vs: RelLsgQp Jepe- dOnCa sQQ 0-C RecuiredCds(O)( ‘[Va uej T
2 Su^.Vertipaq ScaLegOo DwerdOnCcIsQO Integer DorninsntVHut=8LAlMK
3 Scan_Vertipac: Rei LogOp DepencCnCo sOO 0-110 3.ec- redC©ls(86)('Sales [Quantity!)
Sa es [Quantity]; Sce.ogCoDepe"oC-<o$i85< Sa e$[Cwantit>]) r.teger Dor nantVaLe= NONE
Рис. 19.10 На вкладке Query Plan показаны логический и физический планы выполнения
запроса
Вкладка Server Timings, показанная на рис. 19.11, отображает информацию,
связанную с запросами движка хранилища, а также дает понять, как время вы-
полнения запроса разделяется между движком формул и движком хранилища.
Рис. 19.11 На вкладке Server Timings показана сводная информация о времени
выполнения запроса и подробности о запросах движка хранилища
Примечание Запрос хранилища данных, показанный на рис. 19.11, был запущен в мо-
дели данных, насчитывающей 4 млрд строк для демонстрации высокой загрузки процес-
сора. Модель из этого примера не включена в архив рабочих файлов для данной книги.
\__________________________________________________________________________.
674 ГЛАВА 19 Анализ планов выполнения запросов DAX
Следующие показатели отображены в левой части вкладки Server Timings:
Total: полное время, затраченное на выполнение запроса DAX. Этот по-
казатель соответствует параметру Duration события Query End;
SE CPU: время, затраченное процессором на все операции сканирования
движка VertiPaq. Также здесь показана актуальная степень параллелизма,
выявленная при выполнении операций VertiPaq (количество ядер про-
цессора, используемых одновременно);
FE: время выполнения операций движком формул (formula engine) в мил-
лисекундах и процентах относительно общего времени, показанного
в графе Total;
SE: время выполнения операций движком хранилища (storage engine)
в миллисекундах и процентах относительно общего времени;
SE Queries: количество запросов, посланных движку хранилища данных;
SE Cache: количество запросов движка хранилища, обработанных при
помощи кеша движка хранилища. Отображается в абсолютных значени-
ях и в процентах от показателя SE Queries.
В центральной области вкладки выводится список выполненных запросов
движка хранилища, а справа отображается полный текст запроса, выбранно-
го в списке. По умолчанию в этой вкладке выводится лишь по одной строке
для каждого запроса, скрывая события VertiPaq Scan Internal и другие события
кеширования, которые всегда видны в SQL Server Profiler. Эти скрытые собы-
тия можно отобразить, воспользовавшись кнопками Cache, Internal и Batch
в группе Server Timings на вкладке Ноте, которые видны на рис. 19.9. Но чаще
всего эти вспомогательные события не несут никакой пользы при анализе про-
изводительности запроса, и именно поэтому они скрыты по умолчанию.
Обычно процесс анализа производительности запроса DAX начинается
с оценки показателей, отображаемых на вкладке Server Timings. Если боль-
ше 50 % времени выполнения запроса отводится движку формул (показатель
FE), стоит сначала проанализировать план выполнения запроса на предмет
наличия узких мест среди операций, выполняемых движком формул. Если же
большая часть времени уходит на работу движка хранилища (показатель SE),
желательно тщательно разобраться с запросами движка хранилища, выведен-
ными по центру вкладки Server Timings.
Информация из колонок Duration и CPU поможет вам найти узкие места в за-
просе. Оба значения указаны в миллисекундах. При этом показатель Duration
отражает разницу между временем начала и окончания выполнения запроса,
поступившего в движок хранилища данных. В колонке CPU показано общее
время, затраченное в расчете на одно ядро процессора. Если показатель CPU
превышает Duration, это означает, что при выполнении операции были задей-
ствованы несколько ядер процессора одновременно.
Степень параллелизма выполняемой операции можно рассчитать, поделив
CPU на Duration. Если получившееся число будет близко к количеству ядер про-
цессора на сервере, значит, повлиять на быстродействие запроса при помощи
увеличения параллелизма не получится. В данном примере мы использовали
сервер с восьмиядерным процессором. Так что полученную нами степень па-
раллелизма 7,5 можно назвать близкой к оптимальной. В многопользователь-
ГЛАВА 19 Анализ планов выполнения запросов DAX 675
ском режиме невозможно будет добиться оптимальной производительности
большого запроса, выполнение которого будет также замедлять работу других
пользователей. В таких условиях увеличение количества ядер процессора по-
зволит увеличить скорость выполнения запроса. Если степень параллелизма
оказалась существенно ниже, чем количество доступных ядер процессора, уве-
личение числа ядер сервера не позволит улучшить производительность. Сте-
пень параллелизма вычисляется только для операций, выполняемых движком
хранилища данных, поскольку движок формул работает в однопоточном режи-
ме. Таким образом, эффективность операций, выполняемых движком формул,
не увеличится при увеличении числа ядер процессора.
В колонках Rows и КВ показаны оценочное количество строк и размер ре-
зультата (кеша данных) для каждого запроса движка хранилища. Поскольку
обработка каждого кеша должна производиться движком формул в однопо-
точном режиме, кеш с высокой кратностью может стать причиной задержек
при выполнении операций движком формул. Кроме того, размер кеша дан-
ных определяет объем памяти, требуемой для материализации набора данных
в несжатом виде, ведь движок формул обрабатывает только несжатые данные.
Основным недостатком создания больших кешей движком хранилища обыч-
но является необходимость выделения памяти и размещения в ней несжатых
данных. Таким образом, снижение необходимости производить материализа-
цию кеша критически важно для уменьшения объема данных, которыми об-
мениваются движок хранилища и движок формул. Это, в свою очередь, ведет
к меньшей нагрузке на память, а также способствует повышению быстродей-
ствия запросов и увеличению масштабируемости.
Примечание В колонках Rows и КВ показываются оценочные значения, которые могут
оказаться ошибочными. Точное количество строк, возвращенных запросом движка хра-
нилища да иных, доступ но только в физическом плане выполнения запроса. Оно выводит-
ся в колонке Records события Projectionspool, включающего элемент Cache.Точный размер
кеша данных неизвестен, но он может быть вычислен приблизительно с использованием
пропорции отношения между показателем Records в плане выполнения запроса и оце-
ночным значением Rows запроса движка хранилища.
DAX Studio позволяет упорядочивать список запросов по любой колонке, что
помогает найти наиболее дорогостоящие запросы путем сортировки по полям
CPU, Duration, Rows или КВ в зависимости от проводимого анализа. С помощью
DAX Studio можно очень быстро отыскивать узкие места в запросах DAX. Этот
инструмент сам по себе не оптимизирует запросы, а лишь упрощает нам за-
дачу оптимизации. В оставшейся части книги мы будем часто использовать
DAX Studio. Однако всю ту же информацию, но с большими затратами, можно
получить и посредством инструмента SQL Server Profiler.
Использование SQL Server Profiler
Инструмент SQL Server Profiler устанавливается в составе программного пакета
SQL Server Management, который можно бесплатно скачать по адресу https://
docs.microsoft.com/en-us/sqL/ssms/downLoad-sqL-server-management-studio-ssms.
676 ГЛАВА 19 Анализ планов выполнения запросов DAX
SQL Server Profiler может быть подключен к экземпляру Analysis Services и пе-
рехватывать все события, относящиеся к выполнению запросов DAX. SQL Ser-
ver Profiler также может загружать файлы с событиями трассировки, созданные
как самим SQL Server Profiler, так и другими службами вроде Power Pivot для
Excel и Power BI Desktop. В данном разделе мы расскажем, как использовать
SQL Server Profiler, если по той или иной причине вы не можете воспользовать-
ся DAX Studio. Но вы можете пропустить эту часть главы, если у вас есть DAX
Studio. Мы приводим эту информацию для тех, кому интересно разобраться
в событиях, вовлеченных в анализ производительности запросов.
Для перехвата планов выполнения запросов DAX и запросов хранилища
данных необходимо открыть новую сессию трассировки, выбрав интересую-
щие вас события. Экран настройки показан на рис. 19.12.
Trace Properties
General Events Selection
Review selected events and event columns that are being traced Selection cannot be changed while tracing is active
Events Activity ID Application Name 1 CPUTime □lent Process ID Connection! C ] CurrentT,me Datat aseNarr. e D
- i Queries' Events
P Query End p У У V p P P
Querv ProceEsinc
p DAX Query Plan P p P P P P
p DirectQuery End P p P P P
P VertiPaq SEQuery Cache Match P P P P
p VertiPaq SEQuery End P p P P P
< >
Vertiraq atUuery tnd
VertiPaq SE Query Г Show all events
Г” Show all columns
DstabsseNdme (no filters applied
Name of the database in which the statement of the user is running. Column Filters...
Oraanize Columns...
OK I Cancel Help
Рис. 19.12 Настройка SOL Server Profiler для захвата планов выполнения запросов DAX
и запросов хранилища
Существует пять классов событий для сбора той же информации, которую
предлагает DAX Studio:
Query End: это событие генерируется по окончании выполнения запроса.
Можно, конечно, включить и событие Query Begin, но мы советуем пере-
хватывать только событие Query End, поскольку в нем содержится инфор-
мация о продолжительности выполнения запроса;
DAX Query Plan: это событие возникает после того, как движок запро-
са вычислил план выполнения запроса. В событии содержится текстовое
представление плана запроса. Этот класс событий включает в себя два
подкласса: Logical Plan и Physical Plan. Для каждого запроса движок гене-
рирует оба класса событий: для логического и физического планов;
ГЛАВА 19 Анализ планов выполнения запросов DAX 677
DirectQuery End: событие генерируется при ответе движка DirectQuery
на запрос. Как и в случае с событием Query End, для получения полной ин-
формации о тайминге мы советуем включать только событие окончания
ответа движка DirectQuery;
VertiPaq SE Query Cache Match: данное событие создается, когда запрос
VertiPaq обрабатывается путем обращения к кешу данных. Оно полезно
для получения информации о том, какие данные при выполнении запро-
са потребовали произведения расчетов, а какие были получены путем об-
ращения к кешу;
VertiPaq SE Query End: событие генерируется при ответе на запрос движ-
ка VertiPaq. Как и в случае с событием Query End, для получения полной
информации о времени выполнения запроса движком хранилища Ver-
tiPaq мы советуем включать только событие окончания ответа.
Совет После выбора необходимых событий полезно будет упорядочить столбцы путем
нажатия на кнопку Organize Columns, показанную на рис. 19.12, и сохранить шаблон вы-
бора, чтобы не повторять одни и те же действия каждый раз при старте новой сессии.
Для сохранения шаблона трассировки вы можете проследовать по пунктам меню File /
Templates / New Template в SQL Server Profiler.
\____________________________Z_____________________________________________________J
Примечание При анализе функционирующей рабочей системы необходимо отфильтро-
вать события по одной сессии. В противном случае вы будете видеть события, генериру-
емые всеми запросами, выполняющимися в это время, что затруднит поиск информации
по анализируемому запросу. При использовании SQL Server Profiler в тестовой среде или
среде разработки, где нет других пользователей, видимыми будут только ваши собствен-
ные запросы. Для сравнения, DAX Studio автоматически фильтрует события, относящиеся
к анализируемому запросу, убирая лишнюю информацию. Для этого ему не нужны до-
полнительные настройки.
Чтобы проследить за цепочкой генерируемых событий, запустим тот же за-
прос к таблице из 4 млрд записей, который использовали на рис. 19.11:
EVALUATE
ROW ( "Result"; SUM ( Audience[Weight] ) )
Окно вывода сообщений SQL Server Profiler выдало результат, показанный
на рис. 19.13.
EventClass EventSubdass ] Duration | CPUTime
□Ах Query p'an VertiPaq SE Quer. Errd VertiPaq SE Query End dax Query Plan Quet . End 1 - dax ve^tiPaq Logical Plan io - internal VertiPaq scan 837 G25o 0 - VertiPaq scan B3S 6Z5O 2 -dax vert Paa prysical Plan 3 - DAXQliery [ 844 ] 0
Рис. 19.13 События трассировки, перехваченные в сессии SQL Server Profiler
для простого запроса DAX
678 ГЛАВА 19 Анализ планов выполнения запросов DAX
Даже для такого простого запроса движок DAX сгенерировал пять различ-
ных событий.
1. Событие DAX VertiPaq Logical Plan, говорящее о создании логического
плана выполнения запроса.
2. Событие Internal VertiPaq Scan, относящееся к запросу движка хранилища
данных. Для каждого события VertiPaq Scan (подкласс 0) может быть бо-
лее одного внутреннего события (подкласс 10).
3. Событие VertiPaq Scan, описывающее один запрос движка хранилища,
полученный движком формул.
4. Событие DAX VertiPaq Physical Plan, говорящее о создании физического
плана выполнения запроса.
5. Завершающее событие Query End, возвращающее полную длительность
выполнения запроса DAX. Время процессора, возвращаемое этим собы-
тием, можно проигнорировать. Оно должно быть близким ко времени
загрузки движка формул, но при этом не так точно, как вычисление, ко-
торое мы приведем далее.
Все события показывают процессорное время (CPU Time) и длительность
(Duration) в миллисекундах. При этом CPU Time характеризует время, потре-
бовавшееся центральному процессору для ответа на запрос, a Duration - время
ожидания пользователем результата. Если показатель Duration оказался мень-
ше CPU Time, это значит, что операция выполнялась параллельно на несколь-
ких ядрах процессора. Если же Duration больше CPU Time, значит, задача ожи-
дала окончания выполнения других операций, информация о которых обычно
хранится в иных событиях.
Примечание Точность показателей CPU Time и Duration может страдать для значений,
меньших 16 мс. А при высокой степени параллелизма значения CPU Time могут оказаться
еще менее точными. Более того, эти показатели могут напрямую зависеть от других опера-
ций, запущенных на этом сервере. Распространенной практикой является многократный
запуск одной и той же операции для получения среднего времени ее выполнения, осо-
бенно когда нам нужны более точные цифры. Но если нам нужен только порядок скорости
выполнения запросов, показания меньше сотой доли секунды можно проигнорировать.
С точки зрения последовательности событий создание логического пла-
на выполнения запроса предшествует запуску запросов хранилища данных
(представленных событиями VertiPaq Scan), и только после его окончания гене-
рируется событие создания физического плана. Иными словами, физический
план выполнения запроса является фактическим, а не оценочным. В нем со-
держится информация о количестве строк, обработанных во время осуществ-
ления итераций в движке формул, но при этом нет данных о процессорном
времени и длительности каждого шага в плане выполнения запроса.
Логический и физический планы выполнения запроса не предоставляют
информации о тайминге - эти данные доступны в других событиях, обрабаты-
ваемых SQL Server Profiler. Информация в колонках CPU Time и Duration здесь
та же самая, что и в колонках CPU и Duration в DAX Studio для запросов движка
хранилища. Однако вычисление времени выполнения запроса движком фор-
мул, отображаемое в DAX Studio, в SQL Server Profiler займет больше времени.
ГЛАВА 19 Анализ планов выполнения запросов DAX 679
Событие Query End предоставляет информацию только о полном времени
выполнения запроса DAX в колонке Duration, суммируя при этом длительно-
сти из движка формул и движка хранилища данных. Из событий VertiPaq Scan
можно получить информацию о времени, затраченном движком хранилища
данных. Таким образом, время работы движка формул рассчитывается путем
вычитания суммы длительностей выполнения всех запросов хранилища из
общей продолжительности запроса DAX, данные о которой представлены в со-
бытии Query End.
Как видно по рис. 19.13, событие Query End длилось 844 мс. Время, затра-
ченное на работу движка хранилища, составило 838 мс. При этом у нас был
лишь один запрос хранилища данных, на выполнение которого ушло все это
время. Обращайте внимание только на события VertiPaq Scan, игнорируя все
остальные. Разница составила 6 мс, а значит, это и есть время работы движка
формул. Если запросов хранилища будет несколько, нужно будет сложить все
их длительности, чтобы посчитать общую продолжительность работы храни-
лища данных, а затем вычесть полученное значение из общей длительности
выполнения запроса.
Также стоит отметить, что SQL Server Profiler умеет сохранять и загружать
сессии трассировок. SQL Server Profiler не может подключаться к Power Pivot
для Excel, но он может открыть файл с трассировкой, сохраненный в Power Pi-
vot для Excel или Power BI Desktop. При этом в Power Pivot для Excel присутству-
ет специальный флажок в настройках Включить трассировку Power Pivot
(Enable Power Pivot Tracing), установка которого позволяет создавать файлы
TRC. Расширение TRC зарезервировано для файлов трассировки. События, за-
писанные в файл таким образом, не могут быть изменены. Кроме того, в файл
обычно включается больше событий, чем необходимо для анализа плана вы-
полнения запроса. DAX Studio не умеет читать файлы трассировки, зато может
напрямую и без всяких ограничений подключаться к различным инструмен-
там, в том числе Power Pivot для Excel.
Чтение запросов движка хранилища VertiPaq
В предыдущих разделах мы посвятили вас в некоторые детали построения
логического и физического планов выполнения запроса. И хотя эти планы
в определенных ситуациях бывают важны, наибольший интерес представляет
часть планов выполнения запросов, касающаяся запросов движка хранилища
VertiPaq.
В данном разделе мы расскажем, как читать эти запросы и понимать прин-
ципы их трансформации в запросы xmSQL. Эта информация крайне полез-
на при определении узких мест в движке хранилища данных VertiPaq. Кроме
того, чтение этих запросов полезно и для понимания того, что происходит
в движке формул: если вычисление не выполняется в движке хранилища, оно
должно быть выполнено в движке формул. Поскольку обычно запросов движ-
ка хранилища бывает меньше, чем строк в плане выполнения запроса, лучше
всегда начинать с их анализа вне зависимости от типа обнаруженного узкого
места.
680 ГЛАВА 19 Анализ планов выполнения запросов DAX
Введение в синтаксис xmSQL
В предыдущем разделе мы рассмотрели простой запрос движка хранилища,
описанный при помощи упрощенного синтаксиса xmSQL, как показано в DAX
Studio:
SELECT
SUM ( Sales[Quantity] )
FROM Sales;
Этот синтаксис остался бы практически неизменным в виде стандартного
ANSI SOL:
SELECT
SUM ( Quantity )
FROM Sales;
Каждый запрос на языке xmSQL включает в себя предложение GROUP BY,
даже если это не указано в синтаксисе запроса явно. Например, следующий за-
прос DAX возвращает список уникальных значений столбца Color из таблицы
Product:
EVALUATE VALUES ( 'Product'[Color] )
В результате мы получим запрос на языке xmSQL. Заметьте, что здесь нет
предложения GROUP BY:
SELECT Product[Color]
FROM Product;
При этом в соответствующем этому запросу синтаксисе ANSI SQL оператор
GROUP BY представлен будет:
SELECT Color
FROM Product
GROUP BY Color
Причина, по которой мы сравниваем запрос xmSQL со стандартным синтак-
сисом ANSI SQL, включающим оператор GROUP BY вместо DISTINCT (который
мог быть применен в предыдущем примере), состоит в том, что в большинстве
случаев запросы xmSQL содержат агрегированные вычисления. Давайте рас-
смотрим следующий пример запроса на DAX:
EVALUATE
SUMMARIZECOLUMNS (
Sales[Order Date];
"Revenues"; CALCULATE ( SUM ( Sales[Quantity] ) )
)
А вот соответствующий ему запрос на языке xmSQL, который будет отправ-
лен движку хранилища данных:
SELECT Sales[Order Date], SUM ( Sales[Quantity] )
FROM Sales;
ГЛАВА 19 Анализ планов выполнения запросов DAX 681
В стандарте ANSI SQL этот запрос должен включать оператор GROUP BY по
полю Order Date:
SELECT [Order Date], SUM ( Quantity )
FROM Sales
GROUP BY [Order Date]
Запрос на языке xmSQL никогда не возвращает дублирующиеся строки. Если
запрос DAX проходит по таблице, в которой отсутствует уникальный ключ, со-
ответствующий ему запрос на xmSQL будет включать специальный столбец
RowNumber, при помощи которого будет поддерживаться уникальность строк.
При этом к столбцу RowNumber невозможно получить доступ посредством язы-
ка DAX. Давайте рассмотрим такой запрос:
EVALUATE Sales
Сгенерированный код на языке xmSQL будет выглядеть следующим обра-
зом:
SELECT Sales[RowNumber], Sales[columnl], Sales[column2], ... ,Sales[columnN]
FROM Sales
Функции агрегирования
Язык xmSQL включает в себя следующие функции агрегирования данных:
SUM суммирует значения в столбце;
MIN возвращает минимальное значение из столбца;
МАХ возвращает максимальное значение из столбца;
СО UNT подсчитывает количество строк в текущей инструкции GROUP BY;
DCOUNT подсчитывает количество уникальных значений в столбце.
Поведение функций SUM, MIN, МАХ и DCOUNT мало отличается от того, что
мы уже видели. Например, следующий запрос DAX возвращает количество
уникальных покупателей на каждую дату:
EVALUATE
SUMMARIZECOLUMNS (
Sales[Order Date];
"Customers"; DISTINCTCOUNT ( Sales[CustomerKey] )
)
А вот каким будет сгенерированный запрос на языке xmSQL:
SELECT Sales[Order Date], DCOUNT ( Sales[CustomerKey] )
FROM Sales;
И соответствующий запрос в соответствии со стандартом ANSI SQL:
SELECT [Order Date], COUNT ( DISTINCT CustomerKey )
FROM Sales
GROUP BY [Order Date]
682 ГЛАВА 19 Анализ планов выполнения запросов DAX
Функция COUNT не предполагает наличия аргументов. Она просто подсчи-
тывает количество строк для текущей группы. Например, рассмотрим следую-
щий пример запроса DAX, подсчитывающего товары каждого цвета:
EVALUATE
SUMMARIZECOLUMNS (
'Product'[Color];
"Products”; COUNTROWS ( 'Product' )
)
Запрос на языке xmSQL, посланный движку хранилища данных, будет вы-
глядеть так:
SELECT Product[Color], COUNT ( )
FROM Product;
А соответствующий запрос по стандарту ANSI SQL - так:
SELECT Color, COUNT ( * )
FROM Product
GROUP BY Color
Остальные функции агрегирования в DAX не имеют простых аналогов в xm-
SQL. Например, рассмотрим следующий запрос DAX с использованием функ-
ции A VERAGE:
EVALUATE
SUMMARIZECOLUMNS (
'Product'[Color];
'’Average Unit Price"; AVERAGE ( 'Product' [Unit Price] )
)
Соответствующий ему запрос на языке xmSQL будет содержать сразу две
агрегации: одну для числителя, а вторую для знаменателя операции деления,
которая будет произведена в движке формул:
SELECT Product[Color], SUM ( Product[Unit Price] ), COUNT ( )
FROM Product
WHERE Product[Unit Price] IS NOT NULL;
В соответствии co стандартом ANSI SQL этот запрос выглядел бы так:
SELECT Color, SUM ( [Unit Price] ), COUNT ( * )
FROM Product
WHERE Product[Unit Price] IS NOT NULL
GROUP BY Color
Арифметические операции
Язык xmSQL включает в себя набор простых арифметических операторов: +,
-, *,/ (сложение, вычитание, умножение и деление). Эти операторы работают
в рамках одной строки, тогда как движок формул выполняет арифметиче-
ские операции с результатами агрегаций. Зачастую арифметические опера-
ГЛАВА 19 Анализ планов выполнения запросов DAX 683
ции используются в выражениях, поступающих на вход функций агрегиро-
вания. Например, следующий запрос DAX возвращает сумму произведений
значений столбцов Quantity и Unit Price, вычисленных построчно в таблице
Sales:
EVALUATE
{ SUMX ( Sales, Sales[Quantity] * Sales[Unit Price] ) }
А вот сгенерированный код на языке xmSQL:
WITH
$Ехрг0 := ( Sales[Quantity] * Sales[Unit Price] )
SELECT
SUM ( @$Expr0 )
FROM Sales;
Инструкция WITH объявляет выражение, ассоциированное с символьным
именем (начинающимся с префикса $Ехрг), к которому в дальнейшем идет
обращение. Например, в предыдущем коде выражение $ЕхргО соответству-
ет умножению столбца Quantity на Unit Price и используется в запросе для
каждой строки таблицы Sales, что позволяет рассчитать необходимую агре-
гацию.
Представленный код на языке xmSQL в соответствии со стандартом ANSI
SQL выглядел бы так:
SELECT SUM ( [Quantity] * [Unit Price] )
FROM Sales
В языке xmSQL также предусмотрено приведение типов данных для выпол-
нения арифметических операций. Важно помнить, что эти операции, исполь-
зуя терминологию DAX, выполняются только в контексте строки.
Операции фильтрации
В языке xmSQL есть возможность использовать оператор WHERE. Эффектив-
ность условных выражений зависит от кратности примененных фильтров.
Подробнее об этом мы поговорим далее в данной главе.
Рассмотрим следующий запрос, возвращающий сумму по столбцу Quantity
для всех продаж, в которых цена товара составляла 42:
EVALUATE
CALCULATETABLE (
ROW ( "Result"; SUM ( Sales[Quantity] ) );
Sales[Unit Price] = 42
)
Результирующий запрос на языке xmSQL получится таким:
SELECT SUM ( Sales[Quantity] )
FROM Sales
WHERE Sales[Unit Price] = 420000;
684 ГЛАВА 19 Анализ планов выполнения запросов DAX
Примечание Условие WHERE в приведенном выше запросе содержит число 420 000
вместо 42, поскольку столбец Unit Price имеет тип данных Currency (также известный как
Fixed Decimal Number в Power Bl). В движке VertiPaq значения в таких столбцах физически
хранятся как Integer,а движок формул при необходимости делит их на 10 000, чтобы полу-
чить дробное значение. Заметим, что эта операция деления не отображается ни в плане
выполнения запроса, ни в коде xmSQL.
Условие WHERE может включать в себя несколько фильтров. Рассмотрим, на-
пример, вариант предыдущего запроса, в котором выполняется суммирование
количества по продажам с ценой, равной 16 или 42. При помощи DAX это вы-
числение делается следующим образом:
EVALUATE
CALCULATETABLE (
ROW ( "Result”; SUM ( Sales[Quantity] ) );
OR ( Sales[Unit Price] = 16; Sales[Unit Price] = 42 )
)
В языке xmSQL с целью перечисления значений используется оператор IN:
SELECT SUM ( Sales[Quantity] )
FROM Sales
WHERE Sales[Unit Price] IN ( 160000, 420000 );
Фильтрующие условия в языке xmSQL включают только значения, присут-
ствующие в столбцах. Если же DAX ссылается на несуществующие значения,
в результирующем коде на xmSQL будет присутствовать условие, исключаю-
щее из итогового набора все строки. Например, если бы в предыдущем при-
мере в столбце Unit Price таблицы Sales не встречались бы значения 16 и 42, за-
прос на xmSQL мог бы не вызываться из движка формул вовсе или приобрести
следующий вид:
SELECT SUM ( Sales[Quantity] )
FROM Sales
WHERE Sales[Unit Price] IN ( );
Результат такого запроса xmSQL всегда будет пустым.
Важно помнить, что выражение на языке xmSQL является, по сути, тексто-
вым представлением запроса движка хранилища данных. Фактическая струк-
тура запроса будет более оптимизированной. Например, если список значений
для проверки столбца будет слишком длинным, в запрос xmSQL могут быть
включены лишь несколько из них, и при этом будет указано общее количество
значений, переданных запросу внутренне. Так часто бывает в случае использо-
вания функций логики операций со временем. Рассмотрим следующий запрос
DAX, возвращающий сумму по столбцу Quantity за один год:
EVALUATE
CALCULATETABLE (
ROW ( "Result"; SUM ( Sales[Quantity] ) );
Sales[Order Date] >= DATE ( 2006; 1; 1 ) && Sales[Order Date] <= DATE ( 2006; 12; 31 )
)
ГЛАВА 19 Анализ планов выполнения запросов DAX 685
Последняя версия движка DAX построит по этому запросу следующий код
на языке xmSOL:
SELECT SUM ( Sales[Quantity] )
FROM Sales
WHERE Sales[Order Date] >= 38718.000000
VAND Sales[Order Date] <= 39082.000000
DAX хранит дату и время как числа с плавающей запятой. Именно поэтому
сравнение дат может быть выполнено с использованием двух чисел, соответ-
ствующих определенным датам, указанным в аргументе фильтра выражения
DAX.
Однако прежние версии движка DAX могли сгенерировать следующий код
на xmSOL:
SELECT SUM ( Sales[Quantity] )
FROM Sales
WHERE Sales[Order Date] IN ( 38732.000000, 38883.000000, 38846.000000, 38997.000000,
38809.000000, 38960.000000, 38789.000000, 38923.000000, 39074.000000, 38752.000000..[365
total values, not all displayed] ) ; -- всего 365 значений, отображены не все
Здесь вместо оператора диапазона применяется так называемый бито-
вый индекс (bitmap index), идентифицирующий все значения, включенные
в фильтр. Условие WHERE/IN представляет подобный индекс, включая в за-
прос на языке xmSQL ограниченную выборку значений, а также общее ко-
личество значений в фильтре. Чтобы получить полный список значений для
диапазона, может понадобиться другой запрос xmSQL, который запускается
предварительно:
SELECT Sales[Order Date]
FROM Sales
WHERE Sales[Order Date] >= 38718.000000
VAND Sales[Order Date] <= 39082.000000
На самом деле итоговый запрос xmSQL в последнем примере мог оказаться
намного сложнее, включая использование функций обратного вызова (callback)
с повторным обращением к движку формул для приведения результатов функ-
ции DATE к типу с плавающей запятой. Подробнее о применении функций об-
ратного вызова мы расскажем далее в этой главе.
Операторы объединения
Язык xmSQL предусматривает использование условий JOIN, когда в запро-
се DAX присутствуют ссылки на таблицы, объединенные посредством связей.
Рассмотрим следующий запрос DAX, возвращающий сумму по столбцу Quan-
tity таблицы Sales для каждого цвета (столбец Color) в таблице Product:
EVALUATE
SUMMARIZECOLUMNS (
'Product'[Color];
"Sales"; SUM ( Sales[Quantity] )
)
686 ГЛАВА 19 Анализ планов выполнения запросов DAX
Если между таблицами Product и Sales присутствует связь «один ко многим»,
соответствующий запрос на языке xmSQL будет включать левое внешнее объ-
единение (LEFT OUTER JOIN) этих таблиц, как показано в следующем коде за-
проса движка хранилища:
SELECT Product[Color], SUM ( Sales[Quantity] )
FROM Sales
LEFT OUTER JOIN Product ON Sales[ProductKey] = Product[ProductKey];
В условие ON оператора JOIN автоматически включаются столбцы, по кото-
рым выполнено объединение таблиц. Для каждой связи, упомянутой в запро-
се, в коде xmSQL будет присутствовать свой оператор JOIN,
Временные таблицы и события Batch
Движок хранилища VertiPaq может выполнять запросы xmSQL и хранить их
результаты в памяти для использования в других запросах xmSQL без возвра-
та к движку формул. Это позволяет увеличить быстродействие запросов, по-
скольку промежуточные результаты не требуют материализации для движка
хранилища. Если временная таблица используется другим запросом xmSQL,
в VertiPaq должна появиться операция Batch, группирующая несколько выпол-
ненных запросов движка хранилища. Рассмотрим следующий запрос, вычис-
ляющий средний годовой доход покупателей, сделавших как минимум одно
приобретение в отчетном году:
EVALUATE
CALCULATETABLE (
SUMMARIZECOLUMNS (
'Date'[Calendar Year];
"Yearly Income"; AVERAGE ( Customer[Yearly Income] )
);
CROSSFILTER ( Sales[CustomerKey]; Customer[CustomerKey]; BOTH )
)
Наличие двунаправленной кросс-фильтрации между таблицами Sales и Cus-
tomer инициирует особое поведение движка хранилища данных, который ге-
нерирует запрос, выполняющийся на разных этапах инструкции Batch. В DAX
Studio событие Batch скрыто по умолчанию, но его можно активировать, чтобы
оно появилось после одного или нескольких событий Scan. Это показано на
рис. 19.14.
Une Subclass Duration CPU Rows KB Query
2 Scan •b 3 C 14 228 5€ DEFINE TABLE ‘STTableS := SELEC
4 Scan 0 C 1888C •% 3 DEFINE TABLE ‘STTable4 := SELEC
6 Scan 5 C 3 1 DEFINE TABLE’STTablel = SELEC
7 Batch 9 0 DEFINE TABLE 'STTabte3 ; = SELEC
Рис. 19.14 События движка хранилища, перехваченные DAX Studio
с активным фильтром Batch в Server Timings
Событие Batch в седьмой строке включает в себя все события Scan в строках 2,
4 и 6. Запросы каждого события Scan отделены запятыми, при этом в событии
ГЛАВА 19 Анализ планов выполнения запросов DAX 687
Batch могут присутствовать и дополнительные инструкции по примеру тех, что
выделены в представленном ниже коде жирным шрифтом. Инструкция CRE-
ATE SHALLOW RELATION реализует поведение, аналогичное двунаправленной
кросс-фильтрации, на уровне движка хранилища данных, тем самым оптими-
зируя выполнение запросов DAX, в которых присутствуют двунаправленные
связи:
- - Этот запрос представляет первое обработанное событие Scan
DEFINE TABLE '$TTable3' :=
SELECT
Customer[CustomerKey], Date[Calendar Year]
FROM Sales
LEFT OUTER JOIN Customer
ON Sales[CustomerKey]=Customer[CustomerKey]
LEFT OUTER JOIN Date
ON Sales[OrderDateKey]=Date[DateKey],
- - Эта инструкция не генерирует событие Scan
CREATE SHALLOW RELATION '$TRelationl' MANYTOMANY
FROM Customer[CustomerKey] TO '$TTable3'[Customer$CustomerKey],
- - Этот запрос представляет второе обработанное событие Scan
DEFINE TABLE *$TTable4' :=
SELECT
SIMPLEINDEXN ( '$TTable3'[Customer$CustomerKey] )
FROM '$TTable3',
- - Этот запрос представляет третье, и последнее, обработанное событие Scan для этого
пакета
DEFINE TABLE '$TTablel' :=
SELECT
'$TTable3'[Date$Calendar Year],
SUM ( '$TTable2'[$Measure0] ), SUM ( '$TTable2'[$Measurel] )
FROM '$TTable2'
INNER JOIN '$TTable3'
ON '$TTable2'[Customer$CustomerKey]='$TTable3'[Customer$CustomerKey]
REDUCED BY
'$TTable2' :=
SELECT
Customer[CustomerKey],
SUM ( Customer[Yearly Income] ),
SUM ( ( PFDATAID ( Customer[Yearly Income] ) <> 2 ) )
688 ГЛАВА 19 Анализ планов выполнения запросов DAX
FROM Customer
WHERE
Customer[CustomerKey] ININDEX '$TTable4'[$Indexl];
Только последний оператор DEFINE TABLE в пакете генерирует результат,
возвращаемый движку формул. Все остальные операторы DEFINE TABLE созда-
ют временные таблицы, используемые позже в этом пакете. Также стоит отме-
тить, что последний запрос начинается с DEFINE TABLE $TTablel и продолжается
до конца пакета, включая в себя инструкцию REDUCED BY. Эта инструкция объ-
являет подзапрос внутри запроса движка хранилища, вместо того чтобы ини-
циировать новый запрос хранилища в рамках текущего пакета, как в примере
с запросами $ТТаЫеЗ и $ТТаЫе4. Во временной таблице, созданной внутри
предпоследнего в этом пакете оператора DEFINE TABLE, может содержаться
информация в двоичном виде, которая не может быть возвращена в качест-
ве результата в DAX. Например, функция SIMPLEINDEXN создает структуру
индекса, который будет использован в следующем запросе для применения
фильтра к столбцу посредством оператора ININDEX. Эти временные таблицы
не предназначены для возврата движку формул, они в оптимизированном
виде содержатся в движке хранилища данных и используются исключительно
для ускорения внутренних вычислений в других запросах хранилища.
Время сканирования
После описания синтаксиса запросов xmSQL пришло время внимательнее
присмотреться к работе движка хранилища данных при выполнении этих за-
просов.
VertiPaq осуществляет полное сканирование всех столбцов, вовлеченных в за-
прос движка хранилища. При этом по столбцам может быть проведено больше
итераций в зависимости от запроса. Ввиду отсутствия индексов время, необхо-
димое для полного сканирования, зависит от памяти, занимаемой столбцом,
а она, в свою очередь, находится в зависимости от количества уникальных зна-
чений в столбце, от их распределения по строкам и от количества строк в табли-
це. И важность каждого из этих параметров определяется тем, какая функция
агрегирования используется в запросе xmSQL. Рассмотрим эти зависимости на
примере большой таблицы с четырьмя столбцами: Date, Time, Age и Score. В таб-
лице 4 млрд строк, и этого вполне достаточно, чтобы заметить различия в скоро-
сти выполнения запросов. Запустим для каждого столбца следующие запросы:
EVALUATE
ROW ( "Sum"; SUM ( Example[<col.umn name>] ) )
EVALUATE
ROW (
"Distinct Count";
CALCULATE (
DISTINCTCOUNT ( Example[<column name>] );
NOT ISBLANK ( Example[<column name>] )
)
)
ГЛАВА 19 Анализ планов выполнения запросов DAX 689
Примечание Во втором запросе мы добавили к вычислению условие NOT ISBLANK, что
потребовалось нам для непосредственного обращения к модели данных. Если бы это
условие отсутствовало, количество уникальных значений в столбцы было бы извлече-
но из метаданных модели, без фактического выполнения запросов движком хранилища
данных.
Нам не особенно интересны результаты этих запросов. Гораздо больше нас
интересует время, затраченное на их выполнение движком хранилища, ко-
торое в случае с такими простыми запросами чаще всего будет сопоставимо
с общим временем выполнения запроса DAX. В табл. 19.1 мы свели воедино все
результаты по указанным таблицам по следующим критериям:
память (Мб): место в памяти, занимаемое столбцом в общей таблице,
состоящей из 4 млрд строк;
уникальные значения: количество уникальных значений в столбце, по-
лученное путем вызова функции агрегирования DISTINCTCOUNT;
SUM (мс): время выполнения запроса, в котором применяется функция
агрегирования SUM к этому столбцу;
DISTINCTCOUNT (мс): время выполнения запроса, в котором применя-
ется функция агрегирования DISTINCTCOUNT к этому столбцу.
ТАБЛИЦА 19.1 Размер столбца, кратность и время выполнения функций
агрегирования
Столбец Память (Мб) Уникальные значения SUM (мс) DISTINCTCOUNT (мс)
Date 0,03 1588 9 20
Age 165,26 96 146 333
Score 2648,40 9 766 664 837 4288
Time 6493,57 1439 1330 4102
С первого взгляда на таблицу может показаться, что некоторые цифры в ней
нелогичны. Обычно чем больше уникальных значений в столбце, тем дольше
выполняется запрос к нему. В нашем случае столбец Date опережает по скоро-
сти выполнения запроса столбец Age, хотя в последнем намного меньше уни-
кальных значений. Более того, несмотря на сопоставимую кратность, запросы
к столбцу Time выполняются на порядок дольше, чем к Date. Все дело в разных
степенях сжатия столбцов - величине, напрямую зависящей от порядка сорти-
ровки данных.
Запросы к столбцу Date выполняются невероятно быстро. Причина это-
го в том, что все 4 млрд строк были обработаны с сортировкой по дате. При
этом даже без секционирования были созданы сегменты с одним или двумя
уникальными значениями каждый. Таким образом, в каждом сегменте была
выполнена очень эффективная компрессия данных, что видно по объему ис-
пользованной этим столбцом памяти.
Столбец Age характеризуется второй по скорости производительностью за-
просов с использованием функций SUM и DISTINCTCOUNT. Этот столбец зани-
мает в памяти больше места, чем Date, поскольку для одних и тех же значений
Date есть разные значения Age, и при этом данные отсортированы по дате.
690 ГЛАВА 19 Анализ планов выполнения запросов DAX
Столбцы Score и Time отстают в плане производительности. Быстродействие
функции SUM главным образом зависит от занимаемого столбцом объема па-
мяти, тогда как функция DISTINCTCOUNT также чувствительна к количеству
уникальных значений в столбце. Причина этого в разных алгоритмах, приме-
няемых во время выполнения этих двух типов агрегирования.
Важным выводом из всего сказанного является то, что быстродействие за-
просов движка хранилища напрямую зависит от места в памяти, занимаемого
столбцами. Следовательно, можно ускорить выполнение таких запросов путем
уменьшения их объема при хранении. А добиться этого можно, снизив коли-
чество уникальных значений в столбце или общее количество строк в табли-
це, а также при помощи изменения порядка сортировки исходной таблицы
в источнике данных. Другие техники оптимизации запросов мы рассмотрим
в оставшейся части книги.
Внутренние события DISTINCTCOUNT
Использование функции DISTINCTCOUNT в выражениях DAX ведет к созданию
множественных событий VertiPaq Scan Internal для одного события VertiPaq
Scan. Для отображения внутренних событий (internal events) необходимо на-
жать на кнопку Internal в группе Server Timings на ленте DAX Studio.
Рассмотрим следующий запрос DAX:
EVALUATE
ROW (
"Distinct Count";
CALCULATE (
DISTINCTCOUNT ( Example[Score] );
Example[Score] <> 0
)
)
В табл. 19.2 мы свели все события VertiPaq Scan, сгенерированные предыду-
щим запросом.
ТАБЛИЦА 19.2 События VertiPaq Scan для запроса DAX с использованием
функции DISTINCTCOUNT
Строка Подкласс Длительность CPU Запрос
1 Internal 4269 31 641 SELECT Example[Score] FROM Example;
2 Internal 4269 31 641 SELECT Exam pie [Score] FROM Example;
3 Internal 19 31 766 SELECT COUNTf) FROM $DCOUNT_DATACACHE;
4 Scan 4288 31 766 SELECT DCOUNT ( Example[Score]) FROM Example;
Последняя строка включает в себя запрос движка хранилища от движка
формул. При этом внутренне этот запрос разбит на два подзапроса. Первый
результат дублирован в двух идентичных строках (обратите внимание на ко-
лонки Длительность и CPU). Ниже представлен код на языке xmSQL первого
внутреннего подзапроса, извлекающего список уникальных значений столбца
Score в таблице Example:
ГЛАВА 19 Анализ планов выполнения запросов DAX 691
SELECT Example[Score]
FROM Example
WHERE Example[Score] <> 0;
Результатом этого запроса движка хранилища будет список уникальных
значений столбца Score из таблицы Example. Следующим шагом нам необходи-
мо подсчитать количество строк в этом списке. Иными словами, подсчет строк,
возвращенных внутренним запросом, даст правильную информацию для ис-
ходного запроса. В нашем случае запрос xmSQL просто ссылается на специ-
альную таблицу SDCOUNTDATACACHE, которая, в свою очередь, ссылается на
предыдущий результат запроса движка хранилища:
SELECT COUNT ( )
FROM $DCOUNT_DATACACHE;
По табл. 19.2 также видно, что длительность события Scan соответствует сум-
ме длительностей двух внутренних событий, при этом время дублирующегося
события считается лишь раз. Что касается процессорного времени, оно всег-
да будет одинаковым во всех событиях одного и того же запроса. Степень па-
раллелизма можно рассчитать, поделив процессорное время на длительность.
В нашем случае этот показатель близок к семи, а это означает, что при обра-
ботке запроса были задействованы до восьми ядер центрального процессора.
В следующем разделе мы подробнее поговорим о параллелизме применитель-
но к запросам движка хранилища данных.
Параллелизм и кеш данных
Каждый запрос движка хранилища, выраженный при помощи языка xmSQL,
возвращает результат, называемый кешем данных и представляющий из себя
таблицу в памяти в несжатом виде. Результат запроса движка хранилища мо-
жет быть полностью материализован в памяти, или его строки могут быть ис-
пользованы в итерациях без их предварительного сохранения. Обычно мы об-
ращаемся к кешу данных, когда его результаты материализованы, - так бывает
в большинстве случаев при работе со сложными запросами.
Выполнение запроса движка хранилища может быть разбито на несколько
ядер процессора при помощи многопоточности. При этом количество потоков
будет зависеть от аппаратного обеспечения и физической структуры столбцов,
участвующих в запросе. Движок VertiPaq выделяет по одному потоку на каж-
дый сегмент, вовлеченный в одну операцию сканирования, как было описано
в главе 17. Когда операция выполняется в нескольких потоках, каждый поток
генерирует промежуточные результаты. И только по завершении работы всеми
потоками движок VertiPaq консолидирует полученные результаты в единый
кеш данных. После этого движок формул в однопоточном режиме обращается
к этому кешу. По этой же причине результат выполнения запроса движка хра-
нилища нуждается в такой консолидации. Схематически параллельная обра-
ботка данных и процесс их консолидирования показаны на рис. 19.15.
При этом сегменты не должны быть слишком маленькими, поскольку про-
цесс консолидации данных требует времени. Преимущества от запуска опера-
692 ГЛАВА 19 Анализ планов выполнения запросов DAX
ции сканирования в многопоточном режиме должны перекрывать накладные
расходы на объединение результатов, но это будет невозможно при малом
размере сегментов. Кроме того, операции VertiPaq при работе с таблицами не-
большого размера также не станут более эффективными, если задействовать
сразу несколько ядер процессора. Процесс консолидации данных в этом случае
будет более дорогостоящим и не сможет перекрыть преимущества от исполь-
зования параллелизма при работе с маленькими таблицами.
Хранилище столбцов
Запрос VertiPaq (xmSQL)
Запрос VertiPaq (xmSQL)
Запрос VertiPaq (xmSQL)
Запрос VertiPaq (xmSQL)
Кеши данных
Рис. 19.15 Итоговый кеш данных собирается из разрозненных кешей,
создаваемых одновременно выполняемыми запросами движка VertiPaq
Важно помнить, что запросы движка хранилища просто предоставляют дан-
ные движку формул. В простейшем сценарии работа выглядит следующим об-
разом.
1. Движок хранилища получает запрос xmSQL.
2. Движок хранилища выполняет операции сканирования в многопоточ-
ном режиме, создавая по одному кешу данных для каждого потока.
3. Движок хранилища объединяет вместе разрозненные кеши, собирая та-
ким образом единый кеш данных.
4. Движок формул считывает полученный кеш данных в однопоточном ре-
жиме.
5. Движок формул может обращаться к одному и тому же кешу на разных
шагах плана выполнения запроса.
События движка хранилища всегда будут предшествовать плану выполне-
ния запроса. При этом физический план всегда выводится в конце событий, от-
ГЛАВА 19 Анализ планов выполнения запросов DAX 693
носящихся к запросу, а логическому могут предшествовать несколько запросов
движка хранилища. Это будет означать, что движок DAX сам посылает запросы
на извлечение информации о размере и плотности столбцов. Движок исполь-
зует эту информацию для создания более эффективного плана выполнения за-
проса. Используя DAX Studio, вы не сможете наблюдать за таким поведением,
поскольку этот инструмент показывает планы выполнения запросов и запро-
сы движка хранилища в разных окнах.
Кеш движка VertiPaq
Движок формул DAX не располагает кешем данных, зато он имеется у движ-
ка хранилища VertiPaq, и мы будем называть его кешем VertiPaq. Его главная
задача - улучшить производительность множественных обращений к одному
и тому же кешу данных в рамках одного запроса. Второстепенная цель - уве-
личить быстродействие разных запросов DAX при доступе к одному кешу дан-
ных. Очень важно понимать цели и назначение кеша VertiPaq, чтобы проана-
лизировать его поведение и оценить эффективность.
Рассмотрим следующий запрос DAX:
EVALUATE
ADDCOLUMNS (
VALUES ( Example[Date] );
"A"; CALCULATE ( SUM ( Exanple[Amt] ) );
"Q"; CALCULATE ( SUM ( Example[Qty] ) )
)
В результат этого запроса будут включены два столбца А и О с суммами по
столбцам Amt и Qty соответственно из таблицы Example для каждой даты. Мы
собираемся запустить этот запрос дважды и замерить скорость его выполне-
ния в каждой из попыток. В табл. 19.3 показана последовательность событий
Scan для первого запуска запроса в режиме просмотра событий Cache и Internal
в DAX Studio.
ТАБЛИЦА 19.3 События VertiPaq для первого запуска запроса DAX
с двумя агрегациями
Строка Подкласс Длительность CPU Запрос
1 Internal 1796 13 516 SELECT Example[Date], SUM ( Exam pie [Amt]), SUM ( Example[Qty]), COUNT () FROM Example;
2 Scan 1796 13 516 SELECT Example[Date], SUM ( Exam pie [Amt]), SUM ( Example[Qty]), COUNT () FROM Example;
3 Internal 6 31 SELECT Example[Date], COUNT () FROM Example;
4 Scan 6 31 SELECT Example[Date] FROM Example;
Второй запуск того же самого запроса дает уже совсем иные результаты,
поскольку при его выполнении движок смог воспользоваться кешем VertiPaq,
созданным на первом проходе. Итоги второго запуска сведены в табл. 19.4.
694 ГЛАВА 19 Анализ планов выполнения запросов DAX
ТАБЛИЦА 19.4 События VertiPaq для второго запуска запроса DAX с двумя
агрегациями
Строка Подкласс Длительность CPU Запрос
1 Cache 0 0 SELECT Example[Date],SUM ( Example[Amt] ),SUM ( ExampLe[Qty]), COUNT () FROM Example;
2 Scan 0 0 SELECT Example[Date], SUM ( Example[Amt] ),SUM ( Example[Qty]), COUNT () FROM Example;
3 Cache 0 0 SELECT Example[Date], COUNT () FROM Example;
4 Scan 0 0 SELECT Exam pie [Date] FROM Example;
Длительность второго выполнения этого запроса составила 0 мс. Причина
в том, что вся необходимая информация для второго запуска запроса была со-
хранена в кеш VertiPaq во время первого запуска. Таким образом, движок, по
сути, не выполнил ни единого запроса к данным. Вместо этого он просто из-
влек информацию из кеша.
События Cache и Internal по умолчанию отключены в DAX Studio, так что ти-
пичный вывод запуска запроса с обращением к кешированным данным будет
выглядеть так, как показано в табл. 19.5. Единственными видимыми события-
ми останутся Scan с нулевой длительностью.
ТАБЛИЦА 19.5 Видимые события VertiPaq Scan при запуске запроса DAX
с двумя агрегациями
Строка Подкласс Длительность CPU Запрос
2 Scan 0 0 SELECT Example[Date], SUM ( Example[Amt] ),SUM (Example[Qty]), COUNT () FROM Example;
4 Scan 0 0 SELECT Example[Date] FROM Example;
Движок VertiPaq повторно использует данные в кеше только в том слу-
чае, если кратность совпадает, и столбцы представляют собой подмножество
столбцов из предыдущего запроса. Этот алгоритм очень прост, поскольку
поиск в кеше VertiPaq не должен приводить к накладным расходам подоб-
но операции сканирования памяти. По этой причине кеш VertiPaq сохраняет
в памяти ограниченный набор кешей данных. Следовательно, нет никакой
гарантии, что очередной запрос возьмет информацию из кеша, даже если
в плане запроса несколько раз повторяется один и тот же запрос движка хра-
нилища. Но в большинстве случаев кеш VertiPaq удовлетворяет критериям
поиска от нескольких запросов, поступающих в рамках ограниченного ин-
тервала.
Примечание Движок VertiPaq игнорирует настройки безопасности на уровне строк
(row-level security). Движок формул DAX следует безопасности на основе ролей (role-based
security) и генерирует разные запросы движка хранилища в зависимости от настроек
безопасности и учетной записи пользователя. Поэтому кеш VertiPaq представляет собой
общий ресурс, распределяющий результаты между пользователями и сессиями. Движок
формул гарантирует корректность результата, генерируя разные запросы движка храни-
лища в зависимости оттребований.
\/
ГЛАВА 19 Анализ планов выполнения запросов DAX 695
При анализе производительности запроса очень важно перед его запуском
очистить кеш. Для поиска узких мест в запросе и возможных областей для оп-
тимизации плана его выполнения всегда лучше проводить проверку, имитируя
худшие из возможных обстоятельств (пустой кеш). Из-за ограниченности объ-
ема кеша VertiPaq часто случается, что при загруженном сервере и множестве
одновременно работающих пользователей необходимых данных в нем может
не оказаться.
DAX Studio предлагает два способа очистки кеша перед выполнением за-
проса:
нажать на кнопку Clear Cache на вкладке Ноте для очистки кеша перед
запуском запроса кнопкой Run Query;
выбрать вариант кнопки Clear Cache then Run на вкладке Home, чтобы
кеш очищался перед каждым запуском запроса.
Кнопки Run и Clear Cache then Run показаны на рис. 19.16.
Рис. 19.16 На вкладке Ноте в DAX Studio
есть несколько возможностей для очистки
кеша движка DAX
DAX Studio посылает команду на очистку кеша движку DAX, используя про-
токол XMLA, с указанием конкретной базы данных. В представленном ниже
примере происходит очистка кеша для базы данных Contoso:
<ClearCache xmins="http://schemas.microsoft.com/analysisservices/2003/engine">
<Object>
<DatabaseID>Contoso</DatabaseID>
</Object>
</ClearCache>
Функция обратного вызова CaLLbackDatalD
Движок хранилища VertiPaq поддерживает ограниченный набор операторов
и функций языка xmSQL. Таким образом, вся отсутствующая функциональ-
ность в движке хранилища данных ложится на плечи движка формул. При этом
во время сложных вычислений, производимых внутри итераций, движок хра-
нилища имеет возможность обращаться к движку формул посредством специ-
альной функции xmSQL, именуемой CallbackDatalD.
Поддерживаемые языком xmSQL операторы включают в себя простейшие
математические действия (сложение, вычитание, умножение и деление), но
более сложных математических функций (вроде вычисления квадратного
696 ГЛАВА 19 Анализ планов выполнения запросов DAX
корня) и условных конструкций в его арсенале нет. И если включить в итера-
тор выражение, не поддерживаемое xmSQL, план выполнения создаст запрос
xmSQL, включающий функцию обратного вызова CallbackDatalD. Таким обра-
зом, во время обработки каждой отдельной строки в цикле движок хранилища
будет обращаться к движку формул, передавая выражение DAX и необходимые
значения в качестве аргументов.
Давайте рассмотрим пример вычисления суммы округленных значений
в запросе DAX:
EVALUATE
ROW (
"Result”; SUMX ( Sales; ROUND ( Sales[Line Amount]; 0 ) )
)
При выполнении этого выражения движок хранилища не сможет самостоя-
тельно провести округление значений при помощи функции ROUND. Таким
образом, план выполнения сгенерирует следующее выражение xmSQL:
WITH
$Ехрг0 := [CallbackDatalD ( ROUND ( Sales[Line Amount]] ), 0 ) ]
( PFDATAID ( Sales[Line Amount] ) )
SELECT
SUM ( @$Expr0 )
FROM Sales;
Функция CallbackDatalD содержит выражение DAX, округляющее значение
до ближайшего целого. Это вычисление производится для столбца Line Amount
таблицы Sales в текущей строке. Синтаксис PFDATAID не важен для анализа ло-
гики выражения, который мы сейчас производим. Движок хранилища вызы-
вает функцию CallbackDatalD для каждой строки в таблице Sales. Результатом
запроса xmSQL будет кеш данных, состоящий из одной строки с агрегирован-
ным выражением. Несмотря на то что движок формул работает в однопоточ-
ном режиме, при обращении к нему посредством функции обратного вызова
CallbackDatalD многопоточность движка хранилища данных не пострадает.
Фактически может быть создано несколько экземпляров движка формул, ра-
ботающих параллельно, по одному для каждого потока движка хранилища.
Параллелизм функции CallbackDatalD и возможные альтернативы
Чтобы понять, как параллелизм распространяется на функцию CallbackDatalD и влияет на
производительность, для начала представим, что этой функции нет в нашем распоряже-
нии. Наш план выполнения запроса мог бы запросить кеш данных со значением столбца
Line Amount для всех строк в таблице Sales, используя следующий код xmSQL:
SELECT
Sales[Line Amount], COUNT( )
FROM Sales;
Полученный движком формул кеш содержал бы по одной строке для каждого уникаль-
ного значения столбца Line Amount с указанием количества таких вхождений в таблицу
Sales (результат функции COUNT). Используя эту информацию, движок формул применил
бы функцию ROUND к значениям Line Amount для каждой строки кеша данных, умножив
ГЛАВА 19 Анализ планов выполнения запросов DAX 697
получившийся результат на количество вхождений текущего значения. Итоговая цифра
получилась бы такая же, но в этом случае движку хранилища пришлось бы создавать го-
раздо больший кеш данных по сравнению с кешем из одной строки в случае применения
запроса xmSQL с функцией CallbackDatalD. Помните, что движок хранилища всегда мате-
риализует кеш данных в памяти целиком, причем в несжатом виде. После этого движок
формул должен был бы проходить по всем строкам этого кеша данных в однопоточном
режиме. Все это привело бы к очень низкой производительности запроса и высокому
расходу памяти.
Вариант с использованием функции обратного вызова CallbackDatalD будет гораздо
менее затратным с точки зрения расходования памяти (материализованный кеш данных
будет состоять всего из одной строки) и более масштабируемым. В случае выполнения
операции VertiPaq Scan в многопоточном режиме обращения к движку формул посред-
ством функции CallbackDatalD будут производиться в отдельных экземплярах движка для
каждого потока. Иначе говоря, можно представить, что каждый отдельный поток рас-
полагает своим экземпляром движка формул - даже в рамках одного запроса. В этом
случае в однопоточном режиме будет производиться только операция консолидации ке-
шей данных, созданных в отдельных потоках. Но эта операция не займет много времени,
поскольку в объединяемых кешах будет всего по одному столбцу.
С точки зрения производительности вызов функции CallbackDatalD имеет
три особенности:
вызовы функции CallbackDatalD всегда будут обходиться дороже при-
менения внутренних операторов движка хранилища данных, поскольку
они связаны с дополнительными накладными расходами;
в сессии с трассировкой событие движка хранилища включает в себя
показатель времени, затраченного на вызов функции CallbackDa-
talD. Имейте в виду, что оптимизация запросов движка хранилища с про-
должительным временем выполнения может потребовать уменьшения
количества вызовов функции CallbackDatalD в запросах xmSQL или пол-
ного отказа от них;
кеш движка хранилища не сохраняет кеши данных, созданные запро-
сами xmSQL, содержащими функцию CallbackDatalD. Так что необхо-
димо тщательно оценить возможные последствия присутствия функции
CallbackDatalD в выражении xmSQL при использовании итераторов.
Важно Обычно движок формул работает в однопоточном режиме, но при обращении
к нему со стороны движка хранилища данных посредством функции CallbackDatalD вы-
полнение кода в движке формул осуществляется параллельно в отдельных потоках, соз-
данных движком хранилища. Параллелизм, появляющийся при использовании этой техни-
ки, позволяет снизить общую длительность выполнения запроса, но процессорное время
в связи с накладными расходами на вызовы функции CallbackDatalD может возрасти.
\_____________________________________________________________________________________________J
Чтобы лучше понять влияние использования функции CallbackDatalD на
производительность, рассмотрим следующий запрос DAX, построчно сумми-
рующий результаты деления одного столбца на другой:
EVALUATE
{
698 ГЛАВА 19 Анализ планов выполнения запросов DAX
SUMX (
Example;
IF (
Example[Denominator] <> 0;
Example[Numerator] / Example[Denominator]
)
)
}
Функция IF используется во избежание возникновения ошибки деления на
ноль. Запрос xmSQL, отправленный движку хранилища данных, будет пример-
но таким:
WITH
$Ехрг0 := [CallbackDatalD (
IF (
Example[Denominator] <> 0,
Example[Numerator] / Example[Denominator]
) ]
( PFDATAID ( Example[Numerator] ), PFDATAID ( Example[Denominator] ) )
SELECT
SUM ( @$Expr0 )
FROM Example;
Мы запустили запрос DAX в таблице Example, насчитывающей 4 млрд строк,
и получили события движка хранилища, показанные в табл. 19.6.
ТАБЛИЦА 19.6 События VertiPaq Scan при вызове функции CallbackDatalD,
включающей условный оператор
Строка Подкласс Длительность CPU Строки Запрос
1 Internal 8379 64 234 1 WITH $ExprO := [CallbackDatalD (IF (Example[Denominator] <> 0,...
2 Scan 8379 64 234 1 WITH $ExprO := [CallbackDatalD (IF (Example[Denominator] <> 0,...
Здесь мы получили степень параллелизма (процессорное время (CPU), де-
ленное на длительность), близкую к восьми, поскольку использовали процес-
сор с восемью ядрами. Важно уяснить, что в данном случае в разных потоках
выполнялись обращения к движку формул. В предыдущих главах мы говорили,
что в DAX мы можем использовать функцию DIVIDE, чтобы избежать проверки
знаменателя на ноль. Посмотрим, что изменится, если в нашем выражении за-
менить IF на DIVIDE. Запрос DAX приобретет следующий вид:
EVALUATE
{
SUMX (
Example;
DIVIDE ( Example[Numerator]; Example[Denominator] )
)
}
ГЛАВА 19 Анализ планов выполнения запросов DAX 699
Функция DIVIDE не имеет аналогов в языке xmSQL, по причине чего мы по-
лучили вызов функции CallbackDatalD в преобразованном запросе:
WITH
$Ехрг0 := [CallbackDatalD (
DIVIDE ( Example[Numerator], Example[Denominator] ) ]
( PFDATAID ( Example[Numerator] ), PFDATAID ( Example[Denominator] ) )
SELECT
SUM ( @$Expr0 )
FROM Example;
В табл. 19.7 представлены события движка хранилища данных, полученные
в результате запуска этого запроса на той же таблице из 4 млрд записей.
ТАБЛИЦА 19.7 События VertiPaq Scan при вызове функции CallbackDatalD,
включающей DIVIDE
Строка Подкласс Длительность CPU Строки Запрос
1 Internal 6790 51 984 1 $ЕхргО := [CallbackDatalD ( DIVIDE (Example[Numerator],...
2 Scan 6790 51 984 1 $ExprO := [CallbackDatalD ( DIVIDE (Example[Numerator],...
Использование оператора DIVIDE вместо IF позволило повысить произво-
дительность запроса на 19 % как в плане длительности выполнения, так и в от-
ношении процессорного времени. Однако, несмотря на применение техники
параллелизма, накладные расходы, связанные с вызовом функции CallbackDa-
talD, остались довольно высокими, поскольку движку хранилища по-прежнему
приходится обращаться за помощью к движку формул. Если нам удастся из-
бавиться от обратных вызовов, мы исключим из уравнения и эти накладные
расходы. В нашем случае для этого достаточно применить фильтр, чтобы в ите-
ратор не попадали строки, в которых значение в столбце Denominator нулевое.
Это можно сделать, написав следующее выражение:
EVALUATE
{
CALCULATE (
SUMX (
Example;
Example[Numerator] / Example[Denominator]
);
Example[Denominator] <> 0
Соответствующий синтаксис запроса xmSQL без использования функции
CallbackDatalD будет выглядеть так:
WITH
$Ехрг0 := Example[Numerator] / Example[Denominator]
SELECT
700 ГЛАВА 19 Анализ планов выполнения запросов DAX
SUM ( @$Ехрг0 )
FROM Example
WHERE Example[Denominator] <> 0;
Судя по событиям движка хранилища данных, показанным в табл. 19.8, нам
удалось добиться существенного повышения производительности запроса по
сравнению с вариантом, использующим DIVIDE.
ТАБЛИЦА 19.8 События VertiPaq Scan без участия функции CallbackDatalD
Строка Подкласс Длительность CPU Строки Запрос
1 Internal 3108 23 859 1 WITH $ExprO := Exam pie [Num era to г] / Example[Denominator],...
2 Scan 3108 23 859 1 WITH $ExprO := Exam pie [Num era tor] / Example[Denominator],...
Отказ от функции CallbackDatalD позволил добиться еще одного существен-
ного преимущества при выполнении этого запроса. Дело в том, что движок
VertiPaq теперь сможет хранить кеши данных для будущего использования -
такая роскошь была недоступна нам, когда запрос xmSQL включал функцию
CallbackDatalD. К примеру, если второй раз запустить последний запрос DAX,
мы получим события движка хранилища, показанные в табл. 19.9.
ТАБЛИЦА 19.9 События VertiPaq Scan без участия функции CallbackDatalD
с использованием кеша движка хранилища
Строка Подкласс Длительность CPU Строки Запрос
1 Cache 0 0 1 WITH $ExprO := Exam pie [Numerator] / Example[Denominator],...
2 Scan 0 0 1 WITH $ExprO := Example[Numerator] / Example[Denominator],...
Вам как разработчику следует пытаться свести к минимуму или вовсе ис-
ключить из своих запросов функции обратного вызова. В главе 20 мы покажем
несколько вариантов оптимизации, которые позволят этого добиться.
Ограничения Analysis Services 2012/2014
при использовании функции CallbackDatalD
У Analysis Services 2012 и 2014 существуют определенные ограничения при анализе со-
бытий запросов xmSQL, включающих функции CallbackDatalD. Внутреннее выражение
DAX, переданное функции CallbackDatalD, может содержать подзапросы, инициирующие
повторное обращение к движку хранилища данных. К сожалению, в версиях Analysis
Services, выпущенных до 2015 года, информация об этих подзапросах отображается
только в логическом плане выполнения. Физический план не включает подвыражения
из функции CallbackDatalD. Запросы движка хранилища данных, призванные обраба-
тывать эти подвыражения, просто не генерируют никаких видимых событий. В версиях
Analysis Services, Excel и Power Bl Desktop после 2016 года такой проблемы не наблю-
дается.
ГЛАВА 19 Анализ планов выполнения запросов DAX 701
Чтение запросов движка хранилища DirectQuery
В данном разделе мы расскажем, как читать запросы движка хранилища Direct-
Query. Эти запросы выражены на языке SQL, который понимает источник дан-
ных. Мы настоятельно рекомендуем вам сперва ознакомиться с предыдущим
разделом, касающимся чтения запросов движка хранилища VertiPaq, чтобы
лучше понять сходства и различия между двумя техниками.
Рассмотрим следующий запрос DAX:
EVALUATE
SUMMARIZECOLUMNS (
Sales[Order Date];
"Total Quantity"; SUM ( Sales[Quantity] )
)
Будучи запущенным в модели данных DirectQuery, этот запрос приведет
к созданию движком DAX единого запроса SQL к источнику данных примерно
следующего вида:
SELECT
ТОР (1000001) [t4].[Order Date],
SUM ( CAST ( [t4].[Quantity] as BIGINT ) ) AS [a0]
FROM (
select [StoreKey],
[ProductKey],
... // другие столбцы, которые мы здесь пропустим
from [dbo].[Sales] as [$Table]
) AS [t4]
GROUP BY [t4].[Order Date]
Присутствие условия TOP ограничивает количество строк, передаваемых
из источника данных движку DAX. Если возвращенное количество строк бу-
дет идентично параметру условия ТОР, запрос DAX вернет ошибку из-за не-
возможности извлечь полный набор данных из источника. По этой причине
в качестве аргумента условия TOPN стоит значение 1 000 001 при ограничении
на количество строк для запроса DirectQuery в 1 000 000. Такое ограничение по-
зволяет избежать чрезмерного расходования памяти, ведь результаты запроса
движка хранилища должны быть полностью загружены в память в несжатом
виде после их передачи из источника данных в движок DAX.
Примечание Ограничение в 1 млн строк для запросов движка хранилища DirectQuery
установлено по умолчанию. Это количество может быть изменено путем корректировки
параметра MaxIntermediateRowsetSize в настройках Analysis Services, но не в Power Bl.
Подробнее об этом ограничении можно почитать по адресу: https://www.sqlbi.com/ar-
ticles/tuning-query-limits-for-directquery/.
На рис. 19.17 показан пример информации, извлеченной из запросов SQL,
посланных движку хранилища данных DirectQuery. В колонке Duration отра-
жено время в миллисекундах, затраченное на ожидание результата запроса
702 ГЛАВА 19 Анализ планов выполнения запросов DAX
SQL от источника данных. В колонке CPU, отражающей процессорное время,
чаще всего будет маленькое число, если не ноль, поскольку этот показатель
должен отражать расходы движка DirectQuery на извлечение результата, но
при этом он игнорирует реальные расходы источника данных. Чтобы изме-
рить действительное потребление ресурсов центрального процессора в ис-
точнике данных, необходимо анализировать запрос, запущенный в движке
самого источника, например с использованием SQL Server Profiler для Micro-
soft SOL Server.
Total 3,uo ms SF CPU Line Subclass Duration CPU Rows KB Query 1 SQL 3,132 0 SELECT TCP (lOXrOOl) [t
FE & ms 33% SE 3.132 ms 95 7%
SE Queries 1 SE Cache 0 0.3%
Рис. 19.17 Запросы движка хранилища DirectQuery на языке SQL
В событии SQL на рис. 19.17 не указаны значения в колонках Rows и КВ. Дело
в том, что для запросов на языке SQL не производится оценка в плане коли-
чества строк и объема памяти, занимаемого данными, как в случае с запро-
сами xmSQL, направляемыми движку хранилища VertiPaq.
Наконец, результаты запросов движка хранилища DirectQuery никогда не
сохраняются в кеше движка, так что метрика SE Cache всегда будет показывать
нулевое значение.
Анализ составных моделей данных
При использовании составных моделей данных один и тот же запрос DAX мо-
жет генерировать как запросы к движку хранилища VertiPaq, так и к DirectQue-
ry. Рассмотрим следующий запрос, запущенный в модели данных, в которой
таблица Sales хранится в режиме DirectQuery, а все остальные таблицы - в ре-
жиме Dual:
EVALUATE
ADDCOLUMNS (
VALUES ( 'Date'[Calendar Year] );
"Quantity"; CALCULATE ( SUM ( Sales[Quantity] ) )
)
Функция ADDCOLUMNS обычно генерирует как минимум два запроса движ-
ка хранилища: один для функции VALUES, а второй - для расчета количества
продаж по годам. На рис. 19.18 показаны два запроса разных типов.
Расчет суммарного количества проданных товаров по годам требует, что-
бы запрос SQL (отображенный в первой строке) был послан источнику данных
DirectQuery. Список календарных лет, извлеченный функцией VALUES, пред-
ставлен в запросе движка хранилища VertiPaq в строке 3.
ГЛАВА 19 Анализ планов выполнения запросов DAX 703
Total SE CPU Line Subclass Duration CPU Rews КЗ Query
3 271 ms 3 ms 1 SQL 3 264 C SELECT ТС» (1000001) [tl•
XvO 3 Scan C C 1C 1 SELECT'Date'[Calendar Yej
FE SE
7 ns 3 264 ms
0.29b 5^8%
SF Queries SE Cache
2 0
no
Рис. 19.18 Запросы к движку хранилища DirectQuery отображаются как SQL
При анализе составных моделей данных необходимо обращать внимание на
колонку Subclass, в которой указан тип используемого движка хранилища дан-
ных. SQL здесь всегда означает использование источника данных DirectQuery -
обычно этот движок работает медленнее, чем VertiPaq, и запросы к нему могут
быть оптимизированы путем использования агрегаций. Об этом мы погово-
рим в следующем разделе.
Использование агрегатов в модели данных
Как мы уже упоминали в главе 18, присутствие агрегированных таблиц в мо-
дели данных способно повысить быстродействие запросов движка хранили-
ща. Агрегаты могут быть определены как в движке VertiPaq, так и в DirectQue-
гу и позволяют применить альтернативный подход к выполнению запросов
движка хранилища. При наличии подходящих агрегатов движок попытается
переписать стандартные запросы к движку таким образом, чтобы их задей-
ствовать. Но это будет возможно, только если найдется совместимый агрегат.
В противном случае движок приступит к выполнению запроса в исходном виде.
В DAX Studio можно увидеть попытки изменения исходных запросов с целью
использования агрегатов. Анализ этих попыток зачастую помогает понять, по-
чему не была использована та или иная агрегация, когда мы этого ожидали.
Рассмотрим для примера следующий запрос, выполненный в составной моде-
ли данных:
EVALUATE
SUMMARIZECOLUMNS (
'Date'[Calendar Year];
"Qty"; SUM ( Sales[Quantity] );
"Qty Red"; CALCULATE (
SUM ( Sales[Quantity] );
'Product'[Color] = "Red"
)
)
В модели данных имеется агрегат для таблицы Sales с гранулярностью по
столбцам Date и Customer. В запросе вычисляется два выражения для каждого
календарного года: Qty представляет собой суммарное количество по всем за-
казам в отчетном году, a Qty Red - количество проданных товаров красного
цвета. На рис. 19.19 показаны запросы движка хранилища данных в DAX Studio
для этого запроса.
704 ГЛАВА 19 Анализ планов выполнения запросов DAX
Total 2 266 ms FE 12 ms 05% St CPU 16 ms aO.O SE 2 3 54 ms 92 5% Line 1 3 5 Subclass RewriteAttempted Scan Re л rite Artem ptea SQL Duration CPU 0 1 c 2,353 Rows KE C 1C 16 Query <rr?tcrFouro> 1 SELECT ‘Date [Ca end ar Year] St <at:emptedtailed> SELECT TCP (1000001) [tl] [Ca
Si Quel ies SE Cache
2 0
00%
Рис. 19.19 Попытки использования агрегаций отображаются в DAX Studio
в событиях RewriteAttempted
Здесь мы видим два события подкласса RewriteAttempted, демонстрирующих
попытки движка DAX выполнить вычисления перед созданием запроса движ-
ка хранилища. Вычисление столбца Qty требует фильтрации по годам, и этот
запрос совместим с существующим агрегатом (с группировкой по Date и Cus-
tomer). Это отражено в первой строке отчета и окне детализации события, по-
казанном на рис. 19.20.
Age- egate Rewrite Attempt
Match Result:
V matchFound
Original ’able;
Sa es
Mapped To;
Sa es_Agg
v Details
Рис. 19.20 Детализация использования
совместимого агрегата в событии RewriteAttempted
Поскольку агрегация представляет собой таблицу, загруженную в память,
движок сгенерирует следующий запрос для строки 3, показанной на рис. 19.19:
SELECT
'Date1[Calendar Year],
SUM ( 'SaleS-Agg'[Quantity] )
FROM 'Sales.Agg'
LEFT OUTER JOIN 'Date' ON 'Sales_Agg'[Order Date]='Date'[Date];
В четвертой строке отчета, показанного на рис. 19.19, для события RewriteAt-
tempted не было найдено совместимого агрегата при расчете столбца Qty Red,
требующего фильтра по Date и Product. В этом случае исходная таблица Sales,
хранящаяся в режиме DirectQuery, будет запрошена напрямую, без использо-
вания агрегатов, как показано на рис. 19.21.
Поскольку таблица Sales хранится в режиме DirectQuery, движок сгенерирует
запрос SQL, показанный в строке 5. Большая длительность (свыше двух секунд)
в данном случае вполне нормальна и ожидаема. Агрегирование помогает улуч-
шить быстродействие запросов DAX, узким местом в которых является движок
хранилища данных. Если узкое место находится в движке формул, использова-
ние агрегатов поможет вряд ли.
ГЛАВА 19 Анализ планов выполнения запросов DAX 705
Aggregate Rewrite Attempt
Match Result;
X attempted Failed
Original Table:
Sa es
Mapped To:
Q Detai Is
Рис. 19.21 Неудачная попытка поиска
совместимой агрегации в событии RewriteAttempted
Чтение планов выполнения запросов
В начале данной главы мы описали два типа плана выполнения запросов
в DAX: логический и физический. В реальности мы используем эти планы не
так часто, а в первую очередь уделяем внимание запросам движка хранилища
данных. При анализе производительности этих запросов можно обнаружить
проблемы, причины которых кроются в движке хранилища и/или материали-
зации объемных кешей данных в памяти. Читать запросы движка хранилища
куда проще, чем планы выполнения запросов.
В этом разделе мы опишем важные техники проверки планов выполнения
запросов на предмет наличия узких мест. Полный детализированный обзор
всех операторов, использующихся в логическом и физическом планах, выхо-
дит за рамки данной книги. Здесь же нам важно понять связь между планом
выполнения запросов и запросами движка хранилища с целью повышения
производительности.
Как правило, план выполнения запроса генерирует сразу несколько запро-
сов движка хранилища данных. Движок формул консолидирует результаты
кешей данных, выполняя операции объединения разных временных таблиц.
Давайте рассмотрим следующий запрос DAX, возвращающий таблицу с коли-
чеством проданных товаров по цветам, причем с учетом только тех транзак-
ций, в которых значение в столбце Net Price превышает 1000:
EVALUATE
CALCULATETABLE (
ADDCOLUMNS (
ALL ( Product[Color] );
"Units"; CALCULATE (
SUM ( Sales[Quantity] )
)
);
Sales[Net Price] > 1000
)
ORDER BY Product[Color]
Результат, показанный на рис. 19.22, включает в себя все уникальные цвета,
даже те, по которым не было продаж вовсе. И чтобы добиться этого, движок
DAX применяет несколько иной подход по сравнению с языком SQL. Причина
этого в разных техниках, используемых для объединения таблиц. Позже мы
расскажем об этих различиях, а сейчас сосредоточимся на самом процессе.
706 ГЛАВА 19 Анализ планов выполнения запросов DAX
Color
Azure
Black
Blue
Brown
Gold
Green
Grey
Units
551
575
64
403
421 Рис. 19.22 Результат функции ADDCOLUMNS
qq включает строки с пустыми значениями в столбце Units
Логический план выполнения этого запроса, показанный на рис. 19.23,
включает три операции ScanVertipaq, две из которых соответствуют двум раз-
ным кешам данных, созданным запросами движка хранилища.
Line Logical Query Plan
1 Order. RelLogOp DependOnCoIsQO 1-2 RequiredColsd 2)('Product (Color) ' (Units))
2 CalculateTable RelLogOp DependOnCoIsQO 1-2 RequiredCols[1, 2)(,Produc:,[Color), ' [Units])
3 AddColumns: RelLogOp DependOnCoIsQO 1-2 RequiredColsCI. 2)('Product'[Color) [Units])
4 Scan_Vertipaq: RelLogOp DependOnCoIsQO 1-1 RequredCo s(1)( ₽roduct'(Calor])
5 Sum.Vertipaq: ScaLogOp DependOnColsf^CProduct'lColorj) Integer Com nantValue=BLAKK
6 Scan_Vertipeq: RelLogOp DependOnCols(1)('Product [Color]) 2-‘ 13 RequiredCols(1. 88)('Product'[Color] Sales‘(Quantity])
7 Sales'lQuantity]: ScaLogOp DependOnCols(88)( Sales'(Quantty]J Integer DommantValue=NONE
8 Filter. Vertipaq: RelLogOp DependOnCoIsQO 0-0 RequiredCols(0)(‘Sales [Net Price])
9 Scan_Vertipaq: RelLogOp DependOnCoIsQO 0-0 RequiredCols{0)('Sales‘(Net Price])
10 GreaterThan: ScaLogOp DependOnCols(C)CSales [Net Price]) Boolean DominantVa!ue=NONE
11 Sales'(\et Price]: ScaLogOp Deper dOnCoisCOX'Sales'[Net Price]) Currency DominantValue=NONE
12 Constant: ScaLogOp DependOnCoIsQO Currency DominantValue= 1000
13 ColPosftion<Product‘[Color]>: ScaLogOp DependOnCols(1)['Product [Color]) String DominantValuesNONE
Рис. 19.23 Логический план выполнения простого запроса DAX
Две операции Scan Vertipaq в строках 4 и 6 запрашивают разные наборы
столбцов. Третья операция Scan Vertipaq в строке 9 используется для фильтра
и не генерирует отдельный кеш данных. Ее логика включена в один из двух
других запросов движка хранилища.
Операция Scan Vertipaq в строке 4 использует в своей работе только столбец
Color, тогда как Scan Vertipaq в строке 6 добавляет к нему еще и столбец Quan-
tity, притом что эти столбцы относятся к двум разным таблицам. Для выполне-
ния таких операций необходимо объединить две или более таблиц.
После составления логического плана выполнения запроса профилировщик
получает события от движка хранилища данных. Вот соответствующие запро-
сы xmSOL:
SELECT
Product[Color],
SUM ( Sales[Quantity] )
FROM Sales
LEFT OUTER JOIN Product ON Sales[ProductKey] = Product[ProductKey]
WHERE Sales[Net Price] > 1000;
SELECT Product[Color] FROM Product;
ГЛАВА 19 Анализ планов выполнения запросов DAX 707
Первый запрос движка хранилища извлекает таблицу, содержащую по од-
ной строке для каждого цвета, по которому была как минимум одна продажа
в таблице Sales с ценой, превышающей 1000. Чтобы сделать это, запрос объ-
единяет таблицы Sales и Product по столбцу ProductKey. Второй запрос xmSQL
возвращает список всех цветов независимо от их упоминания в таблице Sales.
Эти запросы генерируют два разных кеша данных: один с двумя столбцами
(цвет товара и суммарное количество), а другой с одним (только цвет).
На этом этапе у вас мог бы появиться резонный вопрос: а зачем нужен вто-
рой запрос? Неужели одного запроса xmSQL не достаточно? Причина в том, что
слева в операторе объединения таблиц LEFT JOIN в нашем запросе xmSQL на-
ходится таблица Sales, а справа - Product. В традиционном SQL мы бы написали
этот запрос иначе:
SELECT
Product.Color,
SUM ( Sales.Quantity )
FROM Product
LEFT OUTER JOIN Sales
ON Sales.ProductKey = Product.ProductKey
WHERE Sales.NetPrice > 1000
GROUP BY Product.Color
ORDER BY Product.Color;
Указание таблицы Product слева от оператора LEFT JOIN позволяет включить
в итоговый результат все имеющиеся в базе данных цвета товаров. Однако
движок хранилища умеет генерировать запросы к таблицам только на осно-
вании связей, существующих в модели данных, и в результирующем запросе
xmSQL таблица, находящаяся в связи на стороне «многие», всегда будет распо-
лагаться слева от оператора LEFT JOIN. Такой подход гарантирует включение
в итоговый набор продаж по товарам даже в случае отсутствия соответствую-
щих ключей в таблице Product; для таких товаров в результирующей таблице
все их атрибуты будут представлены пустыми значениями - в нашем случае
это цвет товара.
Теперь, когда мы знаем, почему движок DAX генерирует два запроса движка
хранилища для одного исходного запроса DAX, можно приступить к анализу
физического плана выполнения запроса, показанного на рис. 19.24, из которо-
го можно получить дополнительную важную информацию о ходе выполнения
запроса.
Line Records Physical Query Plan
1 PartitionIntoGroups: IterPhyOp LogOp=Crder lterCols(1, 2)CProduct'[Color], lUnits]' ^Groups=1 sRows=16
2 1 AggregationSpocI* Order»: SpoolPhyOp *Records=1
3 AddColumns: IterPhyOp LogOp=AddColumns IterCols;!. 2)(*Product [Color],' [Units])
4 16 Spool_lterator< Spool Iterator»; IterPhyOp LogOp= Scan_Vertipaq lterCols(1)f Product [Color]) ^Records=16
5 16 ProjectionSpooi<Prqjectrusion< >>: SpoolPnyCp *Records=16
6 Cache: IterPhyOp -Fie!dCols=1 *ValueCols=0
7 10 SpoolLookup: LookupPhyOp LcgOpsSum.Vertipaq LookupColsilX'Product'IColor)) Integer *Records=1C *Ke
8 10 ProjectionSpool<ProjectFusion<Copy>> SpoolPhyOp *Records=10
9 Cache: IterPhyOp sFie!dCols= 1 »ValueCols=1
Ю ColPosition<,Product‘|Color]>: LookupPhyOp LogOp=ColPosition<’Product'[Color]>ColPo$ t on< Product [Coloi
Рис. 19.24 Физический план выполнения простого запроса DAX
708 ГЛАВА 19 Анализ планов выполнения запросов DAX
В физическом плане выполнения запроса используется оператор Cache
(в строках 6 и 9) для обозначения мест, где происходит чтение из кеша дан-
ных, предоставленного движком хранилища. К сожалению, невозможно про-
читать текст соответствующего запроса движка хранилища для каждой опера-
ции. Но в простых запросах, таких как наш, можно получить всю необходимую
информацию, исходя из вторичных признаков. Например, в одной операции
Cache участвует только один столбец, полученный при помощи операции груп-
пировки, тогда как в другой - сразу два столбца: один получен в результате
выполнения группировки, а второй - в результате агрегирования (сумма по
столбцу Quantity). В физическом плане выполнения запроса параметр ^Value-
Cols означает количество столбцов, полученных в результате агрегирования,
a #FieldCols - количество остальных столбцов, использованных для выполне-
ния группировки результата. Глядя на столбцы в операциях Cache, часто можно
легко определить соответствующий запрос xmSQL, хотя в сложных планах вы-
полнения запросов на это может понадобиться немало времени. В нашем при-
мере операция Cache в строке 6 возвращает 16 цветов товаров в одном столбце,
a Cache из строки 9 - десять строк и два столбца, учитывая только те цвета, по
которым в таблице Sales есть транзакции по выбранному фильтру (в нашем
случае значения столбца Net Price должны быть больше 1000).
Операция ProjectionSpoolo читает данные из кешей соответствующих узлов
Cache в физическом плане выполнения запроса. Здесь мы видим очень важ-
ную информацию, выраженную в количестве записей итерации, что соответ-
ствует числу строк в используемом кеше данных. Это значение указано следом
за атрибутом ^Records, а также продублировано в отдельной колонке Records
в DAX Studio. Этот же атрибут ^Records отображается и в родительских узлах
в плане, там же, где указан тип используемой агрегации, если таковая име-
ется. В нашем примере операция Cache в строке 9 насчитывает два столбца:
Product[Color] и еще один, отражающий результат агрегации. Эта информация
содержится в аргументе LogOp операций Spool lterator и SpoolLookup в стро-
ках 4 и 7 соответственно.
На данном этапе мы можем подытожить информацию о том, что мы узнали
из планов выполнения нашего запроса и запросов движка хранилища данных.
1. Движок формул считывает информацию из двух кешей данных, соответ-
ствующих узлам Cache в физическом плане запроса.
2. Движок формул осуществляет итерации по списку цветов товаров, кото-
рый представляет собой таблицу из одного столбца и 16 строк. Этот кеш
данных был получен вторым запросом движка хранилища. Не делайте
выводов из порядка следования запросов движка хранилища данных
в профилировщике.
3. Для каждой строки в этом кеше данных (с цветами товаров) движок фор-
мул выполняет поиск данных во втором кеше, содержащем, помимо цве-
та товара, еще и проданное количество (этот кеш содержит два столбца
и десять строк).
Все свои действия движок формул выполняет в одном потоке, посылая
движку хранилища данных по одному запросу за раз. Если движок хранили-
ща способен производить вычисления в режиме многопоточности, то движок
формул работает исключительно последовательно.
ГЛАВА 19 Анализ планов выполнения запросов DAX 709
Примечание Функционирование движка формул и движка хранилища данных является
предметом активной оптимизации со стороны разработчиков. И в новых версиях движка
DAX их поведение может измениться.
Движок формул может комбинировать результаты как посредством поиска,
как было показано в предыдущем примере, так и с применением других опе-
раторов для работы со множествами. Так или иначе, движок формул всегда ра-
ботает в последовательном режиме. По этой причине консолидирование боль-
ших кешей данных и поиск миллионов строк могут занимать немало времени.
Простым и эффективным способом поиска таких узких мест в физическом
плане выполнения запроса является анализ строк с предельно большим коли-
чеством записей в операторах логического плана. С этой целью в DAX Studio
данный показатель выделен в отдельную колонку (Records), чтобы можно было
сортировать по нему операторы плана выполнения запроса. Чтобы выполнить
сортировку операторов по количеству записей, достаточно кликнуть по заго-
ловку колонки Records в отчете, представленном на рис. 19.24. В следующей гла-
ве мы рассмотрим более подробный пример с использованием такого подхода.
Наличие связей в модели данных является одним из ключевых факторов для
достижения высоких показателей быстродействия запросов. Для сравнения
рассмотрим запрос, подразумевающий объединение двух таблиц, при отсут-
ствии физической связи между ними в модели данных. Напишем запрос DAX,
аналогичный предыдущему, но в условиях отсутствия связи между таблицами
Product и Sales. Для этого воспользуемся шаблоном создания виртуальной свя-
зи, который мы обсуждали в главе 15:
DEFINE
MEASURE Sales[Units] =
CALCULATE (
SUM ( Sales[Quantity] );
INTERSECT (
ALL ( Sales[ProductKey] );
VALUES ( 'Product'[ProductKey] )
);
-- Отключаем существующую связь между таблицами Sales и Product
CROSSFILTER ( Sales[ProductKey], 'Product'[ProductKey], NONE )
)
EVALUATE
ADDCOLUMNS (
ALL ( 'Product'[Color] );
"Units”; [Units]
)
ORDER BY 'Product'[Color]
Функция INTERSECT, используемая в определении меры Units, эквива-
лентна созданию связи между таблицами Sales и Product. Получившийся план
выполнения запроса будет сложнее, чем в предыдущем примере, поскольку
в обоих планах - логическом и физическом - появятся дополнительные опе-
рации. Мы не будем обсуждать в подробностях весь план выполнения запроса,
поскольку на это не хватит одной книги, а просто разобьем его на следующие
логические шаги.
710 ГЛАВА 19 Анализ планов выполнения запросов DAX
1. Извлекаем список значений столбца ProductKey для каждого цвета товара.
2. Суммируем столбец Quantity для каждого ProductKey,
3. Для каждого цвета агрегируем Quantity соответствующего значения Pro-
ductKey.
Движок формул выполняет четыре запроса движка хранилища данных, что
видно по рис. 19.25.
Line Subclass Duration CPU Rows KB Query
2 Scan 1 0 2 238 18 SELECT’
Scan 0 0 19 1 SELECT ’
6 Scan 1 0 2517 10 SELECT ’
8 Scan 2 0 2 238 35 SELECT ’
Рис. 19.25 Запросы движка хранилища для вычисления
с использованием виртуальной связи с применением функции INTERSECT
Вот полный список запросов xmSQL, сгенерированных посредством четырех
запросов движка хранилища:
SELECT
Sales[ProductKey]
FROM Sales;
SELECT
Product[Color]
FROM Product;
SELECT
Product[ProductKey], Product[Color]
FROM Product;
SELECT
Sales[ProductKey], SUM ( Sales[Quantity] )
FROM Sales
WHERE Sales[ProductKey] IN ( 490, 479, 528, 379, 359, 332, 374, 597, 387,
484..[158 total values, not all displayed] );
Условие WHERE, выделенное в последнем запросе жирным шрифтом, может
показаться бесполезным, поскольку в запросе DAX не применяется фильтр по
товарам. Но в реальной жизни обычно будут присутствовать другие активные
фильтры на товары и другие таблицы. План выполнения запроса пытается из-
влечь количество проданных товаров только по тем из них, которые относятся
к текущему запросу, чтобы снизить объем кеша данных, возвращаемого движ-
ку формул. Когда в движке хранилища присутствуют похожие условия WHERE,
опасение вызывает только размер соответствующего битового индекса, пере-
мещаемого между движком формул и движком хранилища данных.
В обязанности движка формул входит группировка товаров по цветам. Быст-
родействие этой операции объединения на уровне движка формул в первую
очередь зависит от количества товаров, а во вторую - от количества цветов.
Еще раз повторим, что при поиске узких мест в движке формул главенствую-
щую роль играет размер кеша данных.
ГЛАВА 19 Анализ планов выполнения запросов DAX 711
Мы рассмотрели вариант создания виртуальной связи между таблицами
при помощи функции INTERSECT исключительно в образовательных целях.
Мы хотели показать запросы движка хранилища, требуемые для выполнения
объединения таблиц преимущественно на стороне движка формул. При этом
лучшей альтернативой в плане быстродействия для объединения таблиц в от-
сутствие физической связи между ними является функция TREATAS. Рассмот-
рим такую реализацию предыдущего запроса:
DEFINE
MEASURE Sales[Units] =
CALCULATE (
SUM ( Sales[Quantity] );
TREATAS (
VALUES ( 'Product'[ProductKey] );
Sales[ProductKey]
);
-- Отключаем существующую связь между таблицами Sales и Product
CROSSFILTER ( Sales[ProductKey]; 'Product'[ProductKey]; NONE )
)
EVALUATE
ADDCOLUMNS (
ALL ( 'Product'[Color] );
"Units”; [Units]
)
ORDER BY 'Product'[Color]
Как видно по рис. 19.26, в результате выполнения этого запроса было сге-
нерировано три запроса движка хранилища вместо четырех. Помните, что
событие Batch просто объединяет в себе предыдущие события Scan. Кроме
того, размер кеша данных в этом случае будет меньше - у нас есть один кеш
из 2517 строк, что соответствует количеству товаров в таблице Product. В пре-
дыдущей реализации формулы с использованием функции INTERSECT у нас
было несколько запросов с тысячами записей. И все эти кеши данных движку
формул пришлось бы объединять.
Line Subclass Duration CPU Rows KB Query
2 Scan 1 0 2 517 10 DEFINE TABLE 'STTal
4 Scan 2 0 16 1 DEFINE TABLE 'STTal
5 Batch 4 16 DEFINE TABLE 'STTal
7 Scan 0 0 19 1 SELECT Product iCo
Рис. 19.26 Запросы движка хранилища для вычисления
с использованием виртуальной связи с применением функции TREATAS
Посмотрим на содержимое события Batch из строки 5, которое включает
в себя два события Scan (из строк 2 и 4):
DEFINE TABLE '$TTable3' := SELECT
'Product'[ProductKey], 'Product'[Color]
FROM 'Product',
712 ГЛАВА 19 Анализ планов выполнения запросов DAX
CREATE SHALLOW RELATION '$TRelationl' MANYTOMANY
FROM 'Sales'[ProductKey] TO '$TTable3'[Product$ProductKey],
DEFINE TABLE '$TTablel' := SELECT
'$TTable3'[Product$Color],
SUM ( '$TTable2'[$Measure0] )
FROM '$TTable2'
INNER JOIN '$TTable3' ON '$TTable2'[Sales$ProductKey]='$TTable3'[Product$ProductKey]
REDUCED BY
'$TTable2' := SELECT
'Sales'[ProductKey],
SUM ( 'Sales'[Quantity] ) AS [$Measure0]
FROM 'Sales';
Преимущество в быстродействии этого варианта состоит в том, что функ-
ция TREATAS передает выполнение операции движку хранилища данных бла-
годаря выражению CREATE SHALLOW RELATION, выделенному в коде жирным
шрифтом. В этом случае отпадает необходимость в материализации большего
количества данных для движка хранилища. Фактически объединение было вы-
полнено в движке формул, что позволило уменьшить количество строк в фи-
зическом плане выполнения запроса с 37 в случае использования функции
INTERSECT (в книге запрос не показан для экономии места) до десяти с при-
менением функции TREATAS. В результате мы получим план выполнения за-
проса, очень близкий к тому, что показан на рис. 19.24.
Для анализа более сложных планов выполнения запросов понадобилось бы
написать отдельную книгу, поскольку сами планы были бы довольно длин-
ными. Больше информации о внутреннем устройстве планов выполнения за-
просов можно почерпнуть в статьях «Understanding DAX Query Plans» по адре-
су http://www.sqLbi.com/articLes/understanding-dax-query-pLans/ и «Understanding
Distinct Count in DAX Query Plans» по адресу http://www.sqlbi.com/articLes/under-
standing-distinct-count-in-dax-query-pLans/.
Заключение
Как вы увидели, более близкое знакомство с планами выполнения запросов
способно открыть вам целый новый мир. В данной главе мы лишь вскользь
коснулись объемной темы, связанной с планами выполнения запросов, а для
ее полного освещения потребовалась бы книга вдвое больше этой. Хорошая
новость заключается в том, что в большинстве сценариев, если не во всех,
углубление в детали в этом вопросе будет излишним.
Опытный разработчик DAX, стремящийся писать оптимальный код, должен
уметь фокусироваться на простых вещах, которые очень быстро можно понять,
оценив наиболее важные аспекты планов выполнения запросов:
присутствие большого количества строк для сканирования в физическом
плане выполнения запроса говорит о необходимости материализации
больших наборов данных. А это, в свою очередь, является предвестником
того, что запрос будет очень медленным и «прожорливым»;
ГЛАВА 19 Анализ планов выполнения запросов DAX 713
в большинстве случаев запросы VertiPaq включают в себя достаточно ин-
формации, чтобы понять в целом алгоритм расчетов. Все, что остается
необработанным в запросе VertiPaq, должно быть обработано в движке
формул. Осознание этого позволит вам лучше понять весь процесс вы-
полнения запросов;
присутствие функции CallbackDatalD в запросе движка хранилища озна-
чает, что во время итераций на уровне строк возникает необходимость
выполнения слишком сложных для движка VertiPaq вычислений. Само
по себе использование функций обратного вызова не является ошибкой.
И все же избавление кода от них в большинстве случаев приведет к росту
производительности запросов;
модели данных VertiPaq и DirectQuery отличаются друг от друга. При
использовании режима DirectQuery быстродействие запросов DAX на-
прямую зависит от производительности источника данных. Есть смысл
использовать режим DirectQuery только в том случае, если соответствую-
щий источник данных специально оптимизирован под запросы, генери-
руемые движком хранилища DirectQuery.
В заключительной главе книги мы используем приобретенные ранее зна-
ния, чтобы выполнить несколько пошаговых процессов оптимизации.
ГЛАВА 20
Оптимизация в DAX
Это последняя глава книги, и сейчас пришло время использовать все получен-
ные ранее знания, чтобы погрузиться в самую захватывающую тему в DAX -
оптимизацию формул. Вы уже знаете, как работает движок DAX, как читать
планы выполнения запросов, а также понимаете внутреннее устройство движ-
ков формул и хранилища данных. А значит, у вас есть все необходимое, чтобы
сделать последний шаг и научиться писать более эффективный код на DAX.
Но перед тем как приступить к делу, позвольте сделать важное замечание.
Не ожидайте, что, прочитав эту главу, вы узнаете какой-то универсальный
способ писать быстрый код. Скажем прямо: в DAX невозможно написать код,
который в любых условиях будет работать оптимально. Скорость работы фор-
мул в DAX зависит от множества факторов, и главный из них, увы, связан не
с синтаксисом языка DAX, а с распределением данных в конкретной модели.
Вы уже знаете, что степень сжатия информации в движке VertiPaq напрямую
зависит от распределения данных. Размер столбца (а значит, и скорость его
сканирования), в свою очередь, находится в зависимости от кратности: чем
она меньше, тем быстрее будут обрабатываться данные в столбце. Таким обра-
зом, одна и та же формула может работать с разными столбцами с совершенно
разной скоростью.
Здесь вы узнаете, как измерить скорость выполнения формулы. Кроме того,
мы покажем вам несколько примеров, в которых изменение кода по-разному
будет влиять на его производительность. Внимательно изучите все представ-
ленные в данной главе примеры - они могут помочь вам выработать собствен-
ные идеи применительно к вашему коду. Но не принимайте наши примеры как
догму, поскольку они таковыми не являются.
Мы не учим вас правилам. Вместо этого мы пытаемся научить вас выра-
батывать собственные правила и находить оптимальные решения в ваших
конкретных обстоятельствах и сценариях. Будьте готовы к тому, что вам при-
дется менять правила при изменении модели данных или сценария работы.
При оптимизации кода DAX ключевую роль играет гибкость. Также очень важ-
ны глубокие технические познания в отношении работы движка и склонность
к творческому подходу - достаточная, чтобы проверять не самые интуитивно
понятные с первого взгляда формулы и выражения.
Наконец, стоит помнить, что вся информация, которую мы приводим в дан-
ной книге, актуальна на момент ее написания. Новые версии движков появля-
ются буквально каждый месяц, а команда разработчиков языка DAX очень тре-
петно относится к улучшению своего детища. Так что будьте готовы к тому, что
на вашей версии движка цифры в представленных примерах могут оказаться
совсем другими, и вам, вероятно, придется применять иные методы опти-
ГЛАВА20 Оптимизация в DAX 715
мизации. Если однажды вы напишете код, измерите его производительность
и подумаете: «А Марко с Альберто ведь были неправы, и мой код работает го-
раздо быстрее предложенного ими!», это будет один из самых счастливых дней
в нашей жизни, поскольку это будет означать, что мы научили вас всему, что
умеем сами, и вы действительно готовы писать более быстрый код, чем наш.
Выбор стратегии оптимизации
Процесс оптимизации запроса, выражения или меры в DAX требует опреде-
ленной стратегии для воспроизведения проблем с производительностью,
идентификации узких мест и их устранения. Сложные запросы всегда изна-
чально выполняются медленно, при этом оптимизация составных выражений
DAX, включающих в себя несколько мер, является гораздо более запутанным
процессом по сравнению с оптимизацией одной меры за раз. По этой причине
мы всегда советуем выделять наиболее медленную меру или выражение и оп-
тимизировать их в более простом запросе, воспроизводящем проблему, но при
этом обладающем достаточно коротким планом выполнения.
Представляем вам упрощенный план, которому необходимо следовать при
выполнении оптимизации кода DAX.
1. Выделить конкретное выражение DAX, которое вам предстоит оптими-
зировать.
2. Создать запрос, воспроизводящий проблему с производительностью.
3. Проанализировать время выполнения запроса на сервере и информа-
цию о плане выполнения.
4. Идентифицировать узкие места в производительности движка хранили-
ща данных или движка формул.
5. Внести изменения в запрос и повторить тестовый запуск.
В следующих разделах мы подробно поговорим о каждом из этих пунктов.
Выделение выражения DAX для оптимизации
Если вы уже определили самую медленную меру в вашей модели данных, мо-
жете пропустить этот раздел и переходить к следующему. Зачастую же отчет,
формирующийся медленно, может состоять из множества запросов, каждый из
которых может включать несколько мер. И первым делом необходимо четко
определить, какое выражение DAX вы собираетесь оптимизировать. В процес-
се поиска вы будете сокращать количество выполняемых шагов и в итоге при-
дете к одному запросу, а возможно, и к одной мере.
Полное обновление отчета в Power BI, Reporting Services или рабочей книге
Microsoft Excel обычно ведет к созданию множества запросов на языке DAX или
MDX (сводные таблицы и графики в Excel всегда используют MDX). При гене-
рации большого количества запросов первым делом необходимо выделить из
них самый медленный. В главе 19 мы увидели, как DAX Studio умеет перехва-
тывать запросы, посланные движку DAX, и выделять из них самый медленный
благодаря выводу длительности их выполнения.
716 ГЛАВА 20 Оптимизация в DAX
Если вы используете Excel, то можете также применять другие техники для
выделения конкретных запросов. К примеру, можно извлекать генерируе-
мые Excel запросы MDX при помощи OLAP PivotTable Extensions, бесплатной
надстройки для Excel, доступной по адресу: https://oLappivottableextensions.
github.io/.
После определения самого медленного запроса DAX или MDX необходимо
опуститься на уровень выражений и выделить одно из них, являющееся при-
чиной проблем с производительностью. Сделав это, вы сможете сконцентри-
ровать свои усилия на конкретной области. Вы можете уменьшать количество
мер, включенных в запрос, путем изменения и запуска запроса непосредствен-
но в DAX Studio.
Например, рассмотрим следующий табличный вывод в Power BI с участием
четырех выражений (двух подсчетов количества уникальных значений и двух
мер), сгруппированных по бренду, как показано на рис. 20.1.
Brand Count ot ProductKey Sales Amount Margin % Count ot Order Number
A. Datum 132 251,211.515.57 58.42% 131413
Adventure Works 192 518,462,059.16 51.31% 310083
Contoso 710 871,501,804.63 53.19% 462805
Fabrikam 267 627,751,182.08 54.38% 53309
Litware 264 416,239,414.35 51.73% 118500
Northwind Traders 47 151,481,923.36 52.33% 131667
Proseware 244 312,763,353.13 54.06% 51063
Southridge Video 192 183,482,982.39 49.53% 570613
Tailspin Toys 144 42,801,223.58 48.83% 688390
The Phone Company 152 174,742,660.20 52.23% 29852
Wide World Importers 173 254,953,905.77 52.39% 49745
Total 2517 3.805.392.024.21 53.03% 1663351
Рис. 20.1 Простая визуализация запроса DAX с четырьмя выражениями в Power Bl
Этот отчет генерирует следующий запрос DAX, который мы перехватили при
помощи DAX Studio:
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Brand]; "IsGrandTotalRowTotal” );
"DistinctCountProductKey"; CALCULATE (
DISTINCTCOUNT ( 'Product'[ProductKey] )
);
"SaleS-Amount"; 'Sales'[Sales Amount];
"Margin_"; 'Sales'[Margin %];
"DistinctCountOrder_Number"; CALCULATE (
DISTINCTCOUNT ( 'Sales'[Order Number] )
)
);
[IsGrandTotalRowTotal]; 0;
'Product'[Brand]; 1
ГЛАВА 20 Оптимизация в DAX 717
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Product'[Brand]
Необходимо попробовать сократить этот запрос таким образом, чтобы за
раз выполнялось только одно вычисление. Для этого достаточно закомменти-
ровать или удалить три или четыре столбца из функции SUMMARIZECOLUMNS
(JDistinctCountProductKey, Sales Amount, Margin_ и DistinctCountOrderNumber),
чтобы в процессе найти самое медленное вычисление. В нашем случае тако-
вым оказалось последнее вычисление. Выполнение следующей вариации за-
проса занимает порядка 80 % времени от исходного запроса, а это означает,
что подсчет уникальных значений в столбце Sales[Order Number] является наи-
более дорогой операцией в нашем запросе:
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Brand]; "IsGrandTotalRowTotal” );
// "DistinctCountProductKey"; CALCULATE (
// DISTINCTCOUNT ( 'Product'[ProductKey] )
// );
// "Sales_Amount"; 'Sales'[Sales Amount];
// "Margin__"; 'Sales'[Margin %];
"DistinctCountOrder_Number"; CALCULATE (
DISTINCTCOUNT ( 'Sales'[Order Number] )
)
);
[IsGrandTotalRowTotal]; 0;
'Product'[Brand]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Product'[Brand]
Еще одним примером является следующий запрос MDX, сгенерированный
сводной таблицей Excel, показанной на рис. 20.2:
SELECT {
[Measures].[Sales Amount],
[Measures].[Total Cost],
[Measures].[Margin],
[Measures].[Margin %]
} DIMENSION PROPERTIES PARENT_UNIQUE_NAME, HIERARCHY_UNIQUE_NAME ON COLUMNS,
NON EMPTY HIERARCHIZE(
DRILLDOWNMEMBER(
{ { DRILLDOWNMEMBER(
{ { DRILLDOWNLEVEL(
{ [Date].[Calendar].[All] },,, include_calc_members )
} },
{ [Date].[Calendar].[Year].&[CY 2008] },,, include_calc_members )
718 ГЛАВА 20 Оптимизация в DAX
} ъ
{ [Date].[Calendar].[Quarter].&[Q4-2008] },,, include_calc_members
)
)
DIMENSION PROPERTIES PARENT_UNIQUE_NAME,HIERARCHY_UNIQUE_NAME ON ROWS
FROM [Model]
CELL PROPERTIES VALUE, FORMAT_STRING, LANGUAGE, BACK_COLOR, FORE_COLOR, FONT_FLAGS
Row Labels 7 Sales Amount Total Cost Margin Mat 51л %
♦CY 2007 1,415*298,561.42 656,812,625.98 758,485,935.44 53.59%
CY 2008
1Щ-2008 249328,005-72 121389,562-10 127,938,843-62 5131%
ffl Q2-2C08 321,749-612-47 147,214,171*80 174,535,440.07 54.25%
* Q3-2C08 323,449 998.92 147,948,797.33 175,501,201.59 54.26%
~ Q4-2U08 October 2003 97,130,506 81 44 062,202.41 53.068,504 40 54.64%
November 20C8 96 777 975.30 50,656,942.35 46,121,032.95 47.66%
December 2008 10Д8Э0 113.59 52,797,506.05 48,092,607.54 47.67%
* CY 2009 1,200*766,849.99 566,553 987 10 634,212,862.89 52.82%
Grand Total 3,805,392,024.21 1,787,435,795.12 2,017,956,229.09 53.03%
Рис. 20.2 Простая сводная таблица Excel,
генерирующая запрос MDX с четырьмя мерами
Вы можете уменьшить количество мер либо в самой сводной таблице, либо
непосредственно в запросе MDX. Вы вольны изменять код MDX, уменьшая ко-
личество мер в фигурных скобках. Например, вы можете оставить одну меру
Sales Amount, изменив начальную часть запроса следующим образом:
SELECT
{ [Measures].[Sales Amount] }
DIMENSION PROPERTIES PARENT_UNIQUE_NAME, HIERARCHY_UNIQUE_NAME ON COLUMNS,
Вне зависимости от техники, которую вы используете, как только вы опре-
делились с выражением или мерой, критически влияющей на производитель-
ность запроса в целом, вам необходимо создать так называемый запрос вос-
произведения (reproduction query) или проверочный запрос для использования
в DAX Studio.
Создание проверочного запроса
Процесс оптимизации требует наличия запроса, который вы могли бы запус-
кать несколько раз, возможно, попутно меняя определения мер для разносто-
ронней оценки производительности.
Если вы перехватили запросы на DAX или MDX, у вас уже есть отличная от-
правная точка для создания проверочного запроса. При этом вы должны мак-
симально, насколько это возможно, упростить запрос, чтобы облегчить себе
поиск узких мест. Сложную структуру запроса можно оставить только в случае,
если она играет важную роль при определении его быстродействия.
ГЛАВА 20 Оптимизация в DAX 719
Создание проверочного запроса в DAX
Если мера все время вычисляется медленно, у вас должна быть возможность
создать проверочный запрос, возвращающий в качестве результата одно зна-
чение. Используя функцию CALCULATE или CALCULATETABLE, вы можете при-
менить все необходимые фильтры. Например, вы можете вычислить меру Sales
Amount для ноября 2008 года с использованием следующего кода и получить ту
же сумму $96 777 975,30, которая показана для ноября на рис. 20.2:
EVALUATE
{
CALCULATE (
[Sales Amount];
'Date'[Calendar Year] = "CY 2008";
'Date'[Calendar Year Quarter] = "Q4-2008";
'Date'[Calendar Year Month] = "November 2008"
)
}
Можно написать этот запрос и с использованием функции CALCULATETABLE
вместо CALCULATE:
EVALUATE
CALCULATETABLE (
{ [Sales Amount] };
'Date'[Calendar Year] = "CY 2008";
'Date'[Calendar Year Quarter] = "Q4-2008";
'Date'[Calendar Year Month] = "November 2008"
)
Эти два подхода дадут одинаковый результат. При этом функцию CALCULA-
TETABLE стоит применять, когда запрос, используемый для проверки меры,
оказывается сложнее, чем просто табличный конструктор.
Создав проверочный запрос для конкретной меры в вашей модели данных,
вы должны рассмотреть вариант определения этой меры непосредственно
внутри запроса с использованием ключевого слова MEASURE.
Например, вы могли бы изменить предыдущий запрос следующим образом:
DEFINE
MEASURE Sales[Sales Amount] =
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
EVALUATE
CALCULATETABLE (
{ [Sales Amount] };
'Date'[Calendar Year] = "CY 2008";
'Date'[Calendar Year Quarter] = "Q4-2008";
'Date'[Calendar Year Month] = "November 2008"
)
Теперь вы можете вносить изменения в выражение меры непосредственно
в коде запроса. Таким образом, вам не придется каждый раз менять модель
данных, чтобы проверить тот или иной вариант меры. Вместо этого вы можете
720 ГЛАВА 20 Оптимизация в DAX
изменить запрос, очистить кеш и запустить вычисление меры в DAX Studio,
одновременно измерив ее быстродействие.
Создание мер для запросов в DAX Studio
В DAX Studio можно создавать синтаксис MEASURE для мер, определенных
в модели данных, используя контекстное меню Define Measure. Для этого не-
обходимо выбрать необходимую меру на панели Metadata, как показано на
рис. 20.3.
Рис. 20.3 Использование контекстного меню Define Measure в DAX Studio
Если мера ссылается на другие меры, все они должны быть включены в за-
прос, чтобы можно было вносить требуемые изменения непосредственно
в проверочном запросе. Пункт контекстного меню Define Dependent Measures
позволяет включить определения всех мер, на которые ссылается выбранная
мера, тогда как пункт Define and Expand Measure заменяет ссылки на меры со-
ответствующими выражениями.
Рассмотрим для примера следующий запрос, вычисляющий меру Margin %:
EVALUATE
{ [Margin %] }
Выбрав пункт контекстного меню Define Measure на мере Margin %, вы по-
лучите следующий код, в котором также присутствуют ссылки на меры Sales
Amount и Margin:
DEFINE
MEASURE Sales[Margin %] =
DIVIDE ( [Margin]; [Sales Amount] )
EVALUATE
{ [Margin %] }
А чтобы не выбирать пункт Define Measure для всех мер, участвующих в вы-
ражении, вы можете один раз выбрать пункт Define Dependent Measures на мере
Margin %, что позволит получить выражения для всех мер, включая Total Cost,
которая используется в выражении меры Margin:
DEFINE
MEASURE Sales[Margin] = [Sales Amount] - [Total Cost]
ГЛАВА 20 Оптимизация в DAX 721
MEASURE Sales[Sales Amount] =
SUMX ( Sales; Sales[Quantity] * Sales[Net Price] )
MEASURE Sales[Total Cost] =
SUMX ( Sales; Sales[Quantity] * Sales[Unit Cost] )
MEASURE Sales[Margin %] =
DIVIDE ( [Margin]; [Sales Amount] )
EVALUATE
{ [Margin %] }
Также вы можете получить единое выражение DAX без деления на меры, вы-
брав пункт контекстного меню Define and Expand Measure для меры Margin %:
DEFINE
MEASURE Sales[Margin %] =
DIVIDE (
CALCULATE (
CALCULATE ( SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) )
- CALCULATE ( SUMX ( Sales; Sales[Quantity] * Sales[Unit Cost] ) )
);
CALCULATE ( SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) )
)
EVALUATE
{ [Margin %] }
Последняя техника, несмотря на сложность итогового запроса, может при-
годиться при определении того, включает ли мера вложенные итераторы.
Создание проверочного запроса в MDX
Иногда для воспроизведения проблемы, возникающей в MDX, а не в DAX, вам
потребуется создать проверочный запрос именно в MDX. Одна и та же мера
DAX, выполненная в DAX и MDX, генерирует разные планы выполнения запро-
са, и поведение запроса может быть разным в зависимости от выбранного язы-
ка. Однако и в этом случае вы можете определить меру DAX локально в рамках
запроса. Это позволит более эффективно изменять и запускать запрос. Напри-
мер, можно определить меру Sales Amount локально внутри запроса MDX с ис-
пользованием конструкции WITH MEASURE следующим образом:
WITH
MEASURE Sales[Sales Amount] = SUMX ( Sales, Sales[Quantity] * Sales[Unit Price] )
SELECT {
[Measures].[Sales Amount],
[Measures].[Total Cost],
[Measures].[Margin],
[Measures].[Margin %]
} DIMENSION PROPERTIES PARENT_UNIQUE_NAME, HIERARCHY_UNIQUE_NAME ON COLUMNS,
NON EMPTY HIERARCHIZE(
DRILLDOWNMEMBER(
{ { DRILLDOWNMEMBER(
{ { DRILLDOWNLEVEL(
{ [Date].[Calendar].[All] },,, include_calc_members )
} },
722 ГЛАВА 20 Оптимизация в DAX
{ [Date].[Calendar].[Year].&[CY 2008] },,, include_calc_members )
} },
{ [Date].[Calendar].[Quarter].&[Q4-2008] },,, include_calc_members
)
)
DIMENSION PROPERTIES PARENT_UNIQUE_NAME,HIERARCHY_UNIQUE_NAME ON ROWS
FROM [Model]
CELL PROPERTIES VALUE, FORMAT-STRING, LANGUAGE, BACK_COLOR, FORE_COLOR, FONT_FLAGS
Как видите, в MDX вам необходимо использовать ключевое слово WITH
вместо DEFINE, и вы должны соответствующим образом изменить синтаксис
запроса в DAX Studio, если оптимизируете код MDX. После ключевого слова
MEASURE всегда следует код на языке DAX, так что процесс оптимизации для
вас останется прежним. Вне зависимости от выбранного языка для провероч-
ного запроса оптимизировать вам всегда придется выражение DAX, которое
можно определить при помощи ключевого слова MEASURE.
Анализ времени выполнения запроса и информации
из плана
Создав проверочный запрос, вы должны запустить его и собрать всю необходи-
мую информацию о времени выполнения и соответствующих шагах из плана.
В главе 19 вы узнали, как анализировать данные, представленные в DAX Studio
и SQL Server Profiler. В этом разделе мы подытожим шаги, требуемые для ана-
лиза простого запроса в DAX Studio.
Рассмотрим для примера следующий запрос DAX:
DEFINE
MEASURE Sales[Sales Amount] =
SUMX ( Sales; Sales[Quantity] * Sales[Unit Price] )
EVALUATE
ADDCOLUMNS (
VALUES ( 'Date'[Calendar Year] );
"Result"; [Sales Amount]
)
Если вы запустите этот запрос на выполнение в DAX Studio, предваритель-
но включив опции Query Plan и Server Timings и очистив кеш, то получите
результат с одной строкой для каждого года из таблицы Date и рассчитанной
мерой Sales Amount для соответствующего года. Начинать анализ всегда необ-
ходимо со вкладки Server Timings, в которой отображается информация о за-
просе в целом, как показано на рис. 20.4.
Наш запрос выполнялся 25 мс (Total), при этом 72 % всего времени понадо-
билось на вычисления в движке хранилища данных (SE), а оставшиеся 28 % -
на работу движка формул (FE). Эта вкладка не содержит подробностей о внут-
ренних операциях в движке формул, зато дает полное представление о работе
движка хранилища. В нашем случае было создано два запроса движка храни-
лища, на выполнение которых суммарно потребовалось 94 мс процессорного
времени (SE CPU). Этот показатель может быть больше, чем общее время вы-
ГЛАВА20 Оптимизация в DAX 723
полнения запроса, поскольку операции в движке хранилища могут задейство-
вать сразу несколько ядер центрального процессора. В нашем примере было
использовано аппаратное обеспечение с восемью логическими процессорами,
и степень параллелизма запроса (отношение между показателями SE CPU и SE)
оказалась равной 5,2. Это число не может превышать количество логических
процессоров в системе.
Рис. 20.4 Вкладка Server Timings после запуска простого запроса
В списке показаны запросы движка хранилища данных, и, как видите,
единственный запрос, стоящий в перечне первым, расходует все общее вре-
мя выполнения и время загрузки процессора. Включив показ событий Internal
и Cache, можно убедиться в том, что оба запроса действительно были выполне-
ны в движке хранилища данных, - это отчетливо видно по рис. 20.5.
Рис. 20.5 Вкладка Server Timings со включенным показом внутренних событий
Если вы запустите этот запрос повторно без предварительной очистки кеша,
то увидите результаты, показанные на рис. 20.6. Оба запроса движка храни-
лища извлекают данные из кеша (SE Cache), что видно по соответствующим
значениям в колонке Subclass.
Обычно мы будем использовать проверочные запросы на холодном кеше
(cold cache), то есть с очисткой кеша перед выполнением запроса, но бывают
ситуации, когда необходимо оценить, насколько эффективно тот или иной
724 ГЛАВА 20 Оптимизация в DAX
запрос использует данные из кеша. Поэтому показ событий класса Cache по
умолчанию отключен в DAX Studio и активируется только по требованию.
Рис. 20.6 Вкладка Server Timings со включенным показом событий из кеша
Теперь пришло время взглянуть на планы выполнения запроса. На рис. 20.7
показаны физический и логический планы запроса из предыдущего примера.
Чаще всего вы будете работать именно с физическим планом выполнения за-
проса. В нашем запросе используется два кеша данных - по одному на каждый
запрос движка хранилища. Каждая строка с пометкой Cache в физическом пла-
не использует один из кешей данных. При этом нет простого способа сопоста-
вить операцию плана с конкретным кешем данных. Вы можете сделать пред-
положение о том, какой именно кеш использует операция, глядя на колонки
в соответствующих событиях Spool Iterator и SpoolLookup, как видно на рис. 20.7.
Рис. 20.7 На вкладке Query Plan показаны физический и логический планы
выполнения запроса
Очень важную информацию для анализа в физическом плане содержит ко-
лонка с количеством обработанных записей. Как вы увидите позже, при оп-
ГЛАВА20 Оптимизация в DAX 725
тимизации узких мест в движке формул бывает полезно идентифицировать
самую медленную операцию в движке формул путем поиска строки с макси-
мальным количеством обработанных записей. Вы всегда можете отсортиро-
вать таблицу по колонке Records, как показано на рис. 20.8. Вернуть исходный
порядок сортировки можно, щелкнув по заголовку столбца Line.
Рис. 20.8 Операции в физическом плане выполнения запроса
могут быть отсортированы по столбцу Records
Поиск узких мест в движке формул и движке хранилища
данных
Как правило, для оптимизации запросов DAX есть больше одного способа.
Первое, что обычно нужно сделать, - это понять, где запрос проводит больше
времени - в движке формул или в движке хранилища данных. И основная ин-
дикация для этого содержится в DAX Studio в соответствующих графах FE и SE,
о которых мы говорили выше. Обычно это служит отличной отправной точ-
кой для анализа, но также важно определить распределение рабочей нагруз-
ки и внутри самих движков формул и хранилища данных. В сложных запро-
сах большой процент времени в движке хранилища данных может говорить
о наличии большого количества маленьких запросов движка хранилища или
о присутствии нескольких запросов, на которых лежит большая часть нагруз-
ки. Как вы увидите далее, эти две особенности запросов требуют совершенно
разных подходов в плане оптимизации.
При поиске узких мест в запросе необходимо также выделить для себя прио-
ритетные области для оптимизации. Например, в плане выполнения запроса
может присутствовать некая аномалия, которая может привести к чрезмерно
длительному выполнению запроса в движке формул. Вам необходимо обнару-
жить подобные аномалии и сосредоточить на них первостепенное внимание.
Если не следовать этому подходу, можно провести немало времени за оптими-
зацией выражений, не особенно влияющих на быстродействие запроса в це-
726 ГЛАВА 20 Оптимизация в DAX
лом. Иногда наиболее эффективные оптимизации скрываются в нюансах, на
которые при поиске узких мест сразу не подумаешь - например, в операциях
преобразования контекста или других мелочах синтаксиса DAX. Необходимо
замерять время выполнения запросов до и после выполнения оптимизации -
только так можно убедиться, что вы действительно добиваетесь весомого пре-
имущества за счет оптимизации, а не бездумно применяете шаблоны оптими-
зации, подсмотренные в интернете или в данной книге.
Наконец, стоит помнить о том, что даже если вы обнаружили проблему в об-
ласти движка формул, начинать анализ всегда необходимо с запросов движка
хранилища данных. В них заложена важная информация о содержимом и раз-
мере кешей данных, используемых движком формул. Чтение плана запроса,
описывающего операции, выполняемые движком формул, - задача не из лег-
ких. Проще полагаться на то, что именно движок формул будет использовать
созданные кеши данных и выполнять операции, необходимые для получения
результата запроса DAX, с которыми не справился движок хранилища данных.
Такой подход к анализу является наиболее эффективным при работе с боль-
шими и сложными запросами DAX, в процессе выполнения которых могут
быть сгенерированы тысячи строк в планах выполнения, но при этом запросы
движка хранилища будут создавать очень маленькие кеши данных.
Внесение изменений и повторные запуски
тестовых запросов
После обнаружения узкого места в запросе необходимо внести изменения
в выражение DAX и/или модель данных, чтобы план выполнения запро-
са стал наиболее эффективным. Повторно запуская тестовый запрос, можно
убедиться в том, что внесенные корректировки сработали, после чего присту-
пать к поиску следующего узкого места в запросе, каждый раз начиная с шага
«Анализ времени выполнения запроса и информации из плана». Этот процесс
должен продолжаться до тех пор, пока производительность запроса не станет
оптимальной или просто не останется возможностей для дальнейшей опти-
мизации.
Оптимизация узких мест в выражениях DAX
Продолжительное время выполнения запроса в движке хранилища данных
обычно является следствием одной или нескольких следующих предпосылок
(подробнее об этом мы говорили в главе 19):
более длительное время сканирования. Даже для выполнения прос-
тых агрегаций DAX вынужден сканировать один или несколько столбцов.
Стоимость такого сканирования зависит от размера столбцов, который,
в свою очередь, зависит от количества уникальных значений и распреде-
ления данных;
большая кратность. Количество уникальных значений в столбце ока-
зывает прямое влияние на вычисление DISTINCTCOUNT и аргументов
ГЛАВА 20 Оптимизация в DAX 727
фильтра функций CALCULATE и CALCULATETABLE. Большая кратность
также влияет на время сканирования столбца. Кроме того, она может
быть проблемой сама по себе, вне зависимости от размера столбца;
частый вызов функции CallbackDatalD. Слишком частое взаимодей-
ствие с движком формул со стороны движка хранилища данных может
негативно сказаться на быстродействии запроса;
объемная материализация. Если запрос движка хранилища вынужден
создавать большие кеши данных, на это может потребоваться время (для
выделения и записи в память). К тому же чтение таких больших кешей
движком формул также может привести к образованию узкого места при
выполнении запроса.
В следующих разделах мы разберем несколько примеров оптимизации за-
просов. Начнем мы с концепций, усвоенных вами в предыдущих главах, и рас-
смотрим типичные проблемы на примере простых запросов, а также решим их
в процессе оптимизации.
Оптимизация условий фильтрации
Всегда, когда это возможно, аргументы фильтра функций CALCULATE/CALCU-
LATETABLE должны фильтровать столбцы, а не таблицы. Движок DAX постоян-
но улучшается, и начиная с версий 2019 года он научился относительно непло-
хо справляться с простыми табличными фильтрами, но это не отменяет того
факта, что фильтры по столбцам всегда будут эффективнее.
Рассмотрим для примера отчет, показанный на рис. 20.9, в котором сравни-
вается общий итог по мере SalesAmount с суммой по транзакциям, превышаю-
щим $1000 (Big Sales Amount) по брендам.
Brand Sales Amount Big Sales Amount (slow)
A. Datum 251,211.515.57 63,063,695.61
Adventure Works 518,462,059.16 204,706,066.50
Contoso 871,501.804.63 390,591,570.37
Fabrikam 627,751,182.08 296,193,218.97
Litware 416,239,414.35 228,117,264.38
Northwind Traders 151,481,923.36 133,573,773.58
Proseware 312,763,353.13 116,288,985.19
Southridge Video 183,482,982.39 20,706,704.07
Tailspin Toys 42,801.223.58 290,849.14
The Phone Company 174,742,660.20 24,106,049.40
Wide World Importers 254,953,905.77 89,883,001.77
Total 3,805.392.024.21 1.567,521,178.98
Рис. 20.9 Меры Sales Amount и Big Sales Amount в разрезе брендов
Поскольку условие фильтрации в мере Big Sales Amount требует наличия двух
столбцов, очевидным решением является наложение фильтра на всю таблицу
Sales. Следующий запрос вычисляет меру Big Sales Amount из предыдущего от-
чета, а анализ времени его выполнения показан на рис. 20.10:
728 ГЛАВА 20 Оптимизация в DAX
DEFINE
MEASURE Sales[Big Sales Amount (slow)] =
CALCULATE (
[Sales Amount];
FILTER (
Sales;
Sales[Quantity] * Sales[Net Price] > 1000
)
)
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Brand]; "IsGrandTotalRowTotal" );
"Big_Sales_Amount"; ’Sales’[Big Sales Amount (slow)]
)
ToU 144 ms FE 15 ms SECPU S63 rrs хдл SE 129 rrs Line 2 u 6 Subclass Scan Scan Scan Duration CPU Rov.s KB 3,937 3,937 1 47 16 1 Query WITH SExprO:=(< S ELECT 'Dax Book WITH SEkoO:.= [<
53 37 34 203 183 172
SF Queries SE Cache
3 3
00%
Рис. 20.10 Вкладка Server Timings в момент вычисления меры Big Soles Amount (slow)
Поскольку функция FILTER осуществляет итерации по всей таблице продаж,
запрос генерирует больший по объему кеш данных, чем необходимо. В резуль-
тат отчета, показанный на рис. 20.9, выведено всего 11 брендов плюс итоговая
строка. При этом на вкладке Server Timings четко видно, что первые два воз-
вращенных кеша данных содержат по 3937 строк. Столько же записей отобра-
жено на вкладке Query Plan, показанной на рис. 20.11.
Line Records Physical Query Plan
7 Union: IterPhyOp LogOp=Union iterCois(0.1. 2.3)( Product’[Brah
3 Gro-pSerr jom: IterPhyOp -ogOp=G'OuoSemi.’oin IterColsfO 1
9 11 Spool_lterator<Spooiterator>: IterPhyOp LogOpsSuir_Ve't
1C 11 Aggregationspool<Aggrusion<Sum > >: SpoolPhyOp *R<
11 CrossAppty: IterPhyOp LogCp=Sbm_Vertipaq iterCo s[(
12 3937 Spooi_MultiValuedrashLookup: IterPhyOp LogOpsF
13 3937 ProjectonSpool<ProjectFuson< >> SpoolPhyOp
14 Cache: IterPhyOp sFieWCols=3 *VaLeCols=0
15 Cache: IterPhyOp »FiedCols=3 *Va ueCols=l
16 GrovpSerrjoin: IterPhyOp .ogOpsG'CuoSemJoin lterCols(C
17 1 Spool_lterator<Spoo iterator»: IterPhyOp LogOp=Sunn_Vert
18 1 ProjectionSpoo!<₽rq>ec:Fusion<Copy> > SpoolPhyOp aF
19 Cache IterPhyOp =FieldCos=0 -Va’ueCols=1
Рис. 20.11 Вкладка Query Plan при расчете меры Big Soles Amount (slow)
Как видим, движок формул вынужден обрабатывать гораздо более объем-
ный кеш данных, чем необходимо для вывода результата, из-за присутствия
ГЛАВА 20 Оптимизация в DAX 729
двух дополнительных столбцов. В итоге запрос в строке 2 на вкладке Server
Timings выглядит следующим образом:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
SELECT
'DaxBook Product*[Brand],
'DaxBook Sales'[Quantity],
'DaxBook Sales'[Net Price],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON 'DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey]
WHERE
( COALESCE ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) ) ) > COALESCE ( 1000.000000 ) );
Структура запроса xmSQL в строке 4 будет похожа на приведенную выше,
за исключением функции суммирования. Присутствие табличного фильтра
в функции CALCULATE косвенно влияет на план выполнения запроса, посколь-
ку семантика фильтра включает в себя все столбцы расширенной таблицы Sales
(расширенные таблицы подробно обсуждаются в главе 14).
Процесс оптимизации этой меры требует поменять табличный фильтр на
фильтр по столбцам. Поскольку в выражении фильтрации присутствует два
столбца, контексту строки понадобится таблица только с этими столбцами
для создания соответствующего и более эффективного аргумента фильтра
для функции CALCULATE. В следующем запросе реализован фильтр по столб-
цам при помощи функции KEEPFILTERS для сохранения семантики фильт-
ра из предыдущего запроса. Время выполнения этого запроса показано на
рис. 20.12:
DEFINE
MEASURE Sales[Big Sales Amount (fast)] =
CALCULATE (
[Sales Amount];
KEEPFILTERS (
FILTER (
ALL (
Sales[Quantity];
Sales[Net Price]
);
Sales[Quantity] * Sales[Net Price] > 1000
)
)
)
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Brand]; "IsGrandTotalRowTotal" );
"Big_Sales_Amount"; 'Sales'[Big Sales Amount (fast)]
)
730 ГЛАВА 20 Оптимизация в DAX
Total 72 r* *s FE 0 П5 83% SE CPU 391 ms *59 SE 66 ms 91.7% Line 2 L Suodass Scan Scan Duration CPU Rows Kr- Que , WITH 5ЕхзЮ. = ;c W TH SExprO x J 0
42 1E8 203 14 1 1 1
SE Queries SE Cache
2 0
0.0%
Рис. 20.12 Вкладка Server Timings при вычислении меры Big Soles Amount (fast)
Запрос DAX стал выполняться быстрее, но, что более важно, теперь нам ну-
жен всего один кеш данных для вывода отчета, если не считать строки ито-
гов, которая по-прежнему вычисляется при помощи отдельного запроса xm-
SOL. Материализация кеша данных из строки 2 на рис. 20.12 возвращает всего
14 строк, тогда как в актуальном списке на вкладке Query Plan, показанной на
рис. 20.13, присутствует 11 строк.
Line Records Physical Query Plan
7
8
9
10
11
12
13
14
15
Union: IterPhyOp LogOp=Union IterColsiO, 1. 2.3)(‘Product’[Bren<
GroDpSernijcin: IterPhyOp LogCp=GroupSemiJoin IterCsIsfC 1
11 Spccl_lterator< Spool iterator» IterPhyOp Log Op=Sum_Vert
11 Projectionspool <Projectrusion< Copy» >: SpoolPhyOp -R
Cache IterPhyOp =FieldCos=1 -VaiueCols=1
GrowpSenr join: IterPhyOp LogCp=GroupSemUoin lterCols(0 1
1 Spool_lterator<5poo!lterator>: IterPhyOp Log Op=Sum_Vert
1 ProjectionSpool<ProjectFusion<Copy>>: SpoolPhyOp SR
Cache: IterPhyOp =FieldCois=0 ~VaiueCols=1
Рис. 20.13 Вкладка Query Plan при вычислении меры Big Soles Amount (fast)
В результате выполнения оптимизации мы смогли добиться наиболее эф-
фективной работы от движка хранилища данных, которому теперь нет ника-
кой необходимости возвращать движку формул лишние данные, присутство-
вавшие в первоначальном запросе по причине использования табличного
фильтра. В результате мы пришли к следующему запросу xmSQL из строки 2
на рис. 20.12:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
SELECT
'DaxBook Product*[Brand],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON ’DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey]
WHERE
( COALESCE ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) ) ) > COALESCE ( 1000.000000 ) );
Кеш данных больше не содержит ссылки на столбцы Quantity и Net Price, и его
кратность соответствует результату запроса DAX. Это идеальные условия для
ГЛАВА 20 Оптимизация в DAX 731
минимальной материализации. И для достижения этого нам достаточно было
поменять табличный фильтр на фильтр по столбцам.
Из данного раздела вы должны вынести один важный урок: всегда необходи-
мо обращать внимание на количество строк, возвращаемых запросами движка
хранилища данных. Если оно значительно превышает число строк в резуль-
тирующем выводе отчета, вполне вероятно, что движок выполняет какую-то
лишнюю работу по материализации кешей данных, а движок формул вынуж-
ден обрабатывать ненужные строки в полученных кешах. Применение таблич-
ных фильтров является распространенной причиной появления чрезмерной
материализации, хотя проблема может быть и не только в этом.
Примечание При написании фильтров в DAX всегда помните о кратности. Если крат-
ности табличного фильтра и фильтра по столбцам будут идентичны и при этом фильтр
не будет расширяться на другие таблицы, можете остановить свой выбор на табличном
фильтре. К примеру, обычно нет большой разницы между фильтрами по таблице Date и по
столбцу Date [Date].
Оптимизация преобразования контекста
Движок хранилища может выполнять лишь простые операции агрегирования
и группировки по столбцам в модели данных. Все остальные расчеты произво-
дятся в движке формул. Каждый раз, когда в запросе осуществляются итерации
с последующим преобразованием контекста, движок хранилища выполняет
материализацию кеша данных на уровне гранулярности таблицы, по которой
идет перебор. Если выражение на уровне итерации оказывается достаточно
простым для вычисления силами движка хранилища данных, производитель-
ность обычно остается приемлемой. В противном случае, когда выражение
обладает приличной сложностью, появляются чрезмерно объемные матери-
ализации и/или дорогостоящие вызовы функции CallbackDatalD, как будет
показано в следующем примере. Если это ваш вариант, вам поможет упроще-
ние кода путем снижения частоты использования преобразования контекста
и уменьшения гранулярности таблицы для итераций. Рассмотрим пример рас-
чета меры Cashback, в которой происходит перемножение другой меры Sales
Amount и атрибута Cashback % из таблицы Customer, рассчитанного отделом
маркетинга. Отчет, представленный на рис. 20.14, показывает меру Cashback
по странам.
Country Cashback (slow) Cashback (fast)
Australia 1,169,963.12 1,169,963.12
Canada 372,901.28 372,901.28
France 442,965.22 442,965.22
Germany 749,048.18 749,048.18
United Kingdom 1,003,561.31 1,003.561.31
United States 1,785,417.75 1,785,417.75
Total 5.523.856.86 5.523.856.86
Рис. 20.14 Мера Cashback в разрезе стран покупателей
732 ГЛАВА 20 Оптимизация в DAX
Самый простой и интуитивно понятный способ расчета меры Cashback будет
одновременно и самым медленным - мы будет просто перемножать атрибут
Cashback % на меру Sales Amount и подсчитывать итоги. Следующий код соот-
ветствует медленной мере Cashback из приведенного выше отчета, а быстро-
действие этой меры отображено на рис. 20.15:
DEFINE
MEASURE Sales[Cashback (slow)] =
SUMX (
Customer;
[Sales Amount] * Customer[Cashback %]
)
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Customer'[Country]; "IsGrandTotalRowTotal" );
"Cashback"; ’Sales’[Cashback (slow)]
Total SECPU Line Subclass Duration CPU Rrws KB Query
46 ms 11C ms 2 Scan 24 94 18,372 295 WITH S ExorO = [C
x3 3 z Son 3 16 32 1 WITH SExprO := I
FE SE 3 Scan c c 18372 295 WITH SExprO = {C
17 ns 29 ns 3 Scan 2 C 1 1 WITH SExprCc
езсч
SE Quer’es SE Cache
4 1
Рис. 20.15 Вкладка Server Timings при расчете меры Cashback (slow)
Запросы в строках 2 и 4 на рис. 20.15 рассчитывают результаты на уровне
страны, тогда как в строках 6 и 8 выполняются те же операции для итогов. Мы
главным образом сосредоточимся на первых двух запросах движка хранили-
ща. Для проверки правильности оценки количества материализованных строк
можно взглянуть на план выполнения запроса, представленный на рис. 20.16.
Этот план может вас сильно удивить, ведь создается ощущение, что некоторые
запросы движка хранилища просто не используются.
Line Records Physical Query Plan
1 AddColumns: IterPhyCp LogOp=AddColumns lterCols(0)( [BLANK])
2 SingletonTab e IterPhyOp LogOp=AddColumns
3 Constant: LookupPhyOp LogOp=Constant Integer 0
4 AddColumns: IterPhyOp LogOp=AddColumns lterCols(0)( [BLANK])
5 Sngletc^Taoe IterPhyOp LogOp=AddColumn$
6 Constant: LookupPhyOp LogOp=Constant Integer 0
7 Union: IterPhyOp LogOp=Union ltefCol$[0, 1. 2. 3)(‘Cu5tomer [Country! (IsGq
8 GroupSemjoin: IterPhyOp LogCp=GroupSemi.<oin lterCols(0 1, 2)'’Custome
9 29 Spool_lterator<Spoollterator>: IterPhyOp LogOp=Sum_Vertipaq lterCols{
10 29 ProjectionSpool <ProjectFusion<Copy>> SpoolPhyOp 3Record$=29
11 Cacne: IterPhyOp-FieldCols=1 ~ValueCols=1
12 GroupSerrijoin: IterPhyOp LogOp=GroupSemiJoin lterCols(0 1, ZX’Custome
13 1 SpoolJterator<Spoollteraror>: IterPhyOp LogOp=5um_Vertipaq «Record
14 1 ProjectionSpool <ProjectFusion< Copy > > SpoolPhyOp *Records=1
15 Cacne: IterPhyOp «FieldCoi$=0 *VaiueCols=1
Рис. 20.16 Вкладка Query Plan при расчете меры Cashback (slow)
ГЛАВА 20 Оптимизация в DAX 733
В плане выполнения запроса, показанном на рис. 20.16, изображены только
узлы Cache, соответствующие строкам 4 и 8 вкладки Server Timings, представ-
ленной на рис. 20.15. Это еще один пример того, когда анализ плана выпол-
нения запроса может приводить в замешательство. Движок формул на самом
деле выполняет совсем другую работу, при этом операции внутри функций
CallbackDatalD не всегда отображаются в плане выполнения запроса, и это один
из таких случаев. Ниже представлен запрос xmSQL из строки 4 на рис. 20.15,
возвращающий 29 строк вместо оценочных 32:
WITH
$ЕхргО := ( [CallbackDatalD ( SUMX ( Sales, Sales[Quantity]] * Sales[Net Price]] ) )
] ( PFDATAID ( 'DaxBook Customer'[CustomerKey] ) )
* PFCAST ( 'DaxBook Customer1[Cashback %] AS REAL ) )
SELECT
'DaxBook Customer'[Country],
SUM ( @$Expr0 )
FROM 'DaxBook Customer';
Выражение DAX, переданное функции CallbackDatalD, должно быть вычис-
лено движком формул для каждого покупателя (поле CustomerKey передается
в функцию в качестве аргумента). Мы видим дополнительные запросы движка
хранилища данных, но соответствующие изменения в плане выполнения запро-
са не появляются. Так что мы можем только догадываться о том, каков на самом
деле план выполнения, по запросу движка хранилища из строки 2 на рис. 20.15:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
SELECT
'DaxBook Customer'[CustomerKey],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON 'DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey];
В итоговом наборе этого запроса xmSQL будет всего два столбца: Customer-
Key и результат вычисления меры Sales Amount для текущего покупателя. Дви-
жок формул использует результаты этого запроса для передачи функции Call-
backDatalD из предыдущего запроса.
Снова повторим, что вместо попыток определить четкую последователь-
ность операций, выполняемых движком, легче будет проанализировать резуль-
таты запросов движка хранилища на предмет того, превышает ли количество
строк в материализации реальное число записей в итоговом выводе. В нашем
случае ответ на этот вопрос будет положительным: запрос DAX возвращает
только шесть строк, тогда как движком формул был получен список из 29 стран.
Так или иначе, мы видим огромную разницу по сравнению с 18 872 строками
с покупателями, возвращенными последним запросом xmSQL. А можно ли уве-
личить нагрузку на движок хранилища, чтобы он вычислял данные по странам,
а не по покупателям? Можно - путем уменьшения количества операций пре-
образования контекста. Давайте рассмотрим исходную меру Cashback: ее вы-
734 ГЛАВА 20 Оптимизация в DAX
ражение, вычисляемое в рамках контекста строки, зависит от значения одного
столбца таблицы Customer (Cashback %):
Sales[Cashback (slow)] :=
SUMX (
Customer;
[Sales Amount] * Customer[Cashback %]
)
Поскольку мера Sales Amount может быть вычислена для группы покупате-
лей с одинаковым значением Cashback %, оптимальная кратность итератора
SUMX будет определяться количеством уникальных значений в столбце Cash-
back %. В следующей оптимизированной версии меры мы просто заменим пер-
вый аргумент функции SUMX, используя список уникальных значений столбца
Cashback %, видимых в текущем контексте фильтра:
DEFINE
MEASURE Sales[Cashback (fast)] =
SUMX (
VALUES ( Customer[Cashback %] );
[Sales Amount] * Customer[Cashback %]
)
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Customer'[Country]; "IsGrandTotalRowTotal" );
"Cashback"; 'Sales'[Cashback (fast)]
В этом случае материализация окажется гораздо меньшей, что видно по
рис. 20.17. Однако, несмотря на значительное уменьшение количества строк
в материализации, общее время выполнения запроса осталось прежним, если
даже не увеличилось. Разница в несколько миллисекунд не может считаться
значительной.
Total -9 ГП5 SE CPU Line Subclass Duration CPU Row KB Query 5 2 Son 2- 63 2E3 5 l’.'THSE<s-3 =(C ~ Son ‘3 141 9 1 V. TH SExprO = (C
FE 7 14 34 SE 42 ms 85 74
SF Queri**s 4 SE Cache C 0C4
Рис. 20.17 Вкладка Server Timings при вычислении меры Coshbock (fast)
На этот раз мы получили один запрос xmSQL, производящий вычисление по
странам. Ниже представлен запрос xmSQL из строки 2 на рис. 20.17:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
ГЛАВА 20 Оптимизация в DAX 735
SELECT
'DaxBook Customer'[Country],
'DaxBook Customer'[Cashback %],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON 'DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey];
В результат этого запроса будут включены три столбца: Country, Cashback %
и соответствующее значение меры Sales Amount. Таким образом, в движке фор-
мул будет производиться перемножение Cashback % и Sales Amount для каждой
строки с агрегацией по странам. Оценочное количество строк получилось рав-
ным 288, тогда как до движка формул дошло всего 65 строк. Это видно на плане
выполнения запроса, показанном на рис. 20.18.
Line Records Physical Query Plan
7 Union: IterPhyOp LogOp=Union IterCdsCO. 1, 2, 3)(‘Customer [Counttyl (IsGran
8 GroupSemjom: IterPhyOp LogCpsGroupSernUoin lterCols(0. 1, 2)( Custoner’|
9 6 Spool.lterator<Spoollterator>: IterPhyOp LogOp=SumX lterCols(0)( Custon
10 6 Aggregationspool* Sum >: SpoolPhyOp »Records=6
11 Extend.Looicup: IterPhyOp LogOp=Multiply lterCols(0,1)( Customer [C
12 65 Spool .Iterator* Spooliterator»: IterPhyOp LogOp=Sum_Vertipaq Itei
13 65 ProjectionSpool<Pro)ectFusion<Copy> >: SpoolPhyOp *Records=
14 Cache: IterPhyOp *FieWCols=2 »ValueCols=1
15 Col Value < ’Си sterner’ [Cash back %] >: LookupPhyOp LogCpsColValu
Рис. 20.18 Вкладка Query Plan при вычислении меры Cashback (fast)
Пусть это и не столь очевидно, но эта мера вычисляется быстрее, чем перво-
начальная. С учетом незначительных требований к памяти эта мера будет рас-
считываться ощутимо быстрее в более сложных отчетах. К примеру, возьмем
отчет, показанный на рис. 20.19, группирующий меру Cashback по брендам то-
варов, а не по странам покупателей.
Brand Cashback (slow) Cashback (fast)
A. Datum 477,910.16 477.910.16
Adventure Works 1,025,695.46 1,025,695.46
Contoso 1,464,170.77 1,464,170.77
Fabrikam 196,534.01 196,534.01
Litware 531,080.24 531,080.24
Northwind Traders 1,046,681.00 1,046,681.00
Proseware 111,461.75 111,461.75
Southridge Video 357,256.48 357.256.48
Tailspin Toys 203.818.73 203,818.73
The Phone Company 50,731.91 50.731.91
Wide World Importers 58,516.35 58,516.35
Total 5.523.856.86 5.523,856.86
Рис. 20.19 Мера Cashback в разрезе брендов
Ниже приведен код медленной меры из отчета, представленного на
рис. 20.19. А на рис. 20.20 можно увидеть быстродействие этой меры:
736 ГЛАВА 20 Оптимизация в DAX
DEFINE
MEASURE Sales[Cashback (slow)] =
SUMX (
Customer;
[Sales Amount] * Customer[Cashback %]
)
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( Product[Brand]; "IsGrandTotalRowTotal" );
"Cashback"; 'Sales'[Cashback (slow)]
)
Total SE CPU Line Suodass Duration CPU Roivs K8 Query
-'5rs 922 rrs 2 Scan 2Г ?9 ’ 192 5'- 2 257 with 5Е/.ОЮ =
z Scan 4 C '8869 148 SELEC- DexBoolc C-stoTisi
FE SE c Scan 22 105 'SS71 295 W T4 SExprC* = { CAST ( " z
158 rrs SE Queries 4 2i" ms SE Cache 0 0.0* g Scan 1 1 WITH SExprO: = ({Call bar
Рис. 20.20 Вкладка Server Timings при вычислении меры Cashback (slow)
В представленных планах выполнения запросов есть целый ряд отличий, но
мы остановимся на материализации из 192 514 строк, произведенной следую-
щим запросом xmSQL из строки 2 на рис. 20.20:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
SELECT
* DaxBook Customer *[CustomerKey]>
'DaxBook Product*[Brand],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON ’DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey]
LEFT OUTER JOIN 'DaxBook Product'
ON ’DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey];
Причина столь объемной материализации состоит в том, что здесь во внут-
реннем выражении мера Sales Amount вычисляется для всех комбинаций Cus-
tomerKey и Brand. Оценочное количество строк 192 514 подтверждается реаль-
ными расчетами в плане выполнения запроса, показанном на рис. 20.21.
Если мы будем использовать оптимизированную меру, объем материализа-
ции значительно снизится, а вместе с ним и время выполнения запроса. Вы-
полнение следующего запроса DAX характеризуется показателями быстродей-
ствия, представленными на рис. 20.22:
DEFINE
MEASURE Sales[Cashback (fast)] =
ГЛАВА 20 Оптимизация в DAX 737
SUMX (
VALUES ( Customer[Cashback %] );
[Sales Amount] * Customer[Cashback %]
)
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( Product[Brand]; "IsGrandTotalRowTotal" );
"Cashback"; 'Sales'[Cashback (fast)]
)
Line dearth Р сз1 Quer 7 аг
If Ooss-ooy: lerPhyGp c§C,u=5um_.,er: рас lie Csk-lC 2)i, crO(Juct [Brand] Cl
* Т '3 369 Spec .MultivckjeJ-iasnlookup' terPhyOp Lo-jOps'xan.Vertipaq -cckupCo S
13 '3 369 "gq.ezat >: Spc«olF*iyCp =9e:crcs= 16369
'г9 '3 369 SpccHteiaterteiero > Hsr-h, Do LocDp=Sc3n_Vertip3o terCo 1
2и '3S69 "rc;ec:icnS<oo <PrCi ec‘-u;ion* > Spoo Pb)Op “ Records =18969
21 Cacne Itsi-hyOp ='ie dCos=3 *’,₽jeColi=3
22 192 5U Sc-oo_T=io:c-'<Spoclhe'3tcr> -ter0'-1 C LocOo=Sur^_-.;eri s.= j lie CbIsjO. 2]
23 192 5'- ^rojecrisnSpcol*- cjectF,. s сг<Сзру> >: Sooo P-"; Op £Revorcfc=192514
-> 1 Гч.-^р fjj-CI'. ГxP i-f ,Mc-1
Рис. 20.21 Вкладка Query Plan
при вычислении меры Coshbock (slow) по брендам
Total 48 ms FE Ст ГП5 1С.7Ч SE CPU 172 rrs SE 40 ns 83 3% Line Subclass 2 Scan Duration CPU Rows KB 26 125 126 14 47 9 Query 2 WITH SExprO := (CAST ( 1 WiT4 SExo-O =( CAST
Scan
St Queries SE Cache
2 0
0.0%
Рис. 20.22 Вкладка Server Timings
при вычислении меры Coshbock (fast) по брендам
Объем материализации оказался на три порядка меньше по сравнению
с предыдущим запросом (126 строк вместо 192 514), а общее время выполне-
ния запроса снизилось в девять раз (с 415 мс в случае с медленной мерой до
48 мс для быстрой). Поскольку разница в производительности запросов напря-
мую зависит от кратности, вы должны стремиться минимизировать занятость
движка формул и по максимуму перенести вычислительные функции на дви-
жок хранилища данных. И важным шагом на пути достижения этой цели явля-
ется снижение количества операций преобразования контекста.
Примечание Чрезмерная материализация, возникающая вследствие ненужных преоб-
разований контекста, является наиболее распространенной проблемой при написании
мер. Использование табличных фильтров вместо фильтров по столбцам - вторая по рас-
пространенности проблема. Так что вашей первоначальной целью на пути оптимизации
кода должно быть избавление от этих двух недостатков. При анализе вкладки Server Tim-
ings вы должны уделять особое внимание количеству строк в материализации - именно
здесь обычно кроются основные симптомы недомогания кода DAX.
738 ГЛАВА 20 Оптимизация в DAX
Оптимизация условных выражений IF
Функция IF всегда выполняется исключительно в движке формул. Иначе гово-
ря, если вы видите в итераторе функцию IF, значит, в запросе гарантированно
появится функция CallbackDatalD. К тому же движок может вычислять аргу-
менты функции IF вне зависимости от условия в первом аргументе. Даже если
итоговый результат будет верным, время может быть потрачено на вычисле-
ние всех возможных вариантов. Как обычно, здесь могут быть разные особен-
ности поведения движка DAX в зависимости от используемой версии.
Оптимизация условных выражений IF в мерах
Использование условных выражений в мерах может привести к побочному не-
гативному эффекту в плане выполнения запроса с вычислением каждой ветки
ветвления вне зависимости от необходимости. В целом следует избегать или
хотя бы минимизировать количество условных выражений в мерах, применяя
фильтры всякий раз, когда это возможно.
Например, в отчете, показанном на рис. 20.23, выведена мера Fam. Sales,
учитывающая продажи только тем покупателям, у которых есть как минимум
один ребенок в семье. Поскольку нашей целью является вывод информации
по конкретным покупателям, первая реализация меры (медленная) не будет
работать для агрегаций из двух и более клиентов (в строке итогов будет пус-
тое значение), тогда как оптимизированный вариант меры будет работать
и с агрегациями.
Date Maniracturer Adventure Works CustomerKey Name Sales Amount Fam. Sales (slow) Fam. Sales (fast)
5/10/2007 5/10/2007 Contoso. Ltd 12189 Jai, Austin 1.599.90 1.599.90 1.599.90
1 Fabrikam, Inc. 12190 Moore, Megan 1,599.90
Litware. Inc. 12192 Raji, Gilbert 1,599.90
Category Northwind Traders 12193 Garcia, Emily 1,599.90
Audio Proseware. Inc. 12194 Flores. Alexis 1.599.90 1.599.90 1.599.90
Cameras and camcorders Wide World Importers 12195 Cook, Shelby 1,599.90 1,599.90 1,599.90
Cell phones 12196 Mohamed, X... 3.199.80 3.199.80 3,199.80
Computers Class 12197 Roberts, Carlos 1,599.90 1,599.90 1,599.90
Games and Toys Deluxe 12198 Cook. Cole 1.599.90 1,599.90 1.599.90
Home Appliances Economy 12199 Sanders. Jac... 1,599.90 1,599.90 1,599.90
Music, Movies and Audio Bo... Regular Total 17,598.90 12,799.20
TV and Video
Рис. 20.23 Мера Fam. Sales в разрезе брендов
Следующий запрос рассчитывает меру Fam. Sales (slow) для отчета. Для каж-
дого покупателя движок проверяет наличие детей, чтобы выполнить требуе-
мую классификацию. Быстродействие приведенного здесь запроса можно уви-
деть на рис. 20.24:
DEFINE
MEASURE Salesman. Sales (slow)] =
VAR ChildrenAtHome = SELECTEDVALUE ( Customer[Children At Home] )
VAR Result =
IF (
ChildrenAtHome > 0;
[Sales Amount]
ГЛАВА 20 Оптимизация в DAX 739
RETURN Result
EVALUATE
CALCULATETABLE (
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
'Customer'[CustomerKey];
'Customer'[Name]
); "IsGrandTotalRowTotal"
);
"Fam__Sales__slow_"; 'Sales'[Fam. Sales (slow)]
);
'Product Category'[Category] = "Home Appliances";
'Product'[Manufacturer] = "Northwind Traders";
'Product'[Class] = "Regular";
DATESBETWEEN (
'Date'[Date];
DATE ( 2007; 5; 10 );
DATE ( 2007; 5; 10 )
)
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Customer'[CustomerKey];
'Customer'[Name]
Total SECPU Line Subclass Duration CPU Rows KB Query
55 ns 32 ms 2 Scan C 0 2559 2C SELECT 'DaxBi
4 Scan a C 8869 66*1 SELECT 'DaxBi
ЕЕ SE 6 Scan 5 16 18369 74 SELECT 'DaxB
32 ns 23 ms 9 Scan 14 16 '8872 295 WITH SExprO
53_2%
SE Queries SE Cache
4 C
СОЧ
Рис. 20.24 Вкладка Server Timings
при вычислении меры Fam. Sales (slow) по покупателям
Запрос выполняется не так уж и медленно, но мы хотели бы получить запрос
с небольшим количеством строк, поскольку основное внимание сейчас уделя-
ем материализации. Можно не смотреть план выполнения запроса, занимаю-
щий 62 строки, поскольку пока нам достаточно информации, представленной
на вкладке Server Timings:
хотя в итоговом выводе у нас семь строк, материализация в трех запросах
xmSQL насчитывает более 18 000 строк, что близко к количеству покупа-
телей в модели данных;
материализация, произведенная движком хранилища данных в строке 4
на рис. 20.24, включает информацию о количестве детей, вычисленном
для каждого покупателя;
740 ГЛАВА 20 Оптимизация в DAX
материализация, произведенная движком хранилища данных в строке 9
на рис. 20.24, включает меру Sales Amount, вычисленную для каждого по-
купателя;
общий итог не рассчитывается в движке хранилища данных, так что эту
агрегацию по покупателям выполняет движок формул.
Давайте посмотрим на запрос из строки 4 на рис. 20.24. Здесь представлена
информация, необходимая движку формул для выполнения фильтрации по-
купателей на основании количества детей:
SELECT
'DaxBook Customer'[CustomerKey],
SUM ( ( PFDATAID ( 'DaxBook Customer'[Children At Home] ) <> 2 ) ),
MIN ( 'DaxBook Customer'[Children At Home] ),
MAX ( 'DaxBook Customer'[Children At Home] ),
COUNT ( )
FROM 'DaxBook Customer';
Результат этого запроса используется в качестве аргумента для следующего
запроса движка хранилища данных из строки 9 на рис. 20.24 с целью получе-
ния оценочного количества в 7368 покупателей, у которых есть как минимум
один ребенок:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
SELECT
'DaxBook Customer'[CustomerKey],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON 'DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey]
LEFT OUTER JOIN 'DaxBook Date'
ON 'DaxBook Sales'[OrderDateKey]='DaxBook Date'[DateKey]
LEFT OUTER JOIN 'DaxBook Product'
ON 'DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey]
LEFT OUTER JOIN 'DaxBook Product Subcategory'
ON 'DaxBook Product'[ProductSubcategoryKey]
='DaxBook Product Subcategory'[ProductSubcategoryKey]
LEFT OUTER JOIN 'DaxBook Product Category'
ON 'DaxBook Product Subcategory'[ProductCategoryKey]
='DaxBook Product Category'[ProductCategoryKey]
WHERE
'DaxBook Customer'[CustomerKey]
IN ( 2241, 13407, 5544, 7787, 11090, 7368, 17055, 16636, 1329, 12914..
[7368 total values, not all displayed] )
VAND 'DaxBook Date'[Date] = 39212.000000
VAND 'DaxBook Product'[Manufacturer] = 'Northwind Traders'
VAND 'DaxBook Product'[Class] = 'Regular'
VAND 'DaxBook Product Category'[Category] = 'Home Appliances';
Оценочное количество строк в этом результате оказалось ошибочным, по-
скольку всего семь строк было возвращено предыдущим запросом движка
ГЛАВА 20 Оптимизация в DAX 741
хранилища. Это можно увидеть на плане выполнения запроса, показанном на
рис. 20.25, хотя найти соответствующий запрос xmSQL для каждого узла Cache
может быть и не так просто.
Line Records Physical Query Plan
31 EmptyTable: IterPhyOp LogOp=Constant
32 7 Spoo_lterator<Spoollterator>: IterPhyOp LogOp=Sum_Vertipac IterCols^^'Customer'lCustomerKey]) -
33 7 ProjectionSpool<ProjectFusion<Copy> >: SpooiPhyOp *Records=7
3^ Cache: IterPhyOp *FieldCols=1 =ValueCols=1
Рис. 20.25 Вкладка Server Timings при вычислении меры Fam. Sales (slow) по покупателям
Предыдущий запрос движка хранилища принимает фильтр по столбцу Cus-
tomerKey. Движок формул требует материализации такого списка значений
в столбце CustomerKey, чтобы предоставить соответствующий фильтр запросу
движка хранилища. При этом материализация большого количества покупате-
лей в движке формул с большой долей вероятности приведет к увеличению вре-
мени выполнения запроса. Размер этой материализации напрямую зависит от
количества покупателей в модели данных. Таким образом, в модели с сотнями
тысяч или миллионами покупателей вы непременно почувствуете проблемы
с производительностью. В таких случаях вы должны обращать внимание исклю-
чительно на размер материализации, а не на время выполнения запроса. Вре-
мя может оставаться относительно небольшим. Но понимание того, насколько
эффективна ваша материализация, крайне важно для создания запросов, легко
масштабируемых при увеличении количества строк в модели данных.
Функция IF в мере может быть обработана только в движке формул. Это
требует либо выполнения материализации, как в этом примере, либо вызовов
функции CallbackDatalD, о чем мы расскажем дальше. Лучшим подходом бу-
дет применение фильтра к текущему контексту фильтра при помощи функции
CALCULATE. Так вы избежите необходимости оценивать условие IF для каждой
ячейки результата запроса.
При использовании быстрой меры в рамках тестового запроса размер ма-
териализации стал намного меньше, и время отклика запроса также сократи-
лось. Выполнение следующего запроса DAX генерирует показатели произво-
дительности, представленные на рис. 20.26:
DEFINE
MEASURE Salesman. Sales (fast)] =
CALCULATE (
[Sales Amount];
KEEPFILTERS ( Customer[Children At Home] > 0 )
)
EVALUATE
CALCULATETABLE (
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
'Customer'[CustomerKey];
'Customer'[Name]
); "IsGrandTotalRowTotal"
);
742 ГЛАВА 20 Оптимизация в DAX
"Fam__Sales___fast."; 'Sales'[Fam. Sales (fast)]
);
'Product Category'[Category] = "Home Appliances";
'Product'[Manufacturer] = "Northwind Traders";
'Product'[Class] = "Regular";
DATESBETWEEN (
'Date'[Date];
DATE ( 2007; 5; 10 );
DATE ( 2007; 5; 10 )
)
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Customer'[CustomerKey];
'Customer'[Name]
Total SE CPU Line Suodars Duration CP'J Rews KB Query
" ms 94 ms Scan C C 2 559 2C 5E_ECT'D*xBo
хЗЛ *- Scan '3 94 3 372 295 WTH SExoC- -
FE SE Scan 3 C "3 369 ”4 SELECT 'DaxEo
22 ns 25 ns 46 S% 52 2№ SE Queries SE СгсЬе 4 D 0.G% з Scan 1 1 WTH5Exs< r
Рис. 20.26 Вкладка Server Timings
при вычислении меры Fam. Sales (fast) по покупателям
Несмотря на то что количество запросов движка хранилища не уменьши-
лось, запрос из строки 4 на рис. 20.24 больше не используется. Запрос в этой
строке на рис. 20.26 соответствует запросу из строки 9 с рис. 20.24. Он включает
фильтр по количеству детей, выделенный жирным шрифтом в следующем за-
просе xmSQL:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
SELECT
’DaxBook Customer'[CustomerKey],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON ’DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey]
LEFT OUTER JOIN 'DaxBook Date'
ON ’DaxBook Sales'[OrderDateKey]='DaxBook Date'[DateKey]
LEFT OUTER JOIN 'DaxBook Product'
ON ’DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey]
LEFT OUTER JOIN 'DaxBook Product Subcategory'
ON 'DaxBook Product'[ProductSubcategoryKey]
='DaxBook Product Subcategory'[ProductSubcategoryKey]
LEFT OUTER JOIN 'DaxBook Product Category'
ON 'DaxBook Product Subcategory'[ProductCategoryKey]
ГЛАВА 20 Оптимизация в DAX 743
='DaxBook Product Category'[ProductCategoryKey]
WHERE
'DaxBook Date'[Date] = 39212.000000
VAND 'DaxBook Product'[Manufacturer] = 'Northwind Traders'
VAND 'DaxBook Product'[Class] = 'Regular'
VAND 'DaxBook Product Category'[Category] = 'Home Appliances'
VAND ( PFCASTCOALESCE ( 'DaxBook Customer'[Children At Home] AS INT )
> COALESCE ( 0 ) );
Этот альтернативный план выполнения запроса обладает своими достоин-
ствами и недостатками. Его преимущество состоит в том, что нагрузка на дви-
жок формул будет снижена благодаря отсутствию необходимости перемещать
фильтр по покупателям между запросами движка хранилища данных. Платить
за это приходится тем, что фильтры применяются на уровне движка храни-
лища, что в нашем случае привело к увеличению процессорного времени (SE
CPU) с 32 до 94 мс.
Еще одним побочным эффектом изменения плана явилось добавление за-
проса хранилища данных в строке 8 на рис. 20.26. В этом запросе вычисляются
значения для строки итогов, что снимает обязанность по выполнению такой
агрегации с движка формул. Его код похож на предыдущий запрос xmSQL que-
ry, без агрегации по столбцу CustomerKey.
Как правило, идея замены условного выражения на аргумент фильтра
в функции CALCULATE себя оправдывает, ставя во главу угла сокращение раз-
мера материализации, а не время выполнения маленьких запросов. Обычно
это позволяет писать более масштабируемые выражения с учетом потенци-
ального роста размера модели данных. При этом вы должны всегда анализи-
ровать производительность запросов в конкретных условиях, выполняя изме-
рения в DAX Studio для разных реализаций. В противном случае вы можете
остановить свой выбор на реализации, которая в реальном сценарии окажется
не быстрее, а даже медленнее.
Выбор между функциями IF и DIVIDE
Часто условная функция IF используется с целью осуществления вычислений
только в случае передачи достоверных аргументов. Например, с ее помощью
может быть проверен знаменатель при выполнении операции деления, чтобы
избежать деления на ноль. Но для этого конкретного случая есть более эффек-
тивная альтернатива в виде функции DIVIDE. Можно задействовать аналити-
ческие средства DAX Studio для измерения эффективности того или иного вы-
ражения, чем мы сейчас и займемся.
В отчете, представленном на рис. 20.27, выведена мера Average Price в раз-
резе покупателей и брендов.
Следующий код рассчитывает медленный вариант нашей меры - Average
Price (slow). Для каждой комбинации бренда и покупателя выполняется деле-
ние суммы продаж на количество, но только в случае, если последняя не равна
нулю. Анализ быстродействия следующего запроса DAX показан на рис. 20.28:
DEFINE
MEASURE Sales[Average Price (slow)] =
VAR Quantity = SUM ( Sales[Quantity] )
VAR SalesAmount = [Sales Amount]
744 ГЛАВА 20 Оптимизация в DAX
VAR Result =
IF (
Quantity <> 0;
SalesAmount / Quantity
)
RETURN Result
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
'Customer'[CustomerKey];
'Product'[Brand]
); "IsGrandTotalRowTotal"
);
"Average_Price__slow_"; 'Sales'[Average Price (slow)]
);
[IsGrandTotalRowTotal]; 0;
'Customer'[CustomerKey]; 1;
'Product'[Brand]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Customer'[CustomerKey];
'Product'[Brand]
Brand CustomerKey Average Price (slow) Average Price (fast)
Southridge Video 8 35.22 35.22
Tailspin Toys 8 34.22 34.22
A. Datum 9 551.76 551.76
Adventure Works 9 425.11 425.11
Contoso 9 492.78 492.78
Litware 9 1,343.16 1,343.16
Mnrthwind Traders 9 1.047_21 1.047.21
Total 216.98 216.98
Рис. 20.27 Мера Average Price по брендам товаров и покупателям
и
Total SE CPU Line Subclass Duration CPU Rows KD Query
2,338 ms 8S9 ms 2 Scan 204 750 192 5'4 3,761 WITH SbprO = (CAST
x3J 4 Scan 1 C *• 1 SELECT DaxBook Produ
FE SE € Scan 1 c 18.872 148 SELECT 'DaxBook C-$to
2,119 ms a Scan 13 109 1 1 WITH SExprO = (CAST
90.6%
SE Queries SE Cache
4 C
c.o%
Рис. 20.28 Вкладка Server Timings при вычислении меры Average Price (slow)
по брендам и покупателям
ГЛАВА 20 Оптимизация в DAX 745
Несмотря на ограничение результата вывода в размере 500 строк, материа-
лизация кеша данных, возвращенная запросами движка хранилища, оказалась
гораздо больше. В строке 2 на рис. 20.28 выполняется следующий запрос xmSQL,
возвращающий по одной строке для каждой комбинации покупателя и бренда:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
SELECT
'DaxBook Customer'[CustomerKey],
'DaxBook Product'[Brand],
SUM ( @$Expr0 ),
SUM ( 'DaxBook Sales'[Quantity] )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON 'DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey]
LEFT OUTER JOIN 'DaxBook Product'
ON 'DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey];
В этом запросе нет никаких фильтров, а значит, движок формул должен бу-
дет обработать каждую строку этого кеша данных, отсортировать результаты
и вернуть из них 500 первых строк. Это наиболее дорогостоящая операция, вы-
полняемая движком хранилища, и она занимает порядка 90 % общего времени
выполнения запроса. Остальные три запроса движка хранилища возвращают
список брендов (строка 4), список покупателей (строка 6), а также значения
суммы продаж и количества на уровне итогов (строка 8). Эти запросы пред-
ставляют для нас меньший интерес в плане оптимизации. Что действительно
важно, так это то, что движок формул должен будет выполнить условную функ-
цию IF для более чем 190 000 строк. План выполнения запроса с медленной
мерой в нашем случае занял порядка 80 строк (в данной книге не приведен),
и в нем каждый кеш данных обрабатывается по нескольку раз. Это побочный
эффект от наличия разных ветвей выполнения в функции IF.
Оптимизация меры Average Price будет базироваться на замене функции IF
на DIVIDE. Быстродействие следующего запроса DAX показано на рис. 20.29:
DEFINE
MEASURE Sales[Average Price (fast)] =
VAR Quantity = SUM ( Sales[Quantity] )
VAR SalesAmount = [Sales Amount]
VAR Result =
DIVIDE (
SalesAmount;
Quantity
)
RETURN Result
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
746 ГЛАВА 20 Оптимизация в DAX
'Customer'[CustomerKey];
'Product'[Brand]
); "IsGrandTotalRowTotal"
);
"Average_Price__fast_"; 'Sales'[Average Price (fast)]
);
[IsGrandTotalRowTotal]; 0;
'Customer'[CustomerKey]; 1;
'Product'[Brand]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Customer'[CustomerKey];
'Product'[Brand]
Told 413 rrs FE 181 rrs 43 8% St CPU 751 its SE 232 ms SC 24 Line 2 A Subc'ass ^can Scan Duration CPU Rowi, KB 192.514 3761 1 1 Que'y WITH SExoO VYiTH SExpiO = (CA< = (CA<
2"8 888 63
SE Queries SF Cache
2 0
00%
Рис. 20.29 Вкладка Server Timings при вычислении меры Average Price (fast)
по брендам и покупателям
Оптимизированный запрос выполняется 413 мс, что примерно на 80 %
быстрее по сравнению с исходным. На первый взгляд кажется, что увеличе-
нию производительности поспособствовало уменьшение количества запросов
движка хранилища с четырех до двух. Но на самом деле это не так. Процессор-
ное время (SE CPU) существенно не изменилось, и объемная материализация
также никуда не делась. Фактически оптимизация была достигнута за счет бо-
лее короткого и эффективного плана выполнения запроса, состоящего всего
из 36 строк вместо 80. Иными словами, использование функции DIVIDE позво-
лило нам снизить размер и сложность плана выполнения запроса, а также на
порядок уменьшить время выполнения в движке формул.
Оптимизация функции IF в итераторах
Использование функции IF внутри объемных итераторов может стать источни-
ком появления дорогостоящих обратных вызовов к движку формул. Рассмот-
рим меру Discounted Sales, добавляющую скидку в размере 10 % на все транзак-
ции, в которых количество приобретенных товаров больше или равно трем. На
рис. 20.30 показан отчет с выводом меры Discounted Sales в разрезе брендов.
Следующий запрос, вычисляющий медленный вариант меры Discounted
Sales, демонстрирует производительность, показанную на рис. 20.31:
DEFINE
MEASURE Sales[Discounted Sales (slow)] =
ГЛАВА 20 Оптимизация в DAX 747
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price] * IF (
Sales[Quantity] >= 3;
.9;
1
)
)
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Brand]; "IsGrandTotalRowTotal" );
"Sales_Amount"; ’Sales’[Sales Amount];
"Discounted_Sales__slow_"; ’Sales’[Discounted Sales (slow)]
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Product'[Brand]
Brand Sales Amount Discounted Sales (slow) Discounted Sales (scalable)
A. Datum 251,211.515.57 242,822,223.32 242,822,223.32
Adventure Works 518,462.059.16 501,169,853.87 501,169,853.87
Contoso 871,501,804.63 842,438,948.01 842,438.948.01
Fabrikam 627,751.182.08 606,861,928.96 606,861,928.96
Litware 416,239,414.35 402,383,288.37 402,383,288.37
Northwind Traders 151,481,923.36 146,432,377.72 146,432,377.72
Proseware 312,763,353.13 302,307,008.81 302,307,008.81
Southridge Video 183,482,982.39 177,362,856.28 177,362,856.28
Tailspin Toys 42,801,223.58 41,376,146.41 41,376,146.41
The Phone Company 174,742,660.20 168,915,267.19 168,915,267.19
Wide World Importers 254,953.905.77 246,451,374.64 246,451,374.64
Total 3.805.392.024.21 3.678.521.273.59 3.678.521.273.59
Рис. 20.30 Вывод меры Discounted Sales по брендам
Total 142 rrs FE 9 ns c.3% SECPU 433 rrs x33 SE 133 ms 93 7% Line 2 A Subclass Scan Scan Duration 77 56 CPU 172 266 Rews KB 14 1 Query 1 WITH SExprt) :*( (CAS 1 WITH SExprt) :( (CAS
SE Qjer les SE Cache
2 0
cc%
Рис. 20.31 Вкладка Server Timings при вычислении меры Discounted Sales (slow) по брендам
Функция IF, присутствующая в итераторе SUMX, генерирует два запроса
движка хранилища данных с вызовами функции CallbackDatalD. Ниже пред-
ставлен запрос xmSQL из строки 2 на рис. 20.31:
WITH
$Ехрг0 := ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
748 ГЛАВА 20 Оптимизация в DAX
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
* [CallbackDatalD ( IF ( Sales[Quantity]] >= 3, .9, 1 ) ) ]
( PFDATAID ( 'DaxBook Sales'[Quantity] ) ) ) ,
$Exprl := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) )
SELECT
'DaxBook Product'[Brand],
SUM ( @$Expr0 ),
SUM ( @$Exprl )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON 'DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey];
Присутствие в запросе функции CallbackDatalD грозит двумя последствия-
ми: большим временем выполнения в сравнении с задействованием движка
хранилища и невозможностью воспользоваться кешем данных, созданным
в движке хранилища. Данные должны рассчитываться каждый раз заново и не
могут быть извлечены из кешей соответствующих запросов. И второе послед-
ствие, как в нашем случае, может представлять гораздо большую проблему,
чем первое.
Избавиться от функции CallbackDatalD можно, переписав нашу меру таким
образом, чтобы результат представлял собой сумму значений двух функций
CALCULATE с разными фильтрами. Например, в случае с мерой Discounted Sales
можно использовать две функции CALCULATE - по одной для каждого про-
цента. Следующий запрос DAX реализует версию меры Discounted Sales, не ис-
пользующую функцию CallbackDatalD. Код меры получился более длинным
и содержит функцию KEEPFILTERS для сохранения семантики исходной меры.
Анализ плана выполнения этого запроса показан на рис. 20.32:
DEFINE
MEASURE Sales[Discounted Sales (scalable)] =
CALCULATE (
SUMX (
Sales;
Sales[Quantity] * SalesfNet Price]
) * .9;
KEEPFILTERS ( SalesfQuantity] >= 3 )
) + CALCULATE (
SUMX (
Sales;
SalesfQuantity] * SalesfNet Price]
);
KEEPFILTERS ( NOT ( SalesfQuantity] >= 3 ) )
)
EVALUATE
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Brand]; "IsGrandTotalRowTotal" );
"Sales_Amount"; 'Sales'[Sales Amount];
"Discounted_Sales__slow_"; 'Sales'[Discounted Sales (scalable)]
)
ГЛАВА 20 Оптимизация в DAX 749
Total SECPU Line Subclass Duration CPJ Rows KB Query
159 its ”51 ns 2 Scan 34 94 1 WITH 5ExprO: = (CASj
x5.1 4 Scan 26 141 14 1 WlTHSExpX) sfCAsj
Ft SE 6 Scan 37 172 14 1 WITH SExprOt=( CAS]
13ms 146 ns s Scan 14 94 1 1 WITHSExpfO: = i CA54!
91-8% 10 Scan 15 109 1 1 WITH ЗЕхргЭ:;; (CAS^
SE Queries 6 SE Cache 0 00% 12 Scan “C 141 1 1 WITH SExprO:= (CAS।
Рис. 20.32 Вкладка Server Timings для вычисления меры Discounted Sales (scalable)
по брендам в первый раз
На самом деле общее время выполнения этого простого запроса не умень-
шилось. Более того, оно даже увеличилось по сравнению с «медленной» верси-
ей со 142 до 159 мс. При этом мы назвали эту меру масштабируемой (scalable).
И действительно, второй запуск этого запроса на теплом кеше (warm cache) вы-
даст результаты, показанные на рис. 20.33, тогда как медленная версия меры
будет выдавать каждый раз примерно одинаковые результаты, которые мы
видели на рис. 20.31.
Total SECPU Line SuDclass Duration CPU Rows KB Query
Bns 3 ms 2 Scan C 0 14 1 AYTH SExprO ±(CAS
1U0 4 Scan C 0 14 1 WITHSExprO = {CAS
FE SE 6 Scan c 0 14 1 WITH SExprO = (CAS
Sms 0 ms 9 Scan 0 c 1 1 WITHSExprO = (CAS
130 0% 0.0% 1C Scan 0 0 1 1 WITH SExprO = (CAS
12 Scan c 1 1 WITH SExorO; = (CAS
SE Queries SE Cache
б 6
1000%
Рис. 20.33 Вкладка Server Timings для вычисления меры Discounted Sales (scalable)
по брендам во второй раз
Как видим, процессорное время (SE CPU) на втором запуске запроса у нас
обнулилось. Это очень важно, когда модель данных публикуется на сервере
и разные пользователи открывают одни и те же отчеты. Время формирования
отчетов будет достаточно низким, а память и процессор на сервере будут за-
действоваться по минимуму. Такая оптимизация особенно важна в случае ис-
пользования систем с фиксированными резервными ресурсами вроде Power BI
Premium и Power BI Report Server.
Как правило, необходимо исключать или минимизировать употребление
функции IF внутри итераторов с большой кратностью, поскольку это может
привести к нежелательному появлению функций CallbackDatalD в запросах
движка хранилища. В следующем разделе мы подробнее поговорим о влиянии
на производительность запросов функции CallbackDatalD, которая может по-
явиться в результате использования множества разных функций DAX внутри
итераторов.
Примечание Функция SWITCH в языке DAX является аналогом вложенных функций IF,
и к ней могут быть применены те же самые принципы оптимизации.
750 ГЛАВА 20 Оптимизация в DAX
Снижение влияния функции CallbackDatalD
на производительность
В главе 19 мы уже говорили, что присутствие функции CallbackDatalD в запро-
сах движка хранилища может негативно сказаться на производительности.
Это происходит из-за замедления выполнения запроса на стороне хранилища
и невозможности воспользоваться внутренним кешем движка хранилища при
создании нового кеша данных. Знать заранее о появлении в запросе функций
обратного вызова очень важно, поскольку это зачастую может говорить об об-
разовании узкого места в движке хранилища, особенно для моделей с несколь-
кими миллионами строк в самой большой таблице.
Например, рассмотрим следующий запрос, вычисляющий меру Rounded
Sales как сумму продаж с округлением цены до ближайшего целого. В отчете,
представленном на рис. 20.34, показана мера Rounded Sales в разрезе брендов.
Brand Rounded Sales (slow) Rounded Sales (fast)
A. Datum 251.231,956.00 251,231,956.00
Adventure Works 518,414,395.00 518,414,395.00
Contoso 871.357,864.00 871,357.864.00
Fabrikam 627,737,296.00 627,737,296.00
Litware 416,210,111.00 416,210.111.00
Northwind Traders 151,497,660.00 151,497,660.00
Proseware 312.741,659.00 312,741.659.00
Southridge Video 183,564,219.00 183,564.219.00
Tailspin Toys 42,843,104.00 42,843.104.00
The Phone Company 174,730,262.00 174,730,262.00
Wide World Importers 254.943,149.00 254,943.149.00
Total 3.805.271.675.00 3.805.271.675.00
Рис. 20.34 Мера Rounded Sales по брендам товаров
В самой простой (и медленной) реализации этой меры мы применим функ-
цию ROUND к каждой строке в таблице Sales. Это приведет к появлению в за-
просе движка хранилища функции CallbackDatalD, что, в свою очередь, нега-
тивно скажется на скорости выполнения исходного запроса. Ниже приведен
код медленной меры Rounded Sales из предыдущего отчета, а детали его вы-
полнения показаны на рис. 20.35:
DEFINE
MEASURE Sales[Rounded Sales (slow)] =
SUMX (
Sales;
Sales[Quantity] * ROUND ( Sales[Net Price]; 0 )
)
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Brand]; "IsGrandTotalRowTotal" );
ГЛАВА 20 Оптимизация в DAX 751
"Rounded-Sales"; 'Sales'[Rounded Sales (slow)]
);
[IsGrandTotalRowTotal]; 0;
'Product'[Brand]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Product'[Brand]
Total 632 its FE 6П5 09^ S£ CPU Line 3.SOO mt 2 x56 a SE ns Subclass Scan Scan Duration CPU Rows 32S 1 '33 300 1797 KB 1 Query 1 WITH SExprO := (t 1 WITH SExprO ; (C
SE Queries Cache
2 0
Рис. 20.35 Вкладка Server Timings при вычислении меры Rounded Sales (slow)
Два запроса движка хранилища данных в строках 2 и 4 рассчитывают значе-
ние по каждому бренду и общий итог соответственно. Таким получился запрос
xmSQL из строки 2 на рис. 20.35:
WITH
$Ехрг0 := ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* [CallbackDatalD ( ROUND ( Sales[Net Price]], 0 ) ) ]
( PFDATAID ( 'DaxBook Sales'[Net Price] ) ) )
SELECT
'DaxBook Product'[Brand],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON ’DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey];
В таблице Sales содержится более 12 млн строк, и каждый запрос движка
хранилища столько же раз вызывает функцию CallbackDatalD для выполнения
операции округления. Фактически функция ROUND используется для исключе-
ния дробной части из цены. Судя по отчету на вкладке Server Timings, движок
формул выполняет порядка 7 тысяч функций ROUND в миллисекунду. Стоит
запомнить эту цифру, чтобы в дальнейшем понять, как влияет на быстродей-
ствие оптимизация кратности итератора, в котором вызывается функция Call-
backDatalD. Если бы в таблице было 12 тысяч строк, а не 12 млн, нашим прио-
ритетом была бы оптимизация в какой-то другой области. Но в нашем случае
необходимо постараться снизить количество вызовов функции CallbackDatalD.
Попробуем добиться поставленной цели путем реорганизации меры. Вос-
пользовавшись помощью VertiPaq Analyzer, мы определили, что таблица Sales
насчитывает более 12 млн строк, тогда как в столбце Net Price содержится менее
2500 уникальных значений. Соответственно, формула вычислит один и тот же
результат для всех строк с одинаковыми значениями столбца Net Price.
752 ГЛАВА 20 Оптимизация в DAX
Примечание Вы всегда должны пользоваться данными статистики вашей модели при
выполнении оптимизации в DAX. И легче всего получить всю необходимую информацию
при помощи инструмента VertiPaq Analyzer (http://www.sqLbi.com/tools/vertipaq-anaLyzer/).
Следующая оптимизированная версия меры Rounded Sales материализует
порядка 2500 строк, перемножая итог по столбцу Quantity на уникальные зна-
чения Net Price:
DEFINE
MEASURE Sales[Rounded Sales (fast)] =
SUMX (
VALUES ( Sales[Net Price] );
CALCULATE ( SUM ( SalesfQuantity] ) ) * ROUND ( SalesfNet Price]; 0 )
)
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Brand]; "IsGrandTotalRowTotal" );
"Rounded_Sales"; ’Sales’[Rounded Sales (fast)]
);
[IsGrandTotalRowTotal]; 0;
'Product'[Brand]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Product'[Brand]
На этот раз движок формул будет вызывать функцию ROUND, используя при
этом данные из кеша с суммой по столбцу Quantity для каждого уникального
значения Net Price. Несмотря на более объемную материализацию по сравне-
нию с медленной мерой, общее время выполнения запроса снизилось на по-
рядок. Кроме того, результаты, возвращенные запросами движка хранилища
данных, могут быть использованы повторно, поскольку они будут сохранены
в кеше хранилища, а в запросах xmSQL при этом не будет ни одного вызова
функции CallbackDatalD.
Total 5 ms FE S>ni5 170% SECPU 297 пч x7.1 SE 42 ns 82.4% Lune 2 4 Subclass Scan Scan Dur alien 30 12 CPU 2*9 7S Rows KE ЗЙ65 2472 Que. у
46 39 SELECT'I SELECT 'I
SE Queries SE Cache
2 c
0.0*
Рис. 20.36 Вкладка Server Timings при вычислении меры Rounded Sales (fast)
Ниже представлен запрос xmSQL из строки 2 на рис. 20.36. Он возвращает
Net Price и сумму по столбцу Quantity для каждого бренда, и в нем отсутствуют
вызовы функции CallbackDatalD:
ГЛАВА 20 Оптимизация в DAX 753
SELECT
'DaxBook Product1[Brand],
'DaxBook Sales'[Net Price],
SUM ( 'DaxBook Sales'[Quantity] )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON 'DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey];
В последней версии меры округление выполняется непосредственно движ-
ком формул, а не движком хранилища данных посредством функции Callback-
DatalD. Имейте в виду, что большое количество уникальных значений в столб-
це Net Price потребовало бы объемной материализации - вплоть до того, что
первоначальная версия меры могла бы оказаться более быстрой. К примеру,
если столбец Net Price будет насчитывать миллионы уникальных значений, вам
потребуется провести сравнительный анализ, чтобы выбрать наиболее подхо-
дящую меру. Более того, результаты могут отличаться на разном аппаратном
обеспечении. Вместо того чтобы строить предположения о том, какой вариант
будет оптимальным в той или иной ситуации, лучше будет провести полный
анализ мер на реальных данных, а не на небольшой их выборке.
Наконец, стоит помнить, что большинство скалярных функций DAX, не ис-
пользующих агрегацию данных, будучи примененными внутри итераторов,
при создании запроса движка хранилища потребуют вызова функции Callback-
DatalD. Такие функции, как DATE, VALUE, IFERROR, DIVIDE, большинство функ-
ций преобразования типов, все математические функции, включая округле-
ние, и функции для работы с датой и временем реализованы только в движке
формул. В большинстве случаев их присутствие в итераторах приведет к по-
следующему вызову функции CallbackDatalD. При этом вам всегда следует про-
верять запросы xmSQL визуально на наличие функции CallbackDatalD.
Оптимизация вложенных итераторов
Вложенные итераторы в DAX не могут объединяться в единый запрос движка
хранилища данных. В результате в запрос движка хранилища будет включен
только итератор с максимальным уровнем вложенности, тогда как остальные
итераторы обычно требуют более объемной материализации или размещения
в других запросах движка хранилища.
Рассмотрим для примера еще одну разновидность меры Cashback, которую
назовем Cashback Sim. В ней будет имитирован кешбэк для каждого покупателя
с использованием текущей цены товара, умноженной на историческое коли-
чество и процент кешбэка этого покупателя. На рис. 20.37 показан отчет с вы-
водом меры Cashback Sim по странам.
В первой и самой медленной реализации меры мы пройдем итерациями по
таблицам Customer и Product, чтобы извлечь процент кешбэка по покупателю
и текущую цену товара соответственно. Во внутреннем итераторе мы получим
проданное количество по каждой комбинации покупателя и товара и умножим
это значение на значения столбцов Unit Price и Cashback % из соответствующих
таблиц. Код этой версии меры Cashback Sim представлен ниже, а его быстро-
действие можно проанализировать по рис. 20.38:
754 ГЛАВА 20 Оптимизация в DAX
DEFINE
MEASURE Sales[Cashback Sin. (slow)] =
SUMX (
Customer;
SUMX (
'Product';
SUMX (
RELATEDTABLE ( Sales );
Sales[Quantity] * 'Product'[Unit Price] * Customer[Cashback %]
)
)
)
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Customer'[Country]; "IsGrandTotalRowTotal" );
"Cashback Sim. (slow)"; ’Sales’[Cashback Sim. (slow)]
);
[IsGrandTotalRowTotal]; 0;
'Customer'[Country]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Customer'[Country]
Country Cashback Sim. (slow) Cashback Sim. (medium) Cashback Sim. (fast)
Australia 1,308.420.16 1,308.420.16 1,308.420.16
Canada 398.393.28 398.393.28 398.393.28
France 489,314.08 489,314.08 489.314.08
Germany 828,920.45 828.920.45 828,920.45
United Kingdom 1,110,960.36 1,110,960.36 1,110,960.36
United States 1,912,379.56 1,912,379.56 1,912,379.56
Total 6.048.387.89 6.048.387.89 6.048.387.89
Рис. 20.37 Мера Coshbock Sim в разрезе по странам
Total SECPU Line Sabdass Duration CPU Rows KE Query
12 .891 ms 11 516ms 2 Scan 5.575 11.484 12 527442 97,871 SELECT
x21 • Scan C 0 2 517 20 SELECT’
EE SE 6 Scan 3 16 18,869 74 SELECT’
7,305 ms 5 53b ms 8 Scan 3 c 32 1 WITH $
5С7Ч 43 3% 10 Scan 0 0 12,527442 97 871 SELECT ’
12 Scan 3 16 18,869 74 SELECT’
SE Queries SE Cache 14 Scan 2 c 1 1 WITH s
i
14.3%
Рис. 20.38 Вкладка Server Timings при вычислении меры Coshbock Sim. (slow) по странам
Процесс выполнения этого запроса почти поровну разделен между движ-
ком хранилища данных и движком формул. При этом первый тратит немало
ГЛАВА 20 Оптимизация в DAX 755
времени на создание большой материализации, а второй - на ее обработку.
Запросы движка хранилища из строк 2 и 10 на рис. 20.38 идентичны и материа-
лизуют следующие столбцы для всей таблицы Sales: CustomerKey, ProductKey,
Quantity и RowNumber:
SELECT
'DaxBook Customer'[CustomerKey],
'DaxBook Product'[ProductKey],
'DaxBook Sales'[RowNumber],
'DaxBook Sales'[Quantity]
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON 'DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey]
LEFT OUTER JOIN 'DaxBook Product'
ON 'DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey];
RowNumber представляет собой специальный столбец, недоступный в DAX,
который применяется для уникальной идентификации строки в таблице. Пе-
речисленные четыре столбца используются в движке формул для расчета зна-
чений во внутреннем итераторе, где учитываются продажи по всем комбина-
циям значений из таблиц Customer и Product. Запрос в строке 2 создает кеш
данных, который также возвращается в строке 10. Присутствие этого второго
запроса движка хранилища обусловлено необходимостью рассчитывать общие
итоги в функции SUMMARIZECOLUMNS. Без использования двух уровней гра-
нулярности в отчете половина плана выполнения и запросов движка хранили-
ща оказалась бы не нужна.
Итак, наша мера DAX проходит итерациями по двум таблицам (Customer
и Product), выявляя все существующие комбинации покупателей и товаров.
Далее для каждой комбинации внутренний итератор SUMX проходит только
по соответствующим строкам из таблицы Sales. При этом формула также тра-
тит драгоценное процессорное время на обработку комбинаций покупателя
и товара, для которых нет соответствующих строк в таблице продаж. Из плана
выполнения запроса мы узнаем, что в модели данных присутствуют 2517 това-
ров и 18 869 покупателей - эти же значения присутствуют и в качестве оценоч-
ного количества строк в списке запросов движка хранилища в строках 4 и 6 на
рис. 20.38. Таким образом, движку формул придется выполнить 1 326 280 агре-
гаций по строкам, материализованным таблицей Sales, что видно по фрагменту
плана выполнения, представленному на рис. 20.39. В колонке Records показано
количество строк, участвовавших в итерациях по кешам данных, возвращен-
ным движком хранилища (узлы Cache в строках 28, 33 и 36) или вычисленным
другими операциями движка формул (узел CrossApply в строке 23).
Несмотря на то что в коде DAX осуществляются итерации по таблицам, в за-
просе xmSQL будут извлекаться только столбцы, уникально идентифициру-
ющие строки в таблице. Это позволит уменьшить количество столбцов в ма-
териализации, даже если кратность просматриваемых таблиц больше, чем
необходимо. На данном этапе стоит сделать два важных замечания:
кратность итераторов больше, чем требуется. Благодаря операции пре-
образования контекста можно снизить кратность внешних итераторов.
Таким образом, в контексте запроса будут присутствовать все строки из
756 ГЛАВА 20 Оптимизация в DAX
таблицы Sales для заданной комбинации столбцов Unit Price и Cashback %,
а не все сочетания товаров и покупателей;
избавление от вложенных итераторов позволит повысить эффективность
плана выполнения запроса, попутно исключив дорогостоящую материа-
лизацию.
Line Records Physical Query Plan
20 1.326.280
21 1.326,280
22
23
24 18.869
25 18869
26 18869
27 18869
28
29 2.517
30 2.517
31 2,517
32 2517
33
34 12.527,442
35 12 527.442
36
Spool_lterator<Spoollterator>: IterPhyOp LogOp=SumX lterCols(2, 27, 29. 45}('Customer'[CustonierKey].
AggregstionSpoo:<Sum>: SpoolPhyOp *Records= 1326280
Extend.Lookup: IterPhyOp LogOp=Multiply lterCols(27. 45, 168X'Cu5torTer'[Cashback %]. 'Product'[
OossApp'y: IterPhyOp LogOp=Multiply lterCol$(27 45, lOSJfCustomer'lCashback %]. Product‘[U
Spool.MultiValuedHashLookup: IterPhyOp LogOp=Scan_Vertipaq LcolcupCois(2)('Customer'[Ci.
AggreoationSpool<Group8y>: SpoolPnyOp “Records= 18869
Spool_lterator<Spool:tera*or>: IterPhyOp LogOp=Scsn_Vertipaq IterColsfO, 2. 27)('Custorr
ProjectionSpool <PrqjectFusion < > >: SpoolPhyOp *Recorcs= 18869
Cache; IterPhyOp *FieldCols=3 *ValueCols=0
Spoo.MultiValuedHashLookup: IterPhyOp LogOp=Scan_Vertipaq LookupCol${29X'Product'[Prc
Aggregationspool <GroupBy>: SpoolPnyOp *Records=2517
Spool_lterator<SpooHtera:or>: IterPhyOp LogOpsScan_Vertipaq lterCol$(28. 29 45)['Prod
ProjectionSpool<Projectedsion< > >: SpoolPhyOp -Recoros=2517
Cache: IterPhyOp -FieldCols=3 -ValueCols=0
Spooi_iterator<Spoollteratcr>: terPhyOp LogOp=Sean_Vertipaq lterCol$(2, 29, 153, 168)CCusti
ProjectionSpool<ProjectFu$ion<> >: SpoolPhyOp “Records=12527442
Cache: terPhyOp ₽FieldCols=4 5ValueCols=0
Рис. 20.39 Вкладка Query Plan при вычислении меры Cashback Sim. (slow) по странам
С учетом первого замечания можно использовать технику, описанную ранее
в разделе про оптимизацию преобразования контекста. Фактически функция
RELATEDTABLE работает как CALCULATETABLE без аргументов фильтра, вы-
полняя только операцию преобразования контекста. В название следующей
меры мы добавили слово «средняя» (medium), и она выполняет итерации по
столбцам Cashback % и Unit Price, а не по таблицам Customer и Product. При этом
семантика запроса осталась прежней, поскольку выражение во внутренней
итерации зависит только от этих столбцов:
DEFINE
MEASURE Sales[Cashback Sim. (medium)] =
SUMX (
VALUES ( Customer[Cashback %] );
SUMX (
VALUES ( 'Product'[Unit Price] );
SUMX (
RELATEDTABLE ( Sales );
Sales[Quantity] * 'Product1[Unit Price] * Customer[Cashback %]
)
)
)
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Customer'[Country]; "IsGrandTotalRowTotal" );
"Cashback Sim. (medium)"; 'Sales'[Cashback Sim. (medium)]
);
[IsGrandTotalRowTotal]; 0;
ГЛАВА 20 Оптимизация в DAX 757
'Customer'[Country]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Customer'[Country]
По рис. 20.40 видно, что общее время выполнения меры Cashback Sim. (medi-
um) сократилось на два порядка по сравнению с медленной версией благодаря
более низкой гранулярности и более простой зависимости между таблицами,
по которым осуществляются итерации, и столбцами, на которые идет ссылка.
Total St CPU Line Subclass Duration CPJ Rows KB Oueiy
105 its 375 ns 2 Scan 32 234 18.774 221 WITH SE
A Scan 39 141 2444 29 WTH5E
ЕЕ SE
14 ns 9’ ns
133%
ъЕ Queries SE Cache
2 О
оо%
Рис. 20.40 Вкладка Server Timings при вычислении меры Cashback Sim. (medium) по странам
Два запроса движка хранилища представляют данные для разных кратно-
стей результата. Ниже представлен запрос из строки 2, тогда как запрос из
строки 4 не содержит столбец Country и подсчитывает значения только для об-
щих итогов:
WITH
$Ехрг0 := ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Product'[Unit Price] AS REAL ) )
* PFCAST ( ’DaxBook Customer'[Cashback] AS REAL ) )
SELECT
'DaxBook Customer'[Country],
'DaxBook Customer'[Cashback],
'DaxBook Product'[Unit Price],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON ’DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey]
LEFT OUTER JOIN 'DaxBook Product'
ON ’DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey];
Эта промежуточная версия меры Cashback Sim содержит прежнее количест-
во вложенных итераторов и потенциально может учитывать все возможные
комбинации значений в столбцах Unit Price и Cashback %. В этой простой мере
план выполнения запроса может установить определенные зависимости для
таблицы Sales, чтобы вычисления производились только по существующим
в таблице комбинациям. Но в языке DAX мы можем в явном виде указать движ-
ку, чтобы он использовал только существующие комбинации. Вместо трех вло-
женных итераторов мы можем использовать один, который будет проходить
по результатам функции SUMMARIZE, группирующей только присутствующие
758 ГЛАВА 20 Оптимизация в DAX
значения в столбцах. Следующая версия меры, которую мы назвали улучшен-
ной (improved), может вести к созданию более эффективного плана выполне-
ния запроса в сложных сценариях, несмотря на то что в нашем случае резуль-
тат и план оказались такими же:
MEASURE Sales[Cashback Sim. (improved)] =
SUMX (
SUMMARIZE (
Sales;
'Product'[Unit Price];
Customer[Cashback %]
);
CALCULATE ( SUM ( SalesfQuantity] ) )
* 'Product'[Unit Price] * Customer[Cashback %]
)
Средняя и улучшенная версии меры Cashback Sim могут быть легко адап-
тированы к использованию существующих мер во внутренней итерации.
В последней из представленных мер функция CALCULATE используется для
вычисления суммы по столбцу Sales [Quantity] для определенной комбинации
столбцов Unit Price и Cashback %, как в ссылке на меру. Такой подход можно
взять на вооружение при написании эффективного кода, который будет лег-
ко поддерживать. Но наиболее эффективная версия меры будет представлена
ниже, и в ней не будут присутствовать вложенные итераторы.
Примечание В определениях мер часто встречаются функции агрегирования вро-
де SUM. Все простые функции агрегирования, за исключением DISTINCTCOUNT, по сути,
представляют собой упрощенный синтаксис соответствующего итератора. Например,
функция SUM внутренне преобразуется в SUMX. Таким образом, присутствие ссылки на
меру внутри итератора часто предполагает неявную вложенность итерационных функций
с сопутствующим преобразованием контекста. Если того требует природа расчетов, боль-
шие вычислительные затраты могут быть оправданы. Когда вложенные итераторы явля-
ются аддитивными, как в случае с функциями SUMX/SUM в мере Cashback Sim. (improved),
с целью оптимизации производительности может быть сделано объединение вычислений.
Однако такой вариант может негативно сказаться на легкости восприятия и возможности
повторного использования меры.
Ниже представлена быстрая (fast) версия меры Cashback Sim с улучшенной
производительностью, достигнутой ценой снижения возможности повторного
использования бизнес-логики существующих мер:
DEFINE
MEASURE Sales[Cashback Sim. (fast)] =
SUMX (
Sales;
Sales[Quantity]
* RELATED ( 'Product'[Unit Price] )
* RELATED ( Customer[Cashback %] )
)
EVALUATE
ГЛАВА 20 Оптимизация в DAX 759
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Customer'[Country]; "IsGrandTotalRowTotal" );
"Cashback Sim. (fast)"; 'Sales'[Cashback Sim. (fast)]
);
[IsGrandTotalRowTotal]; 0;
'Customer'[Country]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Customer'[Country]
На рис. 20.41 показаны характеристики этой версии меры, из которых по-
нятно, что быстродействие было существенно увеличено по сравнению с дву-
мя предыдущими реализациями.
Total 47 ns FE 8 П5 Tj0% SECPU 14C ns x36 SE 39 ns 83 Line 2 Л Subclass Scan Scan Duration 24 15 CPU Rows 31 109 K& 32 1 Query 1 WTHiE 1 WTH5E
SF Queries SF Cache
2 0
_0.0*
Рис. 20.41 Вкладка Server Timings при вычислении меры Cashbock Sim. (fast) по странам
Мера с одним итератором и без выполнения операции преобразования кон-
текста генерирует следующий простой запрос движка хранилища из строки 2
на рис. 20.41:
WITH
$Ехрг0 := ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Product'[Unit Price] AS REAL ) )
* PFCAST ( ’DaxBook Customer’[Cashback] AS REAL ) )
SELECT
’DaxBook Customer'[Country],
SUM ( @$Expr0 )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Customer'
ON ’DaxBook Sales'[CustomerKey]='DaxBook Customer'[CustomerKey]
LEFT OUTER JOIN 'DaxBook Product'
ON ’DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey];
Использование функции RELATED не приводит к появлению в запросе
движка хранилища нежелательной функции CallbackDatalD. Единственным
последствием использования функции RELATED является принудительное
выполнение объединения внутри движка хранилища для обеспечения доступа
к связанным столбцам, что обычно оказывает меньшее негативное влияние на
производительность запроса по сравнению с применением функции Callback-
DatalD. При этом мы советуем вам использовать быструю версию меры только
760 ГЛАВА 20 Оптимизация в DAX
в том случае, если вам крайне необходимо получить максимальное быстродей-
ствие запроса с сохранением минимального уровня материализации.
Отказ от использования табличных фильтров
с функцией DISTINCTCOUNT
Мы уже не раз упоминали, что аргументы фильтра в функциях CALCULATE/
CALCULATETABLE должны применяться к столбцам, а не к таблицам. На сле-
дующем примере мы продемонстрируем вам еще один шаблон, с которым вы
можете столкнуться в процессе оптимизации запросов. Побочным эффектом
использования табличных фильтров является создание объемной материали-
зации для последующей обработки движком формул при вычислении резуль-
тата. При этом для неаддитивных выражений план выполнения запроса может
сгенерировать по одному запросу движка хранилища для каждого элемента,
включенного в гранулярность результата. Функция DISTINCTCOUNT является
простым и распространенным примером неаддитивного выражения.
На рис. 20.42 показан отчет с выводом количества покупателей, приобретав-
ших товары на сумму свыше $1000 (Customers 1к).
Product Name Customers 1k (slow) Customers Ik (fast)
A. Datum SLR Camera 35‘ X358 Pink 59 59
A. Datum SLR Camera 35' X358 Silver 112 112
A. Datum SLR Camera 35’ X358 Silver Grey 182 182
A. Datum SLR Camera Ml35 Black 115 115
A. Datum SLR Camera M136 Silver 116 116
A. Datum SLR Camera M137 Grey 117 117
A. Datum SLR Camera M138 Silver Grey 91 91
Total 18.852 18.852
Рис. 20.42 Количество клиентов с покупками на сумму свыше $1000 по каждому товару
В условии фильтрации для меры Customers 1к должны присутствовать два
столбца. Наименее эффективной реализацией такого требования является
применение фильтра к таблице Sales. Ниже показана медленная версия меры
Customers 1к, а подробная информация о времени выполнения запроса пред-
ставлена на рис. 20.43:
DEFINE
MEASURE Sales[Customers Ik (slow)] =
CALCULATE (
DISTINCTCOUNT ( Sales[CustomerKey] );
FILTER (
Sales;
Sales[Quantity] * Sales[Net Price] > 1000
)
)
EVALUATE
TOPN (
ГЛАВА 20 Оптимизация в DAX 761
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Product Name]; "IsGrandTotalRowTotal" );
"Customers_lk__slow_"; ’Sales’[Customers Ik (slow)]
);
[IsGrandTotalRowTotal]; 0;
'Product'[Product Name]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Product'[Product Name]
Total St CPU Line Subclass Duration CPU Rows KB Quei у
45,944 192453 ms 2 Scan 37 172 13 *-32 53 SELECT'DaxBook P
xx “ Q Scan 35 156 1 1 SELECT DCOt NT (
FE SE 1C Scan 39 155 1 1 SELECT DCOIJNT (
4,832 ms 41,112 ms 14 Scan 33 141 1 1 SELECT DCOUNT’f
10 5% W .5% 13 Scan 31 109 1 1 SELECT DGOUNT (
22 Scan 32 188 1 1 SELECT DCOUNT (
SE Quei ies SE Cache 26 Scan 32 219 1 1 SELECT DCOIJNT (
1,093 0 3C Scan 33 141 1 1 SELECT DOOUNT ('
0.СЧ 34 Scan 33 172 1 1 SELECT DCOUNT (
Рис. 20.43 Вкладка Server Timings при вычислении меры Customers lk (slow)
Этот запрос генерирует большое количество запросов движка хранилища -
по одному для каждого товара, включенного в результат. А поскольку каждый
такой запрос отнимает порядка 100-200 мс процессорного времени, в итоге
мы получаем несколько минут задействования центрального процессора, хотя
общее время ожидания оказалось меньше минуты благодаря одновременному
использованию нескольких потоков.
Первый запрос xmSQL из строки 2 на рис. 20.43 возвращает список наиме-
нований товаров, а также столбцы Quantity и Net Price из соответствующих
транзакций. Несмотря на то что в нашей модели данных присутствует всего
1091 товар, по которым в таблице Sales есть транзакции на сумму более $1000,
гранулярность кеша данных будет больше из-за включения дополнительной
информации, помимо наименования товара. Это приведет к появлению в вы-
борке более одной строки по каждому товару:
SELECT
'DaxBook Product'[Product Name],
'DaxBook Sales'[Quantity],
'DaxBook Sales'[Net Price]
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON ’DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey]
WHERE
( COALESCE ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) ) )
> COALESCE ( 1000.000000 )
);
762 ГЛАВА 20 Оптимизация в DAX
Далее следует 1091 запрос xmSQL, как две капли воды похожий на запрос из
строки 6 на рис. 20.43 и возвращающий единственное значение, полученное
путем выполнения агрегации DISTINCTCOUNT. В запросе, показанном ниже,
фильтр будет содержать все комбинации значений столбцов Quantity и Net
Price, произведение которых превышает 1000 для товара Adventure Works 52’’
LCD HDTVX790W Silver:
SELECT
DCOUNT ( 'DaxBook Sales'[CustomerKey] )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON 'DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey]
WHERE
( COALESCE ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) ) )
> COALESCE ( 1000.000000 )
VAND (
'DaxBook Product'[Product Name],
'DaxBook Sales'[Quantity],
'DaxBook Sales'[Net Price] )
IN {
( 'Adventure Works 52" LCD HDTV X790W Silver', 2, 1592.200000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 4, 1432.980000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 1, 1273.760000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 3, 1480.746000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 4, 1512.590000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 3, 1592.200000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 3, 1353.370000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 4, 1273.760000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 1, 1480.746000 ) ,
( 'Adventure Works 52" LCD HDTV X790W Silver', 1, 1592.200000 )
..[24 total tuples, not all displayed]};
Фактически следующий запрос из строки 10 на рис. 20.43 отличается от пре-
дыдущего только завершающими условиями фильтрации, в которых перечис-
лены подходящие комбинации полей Quantity и Net Price для товара Contoso
Washer & Dryer 21 in E210 Blue:
SELECT
DCOUNT ( 'DaxBook Sales'[CustomerKey] )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON 'DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey]
WHERE
( COALESCE ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) ) )
> COALESCE ( 1000.000000 )
VAND (
'DaxBook Product'[Product Name],
'DaxBook Sales'[Quantity],
ГЛАВА 20 Оптимизация в DAX 763
'DaxBook Sales'[Net Price] )
IN {
( 'Contoso Masher & Dryer 21in E210 Blue', 2, 1519.050000 ) ,
( 'Contoso Washer & Dryer 21in E210 Blue', 2, 1279.200000 ) ,
( 'Contoso Washer & Dryer 21in E210 Blue', 2, 1359.150000 ) ,
( 'Contoso Washer & Dryer 21in E210 Blue', 4, 1487.070000 ) ,
( 'Contoso Washer & Dryer 21in E210 Blue', 3, 1439.100000 ) ,
( 'Contoso Washer & Dryer 21in E210 Blue', 3, 1519.050000 ) ,
( 'Contoso Washer & Dryer 21in E210 Blue', 3, 1359.150000 ) ,
( 'Contoso Masher & Dryer 21in E210 Blue', 2, 1599.000000 ) ,
( 'Contoso Washer & Dryer 21in E210 Blue', 1, 1439.100000 ) ,
( 'Contoso Washer & Dryer 21in E210 Blue', 3, 1279.200000 )
..[24 total tuples, not all displayed]};
Присутствие большого количества похожих запросов также заметно и на
вкладке Query Plan, показанной на рис. 20.44. Каждая строка, начиная с 15-й,
соответствует одному кешу данных всего с одним столбцом, созданному од-
ним из запросов движка хранилища, аналогичных показанным выше.
Line Records Physical Query Plan
/
8
9
10
11
12
13
14
15
16
17
18
19
эл
Partition I ntoGroups: IterPhyOp LogOp=Crder IterColsiO, 1. 2. 3)C₽roduct'[Procuct Name],
1 Aggregationspool < Order >: SpoolPhyOp *Records=l
Partition IntoG roups: IterPhyOp LogOp=TopK lterCols(0, 1. 2 3)( Product [Product Klai
1 AggregationSpool<Top>: SpoolPhyOp *Records=1
Union: IterPhyOp LogOp=Union lterCols(0. 1, 2 3)f Product [Product Kame], ' [Is
GroupSemijoin IterPhyOp LogOp=GroupSemiJoin lterCols(0, 1. 2)( Product’[P
1.091 5pool_:terdtor<Spoollterator>: IterPhyOp LogCp=DistinctCount_Vertipaq 1‘
1.091 ProjectionSpookProjectFusion<Copy> >: SpoolPhyOp =Recorcs=1091
Cache: IterPhyOp ^FieldCols=0 =ValueCois=1
Cache: lterPhyCp^FieldCols=0 =ValueCols=1
Cache: IterPhyOp ~FieldCols=0 =ValueCo!s=l
Cache: IterPhyOp ?FieldCols=0 =ValueCols=1
Cache: IterPhyOp ~FieldCols=0 -ValueCols=1
Г ItArOP-.i.'f'irt —П sVi'ali rJr —1
Рис. 20.44 Вкладка Query Plan при вычислении меры Customers lk (slow)
Присутствие табличного фильтра, примененного к контексту фильтра, при-
водит к созданию далеко не самого эффективного плана выполнения запроса.
В данном случае мы видим образование огромного количества идентичных
запросов движка хранилища вместо одной объемной материализации. Чтобы
оптимизировать этот запрос, необходимо избавиться от табличного фильтра
в функции CALCULATE. Оптимизированная версия меры Customer 1к приме-
няет фильтр к столбцам Quantity и Net Price с использованием модификатора
KEEPFILTERS, чтобы сохранить семантику фильтра исходного кода меры. Сле-
дующий запрос отображает на вкладке Server Timings показатели, представ-
ленные на рис. 20.45:
DEFINE
MEASURE Sales[Customers lk (fast)] =
CALCULATE (
DISTINCTCOUNT ( Sales[CustomerKey] );
KEEPFILTERS (
FILTER (
ALL (
764 ГЛАВА 20 Оптимизация в DAX
Sales[Quantity];
Sales[Net Price]
);
Sales[Quantity] * Sales[Net Price] > 1000
)
)
)
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL ( 'Product'[Product Name]; "IsGrandTotalRowTotal" );
"Customers_lk__fast_"; ’Sales’[Customers Ik (fast)]
);
[IsGrandTotalRowTotal]; 0;
'Product'[Product Name]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Product'[Product Name]
Total 97 ms FE 17 ms 1-5% SECPU 453 rrs х57 SE 80 ms 82.5% Line 4 S Subclass Scan Scan Duration 59 21 CPU 281 172 Rows K8 I C91 1 Query 13 SELECT' 1 SELECT I
SE Queries SE Cache
2 0
Рис. 20.45 Вкладка Server Timings при вычислении меры Customers lk (fast)
Использование фильтра по столбцам в функции CALCULATE позволило су-
щественно упростить план выполнения запроса, в котором осталось всего два
запроса движка хранилища - по одному для каждого уровня гранулярности:
товаров и итогов. Ниже показан запрос xmSQL из строки 4 на рис. 20.45:
SELECT
'DaxBook Product'[Product Name],
DCOUNT ( 'DaxBook Sales'[CustomerKey] )
FROM 'DaxBook Sales'
LEFT OUTER JOIN 'DaxBook Product'
ON ’DaxBook Sales'[ProductKey]='DaxBook Product'[ProductKey]
WHERE
( COALESCE ( ( CAST ( PFCAST ( 'DaxBook Sales'[Quantity] AS INT ) AS REAL )
* PFCAST ( 'DaxBook Sales'[Net Price] AS REAL ) ) )
> COALESCE ( 1000.000000 )
);
Полученный кеш данных соответствует итоговому результату запроса DAX,
и движку формул не придется проводить какую-то дополнительную обработку
ГЛАВА 20 Оптимизация в DAX 765
данных. Это идеальные условия с точки зрения производительности запро-
са. Из этого раздела вы должны сделать вывод о том, что количество запросов
движка хранилища в плане выполнения запроса также имеет большое зна-
чение. Чем больше таких запросов, тем хуже обычно будет план выполнения.
К появлению множества запросов движка хранилища могут приводить не-
аддитивные меры в комбинации с табличными фильтрами или применени-
ем двунаправленной фильтрации, что в итоге может негативно сказаться на
быстродействии запроса.
Уход от множественных вычислений путем использования
переменных
В случаях, когда выражение DAX многократно вычисляет одно и то же подвы-
ражение, можно применить технику сохранения результата этого подвыраже-
ния в переменной и пользоваться ей на протяжении оставшейся части кода.
Использование переменных не только позволяет сделать код более простым
для восприятия, но и в большинстве случаев положительно сказывается на
итоговом плане выполнения запроса - за некоторыми исключениями, о кото-
рых мы поговорим далее в этом разделе.
На рис. 20.46 представлен отчет с выводом меры Sales YOY %, вычисляющей
разницу в процентах между текущим значением меры Sales Amount и соответ-
ствующим показателем предыдущего года.
Year Sales Amount Sales YOY % (slow) Sales YOY % (fast)
r I . r J
November 2007 108,008,618.91
December 2007 110,436,896.10
CY 2008 1,189.326,612.81 -15.97% -15.97%
January 2008 79,431,234.29 -28.85% -28.85%
February 2008 85,088,461.45 -27.11% -27.11%
March 2008 84,808,709.97 -27.07% -27.07%
April 2008 105,627,816.67 -16.83% -16.83%
May 2008 109,011,089.35 -19.01% -19.01%
June 2008 107,110,706.45 -12.13% -12.13%
» A • *4 A • *4 r » А ИЧ • A .
Total 3,805.392.024.21 0.00% 0.00%
Рис. 20.46 Разница в сумме продаж по сравнению с предыдущим годом
в разрезе лет и месяцев
При вычислении меры Sales YOY % происходит внутреннее обращение
к другим мерам. Чтобы иметь возможность менять любую часть вычисления,
бывает полезно включить определения зависимых мер при помощи пункта
контекстного меню Define Dependent Measure в DAX Studio. Ниже приведен
неоптимальный код меры Sales YOY % (slow), а его быстродействие можно
изучить по рис. 20.47:
DEFINE
MEASURE Sales[Sales PY] =
CALCULATE (
766 ГЛАВА 20 Оптимизация в DAX
[Sales Amount];
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
MEASURE Sales[Sales YOY (slow)] =
IF (
NOT ISBLANK ( [Sales Amount] ) && NOT ISBLANK ( [Sales PY] );
[Sales Amount] - [Sales PY]
)
MEASURE Sales[Sales Amount] =
SUMX (
Sales;
Sales[Quantity] * Sales[Net Price]
)
MEASURE Sales[Sales YOY % (slow)] =
DIVIDE (
[Sales YOY (slow)];
[Sales PY]
)
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
’Date’[Calendar Year Month];
’Date’[Calendar Year Month Number]
); "IsGrandTotalRowTotal"
);
"Sales_Y0Y slow_"; ’Sales’[Sales YOY % (slow)]
);
[IsGrandTotalRowTotal]; 0;
’Date’[Calendar Year Month Number]; 1;
’Date’[Calendar Year Month]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
’Date’[Calendar Year Month Number];
’Date’[Calendar Year Month]
Total SE CPU Line Subclass Duration CPU Rows KB Quei у
172 rrs 625 ns 2 Scan 24 156 7 569 119 WITH SE:'
Xi.6 4 Scan 0 c 2 559 20 SELECT'S
ГЕ SE 6 Scan 1 c 2 556 10 SELECT‘E
35 ns 137 ns 3 Scan 26 109 2 559 40 W TH SEJ
20 3% 79-7% Ю Scan 29 125 7 569 119 WITH SE:
12 Scan C C 731 SELECT‘E
SE Qj er es SE Cache Scan 0 c 7 569 60 SELECT‘E
* 2 4 5 Scan 21 141 2,559 40 WITH SE
23 5% 18 Scan 13 0 1 1 with se!
— A A A A * Л. • A
Рис. 20.47 Вкладка Server Timings при вычислении меры Soles YOY % (slow)
Описание плана выполнения запроса включает 1819 строк и не приведено
в данной книге. Более того, у нас есть четыре запроса движка хранилища дан-
ГЛАВА20 Оптимизация в DAX 767
ных, в которых присутствует упоминание кеша движка хранилища (SE Cache),
несмотря на то что мы очистили кеш перед выполнением запроса. Это говорит
о том, что в разных частях плана выполнения генерируются разные запросы
для одного и того же запроса движка хранилища. И хотя обращение к кешу по-
зволяет повысить общую производительность запроса, присутствие подобных
излишеств в плане выполнения запроса обычно является свидетельством воз-
можности дальнейших улучшений.
Когда план выполнения запроса оказывается столь сложным, и в нем при-
сутствует большое количество запросов движка хранилища, обычно стоит
пересмотреть структуру запроса DAX на предмет замены повторных вычисле-
ний переменными. Часто бывает, что именно повторные вычисления в запро-
се приводят к образованию множества дублирующихся запросов движка хра-
нилища. Обычно движку DAX удается самостоятельно найти дублирующиеся
подвыражения, вычисляемые в одном и том же контексте фильтра, и повторно
использовать их результаты без необходимости многократного расчета этих
показателей. Но присутствие в коде условных конструкций с использованием
функций вроде IF и SWITCH может помешать выполнению подобной внутрен-
ней оптимизации.
Рассмотрим реализацию меры Sales YOY (slow), в которой значения зависи-
мых мер Sales Amount и Sales PY рассчитываются в разных ветвях вычисления.
Первый аргумент условной функции IF всегда должен быть рассчитан, тогда
как второй аргумент будет вычислен только в случае истинности первого. Вы-
ражение DAX, присутствующее в обоих аргументах, может быть дважды вы-
числено в плане выполнения запроса, при этом результат, полученный при
вычислении первого аргумента, не сможет быть использован при расчете
выражения второго аргумента. Технические предпосылки такого поведения
и описание ситуаций, в которых оно может быть полезно, выходят за рамки
данной книги.
В следующем фрагменте приведенного выше запроса выделены жирным
шрифтом ссылки на меры, которые могут быть вычислены повторно по при-
чине нахождения в разных аргументах:
MEASURE Sales[Sales YOY (slow)] =
IF (
NOT ISBLANK ( [Sales Amount] ) && NOT ISBLANK ( [Sales PY] );
[Sales Amount] - [Sales PY]
)
Если же заранее сохранить результаты вычисления мер Sales Amount и Sales
PY в переменных, можно явно дать указание движку DAX использовать значе-
ния этих переменных при вычислении обоих аргументов условного выраже-
ния. В следующем фрагменте кода оптимизированной меры Sales YOY (fast)
показано, как реализовать такой подход в DAX:
MEASURE Sales[Sales YOY (fast)] =
VAR SalesPY = [Sales PY]
VAR SalesAmount = [Sales Amount]
RETURN
IF (
768 ГЛАВА 20 Оптимизация в DAX
NOT ISBLANK ( SalesAmount ) && NOT ISBLANK ( SalesPY );
SalesAmount - SalesPY
)
Далее приведен полный код реализации меры Sales YOY % (fast), использу-
ющей зависимую меру Sales YOY (fast) вместо Sales YOY (slow). Быстродействие
оптимизированного варианта меры приведено на рис. 20.48:
DEFINE
MEASURE Sales[Sales PY] =
CALCULATE (
[Sales Amount];
SAMEPERIODLASTYEAR ( 'Date'[Date] )
)
MEASURE Sales[Sales YOY (fast)] =
VAR SalesPY = [Sales PY]
VAR SalesAmount = [Sales Amount]
RETURN
IF (
NOT ISBLANK ( SalesAmount ) && NOT ISBLANK ( SalesPY );
SalesAmount - SalesPY
)
MEASURE Sales[Sales Amount] =
SUMX (
Sales;
Sales[Quantlty] * Sales[Net Price]
)
MEASURE Sales[Sales YOY % (fast)] =
DIVIDE (
[Sales YOY (fast)];
[Sales PY]
)
EVALUATE
TOPN (
502;
SUMMARIZECOLUMNS (
ROLLUPADDISSUBTOTAL (
ROLLUPGROUP (
'Date'[Calendar Year Month];
'Date'[Calendar Year Month Number]
); "IsGrandTotalRowTotal"
);
"Sales_Y0Y fast_"; 'Sales'[Sales YOY % (fast)]
);
[IsGrandTotalRowTotal]; 0;
'Date'[Calendar Year Month Number]; 1;
'Date'[Calendar Year Month]; 1
)
ORDER BY
[IsGrandTotalRowTotal] DESC;
'Date'[Calendar Year Month Number];
'Date'[Calendar Year Month]
ГЛАВА 20 Оптимизация в DAX 769
Total SECPU Line Subclass Duration CPU Rows KE Query
95 ns 259 ms 2 Scan 23 109 7569 1’9 WITH 3
4 Scan C 0 2.559 20 SELECT}
FE SE 5 Scan 1 15 2.555 10 ScLEC
17 ms. 78 ms £ Scan 25 109 2 559 40 WITH 5
17 9% £214 10 Scan C C 7,569 □C SELECT
12 Scan 12 47 1 1 WITH 5
SE Quei ies SE Cache 4 Scan C C 2 559 20 SELECT
a 1 q Scan 17 73 1 1 WITH S
12 5%
Рис. 20.48 Вкладка Server Timings при вычислении меры Soles YOY % (fast)
Описание плана выполнения нашего запроса состоит из 488 строк (он так-
же не будет приведен в данной книге), что гораздо меньше, чем предыдущие
1819 строк. В обновленном плане существенно снижена нагрузка на движок
хранилища данных как в отношении времени выполнения, так и в плане ко-
личества сгенерированных запросов. Также при выполнении запроса было
сокращено время задействования движка формул. В результате оптимизации
нам удалось почти вдвое уменьшить общее время выполнения запроса, при
этом в более сложных моделях данных и выражениях прирост производитель-
ности может оказаться еще большим. А применительно к выражениям с вло-
женными мерами приведенная выше оптимизация позволила бы добиться
экспоненциального роста производительности.
Однако вам стоит помнить о возможных побочных эффектах сохранения
в переменных результатов вычислений перед условными выражениями. Перед
конструкциями с использованием функций IF или SWITCH можно сохранять
в переменные только подвыражения, используемые в первом аргументе ус-
ловия. В противном случае вы можете добиться противоположного эффекта
с вычислением выражений, которые иначе не были бы рассчитаны. В процессе
оптимизации вам следует придерживаться следующих правил:
если выражение DAX вычисляется более одного раза в одном и том же
контексте фильтра, заранее сохраните его результат в переменную и да-
лее в коде используйте ссылку на эту переменную вместо исходного вы-
ражения;
если выражение DAX вычисляется внутри ветви условной конструкции IF
или SWITCH, сохраняйте его результат в переменную внутри этой ветви
всякий раз, когда это возможно;
не применяйте переменные за пределами конструкции IF или SWITCH,
если их использование будет ограничено только условной ветвью выпол-
нения;
в первом аргументе функций IF и SWITCH могут быть использованы пе-
ременные, объявленные до условной конструкции, без негативного влия-
ния на производительность.
Больше примеров по этой теме можно посмотреть по адресу: https://www.
sqlbi.com/articles/optimizing-if-and-switch-expressions-using-variables/.
770 ГЛАВА 20 Оптимизация в DAX
Реализация альтернативных условных операторов
В предыдущем примере мы использовали простое выражение с участием функции IF,
чтобы продемонстрировать вариант выполнения оптимизации. И хотя пользоваться пе-
ременными можно и нужно, в DAX допустимо также применять альтернативную технику
реализации логики условных выражений. Например, если функция IF возвращает число-
вое значение, а выражение во втором аргументе не вызывает ошибку времени выполне-
ния, когда первый аргумент возвращает TRUE, можно переписать такой код:
IF ( <condition>; <expression> )
следующим образом:
<expression> * <condition>
Получается, что мера Sales YOY(fast) в нашем примере может быть реализована так:
MEASURE Sales[Sales YOY (fast)] =
( [Sales Amount] - [Sales PY] )
* ( NOT ISBLANK ( [Sales Amount] ) && NOT ISBLANK ( [Sales PY] ) )
Итоговый план выполнения запроса будет включать всего 208 строк при сопостави-
мом быстродействии. В более сложных моделях данных подобное сокращение плана
выполнения запроса может приводить к ощутимому приросту производительности. При
этом стоит помнить, что разные версии движка могут выдавать разные результаты. При-
меняйте представленную выше технику, если хотите сделать еще один шаг в сторону
оптимизации. Но предварительно обязательно убедитесь, что это даст ощутимый при-
рост быстродействия, способный нивелировать появившиеся недостатки в плане легко-
сти восприятия кода.
Заключение
Из этой главы (да и из всей книги в целом) вы должны вынести важный урок,
заключающийся в необходимости анализировать все без исключения факто-
ры, оказывающие влияние на план выполнения запроса, при поиске узких
мест. Начинать оптимизацию всегда стоит с анализа процентов задействова-
ния движка формул и движка хранилища данных на вкладке Server Timings,
но при этом вы должны четко понимать, что стоит за этими цифрами. Инстру-
менты вроде DAX Studio и VertiPaq Analyzer помогут вам оценить ущерб от соз-
дания неоптимальных планов выполнения запросов и обнаружить истинные
причины дефицита производительности.
Добро пожаловать в мир DAX!
Предметный указатель
В
BLANK, 59
D
DAX Studio, 433, 673
DEFINE, 434
E
EVALUATE, 86,434
M
MDX, 38
MEASURE, 437
R
RETURN, 57,206
s
SOL Server Profiler, 676
V
VAR, 56, 206
X
xmSOL, 681
A
Автоматическая проверка
существования, 469
Автоматические дата и время
в Power BI, 250
Автоматические дата и время в Power
Pivot для Excel, 251
Агрегированные таблицы, 623
Аддитивная мера, 280
Анонимные таблицы, 49
772 Предметный указатель
в
Виды ошибок
наличие пустых или отсутствующих
значений,59
ошибка арифметических
операций, 58
ошибка преобразования, 57
Виртуальная связь, 524
Вычисляемая таблица, 85
Вычисляемые столбцы, 51
Вычисляемые физические связи, 514
Группа вычислений, 315
д
Движок формул, 595
Движок хранилища данных, 595
Движок DirectQuery, 595
Движок VertiPaq, 595
Двунаправленная
кросс-фильтрация, 536
Детализация данных, 305
И
Иерархии,381
родитель/потомок, 386
Информационные функции, 74
Итерационные функции, 69
К
Кеш данных, 596
Ключ таблицы, 29
Кодирование данных
на основе длин серий, 607
на основе значений, 604
при помощи хеш-таблиц, 605
Контекст вычисления, 106
контекст строки, 112
контекст фильтра, 108
Кратность, 534, 645
Кратность итератора, 220
Кросс-фильтрация, 354
л
Ленивое вычисление, 57, 214
Логические функции, 73
м
Математические функции, 75
Материализация, 620
поздняя, 621
ранняя, 621
Меры, 52
Модель данных, 28
Модификаторы функции
CALCULATE, 194
н
Неаддитивная мера, 280
О
Обработка ошибок, 57
Операторы
AddColumns, 669
ProjectionSpool, 671
Scan Vertipaq, 670
SingletonTable, 670
SpoolLookup, 670
Sum VertiPaq, 669
Операторы DAX, 48
П
Перегрузка операторов, 45
Переменные, 56, 206
выражений, 436
запросов, 436
область видимости
переменных, 210
План выполнения запроса, 667
логический, 669
физический, 670
Полуаддитивная мера, 280
Представления динамического
управления, 614
Преобразование контекста, 177
Привязка данных, 368
Проверочный запрос, 719
р
Расширенные таблицы, 478
Ролевые измерения, 259
с
Свойство Precedence, 334
Связь, 28
«многие ко многим», 540
«один к одному», 539
«один ко многим», 538
Сегментация
динамическая, 530
статическая ,517
Сегменты, 613
Секции, 614
Скользящая годовая сумма, 276
Составная модель данных, 533
Столбчатая база данных, 600
Строчное хранилище, 600
Таблица-мост, 540
Таблицы дат, 251
Табличные функции, 83
Текстовые функции, 76
Типы данных, 45
Тригонометрические функции, 76
Ф
Форматирование кода DAX, 65
Функции
агрегирования, 68, 71
для работы с датой и временем, 78
округления чисел, 75
отношений, 79
преобразования, 77
ADDCOLUMNS, 229,402
ADDMISSINGITEMS, 460
ALL, 90, 508
ALLCROSSFILTERED, 509
ALLEXCEPT, 92, 509
Предметный указатель 773
ALLNOBLANKROW, 99, 509
ALLSELECTED, 102,498, 509
AVERAGEX, 70
BLANK, 59
CALCULATE, 142
CALCULATETABLE, 142, 399
CallbackDatalD, 696
COMBINEVALUES, 516
CONCATENATEX, 101,226
CONTAINS, 425, 525
COUNT, 72
COUNTA, 72
COUNTBLANK, 72
COUNTROWS, 72
CROSSFILTER, 198
CROSSJOIN, 409
CURRENCY, 77
DATATABLE, 430
DATE, 77
DATEADD, 270,296
DATESBETWEEN, 277
DATESINPERIOD, 277
DATESMTD, 268
DATESQTD, 268
DATESYTD, 268
DATEVALUE, 78
DETAILROWS, 426
DISTINCT, 96
DISTINCTCOUNT, 72
DISTINCTCOUNTNOBLANK, 72
EARLIER, 123
EARLIEST, 124
EXCEPT, 417
FILTER, 37, 87
FILTERS, 357
FIRSTDATE, 303
FIRSTNONBLANK, 304
FORMAT, 78
GENERATE, 454
GENERATEALL, 457
GENERATESERIES, 431
GROUPBY, 461
HASONEVALUE, 100, 350
IF, 50
IFERROR, 61
IGNORE, 442
INT, 77
INTERSECT, 415
ISBLANK, 60
ISCROSSFILTERED, 354
ISERROR, 59
ISFILTERED, 354
ISINSCOPE, 383
ISNUMBER, 75
ISONORAFTER, 457
ISSELECTEDMEASURE, 339
ISSUBTOTAL, 440
KEEPFILTERS, 164
LASTDATE, 277, 282, 303
LASTNONBLANK, 283, 304
LOOKUPVALUE, 485, 516
NATURALINNERJOIN, 465
NATURALLEFTOUTERJOIN, 465
NEXTDAY, 277
PARALLELPERIOD, 270
PATH, 388
PATHITEM, 389
PATHLENGTH, 392
RANK.EQ, 242
RANKX, 235, 587
RELATED, 79
RELATEDTABLE, 80
ROLLUP, 440
ROLLUPADDISSUBTOTAL, 444
ROLLUPGROUP, 444
ROW, 429
SAMEPERIODLASTYEAR, 270
SAMPLE, 468
SELECTCOLUMNS, 229,427
SELECTEDMEASURE, 316
SELECTEDMEASURENAME, 340
SELECTEDVALUE, 101, 353
SUBSTITUTEWITHINDEX, 466
SUM, 69
SUMMARIZE, 405
SUMMARIZECOLUMNS, 36,442
SWITCH, 73
TIME, 77
TOPN, 448
TOPNSKIP, 461
TOTALMTD, 268
TOTALQTD, 268
TOTALYTD, 268
TREATAS, 369
774 Предметный указатель
TRUNC, 47
UNION, 411
USERELATIONSHIP, 195
VALUE, 77
VALUES, 94
Ц
Циклические зависимости, 191
э
Элемент вычисления, 315
Книги издательства «ДМК ПРЕСС»
можно купить оптом и в розницу
в книготорговой компании «Галактика»
(представляет интересы издательств
«ДМК ПРЕСС», «СОЛОН ПРЕСС», «КТК Галактика»).
Адрес: г. Москва, пр. Андропова, 38;
тел.: (499) 782-38-89, электронная почта: books@alians-kniga.ru.
При оформлении заказа следует указать адрес (полностью),
по которому должны быть высланы книги;
фамилию, имя и отчество получателя.
Желательно также указать свой телефон и электронный адрес.
Эти книги вы можете заказать и в интернет-магазине: www.a-planeta.ru.
Марко Руссо и Альберто Феррари
Подробное руководство по DAX:
бизнес-аналитика с Microsoft Power Bl,
SQL Server Analysis Services и Excel
Главный редактор Мовчан Д. А.
dmkpress@gmaiLcom
Перевод
Корректор
Верстка
Дизайн обложки
Гинько А. Ю.
Синяева Г. И.
Чаннова А. А
Мовчан А. Г.
Формат 70 х 100 1/16.
Гарнитура «РТ Serif». Печать офсетная.
Усл. печ. л. 63,05. Тираж 200 экз.
Отпечатано в ООО «Принт-М»
142300, Московская обл., Чехов, ул. Полиграфистов, 1
Веб-сайт издательства: www.dmkpress.com