Подробное руководство по DAX Бизнес-аналитика с Microsoft Power BI, SQL Server Analysis Services и Excel Примеры на сайте издательства www.dmkpress.com ISBN 978-5-97060-859-3 Интернетмагазин: www.dmkpress.com Оптовая продажа: КТК «Галактика» e mail: books@alians-kniga.ru www.дмк.рф 9 785970 608593 Подробное руководство по DAX Для опытных пользователей и профессионалов в сфере бизнес-аналитики, использующих в своей работе DAX и аналитические инструменты от Microsoft. Об авторах: Марко Руссо и Альберто Феррари – основатели сайта sqlbi.com, на котором они публикуют статьи по Microsoft Power BI, Power Pivot, DAX и SQL Server Analysis Services. Марко и Альберто регулярно выступают на крупнейших международных конференциях, включая TechEd, PASS Summit, SQLRally и SQLBits; проводят консультации и обучение в области бизнес-аналитики (BI) с использованием технологий от Microsoft. В числе написанных ими книг «Анализ данных при помощи Microsoft Power BI и Power Pivot для Excel» (вышла в издательстве ДМК Пресс), «Introducing Microsoft Power BI» и «Microsoft Excel 2013 Building Data Models with PowerPivot». Бизнес-аналитика с Microsoft Power BI, SQL Server Analysis Services и Excel Эта книга – наиболее полное руководство по языку DAX, применяемому в области бизнесаналитики, моделирования данных и анализа. Помимо теоретической информации она содержит примеры, которые можно запустить в бесплатной версии Power BI Desktop. Прочитав книгу, вы освоите передовые техники анализа данных при помощи DAX в Power BI, SQL Server и Excel, а также: • усвоите основные концепции DAX, включая вычисляемые столбцы, меры и группы вычислений; • научитесь эффективной работе с базовыми и продвинутыми табличными функциями; • поймете, как работают контексты вычисления и функции CALCULATE и CALCULATETABLE; • освоите вычисления, базирующиеся на временных периодах; • научитесь использовать группы и элементы вычислений; • изучите синтаксис переменных (VAR), который позволит вам писать более легкий для восприятия и поддержки код на DAX; • узнаете о необычных типах связей, включая связи «многие ко многим» и двунаправленную фильтрацию; • освоите продвинутые техники оптимизации и повышения производительности агрегаций; • научитесь оптимизировать модель данных для выполнения более эффективного сжатия информации; • узнаете, как можно измерить быстродействие запросов при помощи DAX Studio и ускорить свой код на языке DAX. Подробное руководство по DAX Бизнес-аналитика с Microsoft Power BI, SQL Server Analysis Services и Excel Марко Руссо и Альберто Феррари Марко Руссо и Альберто Феррари Подробное руководство по DAX: бизнес-аналитика с Microsoft Power BI, SQL Server Analysis Services и Excel The Definitive Guide to DAX: Business intelligence with Microsoft Power BI, SQL Server Analysis Services, and Excel Marco Russo and Alberto Ferrari Подробное руководство по DAX: бизнес-аналитика с Microsoft Power BI, SQL Server Analysis Services и Excel Марко Руссо и Альберто Феррари Москва, 2021 УДК 004.42DAX ББК 32.97 Р89 Р89 Руссо М., Феррари А. Подробное руководство по DAX: бизнес-аналитика с Microsoft Power BI, SQL Server Analysis Services и Excel / пер. с англ. А. Ю. Гинько. – М.: ДМК Пресс, 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 (англ.)Copyright © 2020 by Alberto Ferrari and Marco Russo ISBN 978-5-97060-859-3 (рус.)© Оформление, издание, перевод, ДМК Пресс, 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 Введение в контексты вычисления. ......................................105 Введение в контексты вычисления......................................................106 Знакомство с контекстом фильтра.................................................106 Знакомство с контекстом строки....................................................112 Тест на понимание контекстов вычисления......................................114 Использование функции SUM в вычисляемых столбцах..........114 Использование ссылок на столбцы в мерах.................................115 Использование контекста строки с итераторами. ...........................116 Вложенные контексты строки в разных таблицах......................117 Вложенные контексты строки в одной таблице..........................119 Использование функции EARLIER..................................................123 Функции FILTER, ALL и взаимодействие между контекстами......125 Работа с несколькими таблицами. .......................................................128 Контексты строки и связи.................................................................129 Контекст фильтра и связи.................................................................133 Использование функций 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 и GENERATEALL............................................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 Функция обратного вызова CallbackDataID. ................................696 Чтение запросов движка хранилища DirectQuery............................702 Анализ составных моделей данных...............................................703 12 Содержание Использование агрегатов в модели данных.................................704 Чтение планов выполнения запросов.................................................706 Заключение................................................................................................713 Глава 20 Оптимизация в DAX. ........................................................................715 Выбор стратегии оптимизации.............................................................716 Выделение выражения DAX для оптимизации. ..........................716 Создание проверочного запроса.....................................................719 Анализ времени выполнения запроса и информации из плана.................................................................................................723 Поиск узких мест в движке формул и движке хранилища данных...................................................................................................726 Внесение изменений и повторные запуски тестовых запросов................................................................................................727 Оптимизация узких мест в выражениях DAX. ..................................727 Оптимизация условий фильтрации...............................................728 Оптимизация преобразования контекста....................................732 Оптимизация условных выражений IF. ........................................739 Снижение влияния функции CallbackDataID на производительность.....................................................................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. Они работают с DAX с момента появления первой бета-версии Power Pivot в 2009 году, и за это время сайт sqlbi.com стал одним из главных поставщиков статей и обучающих материалов по DAX. Их семинары, как очные, так и в удаленном режиме, являются основным источником вдохновения и обучения для энтузиастов DAX. Марко и Альберто проводят консультации и обучение в области бизнес-аналитики (BI) с использованием технологий от Microsoft. За время своей практики они написали несколько книг и статей по Power BI, DAX и Ana­ ly­sis 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), старший руководитель проекта Благодарности Н аписание второго издания этой книги заняло у нас целый год – на три месяца больше, чем первого. Это было долгое и увлекательное путешествие вместе с самыми разными людьми из разных широт и часовых поясов, результатом которого стала эта книга. Мы хотели бы поблагодарить за помощь в ее написании очень многих людей, но понимаем, что всех перечислить просто не сможем. Так что просто скажем спасибо всем, кто так или иначе способствовал выпуску книги – возможно, даже не подразумевая об этом. Комментарии в блогах, посты на форумах, обсуждения по почте, общение на технических конференциях, анализ различных сценариев – все это было для нас очень полезно, и многие из ваших идей нашли отражение в данной книге. Также мы выражаем огромную признательность всем студентам наших курсов: обучая вас, мы развивались сами! И все же отдельных людей мы не можем не выделить особо за их заметный вклад в написание книги. Начать список персональных благодарностей мы хотим с Эдуарда Меломеда. Именно он вдохновил нас на написание книги. Если бы не страстная дискуссия с ним несколько лет назад, итогом которой стало содержание нашей первой книги по Power Pivot, написанное на салфетке, мы могли бы вовсе не отправиться в путешествие по миру DAX. Также мы очень признательны издательству Microsoft Press и его сотрудникам, внесшим весомый вклад в наш труд. Написание книги отнимает немало времени, но еще больше времени уходит на подготовительные исследования. Люди, которых мы называем «инсайдерами SSAS (SQL Server Analysis Services)», очень помогли нам в подготовке к путешествию. Кроме того, стоит особо отметить нескольких людей из Microsoft, уделивших нам свое время при описании важных концепций, касающихся Power BI и DAX. Это Мариус Думитру (Marius Dumitru), Джеффри Ванг (Jeffrey Wang), Акшай Мирчандани (Akshai Mirchandani), Кристиан Саковски (Krystian Sakowski) и Кристиан Петкулеску (Cristian Petculescu). Ваша помощь была неоценима, парни! Также мы хотим поблагодарить Амира Нетца (Amir Netz), Кристиана Уэйда (Christian Wade), Ашвини Шарма (Ashvini Sharma), Каспера Де Йонга (Kasper De Jonge) и T. K. Ананда (T. K. Anand) за многочисленные дискуссии касательно этого проекта. Эти люди помогли нам при выборе стратегического направления как в этой книге, так и в карьере в целом. Отдельные слова признательности хотелось бы сказать в адрес женщины, изрядно поработавшей над нашим английским. Клэр Коста (Claire Costa) тщательно вычитала исходный текст книги и привела его в порядок. Клэр, мы высоко ценим твою помощь! Спасибо! Последнюю персональную благодарность мы адресуем нашему техническому рецензенту Даниилу Маслюку (Daniil Maslyuk), проверившему все без Благодарности 17 исключения фрагменты кода, примеры и ссылки в книге. Он обнаружил все ошибки и опечатки, которые мы не заметили, а его комментарии всегда были по делу. Результат совместной работы превзошел все наши ожидания. И если в книге ошибок оказалось меньше, чем в исходном тексте, в этом заслуга Даниила. А оставшиеся опечатки – исключительно наша вина. Спасибо, ребята! Поддержка Если вам требуется дополнительная помощь или информация, вы можете обратиться по адресу: https://MicrosoftPressStore.com/Support. Отметим, что услуги по поддержке программного обеспечения Microsoft по этому адресу не оказываются. Если вам требуется помощь такого плана, пе­ рейди­те на сайт http://support.microsoft.com. Оставайтесь с нами Давайте продолжим общение! Заходите на наш Twitter: @MicrosoftPress. От издательства Отзывы и пожелания Мы всегда рады отзывам наших читателей. Расскажите нам, что вы думаете об этой книге – что понравилось или, может быть, не понравилось. Отзывы важны для нас, чтобы выпускать книги, которые будут для вас максимально полезны. Вы можете написать отзыв на нашем сайте www.dmkpress.com, зайдя на страницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также можно послать письмо главному редактору по адресу dmkpress@gmail.com; при этом укажите название книги в теме письма. Если вы являетесь экспертом в какой-либо области и заинтересованы в написании новой книги, заполните форму на нашем сайте http://dmkpress.com/ authors/publish_book/ или напишите в издательство: dmkpress@gmail.com. Список опечаток Хотя мы приняли все возможные меры для того, чтобы обеспечить высокое качество наших текстов, ошибки все равно случаются. Если вы найдете ошибку в одной из наших книг – возможно, ошибку в основном тексте или программном коде, – мы будем очень благодарны, если вы сообщите нам о ней. Сделав это, вы избавите других читателей от недопонимания и поможете нам улучшить последующие издания этой книги. Если вы найдете какие-либо ошибки в коде, пожалуйста, сообщите о них главному редактору по адресу dmkpress@gmail.com, и мы исправим это в следующих тиражах. Нарушение авторских прав Пиратство в интернете по-прежнему остается насущной проблемой. Издательство «ДМК Пресс» очень серьезно относится к вопросам защиты авторских прав и лицензирования. Если вы столкнетесь в интернете с незаконной публикацией какой-либо из наших книг, пожалуйста, пришлите нам ссылку на интернет-ресурс, чтобы мы могли применить санкции. Ссылку на подозрительные материалы можно прислать по адресу элект­ ронной почты dmkpress@gmail.com. Мы высоко ценим любую помощь по защите наших авторов, благодаря которой мы можем предоставлять вам качественные материалы. От издательства 19 Предисловие ко второму изданию К огда мы задумались о том, что пришло время обновить книгу, мы посчитали, что сделать это будет легко: в конце концов, в языке 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. Например, если вы захотите вспомнить, как работает функция ALLSELECTED, то можете открыть конкретный раздел и освежить память. Но обращаться к главам без их предварительного чтения мы не советуем – вы просто рискуете не до конца понять описываемую концепцию. Представляем вам описание глав этой книги: глава 1 содержит краткое введение в DAX с несколькими разделами, предназначенными для тех, кто уже знаком с другими языками, такими как SQL, MDX или язык формул Excel. В этой главе мы не представляем какие-то новые концепции, а описываем базовые отличия между DAX и другими языками программирования, которые может знать читатель; в главе 2 мы познакомим вас с языком DAX. Мы пройдемся по основным терминам вроде вычисляемых столбцов и мер, а также расскажем о функциях для перехвата ошибок в выражениях. Кроме того, здесь будут перечислены все основные функции языка; глава 3 будет посвящена основным табличным функциям. Многие функции DAX работают с таблицами и возвращают таблицы в качестве результата. Здесь мы опишем работу большинства табличных функций, а в главах 12 и 13 расскажем о более сложных функциях для работы с таблицами; в главе 4 мы впервые затронем тему контекстов вычисления. Данная концепция является основополагающей в DAX, так что эта глава и следующая, возможно, являются наиболее значимыми в этой книге; в главе 5 мы ограничимся всего двумя функциями – CALCULATE и CALCULATETABLE. Это наиболее важные функции в DAX, и к их изучению можно приступать только после усвоения концепции контекстов вычисления; глава 6 будет посвящена переменным. Мы используем переменные в примерах на протяжении всей книги, но именно в этой главе познакомим вас с их синтаксисом и объясним назначение. Вы сможете возвращаться к этой части книги, когда будете встречаться с переменными в последующих главах; в главе 7 мы обсудим сладкую парочку из итерационных функций и функции CALCULATE, союз которых поистине был заключен на небесах. Использование итерационных функций совместно с техникой преобразования контекста позволит вам извлечь максимум пользы из языка DAX. В этой главе мы продемонстрируем несколько примеров, позволяющих реализовать весь потенциал данной связки; в главе 8 мы подробно остановимся на функциях логики операций со временем. Нарастающие итоги с начала года и месяца, показатели предыдущих лет, недельные интервалы и нестандартные календари – все это будет рассмотрено в этой части книги; глава 9 будет посвящена относительно новой особенности языка DAX – группам вычислений. Это очень мощный инструмент моделирования данных. В данной главе мы рассмотрим создание и использование групп 24 Предисловие к первому изданию вычислений, познакомим вас с базовыми концепциями и представим несколько примеров; в главе 10 мы более подробно поговорим об особенностях использования контекста фильтра, привязке данных и других полезных средствах для расчета сложных формул; в главе 11 вы научитесь проводить вычисления над иерархиями и работать со структурами родитель/потомок в DAX; главы 12 и 13 посвящены продвинутым табличным функциям, полезным как при написании запросов, так и при проведении сложных вычислений; прочитав главу 14, вы продвинетесь на шаг вперед в понимании контекс­ тов вычисления, а заодно узнаете об использовании сложных функций ALLSELECTED и KEEPFILTERS и концепции расширенных таблиц (expanded tables). Это глава для опытных пользователей, раскрывающая секреты сложных выражений DAX; глава 15 посвящена управлению связями в DAX. Благодаря этому языку в модели данных можно создавать связи всех возможных типов. Здесь мы также приведем описание всех типов связей, которые допустимо использовать в моделях; в главе 16 мы приведем несколько примеров сложных расчетов с использованием DAX. Это будет последняя глава, посвященная непосредственно языку, и в ней мы расскажем о разных решениях и новых идеях; в главе 17 мы приведем детальное описание движка (engine) VertiPaq, являющегося самым распространенным движком хранилища данных (storage engine) в моделях с использованием DAX. Понимание особенностей движка позволит вам извлекать максимум потенциала из языка; в главе 18 мы воспользуемся знаниями, полученными в предыдущей главе, чтобы продемонстрировать возможные способы оптимизации на уровне модели данных. Вы узнаете, как снизить количество уникальных значений в столбце, выбрать столбцы для импорта и повысить эффективность системы за счет выбора правильных типов связей и снижения количества используемой памяти в DAX; в главе 19 вы научитесь читать планы выполнения запросов (query plan) и замерять производительность выражений на DAX при помощи DAX Studio и SQL Server Profiler; в главе 20 мы покажем вам несколько техник по оптимизации модели с использованием знаний, полученных в предыдущих главах. Мы продемонстрируем разные выражения на DAX, проанализируем их производительность, а затем представим оптимизированные варианты формул. Условные обозначения В этой книге приняты следующие условные обозначения: жирным помечен текст, который вводите вы; курсив используется для обозначения новых терминов, а также названия мер, вычисляемых столбцов, таблиц и баз данных; Предисловие к первому изданию 25 первые буквы в названиях диалоговых окон, их элементов, а также команд – прописные. Например, в диалоговом окне Save As... (Сохранить как…); названия вкладок на ленте даются ПРОПИСНЫМИ БУКВАМИ; комбинации нажимаемых клавиш на клавиатуре обозначаются знаком плюс (+) между названиями клавиш. Например, Ctrl+Alt+Delete означает, что вы должны одновременно нажать клавиши Ctrl, Alt и Delete. Сопутствующий контент Для развития ваших навыков и подкрепления их практикой мы снабдили книгу сопутствующим контентом, который можно скачать по ссылке: MicrosoftPressStore.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 BI», переходите к следующей главе, с которой, по сути, и начинается наше путешествие в мир DAX. Введение в модель данных Язык DAX предназначен для расчета бизнес-показателей посредством формул в модели данных. Некоторые читатели могут знать, что из себя представляет модель данных. Для остальных мы сделаем разъяснение. Глава 1 Что такое DAX? 27 Модель данных (data model) – это набор таблиц, объединенных связями. Все мы знаем, что такое таблица. Это перечисление строк, содержащих информацию, при этом каждая строка поделена на столбцы. Столбец, в свою очередь, характеризуется определенным типом данных и содержит единый фрагмент информации. Обычно мы называем строку в таблице записью. Таб­ личный способ хранения информации очень удобен в плане организации данных. По сути, таблица сама по себе является моделью данных, пусть и в своей простейшей форме. А значит, когда мы вводим на лист Excel текст и цифры, мы создаем модель данных. Если модель состоит из нескольких таблиц, вполне вероятно, что вам захочется связать их. Связь (relationship) представляет собой объединение двух таб­ лиц. Такие таблицы мы называем связанными (related). Графически связь двух таблиц обозначается линией между ними. На рис. 1.1 показан пример модели данных. Рис. 1.1 Модель данных, состоящая из шести таблиц Далее перечислим важные аспекты связей между таблицами: таблицы, объединенные связью, выполняют разные роли. Одна из них представляет сторону «один», а вторая – «многие», которые помечены на схеме данных символами «1» и «*» (звездочка) соответственно. Обратите внимание на связь между таблицами Product (Товары) и Product Subcategory (Подкатегории товаров) на рис. 1.1. Одной подкатегории может принадлежать несколько товаров, тогда как один товар может представлять только одну подкатегорию. Таким образом, таблица Product Subcategory являет собой сторону «один» в этой связи, а Product – сторону «многие»; существуют особые виды связей. Это связи «один к одному» (1:1) и слабые связи (weak relationships). В связи «один к одному» обе таблицы представ28 Глава 1 Что такое DAX? ляют собой сторону «один», тогда как в слабых связях они могут находиться на стороне «многие». Такие особые виды связей не слишком распространены, и мы подробно обсудим их в главе 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 была однонаправленной, результат был бы иным, и мы расскажем об этом в следующих разделах. Рис. 1.2 Отчет демонстрирует эффект фильтрации по нескольким таблицам Если модифицировать отчет, вынеся на строки цвет товаров (Color) и добавив в область значений количество дат (Count of Date), результат также поменяется. Вывод этого отчета можно видеть на рис. 1.3. Рис. 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. Рис. 1.4 Если активировать двунаправленную фильтрацию, таблица Date будет отфильтрована по столбцу Color Теперь в столбце отображается количество дней, когда был продан как минимум один товар выбранного цвета. На первый взгляд кажется, что стоит все связи в модели сделать двунаправленными, чтобы позволить фильтрам распространять свое действие во все стороны и доставать правильные данные. Как вы узнаете из этой книги, такой подход почти никогда не будет оправдан. Вы должны выбирать направление фильтрации для связей в зависимости от модели, с которой работаете. Если вы последуете нашим советам, то откажетесь от применения двунаправленной фильтрации там, где это возможно. DAX для пользователей Excel Велика вероятность, что вы знакомы с языком формул Excel, который немного напоминает DAX. В конце концов, корни DAX лежат в Power Pivot для Excel, Глава 1 Что такое DAX? 31 и разработчики сделали все, чтобы эти языки были похожими. Эти сходства облегчат вам переход на DAX. Но не стоит забывать и о различиях в этих языках. Ячейки против таблиц В Excel все вычисления производятся над ячейками, которые обладают координатами. Так что мы можем написать формулу вроде этой: = (A1 * 1.25) - B2 В DAX концепция ячеек с координатами просто отсутствует. Этот язык работает с таблицами и столбцами, а не с отдельными ячейками. Как следствие выражения DAX обращаются именно к таблицам и столбцам, что сказывается на синтаксисе языка. Однако концепция таблиц и столбцов не нова для Excel. Если выделить диапазон и воспользоваться пунктом Format as Table (Форматировать как таблицу), можно писать формулы в Excel, обращающиеся непосредственно к таблицам и столбцам. На рис. 1.5 в столбце SalesAmount вычисляется выражение, ссылающееся на столбцы в той же таб­лице, а не на ячейки в рабочей книге. Рис. 1.5 В формулах Excel можно ссылаться на столбцы таблицы В Excel можно обращаться к столбцам, используя следующий формат: [@ColumnName]. Здесь ColumnName – название столбца, а символ @ говорит о том, что необходимо взять значение из текущей строки. Синтаксис получился не самым интуитивно понятным, но мы обычно и не пишем такие выражения вручную. Они появляются автоматически при нажатии на ячейку: Excel сам заботится о вставке нужного кода. Таким образом, в Excel есть два разных вида вычислений. Можно использовать стандартное обращение к ячейкам – в этом случае формула для ячейки F4 будет выглядеть так: E4*D4. Или же применять ссылки на столбцы внутри таблицы. Это позволит использовать одинаковые выражения во всех ячейках 32 Глава 1 Что такое DAX? столбца, а 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. Рис. 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, и столбец CustomerKey является первичным ключом в 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 и Customers, объединение таблиц в запросе происходит автоматически. После этого функции 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] = "Europe" ); "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 для построения и обращения к многомерным моделям данных (Multidimensional models) Analysis Services. Если вы из их числа, приготовьтесь изучать абсолютно новый язык, поскольку у DAX и MDX не так много общего. Более того, некоторые концепции в DAX будут сильно напоминать вам MDX, но смысл их будет совершенно иным. 38 Глава 1 Что такое DAX? По опыту можем сказать, что путь от MDX к DAX наиболее тернист. Чтобы изучить DAX, вам придется забыть все, что вы знаете о MDX. Выкиньте из головы многомерные пространства (multidimensional spaces) и приготовьтесь к приобретению новых знаний с нуля. Многомерность против табличности MDX работает в многомерном пространстве, определенном моделью данных. Его форма зависит от измерений (dimensions) и иерархий (hierarchies), присутствующих в модели, и в свою очередь определяет систему координат многомерного пространства. Пересечения наборов элементов в разных измерениях определяют точки в многомерном пространстве. Может понадобиться немало времени, чтобы понять, что элемент [All] любой иерархии атрибута – это не более чем точка в многомерном пространстве. В 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? Более существенным отличием 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 BI Если вы пропустили предыдущие разделы и сразу оказались здесь, что ж, приветствуем! Язык DAX является родным для Power BI. И если у вас нет опыта работы с Excel, SQL или MDX, Power BI станет для вас первой средой, в которой вы Глава 1 Что такое DAX? 41 сможете изучать DAX. В отсутствие навыков построения моделей данных при помощи других инструментов вам будет приятно узнать, что Power BI является мощнейшим средством анализа и моделирования, а 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 Formatter, о котором мы расскажем позже в этой главе. 44 Глава 2 Знакомство с DAX Типы данных DAX DAX может выполнять вычисления над данными семи разных типов. С течением времени Microsoft вводила разные названия для одних и тех же типов данных (data type), что привело к некоторой неразберихе. В табл. 2.1 показаны различные имена, под которыми можно встретить типы данных в DAX. ТАБЛИЦА 2.1 Типы данных Тип данных в Power Pivot Тип данных Тип данных и Analysis в DAX в Power BI Services Integer Whole Number Whole Number Decimal Decimal Number Decimal Number Соответствующий общепринятый тип данных (например, в SQL Server) Integer/INT Floating point / DOUBLE Тип данных объектной модели Tabular (Tabular Object Model – TOM) int64 double Currency Fixed Decimal Number Currency Currency/MONEY Decimal DateTime DateTime, Date, Time True/False Text – Binary Date Date/DATETIME DateTime True/False Text – Binary Boolean/BIT String/NVARCHAR(MAX) – Blob/VARBINARY(MAX) Boolean String Variant binary Boolean String Variant 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” Рис. 2.1 Прибавление целого числа к дате приводит к образованию новой даты, отстающей от начальной на заданное количество дней Таким образом, тип результата в DAX зависит от оператора, а не от исходных столбцов, значения которых приводятся к нужному типу автоматически согласно требованиям выбранного оператора. И хотя такое поведение анализатора внешне выглядит удобным, далее в этой главе вы увидите, к каким ошибкам может приводить автоматическое приведение типов. Также стоит отметить, что не все операторы поддерживают подобное поведение. Например, операторы сравнения не могут сравнивать строки с числами. Получается, что складывать строки с числами можно, а сравнивать – нет. Более подробную информацию по типам данных можно получить по ссылке: https://docs.microsoft. com/en-us/power-bi/desktop-data-types. С учетом сложности правил мы бы посоветовали вам вовсе избегать автоматического приведения типов данных. Если вам потребуется воспользоваться приведением типов, лучше контролировать этот процесс и указывать все явно. Например, предыдущий пример можно переписать так: = VALUE ( "5" ) + VALUE ( "4" ) Тем, кто привык работать с 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. Внутренне они фактически являются отражением частей типа DateTime. Типы 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, а FALSE как 0. Такая нотация иногда оказывается полезной – например, для сортировки, поскольку TRUE > FALSE. String Все строки в DAX хранятся в кодировке Unicode, в которой каждый символ занимает 16 бит. По умолчанию операция сравнения между строками в DAX не чувствительна к регистру, так что строки «Power BI» и «POWER BI» будут считаться идентичными. 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 Операторы Тип оператора Скобки Символ Использование () Порядок предшествования операций и группировка аргументов Арифметические + Сложение Вычитание * Умножение / Деление Сравнение = Равно <> Не равно > Больше >= Больше или равно < Меньше <= Меньше или равно Строковая & Конкатенация строк конкатенация Логические && Логическое И между двумя || булевыми выражениями IN Логическое ИЛИ между двумя NOT булевыми выражениями Нахождение элемента в списке Логическое отрицание Пример (5 + 2) * 3 4+2 5–3 4*2 4/2 [CountryRegion] = "USA" [CountryRegion] <> "USA" [Quantity] > 0 [Quantity] >= 100 [Quantity] < 0 [Quantity] <= 100 "Value is" & [Amount] [CountryRegion] = "USA" && [Quantity]>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 приведет все значения к обобщенному типу данных, подходящему для всех строк в столбце. { ( "A"; 10; 1,5; DATE ( 2017; 1; 1 ); CURRENCY ( 199,99 ); TRUE ); ( "B"; 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 ( Sales[Quantity] > 1; "MULTI"; "SINGLE" ) У функции IF три параметра, при этом обязательными из них являются только первые два. Третий опциональный и по умолчанию принимает значение BLANK. Взгляните на следующий код: IF ( Sales[Quantity] > 1; Sales[Quantity] ) 50 Глава 2 Знакомство с DAX Он абсолютно равнозначен своей полной версии: IF ( Sales[Quantity] > 1; Sales[Quantity]; 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. Рис. 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 Знакомство с DAX А что произойдет, если вы захотите увидеть валовую прибыль в процентном отношении к сумме продаж? Вы могли бы создать для этого вычисляемый столбец со следующей формулой: Sales[GrossMarginPct] = Sales[GrossMargin] / Sales[SalesAmount] Формула вычисляет правильные значения по строкам, что видно по рис. 2.3, но при этом итог по столбцу будет неверным. Рис. 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 ли, что для создания меры мы использовали знак := вместо обычного =. Таким стандартом мы будем пользоваться на протяжении всей книги, и это позволит вам отличать в коде вычисляемые столбцы от мер. Рис. 2.4 Смена функции агрегирования на AVERAGE не дала нужного результата После объявления GrossMarginPct в качестве меры вы можете построить корректный отчет, показанный на рис. 2.5. Рис. 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 7/0 0/0 (10 / 0) / (7 / 0) Результат Infinity Infinity NaN NaN Важно заметить, что значения Infinity и NaN не являются ошибками, это специальные значения в DAX. Фактически при делении числа на Infinity ошибка не генерируется. Вместо этого возвращается ноль: 58 Глава 2 Знакомство с DAX 9954 / ( 7 / 0 ) = 0 За исключением этой особой ситуации, DAX будет возвращать арифметическую ошибку при вызове функции с недопустимым параметром, например при попытке взять квадратный корень из отрицательного числа: SQRT ( -1 ) = Аргумент или функция "SQRT" имеет неправильный тип данных, либо результат имеет слишком большой или слишком маленький размер. При обнаружении подобной ошибки DAX прекратит дальнейшее вычисление выражения и сгенерирует ошибку. Для проверки того, вернуло ли выражение ошибку, можно воспользоваться функцией ISERROR. Далее в этой главе мы покажем такой сценарий. Стоит отметить, что специальные значения вроде NaN в некоторых инструментах, например в 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 BLANK () = "" -- Всегда вернет TRUE -- Всегда вернет TRUE Таким образом, если в столбцах Sales[DiscountPerc] или Sales[Clerk] будут содержаться пустые значения, следующие условия вернут TRUE даже при сравнении с 0 и пустой строкой: Sales[DiscountPerc] = 0 -- Вернет TRUE, если DiscountPerc либо BLANK, либо 0 Sales[Clerk] = "" -- Вернет TRUE, если Clerk либо BLANK, либо "" В таких случаях можно использовать функцию ISBLANK для проверки значения на пустоту: ISBLANK ( Sales[DiscountPerc] ) -- Вернет TRUE, только если DiscountPerc – BLANK ISBLANK ( Sales[Clerk] ) -- Вернет TRUE, только если Clerk – BLANK Действие функции BLANK в арифметических и логических операциях в выражениях DAX показано в следующих примерах: BLANK () + 10 * BLANK BLANK () / BLANK () / BLANK () = BLANK () () = BLANK () 3 = BLANK () BLANK () = BLANK () Однако функция BLANK оказывает влияние на итоговый результат не во всех формулах в DAX. Некоторые вычисления игнорируют пустые значения. Вмес­ то этого возвращаемое значение зависит от других величин в формуле. Примерами таких операций могут быть сложение, вычитание, деление на BLANK и логические операции с участием BLANK. Следующие примеры показывают поведение некоторых операций с BLANK: BLANK () − 10 = −10 18 + BLANK () = 18 4 / BLANK () = Infinity 0 / BLANK () = NaN BLANK () || BLANK () = FALSE BLANK () && BLANK () = FALSE ( BLANK () = BLANK () ) = TRUE ( BLANK () = TRUE ) = FALSE ( BLANK () = FALSE ) = TRUE ( BLANK () = 0 ) = TRUE ( BLANK () = "" ) = TRUE ISBLANK ( BLANK() ) = TRUE FALSE || BLANK () = FALSE 60 Глава 2 Знакомство с 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 следует использовать в случаях, когда из выражения возвращается другое значение – не то, которое проверялось на ошибки. Кто-то может вовсе не проверять выражение на ошибки, а вместо этого тестировать параметры на допустимость значений перед их обработкой. Например, в случае с функцией SQRT, вычисляющей квадратный корень из выражения, можно было предварительно проверить, является ли ее параметр положительным числом: = 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 ) -- Вернет 0 VAR WrongValue = SQRT ( -1 ) RETURN IFERROR ( WrongValue; 0 ) -- Ошибка возникает здесь, так что результатом -- всегда будет ошибка -- Эта строка никогда не будет выполнена IFERROR ( VAR WrongValue = SQRT ( -1 ) RETURN WrongValue; 0 ) -- Разные результаты в зависимости от версии -- IFERROR сгенерирует ошибку в версии 2017 -- IFERROR вернет 0 в версиях до 2016 Ошибка возникает в момент вычисления переменной WrongValue, так что движок никогда не вызовет функцию IFERROR во втором примере. А в третьем фрагменте результат зависит от версии продукта. При перехвате ошибок нужно с особой внимательностью относиться к переменным. Избегайте использования функций перехвата ошибок Несмотря на то что тему оптимизации кода мы будем отдельно обсуждать далее в этой книге, вам уже сейчас полезно будет узнать, что функции перехвата ошибок способны негативным образом сказаться на эффективности выражений. И проблема не в том, что эти функции медленные сами по себе. Просто движок DAX не может использовать оптимальный план выполнения запроса в случае возникновения ошибки. В большинстве случаев предварительная проверка операндов на допустимость значений будет лучшим решением в сравнении с использованием функций DAX для обработки ошибок. Например, вместо такого фрагмента кода: IFERROR ( SQRT ( Test[Omega] ); BLANK () ) лучше будет использовать такой вариант: IF ( Test[Omega] >= 0; SQRT ( Test[Omega] ); BLANK () ) Глава 2 Знакомство с DAX 63 Второй вариант не нуждается в перехвате ошибок, а значит, будет выполняться быст­ рее первого. Это общее правило. Более подробно об оптимизации кода мы будем говорить в главе 19. Еще одним поводом отказаться от использования функции IFERROR является то, что она не умеет перехватывать ошибки на более глубоком уровне вложенности. Например, в следующем фрагменте кода осуществляется перехват ошибок в столбце Table[Amount] на случай содержания в ней нечисловых значений. Как мы отмечали выше, это довольно дорогостоящая операция, поскольку она выполняется для каждой строки в таблице. 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 PreviousDates = DATESBETWEEN ( BalanceDate[Date]; BLANK (); MAX ( BalanceDate[Date] ) ) 66 Глава 2 Знакомство с DAX VAR LastDateWithBalance = LASTNONBLANK ( PreviousDates; CALCULATE ( COUNTROWS ( Balances ) ) ) RETURN CALCULATE ( SUM ( Balances[Balance] ); LastDateWithBalance ) ); BLANK () ) DAXFormatter.com Мы создали сайт, посвященный форматированию кода на DAX. Изначально мы сделали его для себя, чтобы не тратить драгоценное время на форматирование каждой формулы в исходном тексте. После того как сайт заработал, мы решили показать его всем, кто так же, как и мы, не желает форматировать текст вручную. Одновременно с этим мы популяризировали свои принципы и подходы к форматированию кода. Посетить наш сайт вы можете по адресу www.daxformatter.com. Интерфейс сайта предельно прост – вставляйте свой текст на DAX и жмите кнопку FORMAT. Страница перезагрузится, и перед вами будет отформатированный код, который вы сможете перенести обратно в свой инструмент. Вот краткий перечень правил, которых мы придерживаемся при форматировании кода DAX: всегда отделяйте названия функций вроде IF, SUMX и CALCULATE от других элементов кода пробелом и пишите их прописными буквами; ссылайтесь на столбцы таблиц следующим образом: TableName[Co­lumn­ Name] – без пробела между названием таблицы и открывающей квадратной скобкой. Не опускайте наименование таблицы; названия мер пишите без указания названия таблицы: [MeasureName]; всегда ставьте пробелы после точек с запятыми, а не перед ними; если формула прекрасно укладывается в одну строку, не используйте никаких правил; если формула не помещается в строку, следуйте таким рекомендациям: –– название функции с открывающей скобкой выносите на отдельную строку; –– каждый параметр функции пишите на отдельной строке с дополнительным отступом из четырех пробелов и завершающей точкой с запятой; –– закрывающую скобку размещайте непосредственно под названием соответствующей ей функции. Глава 2 Знакомство с DAX 67 Это базовые правила. С полным списком принципов форматирования кода можно ознакомиться по адресу: http://sql.bi/daxrules. Если вы решите, что вам подходят другие правила форматирования исходного текста, используйте их. Основная цель этого действия состоит в облегчении чтения кода, так что вы вольны выбирать свой стиль. Главное, чтобы форматирование позволяло максимально быстро обнаруживать ошибки в коде, – в этом его основное предназначение. К примеру, если бы в изначально показанном коде без форматирования движок выдал ошибку, связанную с отсутствием закрывающей скобки, вам было бы очень непросто понять, где именно нужно ее поставить. В отформатированном тексте, напротив, хорошо видны соответствия между функциями и их скобками. Помощь при форматировании кода на DAX Форматирование кода на DAX – задача непростая, поскольку обычно им приходится заниматься в небольшом окошке и с текстом маленького размера. В зависимости от версии инструменты Power BI, Excel и Visual Studio предлагают различные средства для написания кода на DAX. Следующие советы могут помочь вам при работе с текстом в этих редакторах: чтобы увеличить шрифт, покрутите колесо мыши с зажатой клавишей Ctrl – это облегчит чтение; переход на новую строку осуществляется одновременным нажатием Shift+Enter; если вам не нравится программировать непосредственно в редакторе, вы можете писать исходный текст в других программах, таких как Блокнот или DAX Studio, а затем переносить его в редактор. При взгляде на код DAX бывает непросто сразу понять, что перед вами: вычисляемый столбец или мера. В наших статьях и книгах мы используем обычный знак равенства (=) для создания вычисляемых столбцов и знак присваивания (:=) для определения мер: CalcCol = SUM ( Sales[SalesAmount] ) -- это вычисляемый столбец Store[CalcCol] = SUM ( Sales[SalesAmount] ) -- это вычисляемый столбец -- в таблице Store CalcMsr := SUM ( Sales[SalesAmount] ) -- а это мера Наконец, мы советуем при объявлении вычисляемых столбцов и мер всегда указывать название соответствующей таблицы для вычисляемых столбцов и никогда – для мер. Этого правила мы будем придерживаться во всех примерах данной книги. Введение в агрегаторы и итераторы Почти во всех моделях данных есть необходимость оперировать агрегированными значениями. DAX предлагает сразу несколько функций, агрегирующих значения в столбцах таблицы и возвращающих скалярный результат. Мы называем эту группу функциями агрегирования (aggregation functions). Например, следующая мера вычисляет сумму по всему столбцу SalesAmount в таблице Sales: Sales := SUM ( Sales[SalesAmount] ) 68 Глава 2 Знакомство с DAX Функция SUM агрегирует все строки в таблице, если используется в вычисляемом столбце. При использовании в мере в расчет берутся только строки, проходящие через фильтры по срезам, строкам и столбцам в отчете. В DAX много агрегирующих функций, в числе которых SUM, AVERAGE, MIN, MAX и STDEV, и их поведение отличается лишь способом агрегирования: SUM возвращает сумму значений, MIN – минимальное число и т. д. Почти все эти функции работают исключительно с числовыми значениями и датами, и лишь функции MIN и MAX способны оперировать со строками. Более того, DAX никогда не учитывает при агрегировании пустые ячейки, и такое его поведение отличается от Excel (подробнее об этом мы расскажем далее в данной главе). Примечание Функции MIN и MAX выделяются своим поведением: если они применяются с двумя параметрами, то возвращают из них минимальное или максимальное значение соответственно. Таким образом, MIN (1, 2) вернет 1, а MAX (1, 2) – 2. Подобное поведение полезно, когда нужно сравнить результаты сложных выражений, поскольку позволяет избежать многократного их повторения в коде с использованием функции IF. Все описанные выше функции агрегирования работают исключительно со столбцами. Таким образом, агрегирование применяется только к значениям одного столбца. Но есть функции, способные оперировать, целыми выраже­ ниями, а не со столбцами. Из-за принципа своей работы они названы итерационными функциями, или просто итераторами (iterators). Эти функции очень полезны, особенно когда вам необходимо провести вычисления с использованием столбцов из связанных таблиц или снизить количество вычисляемых столбцов в таблице. Итераторы принимают как минимум два параметра: таблицу, в которой будет проводиться сканирование, и выражение, которое будет вычисляться для каждой строки в таблице. После выполнения сканирования таблицы с вычислением указанного выражения для каждой строки функция приступает к агрегированию результатов в соответствии со своей семантикой. Например, мы можем рассчитать количество дней, требуемых для доставки товаров по заказам в вычисляемом столбце с названием DaysToDeliver и построить отчет по полученным данным. Его результаты представлены на рис. 2.6. Заметьте, что в итоговом значении произведено суммирование дней, что не несет никакой пользы в подобных расчетах: Sales[DaysToDeliver] = INT ( Sales[Delivery Date] - Sales[Order Date] ) Итог с усредненным значением мы можем получить в мере с названием AvgDelivery , показывающей количество дней доставки для каждого заказа и среднее значение в строке итогов: AvgDelivery := AVERAGE ( Sales[DaysToDeliver] ) Результат работы этой меры показан на рис. 2.7. Мера вычисляет среднее значение, применяя агрегирование к вычисляемому столбцу. Но можно избежать этого промежуточного шага создания вычис­ ляемого столбца при помощи применения итератора, что позволит сэкономить ресурсы. И хотя функция AVERAGE не умеет агрегировать выражения, Глава 2 Знакомство с DAX 69 с этим прекрасно справляется ее коллега AVERAGEX. При помощи нее мы можем пройти по всем строкам в таблице, вычислить необходимое значение для каждой строки и агрегировать полученные результаты. Вот код для меры, позволяющей ограничиться одним шагом вместо двух: AvgDelivery := AVERAGEX ( Sales, INT ( Sales[Delivery Date] - Sales[Order Date] ) ) Рис. 2.6 В итоговой строке мы видим сумму дней, хотя могли бы пожелать увидеть среднее значение Рис. 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 и MAX. Вы узнали, что функции SUM и AVERAGE, например, работают только со столбцами числового типа. DAX также предлагает альтернативный синтаксис для функций агрегирования, унаследованных из Excel, с добавлением окончания A к имени функции, чтобы они выглядели и вели себя как в Excel. Однако эти функции могут оказаться полезными только применительно к столбцам с типом Boolean, в которых значение TRUE расценивается как 1, а FALSE – как 0. Применительно к текстовым столбцам эти функции будут давать 0. Так что использование функции MAXA со столбцом текстового типа вне зависимости от его содержимого всегда выдаст 0. Более того, DAX никогда не учитывает в расчетах агрегации пустые ячейки. И хотя эти функции могут применяться к нечисловым столбцам без Глава 2 Знакомство с DAX 71 возврата ошибки, результат их будет не так полезен, поскольку в DAX отсутствует автоматическое приведение текстовых столбцов к числовым. Это функции AVERAGEA, COUNTA, MINA и MAXA. Мы бы советовали не использовать функции, поведение которых в будущем сохранится для обратной совместимости с существующим кодом. Примечание Несмотря на то что названия этих функций совпадают со статистическими функциями, в DAX и Excel они используются по-разному, поскольку в DAX каждый столбец хранит данные строго одного типа, и именно этим типом определяется поведение агрегирующей функции. В Excel допустимо размещать в одном столбце разнородную информацию, тогда как в DAX это невозможно, в нем столбцы строго типизированы. Если столбцу в Power BI назначен числовой тип, все значения в нем могут быть либо числовыми, либо пустыми. Если столбец имеет текстовый тип, все эти функции (кроме COUNTA) будут возвращать для него 0, даже если текст может быть переведен в число. В то же время в Excel оценка значений на их принадлежность числовому типу производится от ячейки к ячейке. По этой причине такие функции будут бесполезны применительно к текстовым столбцам. В DAX только функции MIN и MAX поддерживают работу с текстовыми значениями. Функции, которые вы изучили ранее, полезны для выполнения агрегирования значений. Но иногда вам может потребоваться просто посчитать значения, а не агрегировать их. Для этих целей DAX представляет сразу несколько функций: COUNT оперирует со всеми типами данных, за исключением Boolean; COUNTA работает со столбцами всех типов; COUNTBLANK возвращает количество пустых ячеек (BLANK или пустые строки) в столбце; COUNTROWS подсчитывает количество строк в таблице; DISTINCTCOUNT возвращает количество уникальных значений в столбце, включая пустые значения, если они есть; DISTINCTCOUNTNOBLANK возвращает количество уникальных значений в столбце, исключая пустые значения. COUNT и COUNTA – почти идентичные функции в DAX. Они возвращают количество непустых значений в столбце вне зависимости от их типа. Эти функции унаследованы от Excel, где COUNTA подсчитывает значения всех типов данных, включая текст, тогда как COUNT работает только с числовыми значениями. Если нужно подсчитать количество пустых значений в столбце, можно воспользоваться функцией COUNTBLANK, которая наравне учитывает BLANK и пустые значения. Наконец, для подсчета количества строк в таблице сущест­ вует функция COUNTROWS, принимающая в качестве параметра не столбец, а таблицу. Последние две функции из этого раздела – DISTINCTCOUNT и DISTINCTCOUNTNOBLANK – очень полезны, поскольку делают ровно то, что и должны, исходя из названий, – считают количество уникальных значений в столбце, который принимают в качестве единственного параметра. Разница между ними заключается в том, что функция DISTINCTCOUNT считает BLANK как одно из значений, тогда как DISTINCTCOUNTNOBLANK игнорирует такие значения. 72 Глава 2 Знакомство с DAX Примечание Функция DISTINCTCOUNT появилась в DAX в версии 2012 года. До этого момента для подсчета уникальных значений в столбце мы вынуждены были пользоваться конструкцией COUNTROWS ( DISTINCT ( table[column] ) ). Конечно, использовать одну функцию DISTINCTCOUNT для этого лучше и проще. Функция DISTINCTCOUNTNOBLANK появилась только в 2019 году, привнеся в DAX простую семантику выражения COUNT DISTINCT из SQL без необходимости написания длинных выражений. Логические функции Иногда нам необходимо встроить логическое условие в выражение – например, для осуществления различных вычислений в зависимости от значения в столбце или перехвата ошибки. В этих случаях нам помогут логические функции. В разделе, посвященном обработке ошибок, мы уже упомянули две важнейшие функции из этой группы: 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, M, 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 Знакомство с 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, ISLOGICAL, 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/A», проверка не пройдет. Если же мы попытаемся использовать функцию ISNUMBER для аналогичной проверки, как в примере ниже, результат всегда будет FALSE: Sales[IsPriceCorrect] = ISNUMBER ( Sales[Price] ) В данном случае функция ISNUMBER всегда будет возвращать FALSE, поскольку, согласно определению модели данных, столбец Price содержит текстовую информацию вне зависимости от того, что конкретно введено в той или иной строке. Математические функции Набор математических функций, доступных в DAX, схож с аналогичным набором в Excel – с похожим синтаксисом и поведением. К самым распространенным математическим функциям можно отнести следующие: ABS, EXP, FACT, LN, LOG, LOG10, MOD, PI, POWER, QUOTIENT, SIGN и SQRT. Для генерации случайных чисел в DAX применяются функции RAND и RANDBETWEEN. Используя функции EVEN и ODD, можно проверить числа. Функции GCD и LCM полезны для вычисления наибольшего общего делителя и наименьшего общего кратного двух чисел соответственно. Функция QUOTIENT возвращает целую часть результата деления двух чисел. Также стоит упомянуть несколько функций округления чисел. Фактически вы можете самыми разными способами добиться одного и того же результата. Внимательно рассмотрите формулы следующих столбцов с результатами вычислений, представленными на рис. 2.8: FLOOR = FLOOR ( Tests[Value]; 0,01 ) TRUNC = TRUNC ( Tests[Value]; 2 ) Глава 2 Знакомство с 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 ) Рис. 2.8 Результаты использования различных функций округления Функции FLOOR, TRUNC и ROUNDDOWN похожи, за исключением способа задания количества знаков округления. Функции CEILING и ROUNDUP дают одинаковые результаты. Различия можно заметить в выводе функций MROUND и ROUND. Тригонометрические функции DAX предлагает богатый выбор тригонометрических функций, среди которых можно отметить COS, COSH, COT, COTH, SIN, SINH, TAN и TANH. Префикс в виде буквы A приведет к вычислению обратных тригонометрических функций: арккосинуса, арксинуса и т. д. Мы не будем подробно останавливаться на этих функциях, поскольку их действие весьма прозрачно. Функции 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 Рис. 2.9 Извлечение имени и фамилии посредством текстовых функций Для начала необходимо получить позиции запятых в исходном тексте. После этого мы используем полученную информацию для извлечения нужных составляющих из текста. Формула вычисляемого столбца SimpleConversion может вернуть неправильный результат, если в строке меньше двух запятых. К тому же она выдаст ошибку, если запятых нет вовсе. Вторая формула – для вычисляемого столбца FirstLastName – учитывает эти нюансы и выводит правильный результат в случае недостатка запятых. People[Comma1] = IFERROR ( FIND ( ","; People[Name] ); BLANK ( ) ) People[Comma2] = IFERROR ( FIND ( " ,"; People[Name]; People[Comma1] + 1 ); BLANK ( ) ) People[SimpleConversion] = MID ( People[Name]; People[Comma2] + 1; LEN ( People[Name] ) ) & " " & LEFT ( People[Name]; People[Comma1] - 1 ) People[FirstLastName] = TRIM ( MID ( People[Name]; IF ( ISNUMBER ( People[Comma2] ); People[Comma2]; People[Comma1] ) + 1; LEN ( People[Name] ) ) ) & IF ( ISNUMBER ( People[Comma1] ); " " & LEFT ( People[Name]; People[Comma1] - 1 ); "" ) Как видите, формула для вычисляемого столбца FirstLastName получилась довольно длинной, но нам пришлось пойти на такие ухищрения, чтобы избежать возникновения ошибок, которые распространились бы на весь столбец. Функции преобразования Ранее вы усвоили, что DAX осуществляет автоматическое преобразование типов данных под нужды конкретного оператора. Несмотря на это, в языке также есть несколько полезных функций, позволяющих преобразовывать типы данных явно. Функция CURRENCY, к примеру, предпринимает попытку преобразовать аргумент к типу Currency, тогда как INT – к целочисленному типу. Функции DATE и TIME принимают в качестве параметра дату и время соответственно и возвращают корректное значение типа DateTime. Функция VALUE преобразовыГлава 2 Знакомство с DAX 77 вает текстовое значение в числовое, а FORMAT принимает в качестве первого параметра число, а в качестве второго – текстовый формат и выполняет соответствующее преобразование. Часто функции FORMAT и DateTime применяются совместно. Например, следующий пример возвращает строку «2019 янв 12»: = FORMAT ( DATE ( 2019; 01; 12 ); "yyyy mmm dd" ) Обратная операция преобразования строки в тип DateTime выполняется при помощи функции DATEVALUE. DATEVALUE с датами в разных форматах Функция DATEVALUE характеризуется разным поведением в зависимости от формата. Согласно европейскому стандарту даты записываются в виде «dd/mm/yy», тогда как американский формат предписывает указывать сначала месяц: «mm/dd/yy». Таким образом, дата 28 февраля будет представлена по-разному в двух форматах. Если вы передадите в функцию DATEVALUE строку, которую невозможно будет преобразовать в корректную дату с использованием региональных настроек по умолчанию, вместо того чтобы выдать ошибку, DAX попытается поменять местами день и месяц. Функция DATEVALUE также поддерживает недвусмысленный формат даты в виде «yyyy-mmdd». Например, следующие три выражения вернут 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]; "mmmm" ) 'Date'[MonthNumber] = MONTH ( Calendar[Date] ) 'Date'[Year] = YEAR ( Calendar[Date] ) Рис. 2.10 Извлечение составляющих частей даты при помощи специальных функций Функции отношений В DAX есть две полезные функции, которые позволят вам осуществлять навигацию по связям внутри модели данных. Это функции RELATED и RELA­TEDTABLE. Вы уже знаете, что вычисляемые столбцы могут ссылаться на значения других столбцов в таблице, в которой они определены. Таким образом, вычисляемый столбец, созданный в таблице 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] ) Рис. 2.11 Таблица Sales опосредованно связана с таблицей Product Category Функция RELATED обеспечивает доступ по связи со стороны «многие» к стороне «один», поскольку в этом случае у нас будет максимум одна целевая строка. Если соответствующих строк в связанной таблице не будет, функция RELATED вернет значение BLANK. Если вам нужно обратиться по связи со стороны «один» к стороне «многие», функция RELATED вам не поможет, поскольку в этом случае результатом может быть сразу несколько строк. Здесь необходимо использовать функцию RELATEDTABLE. Результатом выполнения этой функции будет таблица, содержащая все связанные строки по запросу, соответствующие выбранной строке. Например, если вас интересует, сколько товаров содержится в каждой категории, вы можете создать вычисляемый столбец в таблице Product Category со следующей формулой: 'Product Category'[NumOfProducts] = COUNTROWS ( RELATEDTABLE ( Product ) ) Как видно по рис. 2.12, в этом столбце будет отображено количество товаров по каждой категории. Рис. 2.12 Количество товаров по категориям можно посчитать функцией RELATEDTABLE 80 Глава 2 Знакомство с DAX Как и RELATED, функция RELATEDTABLE может проходить через целую цепочку связей, всегда следуя от стороны «один» к стороне «многие». Часто эта функция используется вместе с итераторами. Например, если нам нужно для каждой категории перемножить количество на цену и просуммировать результаты, можно написать следующую формулу в вычисляемом столбце: 'Product Category'[CategorySales] = SUMX ( RELATEDTABLE ( Sales ); Sales[Quantity] * Sales[Net Price] ) Результат этого вычисления показан на рис. 2.13. Рис. 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 MultipleItemSales = FILTER ( Sales; Sales[Quantity] > 1 ) RETURN SUMX ( MultipleItemSales; Sales[Quantity] * Sales[Unit Price] ) В переменной MultipleItemSales будет храниться целая таблица, поскольку ей присваивается результат выполнения табличной функции. Мы настоятельно советуем вам использовать переменные всегда, когда это возможно, ведь они существенно облегчают чтение кода. К тому же, просто присваивая значения переменным, вы одновременно создаете документацию к своему коду. Также в вычисляемом столбце или внутри итерации вы можете использовать функцию RELATEDTABLE для доступа к строкам связанной таблицы. Например, в следующем вычисляемом столбце в таблице Product мы рассчитаем сумму продаж по соответствующему товару: 84 Глава 3 Использование основных табличных функций '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/CALCULATETABLE и FILTER отличается. В следующем разделе мы познакомимся с поведением функции FILTER. Описание функций CALCULATE и CALCULATETABLE будет дано в главе 5. Как правило, мы не можем использовать результат табличной функции в качестве значения для меры или вычисляемого столбца, поскольку они требуют скалярных выражений. Но мы вправе присвоить результат выполнения табличной функции вычисляемой таблице. Вычисляемая таблица (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 Management 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' Рис. 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 вместе с общими продажами. Рис. 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 ) ) В то же время скорость двух этих запросов применительно к таблицам большого объема может быть разной в зависимости от избирательности (selectivity) условий. При разной избирательности условий первым лучше применять то, которое обладает большей избирательностью, – именно его и стоит размещать во вложенной функции 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 построить более эффективный план выполнения запроса, что положительно скажется на его быстродействии. Глава 3 Использование основных табличных функций 89 Введение в функции ALL и ALLEXCEPT В предыдущем разделе вы познакомились с функцией FILTER, полезной в случаях, когда нам необходимо ограничить количество строк в результирующем наборе. Но иногда нам требуется обратное – расширить набор строк для нужд конкретных вычислений. В DAX есть множество функций для этих целей, в числе которых ALL, ALLEXCEPT, ALLCROSSFILTERED, ALLNOBLANKROW и ALLSELECTED. В этом разделе мы рассмотрим первые две функции из данного перечня. Последние две будут описаны далее в этой главе, а с функцией ALLCROSSFILTERED вы познакомитесь только в главе 14. Функция ALL возвращает все строки таблицы или все значения из одного или нескольких столбцов в зависимости от переданных параметров. Например, следующее выражение DAX вернет вычисляемую таблицу ProductCopy с копиями всех строк из таблицы Product: ProductCopy = ALL ( 'Product' ) Примечание Применять функцию ALL в вычисляемых таблицах нет необходимости, поскольку на них не оказывают влияния установленные фильтры в отчетах. Эта функция будет гораздо более полезна в мерах, как будет показано далее. Функция ALL может пригодиться при расчете процентов или соотношений, поскольку позволяет игнорировать установленные в отчете фильтры. Представьте, что вам понадобился отчет, показанный на рис. 3.3, в котором в разных столбцах отражены сумма продажи и доля этой суммы от общего итога по продажам. Рис. 3.3 В отчете показаны суммы продаж и их проценты от общих продаж В мере Sales Amount рассчитывается сумма продажи путем осуществления итераций по таблице Sales и перемножения значений в столбцах Sales [Quantity] и Sales[Net 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 показаны все три меры вместе. Рис. 3.4 В мере All Sales Amount все значения одинаковые и равны общей сумме продаж Параметром функции ALL не может быть табличное выражение. Это должна быть либо таблица, либо перечень столбцов. Вы уже увидели, что делает функция ALL с таблицей. А что будет, если дать ей список столбцов? В этом случае функция вернет уникальный набор значений из переданных столбцов исходной таблицы. Вычисляемая таблица Categories из следующего примера будет содержать уникальные значения из столбца Category таб­лицы Product: Categories = ALL ( 'Product'[Category] ) На рис. 3.5 показан результат этого вычисления. В качестве параметров в функцию ALL можно передать несколько столбцов из одной таблицы. В этом случае она вернет все уникальные комбинации значений этих столбцов. Например, мы можем получить список категорий с подГлава 3 Использование основных табличных функций 91 категориями, добавив в параметры функции ALL столбец Product[Subcategory]. Результат данной функции показан на рис. 3.6: Categories = ALL ( 'Product'[Category]; 'Product'[Subcategory] ) Рис. 3.5 Использование функции ALL с указанием столбца позволило извлечь список уникальных категорий товаров Функция ALL игнорирует все ранее наложенные фильтры при вычислении результата. При этом мы можем использовать ее в качестве параметра итерационных функций, таких как SUMX и FILTER, или как фильтрующий аргумент функции CALCULATE, с которой мы познакомимся в главе 5. Если нам нужно включить большинство, но не все столбцы в итоговый результат, мы можем вместо функции ALL воспользоваться ее коллегой – ALLEXCEPT. Синтаксисом этой функции предусмотрена передача ссылки на таблицу, а также столбцы, которые мы хотим исключить из результата. В итоге функция ALLEXCEPT вернет уникальные строки из оставшихся столбцов исходной таблицы. Рис. 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 проигнорирует их, тогда как функция ALLEXCEPT вернет эквивалент следующего выражения: 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. Рис. 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. Рис. 3.8 Функция VALUES для каждой категории возвращает ее подмножество цветов Поскольку отчет построен в разрезе категорий, очевидно, что в каждой из них могут присутствовать товары определенных цветов, но не всех возможных. Функция VALUES возвращает набор уникальных значений из столбца в рамках наложенных фильтров. Если использовать функции VALUES или DISTINCT в вычисляемом столбце или вычисляемой таблице, их результат будет полностью совпадать с итогом работы функции 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. Рис. 3.9 В первой строке отчета выведена пустая категория, а общее количество цветов осталось равным 16 Мы видим, что в отчете, изображенном на рис. 3.9, в первой строке указана пустая категория, при этом мера NumOfColors показывает один цвет. Это число отражает наличие строки с пустой категорией, цветом и всеми остальными столбцами. Вы не увидите эту строку при просмотре таблицы, поскольку она автоматически создается на этапе загрузки модели данных. Если в какой-то момент связь вновь станет действительной – к примеру, мы вернем в таблицу Product товары серебряного цвета, – пустая строка пропадет из таблицы. Некоторые функции DAX учитывают в своих расчетах пустые строки, другие – нет. Допустим, функция VALUES воспринимает пустую строку как полноценную запись в таблице и возвращает ее при обращении. Функция DISTINCT ведет себя иначе и не учитывает пустые строки в расчетах. Разницу между ними легко проследить на примере следующей меры, основанной на функции DISTINCT, а не VALUES: NumOfDistinctColors := COUNTROWS ( DISTINCT ( 'Product'[Color] ) ) Результат вычисления этой меры показан на рис. 3.10. В хорошо продуманной модели данных не должны появляться недействительные связи. Таким образом, если модель правильно спроектирована, обе функции всегда будут давать одинаковые результаты. Но вы должны помнить о различиях в работе этих функций на случай возникновения недействительных связей в модели данных. В противном случае ваши вычисления могут давать непредсказуемые результаты. Представьте, что вам необходимо рас96 Глава 3 Использование основных табличных функций считать среднюю сумму продажи по товарам. Одним из возможных вариантов будет определить общую сумму продажи и затем поделить ее на количество товаров. Сделать это можно при помощи такого кода: AvgSalesPerProduct := DIVIDE ( SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ); COUNTROWS ( VALUES ( 'Product'[Product Code] ) ) ) Рис. 3.10 Мера NumOfDistinctColors показывает пустое значение для пустой категории, а в итогах отображает число 15, а не 16 Результат вычисления данной меры показан на рис. 3.11. Очевидно, что здесь есть какая-то ошибка, поскольку в первой строке мы видим огромную и бессмысленную сумму. Рис. 3.11 В первой строке стоит огромная сумма, соответствующая пустой категории товаров Глава 3 Использование основных табличных функций 97 Это загадочное большое число в строке с пустой категорией относится к продажам товаров серебряного цвета, которых больше нет в таблице Product. Иными словами, эта пустая строка ассоциируется с товарами серебряного цвета, которые больше не представлены в справочнике. В числителе функции DIVIDE мы учитываем все продажи серебряных товаров, тогда как в знаменателе будет присутствовать единственная строка, возвращенная функцией 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. Рис. 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, а DISTINCT приберечь для случаев, когда вам нужно будет явно исключить из результата возможные пустые строки. Позже в этой книге вы узнаете, как применение функции DISTINCT вместо VALUES может предотвратить появление циклических зависимостей (circular dependencies). Мы поговорим об этом в главе 15. Функции VALUES и DISTINCT могут принимать в качестве аргумента не только столбец, но и целую таблицу. В этом случае, однако, они ведут себя поразному: функция DISTINCT возвращает уникальные значения из таблицы, не учитывая пустые строки. Таким образом, в выводе будут отсутствовать дуб­ ликаты; функция VALUES возвращает строки исходной таблицы без удаления дуб­ ликатов вместе с пустой строкой, если такие есть в таблице. Дублирую­ щиеся записи остаются в итоговом наборе. Глава 3 Использование основных табличных функций 99 Использование таблиц в качестве скалярных значений Несмотря на то что VALUES является табличной функцией, мы будем часто использовать ее для вычисления скалярных величин. Это возможно благодаря одной особенности DAX, заключающейся в том, что таблица с одним столбцом и одной строкой может быть интерпретирована как скалярное значение. Представьте, что мы строим отчет по количеству брендов с разбивкой по категориям и подкатегориям, как показано на рис. 3.13. Рис. 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] ) ) Рис. 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]; ", " ) Рис. 3.15 Функция SELECTEDVALUE возвращает значение по умолчанию в случае, если в категорию или подкатегорию входит сразу несколько брендов Теперь в случае присутствия в строке нескольких брендов их наименования будут аккуратно перечислены через запятую, что видно по рис. 3.16. Рис. 3.16 Функция CONCATENATEX умеет собирать в строку содержимое таблиц Введение в функцию ALLSELECTED Последняя табличная функция, относящаяся к разряду базовых, – это ALLSE­ LECTED. На самом деле это очень сложная функция – возможно, самая сложная из табличных функций в DAX. В главе 14 мы расскажем обо всех ее нюансах, а сейчас просто познакомимся с ней, поскольку ее использование может быть крайне полезным и на начальной стадии изучения языка. Функция ALLSELECTED применяется для извлечения списка значений из таб­ лицы или столбца с учетом только внешних фильтров, не входящих в элемент 102 Глава 3 Использование основных табличных функций визуализации. Чтобы понять, чем может быть полезна функция ALLSELEC­TED, взгляните на отчет, представленный на рис. 3.17. Рис. 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 показано, что произойдет, если выбрать не все категории. Рис. 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. Рис. 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. Рис. 4.1 Мера Sales Amount без контекстов показывает итоговый показатель Само по себе это число не представляет большого интереса. При этом формула сделала ровно то, что мы и попросили, – посчитала сумму по указанному выражению. Но в реальном отчете нам может понадобиться осуществить некоторые срезы по столбцам. Например, можно выбрать бренды товаров, разместить их в строках матрицы, и в результате мы получим уже более показательный отчет, представленный на рис. 4.2. Рис. 4.2 Мера Sales Amount показывает сумму продаж по каждому бренду в отдельных строках Итоговое значение продаж по-прежнему присутствует в отчете, и сейчас оно представляет сумму продаж по отдельным брендам. Все значения в отчете в совокупности составляют предмет некого детализированного анализа. При этом можно отметить одну странность: формула делает не то, что мы ей сказали. Фактически мы не видим в каждой строке сумму по всем продажам. Вместо этого мы видим сумму продаж по конкретному бренду. Заметьте при этом, что нигде в коде меры мы не указывали, что формула должна работать с подмножествами данных. Эта фильтрация произошла где-то за пределами формулы. В каждой строке мы видим свое собственное значение по причине того, что наша формула выполняется в определенном контексте вычисления. Можете думать о контексте вычисления как о своеобразном окружении, обрамляющем ячейку, в котором происходит вычисление. Глава 4 Введение в контексты вычисления 107 В DAX все вычисления производятся в соответствующем контексте. Одна и та же формула может давать совершенно разные результаты, будучи примененной к разным наборам данных. Здесь мы имеем дело с контекстом фильтра, который, как понятно из названия, осуществляет фильтрацию таблиц. И как мы уже говорили, одна и та же формула может давать совершенно разные результаты в зависимости от того, в каком контексте вычисления она была выполнена. Такое поведение формул кажется интуитивно понятным, но вы должны хорошо усвоить эту концепцию, поскольку она скрывает в себе ряд сложностей. У каждой ячейки в отчете свой собственный контекст фильтра. Вы должны иметь в виду, что в каждой отдельно взятой ячейке может производиться свое вычисление – как если бы это был другой запрос, не зависящий от остальных ячеек в отчете. Движок DAX может применять определенную внутреннюю оптимизацию для ускорения вычислений, но вы должны четко понимать, что в каждой ячейке производится собственное автономное вычисление предписанного выражения DAX. Таким образом, значение в итоговой строке отчета, показанного на рис. 4.2, не рассчитывается путем суммирования всех значений в столбце. Оно вычисляется отдельно посредством агрегирования всех строк в таблице Sales, несмотря на то что для других строк в отчете это вычисление уже было произведено. Таким образом, в зависимости от выражения DAX итоговая строка может содержать результат, не зависящий от других строк в отчете. Примечание В наших примерах мы для простоты используем отчет типа матрица. Но можно задавать контекст вычисления и непосредственно в запросах, с чем вы познакомитесь в следующих главах. На данной стадии будет лучше думать только об отчетах, чтобы в наиболее простом и визуальном виде понять описываемые концепции. Когда поле Brand вынесено в строки, контекст фильтра отбирает по одному бренду для каждой строки. Если усложнить матрицу, добавив годы в столбцы, мы получим результат, показанный на рис. 4.3. Рис. 4.3 Мера Sales Amount, отфильтрованная по брендам и годам 108 Глава 4 Введение в контексты вычисления Теперь в каждой ячейке отражены продажи по конкретному бренду в конкретном году. Дело в том, что контекст фильтра в данный момент одновременно фильтрует бренды и годы. В итоговых ячейках по строкам учитываются только указанные бренды, а в итогах по столбцам – конкретные годы. Единственная ячейка, в которой вычисляется общий итог по продажам, находится на пересечении строки и столбца итогов, и на нее не действуют никакие из установленных в модели фильтров. На этом этапе вам должны быть уже ясны правила игры: чем больше столбцов мы будем использовать в нашем отчете, тем больше столбцов будет затрагивать контекст фильтра в каждой отдельной ячейке матрицы. Если, например, вынести на строки также столбец Store[Continent], результат отчета вновь изменится и станет таким, как показано на рис. 4.4. Рис. 4.4 Контекст определяется набором полей, вынесенных в строки и столбцы Теперь контекст фильтра в каждой ячейке матрицы состоит из бренда, континента и года. Иными словами, контекст фильтра состоит из полного набора полей, которые пользователь выносит в строки и столбцы своего отчета. Примечание Поле может находиться в строках или столбцах отчета, в срезах или фильт­ рах уровня страницы, отчета или визуализации либо в других фильтрующих элементах – это абсолютно не важно. Все эти фильтры определяют единый контекст фильтра, который DAX использует при расчете конкретной формулы. Вывод полей на строки или столбцы полезен только в качестве элемента визуализации, для движка DAX эти эстетические нюансы не играют никакой роли. В Power BI контекст фильтра строится путем комбинирования различных визуальных элементов из графического интерфейса. На самом деле контекст фильтра для конкретной ячейки вычисляется при помощи объединения всех Глава 4 Введение в контексты вычисления 109 фильтров, расположенных в строках, столбцах, срезах и других визуальных фильтрующих элементах. Взгляните на рис. 4.5. Рис. 4.5 В типичном отчете контекст содержит множество элементов, включая срезы и фильтры Контекст фильтра верхней левой ячейки (бренд: A.Datum, год: CY 2007, значение: 57 276,00) состоит не только из полей строки и столбца, но также из фильтров по виду деятельности (Professional) и континенту (Europe), расположенных слева в своих визуальных элементах. Все эти фильтры составляют единый контекст фильтра, действительный для каждой ячейки, и DAX применяет его ко всей модели данных перед вычислением формулы. Формально можно сказать, что контекст фильтра представляет собой набор фильтров. Фильтр, в свою очередь, является списком кортежей, а каждый кортеж – это набор значений для определенных столбцов. На рис. 4.6 визуально показано действие контекста фильтра, в рамках которого вычисляется значение в ячейке. Каждый элемент отчета является составной частью контекста фильтра, и в каждой ячейке контекст фильтра будет свой. Calendar Year CY 2007 Education High School Partial College Brand Contoso Рис. 4.6 Визуальное представление контекста фильтра в отчете Power BI 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. Рис. 4.7 Значения в вычисляемом столбце Gross Margin разные и зависят от других столбцов Как мы и ожидали, значения в созданном нами вычисляемом столбце для каждой строки будут свои. Это вполне естественно, поскольку значения других трех столбцов, от которых зависит наша новая величина, также разнятся. Как и в случае с контекстом фильтра, причина таких различий состоит в наличии определенного контекста вычисления. Но на этот раз контекст не фильтрует таблицу. Вместо этого он идентифицирует строку, для которой выполняется вычисление. Примечание Контекст строки ссылается на конкретную строку в результате табличного выражения DAX. Не стоит путать его со строкой в отчете. У DAX нет возможности напрямую ссылаться на строки или столбцы в отчетах. Значения, показываемые в матрице в Power BI и сводной таблице в Excel, являются результатом вычисления мер в контексте фильтра или значениями, сохраненными в обычных или вычисляемых столбцах таблицы. 112 Глава 4 Введение в контексты вычисления Мы знаем, что значения вычисляемого столбца рассчитываются построчно, но как DAX понимает, в какой строке мы находимся в текущий момент? В этом ему помогает специальный вид контекста вычисления, называемый контекс­ том строки. Когда мы добавляем вычисляемый столбец к таблице из миллиона строк, DAX одновременно создает контекст строки, вычисляющий значение в столбце строка за строкой. Во время добавления вычисляемого столбца DAX по умолчанию создает контекст строки. В этом случае нет необходимости делать это вручную – вычисляемый столбец и так всегда вычисляется в контексте строки. Но вы уже знаете, как создавать контекст строки вручную – при помощи итератора. Фактически мы можем написать для подсчета валовой прибыли меру следующего содержания: Gross Margin := SUMX ( Sales; Sales[Quantity] * ( Sales[Net Price] - Sales[Unit Cost] ) ) В этом случае, поскольку мы имеем дело с мерой, контекст строки автоматически не создается. Функция SUMX, будучи итератором, создает контекст строки, который начинает проходить по таблице Sales построчно. Во время итерации происходит запуск второго выражения с функцией SUMX внутри контекста строки. Таким образом, на каждой итерации DAX знает, какие значения использовать для трех столбцов, присутствующих в выражении. Контекст строки появляется, когда мы создаем вычисляемый столбец или рассчитываем выражение внутри итерации. Другого способа создать контекст строки не существует. Можно считать, что контекст строки необходим нам для извлечения значения столбца для конкретной строки. Например, следующее выражение для меры недопустимо. Формула пытается вычислить значение столбца Sales[Net Price], но в отсутствие контекста строки не может получить информацию о строке, для которой необходимо произвести вычисление: Gross Margin := Sales[Quantity] * ( Sales[Net Price] - Sales[Unit Cost] ) Эта формула будет вполне допустимой для вычисляемого столбца, но не для меры. И причина не в том, что вычисляемые столбцы и меры как-то поразному используют формулы DAX. Просто вычисляемый столбец располагает контекстом строки, созданным автоматически, а мера – нет. Если вам необходимо внутри меры вычислить определенное выражение построчно, вам придется использовать итерационную функцию для принудительного создания контекста строки. Примечание Ссылка на столбец требует наличия контекста строки для возврата значения из столбца таблицы. Столбец также может использоваться в качестве аргумента для некоторых функций DAX, не располагающих контекстом строки. Например, функции DISTINCT и 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. Рис. 4.8 Функция SUM ( Sales[Quantity] ) в вычисляемом столбце распространяется на все строки таблицы Из этого примера видно, что два контекста вычисления могут мирно сосуществовать и при этом не взаимодействовать друг с другом. Оба контекста влияют на итоговые результаты, но делают они это по-разному. Агрегирующие функции вроде SUM, MIN и MAX используют только контекст фильтра, игнорируя при этом контекст строки. Если вы выбрали первый вариант ответа, что делают многие студенты, это нормально. Это означает, что вы по-прежнему путаете контекст фильтра с контекстом строки. Еще раз повторим, что контекст фильтра фильтрует, контекст строки осуществляет итерации по таблице. Первый ответ выбирают те, кто полагаются на интуицию, и теперь вы понимае­те, почему. Если же вы сделали правильный выбор, что ж, поздравляем – значит, эта глава помогла вам понять разницу между различными контекстами. Использование ссылок на столбцы в мерах Вторая задача будет из противоположной области. Представьте, что вы пишете формулу для расчета валовой прибыли с использованием меры, а не вычисляе­ мого столбца. У нас есть столбцы с ценой (Net Price) и себестоимостью (Unit Cost) за единицу товара, и мы пишем следующее выражение: Глава 4 Введение в контексты вычисления 115 GrossMargin% := ( Sales[Net Price] - Sales[Unit Cost] ) / Sales[Unit Cost] Какой результат мы получим? Как и в первой задаче, мы предлагаем вам три варианта ответа на выбор: выражение выглядит корректно, пора запускать отчет; здесь есть ошибка, такую формулу писать нельзя; такое выражение написать можно, но оно вернет ошибку при формировании отчета. Как и в первом случае, остановитесь ненадолго, подумайте над ответом и только потом продолжайте чтение. В этом коде мы ссылаемся на столбцы Sales[Net 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 вернет список товаров с ценой, большей, чем у текущего товара, а 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 исключительно в образовательных целях. Products[UnitPriceRank] = Product[UnitPrice] + COUNTROWS ( FILTER ( Product, Product[Unit Price] >= PriceOfCurrentProduct ) )+1 Рис. 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 вычисляется во внешнем контексте строки Рис. 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 показан новый вычисляемый столбец наряду со столбцом UnitPriceRank. Последним шагом в этой задаче был переход на подсчет цен вместо товаров, и решение оказалось более простым, чем можно было ожидать. Чем больше вы будете работать с DAX, тем легче вам будет начать мыслить категориями временных таблиц, создаваемых для определенных вычислений. Из данного примера вы узнали, что лучшим способом управления множест­ венными контекстами в рамках одной таблицы является создание вспомогательных переменных. Помните, что переменные были введены в языке только в 2015 году. Так что вы вполне можете столкнуться с примерами на DAX, которые были написаны до введения переменных, но прекрасно справлялись со множественными контекстами строки с использованием функции EARLIER, с которой мы и познакомимся в следующем разделе. Использование функции EARLIER Язык DAX предоставляет нам функцию EARLIER, предназначенную специально для обращения к внешнему контексту строки. Функция EARLIER извлекает значение по столбцу с использованием предыдущего контекста строки вместо текущего. Так что мы вполне можем переписать формулу для нашей заглушки PriceOfCurrentProduct следующим образом: EARLIER ( Product[UnitPrice] ). Глава 4 Введение в контексты вычисления 123 Рис. 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 практически вышла из обращения – большинство разработчиков отдают предпочтение именно переменным. 124 Глава 4 Введение в контексты вычисления Таким образом, единственная польза от изучения функции EARLIER сегодня состоит в возможности читать чужой код на DAX. Использовать эту функцию в своих выражениях нет никакого смысла, поскольку переменные прекрасно справляются с сохранением значений в том или ином доступном контексте строки. Использовать переменные для этой цели считается более приемлемым вариантом: это делает код более легким для восприятия. Функции FILTER, ALL и взаимодействие между контекстами Ранее мы использовали функцию FILTER исключительно для осуществления фильтрации таблицы. Это очень распространенная функция, необходимая для создания новых ограничений, накладывающихся на существующий контекст фильтра. Представьте, что вам нужно создать меру, в которой будет подсчитано количество красных товаров. С учетом знаний, полученных нами ранее, мы можем легко написать такую формулу: NumOfRedProducts := VAR RedProducts = FILTER ( 'Product'; 'Product'[Color] = "Red" ) RETURN COUNTROWS ( RedProducts ) Эту меру спокойно можно использовать в отчетах. Например, мы можем вынести наименования брендов на строки и построить отчет, показанный на рис. 4.14. Рис. 4.14 Можно подсчитать количество красных товаров в таблице с использованием функции FILTER Перед тем как двигаться дальше, остановитесь на минутку и подумайте о том, как именно DAX получил эти значения. Столбец Brand принадлежит таб­лице Product. Внутри каждой ячейки отчета контекст фильтра накладывает Глава 4 Введение в контексты вычисления 125 ограничения по одному конкретному бренду. Таким образом, в каждой ячейке мы видим количество товаров определенного бренда и при этом исключительно красного цвета. Причина этого в том, что функция FILTER проходит по таблице Product в том виде, в котором она видна в рамках текущего контекста фильтра, включающего конкретный бренд. Кажется, что это все очень просто, но не лишним будет повторить это несколько раз, чтобы уж точно не забыть. Такое поведение меры становится более очевидным с добавлением к отчету среза, фильтрующего товары по цвету. На рис. 4.15 показаны два идентичных отчета со срезами по цвету, каждый из которых фильтрует отчет непосредственно справа от себя. В отчете слева выбран фильтр по красному цвету товаров (Red), и цифры в этом отчете такие же, как на рис. 4.14. В то же время правый отчет, где в срезе указан лазурный цвет (Azure), остался пустым. Рис. 4.15 DAX рассчитывает меру NumOfRedProducts, принимая во внимание внешний контекст фильтра, заданный в срезе В правом отчете функция FILTER проходит по таблице Product в рамках внешнего контекста фильтра, включающего в себя лазурный цвет, при этом в самой мере прописан фильтр по красному цвету, что приводит к несогласованным данным и пустому выводу в отчете. Иными словами, в каждой ячейке этого отчета мера NumOfRedProducts возвращает BLANK. На этом примере мы показали, что в одной и той же формуле вычисление производится как во внешнем контексте фильтра, учитывающем срезы вне самого отчета, так и в контексте строки, созданном непосредственно функцией FILTER внутри формулы. Оба контекста действуют одновременно и влияют на результат вычисления. Контекст фильтра DAX использует для оценки таблицы Product, а контекст строки – для построчной проверки фильтрующего условия во время итераций в функции FILTER. Мы хотим заново повторить эту концепцию: функция FILTER не изменяет контекст фильтра. FILTER – это итерационная функция, которая сканирует таб­ лицу (уже отфильтрованную при помощи контекста фильтра) и возвращает набор данных, соответствующий условиям фильтра. В отчете, показанном на рис. 4.14, контекст фильтра включает в себя только бренды, и после возврата результата из функции FILTER он по-прежнему фильтрует только бренды. Добавив в отчет срезы по цвету (рис. 4.15), мы расширили контекст фильтра до брендов и цветов. Именно поэтому в левом отчете функция FILTER вернула 126 Глава 4 Введение в контексты вычисления все товары из итераций, а в правом список товаров оказался пустым. В обоих отчетах функция FILTER не изменяла контекст фильтра, а только сканировала таблицу и возвращала отфильтрованный результат. На этом этапе кому-то из вас, вероятно, хотелось бы иметь функцию, позволяющую получить полный список красных товаров, независимо от выбора пользователя в срезах. И здесь на помощь придет уже знакомая нам функция ALL. Эта функция возвращает содержимое таблицы, игнорируя при этом контекст фильтра. Давайте определим новую меру, назовем ее NumOfAllRedProducts и пропишем для нее следующую формулу: NumOfAllRedProducts := VAR AllRedProducts = FILTER ( ALL ( 'Product' ); 'Product'[Color] = "Red" ) RETURN COUNTROWS ( AllRedProducts ) В этом случае в функции FILTER будут запускаться итерации не по таблице Product, а по выражению ALL ( Product ). Функция ALL игнорирует установленный контекст фильтра и всегда возвращает все строки из таблицы, так что функция FILTER в данном случае вернет красные товары, даже если таблица Products была предварительно отфильтрована по другим брендам или цветам. Результат, показанный на рис. 4.16, может вас удивить, несмотря на свою корректность. Рис. 4.16 Мера NumOfAllRedProducts вернула неожиданный результат Здесь есть пара любопытных моментов, и мы хотим поговорить о них по­ дробно: результат во всех ячейках равен 99 вне зависимости от бренда в строке; бренды в левом отчете отличаются от брендов в правом. Заметим, что 99 – это количество всех красных товаров в таблице, а не их количество по конкретному бренду. Функция ALL, как и ожидалось, проигнориГлава 4 Введение в контексты вычисления 127 ровала все фильтры, наложенные на таблицу Product. При этом игнорируются не только фильтры по цвету, но и по брендам. Возможно, вы этого не хотели. Однако функция ALL работает именно так – она очень простая и мощная, но действует по принципу «или все, или ничего», игнорируя абсолютно все фильт­ ры, наложенные на указанную таблицу. В данный момент у вас не хватает знаний, чтобы обеспечить игнорирование только части установленных фильтров. В нашем примере логичнее было бы игнорировать лишь фильтр по цвету. Но с функцией CALCULATE, которая позволяет более выборочно управлять наложенными фильтрами, мы познакомимся только в следующей главе. Теперь рассмотрим второй момент, заключающийся в том, что список брендов в двух отчетах отличается. Поскольку у нас в срезе выбран только один цвет, полная матрица отчета вычисляется с учетом этого цвета. В левом отчете у нас выбран красный цвет, а в правом – лазурный. Этот выбор ограничивает список товаров, а значит, и список брендов. Перечень брендов, используемый для вывода в отчет, строится с учетом текущего контекста фильтра, содержащего фильтр по цвету. После определения списка брендов происходит вычисление меры, в результате чего мы получаем число 99 вне зависимости от текущего бренда и цвета. Таким образом, в левом отчете мы видим список брендов, в которых есть товары красного цвета, а в правом – лазурного, тогда как все цифры в обоих отчетах показывают количество товаров красного цвета, независимо от бренда. Примечание Поведение этого отчета не характерно для DAX, а больше подходит для функции SUMMARIZECOLUMNS, используемой в Power BI. Мы познакомимся с этой функцией в главе 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[UnitPrice], в то время как контекст строки для таб­ лицы Product не создан: Sales[UnitPriceVariance] = Sales[Unit Price] – ‘Product’[Unit Price] Поскольку речь идет о вычисляемом столбце, DAX автоматически создаст контекст строки в таблице, содержащей столбец, – в данном случае это таблица Sales. Контекст строки обеспечит построчное вычисление выражения с использованием столбцов из таблицы Sales. Несмотря на то что таблица Product находится на стороне «один» в связи с таблицей Sales, итерации производятся исключительно по таблице Sales. Производя итерации по таблице, находящейся в связи на стороне «многие», мы можем осуществлять доступ к столбцам в таблице на стороне «один», но для этого необходимо использовать функцию RELATED. Функция RELATED принимает в качестве аргумента ссылку на столбец и извлекает значение столбца из соответствующей строки в целевой таблице. Функция RELATED может обращаться только к одному столбцу. Если необходимо получить доступ сразу к нескольким столбцам из связанной таблицы, потребуется использовать эту функцию многократно. Таким образом, корректная версия приведенного выше кода будет такой: 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. При этом обе функции – RELATED и RELATEDTABLE – способны проходить по целым цепочкам связей, а не ограничены доступом только к таблице, объ130 Глава 4 Введение в контексты вычисления единенной с текущей напрямую. Допустим, вы можете создать вычисляемый столбец с такой же формулой, как в предыдущем примере, но на этот раз в таб­ лице Product Category: 'Product Category'[NumberOfSales] = VAR SalesOfCurrentProductCategory = RELATEDTABLE ( Sales ) RETURN COUNTROWS ( SalesOfCurrentProductCategory ) Рис. 4.18 Функция RELATEDTABLE может быть полезна в контексте строки на стороне «один» Результатом будет количество продаж по каждой категории товаров, при этом доступ к таблице 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 умеет работать с двунаправленными связями, как будет показано Глава 4 Введение в контексты вычисления 131 далее. С другой стороны, связь типа «один к одному» ведет себя одновременно как связь «один ко многим» и «многие к одному». Так что такая связь вполне может быть одним из звеньев цепочки, соединяющей несколько таблиц. Например, в нашей модели данных таблица Customer связана с Sales, а Sales – с Product. При этом таблицы Customer и Sales объединены связью типа «один ко многим», а Sales с Product – «многие к одному». Получается, что у нас есть цепочка связей между таблицами Customer и Product. Но при этом две связи из этой цепочки различаются по типу. Такой сценарий характеризуется образованием связи «многие ко многим». Одному покупателю соответствуют многие купленные им товары, и в то же время один товар могут приобрести сразу несколько покупателей. Подробно о связях типа «многие ко многим» мы будем говорить в главе 15, а сейчас нас интересуют только вопросы, связанные с контекстом строки. Если вы будете использовать функцию RELATEDTABLE для таб­лиц, объединенных связью типа «многие ко многим», то получите неправильные результаты. Посмотрите на следующий вычисляемый столбец, созданный в таблице Product: Product[NumOfBuyingCustomers] = VAR CustomersOfCurrentProduct = RELATEDTABLE ( Customer ) RETURN COUNTROWS ( CustomersOfCurrentProduct ) В результате мы получим не количество покупателей, приобретавших конкретный товар, а общее количество покупателей, как показано на рис. 4.19. Рис. 4.19 Функция RELATEDTABLE не работает со связью «многие ко многим» Функция RELATEDTABLE не может пробиться по цепочке связей, поскольку они различаются по типу. Контекст строки от таблицы Product не достигает таблицы Customers. Интересно следующее: если мы будем проходить по цепочке связей в обратном направлении, то есть попытаемся получить количест­ во товаров, которые были приобретены конкретным покупателем, результат окажется правильным, – мы увидим для каждого отдельного покупателя свое количество товаров. Причина такого поведения модели не в распространении контекста строки, а в преобразовании контекста, осуществляемом функцией RELATEDTABLE. Последнее замечание мы сделали исключительно для полноты картины. Вы гораздо лучше поймете, как это работает, прочитав главу 5. 132 Глава 4 Введение в контексты вычисления Контекст фильтра и связи В предыдущем разделе вы узнали, что контекст строки предназначен для осуществления итераций по таблице, а значит, не использует связи. Контекст фильтра, напротив, фильтрует данные. Кроме того, контекст фильтра не принадлежит какой-то одной таблице, а воздействует на всю модель данных в целом. И сейчас мы можем немного обновить нашу мантру, посвященную контекстам вычисления, чтобы она отражала истинную картину: Контекст фильтра фильтрует модель данных, а контекст строки осуществ­ ляет итерации по таблице. Поскольку контекст фильтра воздействует на всю модель, логично предположить, что для этого он использует связи между таблицами. При этом взаимодействие контекста фильтра со связями осуществляется автоматически и зависит от направления кросс-фильтрации (cross-filter direction), установленного для каждой из них. Направление кросс-фильтрации обозначается в модели данных маленькой стрелкой посередине связи, как видно на рис. 4.20. Направление кросс-фильтрации: двунаправленная Направление кросс-фильтрации: однонаправленная Рис. 4.20 Поведение контекста фильтра и связей Контекст фильтра распространяется по связи в направлении, показанном стрелкой. Во всех без исключения связях распространение контекста фильтра осуществляется от стороны «один» к стороне «многие», тогда как обратное распространение допустимо только при включении режима двунаправленной кросс-фильтрации. Связь с установленным однонаправленным режимом кросс-фильтрации называется однонаправленной связью (unidirectional relationship), а с двунаправленным режимом – двунаправленной (bidirectional relationship). Такое поведение контекста фильтра интуитивно понятно. И хотя мы ранее специально не касались этой терминологии, фактически во всех отчетах, которые мы использовали, так или иначе было реализовано описанное поведение контекста фильтра. Например, в типичном отчете с фильтром по Глава 4 Введение в контексты вычисления 133 цвету товаров (Product[Color]) и агрегацией по количеству проданных товаров (Sales[Quantity]) вполне логично ожидать, что фильтр от таблицы Product распространит свое действие на таблицу Sales. Причина такого поведения фильт­ ра в том, что таблица Product находится в связи с Sales на стороне «один», что позволяет фильтрам беспрепятственно распространяться с таблицы товаров на таблицу продаж вне зависимости от установленного направления кроссфильтрации. Поскольку в нашей модели данных присутствует как двунаправленная связь, так и множество однонаправленных, можно продемонстрировать поведение фильтра на примере, используя три меры, подсчитывающие количество строк в трех разных таблицах: Sales, Product и Customer. [NumOfSales] := COUNTROWS ( Sales ) [NumOfProducts] := COUNTROWS ( Product ) [NumOfCustomers] := COUNTROWS ( Customer ) В следующем отчете мы вынесли на строки столбец Product[Color]. Таким образом, каждая мера рассчитывается в контексте фильтра, включающем цвет товара. Результаты отчета показаны на рис. 4.21. Рис. 4.21 Демонстрация поведения контекста фильтра и связей В этом примере фильтры беспрепятственно распространяются по связям от стороны «один» к стороне «многие». Фильтр начинает движение с Product[Color]. Далее он распространяется на таблицу Sales, расположенную в связи с Product на стороне «многие», и на саму таблицу Product. В то же время в мере NumOfCustomers для всех строк в таблице показано одно и то же значение, отражающее общее количество покупателей в базе. Это происходит из-за невозмож134 Глава 4 Введение в контексты вычисления ности распространения фильтра по связи между таб­лицами Customer и Sales от таблицы Sales к Customer. В результате фильтр проходит от таблицы Product к Sales, но до Customer не доходит. Вы могли заметить, что связь между таблицами Sales и Product в нашей модели двунаправленная. В связи с этим контекст фильтра, включающий информацию из таблицы Customer, легко распространится на Sales и Product. Мы можем доказать это, немного перестроив отчет и вынеся на строки Customer[Education] (Образование) вместо Product[Color]. Результат показан на рис. 4.22. На этот раз фильтр берет свое начало с таблицы Customer. Он легко достигает таблицы Sales, поскольку она располагается в связи на стороне «многие». Далее фильтр распространяется с таблицы Sales на Product благодаря двунаправленному характеру связи между этими таблицами. Заметьте, что наличие в цепочке единственной двунаправленной связи не делает таковой всю цепочку. Например, похожая мера, призванная подсчитывать количество подкатегорий, наглядно демонстрирует, что контекст фильтра не может распространиться от таблицы Customer на Product Subcategory: Рис. 4.22 Фильтрация по образованию покупателей, таблица Product также фильтруется NumOfSubcategories := COUNTROWS ( 'Product Subcategory' ) Добавление новой меры к предыдущему отчету привело к результату, показанному на рис. 4.23. Заметьте, что количество подкатегорий для всех строк оказалось одинаковым. Рис. 4.23 Из-за однонаправленности связи таблица покупателей не может фильтровать данные по подкатегориям Глава 4 Введение в контексты вычисления 135 Поскольку связь между таблицами Product и Product Subcategory однонаправленная, фильтр от товаров на таблицу подкатегорий распространиться не может. Если мы обновим модель данных, сделав связь двунаправленной, то получим результат, показанный на рис. 4.24. Рис. 4.24 Если связь двунаправленная, покупатели могут фильтровать подкатегории товаров Для распространения контекста строки по связям мы используем функции RELATED и RELATEDTABLE, тогда как для распространения контекста фильтра никаких дополнительных функций не требуется. Контекст фильтра накладывает ограничения на модель, а не на таблицу. Таким образом, после применения контекста фильтра к любой таблице фильтр автоматически распространяется по связям на всю модель. Важно По нашим примерам могло сложиться впечатление, что включение двунаправленной кросс-фильтрации для всех без исключения таблиц в модели данных является оптимальным выбором, поскольку в этом случае все фильтры будут автоматически воздействовать на всю модель. Но это не так. Подробнее мы поговорим о связях в главе 15. Двунаправленные связи несут в себе определенную сложность, но вам пока рано об этом знать. Как бы то ни было, использовать такие связи можно только с полным пониманием возможных последствий. Как правило, вы должны включать режим двунаправленной фильтрации для связи в конкретной мере, используя функцию CROSSFILTER, и делать это нужно только в случае крайней необходимости. Использование функций DISTINCT и SUMMARIZE в контекстах фильтра На данном этапе вы должны уже хорошо разбираться в контекстах вычисления, и мы используем эти знания для пошагового решения одного интересного сценария. Попутно мы расскажем вам о некоторых неочевидных нюансах, которые, надеемся, смогут расширить ваши фундаментальные знания в области контекстов строки и контекстов фильтра. Также в этом разделе мы более по­ дробно коснемся функции SUMMARIZE, которую вскользь затронули в главе 3. Перед тем как начать, заметим, что в процессе разбора сценария мы будем постепенно переходить от неправильных вариантов решения к правильному. 136 Глава 4 Введение в контексты вычисления Это сделано в образовательных целях, ведь нашей задачей является научить вас писать код на DAX, а не предоставить готовое решение. Создавая меры, вы, разумеется, поначалу будете допускать ошибки, и на этом примере мы постараемся подробно описать наш ход мыслей, что может помочь вам в будущем самостоятельно исправлять неточности в своем коде. Задача перед нами стоит следующая: вычислить средний возраст покупателей компании Contoso. И хотя на первый взгляд кажется, что вопрос сформулирован корректно, на самом деле это не так. О каком возрасте мы говорим? О текущем или о том, в котором они совершали покупки? Если человек покупал товары трижды, должны ли мы считать это одной покупкой или тремя разными при подсчете среднего? А что, если он приобретал товары в разном возрасте? Нужно сформулировать задачу более конкретно. И мы это сделали: «Требуется посчитать средний возраст покупателей на момент покупки, при этом все покупки, совершенные человеком в одном возрасте, учитывать только один раз». Процесс решения можно условно разбить на две стадии: вычисление возраста покупателя на момент покупки; усреднение вычисленного показателя. Возраст покупателя меняется со временем, так что нам нужно иметь возможность сохранять его в таблице 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 показан результат вычисления нашей меры в сравнении с корректными ожидаемыми значениями. Глава 4 Введение в контексты вычисления 137 Рис. 4.25 Простое усреднение возраста покупателей не дало ожидаемых результатов Проблема состоит в том, что возраст каждого покупателя должен быть учтен лишь раз. Возможное решение – и тоже неправильное – заключается в применении функции DISTINCT к столбцу с возрастом и дальнейшем усреднении полученного значения, как показано в мере ниже: Avg Customer Age Wrong Distinct := AVERAGEX ( -- Проходим по уникальным значениям возрастов DISTINCT ( Sales[Customer Age] ); -- и рассчитываем среднее значение Sales[Customer Age] -- по этому показателю ) Это решение, как мы уже сказали, также неправильное. Фактически функция DISTINCT просто вернет уникальные значения возрастов из нашей базы продаж. Таким образом, два покупателя, совершивших покупки в одном возрасте, посчитаются лишь раз. Получается, что мы хотели учитывать покупателей в одном возрасте один раз, а учитываем сам возраст без привязки к покупателям. На рис. 4.26 показан вывод меры Avg Customer Age в сравнении с правильными значениями. Как видите, мы все еще далеки от правильного решения. Можно, конечно, попробовать заменить в нашей формуле параметр функции DISTINCT с Customer Age на CustomerKey, чтобы получился следующий код: Avg Customer Age Invalid Syntax := AVERAGEX ( DISTINCT ( Sales[CustomerKey] ); Sales[Customer Age] ) -- Проходим по уникальным значениям -- Sales[CustomerKey] и рассчитываем среднее -- по этому показателю Но такая формула вовсе не выполнится, поскольку содержит ошибку. Сможете найти ее самостоятельно, не читая следующий абзац? 138 Глава 4 Введение в контексты вычисления Дело в том, что функция AVERAGEX, как любой итератор, создает при запуске контекст строки. Первым параметром в функцию AVERAGEX передается DISTINCT ( Sales[CustomerKey] ). Функция DISTINCT возвращает таблицу с единственным столбцом, содержащим уникальные значения кодов покупателей. Таким образом, контекст строки, созданный функцией AVERAGEX, будет содержать только один столбец, а именно Sales[CustomerKey]. DAX просто не сможет вычислить значение Sales[Customer Age] в контексте строки, содержащем только Sales[CustomerKey]. Рис. 4.26 Усреднение уникальных возрастов покупателей также не помогло Что нам нужно, так это каким-то образом получить контекст строки с гранулярностью (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. Глава 4 Введение в контексты вычисления 139 Дело в том, что переменная может содержать в себе таблицу, но не может использоваться в качестве ссылки на нее. Correct Average := VAR CustomersAge = SUMMARIZE ( Sales; Sales[CustomerKey]; Sales[Customer Age] ) RETURN AVERAGEX ( CustomersAge; Sales[Customer Age] ) ----- Существующие комбинации в таблице Sales из ключа покупателя и его возраста -- Проходим по сочетаниям -- ключей и возрастов покупателей в таблице Sales -- и вычисляем среднее значение возраста Функция SUMMARIZE помогает получить список из уникальных комбинаций покупателей и их возрастов в текущем контексте фильтра. Таким образом, разные покупатели с одинаковым возрастом будут учитываться отдельно. Функция AVERAGEX игнорирует присутствие CustomerKey в таблице, она использует только возраст покупателей. Столбец CustomerKey нужен лишь для корректного подсчета количества уникальных возрастов. Необходимо подчеркнуть, что наша мера вычисляется в рамках контекста фильтра в отчете. Таким образом, функцией SUMMARIZE будут обработаны и возвращены только те покупатели, которые приобретали товары. В каждой ячейке отчета действует свой контекст фильтра, учитывающий только тех покупателей, которые приобрели как минимум один товар определенного цвета, указанного в отчете. Заключение Пришло время вспомнить, что мы узнали из этой главы о контекстах вычисления: существуют два контекста вычисления: контекст фильтра и контекст строки. При этом они не являются разновидностями одной концепции: контекст фильтра фильтрует всю модель данных, а контекст строки осуществляет итерации по одной таблице; чтобы понять поведение той или иной формулы, необходимо правильно оценить оба контекста вычисления, поскольку они действуют одновременно; DAX открывает контекст строки автоматически всякий раз, когда создается вычисляемый столбец в таблице. Также создать контекст строки можно программно при помощи итерационной функции. Каждая такая функция открывает свой контекст строки; контексты строки можно вкладывать друг в друга, и в случае если они действуют в одной и той же таблице, внутренний контекст строки будет скрывать внешний. Для сохранения значений, полученных в определенном 140 Глава 4 Введение в контексты вычисления контексте строки, можно использовать переменные. В ранних версиях DAX, в которых переменные не присутствовали, можно было для обращения к предыдущему контексту строки обращаться при помощи функции EARLIER. Сегодня в использовании этой функции нет необходимости; при проходе по таблице, являющейся результатом выполнения табличного выражения, контекст строки содержит только столбцы, возвращенные табличным выражением; в клиентских инструментах наподобие Power BI контекст фильтра создается при размещении элементов в строках, столбцах, срезах и фильтрах. Контекст фильтра также может быть создан программно при помощи функции CALCULATE, о которой мы будем говорить в следующей главе; контекст строки не распространяется по связям автоматически. При необходимости распространить его вручную можно использовать функции RELATED и RELATEDTABLE. При этом каждую из этих функций нужно использовать в контексте строки строго на определенной стороне связи: RELATED на стороне «многие», RELATEDTABLE – на стороне «один»; контекст фильтра фильтрует модель данных, используя связи в ней в соответствии с их направлениями кросс-фильтрации. Фильтры всегда распространяются от стороны «один» к стороне «многие». Если установить режим двунаправленной кросс-фильтрации для связи, фильтры будут распространяться по ней и в обратном направлении – от стороны «многие» к стороне «один». К этому моменту вы усвоили все самые сложные концепции языка DAX. Эти концепции полностью определяют и регулируют процесс вычисления всех ваших формул и являются настоящими столпами языка. Если написанные вами выражения не дают ожидаемых результатов, велика вероятность, что вы прос­ то не до конца усвоили перечисленные выше правила. Как мы уже сказали во введении, на первый взгляд эти правила выглядят весьма простыми. Такими они и являются на самом деле. Сложность заключается в том, что в своих выражениях на DAX вам часто придется поддерживать разные активные контексты вычисления в разных частях формулы. Мастерство в работе со множественными контекстами вычисления приходит с опытом, и мы постараемся обеспечить вам его при помощи многочисленных примеров в следующих главах. В процессе самостоятельного написания формул на DAX к вам постепенно придет понимание того, какие контексты в тот или иной момент используются и каких функций они требуют. Шаг за шагом вы освоите все тонкости языка и станете настоящим гуру в мире DAX. ГЛ А В А 5 Функции CALCULATE и CALCULATETABLE В этой главе мы продолжим путешествовать по миру DAX и детально опишем лишь одну функцию CALCULATE. Сразу заметим, что все сказанное далее будет относиться и к функции CALCULATETABLE, отличающейся от CALCULATE лишь тем, что она возвращает таблицу, а не скалярную величину. Для простоты изложения мы будем показывать примеры с использованием функции CALCULATE, но вы должны помнить, что они также будут работать и с функцией CALCULATETABLE. 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. Рис. 5.1 Три меры в отчете позволяют бегло оценить валовую прибыль компании в разрезе категорий товаров Следующие шаги в работе с этим отчетом будут не самыми простыми. Финальный отчет, к которому мы хотели бы прийти, показан на рис. 5.2. Тут мы Глава 5 Функции CALCULATE и CALCULATETABLE 143 видим два дополнительных столбца, в которых выводится валовая прибыль по нашему собственному бренду Contoso, выраженная в деньгах и процентах. Рис. 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 ContosoSalesAmount = -- Проходим по табличной переменной 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. Рис. 5.3 Срез по бренду позволил получить данные по Contoso в мерах Gross Margin и GM % В выделенной строке контекст фильтра был создан путем наложения фильт­ ра по бренду Contoso. Как мы помним, контекст фильтра фильтрует всю модель Глава 5 Функции CALCULATE и CALCULATETABLE 145 данных в целом. Таким образом, наложенный на столбец Product[Brand] фильтр оказал воздействие и на таблицу Sales благодаря связи, присутствующей между таблицами Sales и Product. Так что мы просто воспользовались косвенной фильтрацией таблиц по связям. Ах, если бы мы могли создать контекст фильтра для меры Gross Margin программно и отфильтровать при помощи него только бренд Contoso. Тогда две оставшиеся меры мы бы вычислили очень легко и просто. И здесь на помощь приходит функция CALCULATE. Полное описание функции CALCULATE мы дадим далее в этой главе. А сейчас просто посмотрим на ее синтаксис: CALCULATE ( Выражение, Условие1, ... УсловиеN ) Функция CALCULATE может принимать любое количество параметров. Единственным обязательным параметром при этом является первый, в котором содержится выражение для вычисления. Условия, следующие за выражением, называются аргументами фильтра (filter arguments). Функция CALCULATE создает новый контекст фильтра, основываясь на переданных аргументах фильт­ ра. Созданный контекст фильтра применяется ко всей модели, и в рамках него вычисляется выражение из первого параметра. Таким образом, воспользовавшись функцией CALCULATE, можно существенно упростить код для мер Contoso Margin и Contoso GM %: Contoso GM := CALCULATE ( [Gross Margin]; 'Product'[Brand] = "Contoso" ) Contoso GM % := CALCULATE ( [GM %]; 'Product'[Brand] = "Contoso" ) -- Рассчитываем валовую прибыль -- в контексте фильтра, где бренд = Contoso -- Рассчитываем валовую прибыль в процентах -- в контексте фильтра, где бренд = Contoso И снова здравствуйте, простота и элегантность языка DAX! Создав контекст фильтра, в котором бренд отфильтрован по названию Contoso, мы смогли воспользоваться существующими мерами с измененным поведением, вместо того чтобы писать все заново. Функция CALCULATE позволяет создавать новые контексты фильтра путем манипулирования фильтрами в текущем контексте. Как видите, это позволило нам сделать наш код элегантным и лаконичным. В следующих разделах мы представим полное, более формализованное определение функции CALCULATE и подробно расскажем, как она работает и как можно воспользоваться всеми ее преимуществами. Пока мы оставим наш пример в том виде, в каком он есть, хотя на самом деле изначальное определение мер по бренду Contoso не в полной мере эквивалентно семантически итоговому определению. Между ними есть некоторые различия, которые необходимо очень хорошо понимать. 146 Глава 5 Функции CALCULATE и CALCULATETABLE Знакомство с функцией CALCULATE Теперь, когда вы увидели в действии функцию CALCULATE, пришло время познакомиться с ней ближе. Как мы уже говорили ранее, CALCULATE является единственной функцией в DAX, способной модифицировать контекст фильтра. Напомним, что все, что мы говорим о функции CALCULATE, касается также и CALCULATETABLE. На самом деле функция CALCULATE не изменяет существующий контекст фильтра, а создает новый, объединяя его параметры фильтра с сущест­ вующим контекстом фильтра. По выходу из функции CALCULATE созданный ей контекст фильтра удаляется, и в действие вступает прежний контекст фильтра. Мы уже представляли вам синтаксис функции CALCULATE: CALCULATE ( Выражение, Условие1, ... УсловиеN ) Первым параметром в функцию передается выражение, которое будет вычислено. Но перед тем как начать вычисление, функция CALCULATE анализирует аргументы фильтра, переданные в качестве остальных параметров, используя их для манипулирования контекстом фильтра. Первое, что очень важно уяснить, – это то, что переданные в функцию CALCULATE аргументы фильтра не являются логическими выражениями, это таб­ лицы. Всякий раз, когда в качестве параметра в функцию CALCULATE поступает логическое выражение, DAX переводит его в таблицу значений. В предыдущем разделе мы использовали следующее выражение: Contoso GM := CALCULATE ( [Gross Margin]; 'Product'[Brand] = "Contoso" ) -- Рассчитываем валовую прибыль -- в контексте фильтра, где бренд = Contoso Использование логического выражения в качестве второго параметра функции CALCULATE является лишь упрощением полноценной языковой конструкции, часто называемым синтаксическим сахаром. На самом деле предыдущую формулу нужно читать так: Contoso GM := CALCULATE ( [Gross Margin]; FILTER ( ALL ( 'Product'[Brand] ); 'Product'[Brand] = "Contoso" ------ Рассчитываем валовую прибыль с использованием допустимых значений Product[Brand] все значения Product[Brand], содержащие строку "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, вложенной в CALCULATE, сканирует выражение ALL(Product[Brand]), так что все ранее наложенные фильтры по брендам перезаписываются новым фильтром. Более очевидным такое поведение становится в отчете с этой мерой и срезом по брендам. На рис. 5.4 мера Contoso Sales показывает одно и то же значение во всех строках, и оно совпадает со столбцом Sales Amount для бренда Contoso. Рис. 5.4 Мера Contoso Sales перезаписывает существующий фильтр при помощи фильтра по Contoso 148 Глава 5 Функции CALCULATE и CALCULATETABLE В каждой строке отчета создается свой контекст фильтра, включающий конкретный бренд. Например, в строке с брендом Litware контекст фильтра, установленный в отчете изначально, включает только это значение Litware и больше ничего. Функция CALCULATE оценивает свой аргумент фильтра, возвращающий таблицу с брендами, содержащую только бренд Contoso. Созданный фильтр перезаписывает существующий фильтр, который был установлен на тот же столбец. Графическое представление этого процесса можно видеть на рис. 5.5. Рис. 5.5 Фильтр по бренду Litware перезаписан фильтром по Contoso из функции CALCULATE Функция CALCULATE не перезаписывает весь исходный контекст фильт­ра. Она заменяет на новые фильтры по столбцам, которые присутствуют и в старом контексте, и в новом. Фактически если заменить срез в отчете, вынеся в строки категории товаров вместо брендов, результат будет иным, что видно по рис. 5.6. Теперь в отчете присутствует срез по столбцу Product[Category], тогда как функция CALCULATE при вычислении меры Contoso Sales применяет фильтр на столбец Product[Brand]. Два фильтра воздействуют на разные столбцы таблицы Product. Таким образом, никакой перезаписи не происходит, и оба фильт­ ра объединяются в единый контекст фильтра. В результате в каждой строке новой меры показываются продажи товаров конкретной категории, входящих в бренд Contoso. Графически этот сценарий показан на рис. 5.7. Глава 5 Функции CALCULATE и CALCULATETABLE 149 Рис. 5.6 Если отчет изначально фильтруется по категориям, то фильтр по брендам просто объединится с ранее настроенным контекстом фильтра Рис. 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. Рис. 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. Посмотрите на следующее определение меры: All Category Sales := CALCULATE ( [Sales Amount]; ALL ( 'Product'[Category] ) ) -- Меняет контекст фильтра -- для суммы продаж так, -- чтобы были видны все (ALL) категории Функция ALL удаляет фильтр по столбцу Product[Category] в текущем контексте фильтра. Следовательно, в каждой ячейке таблицы будет проигнорирован фильтр, установленный на категорию, а именно тот фильтр, который был наложен в строке. Посмотрите на рис. 5.9. Вы видите, что во всех строках таблицы в мере All Category Sales показывается одно и то же число, а именно итог по мере Sales Amount. Рис. 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, где мы также вынесли поле Product[Color] в строки – на второй уровень детализации. Рис. 5.10 Добавление цвета товара в отчет привело к неожиданным результатам на этом уровне 154 Глава 5 Функции CALCULATE и CALCULATETABLE Похоже, на уровне категорий мы получили правильные проценты, тогда как на уровне цветов цифры не соответствуют действительности. Проценты по цветам при суммировании не бьются ни с итогами по категориям, ни с общей долей, равной 100 %. Чтобы узнать, что значат и как рассчитываются конкретные значения в отчете, полезно взять для примера одну ячейку и попытаться понять, что при ее вычислении происходит с контекстом фильтра. Посмотрите на рис. 5.11. Рис. 5.11 Функция ALL с Product[Category] удаляет фильтр с категории товаров, но оставляет его по цветам Исходный контекст фильтра, созданный в отчете, содержал фильтры по категориям и цветам. Фильтр по цветам не был перезаписан функцией CALCULATE – она затронула только фильтр по категориям. В результате в итоговый контекст фильтра вошел лишь фильтр по цветам. Следовательно, в знаменателе при расчете процента будет содержаться сумма продаж по всем товарам конкретного цвета (в рассматриваемой строке – черного (Black)) и всех без исключения категорий. Нельзя сказать, что эти ошибки в расчетах стали для нас неожиданностью. В нашей формуле изначально была прописана работа с фильтром по категориям товаров, все остальные возможные фильтры она не затрагивает. Та же самая формула в другом отчете великолепно отработает. Смотрите, что будет, если Глава 5 Функции CALCULATE и CALCULATETABLE 155 группировки по строкам поменять местами – сначала поставить цвета, а лишь затем категории товаров. Такой отчет показан на рис. 5.12. Рис. 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, но проблемы вернутся, если добавить в отчет еще один срез. Рис. 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 вы можете видеть вывод новой меры. Рис. 5.14 Функция ALL с таблицей Product в качестве аргумента удаляет фильтры со всех ее столбцов До сих пор вы видели, что совместное использование функций CALCULATE и ALL позволяет удалять фильтры со столбца, нескольких столбцов и целой таб­ лицы. Истинная мощь функции CALCULATE заключается в ее возможностях управлять контекстами фильтра, но даже этим ее потенциал не ограничивается. Фактически вы можете осуществлять срезы и вычислять проценты сразу по нескольким таблицам из модели данных. Например, если вы захотите сделать выборку по категориям товаров и континенту проживания покупателя, последняя созданная нами мера не даст ожидаемых результатов, что видно по рис. 5.15. Рис. 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, ожидаемо оказался верным. Рис. 5.16 Использование ALL с двумя таблицами позволило удалить фильтры с обеих C двумя таблицами в 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. Рис. 5.17 Функция ALL с таблицей фактов в качестве аргумента удаляет также фильтры со всех связанных таблиц Перед тем как завершить этот длинный пример с вычислением процентов, мы покажем вам еще один способ управления контекстами фильтра. Как вы видите по рис. 5.17, все проценты, как и ожидалось, вычислены относительно общих итогов. А что, если нам понадобится подсчитать долю продаж в рамках каждого года? В этом случае новый контекст фильтра, созданный функцией CALCULATE, должен быть соответствующим образом подготовлен. А именно 160 Глава 5 Функции CALCULATE и CALCULATETABLE в знаменателе должны подсчитываться итоги по всем продажам без учета любых фильтров, за исключением текущего года. Этого можно добиться следующими двумя действиями: удалить фильтры с таблицы фактов; восстановить фильтр по году. Имейте в виду, что оба условия будут действовать одновременно, даже если кажется, что это два последовательных шага. Вы уже умеете удалять все фильт­ ры с таблицы фактов. Теперь пришло время научить восстанавливать сущест­ вующий фильтр. Примечание В этой главе мы ставим себе цель научить вас базовым техникам управления контекстами фильтра. Позже мы покажем более простой способ решить задачу с подсчетом процента в рамках видимых в таблице итогов – при помощи функции ALLSELECTED. В главе 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 ( Date[Calendar Year] ): Глава 5 Функции CALCULATE и CALCULATETABLE 161 –– функция ALL ( Sales ) удаляет фильтр с таблицы Sales; –– функция VALUES ( Date[Calendar Year] ) выполняется в исходном контексте фильтра, в котором присутствует значение CY 2007. И именно его функция и возвращает как единственное значение, видимое в текущем контексте фильтра. Рис. 5.18 Функция VALUES позволяет частично восстановить контекст фильтра путем извлечения столбцов из исходного контекста Рис. 5.19 Важно понять, что функция VALUES выполняется в рамках исходного контекста фильтра Два аргумента фильтра функции CALCULATE применяются к текущему контексту фильтра, в результате чего создается новый контекст фильтра, содержа162 Глава 5 Функции CALCULATE и CALCULATETABLE щий единственный фильтр Calendar Year. В знаменателе формулы вычисляется общая сумма продаж в рамках контекста фильтра, состоящего из одного года CY 2007. Крайне важно уяснить, что аргументы фильтра функции CALCULATE оцениваются в рамках исходного контекста фильтра, в котором эта функция вызывается. Фактически функция CALCULATE меняет контекст фильтра, но это происходит только после оценки аргументов фильтра. Использование функции ALL для таблицы с последующим вызовом функции VALUES для столбца является распространенной техникой для замены контекста фильтра на фильтр по отдельному столбцу. Примечание Предыдущий пример также можно было бы решить при помощи функции ALLEXCEPT. При этом семантика использования связки ALL/VALUES отличается от применения функции ALLEXCEPT. В главе 10 мы подробно расскажем о том, чем именно отличается использование функции ALLEXCEPT от последовательности ALL/VALUES. Вы, наверное, заметили по этим примерам, что сама по себе функция CALCULATE не так уж и сложна. Ее поведение довольно просто описать. В то же время сложность кода, в котором активно используется функция CALCULATE, заметно возрастает. На самом деле все, что вам нужно, – это сосредоточить внимание на контекстах фильтра и понять, как именно функция CALCULATE их создает. Простое вычисление процентов сопряжено с большими сложностями, кроющимися в мелочах. Если не понять должным образом, как работают контексты вычисления, DAX останется для вас загадкой. Ключ ко всем тонкостям этого языка находится как раз в искусном управлении контекстами вычисления. При этом в рассмотренных нами примерах было всего по одной функции CALCULATE. В действительно сложных формулах количество одновременно использующихся контекстов нередко доходит до четырех-пяти, и в них вы можете увидеть не одну функцию CALCULATE. Было бы неплохо, если бы вы прочитали этот раздел о расчетах процентов как минимум дважды. По опыту можем сказать, что второе прочтение всегда дается легче, и человек обращает гораздо больше внимания на важные нюансы кода. Мы решили показать вам этот сложный пример, чтобы подчеркнуть важность освоения теоретической базы при работе с функцией CALCULATE. Незначительные изменения в коде способны кардинальным образом повлиять на результаты вычислений. После повторного прочтения предлагаем вам переходить к следующим разделам, где мы больше внимания уделим теории, а не практике. Введение в функцию KEEPFILTERS В предыдущих разделах вы узнали, что аргументы фильтра функции CALCULATE перезаписывают все существующие фильтры по одним и тем же столбцам. Таким образом, следующая мера вернет продажи по всей категории Audio вне зависимости от того, были ли ранее наложены какие-то фильтры на столбец Product[Category]: Глава 5 Функции CALCULATE и CALCULATETABLE 163 Audio Sales := CALCULATE ( [Sales Amount]; 'Product'[Category] = "Audio" ) Как видно по рис. 5.20, новая мера по всем строкам заполнена одним и тем же значением из категории Audio. Рис. 5.20 Мера Audio Sales во всех строках выводит сумму продаж по категории Audio Функция CALCULATE перезаписывает существующие фильтры по столбцам, на которые накладываются новые фильтры. Все оставшиеся столбцы контекста фильтра остаются неизменными. Если же вы не хотите, чтобы существующие фильтры перезаписывались, можете обернуть аргумент фильтра в функцию KEEPFILTERS. Например, если вы хотите показывать сумму продаж по категории Audio в случае ее присутствия в контексте фильтра, а в противном случае выводить пустое значение, вы можете написать следующую формулу: Audio Sales KeepFilters := CALCULATE ( [Sales Amount]; KEEPFILTERS ( 'Product'[Category] = "Audio" ) ) Функция KEEPFILTERS представляет собой второй модификатор функции CALCULATE, первым был ALL. Позже в этой главе мы еще поговорим про модификаторы функции CALCULATE. KEEPFILTERS меняет подход функции CALCULATE к применению фильтров в новом контексте фильтра. В этом случае, вместо того чтобы перезаписывать существующий фильтр по одному и тому же столбцу, функция просто добавляет новый фильтр к предыдущему. В результате значение окажется видимым только в тех ячейках, где отфильтрованная категория была включена в исходный контекст фильтра. Вы можете видеть это на рис. 5.21. Функция KEEPFILTERS делает ровно то, что и должна, исходя из названия. Она сохраняет существующий фильтр и добавляет к контексту фильтра новый. На рис. 5.22 графически показана работа этого модификатора. 164 Глава 5 Функции CALCULATE и CALCULATETABLE Рис. 5.21 В мере Audio Sales KeepFilters продажи по категории Audio показаны только в соответствующей строке и в итогах Рис. 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. Рис. 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 будет обязательным. В таких случаях модификатор KEEPFILTERS может обрамлять функцию 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]. Важной особенностью предикатов, используемых в качестве аргументов фильтров в функции CALCULATE, является то, что они по своей сути являются таблицами, а не условиями. По первому из предыдущих двух фрагментов кода можно понять, что функция 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 ) Такая формула не сработает, поскольку аргумент фильтра функции CALCULATE ссылается сразу на два столбца в выражении. Следовательно, 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. Quantity Net Price 1 1000.00 1 1001.00 1 1199.00 … … 2 500.00 2 500.05 … … 3 333.34 … Рис. 5.24 Фильтр по нескольким столбцам включает в себя все сочетания полей Quantity и Net Price, произведение которых будет не меньше 1000 Глава 5 Функции CALCULATE и CALCULATETABLE 169 Результат применения такого фильтра показан на рис. 5.25. Рис. 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. Результат может вас удивить. Рис. 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 ) ); 'Product'[Category] = "Audio"; Sales[Net Price] >= 500 ) Из этого фрагмента кода следует, что вызов ALL во внутренней функции CALCULATE полностью игнорирует фильтр по Sales[Net Price], установленный во внешней CALCULATE. Здесь мы можем использовать модификатор KEEPFILTERS, чтобы избежать перезаписи фильтров: 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. Рис. 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 со вспомогательными переменными получилась более многословной по сравнению с остальными, но ее предпочтительно использовать, если вы имеете дело с достаточно сложными выражениями с необходимостью явного использования функции FILTER. В таких случаях использование переменных помогает понять, что фильтры оцениваются прежде, чем будет вычислено выражение. Глава 5 Функции CALCULATE и CALCULATETABLE 173 Это правило оказывается полезным при использовании вложенных функций CALCULATE. В таком случае внешние фильтры будут оценены первыми, а внутренние – последними. Понимание работы вложенных функций CALCULATE очень важно, ведь вы сталкиваетесь с этим каждый раз, когда вкладывае­ те меры друг в друга. Взгляните на следующий пример, где мера 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. 174 Глава 5 Функции CALCULATE и CALCULATETABLE Рис. 5.28 Последние три меры вернули одинаковые результаты, отражающие продажи по красным товарам Рассмотрим последовательность выполнения операций и оценку фильт­ров на другом примере: Sales YB := CALCULATE ( CALCULATE ( [Sales Amount]; 'Product'[Color] IN { "Yellow"; "Black" } ); 'Product'[Color] IN { "Black"; "Blue" } ) Изменение контекста фильтра в мере Sales YB показано графически на рис. 5.29. Рис. 5.29 Внутренний фильтр перезаписывает внешний Глава 5 Функции CALCULATE и CALCULATETABLE 175 Как вы уже видели ранее, внутренний фильтр по столбцу Product[Color] перезаписывает внешние. Таким образом, мера выдаст результаты по товарам желтого (Yellow) и черного (Black) цветов. Использование модификатора KEEPFILTERS во внутренней функции CALCULATE позволит сохранить внешний фильтр: Sales YB KeepFilters := CALCULATE ( CALCULATE ( [Sales Amount]; KEEPFILTERS ( 'Product'[Color] IN { "Yellow"; "Black" } ) ); 'Product'[Color] IN { "Black"; "Blue" } ) Изменение контекста фильтра в мере Sales YB KeepFilters показано на рис. 5.30. Рис. 5.30 Использование модификатора KEEPFILTERS позволяет функции CALCULATE не перезаписывать существующий контекст фильтра Поскольку оба фильтра сохранились, их наборы, по сути, пересеклись. Таким образом, в итоговом контексте фильтра единственным видимым цветом товаров останется черный (Black), поскольку только он присутствует в обоих фильтрах. При этом порядок следования аргументов фильтра внутри одной и той же функции CALCULATE не имеет значения – все они применяются к контексту фильтра независимо друг от друга. 176 Глава 5 Функции CALCULATE и CALCULATETABLE Преобразование контекста В главе 4 мы несколько раз повторили, что контекст строки и контекст фильтра представляют собой совершенно разные концепции. И это правда. Как правда и то, что функция CALCULATE обладает уникальной способностью трансформировать контекст строки в контекст фильтра. Эта операция получила название преобразования контекста (context transition) и описывается следующим образом: Функция CALCULATE отменяет действие любого контекста строки. Она автоматически преобразует все столбцы из текущего контекста строки в аргументы фильтра, используя их фактические значения в строке, по которой осуществляется итерация. Концепцию преобразования контекста новичкам в DAX будет усвоить нелегко. Даже специалисты с опытом могут испытывать проблемы, когда сталкиваются с тонкими нюансами этой концепции. Мы абсолютно уверены, что данного ранее определения преобразования контекста будет совершенно недостаточно для всестороннего понимания этой возможности языка. В этой главе мы попытаемся объяснить вам, что из себя представляет преобразование контекста, на примерах, постепенно двигаясь от простого к сложному. Но перед этим необходимо убедиться, что вы досконально понимаете, что такое контекст строки и контекст фильтра. Повторение темы контекста строки и контекста фильтра Давайте повторим все важные факты, касающиеся контекста строки и контекста фильтра, при помощи рис. 5.31, на котором показан отчет по продажам с вынесенными на строки брендами и вспомогательная диаграмма, описывающая схему работы контекстов. Таблицы Products и Sales на диаграмме не отражают реальные данные. В них показано несколько строк для облегчения понимания общей картины. Рис. 5.31 На диаграмме схематично показано выполнение простой итерации при помощи функции SUMX Глава 5 Функции CALCULATE и CALCULATETABLE 177 Приведенные ниже комментарии помогут вам проверить свое понимание полного процесса вычисления меры Sales Amount по строке с брендом Contoso: в отчете создан контекст фильтра, включающий в себя фильтр Pro­ duct[Brand] = “Contoso”; действие фильтра распространяется на всю модель данных, включая таб­ лицы Product и Sales; контекст фильтра ограничивает набор строк в итерациях для функции SUMX по таблице Sales. В результате функция SUMX проходит только по строкам из таблицы Sales, относящимся к товарам бренда Contoso; в таблице Sales на представленном рисунке содержится две строки по товару A, принадлежащему бренду Contoso; функция SUMX проходит по этим двум строкам. Итогом первой итерации будет результат 1*11,00, составляющий 11,00, а по второй – 2*10,99, что дает 21,98; функция SUMX возвращает сумму полученных на предыдущем шаге результатов; во время осуществления итераций по таблице Sales функция SUMX проходит только по видимой части таблицы, создавая контекст строки для каждой видимой строки; в первой строке таблицы значение столбца Sales[Quantity] равно 1, а Sales[Net Price] – 11. В следующей строке значения будут уже другие. В каждом столбце есть текущее значение, зависящее от строки, по которой в данный момент осуществляется итерация. Для каждой отдельной строки значения в столбцах могут отличаться; во время итерации одновременно существуют контекст строки и контекст фильтра. При этом контекст фильтра остается неизменным (с фильт­ром по бренду Contoso), поскольку ни одна функция CALCULATE его не ме­ няла. В свете предстоящего разговора о преобразовании контекстов последний пункт приобретает важное значение. Во время осуществления итераций по таблице контекст фильтра действительно остается неизменным и содержит фильтр по бренду Contoso. Контекст строки в это время занят, собственно, выполнением итераций по таблице Sales. Каждый столбец таблицы Sales содержит свое значение, и контекст строки предоставляет очередное значение, считывая его из текущей строки. Помните о том, что контекст строки занят осуществлением итераций по таблице, контекст фильтра этим не занимается. Это очень важное замечание. Мы настоятельно рекомендуем вам дважды убедиться в том, что вы досконально поймете следующий сценарий. Представьте, что вы создали меру для подсчета количества строк в таблице Sales со следующей формулой: NumOfSales := COUNTROWS ( Sales ) В отчете данная мера будет показывать количество транзакций в таблице Sales в рамках текущего контекста фильтра. В результате, показанном на рис. 5.32, нет ничего неожиданного: для каждого бренда свое количество транз­акций. 178 Глава 5 Функции CALCULATE и CALCULATETABLE Рис. 5.32 Мера NumOfSales подсчитывает количество строк, видимых в текущем контексте фильтра в таблице Sales В таблице Sales присутствует 37 984 записи для бренда Contoso, а это значит, что именно столько итераций будет сделано по таблице. Мера Sales Amount, которую мы обсуждали выше, справится со своей работой за 37 984 операции умножения. Сможете ли вы, вооружившись полученными знаниями, ответить на вопрос о том, какой результат покажет следующая мера в строке с брендом Contoso? Sum Num Of Sales := SUMX ( Sales; COUNTROWS ( Sales ) ) Не спешите с ответом. Подумайте хорошенько и дайте осмысленную версию. В следующем абзаце мы дадим правильный ответ. Контекст фильтра включает в себя бренд Contoso. По предыдущим примерам мы знаем, что функция SUMX должна сделать ровно 37 984 итерации. И для каждой из этих 37 984 строк функция SUMX подсчитает количество видимых строк из таблицы Sales в текущем контексте фильтра. При этом контекст фильт­ ра все это время будет оставаться неизменным, так что для каждой из строк функция COUNTROWS будет выдавать ответ 37 984. Следовательно, функция SUMX просуммирует значение 37 984 ровно 37 984 раза. И результатом меры будет 37 984 в квадрате. Вы можете убедиться в этом, взглянув на рис. 5.33, на котором показан вывод этого отчета. Теперь, когда вы освежили память относительно контекста строк и контекста фильтра, можно приступать к изучению концепции преобразования контекста. Введение в преобразование контекста Контекст строки создается всякий раз, когда по таблице начинают осуществ­ ляться итерации. Внутри одной итерации значения столбцов зависят от контекста строки. Для демонстрации этого процесса подойдет хорошо знакомая вам мера: Sales Amount := SUMX ( Глава 5 Функции CALCULATE и CALCULATETABLE 179 Sales; Sales[Quantity] * Sales[Unit Price] ) Рис. 5.33 В мере Sum Num Of Sales выводится значение NumOfSales в квадрате, поскольку для каждой строки производится подсчет строк На каждой итерации в столбцах Quantity и Unit Price содержится свое значение, зависящее от текущего контекста строки. В предыдущем разделе мы показывали, что если выражение внутри итерации не привязано к контексту строки, оно будет вычисляться в контексте фильтра. А значит, результаты могут быть неожиданными, по крайней мере для новичков в DAX. Несмотря на это, вы вольны использовать любые функции внутри контекста строки. Но среди прочих сильно выделяется функция CALCULATE. Вызванная в рамках контекста строки, она отменяет его действие еще до вычисления своего выражения. Внутри выражения, вычисляемого функцией CALCULATE, все предыдущие контексты строки утрачивают свое действие. Таким образом, следующее выражение для меры будет недопустимым, выдаст синтаксическую ошибку: Sales Amount := 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, чтобы получить первое представление о том, как работает преобразование контекста. Мы уменьшили количество столбцов для простоты восприятия. Рис. 5.34 Вызов функции CALCULATE в контексте строки ведет к созданию контекста фильтра с образованием фильтра для каждого столбца таблицы После начала итераций функция CALCULATE попадает в первую строку таб­ лицы и пытается вычислить выражение SUM ( Sales[Quantity] ). При этом она создает по одному аргументу фильтра для каждого столбца в таблице, по которой осуществляются итерации. В нашем примере таких столбцов три: Pro­ duct, Quantity и Net Price. Созданный в результате преобразования из контекста строки контекст фильтра содержит текущие значения (A, 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 следует довольно осторожно; итогом преобразования контекста не обязательно будет одна строка. Исходный контекст строки, в котором вызывается функция CALCULATE, всегда указывает ровно на одну строку. Контекст строки идет последовательно – от строки к строке. Но когда контекст строки переходит в контекст фильтра посредством преобразования контекста, вновь образованный контекст будет фильтровать все строки, удовлетворяющие выбранным значениям. Таким образом, неправильно говорить, что преобразование контекста ведет к созданию контекста фильтра с одной строкой. Это очень важный момент, и мы вернемся к нему в следующих разделах; преобразование контекста задействует столбцы, не присутствующие в формуле. Несмотря на то что столбцы, используемые в фильтре, скрыты, они являются частью выражения. Это делает любую формулу, в которой есть функция CALCULATE, намного сложнее, чем кажется на первый взгляд. При использовании преобразования контекста все столбцы таблицы становятся скрытыми аргументами фильтра. Такое поведение может приводить к образованию неожиданных зависимостей, и мы поговорим об этом далее в этом разделе; 182 Глава 5 Функции CALCULATE и CALCULATETABLE преобразование контекста создает контекст фильтра на основании контекста строки. Вы должны помнить нашу любимую мантру: «Контекст фильтра фильтрует, контекст строки осуществляет итерации по таблице». Преобразуя контекст строки в контекст фильтра, мы, по сути, меняем саму природу фильтра. Вместо прохода по одной строке DAX осуществляет фильтрацию всей модели данных, используя при этом связи между таблицами. Иными словами, преобразование контекста, примененное к одной таблице, способно распространить фильтрацию далеко за пределы этой отдельной таблицы, в которой был изначально создан контекст строки; преобразование контекста происходит всегда, когда есть активный контекст строки. Всякий раз, когда вы будете использовать функцию CALCULATE в вычисляемом столбце, будет происходить преобразование контекста. При создании вычисляемого столбца контекст строки появляется автоматически, и этого достаточно для того, чтобы произошло преобразование контекста; преобразование контекста затрагивает все контексты строки. Когда мы имеем дело с вложенными итерациями по разным таблицам, преобразование контекста будет учитывать все активные контексты строки. Таким образом, эта операция отменит действие всех из них и создаст аргументы фильтра для всех без исключения столбцов, которые участвуют в итерациях во всех активных контекстах строки; преобразование контекста отменяет действие контекстов строки. Несмотря на то что мы повторили это уже не один раз, важно обратить на этот аспект особое внимание. Ни один из внешних контекстов строк не будет действовать внутри выражения, вычисляемого при помощи функции CALCULATE. Все внешние контексты строки будут трансформированы в соответствующие поля контекста фильтра. Как мы уже говорили ранее, многие из этих аспектов нуждаются в дополнительном объяснении. В оставшейся части данного раздела мы углубимся в некоторые очень важные моменты. И хотя мы написали об этих аспектах как о предостережениях, на самом деле это просто особенности концепции. Если игнорировать их, результаты вычислений могут оказаться непредсказуемыми. Но когда вы освоите этот прием, то сможете использовать его по своему усмот­рению. Единственным отличием между странным поведением и полезной возможностью – по крайней мере, в DAX – является глубина знаний в этой области. Преобразование контекста в вычисляемых столбцах Значение в вычисляемом столбце рассчитывается в рамках контекста строки. Следовательно, использование функции CALCULATE в вычисляемом столбце автоматически приведет к преобразованию контекста. Давайте применим эту особенность в таблице Product, для того чтобы особым образом пометить товары, продажа которых приносит компании более 1 % от всех продаж. Чтобы произвести требуемое вычисление, нам нужны два значения: сумма продаж по конкретному товару и общая сумма продаж. Для вычисления первоГлава 5 Функции CALCULATE и CALCULATETABLE 183 го из них необходимо отфильтровать таблицу Sales так, чтобы в расчет брался только этот товар, тогда как при расчете общей суммы продаж нужно снять все фильтры по товарам. Посмотрите на представленный ниже код: 'Product'[Performance] = VAR TotalSales = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) VAR CurrentSales = 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 -- Общая сумма продаж -- Sales не отфильтрована, -- так что считаются все продажи -- Происходит преобразование контекста -- Продажи только по одному товару -- Здесь мы вычисляем продажи -- по конкретному товару -- 1 %, выраженный как число -- Очень популярный товар -- Обычный товар Вы, наверное, заметили, что между двумя переменными есть лишь одно небольшое различие: переменная TotalSales рассчитывается путем осуществления обычных итераций, а в CurrentSales тот же самый код DAX заключен в функцию CALCULATE. Поскольку мы имеем дело с вычисляемым столбцом, при встрече с функцией CALCULATE происходит преобразование контекста строки в контекст фильтра. При этом контекст фильтра распространяется на всю модель данных, достигая таблицы Sales и фильтруя ее по одному выбранному товару. Таким образом, несмотря на внешнее сходство, эти переменные выполняют совершенно разные функции. В TotalSales подсчитывается общая сумма продаж по всем товарам, поскольку контекст фильтра в рамках вычисляемого столбца всегда пустой и не фильтрует товары. В то же время CurrentSales отражает сумму продаж по конкретному товару благодаря преобразованию кон­ текста, выполненному в функции CALCULATE. Оставшаяся часть кода вопросов вызывать не должна – здесь просто выполняется проверка на соответствие определенному условию и присвоение товару соответствующего статуса. Созданный вычисляемый столбец можно использовать в отчете, как показано на рис. 5.35. В коде вычисляемого столбца Performance мы использовали функцию CALCULATE и инициированное ей преобразование контекста. Перед тем как двигаться дальше, давайте посмотрим, все ли нюансы мы учли. В таблице Product достаточно мало строк – всего несколько тысяч. Так что производительность вычисляемого столбца просесть не должна. Контекст фильтра, созданный функцией CALCULATE, включает в себя все столбцы. Есть ли у нас гарантия, 184 Глава 5 Функции CALCULATE и CALCULATETABLE что в переменную CurrentSales попадут продажи только по выбранному товару? В нашем конкретном случае да, поскольку у нас все товары уникальные – это обеспечивается тем, что в таблице Product содержится столбец ProductKey с уникальными значениями. Следовательно, образованный путем преобразования из контекста строки контекст фильтра будет гарантированно содержать одну строку. Рис. 5.35 Лишь четыре товара получили статус «High Performance» (Очень популярный) В этом случае мы полностью можем полагаться на преобразование контекста, ведь каждая строка в таблице, по которой осуществляются итерации, уникальная в своем роде. Но так будет не всегда. И сейчас мы продемонстрируем ситуацию, не подходящую для преобразования контекста. Создадим следующий вычисляемый столбец в таблице Sales: Sales[Wrong Amt] = 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 – ошибочное. Рис. 5.36 Большинство значений в столбцах совпадают Как видите, значения в столбцах отличаются только по бренду Fabrikam и в итоговой строке. Дело в том, что в таблице Sales есть несколько дублей по товарам бренда Fabrikam, и именно это привело к двойным подсчетам. Но важно то, что присутствие таких строк в столбце может быть вполне обоснованным: один и тот же покупатель мог приобрести один товар в том же самом магазине и в тот же день – утром и вечером, тогда как в таблице Sales хранится только дата без указания времени. А поскольку таких случаев будет очень мало, в основной своей массе результаты будут выглядеть корректно. В то же время ошибка будет, поскольку в своих расчетах мы опирались на данные из таблицы с дубликатами. И чем больше будет дубликатов, тем сильнее будет расхождение. В этом случае полагаться на преобразование контекста будет ошибкой. Когда нет гарантии, что все записи в таблице будут уникальными, прием с преобразованием контекста может оказаться небезопасным. И эксперт по языку DAX должен уметь предвидеть такие ситуации. Кроме того, в таблице Sales может быть много записей – до нескольких миллионов. Так что наш вычисляемый столбец не только рискует выдавать неправильные результаты, но и рассчитываться он может очень долго. Преобразование контекста в мерах Хорошее понимание концепции преобразования контекста очень важно и по причине следующей интересной особенности языка DAX: 186 Глава 5 Функции CALCULATE и CALCULATETABLE Каждая ссылка на меру неявным образом обрамляется в функцию CALCULATE. Это приводит к тому, что обращение к мере в присутствии любого контекста строки автоматически ведет к преобразованию контекста. Именно поэтому в DAX так важно соблюдать единообразные принципы именования при обращении к столбцам (с обязательным указанием названия таблицы) и мерам (без указания таблицы). Таким образом, всегда важно помнить о возможности возникновения неявного преобразования контекста при чтении и написании кода на DAX. Приведенное нами короткое определение в начале этого раздела нуждается в подробном пояснении с примерами. Первое, что стоит отметить, – любая мера при обращении к ней автоматически заключается в функцию CALCULATE. Посмотрите на следующий код, где мы создаем меру 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[Quantity] * Sales[Net Price] ) VAR CurrentSales = CALCULATE ( 188 -- Общая сумма продаж -- Sales не отфильтрована, -- так что считаются все продажи -- Происходит преобразование контекста Глава 5 Функции CALCULATE и CALCULATETABLE SUMX ( Sales; -- Продажи только по одному товару Sales[Quantity] * Sales[Net Price] -- Здесь мы вычисляем продажи ) -- по конкретному товару ) VAR Ratio = 0.01 VAR Result = IF ( CurrentSales >= TotalSales * Ratio; "High Performance product"; "Regular product" ) RETURN Result -- 1 %, выраженный как число -- Очень популярный товар -- Обычный товар Код с итерациями внутри функции SUMX действительно в точности повторяется в обеих переменных. Разница состоит только в том, что в одном случае он заключен в функцию CALCULATE, а в другом – нет. Кажется, что можно было бы разместить этот повторяющийся код в отдельной мере и использовать ее в переменных. Этот вариант выглядит очень логичным, особенно если повторяющийся код не будет состоять из простой итерации при помощи функции SUMX, а будет длинным и сложным. К сожалению, такой способ сократить формулу неприменим, поскольку DAX автоматически заключает код, на который ссылается мера, в функцию CALCULATE. Представьте, что мы создали меру Sales Amount, а затем в вычисляемом столбце дважды обратились к ней при объявлении переменных: один раз с использованием функции CALCULATE, другой – без. Sales Amount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) 'Product'[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 de190 Глава 5 Функции CALCULATE и CALCULATETABLE pendencies) в формулах. В этом разделе вы узнаете, что такое циклические зависимости и как избегать их появления в вашей модели данных. Перед тем как приступать к обсуждению циклических зависимостей, стоит поговорить о простых линейных зависимостях (linear dependencies) на конкретных примерах. Посмотрите на такую формулу для вычисляемого столбца: Sales[Margin] = Sales[Net Price] - Sales[Unit Cost] Образованный вычисляемый столбец Margin зависит от двух столбцов: Net Price и Unit Cost. Это означает, что для того, чтобы вычислить значение Margin, 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 выдаст ошибку, говорящую об обнаружении циклической зависимости. Циклические зависимости возникают в коде не так часто, поскольку разработчики делают все, чтобы их не было. К тому же сама проблема понятна всем. B не может зависеть от A, если A зависит от B. Но бывает, что циклические зависимости возникают. Не потому, что разработчик этого захотел, а из-за недопонимания всех тонкостей языка DAX. В этом сценарии мы будем использовать функцию CALCULATE. Представьте, что в таблице Sales есть вычисляемый столбец со следующей формулой: Sales[AllSalesQty] = CALCULATE ( SUM ( Sales[Quantity] ) ) Попробуйте ответить на вопрос: от какого столбца зависит значение в вычисляемом столбце AllSalesQty? На первый взгляд кажется, что единственным столбцом, от которого зависит AllSalesQty, является Sales[Quantity], поскольку другие столбцы в выражении просто не упоминаются. Как же легко забыть про действительную семантику функции CALCULATE и связанную с ней концепцию преобразования контекста! Поскольку функция CALCULATE вызвана в контексте строки, текущие значения всех столбцов таблицы будут включены в выражение, пусть и незримо. Таким образом, полное выражение, которое поступит на исполнение движку DAX, будет выглядеть так: Глава 5 Функции CALCULATE и CALCULATETABLE 191 Sales[AllSalesQty] = CALCULATE ( SUM ( Sales[Quantity] ); Sales[ProductKey] = <CurrentValueOfProductKey>; Sales[StoreKey] = <CurrentValueOfStoreKey>; ...; Sales[Margin] = <CurrentValueOfMargin> ) Как видите, список столбцов, от которых зависит AllSalesQty, включает в себя полный набор столбцов таблицы. Поскольку функция CALCULATE была вызвана в контексте строки, выражение автоматически получает зависимости от всех без исключения столбцов таблицы, по которой осуществляются итерации. Это более очевидно в случае с вычисляемым столбцом, в котором контекст строки присутствует по умолчанию. Если написать один вычисляемый столбец с использованием функции CALCULATE, ничего страшного не произойдет. Проблема возникнет, если создать сразу два вычисляемых столбца в таблице с применением функции CALCULATE, инициирующей преобразование контекста для обоих столбцов. Так что попытка создания следующего вычисляемого столбца завершится неудачей: Sales[NewAllSalesQty] = CALCULATE ( SUM ( Sales[Quantity] ) ) Причина возникновения ошибки в том, что функция CALCULATE автоматически принимает все столбцы таблицы в качестве аргументов фильтра. А добавление в таблицу нового столбца влияет на определение других столбцов. Если бы DAX позволил нам создать столбец NewAllSalesQty, код двух вычисляемых столбцов выглядел бы примерно так: Sales[AllSalesQty] = 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> ) Как видите, две выделенные строки ссылаются друг на друга. Получается, что столбец AllSalesQty зависит от значения столбца NewAllSalesQty, который, в свою очередь, находится в зависимости от AllSalesQty. В результате мы получаем циклическую зависимость. 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. До сих пор мы рассматривали только работу с аргументами фильтра функции и преобразованием контекста. Но есть еще одна важная концепция, без понимания которой невозможно в полной мере овладеть навыками использования функции CALCULATE. Речь идет о модификаторах (modifier) этой функции. Ранее мы уже познакомились с двумя модификаторами функции CALCULATE: 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. Посредством этого модификатора функция CALCULATE способна активировать ту или иную связь во время вычисления выражения. Изначально в модели данных присутствуют как активные (active), так и неактивные (inactive) связи. Неактивные связи могут появиться, например, в случае наличия нескольких связей между таблицами, при этом активной в любой момент времени может быть только одна из них. Например, в таблице Sales мы можем хранить как дату заказа, так и дату поставки. Обычно анализ продаж производится на основании дат заказов, но для специфических мер вполне может потребоваться учет даты поставки. В этом случае вы можете изначально создать две связи между таблицами Sales и Date: одну на основании поля Order Date (Дата заказа), а вторую – на основании Delivery Date (Дата поставки). Модель данных при этом будет выглядеть как на рис. 5.37. Рис. 5.37 Таблицы Sales и Date объединены двумя связями, но активной в любой момент времени может быть только одна из них Глава 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. Рис. 5.38 Разница между продажами по дате заказа и дате поставки Используя модификатор USERELATIONSHIP для активации связи, важно иметь в виду один важный момент: связи определяются в момент использования ссылки на таблицу, а не в момент вызова RELATED или любой другой функции для работы со связями. Мы подробнее обсудим этот нюанс в главе 14, когда будем говорить о расширенных таблицах. Сейчас же достаточно будет одного несложного примера. Следующая мера для расчета суммы по товарам, доставленным в 2007 году, не сработает: 196 Глава 5 Функции CALCULATE и CALCULATETABLE Delivered Amount 2007 v1 := CALCULATE ( [Sales Amount]; FILTER ( Sales; CALCULATE ( RELATED ( 'Date'[Calendar Year] ); USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[Date] ) ) = "CY 2007" ) ) Фактически функция CALCULATE отменит действие контекста строки, созданного функцией FILTER во время итераций. Так что внутри выражения в CALCULATE нельзя использовать функцию RELATED. Одним из способов написать нужную нам формулу будет следующий: Delivered Amount 2007 v2 := CALCULATE ( [Sales Amount]; CALCULATETABLE ( FILTER ( Sales; RELATED ( 'Date'[Calendar Year] ) = "CY 2007" ); USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[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 в функции CALCULATE, все аргументы фильтра вычисляются с учетом этого модификатора – Глава 5 Функции CALCULATE и CALCULATETABLE 197 вне зависимости от порядка их следования. Например, в представленной мере Delivered Amount 2007 v3 модификатор USERELATIONSHIP будет оказывать влияние на предикат с использованием Calendar Year, несмотря на то что расположен позже. Такая особенность поведения осложняет применение альтернативных связей в вычисляемых столбцах. Ссылка на таблицу присутствует в определении вычисляемого столбца в неявном виде. Так что мы не можем контролировать этот момент и изменить такое поведение при помощи функции CALCULATE с модификатором USERELATIONSHIP. Важно отметить, что сам по себе модификатор USERELATIONSHIP не является фильтрующим элементом в выражении. Это не аргумент фильтра, а прос­то модификатор, регламентирующий применение остальных указанных фильтров к модели данных. Если внимательно посмотреть на определение меры Delivered Amount in 2007 v3, можно заметить, что в аргументе фильтра по 2007 году не указано, какую связь использовать: по столбцу Order Date или Delivery Date. Этот момент как раз и определяется модификатором USERELATIONSHIP. Таким образом, функция CALCULATE сначала модифицирует структуру модели данных, активируя нужную связь, и только после этого применяет аргумент фильтра. Если бы последовательность действий была иной, то есть аргумент фильтра всегда применялся бы с использованием текущей связи, мера показала бы неправильный результат. В области применения аргументов фильтра и модификаторов в функции CALCULATE действуют определенные правила старшинства. Первое правило гласит, что модификаторы в функции CALCULATE всегда применяются раньше аргументов фильтра, так что все фильтры накладываются уже на измененную версию модели данных. Более детально правила старшинства в функции CALCULATE мы обсудим позже. Модификатор 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 во время преобразования контекста. Фактически во время итераций по значениям столбцов Product[Color] и Product[Brand] функция SUMX вызывает CALCULATE как составную часть вычисления меры Sales Amount. В результате происходит преобразование конГлава 5 Функции CALCULATE и CALCULATETABLE 199 текста, и контекст строки превращается в контекст фильтра путем добавления аргументов фильтра для полей Color и Brand. А поскольку при этом был использован модификатор KEEPFILTERS, в момент преобразования контекста не будут перезаписаны текущие фильтры. Вместо этого будет выполнено их пересечение с новыми фильтрами. Это не самая распространенная техника использования модификатора KEEPFILTERS. В главе 10 мы рассмотрим несколько примеров на эту тему. Использование модификатора ALL в функции CALCULATE Как вы узнали в главе 3, ALL представляет собой табличную функцию. Кроме того, она может быть использована и в качестве модификатора функции CALCULATE, когда присутствует в ней как аргумент фильтра. Название функции остается тем же, но семантика использования ALL совместно с CALCULATE для многих может оказаться неожиданной. Глядя на следующий пример, можно было бы подумать, что функция ALL выдаст все годы и тем самым изменит контекст фильтра, сделав все годы видимыми: All Years Sales := CALCULATE ( [Sales Amount]; ALL ( 'Date'[Year] ) ) Однако это не так. Будучи использованной в качестве функции верхнего уровня в аргументе фильтра функции CALCULATE, ALL удаляет существующий фильтр вместо создания нового. Так что здесь эту функцию можно было бы назвать не ALL, а REMOVEFILTER. Но по историческим соображениям было решено оставить название функции неизменным. Мы же поясним на примере, как работает эта функция. Если воспринимать ALL как табличную функцию, можно интерпретировать работу CALCULATE так, как показано на рис. 5.39. Внутренний ALL по столбцу Date[Year] представляет собой функцию верхнего уровня в рамках CALCULATE. А значит, она ведет себя не как табличная функция. Здесь ее действительно более уместно было бы назвать REMOVEFILTER. Фактически, вместо того чтобы вернуть все годы, тут 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] ) ) Рис. 5.39 Можно представить, что ALL возвращает все годы и использует этот список для перезаписи существующего контекста фильтра Причина в том, что здесь ALL выступает в качестве модификатора функции CALCULATE. Следовательно, он будет применен раньше, чем KEEPFILTERS. Такие же правила старшинства распространяются и на все другие функции с префиксом ALL, в числе которых ALLSELECTED, ALLNOBLANKROW, ALLCROSS­FIL­ TERED и ALLEXCEPT. Обычно мы обращаемся к этой группе функций по общему имени ALL*. Как правило, функции ALL* выступают в качестве модификаторов CALCULATE, когда присутствуют в ней в виде функций верхнего уровня в аргументах фильтра. Глава 5 Функции CALCULATE и CALCULATETABLE 201 Рис. 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 приступает к оценке модификаторов USERELATIONSHIP, CROSSFILTER и ALL*. Этот шаг выполняется только после ша­га 3. Это очень важно, поскольку означает, что на данном этапе мы можем удалить последствия преобразования контекста путем использования функции ALL, как будет описано в главе 10. Модификаторы функции CALCULATE применяются только после выполнения преобразования контекста, чтобы можно было повлиять на его последствия. 5. Функция CALCULATE оценивает все явные аргументы фильтра в исходном контексте фильтра. На этом этапе происходит применение этих фильтров к новому контексту фильтра, созданному на шаге 4. Поскольку преобразование контекста к этому моменту уже выполнено, аргументы могут перезаписывать новый контекст фильтра – после удаления фильт­ ра (эти фильтры не удаляются при помощи модификаторов группы 204 Глава 5 Функции CALCULATE и CALCULATETABLE ALL*) и после обновления структуры связей модели. При этом оценка аргументов фильтра происходит в рамках исходного контекста фильтра и не подвержена влиянию со стороны других модификаторов или фильт­ ров из той же функции CALCULATE. Контекст фильтра, образованный в результате выполнения пятого шага, используется для вычисления выражения функции CALCULATE. ГЛ А В А 6 Переменные Переменные в языке DAX играют важную роль сразу по двум причинам: вопер­вых, они делают код более легким для восприятия, во-вторых, положительно влияют на его производительность. В данной главе мы подробно обсудим создание и использование переменных в DAX, а вопросы производительности и читаемости кода затрагиваются на протяжении всей книги. В самом деле, мы используем переменные почти в каждом примере, а иногда показываем версии формул с переменными и без них, чтобы вы почувствовали разницу. Гораздо позже, в главе 20, мы покажем случаи, когда переменные могут значительно увеличить производительность кода. Здесь же мы просто соберем воедино всю важную и полезную информацию о переменных. Введение в синтаксис переменных VAR В выражениях объявление переменной начинается с ключевого слова VAR, следом за чем идет обязательный блок RETURN, определяющий возвращаемый результат. Так выглядит типичный код, использующий переменную: VAR SalesAmt = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) RETURN IF ( SalesAmt > 100000; SalesAmt; SalesAmt * 1.2 ) В одном блоке может быть объявлено сразу несколько переменных, тогда как блок RETURN в блоке должен быть один. Очень важно отметить, что блоки VAR/RETURN по своей сути являются выражениями. А значит, их можно применять везде, где допустимо использовать выражения. Это позволяет нам объявить переменные внутри итерации или в составе более сложного выражения, как показано в примере ниже: VAR SalesAmt = 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 SalesAmount = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) VAR TotalCost = SUMX ( Sales; Sales[Quantity] * Sales[Unit Cost] ) VAR Margin = SalesAmount - 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[Quantity] * Sales[Unit Cost] ) VAR Margin = SalesAmount - TotalCost RETURN Margin Если же перенести объявление переменной Margin в начало меры, DAX не примет такой синтаксис. Дело в том, что в этом случае переменная Margin ссылается на переменные SalesAmount и TotalCost, которые в данный момент еще не объявлены: Margin := VAR Margin = SalesAmount - TotalCost -- Ошибка: SalesAmount и TotalCost не объявлены VAR SalesAmount = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) VAR TotalCost = SUMX ( Sales; Sales[Quantity] * Sales[Unit Cost] ) RETURN Margin Поскольку к переменной невозможно обратиться до ее объявления, нет рис­ ка создания циклических зависимостей между переменными или образования любого рода рекурсивных определений. Блоки VAR/RETURN допустимо вкладывать один в другой или содержать несколько таких блоков в одном выражении. Область видимости переменных (scope of variables) для каждого из этих сценариев будет своя. Например, в следующем фрагменте кода переменные LineAmount и LineCost объявлены в двух разных областях видимости, не вложенных друг в друга. Таким образом, ни в один момент времени мы не сможем обратиться сразу к обеим переменным в одном выражении: Margin := SUMX ( Sales, ( VAR LineAmount = Sales[Quantity] * Sales[Net Price] RETURN LineAmount ) -- Скобки закрывают область видимости переменной LineAmount -- Переменная LineAmount будет недоступна здесь и далее ( VAR LineCost = Sales[Quantity] * Sales[Unit Cost] RETURN LineCost ) ) Разумеется, мы привели этот пример исключительно в образовательных целях. Гораздо лучше будет объявить обе переменные рядом и свободно использовать внутри меры Margin: 210 Глава 6 Переменные Margin := SUMX ( Sales; VAR LineAmount = Sales[Quantity] * Sales[Net Price] VAR LineCost = Sales[Quantity] * Sales[Unit Cost] RETURN LineAmount - LineCost ) В качестве еще одного обучающего примера интересно рассмотреть действительную область видимости переменных в случае, когда скобки не применяются, а в выражении объявляются и используются сразу несколько переменных в отдельных блоках VAR/RETURN. Посмотрите следующий пример: Margin := SUMX ( Sales; VAR LineAmount = Sales[Quantity] * Sales[Net Price] RETURN LineAmount VAR LineCost = Sales[Quantity] * Sales[Unit Cost] RETURN LineCost -- Здесь переменная LineAmount по-прежнему доступна ) Выражение, стоящее после первого ключевого слова RETURN, является частью единого выражения. Так что объявление переменной LineCost на самом деле вложено в определение переменной LineAmount. Применение скобок для разграничения блоков и использование надлежащих отступов делают этот факт более очевидным: Margin := SUMX ( Sales; VAR LineAmount = Sales[Quantity] * Sales[Net Price] RETURN ( LineAmount - VAR LineCost = Sales[Quantity] * Sales[Unit Cost] RETURN ( LineCost -- Здесь переменная LineAmount по-прежнему доступна ) ) ) Поскольку, как было показано в предыдущем примере, переменные могут быть объявлены внутри выражений, они могут быть определены также и в рамках выражений, принадлежащих другим переменным. Иными словами, переменные в DAX могут быть вложенными. Посмотрите на следующий пример: Amount at Current Price := SUMX ( 'Product'; Глава 6 Переменные 211 VAR CurrentPrice = 'Product'[Unit Price] RETURN -- Переменная CurrentPrice доступна во внутренней функции SUMX SUMX ( RELATEDTABLE ( Sales ); VAR Quantity = Sales[Quantity] VAR AmountAtCurrentPrice = Quantity * CurrentPrice RETURN AmountAtCurrentPrice ) -- Ссылки на переменные Quantity и AmountAtCurrentPrice -- будут недоступны за пределами внутренней функции SUMX ) -- Ссылки на переменную CurrentPrice -- будут недоступны за пределами внешней функции SUMX Основные правила, касающиеся области видимости переменных: переменная доступна после ключевого слова RETURN соответствующего блока VAR/RETURN. Также доступ к этой переменной будет у всех переменных, объявленных позже нее в одном блоке VAR/RETURN. Блок VAR/ RETURN может заменять собой любое выражение DAX, и внутри этого выражения к переменной будет доступ. Иными словами, к переменной можно обращаться с момента ее объявления и до конца выражения, следующего за ключевым словом RETURN, являющимся частью того же блока VAR/RETURN; переменная недоступна за пределами своего блока VAR/RETURN. После выражения, следующего за ключевым словом RETURN, переменная, объявленная в этом блоке VAR/RETURN, будет не видна, и обращение к ней выдаст синтаксическую ошибку. Использование табличных переменных В переменной может храниться как скалярная величина, так и таблица. Тип переменной зависит от ее определения. Например, если выражение, используемое для инициализации переменной, является табличным, то и сама переменная приобретет табличный тип. Рассмотрим следующий код: Amount := IF ( HASONEVALUE ( Slicer[Factor] ); VAR Factor = VALUES ( Slicer[Factor] ) RETURN DIVIDE ( [Sales Amount]; 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 TotalCost Margin Margin% := := := := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) SUMX ( Sales; Sales[Quantity] * Sales[Unit Cost] ) [SalesAmount] – [TotalCost] 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'[RankPrice] = VAR CurrentProductPrice = '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] * Sales[Net Price] ) -- Таблица для осуществления итераций -- Выражение для вычисления в каждой строке Глава 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, нарушает сразу несколько правил преобразования контекста, изложенных в главе 5. Преобразование контекста тут выполняется на таблице большого объема (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 представлен отчет с максимальными дневными продажами по месяцам. Рис. 7.1 Вывод меры Max Daily Sales по годам и месяцам Воспользовавшись преобразованием контекста, можно сделать код более элегантным и интуитивно понятным. Единственное, чего стоит опасаться в случае с использованием преобразования контекста, – это снижения производительности вычисления: не стоит обращаться к мерам внутри объемных итераторов. При просмотре отчета с рис. 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 в переменную MaxDailySales. Затем создаем временную таблицу с датами, в которые продажи равнялись значению переменной MaxDailySales. Если такая дата была одна, фильтр возвратит единственную строку. Если же дат было несколько, возвращаем пус­ тое значение, оповещающее о том, что конкретную дату определить не удалось. Результат вывода можно видеть на рис. 7.2. Рис. 7.2 Мера Date of Max показывает дату с максимальной дневной продажей Использование итераторов в DAX требует, чтобы вы определились со следующими составляющими алгоритма и ровно в таком порядке: гранулярность, на которой вы хотите произвести вычисление; выражение для вычисления на этом уровне гранулярности; тип агрегации. Глава 7 Работа с итераторами и функцией CALCULATE 225 В предыдущем примере (Max Daily Sales 2) гранулярность была определена на уровне даты, в качестве выражения была выбрана сумма продаж, а типом агрегации явилась функция MAX. В итоге мы получили максимальные дневные продажи. Существует множество сценариев, где такой шаблон может быть полезен. Еще один пример – подсчет средней суммы продаж по покупателю. Если думать об этой задаче категориями, описанными выше, получится такая последовательность: гранулярность – отдельный покупатель, выражение – сумма продаж, тип агрегации – AVERAGE. В результате четкого следования этому мыслительному процессу мы при­ шли к простой и понятной формуле: Avg Sales by Customer := AVERAGEX ( Customer; [Sales Amount] ) Эту меру вполне можно использовать в отчетах вроде того, что показан на рис. 7.3, где выводятся средние продажи по покупателям в разрезе континентов и лет. Рис. 7.3 Вывод меры Avg Sales by Customer по континентам и годам Преобразование контекста в итерационных функциях – довольно мощный инструмент. Но использование этой концепции способно снизить производительность вычислений. Чтобы этого не происходило, необходимо уделять внимание кратности внешнего итератора в формуле. Это позволит вам писать более эффективный код на DAX. Использование функции CONCATENATEX В данном разделе мы покажем вариант использования функции CONCATENATEX для отображения значений фильтров, выбранных пользователем в отчете. Представьте, что вы строите простую визуализацию по продажам в разрезе континентов и лет, а затем встраиваете ее в сложный отчет, где пользователь может выбрать срез по цветам товаров. Сам элемент фильтра при этом может находиться как рядом с визуализацией, так и на другой странице. Если фильтр расположен на соседней странице, при просмотре отчета не понятно, сформирован он по товарам всех цветов или каких-то отдельных. В этом случае полезно будет добавить метку в отчет, в текстовом виде отображающую сделанный пользователем выбор, как показано на рис. 7.4. Просмотреть выбранные пользователем цвета можно при помощи функции VALUES. Функция CONCATENATEX пригодится, чтобы сконвертировать полученную таблицу в строку. Взгляните на определение меры Selected Colors, кото226 Глава 7 Работа с итераторами и функцией CALCULATE рую мы использовали для вывода пользовательского выбора цветов в отчете, показанном на рис. 7.4: Selected Colors := "Showing " & CONCATENATEX ( VALUES ( 'Product'[Color] ); 'Product'[Color]; ", "; 'Product'[Color]; ASC ) & " 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]; ", "; Глава 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 мы показали два варианта отчета с разным пользовательским выбором. Теперь пользователь видит, какие цвета выбраны, и в случае необходимости может обратиться к листу с фильтрами. Рис. 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. Рис. 7.6 Столбцы Sales Amount и Products добавлены и рассчитаны функцией ADDCOLUMNS Функция ADDCOLUMNS возвращает все столбцы из исходной таблицы, по которой осуществляет итерации, добавляя при этом новые. А чтобы из исходной таблицы взять только определенный набор столбцов, можно использовать функцию SELECTCOLUMNS, возвращающую лишь запрошенные столбцы. Например, мы можем переписать предыдущую формулу следующим образом: Colors = SELECTCOLUMNS ( VALUES ( 'Product'[Color] ); Глава 7 Работа с итераторами и функцией CALCULATE 229 "Color"; 'Product'[Color]; "Products"; CALCULATE ( COUNTROWS ( 'Product' ) ); "Sales Amount"; [Sales Amount] ) Результат будет таким же, но в этом случае необходимо указать столбец Color явным образом. Функция SELECTCOLUMNS бывает полезна, когда нужно сократить количество столбцов в таблице, часто являющейся результатом промежуточных вычислений. Функции ADDCOLUMNS и SELECTCOLUMNS могут быть удобны при создании новых таблиц, как было показано в нашем первом примере. Также они часто применяются при создании мер, чтобы сделать код более быстрым и понятным. Взгляните на меру, вычисляющую максимальные дневные продажи, с которой мы уже встречались ранее в данной главе: Max Daily Sales := MAXX ( 'Date'; [Sales Amount] ) 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 Если внимательно вчитаться в код, можно заметить, что он далеко не так оптимален с точки зрения производительности. Фактически для вычисления переменной MaxDailySales DAX необходимо подсчитывать дневные продажи товаров, чтобы найти максимум. В процессе вычисления второй переменной движку приходится снова рассчитывать дневные продажи для поиска дат с максимальными показателями. Таким образом, мы дважды проходим по таблице Date и каждый раз вычисляем сумму продажи за каждый день. Теоретически оптимизатор DAX достаточно продвинут, чтобы понять, что можно вычислять дневные продажи лишь раз, а затем использовать уже вычисленное ранее значение, но никто не может гарантировать, что так и будет. Воспользовавшись функцией ADDCOLUMNS, мы можем написать более быстрый код для этой меры. Мы сделаем это, предварительно подготовив таблицу с дневными продажами и сохранив ее в переменную. Затем мы используем эти данные 230 Глава 7 Работа с итераторами и функцией CALCULATE для вычисления значения максимальной дневной продажи и дня, когда это произошло: Date of Max := VAR DailySales = ADDCOLUMNS ( VALUES ( 'Date'[Date] ); "Daily Sales"; [Sales Amount] ) 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. А поскольку после этого нам нужны будут только даты, а не дневные продажи, мы воспользовались функцией SELECTCOLUMNS для исключения столбца с дневными продажами из результата. Итоговая версия кода получилась более сложной по сравнению с первоначальной. Но простотой формул часто приходится жертвовать во время оптимизации кода. Чтобы сделать код более быстрым, приходится писать более сложные конструкции. В главах 12 и 13 мы детальнее обсудим работу функций ADDCOLUMNS и SELECTCOLUMNS. А поговорить есть о чем, особенно если вы хотите использовать результат функции 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 PeriodToUse = 232 Глава 7 Работа с итераторами и функцией CALCULATE FILTER ( ALL ( 'Date' ); AND ( 'Date'[Date] > LastVisibleDate - NumberOfDays; 'Date'[Date] <= LastVisibleDate ) ) VAR Result = CALCULATE ( AVERAGEX ( 'Date'; [Sales Amount] ); PeriodToUse ) RETURN Result Рис. 7.7 Анализировать график дневных продаж очень проблематично Сначала в формуле определяется последний видимый день. Поскольку контекст фильтра в визуализации установлен на уровне даты, мы получим выбранную дату. После этого мы определяем 30-дневный набор дат ранее последней даты. На заключительном шаге мы используем полученный период в качестве фильтра функции CALCULATE, чтобы вложенная в нее функция AVERAGEX вычисляла среднее значение за эти даты. Результат данного вычисления показан на рис. 7.8. Как видите, линия на графике оказалась куда более плавной по сравнению с дневным графиком, что позволяет анализировать тенденции по продажам. Когда пользователь полагается на функции вычисления средних значений вроде AVERAGEX, нужно с большой осторожностью относиться к полученному результату. Фактически при расчете среднего DAX игнорирует пустые значеГлава 7 Работа с итераторами и функцией CALCULATE 233 ния. Если в какой-то день из выбранного периода продаж не было, этот день не будет учтен вовсе при расчете среднего. Об этой особенности нужно помнить. Функция AVERAGEX не подразумевает использование нуля в случае отсутствующего значения. И такое поведение может быть нежелательным при расчете средних показателей по дням. Рис. 7.8 Скользящее среднее за последние 30 дней дает более плавный график Если вам необходимо дни с отсутствующими продажами учитывать как нулевые, то лучше будет использовать обычную функцию деления вместо AVE­RAGEX. Кроме того, этот метод будет быстрее, поскольку преобразование контекста, возникающее в случае использования функции 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 позволило учитывать дни без продаж как нулевые, но в то же время в учет попали выходные и праздничные дни. Правильно ли это – зависит от ваших требований к отчетности, и при необходимости вы можете легко переписать меру, чтобы она учитывала специфику бизнеса. Рис. 7.9 Разные методы расчета скользящего среднего привели к разным результатам Использование функции RANKX Функция RANKX используется для вычисления ранга элементов согласно указанному типу сортировки. Типичным примером применения функции RANKX является ранжирование товаров или покупателей на основании объемов проГлава 7 Работа с итераторами и функцией CALCULATE 235 даж. RANKX принимает несколько параметров, но в большинстве случаев используется только два из них. Остальные параметры необязательные и применяются довольно редко. Представьте, что вам необходимо построить отчет по категориям товаров с ранжированием по объему продаж, показанный на рис. 7.10. Рис. 7.10 Мера Rank Cat on Sales показывает ранг категории, исходя из объема продаж В этом сценарии можно использовать функцию 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 Значение Шаг 3 Позиция Результат: Рис. 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] ) ) Шаг 1 Таблица поиска Шаг 2 Значение Шаг 3 Позиция Результат: Рис. 7.12 В итоговой строке ранг всегда будет равен единице, если таблица поиска отсортирована по убыванию Эта мера вернет пустые значения для строк с множественным выбором категорий в текущем контексте фильтра, а значит, выведет пустоту в итогах. Когда вы используете функцию RANKX, а в общем случае любую меру, зависящую от специфики текущего контекста фильтра, то всегда должны снабжать ее условием, чтобы расчеты проводились только для нужных ячеек, а во всех остальных случаях выводили пустые значения или ошибки. Именно это и показано в предыдущем примере. Как мы упоминали ранее, функция RANKX может принимать еще несколько параметров, помимо двух обязательных. Таких параметров может быть три: третьим параметром является значение, которое может оказаться полезным в случае, если вам необходимо использовать разные выражения для таблицы поиска и ранжирования; четвертым параметром можно передать способ сортировки таблицы поиска. Двумя допустимыми значениями этого параметра могут быть ASC и DESC. Значением по умолчанию является DESC, при котором столбец сортируется по убыванию, а минимальное значение ранга будет соответствовать максимальному числу; пятый параметр определяет метод расчета ранга в случае равенства значений. Вы можете указать два значения этого параметра: DENSE или SKIP. Если передать DENSE, одинаковые значения будут удалены из таблицы поиска. В противном случае они сохранятся. Давайте рассмотрим эти параметры на примерах. Третий параметр функции RANKX можно использовать в случаях, когда для формирования значений в таблице поиска и собственно ранжирования ис238 Глава 7 Работа с итераторами и функцией CALCULATE пользуются разные выражения. Представьте, что нам необходимо провести ранжирование с использованием следующей таблицы, представленной на рис. 7.13. Рис. 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. Рис. 7.14 Ранжирование с использованием меры Sales Amount по фиксированной таблице поиска Sales Ranking Весь процесс ранжирования изображен на рис. 7.15, где также видно, что таб­ лица поиска сортируется перед использованием. Глава 7 Работа с итераторами и функцией CALCULATE 239 Шаг 1 Шаг 2 Таблица поиска Значение Шаг 3 Позиция Результат: Рис. 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 Рис. 7.16 Использование значений DENSE и SKIP приводит к разным результатам в присутствии одинаковых значений в таблице поиска По сути, применение значения DENSE выполняет операцию DISTINCT применительно к таблице поиска перед ее использованием. SKIP этого не делает, используя таблицу поиска в том виде, в котором она была построена изначально. Применяя функцию RANKX, важно уделять особое внимание ее первому параметру для получения желаемого результата. В предыдущих примерах мы указывали в качестве первого параметра выражение ALL ( Product[Brand] ), поскольку в наши планы входило ранжирование по всем брендам. Для краткости мы не использовали условие с функцией HASONEVALUE. В своих запросах вы никогда не должны их пропускать, иначе рискуете получить неожиданные результаты. Например, следующая мера будет выдавать ошибочные значения, если в отчете не будет использоваться срез по брендам: Rank On Sales := RANKX ( ALL ( 'Product'[Brand] ); [Sales Amount] ) На рис. 7.17 мы выполнили срез по цветам товаров, и результат везде оказался равен единице. Рис. 7.17 Ранжирование по брендам даст неожиданные результаты в отчете со срезом по цвету товаров Глава 7 Работа с итераторами и функцией CALCULATE 241 Причина в том, что в таблице поиска окажутся продажи со срезом по бренду и цвету, тогда как значения для поиска будут включать только цвета. А поскольку продажи по определенному цвету всегда будут выше, чем любое значение из подгруппы по бренду, ранг всегда будет равен единице. Добавление условия с использованием IF HASONEVALUE может помочь выводить пустые значения для ранга, в случае если в текущем контексте вычисления есть что-то еще, помимо одного бренда. Наконец, функция RANKX часто используется совместно с ALLSELECTED. Если пользователь выбрал определенный поднабор из общего количества брендов, функция ALL может привести к образованию пропусков в ранжировании, поскольку она возвращает все бренды вне зависимости от выбранных в срезе. Сравните следующие две меры: Rank On Selected Brands := RANKX ( ALLSELECTED ( 'Product'[Brand] ); [Sales Amount] ) Rank On All Brands := RANKX ( ALL ( 'Product'[Brand] ); [Sales Amount] ) На рис. 7.18 показан вывод этих мер в присутствии фильтра по определенным брендам в срезе. Рис. 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-выражением для вычисления, а <column> представляет существующий столбец, по которому будет производиться ранжирование. Третий параметр необязательный и может принимать значение 0 для сортировки столбца по убыванию и 1 – по возрастанию. В Excel вместо целого столбца в функцию может быть передан диапазон ячеек. В DAX чаще всего в первый параметр будет передаваться тот же самый столбец, чтобы провести ранжирование внутри него. Одним из сценариев, когда вам может потребоваться использовать разные параметры, является наличие двух таблиц: одна для значений, которые необходимо ранжировать, например конкретная группа товаров, вторая – для полного набора элементов, к примеру список всех товаров. Однако из-за ограничений, наложенных на параметр <column> (в частности, вы не можете использовать в нем выражения или любые табличные функции, включая ADDCOLUMNS и SELECTCOLUMNS), функция RANK.EQ обычно применяется в вычисляемом столбце с передачей одной и той же колонки из этой же таблицы в качестве параметров, как показано ниже: Product[Price Rank] = RANK.EQ ( Product[Unit Price]; Product[Unit Price] ) Функция RANKX намного более мощная по сравнению с RANK.EQ. Так что, изучив последнюю, вам вряд ли захочется тратить много времени на освоение ее менее эффективного аналога. Изменение гранулярности вычисления Существует ряд сценариев, в которых формулы не могут быть вычислены на уровне итогов. Вместо этого значения должны вычисляться на более высоких уровнях и затем агрегироваться. Представьте, что вам необходимо подсчитать сумму продаж в расчете на каждый рабочий день. Количество рабочих дней в каждом месяце разное изза наличия выходных и праздничных дней. Для простоты в этом примере мы не будем учитывать праздники, а возьмем только субботу и воскресенье в качестве нерабочих дней. В реальных примерах необходимо также принимать в расчет праздничные дни. В таблице Date у нас есть столбец с названием IsWorkingDay, в котором содержатся 1 или 0 в зависимости от того, рабочий это день или нет. Такие флаги удобно хранить в качестве целочисленных значений, поскольку это упрощает подсчет рабочих или праздничных дней. Следующие две меры вычисляют общее количество дней и количество рабочих дней в рамках текущего контекста фильтра: NumOfDays := COUNTROWS ( 'Date' ) NumOfWorkingDays := SUM ( 'Date'[IsWorkingDay] ) На рис. 7.19 представлен отчет с этими двумя мерами. Основываясь на этих мерах, можно вычислить сумму продаж в расчете на один рабочий день. Это значение очень полезно для вычисления показателя эффективности для каждого месяца с учетом валовых продаж и количества дней, в которые эти продажи совершались. Предполагаемая формула представляется довольно простой, но она скрывает в себе определенные трудности, которые мы решим при помощи итерационных функций. Как мы уже не раз делали в данной книге, мы будем приближаться к правильному расчету шаг за Глава 7 Работа с итераторами и функцией CALCULATE 243 шагом, параллельно указывая на ошибки. Цель этого примера состоит не в том, чтобы предоставить вам готовый шаблон решения. Мы вместе допустим ошибки, типичные для подобных задач, и вместе же их исправим. Рис. 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 Рис. 7.20 По месяцам значения правильные, но к итогам есть большие вопросы Новая мера позволила нам выправить значения на уровне годов, как видно по рис. 7.21, но она по-прежнему далека от идеала. Выполняя вычисления на разных уровнях гранулярности, необходимо обес­ печить их правильность. Функция проходит по столбцу с месяцами с января по декабрь. На уровне годов все теперь считается правильно, но с итоговым значением остались проблемы, как видно по рис. 7.22. Когда в контексте фильтра присутствует год, итерации по месяцам работают правильно, поскольку после преобразования контекста в новом контексте фильтра оказывается как месяц, так и год. Однако в итоговой строке год в конГлава 7 Работа с итераторами и функцией CALCULATE 245 тексте фильтра отсутствует. Соответственно, в фильтре остается только месяц, и формула вычисляется не по этому месяцу в рамках текущего года, а по этому месяцу в рамках всех лет. Рис. 7.21 Использование итерационной функции позволило скорректировать значения продаж на уровне годов Рис. 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. Рис. 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 BI В Power BI есть настройка автоматических даты и времени, располагающаяся в секции Загрузка данных (Data Load) меню Параметры и настройки (Options). Окно настроек показано на рис. 8.1. Рис. 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. Примечание Слово Date является зарезервированным в DAX для соответствующей функции DATE. Так что вам необходимо заключать его в кавычки при использовании в качестве названия таблицы, несмотря на то что оно не содержит пробелов и специальных символов. Кроме того, вы можете использовать таблицу с названием Dates вместо Date, чтобы избежать необходимости всегда помнить о кавычках. Но не стоит забывать о преемственности в именовании объектов в модели данных. Если другие таблицы вы именуете в единственном числе, то и с таблицей дат желательно придерживаться такого подхода. Использование функций CALENDAR и CALENDARAUTO Если в вашем источнике данных отсутствует таблица дат, вы всегда можете создать ее самостоятельно при помощи функций CALENDAR и CALENDARAUTO. Обе эти функции возвращают таблицу, состоящую из одного столбца типа 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 есть также столбец AvailableForSaleDate с самой ранней датой в 2004 году, результатом будет интервал с 1 января 2004 года по 31 декабря 2011-го. Если в модели будут и другие столбцы типа дата, они также окажут действие на интервал, генерируемый функцией CALENDARAUTO. Часто бывает, что в календаре оказываются даты, совершенно не нужные для анализа. Например, если среди прочих дат в модели будет присутствовать поле с датами рождения покупателей, функция CALENDARAUTO при создании календаря будет учитывать годы рождения самого пожилого и самого молодого покупателей. В результате мы получим очень объемную таблицу дат, что может негативно сказаться на производительности вычислений. Функция CALENDARAUTO также принимает необязательный параметр, отвечающий за номер последнего месяца финансового года. Если этот параметр передан, функция при создании календаря будет вести отсчет с первого дня следующего месяца и до последнего дня месяца, номер которого передан в качестве параметра. Это бывает полезно, когда в организации финансовый год заканчивается не 31 декабря, а, скажем, 30 июня, как показано в следующем примере создания календаря: Date = CALENDARAUTO ( 6 ) Функцию CALENDARAUTO использовать легче, чем CALENDAR, поскольку она сама определяет границы календаря. Но при этом CALENDARAUTO может включить в таблицу нежелательные даты. На этот случай есть возможность ограничить даты, автоматически генерируемые этой функцией, при помощи фильтра следующим образом: Date = VAR MinYear = YEAR ( MIN ( VAR MaxYear = YEAR ( MAX ( RETURN FILTER ( CALENDARAUTO ( ); YEAR ( [Date] ) >= YEAR ( [Date] ) <= ) Sales[Order Date] ) ) Sales[Order Date] ) ) MinYear && MaxYear Результирующая таблица будет содержать даты только из интервала таблицы продаж. При этом вычислять первый и последний день года совсем не обязательно, функция CALENDARAUTO справится с этим сама. После получения необходимого списка дат разработчику остается дополнить календарь необходимыми столбцами, применяя выражения на DAX. Приведем пример часто используемых столбцов для календарей с дальнейшим их выводом, показанным на рис. 8.4: Date = VAR MinYear = YEAR ( MIN ( Sales[Order Date] ) ) VAR MaxYear = YEAR ( MAX ( Sales[Order Date] ) ) Глава 8 Логика операций со временем 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 ( "Year Month"; FORMAT ( [Date]; "mmmm" ) & " " & YEAR "Year Quarter Number"; YEAR ( [Date] ) * 100 + INT ( "Year Quarter"; "Q" & FORMAT ( [Date]; "q" ) & "-" & ) [Date] ); ( [Date] ); FORMAT ( [Date]; "q" ) ); YEAR ( [Date] ) Рис. 8.4 При помощи функции ADDCOLUMNS можно создать таблицу дат в одном выражении Такого же результата можно добиться, создавая вычисляемые столбцы прямо в пользовательском интерфейсе. Главным преимуществом использования функции ADDCOLUMNS является возможность повторного применения этого кода в других проектах. Использование шаблонов DAX для работы с датами Представленный выше пример был приведен исключительно в образовательных целях, и в нем были оставлены только самые важные столбцы, чтобы код можно было разместить в книге. Но в интернете можно найти и другие шаблоны для работы с датами. Например, мы создали свой шаблон для Power BI и разместили его по адресу 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, как мы показывали в главе 5. Рассмотрим модель данных, представленную на рис. 8.5. Между таблицами 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 Логика операций со временем 257 Рис. 8.5 Активная связь соединяет столбцы Sales[Order Date] и Date[Date] В первой мере Ordered Amount используется активная связь между таблицами Sales и Date, в основе которой лежит столбец Sales[Order Date]. Вторая мера Delivered Amount использует то же выражение DAX, но при этом полагается на связь по полю Sales[Delivery Date]. Модификатор USERELATIONSHIP меняет активную связь между таблицами Sales и Date в контексте фильтра, определенном функцией CALCULATE. В отчете, показанном на рис. 8.6, выведены обе эти меры. Рис. 8.6 Значения в мерах Ordered Amount и Delivered Amount отличаются по месяцам, поскольку дата поставки могла перескочить на следующий месяц Использование множественных связей с таблицей дат увеличивает общее количество созданных мер в модели данных. Обычно в таком случае разработчик создает конкретные меры для использования с определенными датами. Если вы не хотите поддерживать большое количество мер в модели и желаете ис258 Глава 8 Логика операций со временем пользовать любую созданную меру применительно к разным датам, то можете прибегнуть к помощи групп вычислений, описываемых в следующих главах. Поддержка нескольких таблиц дат Вместо того чтобы дублировать меры, можно создавать копии таблиц дат в модели данных – по одной для каждой даты. Таким образом, мера будет вычислять значение, исходя из выбранной пользователем даты в отчете. В плане поддержки это решение может показаться более оптимальным, поскольку ведет к уменьшению количества мер и позволяет, например, выбирать продажи, пересекающиеся по двум месяцам. Но в то же время дублирование таблиц дат усложняет использование модели данных в целом. Допустим, вы можете построить отчет с общим количеством заказов, сформированных в январе, а доставленных в феврале того же года. Но удобно отобразить эту информацию на графике довольно затруднительно. Такой способ организации данных также известен как работа с ролевыми измерениями (role-playing dimension). Таблица дат представляет собой измерение, которое дублируется для каждой связи, а значит, и для каждой роли. В целом же два этих подхода (активирование связей и дублирование таблиц дат) являются взаимодополняющими. Чтобы создать таблицы Delivery Date и Order Date, вы должны создать две копии существующего календаря в модели данных, меняя при этом название. На рис. 8.7 показана модель данных, содержащая несколько таблиц дат, объединенных связями с таблицей Sales. Рис. 8.7 Каждая дата в таблице Sales связана со своей таблицей дат Важно При создании копии таблицы дат вы должны физически дублировать ее в модели данных. Таким образом, лучше всего будет создать разные представления для разных ролевых измерений в источнике данных, чтобы столбцы в них назывались по-разному и имели разное содержимое. Например, вместо того чтобы создавать столбец с именем Year в каждом календаре, лучше будет назвать их Order Year и Delivery Year в таблице дат формирования заказов и их поставки соответственно. В этом случае навигация по отчетам существенно упростится. Это также видно по рис. 8.7. Более того, хорошей практикой является хранение разного содержимого в столбцах. Например, вы можете добавлять префикс к году в зависимости от роли таблицы: CY (Creation Year – Дата создания) для содержимого столбца Order Year и DY – для Delivery Year (Дата поставки). Глава 8 Логика операций со временем 259 На рис. 8.8 показана матрица с одновременным выводом дат из двух календарей. Такой отчет не может быть создан при выборе подхода с поддержкой множественных связей с единой таблицей Date. Как видите, уникальные названия столбцов и содержимое с конкретными префиксами помогает сделать отчет более легким для восприятия. Во избежание неразберихи между датами оформления заказов и их поставки мы используем префиксы CY и DY соответственно. Рис. 8.8 Разные префиксы в содержимом позволяют быстро понять, где дата заказа, а где дата поставки В подходе с разными таблицами дат одна и та же мера может давать разные результаты в зависимости от используемого столбца в фильтре. Однако было бы неправильно выбирать этот вариант организации хранения данных только по причине снижения количества мер в модели данных. В конце концов, в этом случае вы не сможете вынести в отчет одну и ту же меру, сгруппированную по разным датам. Представьте себе линейную диаграмму, отображающую меру Sales Amount по столбцам Order Date и Delivery Date. Для этого вам понадобится, чтобы на оси дат учитывались данные из одной таблицы Date, а со множест­ венными календарями в модели такого результата будет добиться довольно проблематично. Если вашим первостепенным приоритетом является уменьшение количест­ ва мер в модели данных и возможность вычислять одну и ту же меру по разным датам, вам стоит присмотреться к группам вычислений, которые будут описаны в главе 9, с содержанием единой таблицы дат в модели. Единственной пользой от присутствия множества календарей в модели является возможность использования пересечений одной и той же меры в одной визуализации по разным датам, как показано на рис. 8.8. Во всех остальных случаях лучшим выбором будет содержание одной таблицы дат в модели данных со множест­ венными связями. Знакомство с базовыми вычислениями в работе со временем В предыдущих разделах вы узнали, как правильно создать таблицу дат, которая пригодится вам для осуществления вычислений при работе с датами и временем. В DAX есть множество функций для облегчения таких вычислений. Использовать эти функции довольно просто, но при этом они помогают производить очень сложные и полезные расчеты. Понимание деталей работы этих 260 Глава 8 Логика операций со временем функций позволит вам быстро начать применять их в своей работе. В целях обучения мы сначала покажем, как производить вычисления с датами и временем в DAX стандартными средствами – с использованием функций CALCULATE, CALCULATETABLE, FILTER и VALUES. Позже в данной главе мы перейдем к применению специализированных функций из раздела логики операций со временем для тех же расчетов, и вы увидите, как они помогают облегчить написание кода и сделать его гораздо более легким для восприятия. Мы решили использовать такой подход в обучении сразу по нескольким причинам. Но главная из них в том, что, когда речь заходит о логике операций со временем, далеко не все вычисления могут быть произведены с применением стандартных функций DAX. В какой-то момент в карьере разработчика DAX вам понадобится осуществить более сложный расчет, чем просто сумма нарастающим итогом с начала года, и вы обнаружите, что в DAX нет специальных функций, удовлетворяющих вашим требованиям. Если вы опытный разработчик, то для вас это не будет большой проблемой. Вы закатаете рукава и в итоге напишете правильный фильтр без использования специализированных функций DAX. Если же у вас нет достаточного опыта в разработке на языке DAX, вам придется несладко. Посмотрим на практике, что из себя представляет логика операций со временем. Представьте, что у вас есть простая мера, вычисление которой производится в текущем контексте фильтра: Sales Amount := SUMX ( Sales; Sales[Net Price] * Sales[Quantity] ) Поскольку таблицы Sales и Date объединены связью, текущий фильтр в таб­ лице Date ограничит выбор в Sales. Чтобы произвести вычисление в таблице Sales за другой временной период, разработчику необходимо будет изменить существующий фильтр в таблице дат. Например, чтобы получить сумму продаж нарастающим итогом с начала года при текущем фильтре по февралю 2007 го­да, необходимо перед осуществлением итераций по таблице Sales изменить контекст фильтра таким образом, чтобы в него вошли как январь, так и февраль этого года. Для этого можно использовать уже знакомую вам функцию CALCULATE с указанием аргумента фильтра, которая вернет сумму нарастающим итогом на февраль 2007 года: Sales Amount 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 Рис. 8.9 Результатом вычисления меры будет сумма продаж за январь и февраль 2007 года вне зависимости от месяца в строке Функция FILTER, используемая в качестве аргумента фильтра в CALCULATE, возвращает набор дат, замещающий собой текущий выбор в таблице Date. Иными словами, несмотря на то что в текущем контексте фильтра присутствует фильтр по месяцу, исходящий от строк, мера рассчитывается совсем по другому интервалу. Очевидно, что мера, возвращающая сумму продаж по двум статическим месяцам, никакого интереса не представляет. Но, поняв сам механизм, вы можете вооружиться некоторыми стандартными функциями DAX и правильно вычислить сумму продаж нарастающим итогом, как показано ниже: Sales Amount 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 последнюю видимую дату в текущем контексте фильтра. После этого в переменную CurrentYear записываем год этой даты. В третьей переменной SetOfDatesYtd хранятся все даты с начала года по последнюю видимую дату. Этим набором дат мы 262 Глава 8 Логика операций со временем заменяем текущий контекст фильтра в отношении дат для вычисления нарастающего итога, как видно по рис. 8.10. Рис. 8.10 Мера Sales Amount YTD рассчитывает нарастающий итог по сумме продаж с использованием функции FILTER Как мы и утверждали, вы вполне можете производить вычисления при работе с датой и временем, пользуясь при этом стандартными функциями DAX. Важно понимать, что такие вычисления по своей сути ничем не отличаются от любых других, в которых также производятся манипуляции с контекстом фильтра. Поскольку мера призвана агрегировать значения по другому набору дат, ее вычисление производится в два этапа. Сначала мы определяем новый фильтр по датам, а затем применяем его к модели данных для произведения вычисления. Все расчеты в области даты и времени производятся одинаково. И когда вы поймете базовые принципы, логика операций со временем перестанет быть для вас тайной. Перед тем как двигаться дальше, стоит подробнее остановиться на том, как поступает DAX со связями по столбцам с датами. Взгляните на чуть измененный предыдущий код, где вместо фильтра по всей таблице дат мы используем фильтр только по столбцу Date[Date]: 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 Логика операций со временем 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); 'Date'[Date] <= DATE ( 2007; 04; 30 ) ) ); 'Date'[Year] = 2007; 'Date'[Month] = "April" ) -- Этот фильтр эквивалентен -- результату функции FILTER -- Эти фильтры приходят из строки -- матрицы по апрелю 2007 года Если вы вспомните, что уже знаете о контекстах фильтра и функции CALCULATE, то поймете, что этот код не должен правильно вычислять нарастающий итог с начала года. И действительно, аргумент фильтра внутренней функции CAL­CULATE вернет таблицу, содержащую столбец 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); -- результату функции FILTER 'Date'[Date] <= DATE ( 2007; 04; 30 ) ); ALL ( 'Date' ) -- Эта строка добавлена движком DAX автоматически ); 'Date'[Year] = 2007; 'Date'[Month] = "April" -- Эти фильтры приходят из строки -- матрицы по апрелю 2007 года ) Когда фильтр применяется к столбцу типа Date или DateTime, являющемуся основанием для связи «один ко многим», DAX автоматически распространяет действие фильтра на другую таблицу, заменяя фильтры по любым другим столбцам в той же таблице поиска. Это сделано для того, чтобы максимально упростить логику операций со временем, когда таблицы связаны по столбцу с датой. В следующем разделе мы расскажем про специальную отметку для таблиц дат, позволяющую реализовать такое же поведение для связей не по столбцу с датой. Пометка календарей как таблиц дат Применение фильтра к столбцу с датами в календаре прекрасно работает, если этот столбец используется в качестве основы для связи. Но вам может понадобиться связать таблицы по другому столбцу. Во многих календарях есть поле с целочисленным значением – обычно в формате YYYYMMDD, – по которому и производится объединение с другими таблицами в модели данных. Чтобы продемонстрировать такую ситуацию, мы создали столбец DateKey в таблицах Date и Sales. После этого настроили связь между этими таблицами по данному полю, а не по полю типа дата. Получившуюся модель данных можно видеть на рис. 8.11. Рис. 8.11 Связь между таблицами Sales и Date организована по столбцу DateKey типа Integer Теперь тот же самый код с вычислением суммы продаж нарастающим итогом с начала года, который раньше работал нормально, вдруг сломался. Результат вычисления нашей меры показан на рис. 8.12. Глава 8 Логика операций со временем 265 Рис. 8.12 Использование целочисленного столбца для связи привело к ошибочным результатам вычисления меры Как видите, после изменения связи отчет показывает одинаковые значения в столбцах с мерами Sales Amount и Sales Amount YTD. Поскольку связь между таблицами более не основывается на столбце типа DateTime, DAX автоматически не добавляет функцию ALL к таблице дат. А следовательно, новый фильтр по дате будет пересекаться с существующим – из внешнего запроса, что приведет к неправильным расчетам. Здесь возможны два решения. Во-первых, можно вручную добавлять ALL ко всем вычислениям, связанным с логикой операций со временем. Это довольно обременительный вариант для разработчика, который должен не забывать вставлять данную функцию во все аналогичные расчеты. Второй способ гораздо более удобный и заключается в пометке календаря как таблицы дат на панели инструментов. Если таблица дат помечена соответствующим образом, DAX будет автоматически добавлять модификатор ALL ко всем вычислениям, даже если связь организована не по столбцу с датами. Помните о том, что после специальной пометки календаря ALL будет добавляться всякий раз, когда происходит изменение контекста фильтра по столбцу с датами. В некоторых ситуациях такое поведение будет нежелательным, и разработчику придется писать довольно сложный код, чтобы наладить правильную фильтрацию. Мы расскажем об этом далее в данной главе. Знакомство с базовыми функциями логики операций со временем Теперь, когда вы узнали базовые механизмы вычислений при работе с датой и временем, пришло время упростить код. Если бы разработчикам DAX приходилось писать сложные выражения с использованием функции FILTER всякий 266 Глава 8 Логика операций со временем раз, когда необходимо рассчитать простую сумму нарастающим итогом с начала года, жизнь не казалась бы им медом. Для облегчения вычислений, связанных с логикой операций со временем, в DAX имеется целый ряд специальных функций, автоматически выполняющих фильтрацию, которую мы производили вручную в предыдущем примере. Вспомним меру Sales Amount YTD, которую мы написали ранее: 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 = CALCULATE ( SUMX ( Sales; Sales[Net Price] * Sales[Quantity] ); SetOfDatesYtd ) RETURN Result С использованием специальной функции DATESYTD этот код можно значительно упростить, приведя к следующему виду: Sales Amount 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. Рис. 8.13 Меры Sales Amount YTD и Sales Amount QTD выведены вместе с базовой мерой Sales Amount Похожим образом вы можете использовать и функции для расчета нарастающих итогов по месяцам и кварталам, как показано ниже: QTD QTD MTD MTD Sales Sales Sales Sales := := := := TOTALQTD ( [Sales Amount]; 'Date'[Date] ) CALCULATE ( [Sales Amount]; DATESQTD ( 'Date'[Date] ) ) TOTALMTD ( [Sales Amount]; 'Date'[Date] ) 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 и CLOSINGBALANCEYEAR. Глава 8 Логика операций со временем 269 Важно. В зависимости от региональных настроек вам может потребоваться сначала указывать день, а затем месяц. Вы также можете использовать строку в формате YYYY-MMDD, чтобы избежать неоднозначности в трактовке даты. В этом случае указание года не будет оказывать влияния на определение последней даты финансового года при расчете нарастающих итогов: 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 года не исправлены ошибки в расчетах, в случае если финансовый год начинается в марте и заканчивается в феврале. Подробнее об этой проблеме и способах ее решения мы поговорим далее в данной главе. Сравнение временных интервалов Многие вычисления требуют сравнения показателей текущего временного интервала с тем же интервалом в прошедшем году. Это может быть полезно для сравнения тенденций в определенный период нынешнего года и прошлого. При осуществлении таких вычислений вам поможет функция SAMEPERIODLASTYEAR: 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 Логика операций со временем Рис. 8.14 Функция DATEADD позволяет сместить временной период на любое количество интервалов Применяя другие значения параметров, можно получить информацию за соответствующие временные интервалы: PQ Total Sales := CALCULATE ( [Sales Amount]; PARALLELPERIOD ( 'Date'[Date]; -1; QUARTER ) ) На рис. 8.15 показаны значения мер с использованием функции PARALLELPERIOD за предыдущий год и квартал. Есть функции, близкие по смыслу, но не идентичные функции PARALLELPERIOD. Это PREVIOUSYEAR, PREVIOUSQUARTER, PREVIOUSMONTH, PREVIOUSDAY, 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 Логика операций со временем 271 Рис. 8.15 Функция PARALLELPERIOD возвращает полный указанный период, а не текущий период, сдвинутый во времени Разница в поведении между двумя мерами хорошо видна по рис. 8.16. Мера Last PM Sales возвращает значение за декабрь 2007 года как для всего 2008 го­ да, так и для его первого квартала, тогда как PM Total Sales всегда возвращает агрегацию за то же количество месяцев, что и в текущем выборе, – за три для квартала и за двенадцать для года. Это происходит, даже если исходный выбор смещается назад на один месяц. Рис. 8.16 Функция PREVIOUSMONTH возвращает один месяц, даже если в исходном выборе находится квартал или год 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 Логика операций со временем 273 [Sales Amount]; DATESYTD ( SAMEPERIODLASTYEAR ( 'Date'[Date] ) ) ) Можно также использовать функцию CALCULATE, чтобы сместить во времени текущий контекст фильтра, а затем вызвать функцию, которая, в свою очередь, также проанализирует переданный контекст и сместит его во времени. Следующие две меры PY YTD Sales эквивалентны предыдущим двум; меры YTD Sales и PY Sales были определены ранее в данной главе: 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. Рис. 8.17 Сумма продаж нарастающим итогом за прошлый год может быть получена путем сочетания функций логики операций со временем 274 Глава 8 Логика операций со временем Все примеры из этого раздела могут быть адаптированы для работы с кварталами, месяцами и днями, но не с неделями. В DAX нет специальных функций логики операций со временем для подсчета недель из-за отсутствия строгого соответствия между неделями и годами, кварталами и месяцами. Таким образом, при необходимости вам придется самостоятельно реализовывать выражения для работы с неделями. Далее в этой главе мы покажем один пример подобных вычислений. Расчет разницы по сравнению с предыдущим периодом Одной из распространенных операций при работе со временем является расчет разницы между нынешним значением меры и ее значением в предыдущем году. Эта разница может быть выражена как в абсолютных величинах, так и в процентах. Вы уже видели, как можно получить прошлогоднее значение меры при помощи функции SAMEPERIODLASTYEAR: PY Sales := CALCULATE ( [Sales Amount]; SAMEPERIODLASTYEAR ( 'Date'[Date] ) ) Чтобы вычислить разницу между двумя значениями меры Sales Amount в абсолютном выражении, достаточно воспользоваться простой арифметической операцией вычитания. При этом необходимо добавить проверку, чтобы показывать разницу между значениями только в случае присутствия обоих. Здесь лучше будет воспользоваться переменными, дабы не вычислять одну меру дважды. Меру YOY Sales (от «year-over-year» – «по сравнению с предыдущим годом») можно определить так: YOY VAR VAR VAR Sales := CySales = [Sales Amount] PySales = [PY Sales] 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 Логика операций со временем 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 показан отчет со всеми рассчитанными мерами. Рис. 8.18 В отчете показаны все меры, сравнивающие два года Расчет скользящей годовой суммы Еще одним распространенным экономическим показателем, не учитывающим сезонные изменения, является скользящая годовая сумма (moving annual total – MAT). Этот показатель учитывает агрегацию значения за последние 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 похожа на MAX, но вместо значения она возвращает таблицу, которая впоследствии может быть передана в качестве параметра другой функции логики операций со временем. Получив последнюю видимую дату, мы переносим ее на год назад при помощи функции SAMEPERIODLASTYEAR, после чего берем следующий день, применив функцию NEXTDAY. Одна из проблем показателя скользящей годовой суммы заключается в том, что при его расчете используется агрегация в виде суммирования. Чтобы получить средние значения, необходимо разделить получившиеся суммы на количество месяцев, включенных в интервал. Так мы получим скользящую среднегодовую сумму (moving annual average – MAA): Глава 8 Логика операций со временем 277 MAA Sales := CALCULATE ( DIVIDE ( [Sales Amount]; DISTINCTCOUNT ( 'Date'[Year Month] ) ); DATESINPERIOD ( 'Date'[Date]; MAX ( 'Date'[Date] ); -1; YEAR ) ) Как видите, используя функции логики операций со временем, можно проводить довольно сложные расчеты. На рис. 8.19 приведен отчет, в котором выведены и скользящая годовая сумма, и скользящая среднегодовая сумма. Рис. 8.19 Меры MAT Sales и MAA Sales легко реализовать с помощью функций логики операций со временем Выбор порядка вложенности функций логики операций со временем При работе со вложенными функциями логики операций со временем очень важно выбрать правильный порядок иерархии. В предыдущем примере мы использовали следующее выражение для извлечения первого дня для интервала при расчете скользящей годовой суммы: NEXTDAY ( SAMEPERIODLASTYEAR ( LASTDATE ( 'Date'[Date] ) ) ) Такого же результата можно добиться, поменяв местами функции NEXTDAY и SAMEPERIODLASTYEAR: SAMEPERIODLASTYEAR ( NEXTDAY ( LASTDATE ( 'Date'[Date] ) ) ) 278 Глава 8 Логика операций со временем Результат почти всегда будет одинаковым, но в последнем случае мы рис­ куем получить неправильную дату в конце периода. Мера для вычисления скользящей годовой суммы со следующей формулой может выдавать неправильные результаты: MAT Sales Wrong := CALCULATE ( [Sales Amount]; DATESBETWEEN ( 'Date'[Date]; SAMEPERIODLASTYEAR ( NEXTDAY ( LASTDATE ( 'Date'[Date] ) ) ); LASTDATE ( 'Date'[Date] ) ) ) Эта версия формулы будет давать ошибочные цифры на верхней границе периода. Это легко увидеть в отчете, представленном на рис. 8.20. Рис. 8.20 Мера MAT Sales Wrong выдает неправильный результат в конце 2009 года Вплоть до 30 декабря 2009 года все значения меры верны, однако в последний день года сумма оказалась сильно завышена. Дело в том, что для 31 декаб­ ря 2009 года функция NEXTDAY должна вернуть таблицу, содержащую 1 января 2010 года, но не может этого сделать по причине отсутствия такой даты в нашем календаре. В результате функция NEXTDAY возвращает пустую таблицу. Функция SAMEPERIODLASTYEAR, получив на вход пустую таблицу, также возвращает пустую таблицу. А поскольку функция DATESBETWEEN ожидает на вход скалярную величину, пустота, сгенерированная функцией SAMEPERIODLASTYEAR, будет воспринята ей как значение BLANK. В свою очередь, BLANK, приведенный к типу DateTime, дает ноль, что, как мы знаем, соответствует дате 30 декабря 1899 года. Следовательно, функция DATESBETWEEN возвратит все даты из календаря, поскольку пустое значение в качестве нижней даты не устанавливает никаких ограничений на интервал. Как итог мы получаем совершенно неправильный результат. Глава 8 Логика операций со временем 279 Решение здесь очень простое и состоит в использовании правильного порядка вложенности функций. Если первой будет вызвана функция SAMEPERIODLASTYEAR, то из 31 декабря 2009 года мы перенесемся ровно на год назад – в 31 декабря 2008 года. А применение функции NEXTDAY к этой дате даст вполне корректное значение 1 января 2009 года, которое присутствует в нашей таблице дат. Как правило, все функции логики операций со временем возвращают набор дат, существующих в календаре. Если конкретной даты в календаре нет, результатом работы функции будет пустая таблица, которая трансформируется в BLANK. В некоторых сценариях такое поведение функций может приводить к непредсказуемым результатам, как показано в данном разделе. При расчете скользящей годовой суммы более быстро и безопасно отработает функция DATESINPERIOD, но описанные в этом разделе нюансы могут пригодиться вам при комбинировании функций логики операций со временем во время вычисления других показателей. Знакомство с полуаддитивными вычислениями Техника агрегирования значений по разным временным интервалам, которую вы изучили, прекрасно работает с обычными аддитивными мерами. Аддитивной (additive measure) называется мера, вычисления по которой могут агрегироваться простой операцией суммирования при срезе по любому атрибуту. Давайте для примера возьмем меру, отражающую сумму продаж. Сумма продаж по всем покупателям будет равна арифметической сумме всех продаж по каждому отдельному покупателю. Также верно и то, что сумма продаж за год составляется из продаж за каждый день этого года. В аддитивных мерах нет ничего особенного – их очень легко понять и использовать. Но не все вычисления являются аддитивными. Существуют и неаддитивные меры (non-additive measure). Примером такой меры может служить количество уникальных полов покупателей. Для каждого отдельного покупателя результат будет составлять 1. Если вычислять это значение для группы покупателей, итог никогда не будет превышать количество полов (в базе Contoso это три: пустое значение, M (Male) и F (Female)). Таким образом, итог по группе покупателей, дат или любому другому атрибуту не может быть рассчитан путем простого суммирования индивидуальных значений. Неаддитивные меры – частые гости в отчетах, и в большинстве случаев они характеризуют как раз уникальное количество чего бы то ни было. Неаддитивные меры труднее понять и использовать по сравнению с традиционными аддитивными. Но есть и третий – гораздо более сложный – тип аддитивности, именуемый полуаддитивностью. Полуаддитивные меры (semi-additive measure) характеризуются одним типом агрегации (обычно это сумма) по одним столбцам и другим (например, значением на последнюю дату) – по другим. Отличным примером такой меры может являться баланс банковского счета. Баланс всех клиентов банка можно рассчитать путем суммирования индивидуальных балансов. В то же время баланс клиента за год не является суммой балансов по месяцам. Вместо этого 280 Глава 8 Логика операций со временем он равняется сумме баланса на последнюю дату года. Таким образом, срез балансов по покупателям агрегируется стандартным способом, тогда как срез по дате требует совершенно иного подхода. Посмотрите для примера на рис. 8.21. Рис. 8.21 Фрагмент данных по полуаддитивной мере По этому отчету мы видим, что баланс на счете Кэти Джордан (Katie Jordan) на конец января составлял 1687,00, тогда как по истечении февраля он увеличился до 2812,00. Рассматривая два месяца вместе, мы не можем суммировать балансы по ним. Вместо этого мы берем последний доступный баланс. С другой стороны, суммарный баланс клиентов на конец января можно рассчитать путем сложения балансов по всем трем клиентам. Если использовать простую операцию суммирования для всех вычислений по этой мере, может получиться отчет, показанный на рис. 8.22. Рис. 8.22 Здесь мы видим два типа итогов: итоги по датам для каждого клиента и итоги по всем клиентам в рамках разных временных периодов Как видите, значения по месяцам показаны правильно. Но по кварталам и годам производится обычное суммирование балансов, что не может нас Глава 8 Логика операций со временем 281 устраивать. Правильный отчет продемонстрирован на рис. 8.23, здесь по атрибуту времени всегда берется последнее известное значение. Рис. 8.23 Этот отчет показывает правильные значения Работать с полуаддитивными мерами бывает непросто как раз из-за разных подходов к агрегации значений по разным осям и необходимости обращать внимание на все нюансы. В следующих разделах мы познакомимся с базовыми техниками взаимодействия с полуаддитивными вычислениями. Использование функций LASTDATE и LASTNONBLANK DAX предлагает сразу несколько функций для удобной работы с полуаддитивными мерами. Но в обращении с такими нестандартными вычислениями найти нужную функцию – это только полдела. Здесь необходимо уделять внимание самым незначительным нюансам в расчетах. В данном разделе мы продемонстрируем разные версии одного и того же кода, которые будут или не будут работать в зависимости от данных. Неправильные решения мы показываем исключительно в образовательных целях. К тому же в более сложных сценариях к правильному варианту решения нужно идти поэтапно. Первой функцией, с которой мы познакомимся, будет LASTDATE. Мы уже использовали эту функцию ранее при расчете скользящей годовой суммы. Функция LASTDATE возвращает таблицу из одной строки, содержащей последнюю видимую дату в текущем контексте фильтра. Будучи использованной в качестве аргумента фильтра в функции CALCULATE, LASTDATE перезаписывает контекст фильтра по таблице дат таким образом, чтобы видимой осталась только последняя дата выбранного временного периода. В следующем фрагменте кода вычисляется последний доступный баланс с использованием функции LASTDATE, перезаписывающей контекст фильтра по таблице Date: LastBalance := CALCULATE ( SUM ( Balances[Balance] ); 282 Глава 8 Логика операций со временем LASTDATE ( 'Date'[Date] ) ) Функция LASTDATE очень проста в использовании, но, к сожалению, она не годится для многих полуаддитивных расчетов. Фактически эта функция сканирует таблицу дат, всегда возвращая последнюю видимую дату. Например, на уровне месяца она всегда вернет последний день месяца, а на уровне квартала – последний день квартала. Если на этот день нет данных в рассчитываемой мере, результат будет пустым значением. Посмотрите на рис. 8.24. Здесь мы не видим результатов по третьему кварталу (Q3), а также общих итогов. Поскольку мера LastBalance по третьему кварталу вернула пустое значение, этот квартал даже не отображается в отчете, что приводит в некоторое замешательство. Рис. 8.24 Отчет с функцией LASTDATE является неполным, если на последнюю дату месяца нет информации Если вместо месяца на нижнем уровне отчета использовать группировку по дням, проблема использования функции LASTDATE станет еще более очевидной, как видно по рис. 8.25. Третий квартал теперь стал видим, но значения по нему отсутствуют. В случае если в отчете могут присутствовать данные на предпоследний день даты, а на последний – отсутствовать, лучше будет использовать функцию LASTNONBLANK. Функция LASTNONBLANK представляет собой итератор, сканирующий таблицу и возвращающий последнее значение, для которого второй параметр возвращает непустое значение. В нашем примере мы используем функцию LASTNONBLANK для сканирования таблицы Date в поисках последней даты, для которой присутствовало значение в таблице Balances: LastBalanceNonBlank := CALCULATE ( SUM ( Balances[Balance] ); LASTNONBLANK ( 'Date'[Date]; COUNTROWS ( RELATEDTABLE ( Balances ) ) ) ) Глава 8 Логика операций со временем 283 Рис. 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 июля баланс не значится. Таким образом, для июля формула покажет только значение по Мау­рицио. Рис. 8.26 Отчет почти правильный. Вопросы вызывают только строки итогов и третьего квартала Как часто и бывает, в данном случае в поведении DAX нет никаких ошибок. Проблема лишь в том, что наш код пока не учитывает тот факт, что для разных клиентов в нашей модели данных могут присутствовать разные последние даты баланса. В зависимости от наших требований формула может быть поправлена разными способами. Для начала нужно определиться с тем, что показывать в строке итогов. Учитывая то, что на 18 июля в модели есть только частичные данные, можно: принять 18 июля как последнюю дату для всех клиентов вне зависимости от того, какие последние даты для каждого из них. Таким образом, если у клиента нет данных на конкретную дату, значит, у него был нулевой баланс; учитывать последнюю дату для каждого отдельного клиента и агрегировать итоги, исходя из этого. В этом случае актуальным балансом клиента будет считаться последний доступный для него баланс. Оба определения правильны, и здесь все зависит от требований отчета. А раз так, мы рассмотрим написание кода для обоих вариантов. Наиболее простым из них является тот, в котором последней датой считается та, на которую есть какие-то данные, вне зависимости от клиентов. Для этого варианта нам лишь слегка придется изменить поведение функции LASTNONBLANK: LastBalanceAllCustomers := VAR LastDateAllCustomers = CALCULATETABLE ( Глава 8 Логика операций со временем 285 LASTNONBLANK ( 'Date'[Date]; COUNTROWS ( RELATEDTABLE ( Balances ) ) ); ALL ( Balances[Name] ) ) VAR Result = CALCULATE ( SUM( Balances[Balance] ); LastDateAllCustomers ) RETURN Result Здесь мы использовали функцию CALCULATETABLE для очистки фильтра по клиенту при вычислении выражения с функцией LASTNONBLANK. В этом случае для строки общих итогов функция LASTNONBLANK всегда будет возвращать 18 июля – не учитывая текущего клиента в контексте фильтра. В результате итоговые балансы Кэти Джордан и Луиса Бонифаца остались пустыми, что видно по рис. 8.27. Рис. 8.27 Использование единой последней даты для всех клиентов привело к разным результатам в итоговой строке Второй вариант требует чуть больших пояснений. Когда мы используем свою последнюю дату для каждого клиента, итоговые значения не могут быть рассчитаны, просто исходя из действующего контекста фильтра на уровне итогов. Формула должна вычислять подытоги для каждого клиента и агрегировать итоги. Это тот самый случай, когда применение итерационной функции является самым простым и эффективным решением. В следующем примере внешний итератор SUMX используется для того, чтобы суммировать промежуточные итоги по клиентам: 286 Глава 8 Логика операций со временем LastBalanceIndividualCustomer := SUMX ( VALUES ( Balances[Name] ); CALCULATE ( SUM ( Balances[Balance] ); LASTNONBLANK ( 'Date'[Date]; COUNTROWS ( RELATEDTABLE ( Balances ) ) ) ) ) Эта мера рассчитывает последнюю дату индивидуально для каждого клиента. После этого происходит агрегирование значений. Результат работы меры можно видеть на рис. 8.28. Рис. 8.28 Теперь итоги для каждого клиента выводятся на свою последнюю дату Примечание При большом количестве клиентов мера LastBalanceIndividualCustomer может вычисляться неэффективно. Причина в том, что в формуле содержится два вложенных итератора, и внешний отличается большей гранулярностью. Более эффективный и быст­ рый подход к этой задаче будет описан в главе 10, и заключается он в использовании функции TREATAS, которую мы обсудим далее в данной книге. Как вы поняли, сложность в обращении с полуаддитивными мерами кроется вовсе не в коде, а в выборе подхода к таким мерам. Когда этот выбор сделан, написать правильную формулу не представляет большого труда. В данном разделе мы остановились на двух наиболее распространенных функциях для работы с полуаддитивными мерами: LASTDATE и LASTNONBLANK. Есть еще две похожие функции для получения первой даты периода, а не последней. Их названия 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 Линейный график с ежедневными ценами на акции выглядит впечатляюще Рис. 8.30 Матрица по годам и месяцам содержит пустые ячейки Если к таким формулам подходить со всей осторожностью, результаты не станут для вас неожиданными. Что бы мы делали, если бы нам понадобилось рассчитать прирост цен на акции Microsoft с начала квартала? Можно было бы написать такой код, который будет ошибочным: SOQ := CALCULATE ( Глава 8 Логика операций со временем 289 AVERAGE ( MSFT[Value] ); STARTOFQUARTER ( 'Date'[Date] ) ) SOQ% := DIVIDE ( [Last Value] - [SOQ]; [SOQ] ) Функция STARTOFQUARTER возвращает дату начала текущего квартала вне зависимости от того, есть ли значение в эту дату. Например, начало первого квартала – дата 1 января, являющаяся праздничным днем. Соответственно, цен на акции на 1 января просто не бывает, и предыдущая мера выдаст результат, показанный на рис. 8.31. Рис. 8.31 Функция STARTOFQUARTER вернет дату начала квартала вне зависимости от того, был ли этот день праздничным Легко заметить, что для первого квартала значение меры SOQ не вычислено. И такая проблема будет в каждом квартале, который начинается с нерабочего дня. Чтобы получить значения на начало или конец периода, но учитывать при этом только даты, для которых заполнены данные, следует прибегнуть к помощи функций FIRSTNONBLANK и LASTNONBLANK в сочетании с другими функциями логики операций со временем, такими как DATESINPERIOD. Гораздо лучшей реализацией меры SOQ будет следующая: SOQ := VAR FirstDateInQuarter = CALCULATETABLE ( 290 Глава 8 Логика операций со временем FIRSTNONBLANK ( 'Date'[Date]; COUNTROWS ( RELATEDTABLE( MSFT ) ) ); PARALLELPERIOD ( 'Date'[Date]; 0; QUARTER ) ) VAR Result = CALCULATE ( AVERAGE ( MSFT[Value] ); FirstDateInQuarter ) RETURN Result Этот код, конечно, труднее понять и написать. Зато работать он будет вне зависимости от отсутствия каких-либо данных в исходной таблице. Вывод новой версии меры SOQ можно видеть на рис. 8.32. Рис. 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 LastDateInSelection = MAX ( 'Date'[Date] ) RETURN FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] <= LastDateInSelection && YEAR ( 'Date'[Date] ) = YEAR ( LastDateInSelection ) ) ) Или взять функцию DATESMTD: DATESMTD ( 'Date'[Date] ) Этот код легко меняется на следующий: CALCULATETABLE ( VAR LastDateInSelection = MAX ( 'Date'[Date] ) RETURN FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] <= LastDateInSelection && YEAR ( 'Date'[Date] ) = YEAR ( LastDateInSelection ) && MONTH ( 'Date'[Date] ) = MONTH ( LastDateInSelection ) ) ) Не стоит уточнять, что и функция DATESQTD работает по похожему шаблону. Все альтернативные варианты действуют примерно одинаково: извлекают информацию о годе, месяце или квартале из последней даты в текущем выборе, а затем используют полученное значение для построения подходящего фильтра. Преобразование контекста и функции логики операций со временем Вы, наверное, заметили, что в предыдущих фрагментах кода мы везде заключали все выражение в обрамляющую функцию CALCULATETABLE. Это делалось для того, чтобы инициировать преобразование контекста, что необходимо в случае, если дата указана в качестве ссылки на столбец. Ранее в данной главе мы уже говорили, что указание столбца Глава 8 Логика операций со временем 293 с датой в качестве первого параметра функции логики операций со временем автоматически ведет к получению таблицы путем вложенного вызова функций CALCULATETABLE и DISTINCT: DATESYTD ( 'Date'[Date] ) преобразуется в DATESYTD ( CALCULATETABLE ( DISTINCT ( 'Date'[Date] ) ) ) Таким образом, преобразование контекста возникает только для перевода столбца в таб­лицу. Этого не происходит, если в качестве параметра в функцию передан не столбец, а таблица. Так что более точный аналог функции DATESYTD будет следующий: DATESYTD ( 'Date'[Date] ) Он преобразуется в VAR LastDateInSelection = MAXX ( CALCULATETABLE ( DISTINCT ( 'Date'[Date] ) ); [Date] ) RETURN FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] <= LastDateInSelection && YEAR ( 'Date'[Date] ) = YEAR ( LastDateInSelection ) ) Преобразование контекста не происходит, если в качестве параметра в функцию логики операций со временем передается таблица. Функция CALCULATETABLE, генерируемая при передаче ссылки на столбец, необходима в случае, если есть активный контекст строки. Взгляните на следующие два вычисляемых столбца, созданных в таблице Date: 'Date'[CountDatesYTD] = COUNTROWS ( DATESYTD ( 'Date'[Date] ) ) 'Date'[CountFilter] = COUNTROWS ( VAR LastDateInSelection = MAX ( 'Date'[Date] ) RETURN FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] <= LastDateInSelection && YEAR ( 'Date'[Date] ) = YEAR ( LastDateInSelection ) ) ) И хотя они выглядят похоже, это на самом деле не так. Результат вычисления этих столбцов виден на рис. 8.33. CountDatesYTD возвращает количество дней с начала года до даты в текущей строке. Чтобы добиться такого результата, функции DATESYTD нужно проанализировать текущий контекст фильтра и извлечь выбранный период. Однако, поскольку речь идет о вычисляемых столбцах, контекста фильтра у нас нет. Поведение столбца CountFilter объяснить проще. При вычислении максимальной 294 Глава 8 Логика операций со временем даты всегда возвращается последняя дата из календаря, поскольку в контексте фильтров нет никаких фильтров. CountDatesYTD ведет себя иначе, ведь функция DATESYTD инициирует преобразование контекста по причине передачи в нее столбца с датами. Таким образом, создается контекст фильтра с одной текущей датой из итерации. Рис. 8.33 В столбце CountFilter не выполняется преобразование контекста, а в столбце CountDatesYTD – выполняется Если вы используете функцию DATESYTD, но при этом знаете, что код не будет запускаться внутри контекста строки, можете убрать внешнюю функцию CALCULATETABLE, которая в этом случае будет просто не нужна. Это характерно для аргумента фильтра в функции CALCULATE, вызванной не внутри итератора, – где обычно используется функция DATESYTD. В таких случаях вместо функции DATESYTD можно написать: VAR LastDateInSelection = MAX ( 'Date'[Date] ) RETURN FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] <= LastDateInSelection && YEAR ( 'Date'[Date] ) = YEAR ( LastDateInSelection ) ) С другой стороны, чтобы получить дату из контекста строки, например в вычисляемом столбце, можно извлечь эту дату из текущей строки в переменную вместо использования функции MAX: VAR CurrentDate = 'Date'[Date] RETURN FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] <= CurrentDate && YEAR ( 'Date'[Date] ) = YEAR ( CurrentDate ) ) Глава 8 Логика операций со временем 295 Функция DATESYTD позволяет указать дату окончания года, что бывает полезно для вычислений нарастающим итогом в финансовых календарях. Например, если финансовый год начинается 1 июля, в качестве даты окончания года можно указать дату 30 июня одним из следующих способов: DATESYTD ( 'Date'[Date]; "06-30" ) DATESYTD ( 'Date'[Date]; "30-06" ) Чтобы не закладываться на региональные настройки, можно использовать функцию FILTER вместо DATESYTD следующим образом: VAR LastDateInSelection = MAX ( 'Date'[Date] ) RETURN FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] > DATE ( YEAR ( LastDateInSelection ) - 1; <месяц>; <день> ) && 'Date'[Date] <= LastDateInSelection ) Важно Необходимо помнить, что действие функции DATESYTD всегда начинается с даты, следующей за указанной датой окончания финансового года. Проблемы могут возникнуть, когда финансовый год компании начинается 1 марта. Фактически началом года в этом случае может быть как 28 февраля, так и 29 февраля в зависимости от того, високосный год или нет. По состоянию на апрель 2019 года функция DATESYTD этот сценарий корректно не обрабатывает. Таким образом, в моделях компаний, финансовый год которых начинается 1 марта, функцию DATESYTD применять не следует. С обходным путем для этой проблемы можно ознакомиться по адресу: http://sql.bi/fymarch. Функция DATEADD Функция 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 щенном месяце, а 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-го. Рис. 8.34 Выбранные даты смещаются на месяц назад Главная причина того, почему таблица Date должна содержать все даты в рамках одного года, заключается в особенностях поведения функции DATEADD. Всегда помните, что многие функции логики операций со временем внутренне используют функцию DATEADD. Таким образом, присутствие в таблице дат всех без исключения дней без пропусков является залогом правильной работы функций для работы с датами. 298 Глава 8 Логика операций со временем Правило 2 является также очень важным, поскольку в разных месяцах разное количество дней. 31-е число присутствует далеко не во всех месяцах. И если этот день выбран в исходном диапазоне, то в смещенном периоде он будет представлен как последний день месяца. На рис. 8.35 показано, как последние дни марта переносятся на один и тот же последний день февраля, поскольку 29, 30 и 31 февраля в соответствующем году просто не было. Рис. 8.35 Дата, отсутствующая в целевом месяце, заменяется на последний день месяца Следствием из этого правила является то, что в итоговом наборе может оказаться меньше дней, чем в исходном. Это вполне естественно, если представить смещение с целого марта на февраль, в котором дней всегда меньше, и в итоге мы получим 28 или 29 дней вместо 31. Но когда в вашем исходном выборе меньше дней, результат может вас несколько удивить. По рис. 8.36 видно, что пять выбранных дней в марте могут превратиться всего в два дня в феврале. Рис. 8.36 Некоторые дни из исходного выбора могут превращаться в один и тот же день в результате выполнения функции DATEADD Правило 3 описывает особый случай, когда в исходную выборку включен последний день месяца. Например, представьте себе начальный диапазон из трех дней с 29 июня по 1 июля 2007 года. В этой выборке всего три Глава 8 Логика операций со временем 299 дня, но среди них есть последний день месяца, а именно 30 июня. Когда функция DATEADD смещает диапазон назад, она включает в себя последний день мая, то есть 31 мая. На рис. 8.37 изображен этот случай, и к нему стоит присмотреться внимательнее. Как вы могли заметить, 30 июня превратилось в 30 мая. Только если в выборку включаются и 29 июня, и 30 июня, результирующий набор будет содержать 31 мая. В этом случае количество дней в предыдущем месяце будет больше, чем в исходном выборе: два выбранных дня в июне 2007 года превратятся в три дня в мае того же года. Рис. 8.37 Результат функции DATEADD включает все даты между первым и последним днями выбранного диапазона после операции смещения Причина для установки этих правил состоит в том, чтобы формулы логики операций со временем интуитивно понятно работали на уровне месяцев. Как видно по рис. 8.38, сравнивая показатели по месяцам, мы видим ясную и легкую для понимания картину. Смещенный диапазон включает в себя все дни предыдущего месяца. Рис. 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 ) ) Рис. 8.39 Значения в мере PM Sales DateAdd не согласуются с мерой Sales Amount по предыдущему месяцу Мера PM Sales DateAdd образует фильтр дней, не соответствующий полному месяцу. В результате происходит смещение дней выбранного месяца с включением дополнительных дней в конце месяца, согласно правилу 3. Этот фильтр полностью перезаписывает выбор по Day of Week для значений предыдущего месяца. В результате мы получаем неправильные результаты в столбце PM 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. Рис. 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 просто извлекает из столбца с датами минимальное значение, а LASTDATE – максимальное. Получается, что функции FIRSTDATE и LASTDATE работают так же, как MIN и MAX, за одним существенным отличием: FIRSTDATE и LASTDATE возвращают таблицу и инициируют преобразование контекста, тогда как MIN и MAX возвращают скалярные величины без осуществления преобразования контекста. Рассмотрим следующее выражение: CALCULATE ( SUM ( Inventory[Quantity] ); LASTDATE ( 'Date'[Date] ) ) Можно переписать эту формулу с использованием функции MAX вместо LASTDATE, но код при этом увеличится в объеме: CALCULATE ( SUM ( Inventory[Quantity] ); FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] = MAX ( 'Date'[Date] ) ) ) Глава 8 Логика операций со временем 303 Помимо этого, функция LASTDATE выполняет преобразование контекста. Так что точным эквивалентом функции LASTDATE будет следующее выражение: CALCULATE ( SUM ( Inventory[Quantity] ); VAR LastDateInSelection = MAXX ( CALCULATETABLE ( DISTINCT ( 'Date'[Date] ) ); 'Date'[Date] ) RETURN FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] = LastDateInSelection ) ) Преобразование контекста важно, когда вы пользуетесь функциями FIRSTDATE/LASTDATE в условиях наличия контекста строки. Лучше всего пользоваться функциями FIRSTDATE/LASTDATE при написании выражений фильтров, поскольку в этом случае ожидается табличное выражение, тогда как функции MIN/MAX лучше подойдут при составлении логических выражений в контексте строки, где обычно требуются скалярные величины. Действительно, функция LASTDATE, используемая со ссылкой на столбец, подразумевает выполнение преобразования контекста, что скрывает внешний контекст фильтра. Например, стоит предпочесть FIRSTDATE/LASTDATE функциям MIN/MAX при использовании в аргументе фильтра функций CALCULATE/CALCULATETABLE, поскольку синтаксис в этом случае будет проще. При этом стоит использовать функции MIN/MAX в тех случаях, когда преобразование контекста в результате применения функций FIRSTDATE/LASTDATE может повлиять на итоги вычислений. Примером такого использования может быть функция FILTER. Следующее выражение фильтрует даты для расчета промежуточных итогов: FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] <= MAX ( 'Date'[Date] ) ) Здесь правильно использовать функцию MAX. Фактически применение функции LASTDATE вместо MAX привело бы к извлечению всех дат вне зависимости от текущего выбора из-за нежелательного преобразования контекста. Таким образом, следующее выражение всегда будет выдавать полный набор дат. Это происходит из-за того, что функция LASTDATE по причине выполнения преобразования контекста возвращает значение Date[Date] по каждой строке в итерации функции FILTER: FILTER ( ALL ( 'Date'[Date] ); 'Date'[Date] <= LASTDATE ( 'Date'[Date] ) -- это условие всегда возвращает истину ) Функции LASTNONBLANK и FIRSTNONBLANK отличаются от FIRSTDATE и LASTDATE. По своей сути они являются итераторами, а это означает, что они 304 Глава 8 Логика операций со временем проходят по таблице в контексте строки и возвращают последнее (или первое) значение, для которого второй параметр будет непустым. Обычно во второй параметр этих функций передается либо мера, либо выражение с использованием функции CALCULATE для выполнения преобразования контекста. Чтобы получить правильное значение для последней непустой даты для конкретной меры/таблицы, необходимо использовать выражение, подобное следующему: LASTNONBLANK ( 'Date'[Date]; CALCULATE ( COUNTROWS ( Inventory ) ) ) Эта формула вернет последнюю дату (в текущем контексте фильтра), для которой существуют строки в таблице Inventory. Для этой цели можно использовать и следующую формулировку: LASTNONBLANK ( 'Date'[Date]; COUNTROWS ( RELATEDTABLE ( Inventory ) ) ) Это выражение вернет последнюю дату (также в текущем контексте фильт­ ра), для которой есть связанные строки в таблице Inventory. Стоит отметить, что функции FIRSTNONBLANK/LASTNONBLANK могут принимать данные любого типа в качестве первого параметра, тогда как FIRSTDATE/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 CurrentIsoYearNumber = 'Date'[ISO Year Number] VAR CurrentDate = 'Date'[Date] VAR DateFirstJanuary = DATE ( CurrentIsoYearNumber; 1; 1 ) VAR DayOfFirstJanuary = WEEKDAY ( DateFirstJanuary; 3 ) VAR OffsetStartIsoYear = - DayOfFirstJanuary + ( 7 * ( DayOfFirstJanuary > 3 ) ) VAR StartOfIsoYear = DateFirstJanuary + OffsetStartIsoYear VAR Result = CurrentDate - StartOfIsoYear RETURN Result На рис. 8.41 показаны созданные столбцы. Столбец ISO Week будет видим для пользователей, тогда как ISO Week Sequential останется невидимым и будет использоваться для внутренних вычислений. В столбце ISO Year Day Number будет храниться количество дней, прошедших с начала года ISO. С этими вспомогательными столбцами будет гораздо легче производить сравнение различных временных периодов. Глава 8 Логика операций со временем 307 Рис. 8.41 Вычисляемые столбцы в таблице дат для поддержки недель ISO Разработчик может строить собственные агрегации нарастающим итогом с начала года, используя столбец ISO Year Number вместо извлечения года непосредственно из даты. Техника по своей сути останется той же, что и в обсуждении ранее в данной главе. Мы только добавили дополнительную проверку на выбор одного года ISO перед вызовом функции VALUES: ISO YTD Sales := IF ( HASONEVALUE ( 'Date'[ISO Year Number] ); VAR LastDateInSelection = MAX ( 'Date'[Date] ) VAR YearSelected = VALUES ( 'Date'[ISO Year Number] ) VAR Result = CALCULATE ( [Sales Amount]; 'Date'[Date] <= LastDateInSelection; 'Date'[ISO Year Number] = YearSelected; ALL ( 'Date' ) ) RETURN Result ) На рис. 8.42 показан вывод меры ISO YTD Sales для начала 2008 года в сравнении с мерой, вычисленной с применением стандартной функции DATESYTD. Заметьте, что версия ISO включает в себя 31 декабря 2007 года – дату, входящую в состав 2008 года ISO. При сравнении показателей с предыдущим годом необходимо сопоставлять соответствующие недели. А поскольку даты при этом могут быть разные, легче всего использовать другие столбцы таблицы дат для реализации логики сравнения. Распределение недель внутри года всегда одинаковое, ведь любая неделя состоит из семи дней. В то же время месяцы насчитывают разное количество дней, а значит, не могут похвастаться такой же универсальностью. В недельных календарях вы можете упростить вычисления путем поиска тех же относительных дней в предыдущем году, которые были выбраны в текущем контексте фильтра. 308 Глава 8 Логика операций со временем Рис. 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 показан отчет с выводом меры ISO PY Sales. Справа мы добавили информацию о продажах 2007 года, чтобы вам было легче понять, как производится выборка данных в мере ISO PY Sales. Обращаться с недельными календарями довольно просто по причине предположений, которые можно сделать по поводу симметрии между одними и теми же днями в разные годы. С расчетами по месяцам такая логика обычно несовместима, так что если вам необходимо использовать обе иерархии (месяцы и недели), то придется писать свои расчеты для каждой из них. Пользовательские вычисления нарастающим итогом Ранее в данной главе вы узнали, как можно переписать стандартную функцию DATESYTD, использующуюся для произведения вычисления нарастающим итогом с начала года. И там мы использовали атрибуты даты, такие как год, Глава 8 Логика операций со временем 309 из столбца с датами. Когда речь идет о календарях ISO, мы более не можем полагаться исключительно на столбец с датами. Вместо этого воспользуемся созданными ранее вычисляемыми столбцами. В этом разделе мы продемонстрируем на примере, как вместо извлечения атрибутов даты использовать вспомогательные столбцы в таблице дат. Рис. 8.43 В мере ISO PY Sales рассчитаны продажи предыдущего года по аналогичной неделе Рассмотрим стандартную меру YTD Sales: YTD Sales := CALCULATE ( [Sales Amount]; DATESYTD ( 'Date'[Date] ) ) Соответствующий синтаксис для меры в DAX без использования специальных функций логики операций со временем будет выглядеть следующим образом: YTD Sales := VAR LastDateInSelection = MAX ( 'Date'[Date] ) VAR Result = CALCULATE ( [Sales Amount]; 'Date'[Date] <= LastDateInSelection && YEAR ( 'Date'[Date] ) = YEAR ( LastDateInSelection ) ) RETURN Result 310 Глава 8 Логика операций со временем Если вы используете нестандартный календарь, вам следует заменить функцию YEAR на обращение к созданному столбцу с годом, как показано в следующей мере YTD Sales Custom: YTD VAR VAR VAR Sales Custom := LastDateInSelection LastYearInSelection Result = CALCULATE ( [Sales Amount]; 'Date'[Date] <= 'Date'[Calendar ALL ( 'Date' ) ) RETURN Result = MAX ( 'Date'[Date] ) = MAX ( 'Date'[Calendar Year Number] ) LastDateInSelection; Year Number] = LastYearInSelection; Можно использовать этот же шаблон для написания вычислений нарастающим итогом с начала квартала или месяца. Единственным отличием будет столбец, к которому вы будете обращаться вместо столбца Calendar Year Number: QTD VAR VAR VAR Sales Custom := LastDateInSelection = MAX ( 'Date'[Date] ) LastYearQuarterInSelection = MAX ( 'Date'[Calendar Year Quarter Number] ) Result = CALCULATE ( [Sales Amount]; 'Date'[Date] <= LastDateInSelection; 'Date'[Calendar Year Quarter Number] = LastYearQuarterInSelection; ALL ( 'Date' ) ) RETURN Result MTD VAR VAR VAR Sales Custom := LastDateInSelection = MAX ( 'Date'[Date] ) LastYearMonthInSelection = MAX ( 'Date'[Calendar Year Month Number] ) Result = CALCULATE ( [Sales Amount]; 'Date'[Date] <= LastDateInSelection; '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.daxpatterns.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 Amount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) Total Cost := SUMX ( Sales; Sales[Quantity] * Sales[Unit Cost] ) Margin := [Sales Amount] - [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] ) ) Все перечисленные меры отличаются лишь базовой мерой, с которой производится действие. А именно накладывается один и тот же фильтр DATESYTD, в рамках которого вычисляется значение первого параметра функции CALCULATE. Было бы здорово, если бы у разработчика была возможность написать какое-то общее определение с шаблоном для подстановки нужной меры: YTD <Measure> := CALCULATE ( <Measure>; 314 Глава 9 Группы вычислений DATESYTD ( 'Date'[Date] ) ) Этот код не соответствует синтаксису языка DAX, но он дает определенное представление о том, что из себя представляют элементы вычисления (calculation 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 Рис. 9.1 Можно пользоваться группой вычислений так, как если бы она являлась столбцом таблицы в модели данных Создание групп вычислений Реализация групп вычислений в модели Tabular зависит от пользовательского интерфейса инструмента разработчика. На момент написания книги (апрель 2019 года) ни Power BI, ни SQL Server Data Tools (SSDT) для Analysis Services не обладали специальным интерфейсом для групп вычислений, а доступны они были только через API объектной модели Tabular (Tabular Object Model – TOM). Первым инструментом, предоставившим возможность для работы с группами вычислений, стал 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 BI не позволяет разработчику производить агрегацию столбцов, поскольку группы вычислений могут быть применены только к мерам – они не оказывают никакого влияния на функции агрегирования, а оперируют только мерами. Рис. 9.2 В Tabular Editor группа вычислений Time Intelligence отображается как особенная таблица Рис. 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, в отчете показаны нарастающие итоги меры с начала года. Рис. 9.4 Когда пользователь выбирает YTD, значения для меры в отчете рассчитываются нарастающим итогом с начала года 318 Глава 9 Группы вычислений Если в том же отчете пользователь выберет разновидность SPLY, результаты будут другими, что видно по рис. 9.5. Рис. 9.5 Выбор SPLY привел к изменению значений в мере Sales Amount – теперь они представляют показатели, смещенные на год назад Если оставить столбец в срезе без выбора или выбрать сразу несколько значений, движок не будет предпринимать никаких действий с мерой, оставив ее неизменной, что видно по рис. 9.6. Рис. 9.6 Когда ни одно значение в срезе не выбрано, мера показывается без изменений Примечание Поведение групп вычислений без выбора значений или со множественным выбором может измениться в будущем. На апрель 2019 года поведение мер со множественным выбором и без выбора одинаковое. Но в будущих версиях это может измениться – например, при множественном выборе может появляться ошибка. Глава 9 Группы вычислений 319 Однако группы вычислений способны на гораздо большее. В начале раздела мы представили вам четыре меры: Sales Amount, Total Cost, Margin и Sales Quantity. Было бы здорово, если бы пользователь мог выбирать, какую метрику показывать, а не только вид применяемого вычисления. В этом случае мы могли бы построить общий отчет с четырьмя возможными метриками по месяцу и году, предоставив выбор конкретной метрики пользователю. Отчет должен выглядеть примерно так, как показано на рис. 9.7. Рис. 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 Intelligence до 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] Рис. 9.8 Группа вычислений Metric включает в себя четыре элемента вычисления, каждый из который представляет соответствующую меру В этих элементах вычисления оригинальные меры не изменяются, они представлены в исходном виде. Чтобы добиться такого поведения, достаточно не указывать в коде элемента функцию SELECTEDMEASURE. Эта функция очень часто применяется в элементах вычисления, но это отнюдь не обязательно. Последний пример также полезен для демонстрации одного из многих нюансов, связанных с использованием групп вычислений. Если пользователь выберет метрику Quantity, в отчете будет показано количество проданных товаров в таком же формате (с двумя знаками после запятой), как и для других метрик. А поскольку исходная мера Quantity характеризуется целочисленным Глава 9 Группы вычислений 321 типом, было бы удобно удалить эти знаки после запятой или изменить форматирование. Ранее мы уже говорили, что присутствие в выражении нескольких групп вычислений требует указания порядка их применения, как было показано в предыдущем примере. Это лишь одно из многих правил, которых следует придерживаться для создания эффективных групп вычислений. Примечание Если вы используете Analysis Services, помните, что добавление группы вычислений требует обновления соответствующей ей таблицы, чтобы элементы вычисления оказались видимы пользователю. Это не самое очевидное требование, ведь размещение новой меры, допустим, не нуждается в подобном обновлении – она становится видимой пользователю сразу после сохранения. Но поскольку группы и элементы вычислений представлены на клиенте в виде таблиц и столбцов, после их размещения необходимо запустить операцию обновления, чтобы загрузить внутренние данные в таблицы и столбцы. В Power BI такое обновление будет производиться автоматически при помощи пользовательского интерфейса, но это только наши домыслы, поскольку на момент написания книги в этом инструменте группы вычислений не были представлены. Рис. 9.9 Свойство Precedence определяет, в каком порядке группы вычисления будут применяться к мере Знакомство с группами вычислений В предыдущем разделе мы сосредоточились на использовании групп вычислений и их создании при помощи Tabular Editor. Сейчас же мы подробнее познакомимся со свойствами и поведением групп и элементов вычислений. Существует два вида сущностей: группы вычислений и элементы вычисления. Группа вычислений представляет собой коллекцию элементов вычисле322 Глава 9 Группы вычислений ния, объединенную по выбранному пользователем критерию. Обе сущности обладают своим набором свойств, которые корректно должны быть установлены разработчиком. Сейчас мы познакомим вас с этими свойствами, а в оставшейся части главы представим несколько подробных примеров использования групп и элементов вычислений. Группа вычислений является довольно простой сущностью, определяемой следующими свойствами: названием группы или свойством Name. Под этим именем таблица, представляющая группу вычислений, будет представлена на клиенте; очередностью применения группы вычислений к мерам, то есть свойством Precedence. При наличии множества активных групп вычислений это число используется для определения порядка, в котором группы вычислений будут применяться к мерам; свойством Name для атрибута группы вычислений. Это название будет дано столбцу с элементами вычисления, отображаемому на клиенте. Элемент вычисления – сущность чуть более сложная, и она включает следующие свойства: название элемента вычисления (Name). Это свойство характеризует значение, которое будет присутствовать в столбце. По сути, элемент вычисления представлен в группе вычислений одной строкой; выражение элемента вычисления (Expression). Выражение на языке DAX, которое может включать специальные функции вроде SELECTEDMEASURE. Это выражение определяет, как будет применяться к мере элемент вычисления; порядок сортировки элементов вычисления (Ordinal). Этим свойством определяется, как элементы вычисления будут отсортированы при представлении пользователю, и напоминает сортировку по столбцу в модели данных. По состоянию на апрель 2019 года это свойство недоступно, но должно быть реализовано к выходу релиза; строка форматирования (Format String). Если не указана, то будет уна­ следована от базовой меры. Если же модификатор меняет вычисление, можно переопределить строку форматирования меры этим свойством. Свойство Format String является очень важным, поскольку позволяет добиться предсказуемого поведения от мер в модели данных, соответствующего применяемому к ним элементу вычисления. Представьте, что в вашей группе вычислений есть два элемента вычисления, выполняющих операции со временем: YOY (year-over-year) представляет разницу в значениях между выбранным периодом и аналогичным периодом предыдущего года, а YOY% выводит разницу YOY между периодами в процентном отношении: --- Calculation Item: YOY -VAR CurrYear = SELECTEDMEASURE () VAR PrevYear = CALCULATE ( Глава 9 Группы вычислений 323 SELECTEDMEASURE (); SAMEPERIODLASTYEAR ( 'Date'[Date] ) ) VAR Result = CurrYear - PrevYear RETURN Result --- Calculation Item: YOY% -VAR CurrYear = SELECTEDMEASURE () VAR PrevYear = CALCULATE ( SELECTEDMEASURE (); SAMEPERIODLASTYEAR ( 'Date'[Date] ) ) VAR Result = DIVIDE ( CurrYear - PrevYear; PrevYear ) RETURN Result Применение этих элементов вычисления в отчете дает правильные результаты, но если не переопределить свойство Format String для элемента YOY%, то его значение будет выводиться с двумя знаками после запятой, а не в виде процента, как показано на рис. 9.10. Рис. 9.10 Элементы вычисления YOY и YOY% обладают тем же форматированием, что и мера Sales Amount На рис. 9.10 видно, что элемент вычисления YOY унаследовал строку форматирования от исходной меры Sales Amount, что в данном случае вполне приемлемо. Что касается элемента YOY%, было бы логичнее выводить его в отчет не с десятичными знаками, а в виде процента. То есть для января хотелось бы видеть не –0,12, а –12 %. Таким образом, нам необходимо переопределить строку форматирования для этого столбца, чтобы она не зависела от исходной 324 Глава 9 Группы вычислений меры. Чтобы добиться желаемого результата, нужно в свойстве Format String элемента вычисления YOY% выбрать проценты и тем самым переопределить базовые правила форматирования меры. Результат показан на рис. 9.11. Если свойство Format String не задано для элемента вычисления, будет использовано существующее значение. Рис. 9.11 В элементе вычисления YOY% переопределяется строка форматирования меры Sales Amount При этом свойство Format String может быть задано как в фиксированном виде, так и – для более сложных случаев – в виде выражения DAX, возвращающего строку форматирования. В последнем случае допустимо обращаться к строке форматирования текущей меры при помощи функции SELECTEDMEASUREFORMATSTRING. Например, если в модели данных есть мера, возвращающая выбранную в данный момент валюту, и вы хотите добавить в отображении символ соответствующей валюты, вы можете использовать для этого следующий код: 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] ); 'Time Intelligence'[Time 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 Amount] ); 'Time Intelligence'[Time calc] = "YTD" ) Чтобы получить код, который будет выполнен, необходимо все ссылки на меры заменить на определение соответствующего элемента вычисления, как показано в мере CR YTD Actual Code: CR YTD Actual Code := CALCULATE ( DIVIDE ( CALCULATE ( [Total Cost]; DATESYTD ( 'Date'[Date] ) ); CALCULATE ( [Sales Amount]; DATESYTD ( 'Date'[Date] ) ) ) ) Глава 9 Группы вычислений 327 В данном случае результат выполнения этого кода будет эквивалентен следующему фрагменту меры CR YTD Simplified, чуть более понятному: CR YTD Simplified := CALCULATE ( CALCULATE ( DIVIDE ( [Total Cost]; [Sales Amount] ); DATESYTD ( 'Date'[Date] ) ) ) Все три меры вернут одинаковые результаты, как показано на рис. 9.12. Рис. 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] -- Ссылка на меру ) ) Рис. 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. Рис. 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]; -- Ссылка на меру 'Time Intelligence'[Time 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 представляет собой обычный нарастающий итог с начала года, а Daily AVG рассчитывает среднедневное значение путем деления значения меры на количество дней в текущем контексте фильтра. Оба элемента вычисления пре334 Глава 9 Группы вычислений красно работают, как видно по рис. 9.15, где мы использовали две меры для индивидуального применения элементов: YTD := CALCULATE ( [Sales Amount]; 'Time Aggregation'[Aggregation] = "YTD" ) Daily AVG := CALCULATE ( [Sales Amount]; 'Averages'[Averages] = "Daily AVG" ) Рис. 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 Группы вычислений 335 --- Сначала применяется 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. Рис. 9.16 В мере Daily YTD AVG вычисляются неправильные результаты 336 Глава 9 Группы вычислений Очевидно, что по всем месяцам, кроме января, мера Daily YTD AVG показывает некорректные значения. Давайте разберемся, что же произошло. Очередность группы вычислений Averages установлена в 10, а значит, эта группа будет вступать в действие первой. Применение элемента вычисления Daily AVG приводит к следующему вычислению: CALCULATE ( DIVIDE ( [Sales Amount]; COUNTROWS ( 'Date' ) ); 'Time Intelligence'[Time calc] = "YTD" ) В этот момент DAX активирует элемент вычисления YTD из группы вычислений Time Intelligence. Применение элемента YTD приводит к изменению единственной меры в этой формуле, а именно Sales Amount. Соответственно, результирующий код меры Daily YTD AVG получится таким: DIVIDE ( CALCULATE ( [Sales Amount]; 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 Amount]; Глава 9 Группы вычислений 337 DATESYTD ( 'Date'[Date] ) ); 'Averages'[Averages] = "Daily AVG" ) Рис. 9.17 Мера Daily YTD AVG показывает правильный результат После этого применяется элемент Daily AVG из группы вычислений Avera­ ges, заменяя меру на функцию DIVIDE, что приводит к такому выражению: CALCULATE ( DIVIDE ( [Sales Amount]; COUNTROWS ( 'Date' ) ); DATESYTD ( 'Date'[Date] ) ) Теперь при вычислении значения для декабря в знаменателе будут учитываться все 365 дней года, а значит, и общий результат будет правильным. Обратите внимание также, что в этом примере мы строго следовали нашему главному правилу использования элементов вычисления, то есть применяли их исключительно к мерам. При отображении меры Sales Amount в визуализации Power BI применение одного из двух элементов вычисления преобразовало меру таким образом, что результат перестал соответствовать действительности. Получается, в нашем сценарии недостаточно было просто следовать нашему правилу – нужно было еще верно определить очередность применения групп вычислений. Все элементы внутри одной группы обладают одной и той же родительской очередностью применения. Невозможно для разных элементов вычисления в рамках одной группы задать разные значения очередности. Целочисленное свойство Precedence задается именно для группы вычислений. Чем выше его значение, тем выше приоритет группы. Таким образом, элементы группы с наивысшим приоритетом будут применяться к мерам в пер338 Глава 9 Группы вычислений вую очередь. Иными словами, DAX применяет группы вычисления в порядке следования значений их свойств Precedence от большего к меньшему. Само абсолютное значение этого свойства не несет никакой информации. Смысл имеет только относительное значение Precedence в сравнении с другими группами. Кроме того, в модели данных не могут присутствовать две группы вычислений с одинаковыми значениями свойства Precedence. Поскольку все группы вычислений должны иметь свои уникальные значения свойства Precedence, этому необходимо уделить внимание при проектировании модели данных. Правильный выбор очередности применения групп вычислений на этапе создания модели имеет важнейшее значение, ведь изменение этого свойства у той или иной группы может повлиять на уже готовые отчеты. Если в вашей модели данных присутствует несколько групп вычислений, потратьте время, чтобы убедиться, что все вычисления дают ожидаемые результаты при использовании любой комбинации элементов вычисления. Без должной проверки очень высока вероятность того, что будет допущена ошибка с очередностью применения групп к мерам. Включение и исключение мер из элементов вычисления Существуют сценарии, в которых тот или иной элемент вычисления подходит не для всех мер. По умолчанию элемент вычисления распространяет свое влия­ ние на все без исключения меры. Но в руках разработчика есть инструмент, позволяющий ограничить сферу влияния элементов на меры. В DAX можно написать условия для определения того, какая именно мера вычисляется в данный момент. Для этого служат функции ISSELECTEDMEASURE и 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 Item: Daily AVG -IF ( NOT ISSELECTEDMEASURE ( [Margin %] ); DIVIDE ( SELECTEDMEASURE (); COUNTROWS ( 'Date' ) ) ) В обоих случаях мера Margin % будет исключена из числа мер, к которым будет применяться элемент вычисления Daily AVG, как видно на рис. 9.18. Рис. 9.18 Элемент вычисления Daily AVG не применяется к мере Margin % Еще одной функцией, позволяющей анализировать выбранную меру, является функция SELECTEDMEASURENAME, возвращающая не булево значение, а строку с названием меры. Эту функцию можно применять вместо ISSELECTEDMEASURE, как показано ниже: -------------------------------------------------------- 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 и SAMEPERIODLASTYEAR. Следующее выражение поможет решить наш сценарий: --- Calculation Item: PYTD -CALCULATE ( SELECTEDMEASURE (); DATESYTD ( SAMEPERIODLASTYEAR ( 'Date'[Date] ) ) ) По причине простоты вычисления эту формулу вполне можно назвать оптимальной. Но для разминки мозгов мы попробуем написать этот код иначе. У нас ведь уже есть элемент вычисления для расчета нарастающего итога с начала года (YTD). Может, попробовать применить этот элемент повторно, чтобы не использовать вложенные функции логики операций со временем? Взгляните на такой вариант определения для элемента вычисления PYTD: --- Calculation Item: PYTD -CALCULATE ( SELECTEDMEASURE (); SAMEPERIODLASTYEAR ( 'Date'[Date] ); 'Time Intelligence'[Time calc] = "YTD" ) Этот элемент вычисления служит тем же целям, что и предыдущий, но использует при этом совершенно иную технику. Функция SAMEPERIODLASTYEAR смещает текущий контекст фильтра ровно на год назад, а нарастающий итог с начала года мы получим, применив уже имеющийся элемент вычисле342 Глава 9 Группы вычислений ния YTD из группы Time calc. Как мы и предполагали, в этом случае код станет более трудным для понимания. Но в более сложных сценариях возможность использовать ранее созданные элементы вычисления может помочь избежать многократного повторения одного и того же кода в определении меры. Это действительно мощный инструмент, применимый в комплексных решениях. И основан он на принципе рекурсии, который стоит пояснить отдельно. Как вы видели на примере PYTD, синтаксически вполне допустимо определять новый элемент вычисления на основании существующего из той же группы вычислений. Если бы концепция рекурсии была применима в DAX без ограничений, это могло бы приводить к очень сложным зависимостям. К примеру, элемент вычисления A мог бы зависеть от элемента B, который зависел бы от C, а тот, в свою очередь, зависел от A. Следующий вымышленный пример демонстрирует эту ситуацию: -------------------------------------------------------- Calculation Group: Infinite[Loop] --------------------------------------------------------- Calculation Item: Loop A -CALCULATE ( SELECTEDMEASURE (); Infinite[Loop] = "Loop B" ) --- Calculation Item: Loop B -CALCULATE ( SELECTEDMEASURE (); Infinite[Loop] = "Loop A" ) Если попытаться использовать эту группу вычислений в выражении, как показано в следующем примере, DAX не удастся применить элементы вычисления, поскольку элемент A требует применения элемента B, который, в свою очередь, ожидает применения элемента A: CALCULATE ( [Sales Amount]; Infinite[Loop] = "Loop A" ) В некоторых языках программирования допускается использование таких циклических зависимостей при определении выражений – обычно в функциях, что ведет к образованию так называемых рекурсивных определений (recursive definition). Определение рекурсивной функции предполагает использование самой себя. Рекурсия является очень мощной концепцией, но разработчикам бывает не так просто писать подобные функции, а оптимизаторам – искать наиболее эффективный план выполнения запроса. Глава 9 Группы вычислений 343 Именно поэтому в DAX не допускается написание рекурсивных элементов вычисления. Вместо этого разработчик имеет право в одном элементе вычисления ссылаться на другой элемент из той же группы, но повторный вызов элемента недопустим. Иными словами, можно использовать функцию CALCULATE для применения элемента вычисления к мере, но примененный элемент не может прямо или косвенно вызывать исходный элемент вычисления. Именно в этом и заключается принцип косвенной рекурсии. Полноценная рекурсия в DAX недопустима, но повторно использовать элементы вычисления без обратных вызовов можно. Примечание Если вы знакомы с языком MDX, то должны знать, что в нем допустимо использование как косвенной, так и полноценной рекурсии. Отчасти поэтому MDX считается более сложным языком по сравнению с DAX. Кроме того, полноценная рекурсия зачастую влечет за собой проблемы в плане производительности. И это еще одна причина, по которой в DAX она не используется. Стоит также помнить, что рекурсия может возникать и вследствие установки фильтра на элемент вычисления в самой мере – без взаимных вызовов между элементами. Рассмотрим следующий пример определения мер (Sales Amount, MA, MB) и элементов вычисления (A и B): --- Определение мер -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: B -[MA] Элементы вычисления не ссылаются непосредственно друг на друга. Но при этом они ссылаются на меры, которые, в свою очередь, ссылаются на элементы, провоцируя запуск бесконечного цикла. Можно заметить это, последовательно пройдя по шагам. Рассмотрим следующее выражение: CALCULATE ( [Sales Amount]; Infinite[Loop] = "A" ) 344 Глава 9 Группы вычислений Применение элемента вычисления A порождает такой результат: CALCULATE ( CALCULATE ( [MB] ) ) При этом мера MB внутренне ссылается на меру Sales Amount и элемент вычисления B, что приводит к следующему преобразованию кода: CALCULATE ( CALCULATE ( CALCULATE ( [Sales Amount]; Infinite[Loop] = "B" ) ) ) На этом этапе применение элемента вычисления B приводит к такому результату: CALCULATE ( CALCULATE ( CALCULATE ( CALCULATE ( [MA] ) ) ) ) Мера MA внутренне ссылается на меру Sales Amount и элемент вычисления A, что ведет к следующему преобразованию кода: CALCULATE ( CALCULATE ( CALCULATE ( CALCULATE ( CALCULATE ( [Sales Amount]; Infinite[Loop] = "A" ) ) ) ) ) В результате мы вернулись к исходному выражению и фактически вошли в бесконечный цикл элементов вычисления, применяемых к выражению, хотя сами элементы при этом друг на друга не ссылаются. Вместо этого они вызывают меру, которая ссылается на элементы вычисления. К счастью, движок DAX достаточно интеллектуален, чтобы определить возникновение такой ситуации, и выдаст ошибку. Косвенная рекурсия может приводить к написанию очень запутанных выражений, непростых для понимания и потенциально ведущих к неправильГлава 9 Группы вычислений 345 ным расчетам. Сложность использования элементов вычисления совместно с косвенной рекурсией проявляется в случаях, когда меры внутренне применяют элементы вычисления при помощи функции CALCULATE, а пользователь меняет выбор элементов в интерфейсе – например, в срезах отчета в Power BI. Мы советуем ограничить использование косвенной рекурсии в коде DAX до предела, даже если это приведет к появлению повторений в формулах. Косвенную рекурсию можно безопасно использовать только в скрытых группах вычислений, чтобы пользователь никак не мог повлиять на результат. Помните, что в Power BI пользователи могут определять собственные меры в отчетах, и без должного понимания сложных концепций вроде рекурсии они рискуют наделать ошибок, даже не осознавая этого. Два основных правила Как мы уже отмечали в начале главы, есть всего два основных правила, которых необходимо придерживаться, чтобы не возникало проблем при использовании элементов вычисления: используйте элементы вычисления для модификации выражений, представляющих собой ссылку на меру. И никогда не используйте их с более сложными конструкциями; --- Как надо -SalesPerWd := CALCULATE ( [Sales Amount]; 'Time Intelligence'[Time calc] = "YTD" ) --- Как не надо. Никогда так не делайте! -SalesPerWd := CALCULATE ( SUMX ( Customer; [Sales Amount] ); меру 'Time Intelligence'[Time calc] = "YTD" ) -- Ссылка на меру. Это правильно -- Сложное выражение, а не ссылка на избегайте создания косвенной рекурсии в доступных пользователям группах вычислений. Безопасно использовать рекурсию можно исключительно в скрытых группах. Если вы все же решили применить эту концепцию в своей модели данных, внимательно проследите за тем, чтобы косвенная рекурсия не превратилась в полноценную, иначе возникнет ошибка. 346 Глава 9 Группы вычислений Заключение Группы вычислений представляют собой очень мощный инструмент, позволяющий упростить модель данных. Возможность создавать множество вариаций одних и тех же мер при помощи групп вычислений позволяет существенно сократить количество дублирующегося кода в обычных мерах, количество которых иначе могло бы составить несколько сотен. Кроме того, пользователям нравятся группы вычислений за возможность создания собственных сочетаний вычислений. Будучи разработчиком DAX, вы должны хорошо понимать преимущества и недостатки групп вычислений. Вот основные принципы, которые мы старались донести до вас в данной главе: группа вычислений представляет собой набор из элементов вычисления; элемент вычисления является разновидностью меры. Использование функции SELECTEDMEASURE позволяет корректировать ход вычисления; элемент вычисления способен переопределять исходное выражение и строку форматирования текущей меры; если в модели присутствует несколько групп вычислений, разработчик обязан определить очередность применения их элементов, чтобы исключить неоднозначность их поведения; элементы вычисления применяются к ссылкам на меры, а не к выражениям. Использование элементов вычисления с выражениями, не состоящими из одной меры, может привести к неожиданным результатам. Таким образом, лучше всего применять элементы исключительно к выражениям, представляющим единственную ссылку на меру; разработчик вправе использовать косвенную рекурсию при определении элементов вычисления, но это может серьезно усложнить выражение в целом. Следует ограничить использование косвенной рекурсии скрытыми группами вычислений и никогда не применять эту концепцию в группах, видимых пользователю; следование советам из этой главы облегчит жизнь разработчикам и позволит избежать чрезмерного усложнения сценариев, что характерно для использования групп вычислений. Помните, что группы вычислений являются одной из новинок в языке DAX. Это очень мощная концепция, которую мы, по сути, только начинаем изучать. Мы будем постоянно обновлять контент страницы в интернете, указанной в данной главе, на которой вы сможете ознакомиться с новыми статьями и пос­ тами в блоге по этой интересной и важной теме. ГЛ А В А 10 Работа с контекстом фильтра В предыдущих главах вы научились создавать контексты фильтра для проведения сложных вычислений. Например, в главе, посвященной работе с датой и временем, вы узнали, как можно сочетать функции логики операций со временем для сравнения разных временных интервалов. В предыдущей главе вы научились использовать группы вычислений для упрощения кода на DAX и облегчения работы пользователю. В настоящей главе мы познакомим вас со множеством функций для чтения текущего состояния контекста фильтра, при помощи которых вы сможете корректировать поведение формул в зависимости от настройки фильтров и текущего выбора пользователя. Это очень мощные функции, хотя используются они не так часто. И все же хорошее понимание их работы просто необходимо для создания полноценных мер, которые будут работать в любых отчетах, а не только там, где вы использовали их в первый раз. Формула может работать или нет в зависимости от того, какой контекст фильтра установлен в данный момент. Например, вы можете написать формулу, которая будет прекрасно функционировать на уровне месяца, но при этом выдавать неправильные результаты по годам. Еще один пример – ранжирование покупателей. Формула будет работать корректно, если в текущем контексте фильтра будет выбран один покупатель, а для множественного выбора покажет неверные цифры. Следовательно, чтобы работать в любых отчетах, мера должна проверять текущее состояние контекста фильтра и только после этого вычислять значение и возвращать результат. Если контекст фильтра удовле­ творяет требованиям формулы, она должна возвращать осмысленное значение. В противном случае, когда текущий контекст фильтра содержит фильтры, несовместимые с кодом, лучше всего будет вернуть пустое значение. Внимательно относитесь к тому, чтобы ни одна формула никогда не возвращала неправильный результат. Всегда лучше вернуть пустое значение, чем некорректные цифры. Пользователь будет крутить вашу модель данных, не обладая знаниями о том, как внутренне устроен ваш код. И как разработчик DAX вы ответственны за то, чтобы меры работали в любых условиях. Для каждой функции, с которой вы познакомитесь в данной главе, мы покажем сценарии, где она наиболее применима. Но ваши собственные сценарии, конечно, будут отличаться от наших демонстраций. Читая об этих функциях, думайте о том, как они могут улучшить вашу модель данных. Также в этой главе мы коснемся двух важных концепций: привязки данных (data lineage) и применения функции TREATAS. До этого вы уже использовали концепцию привязки данных, даже не догадываясь об этом и не зная всех ее особенностей. В данной главе мы коснемся этой темы более подробно и представим несколько сценариев, в которых можно использовать эти концепции. 348 Глава 10 Работа с контекстом фильтра Использование функций HASONEVALUE и SELECTEDVALUE Как мы уже сказали во вводной части главы, осмысленность результатов некоторых вычислений напрямую зависит от текущего выбора пользователя. В качестве примера рассмотрим следующую формулу, вычисляющую сумму продаж нарастающим итогом с начала квартала: QTD Sales := CALCULATE ( [Sales Amount]; DATESQTD ( 'Date'[Date] ) ) Как видно по рис. 10.1, мера прекрасно работает для месяцев и кварталов, но за 2007 год выводит значение 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. Рис. 10.2 Защита меры QTD Sales при помощи функции HASONEVALUE позволила оставить пустыми ячейки с нежелательными значениями Этот простой пример на самом деле очень важен. Вместо того чтобы оставить формулу как есть, мы пошли на шаг дальше и добавили проверку, позволившую выводить значения только там, где они имеют смысл. Если есть риск, что формула может выдавать неверное значение в определенных состояниях контекста фильтра, необходимо производить определенную проверку на минимальные требования и действовать соответствующе. В главе 7 вы уже видели подобный сценарий при работе с функцией RANKX. Там мы производили ранжирование покупателей и использовали функцию HASONEVALUE для гарантии того, что в текущем контексте фильтра выбран единственный покупатель. Также функция HASONEVALUE часто используется в сценариях с логикой операций со временем из-за большого количества агрегаций, которые способны выдавать осмысленные результаты только на определенных уровнях 350 Глава 10 Работа с контекстом фильтра контекста фильтра. Во всех остальных случаях ячейки в отчетах желательно оставлять пустыми. Еще одним распространенным случаем использования функции HASONEVALUE является извлечение одного выбранного значения из текущего контекста фильтра. Есть множество сценариев, в которых это может пригодиться, но с появлением групп вычислений их количество существенно уменьшится. Мы опишем один сценарий с применением разновидности анализа «что, если». В подобных ситуациях разработчик обычно создает таблицу параметров, позволяя пользователю выбрать в срезе одно значение, после чего этот параметр используется в коде для изменения хода вычисления. Представим, что вы оцениваете сумму продаж путем корректировки значений предыдущих лет в соответствии с уровнем инфляции. Для осуществления анализа пользователь должен выбрать годовой уровень инфляции, который будет использоваться для всех дат транзакций вплоть до сегодняшнего дня. Уровень инфляции представлен здесь как параметр алгоритма. Для начала нам необходимо построить таблицу с инфляцией, из которой пользователь будет делать выбор. В нашем примере достаточно будет значений от 0 % до 20 % с шагом 0,5 %. Фрагмент этой таблицы вы можете видеть на рис. 10.3. Рис. 10.3 В столбце Inflation содержатся значения от 0 % до 20 % с шагом 0,5 % Пользователь выбирает желаемое значение при помощи среза, после чего формула пересчитывается для всех лет вплоть до сегодняшнего дня. Если пользователь не сделал выбор или выбрал больше одного значения, формула должна использовать значение инфляции по умолчанию, равное 0 %. Итоговый отчет показан на рис. 10.4. Примечание Параметр «что, если» (What-If) в Power BI создает таблицу и срез с использованием той же техники, что описана в данном разделе. Несколько важных замечаний по поводу этого отчета: пользователь может выбрать желаемый уровень инфляции в срезе, расположенном вверху слева; Глава 10 Работа с контекстом фильтра 351 вверху справа в отчете показан год, используемый для корректировки. За основу берется дата последней продажи в модели данных; в мере Inflation Adjusted Sales сумма продаж анализируемого года умножается на коэффициент, зависящий от выбранного пользователем уровня инфляции; в строке итогов в формуле должны использоваться разные коэффициенты для разных лет. Рис. 10.4 Параметр Inflation управляет множителем по показателям предыдущих лет Простейшей формулой является определение отчетного года. Здесь мы извлекаем максимальную дату заказа из таблицы Sales: Reporting year := "Reporting year: " & YEAR ( MAX ( Sales[Order Date] ) ) Выбранный пользователем уровень инфляции можно получить при помощи функций MIN или MAX, поскольку при выборе единственного показателя они будут возвращать одно и то же значение, являющееся выбранным уровнем инфляции. При этом пользователь может не выбрать уровень инфляции вовсе или выбрать сразу несколько значений. В этом случае формула должна отрабатывать корректно, используя значение по умолчанию. Лучшим способом проверить, что пользователь выбрал единственное значение в списке уровней инфляции, является использование функции HASO352 Глава 10 Работа с контекстом фильтра 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 Number] ) VAR Inflation = [User Selected Inflation] VAR Years = FILTER ( ALL ( 'Date'[Calendar Year Number] ); AND ( 'Date'[Calendar Year Number] >= CurrentYear; 'Date'[Calendar Year Number] < ReportingYear ) ) VAR Multiplier = MAX ( PRODUCTX ( Years; 1 + Inflation ); 1 ) RETURN Multiplier Остается только суммировать данные по продажам с учетом полученного множителя по годам. Код меры Inflation Adjusted Sales представлен ниже: Inflation Adjusted Sales := SUMX ( VALUES ( 'Date'[Calendar Year] ); [Sales Amount] * [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 Глава 10 Работа с контекстом фильтра со столбцами, так и с таблицами. На самом же деле если один столбец в таблице участвует в перекрестной фильтрации, то в ней участвуют и все остальные. Так что эту функцию стоит применять именно с таблицами, а не со столбцами. Вы по-прежнему можете встретить код, в котором в качестве параметра функции 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 Работа с контекстом фильтра 355 Далее вынесем эти меры в столбцы матрицы, а в строки отправим Cus­to­ mer[Continent] и Customer[Gender]. Результат можно видеть на рис. 10.6. Рис. 10.6 Матрица, демонстрирующая поведение функций ISFILTERED и ISCROSSFILTERED Некоторые комментарии по результатам отчета: столбец Customer[Gender] напрямую отфильтрован только в тех строках, где есть активный фильтр по этому столбцу. На уровне континентов, где фильтр установлен лишь на 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 Глава 10 Работа с контекстом фильтра фильтрован и участвовать в кросс-фильтрации, но при этом все его значения будут видимы. Продемонстрируем это на примере простой меры: 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 работает не так, как ожидается, в связке с SUMMARIZECOLUMNS, которая используется в Power BI для осуществления запросов к модели. Так что этот пример не будет работать в Power BI. Примечание Компания Microsoft осведомлена об этой проблеме использования функции FILTERS в Power BI, и, вероятно, в будущих версиях продукта она будет решена. Но чтобы продемонстрировать этот пример в книге, мы вынуждены были использовать Excel в качестве клиентского приложения, поскольку он не использует функцию SUMMARIZECOLUMNS. В главе 7 мы показывали пример использования функции CONCATENATEX для размещения метки в отчете с указанием выбранных при помощи среза Глава 10 Работа с контекстом фильтра 357 цветов. Там мы в результате пришли к сложной формуле с использованием итераторов и переменных. Здесь мы прибегнем к помощи более простой версии кода: Selected Colors := "Showing " & CONCATENATEX ( VALUES ( 'Product'[Color] ); 'Product'[Color]; ", "; 'Product'[Color]; ASC ) & " colors." Рассмотрим отчет с двумя срезами: в первом выбрана только одна категория, а во втором – несколько цветов, как показано на рис. 10.7. Рис. 10.7 Хотя в срезе выбрано четыре цвета, мера Selected Colors показывает только два из них Несмотря на то что в срезе мы выбрали четыре различных цвета, мера Selected Colors вернула только два из них. Причина в том, что функция VALUES возвращает значения столбца в рамках текущего контекста фильтра. А в нашей модели данных не оказалось товаров из категории «TV and Video» лазурного (Azure) и синего (Blue) цветов. Таким образом, хотя в контекст фильтра и включены четыре цвета, функция VALUES возвращает только два из них. Если в мере заменить функцию VALUES на FILTERS, будут возвращены все отфильтрованные значения вне зависимости от того, есть ли в текущем контексте фильтра товары, представляющие эти значения: Selected Colors := "Showing " & CONCATENATEX ( FILTERS ( 'Product'[Color] ); 'Product'[Color]; ", "; 'Product'[Color]; 358 Глава 10 Работа с контекстом фильтра ASC ) & " colors." С новой версией меры Selected Colors в отчете в качестве выбранных показываются все четыре цвета, что видно по рис. 10.8. Рис. 10.8 С использованием функции FILTERS мера Selected Colors возвращает все четыре цвета Наряду с HASONEVALUE DAX предлагает еще одну функцию для определения того, что на столбец наложен только один активный фильтр: HASONEFILTER. Синтаксис и применение этой функции очень похожи на HASONEVALUE. Единственным отличием между ними является то, что функция HASONEFILTER может возвращать значение TRUE при наличии единственного активного фильтра, в то время как HASONEVALUE вернет FALSE, поскольку значение, хоть и отфильтровано, не является видимым. Понимание разницы между ALLEXCEPT и ALL/VALUES В предыдущем разделе мы представили вам функции ISFILTERED и ISCROSSFILTERED, предназначенные для определения присутствия фильтра. Но одного факта наличия фильтра недостаточно, чтобы понять, видимы ли все значения из столбца или таблицы в отчете. Лучше всего для этого подсчитать коли­ чество строк в текущем контексте фильтра и сравнить с количеством строк без фильтров. Взгляните на рис. 10.9. Мера Filtered Gender проверяет наличие фильтра по столбцу Customer[Gender] при помощи функции ISFILTERED, тогда как в поле NumOfCustomers просто выводится количество строк в таблице Customer: NumOfCustomers := COUNTROWS ( Customer ) Как видите, когда покупателем является компания, его пол, как и ожидается, будет пустым значением. Во второй строке матрицы фильтр по столбцу Gender Глава 10 Работа с контекстом фильтра 359 активен, а значит, в поле Filtered Gender возвращается значение TRUE. В то же время фильтр, по сути, ничего не фильтрует, потому что в столбце Gender есть только одно значение, и оно видимо. Рис. 10.9 Несмотря на наличие фильтра по столбцу Customer[Gender], все покупатели видимы Наличие фильтра еще не означает, что таблица на самом деле будет отфильт­ рована. Оно лишь указывает на активное состояние фильтра. Чтобы проверить, все ли покупатели видимы, лучше полагаться на простой подсчет строк. Если количество строк в таблице с наложенным и снятым фильтром по столбцу Gender будет одинаковым, значит, фильтр, несмотря на свою активность, по сути своей ничего не фильтрует. Проводя подобные вычисления, необходимо обращать внимание на детали контекста фильтра и поведения функции CALCULATE. Есть два способа проверить одно и то же условие: посчитать покупателей с наложенной функцией ALL на поле Gender; посчитать покупателей с тем же типом (Company или Person). Несмотря на то что в отчете на рис. 10.9 два вычисления возвращают одинаковый результат, если изменить столбец, используемый в матрице, цифры будут разными. К тому же у каждого вычисления есть свои за и против, и об этом стоит помнить при выборе решения для определенного сценария. Начнем с простейшего: All Gender := CALCULATE ( [NumOfCustomers]; ALL ( Customer[Gender] ) ) Функция ALL удаляет все фильтры по столбцу Gender, оставляя все прочие фильтры неизменными. В результате происходит подсчет количества покупателей в текущем контексте фильтра без учета половой принадлежности. Вы можете видеть результат на рис. 10.10 вместе с мерой All customers visible, сравнивающей два расчета. В мере All Gender выводятся правильные результаты. Ее недостатком является жесткое указание в коде того, что мы снимаем фильтр со столбца Gender. Например, если использовать эту меру в матрице со срезом по континенту, 360 Глава 10 Работа с контекстом фильтра результат будет не таким, как мы ожидаем. Вы можете видеть это на рис. 10.11, где мера All customers visible всегда возвращает значение TRUE. Рис. 10.10 Во второй строке мера All customers visible возвращает True, несмотря на то что столбец Gender отфильтрован Рис. 10.11 Фильтруя таблицу по континентам, мы получаем неправильные результаты При этом нельзя сказать, что мера неправильно считает значения. Она все правильно считает, если срез в отчете выполнен по полю Gender. Чтобы обес­ печить мере независимость от этого поля, необходимо пойти другим путем, а именно удалить все фильтры с таблицы Customer, за исключением столбца Customer Type. Удаление всех фильтров, кроме одного, выглядит не самой сложной задачей. Но здесь есть одна ловушка, о которой стоит помнить. Первой функцией, которая приходит на ум, является ALLEXCEPT. Но, к сожалению, в данном сценарии использование этой функции может привести к неожиданным результатам. Рассмотрим следующую формулу: AllExcept Type := CALCULATE ( [NumOfCustomers]; ALLEXCEPT ( Customer; Customer[Customer Type] ) ) Функция ALLEXCEPT удаляет все существующие фильтры с таблицы Customer, за исключением фильтра по столбцу Customer Type. При использовании в предыдущем отчете мера покажет правильные результаты, как видно по рис. 10.12. Глава 10 Работа с контекстом фильтра 361 Рис. 10.12 Функция ALLEXCEPT снимает зависимость от пола и работает с любыми столбцами Эта мера будет работать не только с полем Continent. Поменяв континент на пол в отчете, мы все равно увидим правильные результаты, что показано на рис. 10.13. Рис. 10.13 Функция ALLEXCEPT работает и с полом тоже Несмотря на видимую корректность, в этой формуле есть скрытая ловушка. Функции ALL* при использовании в качестве аргумента фильтра функции CALCULATE работают как модификаторы, о чем мы рассказывали в главе 5. Модификаторы не возвращают таблицу, использованную в качестве фильтра. Вместо этого они просто удаляют фильтры из контекста фильтра. Обратите внимание на строку с пустым полом. В этой группе 385 покупателей, и все это компании. Если удалить столбец Customer Type из отчета, единственным столбцом в контексте фильтра останется пол. Пустое значение в этом столбце говорит о том, что в текущем контексте фильтра видимы только компании. При этом отчет, показанный на рис. 10.14, выглядит неожиданно – мера AllExcept Type показывает одинаковые значения для всех строк, а именно общее количество покупателей. Рис. 10.14 Функция ALLEXCEPT дает неожиданные результаты, если поле Customer Type не представлено в отчете 362 Глава 10 Работа с контекстом фильтра Функция ALLEXCEPT удалила все фильтры с таблицы Customer, за исключением фильтра по столбцу Customer Type. Но у нас в отчете нет поля Customer Type, по которому можно было бы сохранить фильтр. Таким образом, единственным фильтром в контексте фильтра остался фильтр по полю Gender, который был успешно удален функцией ALLEXCEPT. Столбец Customer Type участвует в кросс-фильтрации, но не в прямой фильт­ рации. Так что в распоряжении функции ALLEXCEPT нет ни одного столбца, фильтр по которому можно было бы сохранить, а значит, ее действие будет эквивалентно действию функции ALL по всей таблице. В данном случае правильно будет воспользоваться парой функций ALL и VALUES вместо ALLEXCEPT. Взгляните на следующую формулу: All Values Type := CALCULATE ( [NumOfCustomers]; ALL ( Customer ); VALUES ( Customer[Customer Type] ) ) Несмотря на внешнюю схожесть с предыдущим выражением, семантически эта формула значительно отличается. Функция ALL удаляет все фильтры с таб­ лицы Customer. В то же время функция VALUES составляет список значений столбца Customer[Customer Type] в текущем контексте фильтра. Как мы уже сказали, по типу покупателя прямого фильтра у нас нет, но это поле участвует в кросс-фильтрации. Следовательно, функция VALUES возвратит только значения, видимые в текущем контексте фильтра вне зависимости от того, какой столбец участвует в фильтре, создающем кросс-фильтрацию по типу покупателя. Результат вы можете видеть на рис. 10.15. Рис. 10.15 Совместное использование функций ALL и VALUES дало ожидаемый результат Из этого урока вы должны вынести четкое понимание отличий между использованием функции ALLEXCEPT и сочетания функций ALL и VALUES в качестве аргумента фильтра в CALCULATE. Причина этих отличий в том, что семантика функций ALL* предполагает именно удаление фильтров. Функции этой группы никогда не добавляют фильтры к существующему контексту, они только удаляют их. Разница между нюансами добавления и удаления фильтров во многих сценариях бывает не важна. Но есть случаи, когда эта разница имеет решающее значение, как в примере выше. Глава 10 Работа с контекстом фильтра 363 Здесь, как и на протяжении всей книги, мы пытались показать вам, насколько важно точно подходить к формулировке выражений на языке 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 Глава 10 Работа с контекстом фильтра мизатор DAX не так глуп. Зная, что впоследствии будут удалены все фильтры с таблицы Sales при помощи функции ALL, он просто отказывается от выполнения преобразования контекста, при этом удаляя все существующие контексты строки. Такое поведение движка можно использовать в самых разных сценариях, и в частности при работе с вычисляемыми столбцами. В вычисляемых столбцах всегда присутствует контекст строки. Это означает, что любая мера, упомянутая в коде такого столбца, будет вычислена в контексте фильтра только для текущей строки. Представьте, что вам необходимо в вычисляемом столбце рассчитать процент суммы продаж по конкретному товару относительно всех товаров в модели данных. Получить сумму продаж по текущему товару в вычисляемом столбце очень просто – достаточно вычислить меру Sales Amount. Преобразование контекста гарантирует вычисление меры исключительно для одной текущей строки. При этом в знаменателе нам необходимо указать продажи по всем товарам. Но как это сделать при действующем преобразовании контекста? Все очень просто – можно предотвратить операцию преобразования контекста путем указания модификатора ALL в функции CALCULATE, как показано в следующем примере: 'Product'[GlobalPct] = VAR SalesProduct = [Sales Amount] VAR SalesAllProducts = CALCULATE ( [Sales Amount]; ALL ( 'Product' ) ) VAR Result = DIVIDE ( SalesProduct; SalesAllProducts ) RETURN Result Еще раз напоминаем, что функция ALL способна предотвратить операцию преобразования контекста, поскольку является модификатором функции CALCULATE, а значит, применяется после преобразования контекста. Если необходимо рассчитать процент продаж относительно заданной категории товаров, код придется немного переписать: '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 Рис. 10.16 Меры GlobalPct и CategoryPct используют модификаторы ALL и ALLEXCEPT для предотвращения выполнения преобразования контекста Использование функции ISEMPTY Функция ISEMPTY используется для определения того, является ли таблица, переданная в качестве параметра, пустой, что означает, что в ней нет видимых значений в текущем контексте фильтра. Функцию ISEMPTY можно заменить выражением с подсчетом количества строк в табличном выражении: COUNTROWS ( VALUES ( 'Product'[Color] ) ) = 0 Но использование функции ISEMPTY позволяет сделать код более элегантным: ISEMPTY ( VALUES ( 'Product'[Color] ) ) С точки зрения производительности всегда лучше использовать в подобных случаях функцию ISEMPTY, поскольку она сообщает движку DAX, что именно нужно проверить. Функция COUNTROWS требует полного сканирования таблицы для подсчета строк, тогда как для выполнения функции ISEMPTY в этом нет необходимости. Представьте, что вам нужно определить количество покупателей, никогда не приобретавших определенный товар. Эту задачу можно решить следующим образом: NonBuyingCustomers := VAR SelectedCustomers = CALCULATETABLE ( DISTINCT ( Sales[CustomerKey] ); ALLSELECTED () 366 Глава 10 Работа с контекстом фильтра ) VAR CustomersWithoutSales = FILTER ( SelectedCustomers; ISEMPTY ( RELATEDTABLE ( Sales ) ) ) VAR Result = COUNTROWS ( CustomersWithoutSales ) RETURN Result На рис. 10.17 показан отчет с двумя выведенными мерами. Рис. 10.17 В мере NonBuyingCustomers подсчитывается количество покупателей, никогда не приобретавших выбранные товары Пользоваться функцией ISEMPTY довольно просто. В этом примере мы использовали ее, чтобы продемонстрировать читателю одну особенность. В предыдущем фрагменте кода мы сохраняем список покупателей в переменную и позже проходим по ней при помощи функции FILTER, чтобы проверить на пустоту результат выполнения функции RELATEDTABLE. Если содержимым таблицы в переменной SelectedCustomers является список ключей покупателей (Sales[CustomerKey]), как 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 была использована анонимная таблица в качестве аргумента фильтра функции CALCULATE, которая не могла отфильтровать итоговый набор данных. В следующем фрагменте показан рабочий пример использования анонимной таблицы в качестве фильтра. Заметьте, что мы использовали развернутый синтаксис указания аргумента функции CALCULATE исключительно в образовательных целях – простого предиката для фильтрации столбца Product[Color] было бы более чем достаточно: Test := CALCULATE ( [Sales Amount]; FILTER ( ALL ( 'Product'[Color] ); 'Product'[Color] IN { "Red"; "Blue" } ) ) 368 Глава 10 Работа с контекстом фильтра Рис. 10.18 Фильтр с использованием анонимной таблицы не оказывает влияния на результат Привязка данных здесь выполняется следующим образом: функция ALL возвращает таблицу, содержащую все цвета товаров. При этом результат содержит значения из исходного столбца, так что DAX известна трактовка этих значений. Функция FILTER сканирует таблицу, содержащую все цвета, на предмет вхождения в список значений анонимной таблицы (Red и Blue). В результате функция FILTER возвращает таблицу, содержащую значения столбца Product[Color], так что функция 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. Рис. 10.19 Функция TREATAS позволила переопределить привязку данных в анонимной таблице, что привело к возможности выполнить фильтрацию Правила выполнения привязки данных очень просты. Обычная ссылка на столбец поддерживает привязку к таблице, тогда как выражение всегда будет анонимным. По сути, выражение генерирует ссылку на анонимный столбец. В следующем примере возвращается таблица с двумя столбцами, содержащими одинаковые значения. Разница между ними будет состоять лишь в том, что первый столбец будет поддерживать привязку данных к исходной таблице, тогда как второй – нет, поскольку является новым столбцом: ADDCOLUMNS ( VALUES ( 'Product'[Color] ); "Color without lineage"; 'Product'[Color] & "" ) Функция TREATAS может пригодиться, если необходимо обновить привязку данных для одного или нескольких столбцов в табличном выражении. Пример, 370 Глава 10 Работа с контекстом фильтра который мы рассмотрели до этого, был ознакомительным. Сейчас же мы увидим более интересный вариант использования привязки данных в сценарии работы с датой и временем. В главе 8 мы написали следующую меру для расчета даты с использованием функции LASTNONBLANK применительно к полуаддитивному вычислению: LastBalanceIndividualCustomer := SUMX ( VALUES ( Balances[Name] ); CALCULATE ( SUM ( Balances[Balance] ); LASTNONBLANK ( 'Date'[Date]; COUNTROWS ( RELATEDTABLE ( Balances ) ) ) ) ) Данная мера работает, но при этом страдает от одного существенного недостатка: она содержит в себе две итерации, и оптимизатор вряд ли справится с задачей нахождения идеального плана выполнения этого запроса. Было бы лучше создать таблицу, содержащую имена клиентов и последние даты их балансов, после чего использовать эту таблицу в качестве аргумента фильтра функции CALCULATE для фильтрации дат для каждого клиента. Оказывается, что в этом случае очень полезной может оказаться функция TREATAS: LastBalanceIndividualCustomer Optimized := VAR LastCustomerDate = ADDCOLUMNS ( VALUES ( Balances[Name] ); "LastDate"; CALCULATE ( MAX ( Balances[Date] ); DATESBETWEEN ( 'Date'[Date]; BLANK(); 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]; на заключительном шаге мы используем табличную переменную FilterCustomerDate в качестве аргумента фильтра функции CALCULATE. Поскольку теперь таблица корректно привязана к данным в модели, функция CALCULATE осуществляет фильтрацию модели данных таким образом, чтобы для каждого клиента осталась одна дата. Это и будет последняя дата, на которую есть данные об этом клиенте в таблице Balances. В подавляющем большинстве случаев функция TREATAS используется для изменения привязки данных в таблицах, состоящих из одного столбца. Здесь же мы показали более сложный пример использования этой функции, где привязка осуществлялась сразу по двум столбцам. При этом привязка данных в таб­лице, являющейся результатом выражения на DAX, может включать столбцы из разных таблиц. Когда такая таблица применяется к контексту фильтра, это часто ведет к образованию так называемого фильтра произвольной формы, о чем мы поговорим в следующем разделе. Фильтры произвольной формы Фильтры в контексте могут быть двух разновидностей: простым фильтром или фильтром произвольной формы (arbitrarily shaped filter). Все фильтры, которые мы рассматривали в книге до сих пор, обладали простой формой. В данном разделе мы поговорим о фильтрах произвольной формы и способах их применения в коде. Фильтры произвольной формы могут быть созданы в сводной таблице в Excel или путем написания кода меры на языке DAX, тогда как в пользовательском интерфейсе Power BI на данный момент необходимо использовать для их создания специальные элементы визуализации. В этом разделе мы расскажем о фильтрах произвольной формы и работе с ними в DAX. Начнем с описания различий между простым фильтром и фильтром произвольной формы в контексте фильтра: фильтр по столбцу представляет собой список значений из одного столбца. Например, список из трех цветов – красного, синего и зеленого – является фильтром по столбцу. В следующем выражении мы используем функцию CALCULATE для создания фильтра по столбцу в контексте фильтра, влияющего только на столбец Product[Color]: CALCULATE ( [Sales Amount]; 372 Глава 10 Работа с контекстом фильтра 'Product'[Color] IN { "Red"; "Blue"; "Green" } ) простой фильтр – это фильтр по одному и более столбцам, представляющий собой набор из нескольких фильтров по столбцу. Почти все фильтры, которые мы использовали в данной книге до сих пор, являлись простыми. Простые фильтры создаются с использованием нескольких аргументов фильтра в функции CALCULATE: CALCULATE ( [Sales Amount]; 'Product'[Color] IN { "Red"; "Blue" }; 'Date'[Calendar Year Number] IN { 2007; 2008; 2009 } ) Этот код также может быть записан с использованием простого фильтра с двумя столбцами: CALCULATE ( [Sales Amount]; TREATAS ( { ( "Red"; 2007 ); ( "Red"; 2008 ); ( "Red"; 2009 ); ( "Blue"; 2007 ); ( "Blue"; 2008 ); ( "Blue"; 2009 ) }; 'Product'[Color]; 'Date'[Calendar Year Number] ) ) Поскольку простой фильтр содержит в себе все возможные сочетания значений двух столбцов, проще выражать его с использованием двух фильтров по столбцу; фильтр произвольной формы – это любой фильтр, который не может быть выражен через простой фильтр. Взгляните на следующее выражение: CALCULATE ( [Sales Amount]; TREATAS ( { ( "CY 2007"; "December" ); ( "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 Глава 10 Работа с контекстом фильтра к столбцу, она удаляет ранее наложенные фильтры на этот столбец, заменяя их новыми. Это обычно приводит к потере произвольным фильтром своей изначальной формы. В результате формулы начинают выдавать неправильные цифры, и поддерживать их становится очень сложно. Чтобы продемонстрировать эту проблему, мы будем усложнять код шаг за шагом, пока проблема не проявится. Рис. 10.20 При фильтрации иерархии может образоваться фильтр произвольной формы Рис. 10.21 В Microsoft Excel фильтры произвольной формы создаются при помощи встроенных средств фильтрации Глава 10 Работа с контекстом фильтра 375 Представьте, что вы определили простую меру, перезаписывающую год на 2007-й: Sales Amount 2007 := CALCULATE ( [Sales Amount]; 'Date'[Calendar Year] = "CY 2007" ) Функция CALCULATE перезаписывает фильтр по году, но при этом не трогает фильтр по месяцу. Будучи использованной в отчете, мера может показывать неожиданные результаты, что видно по рис. 10.22. Рис. 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 Глава 10 Работа с контекстом фильтра 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] ) Рис. 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 "CY "CY "CY 2007"; 2008"; 2008"; 2008"; "December" ); "January" ); "February" ); "March" ) }; 'Date'[Calendar Year]; 'Date'[Month] ) ) Ключом к решению создавшейся проблемы является понимание того, что происходит во время выполнения выделенной жирным шрифтом функции CALCULATE. Эта функция вызывается в рамках контекста строки, проходящего по столбцу Date[Month]. Следовательно, происходит преобразование контекста, в результате чего текущий месяц добавляется к контексту фильтра. То есть в конкретном месяце, скажем в январе, этот месяц будет добавлен к контексту фильтра, при этом все ранее наложенные фильтры по месяцу будут удалены, тогда как другие фильтры останутся нетронутыми. При выполнении функции 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 Глава 10 Работа с контекстом фильтра Monthly Avg KeepFilters := AVERAGEX ( KEEPFILTERS ( VALUES ( 'Date'[Month] ) ); [Sales Amount] ) Рис. 10.24 Итерации по столбцу с уникальными значениями позволили прийти к верным расчетам Фильтры произвольной формы встречаются в реальных отчетах не так час­то. Но пользователи имеют вполне законное право их создавать по своему усмот­ рению. Чтобы обеспечить правильный расчет меры в присутствии фильт­ра произвольной формы, необходимо следовать двум простым правилам: осуществляя итерации по столбцу, убедитесь в том, что он содержит уникальные значения на том уровне гранулярности, на котором производятся вычисления. Например, в таблице Date больше 12 месяцев, а значит, для подобных итераций лучше будет использовать столбец YearMonth; если первое правило выполнить невозможно, допустимо воспользоваться функцией KEEPFILTERS для гарантии сохранения формы произвольного фильтра в контексте фильтра. Помните при этом, что функция KEEPFILTERS способна изменить семантику вычисления. В связи с этим необходимо внимательно отнестись к тому, чтобы она не внесла ошибочные расчеты в меру. Если вы будете следовать этим простым правилам, ваш код всегда будет должным образом защищен в присутствии фильтров произвольной формы. Заключение В данной главе вы познакомились с несколькими функциями для анализа содержимого контекста фильтра и/или изменения поведения мер в зависимости от контекста. Мы также рассмотрели некоторые важные техники управления контекстом фильтра и описали его возможные состояния. Вот наиболее важные концепции, о которых было рассказано в главе: Глава 10 Работа с контекстом фильтра 379 столбец может быть отфильтрован напрямую, а может входить в состав перекрестного фильтра, когда действие на него распространяется вследствие прямой фильтрации другого столбца или таблицы. Вы можете проверить, является ли столбец участником обычной или кросс-фильтрации при помощи функций ISFILTERED и ISCROSSFILTERED соответственно; функция HASONEVALUE проверяет, одно ли значение из указанного столбца видимо в текущем контексте фильтра. Это бывает полезно перед извлечением этого значения при помощи функции VALUES. Функция SELECTEDVALUE, в свою очередь, призвана упростить использование шаб­ лона 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 не имеет ни малейшего понятия о том, как именно пользователь собирает отчет. Движок просто получает запрос, в котором не указано, что будет вынесено в строки, что – в столбцы и какие фильтры и срезы будут использоваться при построении отчета. Рис. 11.1 Мера PercOnParent помогает лучше понять значения в отчете Несмотря на то что универсальная формула не может быть создана, вы вполне можете написать меру, которая будет возвращать корректные значения при правильном использовании. Поскольку наша иерархия насчитывает три уровня (категории, подкатегории и товары), мы начнем с создания трех разных мер для каждого из них: PercOnSubcategory := DIVIDE ( [Sales Amount]; CALCULATE ( [Sales Amount]; ALLSELECTED ( Product[Product Name] ) ) ) PercOnCategory := DIVIDE ( [Sales Amount]; CALCULATE ( [Sales Amount]; 382 Глава 11 Работа с иерархиями ALLSELECTED ( Product[Subcategory] ) ) ) PercOnTotal := DIVIDE ( [Sales Amount]; CALCULATE ( [Sales Amount]; ALLSELECTED ( Product[Category] ) ) ) Эти три меры прекрасно справляются со своими расчетами. На рис. 11.2 представлен отчет с использованием всех трех мер. Рис. 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 показан отчет с использованием этой меры. Рис. 11.3 Используя функцию ISINSCOPE, мы убрали бесполезные значения 100 % из меры PercOnSubcategory Та же техника может быть использована для удаления значений 100 % из других мер. Обратите внимание, что в мере PercOnCategory мы должны проверять, что столбец Subcategory входит в область видимости, а Product Name – нет. Причина этого в том, что когда в отчете применяется срез по столбцу Product Name с использованием иерархии, автоматически производится срез и по столбцу Subcategory, выводя при этом наименование товара, а не подкатегории. Во избежание дублирования кода для этих условий лучше будет написать единую меру, которая будет производить разные вычисления в зависимости от видимого уровня иерархии, основываясь на результате сканирования иерар­ хии при помощи функции ISINSCOPE с нижнего уровня до верхнего. Представляем вам код получившейся меры PercOnParent: PercOnParent := VAR CurrentSales = [Sales Amount] VAR SubcategorySales = CALCULATE ( [Sales Amount]; ALLSELECTED ( Product[Product Name] ) ) VAR CategorySales = 384 Глава 11 Работа с иерархиями CALCULATE ( [Sales Amount]; ALLSELECTED ( Product[Subcategory] ) ) VAR TotalSales = CALCULATE ( [Sales Amount]; ALLSELECTED ( Product[Category] ) ) VAR RatioToParent = IF ( ISINSCOPE ( Product[Product Name] ); DIVIDE ( CurrentSales; SubcategorySales ); IF ( ISINSCOPE ( Product[Subcategory] ); DIVIDE ( CurrentSales; CategorySales ); IF ( ISINSCOPE ( Product[Category] ); DIVIDE ( CurrentSales; TotalSales ) ) ) ) RETURN RatioToParent Использование меры PercOnParent, как и ожидалось, позволило получить правильные результаты, что видно по рис. 11.4. Рис. 11.4 Мера PercOnParent объединила три столбца в один Теперь нам не нужны те три меры, которые мы написали в начале главы. Единая мера PercOnParent проводит все вычисления и выводит результаты на соответствующих уровнях иерархии. Глава 11 Работа с иерархиями 385 Примечание Порядок, в котором перечислены условные операторы IF, важен. Мы начинаем проверку с нижнего уровня иерархии и постепенно поднимаемся выше. Если изменить порядок обхода иерархии, результаты вычислений окажутся неправильными. Важно помнить, что при фильтрации подкатегории в иерархии автоматически фильтруется и категория. Мера PercOnParent будет работать корректно только в том случае, если пользователь вынесет правильную иерархию в строки. Например, если категорию товаров заменить на цвет, полученные результаты понять будет непросто. Так что эта мера применима только для иерархии товаров вне зависимости от того, выбраны ли в отчете соответствующие поля. Работа с иерархиями типа родитель/потомок Внутренне DAX не поддерживает в чистом виде иерархии типа родитель/потомок, характерные для баз данных Multidimensional в Analysis Services. При этом в языке DAX есть специальные функции, служащие для выравнивания (flatten) иерархий типа родитель/потомок в обычные иерархии на основании столбцов. Этих функций достаточно для большинства сценариев, хотя вам и придется делать прикидку на этапе разработки модели о максимальной глубине иерархии. В данном разделе мы научим вас при помощи функций DAX создавать иерархии типа родитель/потомок (parent/child hierarchy), иногда называемые иерархиями P/C. Типичный пример такой иерархии изображен на рис. 11.5. Annabel Michael Catherine Bill Brad Harry Chris Julie Vincent Рис. 11.5 Графическое представление иерархии типа родитель/потомок Иерархии типа родитель/потомок обладают следующими характерными особенностями: количество уровней может быть неодинаковым внутри иерархии. К примеру, путь от Аннабель (Annabel) к Майклу (Michael) вмещает в себя два уровня, в то время как в той же иерархии путь от Билла (Bill) к Крису (Chris) включает три уровня; иерархия обычно хранится в одной таблице со ссылками на родительский элемент в каждой строке. Традиционный вариант хранения данных об иерархии типа родитель/потомок показан на рис. 11.6. 386 Глава 11 Работа с иерархиями Рис. 11.6 Таблица содержит информацию об иерархии типа родитель/потомок Несложно догадаться, что в столбце ParentKey указан ключ родительского элемента. Например, у Кэтрин (Catherine) в этом поле стоит цифра 6, являющаяся ключом для Аннабель. Проблема с такой моделью данных состоит в том, что в данный момент таблица связана сама с собой, то есть две таблицы, участ­ вующие в отношении, фактически являются одной и той же таблицей в модели данных. Табличные модели данных не поддерживают связи таблицы самой с собой. Следовательно, мы должны изменить модель данных таким образом, чтобы иерархия типа родитель/потомок преобразовалась в обычную иерархию, где каждый столбец представляет свой уровень иерархии. Перед тем как погрузиться в детали иерархий типа родитель/потомок, стоит сделать еще одно замечание. Взгляните на рис. 11.7, на котором изображена таблица со значениями, которые мы хотим агрегировать с использованием иерар­хии. Рис. 11.7 Таблица с данными для иерархии типа родитель/потомок В строках таблицы фактов содержатся ссылки как на элементы конечного уровня (leaf-level), так и на промежуточные элементы иерархии. Возьмем, к примеру, строку с Аннабель. В этой строке содержится числовое значение, но не стоит забывать, что у Аннабель есть три дочерних элемента. Таким образом, Глава 11 Работа с иерархиями 387 при суммировании данных по Аннабель формула должна учитывать как эту строку, так и все дочерние элементы. На рис. 11.8 показан результат, которого мы хотим добиться. Рис. 11.8 Результат просмотра иерархии при помощи матрицы Чтобы прийти к конечной цели, нам необходимо проделать существенный путь. Первым шагом после загрузки таблицы в модель данных будет создание вычисляемого столбца, в котором будет храниться путь для достижения каждого элемента. Поскольку мы не можем использовать традиционные связи между таблицами, придется призвать на помощь специальные функции DAX для работы с иерархиями типа родитель/потомок. В новом вычисляемом столбце с именем FullPath мы воспользуемся функцией PATH: Persons[FullPath] = PATH ( Persons[PersonKey]; Persons[ParentKey] ) Функция PATH принимает два параметра. Первым является ключ таблицы (в нашем случае это Persons[PersonKey]), а вторым – ключ родительского элемента. Функция рекурсивно проходит по таблице и для каждого элемента вычисляет путь, выраженный в последовательности ключей, разделенных вертикальной чертой (|). На рис. 11.9 видна функция FullPath в действии. Сам по себе столбец FullPath не представляет большого интереса. В то же время он обладает исключительной важностью, являясь основой для расчета других вычисляемых столбцов на пути построения иерархии. На следующем шаге мы создадим еще три вычисляемых столбца, по одному для каждого уровня иерархии: Persons[Level1] = LOOKUPVALUE( Persons[Name]; Persons[PersonKey]; PATHITEM ( Persons[FullPath]; 1; INTEGER ) ) Persons[Level2] = LOOKUPVALUE( 388 Глава 11 Работа с иерархиями Persons[Name]; Persons[PersonKey]; PATHITEM ( Persons[FullPath]; 2; INTEGER ) ) Persons[Level3] = LOOKUPVALUE( Persons[Name]; Persons[PersonKey]; PATHITEM ( Persons[FullPath]; 3; INTEGER ) ) Рис. 11.9 В столбце FullPath содержится полный путь к элементу Вычисляемые столбцы названы Level1, Level2 и Level3, а единственное отличие при их создании кроется во втором параметре, переданном функции PATHITEM, который принимает значения 1, 2 и 3 соответственно. При создании вычисляемых столбцов была использована функция LOOKUPVALUE для поиска строки, в которой значение поля PersonKey эквивалентно результату функции PATHITEM. Сама функция PATHITEM возвращает указанный во втором парамет­ре элемент из столбца, построенного при помощи функции PATH, или пустое значение, если в качестве второго параметра передано число, превышающее длину пути. Получившаяся таблица показана на рис. 11.10. Рис. 11.10 В столбцах Level содержатся значения соответствующих уровней иерархии В этом примере мы использовали три столбца – такова максимальная глубина нашей иерархии. В реальных примерах вам придется рассчитывать максиГлава 11 Работа с иерархиями 389 мальную вложенность и создавать соответствующее количество вычисляемых столбцов. Таким образом, несмотря на то что количество уровней в иерархии типа родитель/потомок не является фиксированным, чтобы реализовать подобную иерархию в модели данных, придется определиться заранее с ее максимальной глубиной. Всегда лучше добавить пару лишних уровней для будущего расширения иерархии без необходимости обновлять модель данных. Теперь нам нужно преобразовать набор столбцов в иерархию. Также, поскольку остальные поля не несут никакой полезной информации, мы скроем их от клиентских приложений. На этом этапе мы можем построить отчет с иерар­хией, вынесенной на строки, и суммами в области значений, но результат нас не удовлетворит. На рис. 11.11 показан вывод отчета в виде матрицы. С этим отчетом есть пара проблем: под строкой с именем Аннабель есть две пустые строки с указанием суммы по самой Аннабель; под Кэтрин есть пустая строка также со значением по Кэтрин. То же самое можно сказать и о других строках. В иерархии всегда показываются три уровня вложенности, даже для путей с максимальной глубиной, равной двум, как в случае с Гарри (Harry), у которого нет детей. Рис. 11.11 Иерархия выглядит не так, как мы ожидали, – в ней слишком много строк Эти проблемы связаны с визуализацией результатов. В остальном цифры считаются правильно, поскольку под строкой с Аннабель находятся все ее дочерние элементы. Важным аспектом этого решения было то, что нам удалось имитировать связь таблицы самой с собой (также известную как рекурсивную связь (recursive relationship)), используя функцию PATH для создания вычис390 Глава 11 Работа с иерархиями ляемого столбца. Остальные сложности касались отображения результатов, но мы, по крайней мере, продвинулись к правильному решению. Следующей нашей задачей будет удаление пустых значений. Например, во второй строке вывода значение 600 должно принадлежать Аннабель, а не пус­ той ячейке. Мы можем решить эту проблему, исправив формулу для уровней. Избавимся от пустых значений путем вывода элемента предыдущего уровня, если достигли конца пути. Ниже представлен измененный код для меры Level2: PC[Level2] = IF ( PATHLENGTH ( Persons[FullPath] ) >= 2; LOOKUPVALUE( Persons[Name]; Persons[PersonKey]; PATHITEM ( Persons[FullPath]; 2; INTEGER ) ); Persons[Level1] ) Формула для Level1 в изменениях не нуждается, поскольку первый уровень всегда существует. Формулу для Level3 мы изменили, руководствуясь тем же шаблоном, что и для Level2. С обновленными формулами таблица выглядит так, как показано на рис. 11.12. Рис. 11.12 С измененными формулами в столбцах Level никогда не будет пустых значений Как видите, пустые ячейки исчезли из отчета. Но строк в выводе все равно слишком много. На рис. 11.13 выделены две строки. Обратите внимание на вторую и третью строки отчета. В них мы видим одно и то же значение из иерархии, а именно Аннабель. Мы могли бы смириться с показом второй строки, поскольку она выводит значение для Аннабель, но третья строка тут точно лишняя, она не дает нам никакой новой информации. Как видите, решение о том, показывать или скрывать строку, базируется на глубине вложенности элемента. Мы можем позволить пользователю опуститься до второго уровня в иерархии Аннабель, но третий ему видеть ни к чему. Мы вполне можем хранить в вычисляемом столбце длину пути для достижения строки. Длина пути покажет, что Аннабель является корневым элементом Глава 11 Работа с иерархиями 391 иерархии. И правда, это ведь элемент первого уровня с путем, содержащим только одно значение. Кэтрин, к примеру, является элементом второго уровня с длиной пути, равной двум, поскольку она – дочь Аннабель. Кроме того, хоть это и не так очевидно, но Кэтрин видима также и на первом уровне иерархии, ведь ее значение агрегируется в родительском элементе, то есть в Аннабель. Имя Кэтрин показывается по причине того, что в столбце Level1 для нее указана Аннабель. Рис. 11.13 В новом отчете пустых значений нет Зная уровень каждого элемента в иерархии, мы можем определить, что этот элемент должен быть видим, когда в отчете показана иерархия до этого уровня. На более глубоких уровнях иерархии в отчете этот элемент должен быть скрыт. Чтобы реализовать этот алгоритм, нам необходимы еще два параметра: глубина вложенности каждого элемента. Это фиксированная величина для каждой строки иерархии, а значит, она может храниться в вычисляемом столбце; текущая глубина просмотра визуального элемента отчета. Эта величина является динамической и зависит от текущего контекста фильтра. Она должна быть выражена при помощи меры, поскольку ее значение зависит от отчета, и для каждой строки отчета оно будет свое. Например, Аннабель представляет элемент первого уровня, но она появляется в трех строках по причине того, что текущая глубина отчета содержит три разных значения. Глубину вложенности элемента рассчитать несложно. Добавим новый вычисляемый столбец с простой формулой к таблице Persons: Persons[NodeDepth] = PATHLENGTH ( Persons[FullPath] ) Функция PATHLENGTH вычисляет количество элементов в строке, возвращаемой функцией PATH. На рис. 11.14 показан отчет с новым вычисляемым столбцом. Глубину вложенности вычислить было весьма просто. Сложнее определить глубину просмотра отчета, поскольку это необходимо делать в мере. При этом сама логика расчета будет несложной – похожую технику мы уже использовали при работе с обычными иерархиями. В мере будет использована функция ISINSCOPE для определения того, какие столбцы в иерархии входят в фильтр. 392 Глава 11 Работа с иерархиями Рис. 11.14 В столбце NodeDepth хранится величина уровня вложенности элемента Кроме того, в этой формуле мы воспользуемся тем, что значения типа Boo­ lean могут легко конвертироваться в целочисленные величины, где TRUE равно 1, а FALSE – 0: BrowseDepth ISINSCOPE ( ISINSCOPE ( ISINSCOPE ( := Persons[Level1] ) + Persons[Level2] ) + Persons[Level3] ) Таким образом, если в фильтр включен только Level1, значением меры Browse­Depth будет 1. Если столбцы Level1 и Level2 отфильтрованы, а Level3 – нет, вернется 2, и т. д. На рис. 11.15 представлено вычисление меры BrowseDepth. Рис. 11.15 В мере BrowseDepth содержится глубина просмотра отчета Мы постепенно приближаемся к окончательному решению нашего сценария. Последнее, что нам нужно знать, – это то, что по умолчанию в отчете будут скрыты строки, в которых каждая вычисленная мера возвращает пустое значение. Мы воспользуемся этой особенностью для скрытия нежелательных строк. Преобразуя значение меры Amount в BLANK для строк, которые мы не хотим видеть в отчете, можно скрыть их в матрице. Таким образом, в своем решении мы будем использовать: Глава 11 Работа с иерархиями 393 уровень вложенности каждого элемента, хранящийся в вычисляемом столбце NodeDepth; глубину просмотра текущей ячейки в отчете, выраженную в мере BrowseDepth; скрытие нежелательных строк путем установки в них пустых значений. Самое время объединить всю эту информацию в одной мере, как показано ниже: PC Amount := IF ( MAX (Persons[NodeDepth]) < [BrowseDepth]; BLANK (); SUM(Sales[Amount]) ) Чтобы понять, как работает эта мера, взгляните на отчет, показанный на рис. 11.16. В нем есть все необходимое, чтобы уловить суть этой формулы. Рис. 11.16 В отчете показаны все промежуточные величины, использующиеся в формуле Если вы посмотрите на первую строку с Аннабель, то увидите, что BrowseDepth здесь равен 1, поскольку это корневой элемент иерархии. При этом в столбце MaxNodeDepth, определенном как MAX ( Persons[NodeDepth] ), указано значение 2, сообщая о том, что для текущего элемента должны быть показаны данные не только на первом уровне, но и на втором. Таким образом, для текущего элемента будет показана также информация о детях, а значит, он должен быть видимым. Во второй строке по Аннабель в столбцах BrowseDepth и MaxNodeDepth указаны 2 и 1 соответственно. Дело в том, что контекст фильтра отбирает все строки, где в Level1 и Level2 указано значение Аннабель, а в иерархии есть только одна такая строка, соответствующая самой Аннабель. 394 Глава 11 Работа с иерархиями Но у Аннабель поле NodeDepth равно 1, а поскольку глубина просмотра отчета здесь у нас равна 2, необходимо скрыть эту строку. Так что в мере PC Amount для нее мы вернем пустое значение. Будет полезно, если для остальных строк вы проведете подобный анализ самостоятельно. Так вы лучше поймете, как на самом деле работает эта формула. И хотя вы можете просто вернуться к этой главе и скопировать формулу, когда у вас появится такая необходимость, будет лучше, если вы разберетесь, как она работает. Это даст вам возможность понять, как контекст фильтра взаимодействует с различными частями формулы. И в заключение нам нужно убрать из отчета все вспомогательные столбцы, оставив только PC Amount. Теперь отчет приобрел тот вид, который мы и хотели получить, что видно по рис. 11.17. Рис. 11.17 Оставив в отчете единственную меру, мы тем самым скрыли нежелательные строки Главным недостатком примененного нами подхода является то, что такой же шаблон необходимо будет использовать для каждой меры, которую пользователь захочет добавить в отчет с готовой иерархией. Если мера для нежелательных строк не будет возвращать пустые значения, строки просто не скроются и будут портить весь шаблон отчета. На данном этапе нас удовлетворяет полученный результат. Но есть небольшая проблема. Если взглянуть на итоговую строку по Аннабель, мы увидим значение 3200. Сумма значений по ее детям составляет 2600. Потерялось еще одно значение 600, принадлежащее самой Аннабель. Кому-то такой визуализации отчета будет достаточно, поскольку значение по родителю легко получить путем вычитания суммы по детям из итога по самому элементу. Но если сравнить отчет с нашей изначальной целью, несложно заметить, что необходимо разместить родительский элемент и в качестве дочернего для себя самого. На рис. 11.18 представлено сравнение полученного нами результата и желательного. Сейчас мы уже хорошо представляем себе технику дальнейших действий. Чтобы показать данные по Аннабель, необходимо найти условие, которое позволит нам идентифицировать этот элемент как видимый. Но в подобном случае условие будет не таким простым. Нам нужно показать элементы, не являющиеся конечными (то есть имеющие потомков) и при этом обладающие собственными значениями. При этом мы должны показать эти элементы на Глава 11 Работа с иерархиями 395 вложенном уровне. Остальные элементы (являющиеся конечными или не обладающие собственными значениями) должны оставаться скрытыми. Рис. 11.18 Требуемый результат не достигнут, нужно показать еще несколько строк Первое, что нам нужно сделать, – это создать вычисляемый столбец с отобра­ жением того, является ли элемент конечным, то есть не имеющим потомков. В 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 ( Amount ) VAR Leaf = SELECTEDVALUE ( Persons[IsLeaf]; FALSE ) VAR Result = IF ( NOT TooDeep; IF ( AdditionalLevel; IF ( NOT Leaf && HasData; Amount ); Amount ) ) RETURN Result Рис. 11.19 В столбце IsLeaf для конечных элементов указано значение True Использование переменных позволило сделать формулу более легкой для восприятия. Приведем некоторые замечания по работе этого кода: в переменной TooDeep проверяется, превышает ли глубина просмотра отчета максимальный уровень вложенности элемента, увеличенный на единицу. Таким образом выполняется проверка на предельную глубину просмотра; переменная AdditionalLevel содержит результат проверки на то, что текущая глубина просмотра является дополнительным уровнем для элементов, обладающих собственным значением и не являющихся при этом конечными; в переменной HasData мы проверяем, есть ли у элемента собственное значение; Глава 11 Работа с иерархиями 397 переменная Leaf проверяет, является ли элемент конечным; переменная Result хранит итоговый результат формулы, что облегчает вывод промежуточных значений на этапе написания кода. Оставшаяся часть кода в основном состоит из вложенных условий IF, служащих для поддержания логики формулы. Понятно, что если бы в моделях данных была встроенная возможность работы с иерархиями типа родитель/потомок, нам не пришлось бы проделывать всю эту работу. В конце концов, формула получилась не самая легкая для восприятия, к тому же она требует хорошего понимания контекстов вычисления и моделирования данных в целом. Важно Если уровень совместимости (compatibility level) вашей модели данных равен 1400, вы можете воспользоваться специальным свойством Hide Members. Это свойство позволяет автоматически скрывать пустые значения. По состоянию на апрель 2019 года это свойство недоступно в Power BI и Power Pivot. Подробное описание того, как использовать это свойство в табличных моделях данных, можно найти по адресу: https://docs.microsoft. com/en-us/sql/analysis-services/what-s-new-in-sql-server-analysis-services-2017?view=sqlserver-2017. Если используемый вами инструмент поддерживает свойство Hide Members, мы настоятельно рекомендуем воспользоваться им, вместо того чтобы писать сложный код на DAX, показанный выше, для скрытия уровней в несбалансированной иерархии. Заключение В данной главе вы узнали, как проводить вычисления в модели данных при наличии иерархий. Как обычно, пройдемся по основным концепциям, которые здесь были предложены: иерархии не являются составной частью языка DAX. Они могут быть построены в модели данных, но DAX не умеет ссылаться на иерархии и использовать их в своих вычислениях; чтобы определить уровень иерархии, пригодится специальная функция ISINSCOPE. Эта функция не определяет уровень просмотра. Вместо этого она идентифицирует лишь наличие фильтра по столбцу; для расчета долей внутри родительского элемента необходимо уметь анализировать текущий уровень иерархии и подбирать подходящий набор фильтров для воссоздания фильтра родительского элемента; с иерархиями типа родитель/потомок в DAX можно работать, используя предопределенную функцию PATH и создавая набор вспомогательных столбцов – по одному для каждого уровня иерархии; унарные операторы (unary operators), часто использующиеся в иерархиях типа родитель/потомок, могут стать настоящим испытанием. С их упрощенными разновидностями (только +/–) можно работать путем написания довольно сложного кода на DAX. Решение действительно комплексных сценариев потребует написания еще более сложного кода на DAX, но эта тема выходит за границы данной главы. ГЛ А В А 12 Работа с таблицами Таблицы играют важную роль в формулах DAX. В предыдущих главах вы на­ учились осуществлять итерации по таблицам, создавать вычисляемые таблицы и выполнять некоторые вычисления, требующие навыков работы с таблицами. Более того, аргументы фильтра функции CALCULATE также представляют собой таблицы, и при написании сложных выражений умение строить правильные таблицы фильтров является немаловажным фактором. DAX предлагает богатый набор функций для работы с таблицами. В данной главе мы познакомимся с теми из них, которые предназначены для создания таблиц и манипулирования ими. Описание большинства новых функций мы будем сопровождать примерами, которые будут полезны как в работе с самими таблицами, так и в качестве пособия по написанию сложных мер. Функция CALCULATETABLE Первой функцией для работы с таблицами, с которой мы познакомимся, будет CALCULATETABLE. Да, мы уже не раз использовали ее в данной книге. Здесь же мы дадим более полное описание работы функции и поделимся советами о том, когда ее стоит использовать. Функция CALCULATETABLE работает точно так же, как и ее аналог CALCULATE, за исключением того, что ее результатом является таблица, тогда как на выходе 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] ); "Num of Products"; COUNTROWS ( 'Product' ) ); 'Product'[Color] = "Red" ) Результат выполнения данного кода показан на рис. 12.1. Рис. 12.1 В базе данных Contoso находится 99 красных товаров При использовании функции CALCULATETABLE контекст фильтра, в котором выполняются функции ADDCOLUMNS и COUNTROWS, отфильтрован по красному цвету. Соответственно, в результате мы получили одну строку, содержащую красный цвет с указанием количества товаров этого цвета. Иными словами, функция COUNTROWS подсчитывает уже только красные товары, не требуя преобразования контекста от строки, сгенерированной функцией VA­LUES. Если заменить функцию CALCULATETABLE на FILTER, результат будет другим. Взгляните на следующий пример: Red Products FILTER external = FILTER ( ADDCOLUMNS ( VALUES ( 'Product'[Color] ); "Num of Products"; COUNTROWS ( 'Product' ) ); 'Product'[Color] = "Red" ) На этот раз результат будет уже не 99. Вместо этого мы увидим общее количество товаров в модели данных, как показано на рис. 12.2. Рис. 12.2 Несмотря на единственную строку с указанием красного цвета, значение в столбце Num of Products соответствует общему количеству товаров 400 Глава 12 Работа с таблицами В итоговой таблице также присутствует только одна строка с указанием красного цвета, но теперь количество товаров равно 2517, а не 99, что соответствует общему количеству товаров в базе. Причина в том, что функция FILTER не меняет контекст фильтра. Более того, она вычисляется после функции ADDCOLUMNS. Следовательно, функция ADDCOLUMNS проходит по всем товарам, и COUNTROWS подсчитывает их общее количество из-за отсутствия преобразования контекста. И только затем функция FILTER выбирает строку с красным цветом. Если вы хотите использовать функцию FILTER вместо CALCULATETABLE, выражение должно быть написано иначе, чтобы функция CALCULATE инициировала преобразование контекста: Red Products FILTER internal = ADDCOLUMNS ( FILTER ( VALUES ( 'Product'[Color] ); 'Product'[Color] = "Red" ); "Num of Products"; CALCULATE ( COUNTROWS ( 'Product' ) ) ) Теперь результат вновь будет 99. Чтобы получить тот же итог, что и при вызове функции CALCULATETABLE, нам пришлось изменить порядок выполнения формулы. Здесь функция FILTER запускается первой, после чего подсчет строк полагается на операцию преобразования контекста, чтобы заставить контекст строки от функции ADDCOLUMNS стать контекстом фильтра для функции COUNTROWS. Функция CALCULATETABLE в процессе выполнения меняет контекст фильт­ ра. Это очень мощная функция, поскольку она распространяет свое действие на множество других функций в выражении DAX. Но есть у этой функции и свои ограничения, связанные с типом фильтра, который она создает. К примеру, функция CALCULATETABLE способна накладывать фильтры только на столбцы, принадлежащие модели данных. Если вам нужно будет получить список покупателей с суммой продаж, превышающей один миллион, функция CALCULATETABLE вам не подойдет, поскольку Sales Amount является мерой. Функция CALCULATETABLE не может устанавливать фильтр на меру, тогда как FILTER – может. Это показано в следующем выражении – здесь заменить функцию FILTER на 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 Amount] ) VAR Result = FILTER ( ProductsWithSales; [Product Sales] >= 150000 ) RETURN Result Рис. 12.3 Результат содержит все цвета товаров и суммы продаж по ним Результат показан на рис. 12.4. Рис. 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 Top15Percent = FILTER ( ProdsWithRT; [RunningTotal] / TotalSales <= 0.15 ) RETURN Top15Percent 404 Глава 12 Работа с таблицами Результат можно видеть на рис. 12.5. Рис. 12.5 Самые популярные товары, суммарные продажи по которым дали компании 15 % общих продаж В этом примере мы реализовали решение при помощи вычисляемой таб­ лицы, но существуют и другие варианты. Например, вы могли бы пройти по табличной переменной Top15Percent при помощи итератора 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. Рис. 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. Рис. 12.7 В отчете показаны среднедневные продажи товаров по годам и брендам Здесь мы использовали функцию SUMMARIZE для сканирования таблицы Sales и ее группировки по наименованиям товаров и датам продажи. В результирующую таблицу при этом попадут только строки с днями, когда были произведены продажи. Затем функция AVERAGEX проходит по временной таблице, возвращенной функцией SUMMARIZE, и подсчитывает средние значения. Если по конкретному товару не было продажи в какой-то день, то он не будет включен в таблицу. Функция SUMMARIZE также может использоваться по подобию функции ADDCOLUMNS, добавляя столбцы к результирующей таблице. Например, код предыдущей меры может быть записан так: AvgDailyQty := VAR ProductsDatesWithSalesAndQuantity = 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: AvgDailyQty := VAR ProductsDatesWithSales = 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 предназначена для группировки таблиц и не должна использоваться для вычисления дополнительных столбцов. И хотя в чужих формулах вы зачастую можете встретить вариант использования функции SUMMARIZE с созданием дополнительных столбцов, всегда помните, что это далеко не самая лучшая практика, и в таких случаях всегда лучше пользоваться связкой функций 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. Рис. 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. Рис. 12.9 Функция DISTINCT удалила дубликаты из таблицы Функция UNION поддерживает привязку данных в исходных таблицах, если она выполнена в них одинаково. В предыдущей формуле в результирующей таблице функции DISTINCT привязки данных нет, поскольку в первой таблице у нас был столбец Customer[CountryRegion], а во второй – Store[CountryRegion]. А раз привязка данных в исходных таблицах была разная, в результирующей таблице будет создана абсолютно новая привязка, не относящаяся ни к одному из существующих столбцов в модели данных. Поэтому в следующем выражении для всех стран будет выведена одна и та же сумма продажи: DistinctCountryRegions = VAR CountryRegions = UNION ( ALL ( Customer[CountryRegion] ); ALL ( Store[CountryRegion] ) 412 Глава 12 Работа с таблицами ) VAR UniqueCountryRegions = DISTINCT ( CountryRegions ) VAR Result = ADDCOLUMNS ( UniqueCountryRegions; "Sales Amount"; [Sales Amount] ) RETURN Result Результирующая таблица показана на рис. 12.10. Рис. 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[CountryRegion] = CurrentRegion ) ) RETURN Result Результат показан на рис. 12.11. Рис. 12.11 При помощи сложного выражения с применением функции CALCULATE нам удалось отфильтровать и магазины, и продажи В этом примере функция CALCULATE применяет фильтр к таблицам продаж и магазинов, используя значение из текущей итерации функции ADDCOLUMN по табличному выражению, являющемуся результатом функции UNION. Еще одним способом получить тот же результат является восстановление привязки данных при помощи уже известной нам функции TREATAS, с которой мы познакомились в главе 10. Код такого выражения может выглядеть следующим образом: DistinctCountryRegions = VAR CountryRegions = UNION ( ALL ( Customer[CountryRegion] ); ALL ( Store[CountryRegion] ) ) VAR UniqueCountryRegions = DISTINCT ( CountryRegions ) VAR Result = ADDCOLUMNS ( UniqueCountryRegions; "Customer Sales Amount"; CALCULATE ( [Sales Amount]; TREATAS ( { [CountryRegion] }; Customer[CountryRegion] 414 Глава 12 Работа с таблицами ) ); "Number of stores"; CALCULATE ( COUNTROWS ( Store ); TREATAS ( { [CountryRegion] }; Store[CountryRegion] ) ) ) RETURN Result Результат выполнения двух последних выражений будет одинаковым. Отличаются они лишь техникой, использованной для распространения фильтра с нового столбца на существующий в модели данных. Кроме того, в последнем примере мы применили конструктор таблиц: при помощи фигурных скобок мы преобразовали CountryRegion в таблицу, которую использовали в качестве параметра функции TREATAS. Поскольку функция UNION при объединении информации из разных столбцов утрачивает их исходную привязку данных, для ее восстановления удобно применять функцию TREATAS. Также стоит отметить, что функция TREATAS игнорирует значения, отсутствующие в целевом столбце. Функция INTERSECT Функция INTERSECT так же, как и UNION, предназначена для работы со множествами, но, в отличие от UNION, она не объединяет данные из двух таблиц, а возвращает их пересечение, то есть только строки, присутствующие в обеих таблицах. Эта функция была очень популярна до появления функции TREATAS, поскольку она позволяет применять результат табличного выражения в ка­чест­ве фильтра к другим таблицам и столбцам. После появления 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[Country­ Region]. Так что если попытаться получить сумму продаж по полученным городам, то фильтр будет наложен по столбцу Store[CountryRegion], но не по Cus­ to­mer[CountryRegion]: SalesStoresInCustomersCountries = VAR CountriesWithStoresAndCustomers = INTERSECT ( ALL ( Store[CountryRegion] ); ALL ( Customer[CountryRegion] ) ) VAR Result = ADDCOLUMNS ( CountriesWithStoresAndCustomers; "StoresSales"; [Sales Amount] ) RETURN Result Результат можно видеть на рис. 12.12. Рис. 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. Рис. 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 CustomersNotInLastYear = EXCEPT ( CurrentCustomers; CustomersLastYear ) VAR Result = CALCULATE ( [Sales Amount]; CustomersNotInLastYear ) 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 BI. Отчет, показанный на рис. 12.14, содержит два среза. По умолчанию Power BI выполнит пересечение двух выборов. В результате цифры в отчете покажут продажи бытовой техники (Home Appliances) покупателям, окончившим среднюю школу (High School). Рис. 12.14 По умолчанию выполняется пересечение выборов в срезах, так что все выбранные фильтры применяются одновременно Но иногда вместо пересечения двух условий вам может понадобиться выполнить их объединение. Иными словами, желаемые цифры должны отображать как продажи бытовой техники, так и продажи покупателям, окончившим среднюю школу. Поскольку Power BI не умеет объединять выбранные фильтры по условию OR, нам придется выполнить это объединение вручную – посредством написания кода на языке DAX. Необходимо помнить о том, что в каждой ячейке отчета контекст фильтра свой, и он включает как фильтр по категории товаров, так и фильтр по образованию. И нам нужно заменить оба этих фильтра. Есть множество способов это сделать. Мы покажем три из них. Первое и, возможно, самое простое выражение, способное помочь нам в этой ситуации, приведено ниже: OR 1 := VAR CategoriesEducations = CROSSJOIN ( ALL ( 'Product'[Category] ); ALL ( Customer[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. Рис. 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 вместо CROSSJOIN. Также стоит отметить, что функция 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]. В переменную CustomersFirstYear мы извлекаем список всех покупателей, приобретавших товары в первый год. Заключительный шаг самый простой – здесь мы просто применяем фильтр по покупателям. Таким образом, в каждой ячейке отчета будут отображаться продажи исключительно по покупателям, найденным на втором шаге. Модификатор KEEPFILTERS позволит дополнительно отфильтровать этих покупателей, скажем, по странам. Результат вычисления меры показан на рис. 12.16. Из этого отчета понятно, что покупатели, пришедшие к нам в 2007 году, с каждым годом делают все меньше покупок. Рис. 12.16 Продажи по годам среди покупателей, пришедших к нам в 2007 году Этот пример очень важно понять. Существует множество сценариев, в которых необходимо установить фильтр по дате, определить по нему набор данных, после чего проанализировать поведение этого набора, будь то покупатели, товары или магазины, по другим временным периодам. С помощью этого шаблона можно легко реализовать анализ повторных продаж и другие вычисления с похожими требованиями. Вычисление новых покупателей В предыдущем разделе этой главы, во время изучения функции EXCEPT, мы показали вам способ вычисления новых покупателей. Здесь мы представим вам более эффективный метод определения новых покупателей с использованием табличных функций. Идея этого алгоритма следующая. Сначала мы определим самую раннюю дату продажи для каждого покупателя. После этого проверим, входит ли эта дата для конкретного клиента в выбранный нами период. Если да, значит, этого покупателя можно считать новым относительно текущего периода. Глава 12 Работа с таблицами 423 Представляем вам код меры: New Customers := VAR CustomersFirstSale = CALCULATETABLE ( ADDCOLUMNS ( VALUES ( Sales[CustomerKey] ); "FirstSale"; CALCULATE ( MIN ( Sales[Order Date] ) ) ); ALL ( 'Date' ) ) VAR CustomersWith1stSaleInCurrentPeriod = FILTER ( CustomersFirstSale; [FirstSale] IN VALUES ( 'Date'[Date] ) ) VAR Result = COUNTROWS ( CustomersWith1stSaleInCurrentPeriod ) RETURN Result В формуле переменной CustomersFirstSale необходимо применить функцию ALL к таблице Date, чтобы можно было сканировать таблицу продаж за период, предшествующий выбранному. Результат выражения показан на рис. 12.17. Рис. 12.17 В отчете отображено общее количество покупателей и количество новых клиентов за 2007 год Если пользователь решит добавить фильтр к отчету, скажем, по категориям товаров, то к числу новых покупателей будут относиться те, которые впервые за выбранный период приобрели товар выбранной категории. Таким образом, в зависимости от установленных фильтров один и тот же покупатель может считаться новым или постоянным. Добавляя другие модификаторы функции 424 Глава 12 Работа с таблицами CALCULATE к первой переменной, можно получить различные вариации этой формулы. Например, добавив ALL ( Product ), мы укажем формуле при определении нового покупателя учитывать любые товары, а не только выбранные. Добавление ALL ( Store ) позволит не принимать в расчет конкретные магазины. Использование IN, CONTAINSROW и CONTAINS В предыдущем примере, как и во многих других, мы использовали ключевое слово IN для определения того, присутствует ли значение в таблице. При выполнении кода IN трансформируется в вызов функции CONTAINSROW, так что следующие две инструкции будут эквивалентными: Product[Color] IN { "Red"; "Blue"; "Yellow" } CONTAINSROW ( { "Red"; "Blue"; "Yellow" }; Product[Color] ) Этот синтаксис также работает и со множеством столбцов: ( 'Date'[Year]; 'Date'[MonthNumber] ) IN { ( 2018; 12 ); ( 2019; 1 ) } CONTAINSROW ( { ( 2018; 12 ); ( 2019; 1 ) }; 'Date'[Year]; 'Date'[MonthNumber] ) В прежних версиях DAX ключевые слова IN и CONTAINSROW были недоступны. Альтернативой им была функция CONTAINS, требующая на вход пару из столбца и значения для поиска в таблице. При этом функция CONTAINS является менее эффективной по сравнению с IN и CONTAINSROW. А поскольку в прежних версиях DAX не было также и конструкторов таблиц, функцию CONTAINS приходилось использовать с более многословным синтаксисом: VAR Colors = UNION ( ROW ( "Color"; ROW ( "Color"; ROW ( "Color"; ) RETURN CONTAINS ( Colors; "Red" ); "Blue" ); "Yellow" ) [Color]; Product[Color] ) На момент написания книги использование ключевого слова IN является наиболее оптимальным способом поиска значений в таблице. Выражения с IN очень просты для понимания, а в плане производительности они не уступают формулам с функцией CONTAINSROW. Повторное использование табличных выражений при помощи функции 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 ) Это выражение можно использовать в других мерах, применяя функцию DETAILROWS: 426 Глава 12 Работа с таблицами Cumulative Sales Amount := CALCULATE ( [Sales Amount]; DETAILROWS ( [Cumulative Total] ) ) Cumulative Total Cost := CALCULATE ( [Total Cost]; DETAILROWS ( [Cumulative Total] ) ) Больше примеров по данной теме можно найти по адресу: https://www.sqlbi. com/articles/creating-table-functions-in-dax-using-detailrows/. При этом стоит помнить, что использование функции DETAILROWS для запуска ассоциированных выражений является лишь обходным путем, за неимением в DAX пользовательских функций, а значит, может приводить к проблемам с производительностью. Многие примеры применения функции DETAILROWS можно переписать с помощью групп вычислений, и эта техника уйдет в прошлое, когда в DAX появятся меры, возвращающие таблицы, или пользовательские функции. Создание вычисляемых таблиц Все табличные функции, о которых мы рассказывали в предыдущих разделах, могут быть использованы либо в качестве табличных фильтров в функции CALCULATE, либо для создания вычисляемых таблиц и запросов. Ранее мы в основном описывали функции, более пригодные для использования в качестве фильтра, сейчас же поговорим о функциях, чаще применяющихся для создания вычисляемых таблиц. Есть и другие функции, главным образом использующиеся в запросах, но о них мы расскажем в следующей главе. В то же время необходимо помнить, что функции DAX не ограничиваются какой-то одной областью применения. Ничто не мешает вам использовать функции DATATABLE, SELECTCOLUMNS или GENERATESERIES (о них мы поговорим позже) в коде меры или в качестве табличного фильтра. Это лишь вопрос удобства применения – некоторые функции удобнее использовать в определенных ситуациях. Функция SELECTCOLUMNS Функцию SELECTCOLUMNS удобно использовать для ограничения количества выбираемых столбцов из таблицы. Кроме того, она обладает возможностью добавлять новые столбцы, подобно функции ADDCOLUMNS. На практике функция SELECTCOLUMNS служит для осуществления выборки данных из столбцов, как оператор SELECT в языке SQL. Наиболее частым использованием этой функции является сканирование таб­лицы и возвращение ограниченного набора столбцов. Например, следующее выражение возвращает из таблицы покупателей только столбцы с образованием и полом: Глава 12 Работа с таблицами 427 SELECTCOLUMNS ( Customer; "Education"; Customer[Education]; "Gender"; Customer[Gender] ) Результат выражения со множеством дублирующихся строк показан на рис. 12.18. Рис. 12.18 Функция SELECTCOLUMNS возвращает таблицу с дубликатами Функция SELECTCOLUMNS сильно отличается от SUMMARIZE. Если функция SUMMARIZE осуществляет группировку результирующего набора, то SELECTCOLUMNS просто ограничивает количество столбцов в выводе. Следовательно, результирующая таблица на выходе функции SELECTCOLUMNS может содержать повторяющиеся строки, а в случае с SUMMARIZE – нет. Для ограничения количества столбцов на вход функции SELECTCOLUMNS необходимо подать пары значений с наименованием столбца и выражением. Кроме того, в результирующем наборе могут оказаться не только существующие столбцы, но и новые. Например, следующая формула добавляет к таблице покупателей новый столбец Customer, в котором указаны имя покупателя и в скобках его код: SELECTCOLUMNS ( Customer; "Education"; Customer[Education]; "Gender"; Customer[Gender]; "Customer"; Customer[Name] & " (" & Customer[Customer Code] & ")" ) Результирующую таблицу можно видеть на рис. 12.19. Рис. 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. Рис. 12.20 Функция ROW создает таблицу с одной строкой После появления в DAX конструктора таблиц функция ROW стала использоваться гораздо реже. Например, предыдущее выражение можно записать так: { ( [Sales Amount]; SUM ( Sales[Quantity] ) ) } В этом случае наименования столбцов будут сгенерированы автоматически, что показано на рис. 12.21. Рис. 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. Рис. 12.22 Таблица, сгенерированная функцией 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 BI. Функция GENERATESERIES Функция GENERATESERIES является служебной и предназначена для создания списка значений по переданным параметрам нижней и верхней границ, а также шага. Например, следующее выражение сгенерирует список из 20 чисел от 1 до 20: GENERATESERIES ( 1; 20; 1 ) Тип данных будет зависеть от введенных значений и может быть либо числовым, либо DateTime. Например, если вам понадобится таблица, содержащая время, следующее выражение поможет вам быстро создать таблицу из 86 400 строк – по одной на каждую секунду: Time = GENERATESERIES ( TIME ( 0; 0; 0 ); TIME ( 23; 59; 59 ); TIME ( 0; 0; 1 ) ) -- Начальное значение -- Конечное значение -- Шаг: 1 секунда Изменив шаг и добавив дополнительные столбцы, можно создать небольшую таблицу, которая может служить в качестве измерения, например для осуществления среза продаж по времени: Time = SELECTCOLUMNS ( GENERATESERIES ( TIME ( 0; 0; 0 ); TIME ( 23; 59; 59 ); TIME ( 0; 30; 0 ) ); "Time"; [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 Рис. 12.23 Воспользовавшись функциями GENERATESERIES и SELECTCOLUMNS, можно легко смастерить таблицу со временем Заключение В данной главе вы познакомились со множеством табличных функций. А в следующей встретитесь еще с несколькими. Здесь мы главным образом сосредоточили внимание на функциях, применяющихся для создания вычисляемых таблиц и сложных аргументов фильтра в функциях CALCULATE и CALCULATETABLE. Помните, что в книге мы приводим примеры того, что возможно сделать при помощи языка DAX. Реальные же решения конкретных сценариев вам придется разрабатывать самостоятельно. Функции, с которыми вы познакомились в данной главе: ADDCOLUMNS – для добавления новых столбцов в исходную таблицу; SUMMARIZE – для выполнения группировки после сканирования таблицы; CROSSJOIN – для получения декартова произведения двух таблиц; UNION, INTERSECT и EXCEPT – для выполнения базовых операций со множествами применительно к таблицам; SELECTCOLUMNS – для выбора определенных столбцов из таблицы; ROW, DATATABLE и GENERATESERIES – для создания таблиц с постоянными величинами в качестве вычисляемых таблиц. В следующей главе мы расскажем еще о нескольких табличных функциях и главным образом сосредоточимся на построении сложных запросов и сложных вычисляемых таблиц. ГЛ А В А 13 Создание запросов В данной главе мы продолжим наше путешествие по миру DAX и изучим еще несколько полезных табличных функций. В основном мы сконцентрируемся на функциях, применяемых при подготовке запросов и создании вычисляемых таблиц, а не при написании мер. Помните о том, что большинство функций, с которыми вы познакомитесь в этой главе, вполне можно применять и при написании мер, пусть и с некоторыми ограничениями, на которых мы также подробно остановимся. Для каждой функции будет приведен пример запроса, использующего ее. При написании главы мы поставили себе две цели: познакомить вас с новыми функциями и показать несколько рабочих шаблонов, которые вы сможете адаптировать к своей модели данных. Все демонстрационные материалы в главе представлены в виде текстовых файлов с запросами для выполнения в DAX Studio с подключением к общему файлу с данными Power BI. Этот файл, в свою очередь, содержит модель данных 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 может предшествовать целый ряд дополнительных определений локальных таблиц, столбцов, мер и переменных, область видимости которых распространяется на весь пакет инструкций EVALUATE, выполняемый как единое целое. Например, следующий запрос, в котором функция CALCULATETABLE следует за ключевым словом EVALUATE, вернет список красных товаров: EVALUATE CALCULATETABLE ( 'Product'; 'Product'[Color] = "Red" ) Прежде чем углубиться в изучение продвинутых табличных функций, мы познакомимся с дополнительными опциями инструкции EVALUATE, которые будем активно использовать при написании сложных запросов. Введение в синтаксис EVALUATE Инструкцию EVALUATE можно условно разделить на три части: раздел определений: начинается с ключевого слова DEFINE и включает в себя список определений локальных сущностей, таких как таблицы, столбцы, переменные и меры. Можно написать один раздел определений для всего запроса, в то время как в запросе может присутствовать несколько инструкций EVALUATE; выражение запроса: начинающееся с инструкции EVALUATE, выражение запроса представляет собой, по сути, табличное выражение на языке DAX, возвращающее результат. В запросе может быть множество выражений запроса, каждое из которых должно начинаться ключевым словом EVALUATE и обладать своим набором модификаторов результата; модификаторы результата: это необязательный раздел в рамках инструкции EVALUATE, начинающийся с ключевого слова ORDER BY. Служит для выполнения сортировки результата, а также для вывода ограниченного набора столбцов при помощи инструкции START AT. Первая и третья секции EVALUATE являются опциональными. Так что прос­ тейшим вариантом использования ключевого слова EVALUATE будет указание следом за ним имени таблицы из модели данных. Однако, используя EVALUATE таким образом, разработчик добровольно отказывается от массы полез434 Глава 13 Создание запросов ных особенностей этой мощной инструкции, которые не только можно, но и нужно изучать. Ниже представлен пример запроса: DEFINE VAR MinimumAmount = 2000000 VAR MaximumAmount = 8000000 EVALUATE FILTER ( ADDCOLUMNS ( SUMMARIZE ( Sales; 'Product'[Category] ); "CategoryAmount"; [Sales Amount] ); AND ( [CategoryAmount] >= MinimumAmount; [CategoryAmount] <= MaximumAmount ) ) ORDER BY [CategoryAmount] Этот запрос вернет результат, показанный на рис. 13.1. Рис. 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 variables). Как и переменные выражений, переменные запросов могут хранить как скалярные величины, так и таблицы. Например, предыдущий запрос может быть переписан следующим образом с использованием табличной переменной: DEFINE VAR MinimumAmount = 2000000 VAR MaximumAmount = 8000000 VAR CategoriesSales = ADDCOLUMNS ( SUMMARIZE ( Sales; 'Product'[Category] ); "CategoryAmount"; [Sales Amount] ) EVALUATE FILTER ( CategoriesSales; AND ( [CategoryAmount] >= MinimumAmount; [CategoryAmount] <= MaximumAmount ) ) ORDER BY [CategoryAmount] Переменные запросов действуют в области видимости всего пакета инструкций EVALUATE, которые выполняются как единое целое. Это означает, что пос­ ле инициализации переменная может использоваться в любом из следующих далее запросов. Единственным ограничением является то, что к переменной можно обращаться только после ее объявления. Если в предыдущем примере переменную CategoriesSales объявить раньше переменных MinimumAmount или MaximumAmount, будет выдана синтаксическая ошибка, поскольку в CategoriesSales вы пытаетесь ссылаться на переменные, которые еще не объявлены. Это простое ограничение очень полезно и помогает избежать образования циклических зависимостей. Такое же ограничение действует и на переменные выражений, так что в этом плане наблюдается полная преемственность. Если в запросе содержится несколько секций EVALUATE, объявленные ранее переменные будут доступны во всех из них. Например, в генерируемых Power BI запросах секция DEFINE используется для хранения фильтров срезов в переменных, после чего идут несколько блоков EVALUATE для разных расчетов внут­ри визуального элемента. Переменные также могут быть объявлены и внутри секции EVALUATE. В этом случае это будут переменные выражения, и их область видимости сузится до конкретного табличного выражения. Например, предыдущий пример может быть переписан следующим образом: EVALUATE VAR MinimumAmount = 2000000 VAR MaximumAmount = 8000000 VAR CategoriesSales = 436 Глава 13 Создание запросов ADDCOLUMNS ( SUMMARIZE ( Sales; 'Product'[Category] ); "CategoryAmount"; [Sales Amount] ) RETURN FILTER ( CategoriesSales; AND ( [CategoryAmount] >= MinimumAmount; [CategoryAmount] <= MaximumAmount ) ) ORDER BY [CategoryAmount] Как видите, теперь переменные стали неотъемлемой частью табличного выражения, а значит, для определения результата необходимо использовать ключевое слово RETURN. Область видимости этих переменных в данном случае будет ограничена этой секцией RETURN. Выбор между переменными выражений и переменными запросов зависит от конкретной задачи – у обоих видов переменных есть свои преимущества и недостатки. Если вы хотите использовать переменную в следующих определениях таблиц или столбцов, то ваш выбор – переменная запроса. Если же в обращении к переменной в последующих определениях (или секциях EVALUATE) нет необходимости, лучше ограничиться переменной выражения. Фактически если переменная является составной частью выражения, будет гораздо проще использовать выражение для расчета вычисляемой таблицы или включения в меру. В противном случае вам придется менять синтаксис запроса для преобразования его в выражение. Для выбора между двумя типами переменных можно воспользоваться следующим простым правилом. Используйте переменные выражений всегда, когда это возможно, а переменные запросов – только в случаях крайней необходимости. Одним из ограничений переменных запросов является то, что их достаточно проблематично повторно использовать в других формулах. Использование MEASURE внутри DEFINE Еще одной сущностью, которую можно объявить локально внутри запроса, является мера. Это можно сделать при помощи ключевого слова MEASURE. Меры в запросах ведут себя приблизительно так же, как и обычные меры, за исключением того, что они существуют только в рамках запроса. При определении меры обязательно нужно указывать имя таблицы, в которой она будет размещена. Приведем пример такой меры: DEFINE MEASURE Sales[LargeSales] = CALCULATE ( [Sales Amount]; Sales[Net Price] >= 200 ) Глава 13 Создание запросов 437 EVALUATE ADDCOLUMNS ( VALUES ( 'Product'[Category] ); "Large Sales"; [LargeSales] ) Результат запроса можно видеть на рис. 13.2. Рис. 13.2 Мера запроса LargeSales вычисляется для каждой категории товаров в столбце Large Sales Меры в запросах используются для двух целей. Во-первых, что весьма очевидно, для написания более сложных выражений, которые могут многократно использоваться в рамках запроса. Второе предназначение мер в запросах состоит в их использовании для отладки кода и настройке производительности. Дело в том, что если мера в запросе и мера в модели данных называются одинаково, приоритет внутри запроса будет у первой. Иными словами, ссылки на меру по имени внутри запроса будут обращаться к локальной мере, а не к глобальной, объявленной в модели. В то же время все другие ссылки на эту меру в модели данных продолжат обращаться к мере, существующей вне запроса. И для того чтобы оценить поведение запроса при изменении нужной меры в модели данных, вам достаточно включить меру с точно таким же именем в сам запрос. Тестируя поведение меры, лучше всего будет написать запрос, использующий ее, добавить локальную копию этой меры и заняться отладкой или оптимизацией кода. По окончании процесса код меры в модели данных можно смело заменить на отлаженный код из запроса. В DAX Studio для этой цели есть специальная функция: инструмент предлагает разработчику автоматически добавить все инструкции DEFINE MEASURE в запрос с целью ускорения процесса. Реализация распространенных шаблонов запросов в DAX Теперь, когда вы познакомились с синтаксисом инструкции EVALUATE, мы готовы представить вам ряд функций, полезных при написании запросов. Для наиболее распространенных из них мы также покажем примеры, которые помогут лучше понять использование этих функций. 438 Глава 13 Создание запросов Использование функции ROW для проверки мер Представленная в предыдущей главе функция ROW обычно используется для извлечения значения из меры или проведения анализа плана ее выполнения. Инструкция EVALUATE принимает таблицу на вход и возвращает также таблицу. Если все, что вам нужно, – это получить значение меры, EVALUATE просто не примет ее в качестве аргумента, потому что ожидает на вход таблицу. Обойти это ограничение можно при помощи функции ROW, способной превратить любую скалярную величину в таблицу, как показано в следующем примере: EVALUATE ROW ( "Result"; [Sales Amount] ) Результат выполнения этого запроса показан на рис. 13.3. Рис. 13.3 Функция ROW возвращает таблицу с одной строкой Такого же результата можно добиться, используя конструктор таблиц: EVALUATE { [Sales Amount] } На рис. 13.4 можно видеть результат выполнения запроса. Рис. 13.4 Конструктор таблиц возвращает таблицу с одной строкой и столбцом с именем Value Функция ROW позволяет разработчику самому задать наименование столбца, тогда как конструктор таблиц такой возможности не дает. Кроме того, с помощью функции ROW можно создать таблицу с несколькими столбцами, каждому из которых дать свое имя и выражение. Если есть необходимость смоделировать присутствие среза, можно воспользоваться функцией CALCULATETABLE, как показано ниже: EVALUATE CALCULATETABLE ( ROW ( "Sales"; [Sales Amount]; "Cost"; [Total Cost] ); 'Product'[Color] = "Red" ) Результат выполнения запроса показан на рис. 13.5. Рис. 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] Рис. 13.6 Модификатор ROLLUP создает дополнительные строки с подытогами при выполнении функции SUMMARIZE Последние два столбца будут заполнены булевыми значениями: если в строке содержатся подытоги по соответствующему столбцу, будет указано TRUE, если нет – FALSE, что видно по рис. 13.7. Рис. 13.7 Функция ISSUBTOTAL возвращает True, если в строке представлен подытог С использованием функции ISSUBTOTAL можно легко отличать строки с данными от предварительных итогов. Глава 13 Создание запросов 441 Важно Функция SUMMARIZE не должна использоваться для добавления столбцов в результирующий набор. Примеры с применением связки функций ROLLUP и ISSUBTOTAL мы представили, чтобы наш читатель не терялся, если встретит такую комбинацию в чужом коде. Вы не должны применять в своих выражениях функцию SUMMARIZE с этой целью – лучше использовать функцию SUMMARIZECOLUMNS или связку ADDCOLUMNS и SUMMARIZE, когда применить SUMMARIZECOLUMNS невозможно. Функция SUMMARIZECOLUMNS SUMMARIZECOLUMNS – очень мощная и универсальная функция применительно к запросам. В одной этой функции, по сути, уместилось все, что необходимо для работы с запросами. Судите сами, функция SUMMARIZECOLUMNS позволяет вам задать: набор столбцов для осуществления группировки по подобию функции SUMMARIZE, с опциональным выводом подытогов; набор новых столбцов в результирующей таблице – как связка функций SUMMARIZE и ADDCOLUMNS; набор фильтров для применения к модели данных перед выполнением группировки, подобно функции CALCULATETABLE. И наконец, функция SUMMARIZECOLUMNS удаляет из результата строки, в которых значения всех добавленных столбцов пустые. Неудивительно, что Power BI использует функцию SUMMARIZECOLUMNS для выполнения почти всех запросов. Вот простой пример использования функции SUMMARIZECOLUMNS: EVALUATE SUMMARIZECOLUMNS ( 'Product'[Category]; 'Date'[Calendar Year]; "Amount"; [Sales Amount] ) 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] Рис. 13.8 Таблица содержит сгруппированные категории и годы, а также суммы продаж по этим комбинациям Как результат функция SUMMARIZECOLUMNS проигнорирует тот факт, что в столбце Sales Amount содержится пустое значение, и включит его в набор. В результате мы получим таблицу с пустыми значениями в столбце с продажами, как видно по рис. 13.9. Рис. 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 показан вывод этого запроса с выделенными строками итогов. Рис. 13.10 Функция ROLLUPADDISSUBTOTAL добавляет строки с подытогами и создает дополнительный столбец с информацией о них Группируя таблицу по множеству столбцов, вы можете пометить функцией ROLLUPADDISSUBTOTAL сразу несколько из них. Это позволит создать несколько уровней группировки. Например, следующий запрос подводит промежуточные итоги по категориям товаров для всех годов, а также по годам для всех категорий: EVALUATE SUMMARIZECOLUMNS ( 444 Глава 13 Создание запросов ROLLUPADDISSUBTOTAL ( 'Product'[Category]; "CategoryTotal" ); ROLLUPADDISSUBTOTAL ( 'Date'[Calendar Year]; "YearTotal" ); "Amount"; [Sales Amount] ) ORDER BY 'Product'[Category]; 'Date'[Calendar Year] Строки с подытогами по годам без категорий и по категориям без разделения на годы выделены на рис. 13.11. Рис. 13.11 Функция ROLLUPADDISSUBTOTAL способна группировать таблицу по нескольким колонкам Если вам нужно вывести подытоги для группы столбцов, вам придет на помощь модификатор ROLLUPGROUP. В следующем запросе добавляется объединенный подытог по категориям и годам, в результате чего в таблице появляется только одна дополнительная строка: EVALUATE SUMMARIZECOLUMNS ( ROLLUPADDISSUBTOTAL ( ROLLUPGROUP ( 'Product'[Category]; 'Date'[Calendar Year] ); "CategoryYearTotal" ); "Amount"; [Sales Amount] Глава 13 Создание запросов 445 ) ORDER BY 'Product'[Category]; 'Date'[Calendar Year] На рис. 13.12 показан результат с одной строкой с итогами. Рис. 13.12 Функция ROLLUPADDISSUBTOTAL создает дополнительную строку и столбец с подытогами Последней особенностью функции SUMMARIZECOLUMNS является способность фильтровать результат по подобию функции CALCULATETABLE. Вы можете указать один или несколько фильтров, используя таблицы в качестве дополнительных аргументов. Например, следующий запрос извлекает продажи только по покупателям с определенным уровнем образования (High School). Результат будет таким же, как на рис. 13.12, но с меньшими суммами: EVALUATE SUMMARIZECOLUMNS ( ROLLUPADDISSUBTOTAL ( ROLLUPGROUP ( 'Product'[Category]; 'Date'[Calendar Year] ); "CategoryYearTotal" ); FILTER ( ALL ( Customer[Education] ); Customer[Education] = "High School" ); "Amount"; [Sales Amount] ) Заметим, что в функции 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 в мерах, поскольку просто не будет работать в большинстве отчетов. Меры часто используются в таких элементах визуализации, как матрица или график, где они внутренне вычисляются в контексте строки для каждого значения, выведенного в отчет. Чтобы еще лучше продемонстрировать ограничения функции SUMMARIZECOLUMNS в рамках контекста строки, рассмотрим следующий запрос для вычисления суммарных продаж по товарам с использованием неэффективного, но допустимого способа: 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 Amount] ); [SubcategoryTotal] ) ) ) } В основном функция SUMMARIZECOLUMNS неприменима в мерах, поскольку меры обычно рассчитываются в рамках сложных запросов, сгенерированных клиентскими инструментами. Такие запросы с большой степенью вероятности используют концепцию преобразования контекста, а значит, функции SUMMARIZECOLUMNS в них просто не место. Функция TOPN Функция TOPN позволяет отсортировать таблицу и вернуть набор из заданного количества строк. Это бывает полезно, когда нужно ограничить количество возвращаемых строк. Например, когда Power BI показывает содержимое таб­ лицы, вся таблица целиком не извлекается. Вместо этого из таблицы берется несколько первых строк для заполнения экранного пространства. Оставшаяся часть таблицы извлекается по требованию, когда пользователь осуществляет прокрутку в элементе визуализации. Также функцию TOPN можно использовать для получения ограниченного списка лучших элементов по тому или иному показателю, например лучших покупателей, товаров и т. д. Три товара с максимальной суммой продаж можно получить при помощи следующего запроса, вычисляющего меру Sales Amount для каждой строки в таб­лице Product: EVALUATE TOPN ( 448 Глава 13 Создание запросов 3; 'Product'; [Sales Amount] ) В результирующей таблице будут содержаться все столбцы из исходной таб­ лицы. Но обычно при выполнении подобных запросов пользователя не интересуют все столбцы, так что лучше предварительно ограничить исходную таб­лицу нужными столбцами. В следующем примере показана версия запроса с уменьшенным количеством столбцов. Результат выполнения запроса отображен следом на рис. 13.13: EVALUATE VAR ProductsBrands = SUMMARIZE ( Sales; 'Product'[Product Name]; 'Product'[Brand] ) VAR Result = TOPN ( 3; ProductsBrands; [Sales Amount] ) RETURN Result ORDER BY 'Product'[Product Name] Рис. 13.13 Функция TOPN фильтрует исходную таблицу на основании значений столбца Sales Amount Вероятно, вам может понадобиться включить в итоговый набор и саму меру Sales Amount, чтобы можно было правильно отсортировать результаты. В таком случае лучше всего будет добавить нужный столбец к предварительно сгруппированной исходной таблице, после чего выполнять функцию TOPN. Таким образом, один из самых распространенных шаблонов применения функции TOPN показан в следующем примере: EVALUATE VAR ProductsBrands = SUMMARIZE ( Sales; 'Product'[Product Name]; '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. Рис. 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. Рис. 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 Рис. 13.16 Используя дополнительные параметры сортировки, можно избавиться от конфликтов одинаковых значений в итоговой таблице Помните, что даже добавление дополнительных параметров сортировки не гарантирует вам извлечения строго запрошенного количества строк. Функция TOPN все равно может возвращать больше строк в случае полной идентичности фильтруемых столбцов. Дополнительные параметры сортировки лишь снижают вероятность появления одинаковых значений. Если же вам необходимо гарантированно получить указанное количество строк из таблицы, стоит добавить к порядку сортировки поле с уникальными значениями, что исключит вероятность появления дубликатов. Рассмотрим более сложный пример использования функции TOPN с применением функций для работы со множествами и переменных. Нам необходимо сформировать отчет по десяти лучшим товарам, исходя из суммы продаж, и при этом добавить в вывод дополнительную строку Others (Остальные), в которой будут объединены все оставшиеся позиции. Возможная реализация этого примера показана ниже: EVALUATE VAR NumOfTopProducts = 10 VAR ProdsWithSales = ADDCOLUMNS ( VALUES ( 'Product'[Product Name] ); "Product Sales"; [Sales Amount] ) VAR TopNProducts = TOPN ( NumOfTopProducts; ProdsWithSales; [Product Sales] ) VAR RemainingProducts = EXCEPT ( ProdsWithSales; TopNProducts ) VAR OtherRow = ROW ( "Product Name"; "Others"; "Product Sales"; SUMX ( RemainingProducts; [Product Sales] ) ) VAR Result = UNION ( TopNProducts; OtherRow ) RETURN Result ORDER BY [Product Sales] DESC 452 Глава 13 Создание запросов Сначала мы сохраняем в переменной ProdsWithSales таблицу по всем товарам с продажами. Затем выбираем из этого списка первые десять позиций, записывая результат в переменную TopNProducts. В переменной RemainingProducts мы воспользовались функцией EXCEPT для извлечения всех товаров, не вошедших в десять лучших. Разбив исходный набор товаров на две группы (TopNProducts и RemainingProducts), мы создаем таблицу из одной строки с именем товара Others, в которой подсчитана сумма продаж по всем оставшимся товарам из переменной RemainingProducts. После этого мы используем функцию UNION для объединения наборов из десяти лучших товаров с агрегированной строкой из остальных. Результат выполнения запроса показан на рис. 13.17. Рис. 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. Рис. 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, выведены только три товара. Рис. 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 Amount] ) VAR Top3Products = TOPN ( 3; ProductsSales; [Product Sales] ) RETURN Top3Products ) ) ORDER BY 'Date'[Calendar Year]; [Product Sales] DESC Результат выполнения запроса показан на рис. 13.20. Рис. 13.20 Функция GENERATE объединяет годы с тремя самыми продаваемыми товарами по каждому из них Если вам необходимо выделить по три товара в рамках категорий товаров, достаточно обновить таблицу для итераций в функции GENERATE, как показано ниже: EVALUATE GENERATE ( VALUES ( 'Product'[Category] ); CALCULATETABLE ( 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'[Category]; [Product Sales] DESC Как видно по рис. 13.21, теперь в отчете выведены первые тройки товаров в рамках каждой категории. 456 Глава 13 Создание запросов Рис. 13.21 Проходя по категориям, мы можем выделить по три самых популярных товара в каждой из них Если выражение, переданное функции GENERATE в качестве второго параметра, возвращает пустую таблицу, эта строка исключается из итогового набора. Если же необходимо оставить их в результирующей таблице, можно использовать функцию GENERATEALL. Например, в 2005 году продаж не было, а значит, для этого года и не может быть самых продаваемых товаров. Функция GENERATE не включит этот год в итоговый результат, тогда как GENERATEALL оставит его в выводе, как показано ниже: EVALUATE GENERATEALL ( VALUES ( 'Date'[Calendar Year] ); CALCULATETABLE ( 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 'Date'[Calendar Year]; [Product Sales] DESC Результат выполнения этого запроса показан на рис. 13.22. Функция ISONORAFTER ISONORAFTER является специальной функцией и часто используется Power BI и другими инструментами для постраничного вывода отчетов. В то же время разработчики довольно редко прибегают к ее помощи при написании запросов и мер. Когда пользователь формирует отчет в Power BI, движок извлекает из источника ровно столько строк, сколько необходимо для размещения на одной странице. Для этого он использует уже знакомую нам функцию TOPN. Глава 13 Создание запросов 457 Рис. 13.22 Функция GENERATEALL оставляет в наборе годы, по которым не было продаж, а GENERATE – нет При просмотре таблицы с товарами пользователь каждый раз располагается на определенной странице. Например, на рис. 13.23 последней строкой на странице является товар Stereo Bluetooth Headphones New Gen, а стрелкой обозначена относительная позиция страницы в списке. Рис. 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 Amount] ) ); 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 Name]; 1 ) ORDER BY 'Product'[Category]; 'Product'[Color]; 'Product'[Product Name] Рис. 13.24 Следующая страница со строками, начиная с последней строки в предыдущем наборе В начале кода запускается функция TOPN 501 по результату функции FILTER. FILTER используется для удаления ранее просмотренных строк, а для того чтобы получить следующую страницу, прибегает к помощи функции ISONORAFTER. То же самое условие могло быть выражено и через обычную булеву логику. Фактически фрагмент с функцией ISONORAFTER здесь можно заменить на следующий код: 'Product'[Category] > "Audio" || ( 'Product'[Category] = "Audio" && 'Product'[Color] > "Yellow" ) || ( 'Product'[Category] = "Audio" && 'Product'[Color] = "Yellow" && 'Product'[Product Name] Глава 13 Создание запросов 459 >= "WWI Stereo Bluetooth Headphones New Generation M370 Yellow" ) Но функцию ISONORAFTER применять гораздо лучше. Во-первых, код будет читаться легче, во-вторых, план выполнения запроса с большой степенью вероятности окажется более эффективным. Функция ADDMISSINGITEMS ADDMISSINGITEMS – еще одна специальная функция, часто используемая Power BI и редко – самими разработчиками. Она призвана добавлять в набор строки, которые могли быть пропущены функцией SUMMARIZECOLUMNS. Например, следующее выражение использует функцию SUMMARIZECOLUMNS для группирования данных по годам. Результат запроса показан на рис. 13.25. Рис. 13.25 Функция SUMMARIZECOLUMNS не включает в вывод годы с отсутствующими продажами, то есть когда в столбце Amt находится пустое значение Годы без продаж не включаются в итоговый набор функцией SUMMARIZECOLUMNS. Чтобы извлечь строки, проигнорированные функцией SUMMARIZECOLUMNS, можно использовать функцию 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. Рис. 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; 'Date'[Calendar Year]; 'Date'[Month]; 'Date'[Month Number]; "Amt"; AVERAGEX ( CURRENTGROUP (); Sales[Quantity] * Sales[Net Price] ) ) ORDER BY 'Date'[Calendar Year]; 'Date'[Month Number] В плане производительности функция GROUPBY может проседать применительно к большим наборам данных – начиная от нескольких десятков тысяч запи­сей и выше. Фактически функция приступает к осуществлению группировки только после завершения процесса материализации таблицы, а значит, она не слишком применима к большим наборам данных. Кроме того, в большинГлава 13 Создание запросов 461 стве случаев запросы легче выразить через сочетание функций ADDCOLUMNS и SUMMARIZE. Предыдущий пример можно записать следующим образом: EVALUATE ADDCOLUMNS ( SUMMARIZE ( Sales; 'Date'[Calendar Year]; 'Date'[Month]; 'Date'[Month Number] ); "Amt"; AVERAGEX ( RELATEDTABLE ( Sales ); Sales[Quantity] * Sales[Net Price] ) ) ORDER BY 'Date'[Calendar Year]; 'Date'[Month Number] Рис. 13.27 Использование функции GROUPBY для подсчета средних продаж по годам и месяцам Примечание Стоит отметить, что в предыдущем запросе на выходе из функции SUMMARIZE будет таблица со столбцами из таблицы Date. Так что когда позже функция AVERAGEX осуществляет итерации по результату функции RELATEDTABLE, в таблице, возвращенной функцией RELATEDTABLE, будут год и месяц из текущей итерации функции ADDCOLUMNS по результирующей таблице из функции SUMMARIZE. Помните, что привязка данных в этом случае сохранится. Таким образом, на выходе функции SUMMARIZE мы получим таблицу с привязкой данных. Одним из преимуществ функции GROUPBY является ее способность группировать столбцы, добавленные к запросу функциями ADDCOLUMNS или SUMMARIZE. Ниже приведен пример, где функция SUMMARIZE не может быть использована в качестве альтернативы GROUPBY: EVALUATE VAR AvgCustomerSales = AVERAGEX ( Customer; [Sales Amount] 462 Глава 13 Создание запросов ) VAR ClassifiedCustomers = ADDCOLUMNS ( VALUES ( Customer[Customer 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. Рис. 13.28 Функция GROUPBY умеет группировать столбцы, добавленные в процессе выполнения запроса Предыдущий пример демонстрирует одновременно и преимущества, и недостатки функции GROUPBY. Сначала в коде создается новый столбец в таблице с покупателями, в котором вычисляется, приобрел ли данный покупатель товаров за все время на сумму выше среднего или ниже. В результате мы выполняем группировку по созданному временному столбцу и считаем количест­ во покупателей в обеих категориях. Выполнение группировки по временному столбцу – очень полезная особенность функции GROUPBY. Но при этом нам приходится использовать итерационную функцию SUMX с проходом по CURRENTGROUP с использованием константы 1. Причина в том, что столбцы, добавленные в таблицу функцией GROUPBY, обязательно должны вычисляться путем прохождения по CURRENTGROUP. Простое выражение вроде COUNTROWS ( CURRENTGROUP () ) здесь не сработает. Существует не так много сценариев, где функция GROUPBY может оказаться полезной. В основном это случаи, когда необходимо группировать столбцы, созданные непосредственно в запросе. Стоит также помнить, что столбец, по которому будет осуществляться группировка, должен обладать низкой крат­ ностью. Иначе вы можете столкнуться с проблемами производительности и перерасхода памяти. Глава 13 Создание запросов 463 Функции NATURALINNERJOIN и NATURALLEFTOUTERJOIN Движок DAX использует связи в модели данных автоматически всякий раз, когда разработчик запускает запрос на выполнение. Но иногда бывает полезно объединить в запросе две таблицы, не связанные физически. Например, вы можете объявить табличную переменную и затем связать вычисляемую таблицу с этой переменной. Представьте, что вам необходимо рассчитать средние продажи по категориям и построить отчет с показом категорий с продажами ниже среднего, примерно средними и выше среднего. Столбец легко вычислить при помощи функции SWITCH. Однако если результирующий набор должен быть отсортирован конкретным способом, нам придется одновременно получать описание категории и порядок сортировки (новый столбец) с использованием похожего кода. Но можно поступить иначе – рассчитать только одно из двух значений, а затем использовать временную таблицу со связью для извлечения описания категории. Именно такой подход показан в следующем запросе: EVALUATE VAR AvgSales = AVERAGEX ( VALUES ( 'Product'[Brand] ); [Sales Amount] ) 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 ( 'Product'[Brand] ); "Sales Amt"; [Sales Amount]; "Cat Sort"; SWITCH ( TRUE (); [Sales Amount] <= LowerBoundary; 0; [Sales Amount] >= UpperBoundary; 2; 1 ) ) VAR JoinedResult = NATURALINNERJOIN ( Categories; BrandsClassified 464 Глава 13 Создание запросов ) RETURN JoinedResult ORDER BY [Cat Sort]; 'Product'[Brand] Перед описанием запроса полезно будет взглянуть на его результат, показанный на рис. 13.29. Рис. 13.29 Столбец Cat Sort должен быть использован в аргументе «столбец для сортировки» в Category Сначала мы создаем таблицу с брендами, суммами продаж и столбцом со значением между 0 и 2. Это значение будет использовано в переменной Categories в качестве ключа для извлечения описания категории. Финальная связка между временной таблицей и переменной осуществляется при помощи функции NATURALINNERJOIN, при этом связь устанавливается по столбцу Cat Sort. Функция NATURALINNERJOIN выполняет связь между таблицами на основании столбцов, имеющих одинаковые имена в обеих таблицах. Функция NATURALLEFTOUTERJOIN делает то же самое, но вместо внутренней связи осуществ­ ляет левое внешнее соединение таблиц. Таким образом, из первой таблицы сохраняются строки даже в том случае, если для них нет соответствующих строк во второй таблице. Если обе таблицы физически определены в модели данных, они могут быть объединены только посредством связи. Связи помогают получить объединенную информацию из таблиц, как это происходит в SQL. Обе функции – и NA­TU­RALINNERJOIN, и 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 Amount] ) VAR MatrixRows = SUMMARIZE ( SalesYearCategory; 'Product'[Category] ) VAR MatrixColumns = SUMMARIZE ( SalesYearCategory; 'Date'[Calendar Year] ) VAR SalesYearCategoryIndexed = SUBSTITUTEWITHINDEX ( SalesYearCategory; "ColumnIndex"; MatrixColumns; 'Date'[Calendar Year]; ASC ) -- Первый результирующий набор: заголовки столбцов матрицы EVALUATE MatrixColumns ORDER BY 'Date'[Calendar Year] -- Второй результирующий набор: строки матрицы и их содержание EVALUATE NATURALLEFTOUTERJOIN ( MatrixRows; SalesYearCategoryIndexed ) ORDER BY 'Product'[Category]; [ColumnIndex] Рис. 13.30 Матрица в Power BI, построенная при помощи запроса с использованием функции SUBSTITUTEWITHINDEX Глава 13 Создание запросов 467 В запросе содержится два блока EVALUATE. Первый из них возвращает содержимое заголовков столбцов, как показано на рис. 13.31. Рис. 13.31 Результат извлечения заголовков столбцов из матрицы в Power BI Второй блок EVALUATE возвращает оставшуюся часть матрицы, собирая по строке для каждой ячейки ее содержимого. В результирующем наборе каждая строка будет содержать столбцы, необходимые для заполнения заголовков строк матрицы, следом за которыми будут идти цифры для отображения, а затем столбец с индексом, созданный при помощи функции SUBSTITUTEWITHINDEX. Эта таблица показана на рис. 13.32. Рис. 13.32 Содержимое строк матрицы в Power BI, сгенерированное при помощи функции SUBSTITUTEWITHINDEX Функция SUBSTITUTEWITHINDEX главным образом используется для построения визуальных элементов в Power BI, таких как матрица. Функция SAMPLE Функция SAMPLE предназначена для извлечения ограниченной выборки строк из таблицы. В качестве аргументов функция принимает требуемое количество записей, имя таблицы и порядок сортировки. В итоговую выборку функции SAMPLE включаются первая и последняя строки таблицы, а также недостающее количество строк до требуемого с равномерным распределением по таблице. Например, следующий запрос возвращает набор из десяти товаров, предварительно отсортировав таблицу по столбцу Product Name: EVALUATE SAMPLE ( 10; ADDCOLUMNS ( VALUES ( 'Product'[Product Name] ); "Sales"; [Sales Amount] 468 Глава 13 Создание запросов ); 'Product'[Product Name] ) ORDER BY 'Product'[Product Name] Результат выполнения запроса показан на рис. 13.33. Рис. 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, для категории выведены только три подкатегории, а не перечень из всех возможных подкатегорий. Рис. 13.34 Функция SUMMARIZECOLUMNS возвращает список из существующих комбинаций значений Автоматическая проверка существования вступает в силу всякий раз, когда в запросе осуществляется группировка по столбцам из одной и той же таблицы. И когда этот механизм задействован, в результирующий набор автоматически включаются только существующие комбинации значений столбцов. Это приводит к уменьшению количества строк в выводе и использованию более эффективного плана выполнения запроса. Если же в запросе применяются столбцы из разных таблиц, результат будет другим. В этом случае функция SUMMARIZECOLUMNS выдаст полное перекрестное соединение двух столбцов. Это видно на примере следующего запроса, результат выполнения которого показан на рис. 13.35: EVALUATE SUMMARIZECOLUMNS ( 'Product'[Category]; 'Date'[Calendar Year] ) ORDER BY 'Product'[Category]; 'Date'[Calendar Year] Несмотря на то что обе таблицы объединены посредством связей с таблицей Sales и в модели данных присутствуют годы, в которые не было транзакций, ме470 Глава 13 Создание запросов ханизм автоматической проверки существования не активируется, поскольку столбцы в выражении используются из разных таблиц. Рис. 13.35 При участии в выражении столбцов из разных таблиц создается их полное перекрестное соединение Помните о том, что функция SUMMARIZECOLUMNS удаляет строки, в которых во всех дополнительных столбцах агрегированные значения возвращают пустоту. Таким образом, если в предыдущий запрос добавить меру Sales Amount, функция SUMMARIZECOLUMNS исключит из результирующего набора годы и категории, по которым не было продаж, как показано на рис. 13.36: DEFINE MEASURE Sales[Sales Amount] = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) EVALUATE SUMMARIZECOLUMNS ( 'Product'[Category]; 'Date'[Calendar Year]; "Sales"; [Sales Amount] ) ORDER BY 'Product'[Category]; 'Date'[Calendar Year] Поведение предыдущего запроса не согласуется с механизмом автоматической проверки существования, поскольку его выполнение основывается на результате вычисления, включающего агрегацию. Константные выражения при этом игнорируются. Например, если вместо пустого значения мы будем выводить 0, будет сформирован список из всех категорий по всем годам. Результат выполнения следующего запроса показан на рис. 13.37: Глава 13 Создание запросов 471 DEFINE MEASURE Sales[Sales Amount] = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) EVALUATE SUMMARIZECOLUMNS ( 'Product'[Category]; 'Date'[Calendar Year]; "Sales"; [Sales Amount] + 0 -- Возвращает 0 вместо пустого значения ) ORDER BY 'Product'[Category]; 'Date'[Calendar Year] Рис. 13.36 Присутствие столбца с агрегацией привело к исключению строк с пустыми значениями Рис. 13.37 При возвращении нуля вместо пустого значения функция SUMMARIZECOLUMNS выводит все строки В то же время такой подход для вывода всех строк не сработает, если столбцы в функции будут браться из одной таблицы. В этом случае всегда будет приме472 Глава 13 Создание запросов няться механизм автоматической проверки существования. Например, в следующем примере в выводе останутся только существующие комбинации полей Category и Subcategory, несмотря на то что в мере мы по-прежнему выводим 0 вместо пустого значения: 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. Рис. 13.38 Функция SUMMARIZECOLUMNS применяет автоматическую проверку существования к столбцам из одной таблицы, даже если агрегация возвращает 0 Важно также учитывать особенности работы механизма автоматической проверки существования применительно к функции ADDMISSINGITEMS. Фактически функция ADDMISSINGITEMS добавляет в итоговый результат строки, которые были удалены функцией SUMMARIZECOLUMNS по причине наличия пустых значений. При этом функция ADDMISSINGITEMS не возвращает в результирующий набор строки, исключенные в результате применения автоматической проверки существования к столбцам из одной таблицы. Так что в результате выполнения следующего запроса будет возвращен тот же набор данных, показанный на рис. 13.38, что и в предыдущем примере: DEFINE MEASURE Sales[Sales Amount] = SUMX ( Sales; Глава 13 Создание запросов 473 Sales[Quantity] * Sales[Net Price] ) EVALUATE ADDMISSINGITEMS ( 'Product'[Category]; 'Product'[Subcategory]; SUMMARIZECOLUMNS ( 'Product'[Category]; 'Product'[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] ) Рис. 13.39 Функция SUMMARIZE возвращает комбинацию категорий товаров и годов, по которым были продажи Причина того, что в таблице выводятся только сочетания столбцов с продажами, заключается в том, что функция SUMMARIZE использует таблицу Sales 474 Глава 13 Создание запросов в качестве отправной точки при выполнении группировки. Таким образом, категории товаров и годы, на которые нет ссылок в таблице Sales, автоматически исключаются из результатов. Так что даже если результаты и получатся одинаковыми при выполнении функций SUMMARIZE и SUMMARIZECOLUMNS, получены они будут совершенно разными способами. Кроме того, стоит помнить об особенностях выполнения запросов в разных клиентских инструментах. Например, если пользователь выберет в Power BI категорию товара и год, не включив при этом меру, отчет выдаст список су­ ществующих комбинаций этих столбцов в таблице Sales. И причина не в том, что здесь был применен механизм автоматической проверки существования. Просто Power BI добавляет свои правила к существующей логике DAX. В результате простой отчет с годом и категорией товаров превращается в сложный запрос, показанный ниже: EVALUATE TOPN ( 501; SELECTCOLUMNS ( KEEPFILTERS ( FILTER ( KEEPFILTERS ( SUMMARIZECOLUMNS ( 'Date'[Calendar Year]; 'Product'[Category]; "CountRowsSales"; CALCULATE ( COUNTROWS ( 'Sales' ) ) ) ); OR ( NOT ( ISBLANK ( 'Date'[Calendar Year] ) ); NOT ( ISBLANK ( 'Product'[Category] ) ) ) ) ); "'Date'[Calendar Year]"; 'Date'[Calendar Year]; "'Product'[Category]"; 'Product'[Category] ); 'Date'[Calendar Year]; 1; 'Product'[Category]; 1 ) Подсвеченная строка показывает, что Power BI добавляет скрытый расчет количества строк в таблице Sales. А поскольку функция SUMMARIZECOLUMNS удаляет из набора все строки с пустыми агрегациями, это, по сути, имитирует применение механизма автоматической проверки существования при сочетании столбцов из одной таблицы. Power BI добавляет этот скрытый расчет только в случае отсутствия мер в отчете, используя при этом таблицу, с которой все таблицы в функции SUMMARIZECOLUMNS объединены связью «один ко многим». Как только в запросе появится мера, Power BI удалит этот скрытый расчет и будет опираться на значение добавленной меры, а не на количество строк в таблице Sales. Глава 13 Создание запросов 475 Чаще всего поведение функций SUMMARIZECOLUMNS и SUMMARIZE является интуитивно понятным. Однако в сложных сценариях, например при наличии связей типа «многие ко многим», результаты могут оказаться неожиданными. В этом коротком разделе мы лишь поверхностно познакомились с механизмом автоматической проверки существования в DAX. Более по­ дробное описание этой особенности с примерами сложных сценариев можно найти в статье Understanding DAX Auto-Exist («Понимание механизма автоматической проверки существования в DAX») по адресу: https:// www.sqlbi.com/ articles/understanding-dax-auto-exist/. В этой статье также показаны случаи, когда этот механизм становится причиной появления неожиданных результатов в отчете. Заключение В данной главе мы познакомились с функциями, полезными при написании запросов. Помните, что все эти функции, за исключением SUMMARIZECOLUMNS и 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 Sales Product Product Subcategory Product Category Расширенная версия Date Все таблицы в модели данных Product, Product Subcategory, Product Category Product Subcategory, Product Category Product Category В модели данных могут присутствовать связи трех разных типов: «один к одному», «один ко многим» и «многие ко многим». И правило здесь прос­ тое: расширение таблиц всегда выполняется к стороне «один». Следующие простые примеры помогут вам лучше разобраться с этой концепцией. Представьте, что у вас есть модель данных, показанная на рис. 14.2. С точки зрения моделирования она – далеко не идеал, но в образовательных целях вполне сгодится. Рис. 14.2 В этой модели данных обе связи («один к одному» и «многие ко многим») двунаправленные Мы намеренно использовали такие сложные связи в этом примере. В таб­ лице Product Category содержится по одной строке для Subcategory, так что для каждой категории в этой таблице будет несколько строк, а столбец ProductCategoryKey будет содержать неуникальные значения. Обе связи при этом помечены в модели данных как двунаправленные. Связь между таблицами Product и Product Details имеет тип «один к одному», а связь между Product и Product Category – «многие ко многим», также именуемый слабой связью (weak relationship). Но правило для всех одно: расширение таблиц выполняется только к стороне «один» вне зависимости от того, с какой стороны оно началось. Соответственно, в представленной модели данных таблицы Product Details и Product будут взаимно расширяться, и их расширенные версии будут абсолютно идентичными. В то же время таблицы Product Category и Product не расширяют друг друга, поскольку обе они находятся в связи на стороне «многие». В таких случаях расширения таблиц не происходит. Когда обе таблицы расположены в связи на стороне «многие», эта связь автоматически становится слабой. Это не значит, что такой способ соединения таблиц обладает какими-то 480 Глава 14 Продвинутые концепции языка DAX слабостями или недостатками – слабые связи, как и двунаправленная фильтрация, работают и выполняют свои задачи, просто они никак не связаны с расширением таблиц. Понимание концепции расширенных таблиц важно само по себе. Кроме того, оно помогает лучше усвоить принципы распространения контекста фильтра в формулах DAX. Если фильтр применен к столбцу, все расширенные таблицы, содержащие в себе этот столбец, также будут отфильтрованы. Это утверждение требует дополнительных пояснений. Мы представили концепцию расширенных таблиц модели данных, показанной на рис. 14.1, в виде диаграммы, изображенной на рис. 14.3. Рис. 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. Рис. 14.4 Подсветка строки с цветом товара позволяет определить, какие таблицы будут отфильтрованы Можно использовать эту же диаграмму и для проверки того, как контекст фильтра распространяется по связям. После наложения фильтра на любой столбец, находящийся в связи на стороне «один», все остальные таблицы, в расширенные версии которых входит этот столбец, также будут отфильтрованы. В этот список включаются все таблицы, находящиеся в соответствующих связях на стороне «многие». Если мыслить категориями расширенных таблиц, будет гораздо легче понять принципы распространения контекста фильтра в целом. Фактически контекст фильтра распространяется на все расширенные таблицы, содержащие фильтруемые столбцы. Рассуждая таким образом, вам больше не нужно учитывать наличие связей между таблицами. Расширение таблиц выполняется по связям. После расширения таблицы связь, по сути, включается в саму расширенную таблицу, и думать о ней больше не нужно. Примечание Заметьте, что фильтр по столбцу с цветом товара также распространяется и на таблицу Date, хотя чисто технически атрибут Color не входит в расширенную версию этой таблицы. Здесь мы имеем дело с эффектом двунаправленной фильтрации. Важно отметить, что фильтр, установленный по полю Color, достигает таблицы Date не через расширенные таблицы. Внутренне DAX запускает специальный код для выполнения фильтрации посредством двунаправленных связей, тогда как фильтрация при помощи расширенных таблиц осуществляется автоматически. Разница при этом скрыта от посторонних глаз, но ее очень важно понимать. То же самое касается и слабых связей: они используют не расширенные таблицы, а свой механизм фильтрации. 482 Глава 14 Продвинутые концепции языка DAX Функция RELATED Ссылаясь на таблицу в DAX, вы всегда имеете дело именно с расширенной таб­ лицей. С точки зрения семантики функция RELATED не выполняет никаких действий. Вместо этого она всего лишь обеспечивает доступ извне к связанным столбцам расширенной таблицы. В следующем примере столбец Unit Price принадлежит расширенной таблице Sales, и функция RELATED просто предоставляет доступ к нему посредством контекста строки в таблице Sales: SUMX ( Sales; Sales[Quantity] * RELATED ( 'Product'[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 ( 'Date'[Calendar Year] ); VAR CurrentYear = 'Date'[Calendar Year] RETURN ROW ( "Sales From A"; COUNTROWS ( FILTER ( SalesA; RELATED ( 'Date'[Calendar Year] ) = CurrentYear ) ); "Sales From 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. Если бы не расширенные таблицы, мы могли бы ожидать одинаковых значений по годам в обоих столбцах. Рис. 14.5 В двух расчетах фильтруются разные годы Использование функции RELATED в вычисляемых столбцах Как мы уже говорили, функция RELATED позволяет получить доступ к связанным столбцам в расширенной таблице. В то же время расширение таблицы происходит в момент определения таблицы, а не обращения к ней. Это приводит к тому, что изменение связей в вычисляемых столбцах становится проб­ лематичным. Взгляните на фрагмент модели данных, изображенный на рис. 14.6, с двумя связями между таблицами Sales и Date. 484 Глава 14 Продвинутые концепции языка DAX Рис. 14.6 Между таблицами Sales и Date есть две связи, но в каждый момент времени активна только одна из них Допустим, нам понадобилось создать вычисляемый столбец в таблице 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 'Date'[Calendar Year Quarter]; 'Date'[Date]; Sales[Delivery Date] -- Возвращает квартал года, -- где значение в столбце Date[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 ( 'Product'[Brand] ); "FilterCol"; CALCULATE ( [Sales Amount]; Sales[Quantity] > 1 ); "FilterTab"; CALCULATE ( [Sales Amount]; FILTER ( Sales; Sales[Quantity] > 1 ) ) ) Результат выполнения этого запроса, показанный на рис. 14.7, удивит многих. В столбце FilterCol показаны правильные значения, тогда как в соседнем FilterTab цифры повторяются и составляют итог по всем брендам. И чтобы понять, как это получилось, необходимо снова включить мышление категориями расширенных таблиц. Рассмотрим в деталях поведение вычисления FilterTab. Аргумент фильтра функции CALCULATE осуществляет итерации по таблице Sales, возвращая при этом строки, в которых количество проданных товаров превышает единицу. Результатом вызова функции FILTER является ограниченный набор строк из таблицы Sales. А мы помним, что в DAX любое обращение к таблице подразуме­ вает ее расширенную версию. Поскольку таблица Sales объединена связью с таб­лицей Product, ее расширенная версия будет также включать все столбцы из таблицы Product. И в числе прочих здесь будет и столбец Product[Brand]. Рис. 14.7 В первом столбце показаны правильные результаты, а во втором значения повторяются и равны общему итогу по столбцу Аргументы фильтра функции CALCULATE вычисляются в исходном кон­ тексте фильтра, игнорируя результат будущего преобразования контекста. Глава 14 Продвинутые концепции языка DAX 487 В то же время фильтр по столбцу Brand вступает в силу уже после операции преобразования контекста. Следовательно, результирующий набор на выходе функции FILTER будет включать значения по всем брендам, соответствующим строкам с количеством проданных товаров, большим единицы. В самом деле, во время осуществления итераций функцией FILTER никаких фильтров на столбец Product[Brand] наложено не было. При создании нового контекста фильтра функция CALCULATE выполняет два последовательных шага: 1) осуществляет преобразование контекста; 2) применяет аргументы фильтра. Таким образом, аргументы фильтра могут переопределить результат преобразования контекста. Поскольку функция ADDCOLUMNS выполняет итерации по брендам, преобразование контекста в каждой строке должно приводить к установке фильтра по конкретному бренду. Но так как в результирующем наборе на выходе функции FILTER также содержится информация о бренде, она будет обладать большим приоритетом по сравнению с итогами преобразования контекста. В результате мы в каждой строке будем видеть итоговое значение по мере Sales Amount для всех транзакций с количеством проданных товаров, превышающим единицу, вне зависимости от выбранного бренда. Использование табличных фильтров существенно затруднено из-за особенностей функционирования расширенных таблиц. Применяя фильтр к таблице, мы, по сути, применяем его к ее расширенной версии, что может приводить к неожиданным побочным эффектам. Отсюда следует золотое правило: пытайтесь избегать применения табличных фильтров, когда это возможно. Работая со столбцами, вы сможете писать более простые и понятные выражения, тогда как фильтрация целых таблиц может доставлять проблемы. Примечание. Рассмотренный здесь пример не может быть с легкостью применен в мерах, определенных в модели данных. Причина в том, что мера всегда неявно вычисляется внутри функции CALCULATE, выполняющей преобразование контекста. Рассмотрим следующую меру: Multiple Sales := CALCULATE ( [Sales Amount]; FILTER ( Sales; Sales[Quantity] > 1 ) ) При использовании меры в отчете возможный запрос на языке DAX мог бы выглядеть примерно так: EVALUATE ADDCOLUMNS ( VALUES ( 'Product'[Brand] ); "FilterTabMeasure"; [Multiple Sales] ) 488 Глава 14 Продвинутые концепции языка DAX Расширение таблиц приведет к такому преобразованию запроса: EVALUATE ADDCOLUMNS ( VALUES ( 'Product'[Brand] ); "FilterTabMeasure"; CALCULATE ( CALCULATE ( [Sales Amount]; FILTER ( Sales; Sales[Quantity] > 1 ) ) ) ) Вызов первой функции CALCULATE приведет к преобразованию контекста, что повлияет на оба аргумента второй функции CALCULATE, включая функцию FILTER. И даже если итоговый результат будет соответствовать нашему исходному столбцу FilterCol, использование табличного фильтра непременно скажется на производительности вычисления. Так что наш вам совет: используйте фильтры по столбцам всегда, когда это возможно. Использование табличных фильтров в мерах В предыдущем разделе мы продемонстрировали первый пример, в котором понимание концепции расширенных таблиц помогло нам правильно интерпретировать результат. Но есть и другие сценарии, в которых могут пригодиться расширенные таблицы. К тому же в предыдущих главах мы не раз использовали эту концепцию, просто не объясняли толком, что к чему. Например, в главе 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, а в матрице отображаются данные по продажам по подкатегориям товаров с указанием процента относительно итогового показателя. Рис. 14.8 В столбце Pct показана доля продаж по подкатегориям относительно итога Поскольку в столбце с процентной долей продаж нам необходимо разделить текущее значение меры Sales Amount на ее значение по всем подкатегориям в рамках выбранной категории, первым (и неверным) решением может быть следующий код: Pct := DIVIDE ( [Sales Amount]; CALCULATE ( [Sales Amount]; ALL ( 'Product Subcategory' ) ) ) Идея была в том, чтобы удалить фильтр со столбца Product Subcategory и оставить по столбцу Category, чтобы получить корректный результат. Однако результат, показанный на рис. 14.9, оказался не таким, как мы ожидали. 490 Глава 14 Продвинутые концепции языка DAX Рис. 14.9 Первая реализация меры Pct дала неверный расчет процентов Проблема в том, что выражение ALL ( 'Product Subcategory' ) удаляет фильтры с расширенной версии таблицы 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 хранятся просто как столбцы в таблице Product, а не как обособленные таблицы. Иначе говоря, мы денормализовали категорию и подкатегорию в таблице товаров. В правильно денормализованной модели данных процесс расширения таблиц между Sales и Product проходит более естественным образом. Как часто это бывает, чем лучше спроектирована модель данных, тем проще будет код на DAX. Введение в активные связи Еще один важный аспект, который стоит учитывать при работе с расширенными таблицами, – это активность связей в модели данных. Обычно в моделях с большим количеством связей между таблицами легко запутаться. И в этом разделе мы покажем пример, когда присутствие множества связей является настоящей проблемой. Представьте, что вам необходимо рассчитать меры Sales Amount и Delivered Amount. Эти меры легко вычислить, воспользовавшись полезной функцией USERELATIONSHIP. В следующем примере показан вариант создания этих мер: 492 Глава 14 Продвинутые концепции языка DAX Sales Amount := SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) Delivered Amount := CALCULATE ( [Sales Amount]; USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[Date] ) ) Результат вычисления этих мер показан на рис. 14.10. Рис. 14.10 При создании мер Sales Amount и Delivered Amount были использованы разные связи А вот вариант написания меры Delivered Amount, который не сработает по причине использования табличного фильтра: Delivered Amount = CALCULATE ( [Sales Amount]; CALCULATETABLE ( Sales; USERELATIONSHIP ( Sales[Delivery Date]; 'Date'[Date] ) ) ) Эта мера выдает пустые значения, что видно по рис. 14.11. Рис. 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 12/31/2007 01/05/2008 Delivery Date 01/07/2008 01/10/2008 Quantity 100 200 Если в отчете выбран 2008 год, функция CALCULATETABLE вернет расширенную версию таблицы Sales, которая, помимо прочих, будет включать столбцы, показанные в табл. 14.3. ТАБЛИЦА 14.3 Результатом вызова функции CALCULATETABLE является расширенная таблица Sales, включающая поле Date[Date], пришедшее по связи с Sales[Delivery Date] Order Date 12/31/2007 01/05/2008 Delivery Date 01/07/2008 01/10/2008 Quantity 100 200 Date 01/07/2008 01/10/2008 При применении в качестве фильтра столбец Date[Date] использует активную связь, то есть связь между столбцами Date[Date] и Sales[Order Date]. В этот момент расширенная таблица Sales выглядит так, как показано в табл. 14.4. ТАБЛИЦА 14.4 Расширенная таблица Sales с использованием связи по умолчанию по столбцу Sales[Order Date] Order Date 12/31/2007 01/05/2008 494 Delivery Date 01/07/2008 01/10/2008 Quantity 100 200 Глава 14 Продвинутые концепции языка DAX Date 12/31/2007 01/05/2008 В результате строки, видимые в табл. 14.3, пытаются отфильтровать таблицу, представленную в табл. 14.4. Но для всех строк значения в столбце Date будут разными, а значит, все строки из результирующей таблицы будут удалены. В общем случае в итоговый набор попадут только строки с одинаковыми значениями в столбцах Sales[Order Date] и Sales[Delivery Date], поскольку значения поля Date[Date] будут идентичными в обеих расширенных таблицах, построенных с использованием разных связей. В этот раз фильтрующий эффект исходил от активной связи. Изменение связи внутри CALCULATE оказывает локальное действие только внутри этой функции, но за ее пределами активной вновь становится связь по умолчанию. Как обычно, отметим, что такое поведение является вполне корректным. Понять его непросто, но это не делает его неправильным. Повторим, что использования табличных фильтров необходимо избегать, когда это возможно. Применяя их, можно получать корректные результаты, а можно погрязнуть в чрезмерно сложном и непредсказуемом сценарии. Более того, меры, использующие фильтры по столбцам вместо табличных фильтров, прекрасно работают и легки для восприятия. Разработчики, не следующие золотому правилу не использовать табличные фильтры, обычно платят дважды: первый раз – пытаясь понять, как работают их фильтры, а второй – осознавая чудовищное падение производительности их расчетов. Разница между расширением таблиц и фильтрацией Как мы уже упоминали ранее, расширение таблиц выполняется по связям от стороны «многие» к стороне «один». Рассмотрим модель данных, представленную на рис. 14.12, в которой мы намеренно сделали все связи двунаправленными. Рис. 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, указанный в функции SUMMARIZE, не присутствует в исходной таблице» говорит сама за себя – расширенная версия таблицы Product Subcategory действительно не содержит столбец Product[Color]. Как и SUMMARIZE, функция RELATED также работает исключительно со столбцами, принадлежащими расширенной таблице. Так же, как и в предыдущем примере, таблицу Date нельзя сгруппировать по столбцам, принадлежащим другим таблицам, несмотря на то что она объединена с Sales двунаправленной связью: EVALUATE SUMMARIZE ( 'Date'; 'Product'[Color] ) Есть только один случай взаимного расширения двух таблиц – когда они объединены посредством связи типа «один к одному». В таком варианте расширенные версии обеих таблиц будут включать друг друга. Причина в том, что связь «один к одному» делает две таблицы семантически идентичными: каждая строка в одной таблице напрямую связана со строкой в другой таблице. 496 Глава 14 Продвинутые концепции языка DAX Следовательно, можно представить эти таблицы как одну общую, разбитую на два набора столбцов. Преобразование контекста в расширенных таблицах Расширение таблиц также оказывает влияние на преобразование контекста. Контекст строки преобразуется в эквивалентный контекст фильтра для всех столбцов, принадлежащих расширенной версии таблицы. Рассмотрим следующий запрос, возвращающий категорию товара, используя при этом две разные техники: функцию RELATED в контексте строки и функцию SELECTEDVALUE с преобразованием контекста: EVALUATE SELECTCOLUMNS ( 'Product'; "Product Key"; 'Product'[ProductKey]; "Product Name"; 'Product'[Product Name]; "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. Рис. 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. Рис. 14.14 В отчете показаны продажи по различным брендам с процентными долями В отчете используется срез для осуществления фильтрации по брендам. В строках показывается сумма продажи по каждому бренду и процент от об498 Глава 14 Продвинутые концепции языка DAX щих продаж по всем выбранным брендам. Формула для вычисления меры с процентами очень проста: Pct := DIVIDE ( [Sales Amount]; CALCULATE ( [Sales Amount]; ALLSELECTED ( 'Product'[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 'Product'[Brand] ) EVALUATE TOPN ( 502; SUMMARIZECOLUMNS ( ROLLUPADDISSUBTOTAL ( 'Product'[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 Рис. 14.15 Результат почти такой же, как в предыдущем отчете, за исключением отсутствия итогов Сделаем несколько важных замечаний по поводу этого запроса: внешняя функция CALCULATETABLE создает контекст фильтра, состоящий из шести брендов; функция ADDCOLUMNS осуществляет итерации по шести брендам, видимым внутри CALCULATETABLE; меры Sales Amount и Pct вычисляются внутри итерации. Перед их вычислением происходит преобразование контекста, так что в контекст фильт­ ра каждой из них включается только один текущий бренд; мера Sales Amount не изменяет контекст фильтра, тогда как мера Pct использует функцию ALLSELECTED для модификации контекста фильтра; после изменения контекста фильтра функцией ALLSELECTED внутри меры Pct измененный контекст будет вновь насчитывать шесть брендов вместо одного текущего. Последний пункт этого перечня наиболее важен для понимания того, что из себя представляет неявный контекст фильтра и как на самом деле DAX использует функцию ALLSELECTED. Ключом к происходящему является итератор ADDCOLUMNS, проходящий по шести выбранным брендам, из которых в результате преобразования контекста видимым остается только один, и функции ALLSELECTED необходимо каким-то образом восстановить контекст фильтра, содержащий все шесть изначальных брендов. Давайте более детально разберемся, как в действительности выполняется запрос. Здесь – на третьем шаге – мы впервые встретимся с неявным контекс­ том фильтра. 1.Функция CALCULATETABLE создает контекст фильтра, состоящий из шес­ти брендов. 2.Функция VALUES возвращает шесть видимых брендов функции ADDCOLUMNS. 3.Будучи итератором, функция ADDCOLUMNS создает неявный контекст фильтра, содержащий результат выполнения функции VALUES, непосредственно перед началом осуществления итераций: • неявный контекст фильтра похож на обычный, за исключением того, что он создается неактивным и никак не влияет на вычисления; • активировать неявный контекст фильтра можно лишь при помощи функции ALLSELECTED, что мы скоро увидим. Пока достаточно будет запомнить, что в неявном контексте фильтра находятся все шесть выбранных брендов; Глава 14 Продвинутые концепции языка DAX 501 • чтобы отличать обычный контекст фильтра от неявного, в данном разделе будем именовать его явным (explicit filter context). 4.Во время итерации преобразование контекста выполняется для отдельно взятой строки. Таким образом, в результате этого преобразования создается явный контекст фильтра, содержащий единственный бренд. 5.Когда при вычислении меры Pct движок DAX встречает функцию ALLSELECTED, происходит следующее: функция ALLSELECTED восстанавливает последний неявный контекст фильтра по столбцу или таблице, переданной в качестве параметра, либо по всем столбцам, если функция ALLSELECTED вызвана без параметров (поведение функции ALLSELECTED без параметров описывается в следующем разделе). Поскольку в последнем неявном контексте фильтра содержится шесть выбранных брендов, все они вновь становятся видимыми. Этот несложный пример позволил нам объяснить, что из себя представляет неявный контекст фильтра. Предыдущий запрос демонстрирует, как функция ALLSELECTED использует неявный контекст фильтра для извлечения контекста фильтра за пределами текущего элемента визуализации. Заметьте, что в описании процесса мы ни разу не упомянули какие-либо визуализации, характерные для Power BI. Фактически движок DAX ничего не знает о том, какой именно элемент используется для отображения информации. Он просто принимает запрос DAX, ничего более. Чаще всего функция ALLSELECTED извлекает правильный контекст фильтра. Дело в том, что все элементы визуализации в Power BI и большинство элементов отображения в других инструментах генерируют похожие запросы. Эти автоматически сгенерированные запросы всегда включают в себя итерационную функцию верхнего уровня, создающую неявный контекст фильтра по отображаемым элементам. Именно поэтому создается ощущение, что функция ALLSELECTED восстанавливает контекст фильтра, находящийся за пределами текущей визуализации. Теперь, когда вы лучше узнали предназначение и принципы работы функции ALLSELECTED, пришло время рассказать об условиях, необходимых для ее корректного использования: запрос должен содержать итерационную функцию. Без итератора не будет создан неявный контекст фильтра, а значит, функция ALLSELECTED отработает неправильно; если перед вызовом функции ALLSELECTED стоит несколько итераторов, она восстановит только последний из созданных контекстов фильтра. Иными словами, использование функции ALLSELECTED внутри итератора, выполняющегося в коде меры, с большой долей вероятности будет приводить к непредсказуемым результатам, поскольку мера почти всегда вычисляется в рамках другого итератора в запросе, созданном клиентским инструментом; если столбцы, переданные функции ALLSELECTED в качестве парамет­ ров, не содержатся в неявном контексте фильтра, функция ничего не будет делать. Как видите, функция ALLSELECTED оказалась далеко не такой простой, как представлялось. Разработчики в основном предпочитают использовать ее для 502 Глава 14 Продвинутые концепции языка DAX извлечения внешнего контекста фильтра в визуализации. Ранее в данной книге мы уже использовали функцию ALLSELECTED с этой целью, но каждый раз дважды проверяли, чтобы были выполнены все необходимые условия для ее корректной работы, пусть и не объясняли досконально, что происходит на самом деле. В целом семантика функции ALLSELECTED напрямую связана с извлечением неявных контекстов фильтра, и, по счастливой случайности (на самом деле именно так и было задумано), ее применение ведет к извлечению контекста фильтра, созданного за пределами текущего элемента визуализации. Опытные разработчики прекрасно понимают, как работает функция ALLSELECTED, и используют ее только в тех сценариях, где это допустимо. Злоупотребление данной функцией в условиях, непригодных для ее корректного использования, может привести к нежелательным результатам, и винить в этом нужно будет разработчика, а не какую-то функцию ALLSELECTED… Золотое правило использования функции ALLSELECTED звучит так: функция ALLSELECTED может быть использована для извлечения внешнего контекста фильтра только в рамках меры, представленной в матрице или другом элементе визуализации. Разработчик может не ждать корректного поведения от меры с ALLSELECTED внутри, использованной в рамках итерационной функции, что мы продемонстрируем в следующих разделах. Именно поэтому мы как разработчики DAX всегда предпочитаем следовать одному простому правилу: если в коде меры содержится функция ALLSELECTED, эту меру ни в коем случае не стоит вызывать из другой меры. Дело в том, что в цепочку вызовов мер легко может закрасться итерационная функция, в рамках которой может быть вызвана мера, включающая в себя функцию ALLSELECTED. ALLSELECTED возвращает строки из итераций Чтобы еще лучше продемонстрировать поведение функции ALLSELECTED, немного изменим предыдущий запрос. Вместо того чтобы осуществлять итерации по выражению VALUES ( Product[Brand] ), пройдемся при помощи функции ADDCOLUMNS по ALL ( Product[Brand] ): EVALUATE VAR Brands = FILTER ( ALL ( 'Product'[Brand] ); 'Product'[Brand] IN { "Adventure Works"; "Contoso"; "Fabrikam"; "Litware"; "Northwind Traders"; "Proseware" } ) RETURN CALCULATETABLE ( Глава 14 Продвинутые концепции языка DAX 503 ADDCOLUMNS ( ALL ( 'Product'[Brand] ); "Sales_Amount"; [Sales Amount]; "Pct"; [Pct] ); Brands ) В этом обновленном сценарии неявный контекст фильтра, созданный функцией ADDCOLUMNS до начала итераций, содержит все имеющиеся бренды, а не только выбранные. Таким образом, во время вычисления меры Pct функция ALLSELECTED восстановит этот неявный контекст фильтра, тем самым сделав видимыми все бренды. Результат, показанный на рис. 14.16, отличается от того, что мы видели на рис. 14.15. Рис. 14.16 Функция ALLSELECTED восстанавливает значения из текущего цикла итераций, а не предыдущий контекст фильтра Как видите, все бренды, как и ожидалось, стали видимыми, но при этом цифры по ним отличаются, несмотря на то что наши изменения вычислений не касались. Поведение функции ALLSELECTED в этом сценарии вполне корректное. Разработчикам может показаться, что она ведет себя несколько неожиданно, поскольку при вычислении меры Pct полностью игнорируется контекст фильт­ ра, созданный в переменной Brands. Но функция ALLSELECTED делает ровно то, что ей предписано, – возвращает последний неявный контекст фильтра, а в нашей версии кода в этом контексте будут находиться все бренды, а не только выбранные. Функция ADDCOLUMNS создает неявный контекст фильтра со строками, по которым будут осуществляться итерации, и здесь это все бренды, присутствующие в модели данных. Если вам необходимо восстановить предыдущий контекст фильтра, одной функции ALLSELECTED будет недостаточно. Вам придется использовать модификатор KEEPFILTERS функции CALCULATE, который призван восстанавливать предыдущий контекст фильтра. Интересно, какой результат выдаст формула с использованием этого модификатора: 504 Глава 14 Продвинутые концепции языка DAX EVALUATE VAR Brands = FILTER ( ALL ( 'Product'[Brand] ); 'Product'[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. Рис. 14.17 Функция ALLSELECTED с использованием модификатора KEEPFILTERS дала совсем другой результат с большим количеством пустых ячеек Глава 14 Продвинутые концепции языка DAX 505 Применение функции ALLSELECTED без параметров Как ясно из названия, ALLSELECTED принадлежит к группе функций ALL*. А значит, при использовании в качестве модификатора функции CALCULATE она будет удалять ранее установленные фильтры. Если столбец, переданный функции в качестве параметра, присутствует в каком-либо из неявных контекстов фильтра, будет произведено восстановление последнего из неявных контекстов по этому столбцу. Если неявных контекстов фильтра нет, функция ALLSELECTED не будет выполнять никаких действий. Будучи использованной в качестве модификатора CALCULATE, функция ALLSELECTED, как и ALL, также может не принимать параметров. В этом случае она восстановит последний неявный контекст фильтра по любому столбцу. Но это произойдет только в том случае, если столбец присутствует в каком-либо неявном контексте. Если столбец отфильтрован только при помощи явных фильтров, ситуация с фильтрацией по нему не изменится. Функции группы ALL* По причине повышенной сложности функций группы ALL* в данном разделе мы представим вам сводный обзор по ним. Все функции из этой группы ведут себя по-разному, и для овладения ими в полной мере потребуется немало опыта. Здесь же мы познакомим вас с основными концепциями применения этих полезных функций. В группу ALL* входят следующие функции: ALL, ALLEXCEPT, ALLNOBLANKROW, ALLCROSSFILTERED и ALLSELECTED. Все перечисленные функции могут быть использованы как в качестве обычных табличных функций, так и в роли модификаторов функции CALCULATE. При этом в первом случае понять их поведение бывает намного легче. Будучи использованными в качестве модификаторов CALCULATE, эти функции могут производить неожиданные результаты, поскольку в процессе выполнения они удаляют ранее наложенные фильтры. В табл. 14.5 мы свели воедино краткое описание функций из группы ALL*. В оставшейся части раздела мы поговорим о каждой из них более подробно. Колонка «Табличная функция» в табл. 14.5 относится к сценариям, в которых функции группы ALL* используются внутри выражений DAX, тогда как в колонке «Модификатор функции CALCULATE» приведены принципы их работы при использовании в качестве функций верхнего уровня в аргументах фильтра функции CALCULATE. Еще одно существенное различие между двумя типами использования этих функций заключается в том, что когда вы извлекаете результат их работы через инструкцию EVALUATE, в него включаются только столбцы из исходной таб­лицы, а не из ее расширенной версии. При этом все внутренние вычисления, включая преобразование контекста, всегда используют соответствующие расширенные таблицы. В следующих примерах мы покажем разные варианты использования функции ALL. Эти же концепции могут быть применены к любой функции из группы ALL*. 506 Глава 14 Продвинутые концепции языка DAX ТАБЛИЦА 14.5 Сводный обзор функций из группы ALL* Функция ALL Табличная функция Возвращает уникальные значения из столбца или таблицы ALLEXCEPT Возвращает уникальные значения из таблицы, игнорируя при этом фильтры по некоторым столбцам расширенной таблицы Возвращает уникальные значения из столбца или таблицы, игнорируя пустые строки, добавленные для недействительных связей ALLNOBLANKROW Модификатор функции CALCULATE Удаляет любые ранее наложенные фильтры со столбцов или расширенных таблиц. Никогда не добавляет фильтры, а только удаляет их Удаляет ранее наложенные фильтры с расширенной таблицы, за исключением столбцов (или таблиц), переданных в качестве аргументов Удаляет любые ранее наложенные фильтры со столбцов или расширенных таблиц. Помимо этого, добавляет фильтр, удаляющий пустые строки. Таким образом, даже если фильтров в таблице нет, функция добавляет один фильтр к контексту 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 Amount] > 100 -- Преобразование контекста все равно -- выполняется по расширенной таблице ) В следующем примере применим функцию ALL в качестве модификатора функции CALCULATE для удаления любых фильтров с расширенной версии таб­лицы Sales: CALCULATE ( [Sales Amount]; ALL ( Sales ) ) -- ALL – модификатор CALCULATE Последний пример, хоть и будет очень похож на предыдущий, на самом деле сильно отличается. Здесь функция ALL используется не в качестве модификатора CALCULATE – вместо этого она применяется как аргумент функции FILTER. В этом случае ALL будет вести себя как обычная табличная функция, возвращая целую расширенную таблицу Sales. CALCULATE ( [Sales Amount]; FILTER ( ALL ( Sales ); Sales[Quantity] > 0 ) -- ALL – табличная функция -- Контекст фильтра все равно принимает -- в качестве фильтра расширенную таблицу ) Далее мы приведем более детальное описание функций, входящих в группу ALL*. Все они выглядят очень просто, но в действительности не так прос­ ты в использовании. В большинстве случаев они будут вести себя так, как вы и предполагаете, но в пограничных ситуациях могут выдавать неожиданные результаты. Бывает непросто запомнить все эти правила и особенности их поведения. Надеемся, табл. 14.5 еще не раз пригодится вам при использовании функций из группы ALL*. Функция ALL В качестве табличной функции ALL применять очень просто. Она возвращает все уникальные значения по одному или нескольким столбцам либо по всей таблице. При использовании внутри функции CALCULATE в качестве модификатора ALL начинает вести себя как гипотетическая функция REMOVEFILTER. Если какой-либо из столбцов включен в фильтр, функция удалит эту фильтрацию. Важно пояснить при этом, что если столбец отфильтрован при помощи перекрестной фильтрации, функция ALL его не затронет. Данная функция удаляет только фильтры, установленные напрямую. Таким образом, использование выражения ALL ( Product[Color] ) в качестве модификатора функции CALCULATE может оставить активным кросс-фильтр на столбце Product[Color], если установлен фильтр на другом столбце таблицы Product. Функция ALL оперирует расширенными таблицами. Именно поэтому выражение ALL ( Sales ) удалит фильтры со всех таблиц в модели данных: расширенная таблица Sales, как мы 508 Глава 14 Продвинутые концепции языка DAX уже говорили, включает в себя всю модель данных. Применение функции ALL без аргументов удалит все фильтры со всей модели данных. Функция ALLEXCEPT Будучи использованной в качестве табличной функции, ALLEXCEPT возвращает все уникальные значения столбцов из таблицы, за исключением столбцов, указанных в качестве аргументов. При использовании функции в качестве фильтра в результат будет включена вся расширенная таблица. Применение функции ALLEXCEPT как аргумента фильтра в CALCULATE приведет к аналогичному поведению функции ALL, за исключением того, что фильтры не будут удалены со столбцов, переданных в качестве аргументов. Важно помнить, что использование функции ALLEXCEPT и связки ALL/VALUES не равносильно. Функция ALLEXCEPT просто удаляет фильтры, тогда как в сочетании ALL/VALUES функция ALL удаляет фильтры, а 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, сохраняют свою привязку данных в случае, если их выражение включает в себя только ссылку на столбец. В остальных случаях будет создана новая привязка. В следующем примере мы попытались сформировать таблицу, в которой для каждого цвета должна быть выведена общая сумма продаж по товарам этого цвета. Однако поскольку столбец C2 был создан функцией ADDCOLUMNS, его привязка данных не соответствует столбцу Product[Color] из модели данных, несмотря на одинаковое содержимое. Обратите внимание, что в нашем примере мы действовали пошагово: сначала создали столбец C2, а затем выбрали из таблицы только его. Если бы в итоговой таблице оставались и другие столбцы, результат был бы совсем иным. DEFINE MEASURE Sales[Sales Amount] = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) EVALUATE VAR NonBlueColors = FILTER ( ALL ( 'Product'[Color] ); 'Product'[Color] <> "Blue" 510 Глава 14 Продвинутые концепции языка DAX ) VAR AddC2 = ADDCOLUMNS ( NonBlueColors; "[C2]"; 'Product'[Color] ) VAR SelectOnlyC2 = SELECTCOLUMNS ( AddC2; "C2"; [C2] ) VAR Result = ADDCOLUMNS ( SelectOnlyC2; "Sales Amount"; [Sales Amount] ) RETURN Result ORDER BY [C2] В итоге мы получили столбец с цветами, но мера Sales Amount для каждого из них выдает одинаковую сумму, равную итоговым продажам по таблице Sales. Вывод запроса показан на рис. 14.18. Рис. 14.18 Привязка данных нового столбца C2 не совпадает с привязкой столбца Product[Color] из модели Для изменения привязки данных можно использовать функцию TREATAS. В следующем варианте запроса мы устанавливаем привязку данных нового столбца к столбцу Product[Color] в модели, что позволяет функции ADDCOLUMNS рассчитывать меру Sales Amount с использованием преобразования контекста по столбцу Color: DEFINE MEASURE Sales[Sales Amount] = SUMX ( Sales; Sales[Quantity] * Sales[Net Price] ) EVALUATE VAR NonBlueColors = FILTER ( ALL ( 'Product'[Color] ); 'Product'[Color] <> "Blue" ) Глава 14 Продвинутые концепции языка DAX 511 VAR AddC2 = ADDCOLUMNS ( NonBlueColors; "[C2]"; 'Product'[Color] ) VAR SelectOnlyC2 = SELECTCOLUMNS ( AddC2; "C2"; [C2] ) VAR TreatAsColor = TREATAS ( SelectOnlyC2; 'Product'[Color] ) VAR Result = ADDCOLUMNS ( TreatAsColor; "Sales Amount"; [Sales Amount] ) RETURN Result ORDER BY 'Product'[Color] В качестве побочного эффекта функция TREATAS также переименовала столбец C2 в Color, что мы учли в инструкции ORDER BY. Результат выполнения исправленного запроса показан на рис. 14.19. Рис. 14.19 Теперь привязка данных в столбце Color соответствует привязке столбца Product[Color] Заключение В данной главе мы изучили две важные концепции: расширенные таблицы и неявные контексты фильтра. Расширенные таблицы являются фундаментом 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 невозможно создать связь: для этого нам потребовалось бы объединить таблицы по двум столбцам, а DAX позволяет создавать связи только по одному полю. Первым способом решения задачи может быть создание сопоставимых вычисляемых столбцов в обеих таблицах, сочетающих значения из двух других столбцов: Sales[DiscountKey] = COMBINEVALUES ( "-", Sales[Order Date], Sales[ProductKey] ) Глава 15 Углубленное изучение связей 515 Discounts[DiscountKey] = COMBINEVALUES( "-", Discounts[Date], Discounts[ProductKey] ) В этих вычисляемых столбцах мы прибегли к помощи функции COMBINEVALUES. Функция COMBINEVALUES принимает в качестве параметров разделитель и список выражений, которые будут объединены вместе как строки с указанным разделителем. Для выполнения этой операции можно было воспользоваться обычной конкатенацией строк, но функция COMBINEVALUES обладает определенными преимуществами. Эта функция оказывается чрезвычайно полезной при создании связей на основании вычисляемых столбцов, если в модели данных используется режим DirectQuery. Функция COMBINEVALUES предполагает – но не утверждает, – что если входные данные различны, то и строки на выходе будут отличаться. С учетом этого предположения использование функции COMBINEVALUES при создании вычисляемых столбцов для последующего объединения таблиц в режиме DirectQuery позволяет генерировать наиболее оптимальные условия во время выполнения запроса. Примечание Более подробно о методах оптимизации COMBINEVALUES совместно с DirectQuery читайте по адресу: https://www.sqlbi.com/articles/using-combinevalues-to-optimize-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. Рис. 15.2 Таблица Configuration для хранения диапазонов цен на товары Как и в предыдущем примере, мы не можем создать прямую связь между таб­лицами Sales и Configuration. Причина в том, что в конфигурационной таб­ лице ключ зависит от целого диапазона значений, а в DAX связи на основе целого спектра значений не поддерживаются. Мы могли бы вычислить значение ключа в таблице Sales при помощи вложенных операторов IF, но в этом случае нам пришлось бы включать значения из конфигурационной таблицы прямо в формулу, как показано ниже, чего нам, конечно, хотелось бы избежать: Глава 15 Углубленное изучение связей 517 Sales[PriceRangeKey] SWITCH ( TRUE (), Sales[Net Price] Sales[Net Price] Sales[Net Price] Sales[Net Price] 5 ) = <= <= <= <= 10; 30; 80; 150; 1; 2; 3; 4; Приемлемое решение не должно основываться на указании конкретных ценовых диапазонов внутри формулы. Вместо этого код должен быть напрямую связан с конфигурационной таблицей, чтобы при ее изменении обновлялась вся модель. В таком случае лучше всего будет денормализовать в таблице 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. Рис. 15.3 Теперь мы легко можем делать срезы по ценовому диапазону товаров Замена функции VALUES на DISTINCT позволила нам избавиться от ошибки, связанной с циклической зависимостью. Механизмы, лежащие в основе этих Глава 15 Углубленное изучение связей 519 зависимостей, достаточно сложны. В следующем разделе мы подробно расскажем о причинах, приводящих к возникновению циклических зависимостей при создании связей между вычисляемыми столбцами и таблицами, а также поясним, почему использование функции DISTINCT решило проблему. Циклические зависимости в вычисляемых физических связях В предыдущем примере мы создали вычисляемый столбец, на основании которого было выполнено объединение двух таблиц. Это привело к появлению ошибки, связанной с циклической зависимостью. Работая с вычисляемыми физическими связями, вы будете достаточно часто сталкиваться с такого рода ошибками, так что нелишним будет потратить немного времени, чтобы разобраться с источником их возникновения. А заодно научитесь их избегать. Давайте восстановим формулу вычисляемого столбца в сокращенном виде: Sales[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 и PriceRanges на основании столбца Sales[PriceRangeKey] в таблице PriceRanges могут как присутствовать пустые строки, так и нет – в зависимости от значений столбца Sales[PriceRangeKey]. Иначе говоря, когда значение в столбце Sales[PriceRangeKey] изменяется, содержимое таблицы PriceRanges также может поменяться. В то же время при изменении таблицы PriceRanges может потребоваться обновление столбца Sales[PriceRangeKey], даже если добавленная пустая строка и не будет использоваться. Именно это и является причиной обнаружения движком DAX 520 Глава 15 Углубленное изучение связей циклической зависимости. Человеческому глазу трудно усмотреть здесь несоответствия, но алгоритмы DAX легко их обнаруживают. Если бы инженеры-разработчики языка DAX не позаботились об этой проб­ леме, мы бы не смогли создавать связи на основе вычисляемых столбцов. Но они снабдили движок особой логикой, которая может пригодиться в подобных случаях. Результатом этих усилий стали два вида зависимостей, которые присутствуют в DAX: зависимость по формуле (formula dependency) и зависимость по пустым строкам (blank row dependency). Для нашего примера справедливо следующее: столбец Sales[PriceRangeKey] зависит от таблицы 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 ( MAX ( Customer[YearlyIncome] ); Customer[Education] = "High school" ) Глава 15 Углубленное изучение связей 521 На первый взгляд, эта формула никак не зависит от наличия пустых строк в таблице Customer. На самом же деле это не так. Причина в том, что DAX автоматически расширяет синтаксис функции CALCULATE, если аргументы фильт­ ра в ней указаны в компактной форме, следующим образом: = CALCULATE ( MAX ( Customer[YearlyIncome] ); FILTER ( ALL ( Customer[Education] ); Customer[Education] = "High school" ) ) Выделенная жирным шрифтом строка содержит функцию ALL, что создает зависимость по пустым строкам. Такие моменты бывает непросто обнаружить, но если вы понимаете базовые принципы образования циклических зависимостей, то с легкостью сможете устранить причины такого поведения. Предыдущий пример может быть переписан следующим образом: = CALCULATE ( MAX ( Customer[YearlyIncome] ); FILTER ( ALLNOBLANKROW ( Customer[Education] ); Customer[Education] = "High school" ) ) Использовуя функцию ALLNOBLANKROW явным образом, мы избавились от зависимости по пустым строкам в таблице Customer. Стоит отметить, что в коде зачастую прячутся функции, полагающиеся на пустые строки. Для примера рассмотрим фрагмент кода из предыдущего раздела, где мы создавали вычисляемую физическую связь на основании диапазона цен. Вот оригинальная формула: Sales[PriceRangeKey] = CALCULATE ( VALUES ( PriceRanges[PriceRangeKey] ); FILTER ( PriceRanges; AND ( PriceRanges[MinPrice] <= Sales[Net Price]; PriceRanges[MaxPrice] > 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 relationship) имитирует связь физическую. С точки зрения пользователя такая связь ничем не отличается от обычной, за исключением того, что зрительно в модели данных она не присутствует. А поскольку связи в модели нет, ответственность за распространение фильтров от одной таблице к другой ложится на разработчика DAX. Распространение фильтров в DAX Одной из самых мощных особенностей DAX является возможность распространять установленные фильтры между таблицами по связям. Но бывают ситуации, когда физическую связь между двумя сущностями наладить очень трудно, если не невозможно. В этом случае на выручку приходят выражения DAX, позволяющие разными способами имитировать физически построенные связи. В данном разделе мы познакомимся с различными техниками распространения фильтров на примере идеально подходящего для этого сценария. Компания Contoso размещает свою рекламу в газетах и интернете, выбирая для этого один или несколько брендов каждый месяц. Информация о рекламируемых брендах хранится в таблице Advertised Brands с указанием года и месяца. Фрагмент этой таблицы вы можете видеть на рис. 15.4. Рис. 15.4 В таблице содержится по одной строке для каждого бренда по тем месяцам, когда эти бренды входят в рекламную кампанию Сразу отметим, что в этой таблице нет ключевого столбца с уникальными значениями. Несмотря на то что все строки в ней являются уникальными, в каждом столбце присутствует масса дубликатов. Следовательно, в связи эта таблица никак не может находиться на стороне «один». И этот факт обретет немалую важность, когда мы подробнее опишем задачу. А задача состоит в том, чтобы создать меру для подсчета суммы продаж по товарам только за период времени, когда они были включены в рекламную 524 Глава 15 Углубленное изучение связей кампанию. Чтобы решить этот сценарий, необходимо для начала определить, был ли тот или иной бренд включен в рекламную кампанию в конкретном месяце. Если бы мы могли создать связь между таблицами Sales и Advertised Brands, написать код не составило бы труда. К сожалению, связь здесь наладить не так-то просто (так было задумано в образовательных целях). Одним из возможных решений может быть создание в обеих таблицах вычисляемого столбца, сочетающего в себе год, месяц и бренд. Таким образом, мы могли бы последовать технике создания связи на основании нескольких столбцов, описанной в предыдущем разделе. Но в данном случае есть и другие способы решения задачи – без необходимости создавать вычисляемые поля. Одно из решений (далеко не самое оптимальное) включает в себя использование итерационных функций. Можно пройти по таблице Sales построчно и проверить, входил ли бренд текущего товара в текущем месяце в рекламную кампанию. Таким образом, следующая мера решит нашу проблему, пусть и далеко не самым эффективным способом: Advertised Brand Sales := SUMX ( FILTER ( Sales; CONTAINS ( 'Advertised Brands'; '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 Рис. 15.5 Мера Advertised Brand Sales показывает сумму продаж по брендам, входившим на момент продажи в рекламную кампанию Наиболее оптимальным способом решения этой задачи будет использование функции CALCULATE для распространения фильтров с таблицы Advertised 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 все данные четко привязаны к модели, так что мы можем использовать ее в качестве фильтра, чтобы видимыми остались бренды, годы и месяцы из рекламных кампаний. Стоит отдельно отметить, что нам пришлось использовать в функции CALCULATE модификатор KEEPFILTERS. Если этого не сделать, функция CALCULATE переопределит фильтры по бренду, году и месяцу, а нам это не нужно. В таблице 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 нам не понадобилось, поскольку на выходе первой функции SUMMARIZE и так будут только видимые бренды и месяцы; функция 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, присутствующей здесь в качестве аргумента фильтра CALCULATE, используется та же техника с применением функции CONTAINS, что и в первом примере. Но на этот раз итерации выполняются не по всей таблице Sales, а по результирующему набору, полученному из функции SUMMARIZE. Как мы объясняли в главе 14, использовать таблицу Sales как аргумент фильтра функции CALCULATE было бы неправильно из-за расширенных таблиц. Лучше подвергать фильтру только три столбца. На выходе функции SUMMARIZE данные обладают корректной привязкой к столбцам в модели. Кроме того, нам нет необходимости использовать модификатор KEEPFILTERS, поскольку на выходе функции SUMMARIZE и так будут только выбранные значения для бренда, года и месяца. С точки зрения производительности этот вариант худший из трех, хотя он и быстрее изначальной меры с функцией SUMX. Также стоит отметить, что у всех техник с использованием функции CALCULATE есть одно общее пре­ имущество, состоящее в отсутствии необходимости дублировать бизнес-логику вычисления в мере Sales Amount, чем не могла похвастаться наша первая попытка с итератором SUMX. Динамическая сегментация с использованием виртуальных связей Во всех предыдущих примерах мы использовали код на DAX для расчета значений и распространения фильтров в отсутствие связей, хотя вполне могли решить задачу путем создания физической связи. Но бывают случаи, когда налаГлава 15 Углубленное изучение связей 529 дить физическую связь просто не представляется возможным, как в примере, который мы рассмотрим в данном разделе. Задачу, связанную со статической сегментацией данных, которую мы рассмотрели ранее в данной главе, мы решили при помощи создания виртуальной связи. В случае со статической сегментацией распределение продаж по категориям выполняется посредством вычисляемого столбца. Если говорить о динамической сегментации (dynamic segmentation), то здесь классификация данных выполняется «на лету» – она основывается не на вычисляемом столбце, как в примере с ценовыми группами, а на динамическом вычислении вроде суммы продаж. Для выполнения динамической сегментации у нас должны быть определенные критерии для фильтра. В нашем примере мы будем выполнять фильтрацию покупателей на основании значения меры Sales Amount. В данном примере конфигурационная таблица будет содержать наименования сегментов и их границы, как показано на рис. 15.6. Рис. 15.6 Конфигурационная таблица для выполнения динамической сегментации Если клиент совершил покупки на сумму от 75 до 100 долларов, мы отнесем его к сегменту Low (Низкий), как видно из представленной конфигурационной таблицы. Важный нюанс заключается в том, что значение меры, которое мы сверяем с нашей таблицей, напрямую зависит от пользовательского выбора в отчете. Например, если пользователь выберет в срезе один цвет, то динамическая сегментация покупателей будет выполнена исключительно по товарам выбранного цвета. А поскольку у нас выполняются динамические расчеты, физическую связь между таблицами мы построить просто не можем. Взгляните на отчет, показанный на рис. 15.7, на котором продемонстрировано распределение покупателей по сегментам с разбивкой по годам, причем учет ведется исключительно по выбранным категориям товаров. Рис. 15.7 Каждый покупатель входит в свой сегмент, при этом в разные годы сегмент у него может быть разным Принадлежность покупателя к тому или иному сегменту может меняться из года в год. Например, в 2008 году он может попасть в категорию Very Low 530 Глава 15 Углубленное изучение связей (Очень низкий), а годом позже подняться до уровня Medium (Средний). Более того, при изменении выбора в фильтре по категориям товаров будут меняться и данные в отчете. Фактически у пользователя должно сложиться ощущение, что связь в модели данных на самом деле существует и каждый покупатель принадлежит свое­ му сегменту. При этом физическую связь мы создать здесь никак не можем. И причина как раз в том, что один покупатель может быть отнесен к разным сегментам в разных ячейках отчета. В этом случае поставленную задачу можно решить только при помощи DAX. Нам необходимо написать меру, подсчитывающую количество покупателей, принадлежащих тому или иному сегменту. Иными словами, мера вычисляет, сколько покупателей входит в каждый сегмент, основываясь на данных из текущего контекста фильтра. Формула выглядит довольно просто, но при этом нуждается в определенных пояснениях: CustInSegment := SUMX ( Segments; COUNTROWS ( FILTER ( Customer; VAR SalesOfCustomer = [Sales Amount] VAR IsCustomerInSegment = AND ( SalesOfCustomer > Segments[MinSale]; SalesOfCustomer <= Segments[MaxSale] ) RETURN IsCustomerInSegment ) ) ) За исключением итогов, все строки отчета, показанного на рис. 15.7, выполняются в контексте фильтра, включающем один конкретный сегмент. Таким образом, функция SUMX будет проходить только по одной строке. Использование этой функции облегчает извлечение границ сегмента из конфигурационной таблицы (MinSale и MaxSale) и позволяет правильно рассчитать значения в присутствии фильтров. Внутри итератора SUMX функция COUNTROWS считает клиентов с суммой покупок (для повышения производительности, сохраненной в переменной SalesOfCustomer), входящей в интервал текущего сегмента. Полученная мера будет аддитивной по сегментам и покупателям и неаддитивной по другим фильтрам. Вы могли заметить, что в итоговой колонке по первой строке отчета стоит значение 213, хотя сумма значений по строке дает 214. Причина в том, что в итоговой ячейке мера подсчитывает количест­ во покупателей из этого сегмента по трем годам. Похоже, один из учтенных клиентов совершил за три года столько покупок, что в итоге был переведен в следующую категорию. И хотя такое поведение меры может быть не слишком понятно интуитивно, на самом деле неаддитивность вычислений по времени может оказаться очень Глава 15 Углубленное изучение связей 531 полезной. Чтобы сделать меру аддитивной по годам, необходимо изменить формулу, добавив к расчетам временную характеристику. Например, следующая мера будет аддитивной по оси времени. Но при этом она потеряет в гибкости, поскольку теперь ее не получится использовать без включения в отчет измерения с годами: CustInSegment 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, значения по строкам теперь корректно суммируются, хотя общий итог (сумма по всем годам и сегментам) по-прежнему может показывать неправильные цифры. Рис. 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). В таких моделях разрешено сочетать данные в режиме VertiPaq (копия данных из источника предварительно загружается и кешируется в памяти) и в режиме DirectQuery (обращение к источнику данных происходит только в момент запроса). Подробно режимы DirectQuery и VertiPaq будут описаны в главе 17. Единая модель данных может содержать какое-то количество таблиц, сохраненных в режиме VertiPaq, и какое-то – в режиме DirectQuery. Более того, таблицы DirectQuery могут происходить из разных источников, образуя так называемые острова данных (data island) DirectQuery. Чтобы лучше различать данные, хранящиеся в режимах VertiPaq и DirectQuery, мы будем говорить о них как о континенте (continent) (VertiPaq) и островах (источники данных DirectQuery), как показано на рис. 15.9. DirectQuery Island DirectQuery Island VertiPaq Continent Рис. 15.9 В составной модели данных таблицы распределены по островам Глава 15 Углубленное изучение связей 533 Хранилище VertiPaq представляет собой не что иное, как еще один остров данных. Мы называем его континентом только потому, что этот остров наиболее востребован при работе. Связь объединяет две таблицы. Если таблицы принадлежат одному острову, связь между ними будет именоваться внутриостровной, иначе – межостровной. Последние всегда представляют собой слабые связи. Таким образом, расширенные таблицы никогда не пересекают острова. Связи между таблицами характеризуются кратностью (cardinality), которая бывает трех типов. И разница между ними есть как в техническом плане, так и в области семантики. Здесь мы не будем излишне глубоко вдаваться во все нюансы этих разновидностей, поскольку для этого потребовалось бы сделать ряд отступлений, что не входит в наши планы. Вместо этого мы остановимся на технических подробностях физических связей и посмотрим, какое влияние они оказывают на код DAX. Всего существует три типа кратности связи: кратность связи «один ко многим»: это наиболее распространенный тип кратности связи. На стороне «один» в связи располагается столбец с уникальными значениями, тогда как на стороне «многие» могут быть (и часто бывают) дубликаты. В некоторых клиентских инструментах делаются различия между связями «один ко многим» и «многие к одному». Но по своей сути это одно и то же. Все зависит от порядка расположения таблиц: связь «один ко многим» между таблицами Product и Sales можно представить и как связь «многие к одному» между Sales и Product; кратность связи «один к одному»: этот тип кратности связи встречается довольно редко. Здесь на обеих сторонах связи должны быть столбцы с уникальными значениями. Более точным названием такого типа кратности связи было бы «ноль или один к нулю или одному», поскольку присутствие записи в одной таблице не обязательно должно означать присутствие соответствующей строки в другой; кратность связи «многие ко многим»: в этом случае на обеих сторонах связи столбцы могут содержать дублирующиеся значения. Эта кратность была представлена в 2018 году, и, к сожалению, для нее было выбрано не самое удачное название. В сущности, в теории моделирования данных термин «многие ко многим» относится к другой реализации, использующей сочетание связей «один ко многим» и «многие к одному». Важно понимать, что в этом случае мы говорим не о связи «многие ко многим», а именно о кратности «многие ко многим». Чтобы избежать неоднозначности трактовки и не путаться с канонической терминологией моделирования данных, в которой связь «многие ко многим» означает совсем другую реализацию, мы будем использовать следующие абб­ ревиатуры для описания кратности связи: «один ко многим»: мы будем именовать такую кратность SMR, от SingleMany-Relationship (один–многие–связь); «один к одному»: здесь мы остановимся на аббревиатуре SSR, от SingleSingle-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 по ней дает пустоту, а по умолчанию в матрицах не показываются строки с пустыми значениями в выведенных в отчет мерах. Рис. 15.10 В срезе CountryRegion содержатся страны с нулевыми продажами Если для вас важно, чтобы срезы были синхронизированы между собой, можно включить двунаправленную кросс-фильтрацию между таблицами Customer и Sales, что приведет к образованию модели данных, показанной на рис. 15.11. Рис. 15.11 Кросс-фильтрация для связи между таблицами Customer и Sales стала двунаправленной Установка двунаправленной кросс-фильтрации приведет к тому, что в срезе по CountryRegion останутся только значения, для которых есть соответствия в таблице Sales. По рис. 15.12 видно, что срезы стали синхронизированными, что может понравиться пользователю. Двунаправленная кросс-фильтрация может оказаться полезной для отчетов, но за все приходится платить. Двунаправленные связи могут негативно Глава 15 Углубленное изучение связей 537 сказаться на производительности модели данных в целом, поскольку контекст фильтра при их использовании должен распространяться по связям в обе стороны. К тому же фильтрация таблиц от стороны «один» к стороне «многие» происходит гораздо быстрее, чем в обратном направлении. Так что если держать в уме эффективность модели данных, от использования двунаправленных связей стоит отказаться. Более того, такие связи способны создавать предпосылки для образования неоднозначностей в модели. Этой темы мы коснемся далее в данной главе. Рис. 15.12 Включение режима двунаправленной кросс-фильтрации позволило синхронизировать срезы в отчете Примечание Используя фильтры уровня визуализации, можно сократить количество видимых элементов в визуальном элементе Power BI без применения двунаправленной фильтрации в связи. К сожалению, на апрель 2019 года в Power BI еще не поддерживаются фильтры уровня визуализации. Когда они станут доступны для срезов, использование двунаправленной кросс-фильтрации для ограничения количества видимых элементов в фильтре останется в прошлом. Связи типа «один ко многим» Связи типа «один ко многим» (one-to-many relationships) являются наиболее распространенными в моделировании данных. Например, именно такой тип связи используется для объединения таблиц Product и Sales. Такая связь говорит о том, что для одного товара может присутствовать множество записей в таблице продаж, тогда как одна строка в таблице продаж соответствует только одному товару. Таким образом, таблица Product в этой связи будет располагаться на стороне «один», а 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 реализована посредством создания таблицы-моста с именем AccountsCustomers. Каждая строка в этой таблице соответствует одной связке клиента с расчетным счетом. Сейчас созданная нами модель данных пока не работает. Отчет со срезом по таблице Account формируется правильно, поскольку таблица Accounts фильт­ рует таблицу Transactions, находясь в этой связи на стороне «один». В то же время если в срез поместить таблицу Customers, отчет сломается, ведь фильтр из таблицы Customers распространяется на таблицу-мост AccountsCustomers, а таблицы Accounts уже не достигает по причине однонаправленности кроссфильтрации между этими таблицами. При этом в связи между таблицами Accounts и AccountsCustomers на стороне «один» должна располагаться первая из них, поскольку в столбце AccountKey соблюдается условие уникальности данных, а в таблице AccountsCustomers могут присутствовать дубликаты. На рис. 15.14 видно, что значения в поле CustomerName не применяют никаких фильтров к мере Amount, отображенной в матрице. Рис. 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 BI. Однако присутствие подобных связей в модели данных существенно усложняет процесс распространения фильтров и может негативно сказаться на производительности вычислений в мерах, которые не должны затрагиваться этими фильтрами. Более того, если позже в модель данных будут добавлены новые таблицы, наличие в ней двунаправленной кросс-фильтрации может создать неоднозначность, которую можно будет устранить только путем изменения фильтрации. А это, в свою очередь, может нарушить работу уже существующих отчетов. Так что перед тем как включить режим двунаправленной кросс-фильтрации для той или иной связи между таблицами, дважды подумайте о возможных последствиях таких действий. Рис. 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 TA := CALCULATE ( SUM ( Transactions[Amount] ); TREATAS ( VALUES ( AccountsCustomers[AccountKey] ); Accounts[AccountKey] ) ) В этом случае функция VALUES возвращает значения столбца AccountsCusto­ mers[AccountKey], отфильтрованные по таблице Customers, а функция TREATAS меняет привязку данных таким образом, чтобы была отфильтрована таблица Accounts, а следом за ней и Transactions. Наконец, можно произвести похожее вычисление и при помощи более прос­ той формулы с применением расширенных таблиц. Здесь стоит заметить, что расширение таблицы-моста выполняется и в сторону Customers, и в сторону Accounts, что позволяет добиться почти такого же результата, как в предыдущих примерах. При этом данный код получился чуть короче: -- Версия с использованием расширенных таблиц SumOfAmt ET := CALCULATE ( SUM ( Transactions[Amount] ); AccountsCustomers ) Несмотря на множество вариаций, все эти решения могут быть сгруппированы по единственному общему признаку: с использованием двунаправленной кросс-фильтрации в DAX; с подстановкой таблицы в качестве аргумента фильтра функции CALCULATE. Формулы из этих двух групп будут вести себя по-разному, в случае если связь между таблицами Transactions и Accounts станет недействительной. Мы знаем, что в такой ситуации в таблицу, находящуюся на стороне «один», добавляется пустая строка. Если в таблице Transactions будут содержаться ссылки на расчетные счета, которых нет в таблице Accounts, связь между таблицами Transactions и Accounts будет считаться недействительной, и в таблицу Accounts будет добавлена пустая строка. Этот эффект не распространится на таблицу Customers. Таким образом, пустая строка не будет добавлена в таблицу Customers, она будет присутствовать только в таблице Accounts. Следовательно, осуществление среза таблицы Transactions по столбцу Account покажет пустую строку, тогда как фильтрация Transactions по CustomerName не обнаружит записей, связанных с пустой строкой. Такое поведение может приводить в замешательство, и для демонстрации этого примера мы добавили строку в таблицу Transactions с несоответствующим значением в поле AccountГлава 15 Углубленное изучение связей 543 Key и суммой 10 000,00. Разница в выводе показана на рис. 15.16, где в матрице слева отображен срез по столбцу Account, а справа – по CustomerName. В ка­ честве меры использовался расчет с применением функции CROSSFILTER. Рис. 15.16 В столбце CustomerName пустая строка отсутствует, и итоговое значение в правой матрице кажется неправильным Когда матрица фильтруется по столбцу Account, в ней появляется пустая строка с суммой счета, равной 10 000,00. В то же время срез по столбцу CustomerName пустую строку не выводит. Фильтр начинает свое действие со столбца CustomerName в таблице Customers, но в таблице AccountsCustomers нет значений, которые могли бы включить в фильтр пустую строку из таблицы Accounts. В результате значение, ассоциированное с пустой строкой, оказалось включено только в общий итог, поскольку на этом уровне контекст фильтра не включает в себя значение по столбцу CustomerName. Таким образом, на уровне итогов таблица Accounts не включается в перекрестную фильтрацию – все ее строки, включая пустую, становятся активными, и вычисление меры дает сумму 15 000,00. Заметьте, что мы в качестве примера использовали пустую строку, но такой же сценарий возник бы и в случае, если бы в таблице Accounts появилась строка, не относящаяся ни к одному из клиентов. Эти значения в результате будут включены только в общий итог по причине того, что фильтр по клиентам удаляет счета, не связанные ни с одним клиентом. Это замечание очень важно, поскольку результат, который вы видели на рис. 15.16, может быть и не связан с наличием недействительной связи. Например, если бы транзакция на сумму 10 000,00 была произведена по служебному аккаунту (Service), присутствующему в таблице Accounts, но не имеющему соответствий в таблице Customers, название счета из столбца Account появилось бы в отчете, несмотря на отсутствие соответствий с таблицей клиентов. Эта ситуация показана в отчете на рис. 15.17. Примечание Сценарий, изображенный на рис. 15.17, никоим образом не нарушает ссылочную целостность реляционной базы данных, как это было в случае с отчетом, показанным на рис. 15.16. В базе данных может быть реализована дополнительная логика для отслеживания возникновения подобных ситуаций. 544 Глава 15 Углубленное изучение связей Рис. 15.17 Счет Service (служебный) не связан ни с одним значением из столбца CustomerName Если мы воспользуемся техникой вычисления меры, полагающейся не на функцию CROSSFILTER, а на фильтрацию таблицы в функции CALCULATE, результат может оказаться иным. Строки, недостижимые из таблицы-моста, всегда исключаются из фильтра. А поскольку поддержка фильтра здесь обеспечивается функцией CALCULATE, эти значения не будут включены даже в общий итог. Иначе говоря, фильтр всегда будет оставаться активным. Получившийся результат можно видеть на рис. 15.18. Рис. 15.18 Использование техники с табличными фильтрами привело к скрытию пустой строки и исключению дополнительных значений из общего итога Здесь мы не только лишились добавки к общему итогу – пустая строка также пропала и из отчета с фильтром по столбцу Account, она просто была отсеяна табличным фильтром функции CALCULATE. Ни одно из полученных нами значений нельзя считать полностью верным или неверным. Более того, если таблица-мост будет включать ссылки на все строки из таблицы Transactions, соответствующие Customers, то две меры покажут одинаковый результат. Разработчики вольны сами выбирать технику расчетов в зависимости от своих требований. Глава 15 Углубленное изучение связей 545 Примечание В плане производительности решение с использованием таблиц в ка­ честве аргумента фильтра функции CALCULATE всегда будет проигрывать из-за необходимости сканировать таблицу-мост (AccountsCustomers). Это означает, что в любом отчете, использующем меру без фильтра по таблице Customers, падение эффективности будет максимальным, что окажется абсолютно бесполезным, если для каждого счета будет определен как минимум один клиент. Таким образом, по умолчанию лучше всегда останавливать выбор на мерах с использованием двунаправленной кросс-фильтрации, если целостность данных позволяет гарантировать одинаковые результаты. Также не забывайте, что решения, основанные на расширении таблиц, будут работать только при наличии сильных связей, а значит, если в вашем случае таблицы объединены при помощи слабых связей, лучше предпочесть технику с двунаправленной кросс-фильтрацией. Подробнее о факторах, влияющих на выбор решения, можно почитать в статье по адресу https://www.sqlbi.com/articles/many-to-many-relationships-in-power-bi-and-excel-2016/. Реализация связи «многие ко многим» через общее измерение В данном разделе мы покажем вам еще один сценарий применения связи «многие ко многим», которая, однако, с технической точки зрения таковой вовсе не является. Здесь мы определим связь между двумя сущностями на уровне гранулярности, отличном от первичного ключа. Этот пример мы взяли из области бюджетирования, где информация о бюджете хранится в таблице, содержащей страну, бренд и бюджет в расчете на один год. Модель данных, которой мы будем пользоваться, показана на 15.19. Рис. 15.19 В таблице Budget содержатся столбцы CountryRegion, Brand и Budget 546 Глава 15 Углубленное изучение связей Если мы хотим выводить в одном отчете цифры по продажам и бюджету, нам необходимо иметь возможность одновременно фильтровать таблицы Budget и Sales. В таблице Budget есть столбец CountryRegion, который также присутствует и в таблице Customer. Однако значения в этом столбце неуникальны в обеих таблицах. Столбец Brand из таблицы по бюджетированию присутствует также и в таблице Product, и значения в нем тоже неуникальны. Можно было бы написать простую меру Budget Amt, в которой выполнялось бы обычное суммирование по столбцу Budget в одноименной таблице. Budget Amt := SUM ( Budget[Budget] ) Матрица со срезом по столбцу Customer[CountryRegion] выведет результат, показанный на рис. 15.20. Мера Budget Amt во всех строках показывает одинаковые цифры, а именно сумму по столбцу Budget. Рис. 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. В объемных моделях данных это может негативно сказаться на производительности отчета. Рис. 15.21 Мера Budget Amt фильтруется по столбцу Customer[CountryRegion] Лучшее решение этого сценария потребует от нас незначительного изменения модели данных с добавлением таблицы, которая будет выступать в качестве единого фильтра для таблиц Budget и Customer. Создать ее можно легко и просто при помощи вычисляемых таблиц непосредственно в коде DAX: CountryRegions = DISTINCT ( UNION ( DISTINCT ( Budget[CountryRegion] ); DISTINCT ( Customer[CountryRegion] ) ) ) В этой формуле происходит извлечение всех значений из столбца CountryRegion таблиц Customer и Budget и объединение их в единую таблицу с удалением дубликатов. В результате получим новую таблицу, содержащую все значения CountryRegion без дубликатов из таблиц Budget и Customer. Таким же образом можно объединить таблицы Product и Budget, оперируя со столбцами Product[Brand] и Budget[Brand]. Brands = DISTINCT ( UNION ( DISTINCT ( 'Product'[Brand] ); DISTINCT ( Budget[Brand] ) ) ) После того как эти вспомогательные вычисляемые таблицы созданы, остается лишь соединить их связями с существующими таблицами в нашей модели данных, как показано на рис. 15.22. 548 Глава 15 Углубленное изучение связей Рис. 15.22 В модели данных появились две дополнительные таблицы: CountryRegions и Brands В обновленной модели данных таблица Brands будет фильтровать таблицы Product и Budget, а 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 и CountryRegions обеспечила распространение контекста фильтра с таблицы Customer на Budget, но не наоборот. Если бы двунаправленная кросс-фильтрация была установлена и для связи между таблицами CountryRegions и Budget, модель стала бы до определенной степени неоднозначной, и это помешало бы применить такой же шаблон к связям между таблицами Product и Budget. Примечание Модель, показанная на рис. 15.23, страдает от тех же ограничений, что и ее предшественница с рис. 15.19: если в таблице с бюджетом появятся бренды или страны, не учтенные в таблицах Product или Customer соответственно, значения по ним могут не показываться в отчете. Более подробно мы коснемся этой проблемы в следующем разделе. Рис. 15.23 После установки двунаправленной кросс-фильтрации вспомогательные таблицы могут быть скрыты Заметим, что чисто технически созданные нами связи не являются связями типа «многие ко многим». В этой модели данных мы связали таблицы Product и Budget (то же самое и с Customer) на уровне гранулярности, отличном от конкретных товаров, а именно на уровне брендов. Такого же результата можно было добиться и более простым, но при этом менее эффективным способом, используя слабые связи, что будет описано в следующем разделе. Кроме того, объединение таблиц по альтернативным уровням гранулярности таит в себе определенные нюансы и сложности, о которых мы поговорим далее в данной главе. 550 Глава 15 Углубленное изучение связей Реализация связи «многие ко многим» через слабые связи В предыдущем примере мы объединили таблицы Products и Budget посредством вспомогательной таблицы. В октябре 2018 года мы получили возможность создавать в DAX так называемые слабые связи между таблицами, с помощью которых можно проще решить подобный сценарий. Слабая связь будет установлена между таблицами в случае наличия дублирующихся значений в обоих столбцах, использующихся для объединения таб­ лиц. Иными словами, модель данных, подобная той, что была показана на рис. 15.23, может быть создана и путем непосредственного соединения таблиц Budget и Product по столбцу Product[Brand] – без использования вспомогательной таблицы Brands, как это было в предыдущем разделе. В результате мы получим модель, изображенную на рис. 15.24. Рис. 15.24 Таблица Budget напрямую объединена с таблицами Customer и Product при помощи слабых связей Создавая слабые связи, разработчик имеет возможность выбрать направление распространения контекста фильтра. Как и в случае со связью «один ко многим», слабая связь может быть как однонаправленной, так и двунаправленной. В нашем примере связи должны быть однонаправленными, и контекст фильтра должен распространяться от таблиц Customer и Product к таблице Budget. Установка двунаправленных связей внесет в модель данных неоднозначность. Таблицы на обоих концах слабых связей представляют сторону «многие». А значит, в столбцах, использующихся для объединения таблиц, могут присутГлава 15 Углубленное изучение связей 551 ствовать дублирующиеся значения. Обновленная модель данных будет работать точно так же, как модель, представленная на рис. 15.23, и получение корректных результатов не потребует от нас написания дополнительного кода на DAX в мерах или вычисляемых таблицах. Однако в представленной модели данных присутствует определенная ловушка, о которой читатель должен знать. По причине слабости связи между таблицами ни в одной из них не будет появляться дополнительная пустая строка в случае недействительности соединения. Иными словами, если в таблице Budget появятся страна или бренд, не представленные в таблицах Customer или Product, значения по ним будут скрыты в отчете, как и в случае с моделью, показанной на рис. 15.24. Чтобы продемонстрировать такое поведение, мы немного модифицируем содержимое таблицы Budget – заменим Германию (Germany) на Италию (Italy). В нашей модели нет ни одного покупателя из Италии. Результат отчета, представленный на рис. 15.25, может вас удивить. Рис. 15.25 Если связь между таблицами Budget и Customer недействительна, результаты отчета могут быть неожиданными В строке по Германии наша мера выдает пустое значение. И это вполне ес­ тественно, поскольку мы перекинули весь бюджет Германии на Италию. Но вот что интересно: в таблице отсутствует строка с бюджетом по Италии; итоговое значение по бюджетам превышает сумму двух представленных в столбце значений. Фильтр, установленный по столбцу Customer[CountryRegion], распространяется на таблицу Budget посредством слабой связи. В результате в таблице Budget остаются видимыми только выбранные страны. А поскольку Италия не представлена в столбце Customer[CountryRegion], значение по ней не выводится. Однако, когда по столбцу Customer[CountryRegion] фильтр не установлен, таблица Budget также оказывается освобождена от фильтров. А значит, в общий итог будут включены бюджеты по всем ее строкам, включая Италию. Таким образом, мера Budget Amt напрямую зависит от присутствия фильтра по столбцу Customer[CountryRegion], а если связь станет недействительной, результаты могут оказаться очень неожиданными. Слабые связи являются достаточно мощным инструментом, способствующим проектированию сложных моделей данных без необходимости создавать вспомогательные таблицы. Но особенность этих связей, состоящая в том, что таблицы не дополняются пустой строкой для отсутствующих значений, может приводить к непредсказуемым результатам в отчетах. Сначала мы показали бо552 Глава 15 Углубленное изучение связей лее сложную технику с созданием вспомогательных таблиц, после чего продемонстрировали решение с применением слабых связей. В целом эти варианты служат одной цели, разница лишь в том, что в случае с созданием дополнительных таблиц значения, присутствующие только в одной из двух связанных таб­ лиц, будут показаны в отчете, что может быть полезно в некоторых сценариях. Если мы выполним такую же замену Германии на Италию в модели данных с таблицами Brands и CountryRegions, которая была представлена на рис. 15.23, вывод отчета будет более понятным. Рис. 15.26 Замена Германии на Италию привела к выводу в отчете обеих стран с корректными значениями Выбор правильного типа для связи При создании комплексных моделей данных могут быть использованы связи разных типов. Работая со сложными сценариями, вы то и дело сталкиваетесь с непростым выбором между созданием физической связи и виртуальной. Если говорить в общем, эти разновидности связей служат одной цели: обес­ печить распространение контекста фильтра с одной таблицы на другую. Но с точки зрения производительности и реализации могут быть варианты: физическая связь определяется в модели данных, тогда как виртуальная существует только в коде на DAX. На диаграмме модели данных показаны все физические связи, которыми объединены таблицы. Чтобы добраться до виртуальных связей, нужно внимательно изучить выражения DAX, используемые в мерах, вычисляемых столбцах и вычисляемых таблицах. При необходимости использовать логическую связь в разных мерах вам придется каждый раз дублировать ее код, если речь идет не об элементах в группах вычислений. С физическими связями работать куда проще, и они реже становятся причиной ошибок; физические связи накладывают ограничение на таблицу, представляющую сторону «один». Связи типа «один ко многим» и «один к одному» требуют, чтобы в столбце, находящемся на стороне «один», не было дубликатов и пустых строк. Если это условие будет нарушено, операция обновления данных завершится ошибкой. Здесь есть серьезное отличие по сравнению с ограничениями на внешние ключи в реляционной базе данных, где столбец на стороне «многие» должен включать в себя только значения, присутствующие на другой стороне связи. В табличной модели данных такого ограничения нет; Глава 15 Углубленное изучение связей 553 физическая связь быстрее виртуальной. При создании физической связи движок строит дополнительную структуру данных, помогающую ускорить выполнение запросов за счет привлечения движка хранилища данных. Создание виртуальной связи всегда требует дополнительной работы от движка формул, который уступает в скорости подсистеме хранилища данных. Различия между движком формул и движком хранилища данных будут подробно описаны в главе 17. В большинстве случаев лучшим выбором будет физическая связь. При этом в плане производительности нет никакой разницы между обычной связью (основанной на столбцах в источнике данных) и вычисляемой физической связью (базирующейся на вычисляемых столбцах). Движок рассчитывает значения в вычисляемых столбцах в момент обработки (когда данные обновляются), так что сложность выражения большой роли не играет – связь является физической, и движок может использовать все свои ресурсы для расчетов. Виртуальная связь представляет собой абстрактную концепцию. Фактически каждый раз, когда происходит распространение контекста фильтра с одной таблицы на другую в коде на DAX, между ними создается виртуальная связь. Такие связи вычисляются в момент выполнения запроса, и у движка нет никаких дополнительных ресурсов в виде структур, создаваемых для физических связей, чтобы как-то оптимизировать план выполнения запроса. Поэтому всегда, когда у вас есть такая возможность, отдавайте предпочтение физическим связям в сравнении с виртуальными. Связи типа «многие ко многим» занимают промежуточную позицию между физическими связями и виртуальными. Можно определить связь «многие ко многим» в модели данных при помощи двунаправленной фильтрации или расширенных таблиц. Чаще всего присутствие связи в модели данных будет более выгодным решением по сравнению с подходом, основывающимся на использовании расширенных таблиц, поскольку в этом случае движок получает дополнительные рычаги при оптимизации плана выполнения запроса за счет отключения распространения фильтров там, где они не нужны. В то же время варианты с расширением таблиц и использованием двунаправленной кросс-фильтрации при активном фильтре будут иметь примерно одинаковую эффективность, хотя чисто технически генерируют совершенно разные планы выполнения запроса. С точки зрения производительности вы должны расставлять приоритеты при выборе типа связи следующим образом: физические связи типа «один ко многим» будут обладать наибольшей эффективностью и по максимуму задействуют движок VertiPaq. Вычисляемые физические связи будут работать с той же скоростью, что и связи, основанные на физических столбцах; связи с использованием двунаправленной кросс-фильтрации, связи «многие ко многим» с расширенными таблицами и слабые связи должны идти на втором месте в списке приоритетов. Они обладают хорошей производительностью и активно используют движок, пусть и не по максимуму; виртуальные связи замыкают наш список приоритетов по причине возможной низкой производительности. Заметьте, что вы можете и не 554 Глава 15 Углубленное изучение связей столкнуться с этими проблемами, если будете соблюдать все меры предосторожности и выполнять требования оптимизации, о которых мы поговорим в следующих главах. Управление гранулярностью Как мы уже упоминали в предыдущих разделах, при помощи вспомогательных таблиц или слабых связей можно осуществить связь между таблицами на уровне гранулярности ниже первичного ключа. В последнем примере мы связывали таблицу Budget с таблицами Product и Customer. При этом связь с таблицей Product была создана на уровне брендов, а с таблицей Customer – на уровне стран. Если в модели данных присутствуют связи со сниженным уровнем гранулярности, необходимо проявлять осторожность при написании мер, использующих эти связи. На рис. 15.27 представлена исходная модель данных с двумя слабыми связями типа «многие ко многим» между таблицами Customer и Product с одной стороны и Budget – с другой. Рис. 15.27 Таблицы Customer, Product и Budget объединены слабыми связями Контекст фильтра распространяется по слабым связям от таблицы к таблице в соответствии с выбранным уровнем гранулярности. Это утверждение справедливо для любых связей. Фактически между таблицами Customer и Sales контекст фильтра также распространяется на уровне гранулярности столбца, по которому построена связь. В случае если связь базируется на столбце, являющемся первичным ключом в таблице, ее поведение будет интуитивно понятным. Если же гранулярность связи будет ниже, как в случае со слабыми связями, очень легко будет прийти к вычислениям, смысл которых понять будет затруднительно. Рассмотрим для примера таблицу Product. Связь между ней и таблицей Budget установлена на уровне бренда. Таким образом, мы можем построить матрицу со срезом меры Budget Amt по столбцу Brand и получить осознанные результаты, показанные на рис. 15.28. Глава 15 Углубленное изучение связей 555 Рис. 15.28 Срез таблицы с бюджетом по бренду выдал правильные результаты Ситуация осложнится, если в анализ будут вовлечены другие столбцы из таб­ лицы Product. В отчете, показанном на рис. 15.29, мы добавили срез, чтобы отфильтровать вывод по нескольким цветам товаров, и вывели цвет в столбцы матрицы. Результат оказался неожиданным. Рис. 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. Рис. 15.30 Срез только по цвету позволит лучше понять, что происходит с ячейками в отчете Получившееся значение является суммой по всем брендам, кроме The Phone Company. При переходе по связи информация о цвете товаров была утеряна. Связь между Color и Brand была использована во время перекрестной фильтрации Brand по Color, но при этом фильтр таблицы Budget выполняется исключительно по столбцу Brand. Иначе говоря, в каждой ячейке мы видим сумму по всем брендам, в которых есть как минимум один товар выбранного цвета. Такое поведение отчета может понадобиться очень редко. Есть несколько сценариев, когда такие расчеты будут восприниматься как правильные, но в большинстве случаев пользователь будет ожидать несколько иные цифры. Проблема будет проявляться всякий раз, когда пользователь будет анализировать агрегацию значений на уровне гранулярности, не соответствующем уровню гранулярности связи. Хорошей практикой является скрытие значений в случаях, когда выбранная гранулярность не поддерживается связью. И здесь возникает вопрос: как определить, что выводимые значения в отчете находятся на правильном уровне гранулярности? Чтобы ответить на него, создадим еще несколько мер. Мы начали с построения матрицы, содержащей бренды (правильная гранулярность) и цвета товаров (неправильная гранулярность). Добавим в отчет также меру NumOfProducts, показывающую количество строк в таблице Product в рамках текущего контекста фильтра: NumOfProducts := COUNTROWS ( 'Product' ) Результат можно видеть в отчете, показанном на рис. 15.31. Ключом к решению этого сценария является дополнительная мера NumOfProducts. На строке с брендом A. Datum мера NumOfProducts показывает количество видимых товаров 132, что соответствует общему количеству товаров бренда A. Datum. При дальнейшей фильтрации отчета по цвету товара (или любому другому столбцу) количество видимых товаров будет уменьшаться. Значения из таблицы Budget при этом имеют смысл, только если все 132 товара видимы. Если товаров в выборе меньше, значение меры Budget Amt будет бессмысленным. Следовательно, нужно скрывать меру Budget Amt, когда количество видимых товаров в точности не соответствует количеству товаров в выбранном бренде. Глава 15 Углубленное изучение связей 557 Рис. 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 Углубленное изучение связей Рис. 15.32 Значения в мере Corrected Budget скрыты, когда ячейка показана на несовместимом уровне гранулярности Всякий раз, когда вы используете связи, построенные на основании уровня гранулярности, отличного от первичного ключа таблицы, необходимо осуществлять дополнительную проверку в мерах, чтобы не показывать их значения на несовместимых уровнях гранулярности. Наличие в модели данных слабых связей требует повышенного внимания к таким нюансам. Возникновение неоднозначностей в связях Говоря о связях, не стоит забывать о возможном появлении неоднозначности (ambiguity) в модели данных. Неоднозначность возникает в случае, если от одной таблицы к другой можно добраться несколькими путями, и, к сожалению, в больших моделях данных такие ситуации бывает очень трудно распознать. Легче всего получить неоднозначность в модели данных можно, создав более одной связи между двумя таблицами. Например, в таблице Sales у нас хранятся две даты: дата заказа и дата поставки. Если создать между таблицами Date и Sales две связи на основании двух этих столбцов, одна из них будет неактивной. На рис. 15.33 такая связь между таблицами Date и Sales показана пунктирной линией. Если бы обе связи одновременно были активными, модель данных стала бы неоднозначной. Иными словами, движок DAX не знал бы, по какой из двух связей распространять фильтры при переходе от таблицы Date к Sales. Когда речь идет всего о двух таблицах, неоднозначность модели данных очень легко обнаружить и понять. Но с ростом количества таблиц делать это становится все труднее. Движок сам следит за тем, чтобы при создании модели данных в ней не было никаких неоднозначностей. При этом он пользуется доГлава 15 Углубленное изучение связей 559 вольно сложными алгоритмами, которые человеку понять не так просто. В результате иногда движок не усматривает неопределенностей в моделях, в которых они на самом деле присутствуют. Рис. 15.33 Между двумя таблицами активной может быть только одна связь Давайте рассмотрим модель данных, показанную на рис. 15.34. Перед тем как читать дальше, внимательно вглядитесь в эту модель и ответьте на вопрос, является ли она неоднозначной. Рис. 15.34 Есть ли в этой модели данных неоднозначность? Сможет ли разработчик создать такую модель, или движок выдаст ошибку? Ответ на этот вопрос сам по себе неоднозначный и звучит так: эта модель содержит неоднозначность для человека, но не для движка DAX. И все же это не самая удачная модель данных, поскольку ее достаточно сложно анализировать. Но для начала разберемся, где в ней скрывается неоднозначность. 560 Глава 15 Углубленное изучение связей Заметьте, что для связи между таблицами Product и Sales установлена двунаправленная кросс-фильтрация, а это значит, что контекст фильтра может быть передан от таблицы Sales к Product и далее к Receipts. Теперь посмотрите на таблицу Date. Мы можем легко распространить контекст фильтра от нее через Sales и Product на таблицу Receipts. В то же время фильтр из таблицы Date может добраться до Receipts и по непосредственной связи между этими двумя таблицами. Таким образом, эта модель данных будет считаться неоднозначной, поскольку в ней существует больше одного пути для распространения контекста фильтра между двумя таблицами. Несмотря на это, создавать и использовать подобные модели данных в DAX вполне допустимо, поскольку движок располагает определенными правилами для снижения количества неоднозначностей, встречаемых в моделях. В данном случае будет применено правило о распространении контекста фильтра между этими двумя таблицами по кратчайшему пути. Так что DAX позволит создать такую модель данных, невзирая на наличие в ней неоднозначности. Но это отнюдь не означает, что нужно создавать и работать с такими моделями. Наоборот, это является плохой практикой, и мы настоятельно советуем вам отказаться от использования моделей данных, в которых есть неоднозначности. Но ситуация с неоднозначностями может быть еще более запутанной. Неоднозначности в моделях могут появляться как в результате создания связей между таблицами, так и при выполнении кода DAX, если разработчик использует в функции CALCULATE такие модификаторы, как, например, USERELATIONSHIP или CROSSFILTER. У вас может быть мера, которая прекрасно работает. Но при обращении к ней внутри другой меры, использующей для активации нужной связи функцию CROSSFILTER, она вдруг начинает выдавать неправильные цифры. Причиной может быть неоднозначность, появившаяся в модели данных как раз из-за применения функции CROSSFILTER. Но мы не собираемся пугать наших читателей, мы просто хотим предостеречь их в связи с теми сложностями, которые могут возникать, когда речь заходит о неоднозначностях в модели данных. Появление неоднозначностей в активных связях Наш первый пример будет базироваться на модели данных, представленной на рис. 15.34. В матрице со срезом по годам мы выведем меры Sales Amount и Receipts Amount (простые вычисления с использованием итератора SUMX). Результат этого отчета показан на рис. 15.35. Рис. 15.35 Столбец Calendar Year фильтрует таблицу Receipts, но с использованием каких связей? Глава 15 Углубленное изучение связей 561 От таблицы Date до Receipts можно добраться двумя способами: напрямую (по связи, объединяющей эти две таблицы); в обход, используя путь от Date к Sales, далее от Sales к Product и наконец от Product к Receipts. Представленная модель данных не считается неоднозначной, поскольку движок DAX всегда может выбрать кратчайший путь для распространения контекста фильтра между таблицами. Располагая прямой связью между Date и Receipts, движок игнорирует все остальные пути между этими таблицами. Если же кратчайший путь оказывается недоступен, приходится задействовать обходные пути. Посмотрите, что произойдет, если создать новую меру, вычисляющую меру Receipts Amt после деактивации прямой связи между таблицами Date и Receipts: Rec Amt Longer Path := CALCULATE ( [Receipts Amt]; CROSSFILTER ( 'Date'[Date]; Receipts[Sale Date]; NONE ) ) В мере Rec Amt Longer Path связь, установленная между таблицами Date и Receipts, разрывается, в результате чего движку приходится идти обходным путем. Результат вычисления новой меры показан на рис. 15.36. Рис. 15.36 В мере Rec Amt Longer Path используется длинный путь для фильтрации таблицы Receipts из таблицы Date Остановитесь и подумайте, что будут характеризовать цифры, полученные при вычислении меры 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. Рис. 15.38 Мера First Date Sales показывает все продажи клиенту, располагая их на дате первой покупки 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, а затем в переменную NumberOfWorkingDays помещается результат подсчета строк в получившейся таблице. На рис. 16.1 показан график, на котором отчетливо видна разница между средними сроками поставки товаров с учетом и без учета рабочи