Динамический SQL для выделенных пулов SQL в Azure Synapse Analytics
В эту статью включены советы по разработке решений с помощью динамического SQL в выделенных пулах SQL.
Пример динамического SQL
При разработке кода приложения для выделенных пулов SQL может понадобиться использовать динамический SQL, помогающий получать универсальные и гибкие модульные решения. На данный момент выделенные пулы SQL не поддерживают типы данных большого двоичного объекта.
Это может ограничивать размер строк, так как к двоичным относятся типы данных nvarchar(max) и varchar(max).
Если вы использовали эти типы в коде приложения при создании очень больших строк, необходимо разбить код на фрагменты и вместо этого использовать оператор EXEC.
Вот простой пример:
DECLARE @sql_fragment1 VARCHAR(8000)=' SELECT name ' , @sql_fragment2 VARCHAR(8000)=' FROM sys.system_views ' , @sql_fragment3 VARCHAR(8000)=' WHERE name like ''%table%'''; EXEC( @sql_fragment1 + @sql_fragment2 + @sql_fragment3);
Если строка короткая, то можно использовать sp_executesql как обычно.
К операторам, выполняемым в формате динамического кода SQL, по-прежнему будут применяться все правила проверки T-SQL.
Дальнейшие действия
Дополнительные советы по разработке приведены в обзоре разработки.
Динамическая производительность SQL в ODBC
Хотя статический SQL хорошо работает во многих ситуациях, существует класс приложений, в которых невозможно заранее определить доступ к данным. Например, предположим, что электронная таблица позволяет пользователю вводить запрос, который электронная таблица затем отправляет в СУБД для получения данных. Содержимое этого запроса, очевидно, не может быть известно программисту при написании программы электронной таблицы.
Динамическое выполнение
Для решения этой проблемы электронная таблица использует форму внедренного SQL, называемого динамическим SQL. В отличие от статических инструкций SQL, которые жестко закодируются в программе, динамические инструкции SQL можно создавать во время выполнения и размещаться в строковой переменной узла. Затем они отправляются в СУБД для обработки. Так как СУБД должна создавать план доступа во время выполнения для динамических инструкций SQL, динамический SQL обычно медленнее статического SQL. При компиляции программы, содержащей динамические инструкции SQL, динамические инструкции SQL не отрезаются от программы, как в статических SQL. Вместо этого они заменяются вызовом функции, который передает инструкцию в СУБД; статические инструкции SQL в той же программе обрабатываются обычно.
Самый простой способ выполнения динамической инструкции SQL — с инструкцией EXECUTE ИНТЕРПРЕТАЦИЯ. Эта инструкция передает инструкцию SQL в СУБД для компиляции и выполнения.
Одним из недостатков инструкции EXECUTE ИНТЕРПРЕТАЦИЯ является то, что СУБД должна пройти каждый из пяти шагов обработки инструкции SQL при каждом выполнении инструкции. Затраты, связанные с этим процессом, могут быть значительными, если многие инструкции выполняются динамически, и это расточительно, если эти инструкции похожи.
подготовленное выполнение.
Для решения приведенной выше ситуации динамический SQL предлагает оптимизированную форму выполнения, которая называется подготовленной выполнением, которая использует следующие действия:
- Программа создает инструкцию SQL в буфере так же, как и для инструкции EXECUTE IMMEDIATE. Вместо переменных узла знак вопроса (?) можно заменить константой в любом месте в тексте инструкции, чтобы указать, что значение константы будет предоставлено позже. Вопросительный знак называется маркером параметров.
- Программа передает инструкцию SQL в СУБД с инструкцией PREPARE, которая запрашивает синтаксический анализ, проверку и оптимизацию инструкции и создание плана выполнения. Затем программа использует инструкцию EXECUTE (а не инструкцию EXECUTE ИНТЕРПРЕТАЦИЯ) для последующего выполнения инструкции PREPARE. Он передает значения параметров для инструкции через специальную структуру данных, называемую областью данных SQL или SQLDA.
- Программа может многократно использовать инструкцию EXECUTE, предоставляя различные значения параметров при каждом выполнении динамической инструкции.
Подготовленное выполнение по-прежнему не совпадает со статическим SQL. В статическом SQL первые четыре этапа обработки инструкции SQL происходят во время компиляции. В подготовленном выполнении эти действия по-прежнему выполняются во время выполнения, но они выполняются только один раз. Выполнение плана происходит только при вызове EXECUTE. Это поведение помогает устранить некоторые недостатки производительности, присущие архитектуре динамического SQL.
SQL-Ex blog
Динамический SQL может быть невероятно мощным инструментом при надлежащем использовании, однако он может стать также невероятной прорехой в безопасности или привести к утомительной отладке при плохом написании. Ниже приводится несколько плохих и хороших примеров, которые помогут вам при написании динамических операторов.
Не используйте динамический SQL, если ваш оператор не является динамическим
Может показаться глупым говорить об этом, если бы не бесконечное число примеров динамического SQL, когда запрос вообще не требует его использования. Возьмем, например, следующую процедуру:
CREATE PROC MyProc @ID int AS
BEGIN
DECLARE @SQL nvarchar(MAX);
SET @SQL = N'SELECT * FROM MyTable WHERE + CONVERT(nvarchar(MAX),@ID);
EXEC(@SQL);
END;
В этом операторе действительно нет ничего «динамического». @ID не требуется встраивать в оператор, как и не требуется использовать переменную для хранения оператора. Вы могли бы просто заменить все параметризованным оператором:
CREATE PROC MyProc @ID int AS
BEGIN
SELECT * FROM MyTable WHERE /> END;
Правильно закавычивайте имена ваших объектов
При использовании динамического SQL весьма важно правильно закавычивать имена объектов. Это подразумевает, что вы не встраиваете необработанное значение вашего динамического объекта непосредственно в динамический оператор, как показано ниже.
SET @SQL = N'SELECT * FROM ' + @Table;
SET @SQL = N'SELECT * FROM [' + @Table + N']';
Оба эти оператора одинаково плохи; простое заключение имени динамического объекта в пару скобок не делают ваш код «защищенным» от инъекции. Точно так же, как и для литеральной строки, скобки могут быть экранированы, и злоумышленник может попытаться экранировать значение.
К счастью, SQL Server имеет удобную функцию для вашей безопасности, которой является QUOTENAME. Просто оберните в функцию вашу переменную с именем динамического объекта, и функция автоматически заключит имя в скобки и (что не менее важно) экранирует соответствующим образом любой символ.
SET @SQL = N'SELECT * FROM ' + QUOTENAME(@Table);
Не встраивайте значения параметров
При написании динамических операторов вам, весьма вероятно, также потребуется передать в запрос значение параметра. Это не значения динамических объектов, а нечто подобно части булева выражения в предложении WHERE. Способ передачи параметров не похож на приведенный ниже пример:
SET @SQL = N'SELECT * FROM ' + QUOTENAME(@MyTable) +
N' WHERE + CONVERT(nvarchar(MAX),@ID);
EXEC (@SQL);
Динамический SQL может (и должен) быть параметризован точно так же, как и любой другой оператор SQL. Этого можно достичь использованием sp_executesql вместо простого выполнения динамического оператора с помощью EXEC.
Второй параметр в sp_executesql используется для объявления любых переменных, которые будут передаваться в динамический оператор, после чего значения этих переменных могут быть переданы как последующие параметры:
SET @SQL = N'SELECT * FROM ' + QUOTENAME(@MyTable) +
N' WHERE /> EXEC sp_executesql @SQL, N'@ID int', @ID = @ID;
Это может также использоваться для вывода значений:
SET @SQL = N'SELECT @Count = COUNT(*) FROM ' + QUOTENAME(@MyTable) + N';';
EXEC sp_executesql @SQL, N'@Count bigint OUTPUT', @Count = @Count OUTPUT;
Параметризация динамического оператора имеет еще то преимущество, что план выполнения может использоваться повторно (когда значение динамического объекта то же самое). Если вы встраиваете значение параметра, то план не может использоваться повторно. Движок будет создавать отдельные планы запросов для предложений WHERE и WHERE однако при WHERE будет использован тот же самый план, независимо от значения @ID.
Найдите время для форматирования динамического SQL
Часто приходится слышать, что причиной, по которой не используется динамический SQL, является сложность обнаружения проблем. Я считаю, что это не вполне соответствует действительности. Плохо написанный и/или отформатированный динамический SQL трудно анализировать, но это относится также к любому плохо написанному запросу SQL. Давайте посмотрим на выглядящий достаточно простым запрос:
DECLARE @SQL nvarchar(MAX);
SET @SQL = N'INSERT INTO dbo.Emails(Email) ' +
STUFF((SELECT N'UNION ALL ' +
N'SELECT Email '+
N'FROM ' + QUOTENAME(t.[name])
FROM sys.tables t
JOIN sys.columns c ON t.object_id = c.object_id
WHERE c.[name] = N'Email'
FOR XML PATH(N'')),1,9,'') + N';';
EXEC sp_executesql @SQL;
Запрос выглядит хорошо отформатированным, и его достаточно легко читать. Проблема состоит в том, что динамический SQL возвращает ошибку (возможно, что-то типа неверного имени объекта). Следовательно, перед выполнением динамического оператора вы напечатаете его выражение, которое будет выглядеть примерно так:
INSERT INTO dbo.Emails(Email) SELECT Email FROM [MyTable_A] UNION ALL SELECT Email FROM [MyTable_B] UNION ALL SELECT Email FROM [MyTable_C] UNION ALL SELECT Email FROM [MyTable_D] UNION ALL SELECT Email FROM [MyTable_E] UNION ALL SELECT Email FROM [MyTable_F] UNION ALL SELECT Email FROM [MyTable_G] UNION ALL SELECT Email FROM [MyTable_H] UNION ALL SELECT Email FROM [MyTable_I] UNION ALL SELECT Email FROM [MyTable_J];
Теперь этот как бы «хорошо написанный» динамический оператор выглядит совершенно уродливо. Итак, как пофиксить это? Я предпочитаю добавлять переводы каретки и подачу строки, а также отступы в мои динамические операторы точно также, как и в любой «нормальный» запрос. Я склоняюсь к NCHAR(13) и NCHAR(10) для перевода каретки и подачи строки соответственно, но вы можете добавить значения, которые пожелаете. Теперь вышеприведенный запрос можно написать примерно так:
DECLARE @SQL nvarchar(MAX);
SET @SQL = N'INSERT INTO dbo.Emails(Email)' + NCHAR(13) + NCHAR(10) +
STUFF((SELECT NCHAR(13) + NCHAR(10) +
N'UNION ALL' + NCHAR(13) + NCHAR(10) +
N'SELECT Email' + NCHAR(13) + NCHAR(10) +
N'FROM ' + QUOTENAME(t.[name])
FROM sys.tables t
JOIN sys.columns c ON t.object_id = c.object_id
WHERE c.[name] = N'Email'
FOR XML PATH(N''),TYPE).value(N'.','nvarchar(MAX)'),1,13,'') + N';';
PRINT @SQL;
Если вы затем напечатаете оператор из этого запроса, то результат будет значительно более читабельным.
INSERT INTO dbo.Emails(Email)
SELECT Email FROM [MyTable_A]
UNION ALL
SELECT Email FROM [MyTable_B]
UNION ALL
SELECT Email FROM [MyTable_C]
UNION ALL
SELECT Email FROM [MyTable_D]
UNION ALL
SELECT Email FROM [MyTable_E]
UNION ALL
SELECT Email FROM [MyTable_F]
UNION ALL
SELECT Email FROM [MyTable_G]
UNION ALL
SELECT Email FROM [MyTable_H]
UNION ALL
SELECT Email FROM [MyTable_I]
UNION ALL
SELECT Email FROM [MyTable_J];
Когда вы пишете более «традиционный» запрос, применяется та же логика. Подобный запрос «выглядит» хорошо отформатированным:
DECLARE @TableName sysname = N'MyTable';
DECLARE @SQL nvarchar(MAX);
SET @SQL = N'SELECT @TableName AS TableName,' +
N'ID,' +
N'[name] AS CustomerName ' +
N'FROM ' + QUOTENAME(@TableName) + N' ' +
N'WHERE ' +
N'AND Status = ''Active'';';
PRINT @SQL;
--EXEC sp_executesql @SQL, N'@ID int', @ID = @ID;
Но, напечатав, превращается в однострочный SQL, который может стать трудным для отладки. Если же вы также добавите форматирование в динамический оператор, то он станет значительно более читабельным, например:
DECLARE @TableName sysname = N'MyTable';
DECLARE @SQL nvarchar(MAX);
SET @SQL = N'SELECT @TableName AS TableName,' + NCHAR(13) + NCHAR(10) +
N' ID,' + NCHAR(13) + NCHAR(10) +
N' [name] AS CustomerName' + NCHAR(13) + NCHAR(10) +
N'FROM ' + QUOTENAME(@TableName) + NCHAR(13) + NCHAR(10) +
N'WHERE + NCHAR(13) + NCHAR(10) +
N' AND Status = ''Active'';';
PRINT @SQL;
--EXEC sp_executesql @SQL, N'@ID int', @ID = @ID;
Форматирование может кому-то показаться несколько странным, но я считаю его неоценимым при работе с очень сложными динамическими операторами. Вышеприведенные операторы на самом деле не теряют читабельности, хотя могут показаться показаться несколько запутанными для новичков. Но что вы предпочтете, некоторое усложнение в точке генерации, или нечитабельную кучу кода, когда вы распечатываете значение динамического оператора? Я бы предпочел первое.
Выполняйте проверку по белому списку, чтобы избежать ошибок о несуществующих объектах и сделать ваши операторы более безопасными
Это особенно важно, если вы хотите избежать проникновения ошибок обратно на уровень представления. При динамических именах объектов этого можно достичь проверкой значений динамических объектов в системных таблицах, например, sys.tables или INFORMATION_SCHEMA.COLUMNS, или в вашей собственной таблице «допустимых» значений. Передача значения NULL в sp_executesql не вызывает ошибки, но также ничего не выполняет; что делает ваш оператор даже более безопасным от попыток инъекции.
DECLARE @TableName sysname = N'MyTable]; CREATE DATABASE Test;--';
DECLARE @SQL nvarchar(MAX);
SELECT @SQL = N'SELECT *' + NCHAR(13) + NCHAR(10) +
N'FROM ' + QUOTENAME(s.[name]) + N'.' + QUOTENAME(t.[name]) + N';'
FROM sys.tables t
JOIN sys.schemas s ON t.schema_id = s.schema_id
WHERE t.[name] = @TableName
AND s.[name] = N'dbo';
PRINT @SQL;
EXEC sp_executesql @SQL;
Вышеприведенный запрос выполняется и ничего не печатает и не запускает какой-нибудь динамический SQL, поскольку маловероятно, что у вас есть таблица с подобный именем.
Не используйте nvarchar(MAX) для параметра с именем объекта
Максимальная длина имени объекта в SQL Server составляет 128 символов, поэтому зачем вам необходимо пространство в 2Гб? Очевидно, не нужно. Как можно увидеть, я использовал тип данных sysname, что является синонимом для nvarchar(128). Если вы знаете, что имена ваших объектов будут короче 128 символов, то однозначно используйте меньшую длину, но никогда нет необходимости в длине свыше 128 символов, если вы работаете с объектами SQL Server. Я рекомендую использовать nvarchar, а не varchar, поскольку объект может содержать символы Юникода. Просто потому, что вы «знаете», что ваши объекты не содержат никаких из этих символов в данный момент, не означает, что они не появятся в будущем (а параметр также должен неявно преобразовываться к nvarchar в соответствии с требованиями к допустимым системным объектам).
Никогда не отлаживайте код, который создает динамический SQL, до предварительной отладки сгенерированного оператора SQL
Продолжая мой прежний тезис о том, чтобы сделать ваш SQL легче для отладки с помощью форматирования внутри динамического оператора, добавлю, что зачастую легче сначала заниматься отладкой сгенерированного оператора, а затем уже кода, который его создает. Используя Print (или оператор SELECT, если ваш динамический SQL превышает 4000 символов), вы можете проверить SQL, который будет запущен. Вы можете затем выполнить этот код самостоятельно, чтобы посмотреть, где генерируется ошибка и, что более важно, почему. Выяснив, почему он сбоит, вы можете затем распространить решение на ваш SQL, который создает динамический оператор.
Иногда причины ошибок могут быть очень простыми, типа пропущенного пробела между двумя таблицами. Это может быть очень сложно заметить, когда ваш оператор подобен литеральной строке. Получение значения вашего динамического оператора означает, что вы сможете увидеть код в том виде, в каком он должен выполняться, со всеми теми необычными цветами, которые вы предпочитаете в своем UI, что сделает процесс отладки кода еще проще.
Dynamic T-SQL и как он может быть полезен
В наших проектах нам приходится решать различные задачи. Для решения некоторых из них мы используем dynamic T-Sql (далее по тексту dynamic sql).
Для чего нужен dynamic sql? Каждый решает для себя. В одном из проектов с помощью dynamic sql мы решили задачи построения динамичных отчетов, в других — миграцию данных. Также dynamic sql незаменим в случаях, когда требуется создать/изменить/получить данные или объекты, но значения/названия приходят в качестве параметров. Да, это может показаться абсурдом, но есть и такие задачи.

Дальше мы покажем несколько примеров, как это можно реализовать с помощью dynamic sql.
Выполнить динамическую команду можно несколькими способами:
- С использование ключевого слова EXEC/EXECUTE ;
- C использование хранимой процедуры sp_executesql
Пример кода с EXEC/EXECUTE
DECLARE @sql varchar(1000) DECLARE @columnList varchar(75) DECLARE @city varchar(75) SET @columnList = 'CustomerID, ContactName, City' SET @city = 'London' SELECT @sql = ' SELECT CustomerID, ContactName, City ' + ' FROM dbo.customers WHERE 1 = 1 ' SELECT @sql = @sql + ' AND City LIKE ''' + @city + '''' EXEC (@sql)
Как видно из запроса выше, мы формируем динамическую команду. Если выполнить select @sql , то результат будет следующий:
SELECT CustomerID, ContactName, City FROM customers WHERE City = 'London'
Что же тут плохого? — Запрос отработает, и все будут довольны. Но все же, есть несколько причин, почему так делать не стоит:
- При написании команды очень легко ошибиться с количеством «’», т.к. необходимо указывать дополнительные «’», чтобы передать текстовое значение в запрос.
- При таком запросе возможны Sql инъекции (SQL Injection). Например, стоит задать значение для @city вроде такого
set @city = '''DROP TABLE customers--'''
Пример кода с sp_executesql
DECLARE @sqlCommand varchar (1000) DECLARE @columnList varchar (75) DECLARE @city varchar (75) SET @city = 'London' SET @sqlCommand = 'SELECT CustomerID, ContactName, City FROM customers WHERE City = @city' EXECUTE sp_executesql @sqlCommand, N'@city nvarchar(75)', @city = @city
Что же изменилось?
- В отличие от EXECUTE при использовании sp_executesql , не нужно никакое приведение типов, если мы используем типизированные параметры sp_executesql.
- Это решает проблему с дополнительными «’».
- Решается проблема безопасности — Sql инъекции (SQL Injection).
Получение плана запроса
SELECT q.TEXT,cp.usecounts,cp.objtype,p.*, q.*, cp.plan_handle FROM sys.dm_exec_cached_plans cp CROSS apply sys.dm_exec_query_plan(cp.plan_handle) p CROSS apply sys.dm_exec_sql_text(cp.plan_handle) AS q WHERE q.TEXT NOT LIKE '%sys.dm_exec_cached_plans %' and cp.cacheobjtype = 'Compiled Plan' AND q.TEXT LIKE '%customers%'
План запрос при использование Exec
План запроса при использовании sp_executesql
Также одно из преимуществ использования sp_executesql – это возможность возвращать значение через OUT параметр.
Далее приведем пример, как мы решили одну из проблем в проекте с использованием dynamic sql.
Допустим, у нас есть товар (да неважно, собственно, что это: товар, анкета на должность, персональная анкета). Смысл в том, что каждый объект имеет свой набор свойств (атрибутов), который его характеризует, а их может быть разное количество, и они будут разного типа. Как хранить в БД – это проблема архитектуры.
Для клиента нужен был отчет, который из себя представлял n строк на m столбцов. Где m и был наш набор атрибутов. Отчет собирался по группе объектов или для какого-то объекта из группы. Но смысл остается все тот же: каждый отчет содержит разное количество столбцов для каждой группы объектов.
Поскольку изначально существовала связь между объектами, то решение проблемы выбрали без изменения архитектуры БД. На наш взгляд, решений данной проблемы может быть несколько:
- Использовать систему отчетности, например, MS Sql Reporting Service. Создать матричный отчет, а в качестве запроса у нас будет «простой» Select . Почему мы так не сделали? В проекте не так много было отчетов, чтобы внедрять туда SSRS.
- Использовать тот же «простой» select и на серверной стороне уже создавать DataSet необходимой «формы». Да, так задача была решена изначально, когда данных о товарах было очень мало. Как только данных стало достаточно много, то время сбора отчета стало выходит за установленный timeout.
- Использовать Pivot в sql. Да, отличное решение, когда вы знаете, что у вас только эти атрибуты, и новых не будет. А что делать, когда количество атрибутов часто меняется. И опять же, для каждой группы объектов у нас свой набор атрибутов, мы снова вернемся к созданию процедуры для каждой группы объектов. Не очень удобное решение, не правда ли?
- А если использовать Pivot, но добавить туда немного dynamic sql? – Да, это решение, которое имеет право на жизнь. Его мы и опишем, как пример использования dynamic sql…
В основе отчета будет лежать обычный запрос:
Основной код для отчета
SELECT p.Id as ProductID, p.Name as [Наименование], pcp.Name as PropertiesName, vpp.Value as Value FROM dbo.Products p INNER JOIN dbo.PropertiesCategoryOfProducts pcp ON pcp.CategoryOfProductsId = p.CategoryOfProductsId INNER JOIN dbo.ValueOfProductProperty vpp ON vpp.ProductID = p.Id and vpp.PropertiesCategoryOfProductsId = pcp.Id where p.CategoryOfProductsId = @CategoryOfProductsId
Код запроса для построения отчета
SELECT p.Id as ProductID, p.Name as [Наименование], pcp.Name as PropertiesName, vpp.Value as Value FROM dbo.Products p INNER JOIN dbo.PropertiesCategoryOfProducts pcp ON pcp.CategoryOfProductsId = p.CategoryOfProductsId INNER JOIN dbo.ValueOfProductProperty vpp ON vpp.ProductID = p.Id and vpp.PropertiesCategoryOfProductsId = pcp.Id where p.CategoryOfProductsId = @CategoryOfProductsId Код запроса для построения отчета declare @CategoryOfProductsId int = 1 declare @PivotColumnHeaders nvarchar(max)= REVERSE(STUFF(REVERSE((select '[' + Name + ']' + ',' as 'data()' from dbo.PropertiesCategoryOfProducts t where t.CategoryOfProductsId = @CategoryOfProductsId FOR XML PATH('') )),1,1,'')) if(@PivotColumnHeaders>'') declare @PivotTableSQL nvarchar(max) BEGIN SET @PivotTableSQL = N' SELECT * from (SELECT p.Id as ProductID, p.Name as [Наименование], pcp.Name as PropertiesName, vpp.Value as Value FROM dbo.Products p INNER JOIN dbo.PropertiesCategoryOfProducts pcp ON pcp.CategoryOfProductsId = p.CategoryOfProductsId INNER JOIN dbo.ValueOfProductProperty vpp ON vpp.ProductID = p.Id and vpp.PropertiesCategoryOfProductsId = pcp.Id where p.CategoryOfProductsId = @CategoryOfProductsId ) as Pivot_Data PIVOT ( MIN(Value) FOR PropertiesName IN ( ' + @PivotColumnHeaders + ' ) ) AS PivotTable ' EXECUTE sp_executesql @PivotTableSQL, N'@CategoryOfProductsId int', @CategoryOfProductsId = @CategoryOfProductsId; END
Давайте рассмотрим, что же мы тут написали:
- Инициализируем переменную со значением нашей категории товаров — declare @CategoryOfProductsId int = 1
- Далее нам нужно получить список колонок для нашей категории товаров, но при этом они должны быть заключены в “[]” скобки и перечислены через “,” как этого требует синтаксис функции Pivot —
Получение списка колонок для категории товаров
declare @PivotColumnHeaders nvarchar(max)= REVERSE(STUFF(REVERSE((select '[' + Name + ']' + ',' as 'data()' from dbo.PropertiesCategoryOfProducts t where t.CategoryOfProductsId = @CategoryOfProductsId FOR XML PATH('') )),1,1,''))
Ну а дальше все просто: при выполнении кода список колонок для функции Pivot будет подставлен из @PivotColumnHeaders
Если выполнить select @PivotTableSQL , то мы получим тот запрос, который без использования dynamic sql нам бы пришлось писать вручную.
Результатом выполнения данного запроса будет отчет такого вида:
