Тази статия е петата част от поредицата за грешки в T-SQL, клопки и най-добри практики. По-рано разглеждах детерминизма, подзаявките, присъединяванията и прозореца. Този месец разглеждам завъртане и развъртане. Благодаря на Ерланд Сомарског, Аарон Бертран, Алехандро Меса, Умачандар Джаячандран (UC), Фабиано Невес Аморим, Милош Радивоевич, Саймън Сабин, Адам Мачаник, Томас Гросер, Чан Минг Ман и Пол Уайт за споделянето на вашите предложения!
В моите примери ще използвам примерна база данни, наречена TSQLV5. Тук можете да намерите скрипта, който създава и попълва тази база данни, както и нейната диаграма за ER тук.
Неявно групиране с PIVOT
Когато хората искат да въртят данни с помощта на T-SQL, те или използват стандартно решение с групирана заявка и CASE изрази, или собствения оператор PIVOT таблица. Основното предимство на оператора PIVOT е, че има тенденция да води до по-кратък код. Този оператор обаче има няколко недостатъка, сред които присъщ дизайн капан, който може да доведе до грешки във вашия код. Тук ще опиша капана, потенциалната грешка и най-добрата практика, която предотвратява грешката. Ще опиша също предложение за подобряване на синтаксиса на оператора PIVOT по начин, който помага да се избегне грешката.
Когато завъртате данни, има три стъпки, които участват в решението, с три свързани елемента:
- Групиране въз основа на елемент за групиране/върху редове
- Разпространение въз основа на елемент за разпространение/върху cols
- Агрегиране въз основа на елемент за обобщаване/данни
Следва синтаксисът на оператора PIVOT:
ИЗБЕРЕТЕОТ PIVOT( ( ) ЗА IN( ) ) КАТО <псевдоним>;
Дизайнът на оператора PIVOT изисква изрично да посочите елементите за агрегиране и разпространение, но позволява на SQL Server имплицитно да разбере групиращия елемент чрез елиминиране. Които и колони да се появяват в изходната таблица, която е предоставена като вход за оператора PIVOT, те имплицитно стават групиращ елемент.
Да предположим например, че искате да направите заявка за таблицата Sales.Orders в примерната база данни на TSQLV5. Искате да върнете идентификационните номера на изпращача на редове, години на доставка в колони и броя на поръчките на изпращач и година като сбор.
Много хора се затрудняват да разберат синтаксиса на оператора PIVOT и това често води до групиране на данните по нежелани елементи. Като пример с нашата задача, да предположим, че не осъзнавате, че елементът за групиране е определен имплицитно и измисляте следната заявка:
ИЗБЕРЕТЕ shipperid, [2017], [2018], [2019] ОТ Продажби. Поръчки КРЪСТНО ПРИЛАГАНЕ( СТОЙНОСТИ(ГОДИНА(дата на доставка)) ) КАТО D(година на доставка) ОСНОВНА (БРОЙ(дата на доставка) ЗА година на доставка В([2017]) , [2018], [2019]) ) AS P;
В данните присъстват само трима изпращачи с идентификатори на изпращач 1, 2 и 3. Така че очаквате да видите само три реда в резултата. Реалният изход на заявката обаче показва много повече редове:
shipperid 2017 2018 2019 ----------- ----------- ----------- -3 1 0 01 1 0 02 1 0 01 1 0 02 1 0 02 1 0 02 1 0 03 1 0 02 1 0 03 1 0 0...3 0 1 03 0 1 03 0 1 1 01 0 0 01 0 1 03 0 1 03 0 1 03 0 1 01 0 1 0...3 0 0 11 0 0 12 0 0 11 0 0 12 0 0 11 0 0 13 0 0 13 0 0 12 0 1 0...(830 засегнати реда)
Какво се случи?
Можете да намерите улика, която ще ви помогне да разберете грешката в кода, като погледнете плана на заявката, показан на фигура 1.
Фигура 1:План за центрирана заявка с имплицитно групиране
Не позволявайте използването на оператора CROSS APPLY с клаузата VALUES в заявката да ви обърква. Това се прави просто, за да се изчисли колоната с резултати, изпратена година въз основа на изходната колона shippeddate, и се обработва от първия оператор Compute Scalar в плана.
Таблицата за въвеждане на оператора PIVOT съдържа всички колони от таблицата Sales.Orders, плюс колоната за резултати, изпратена година. Както бе споменато, SQL Server определя групиращия елемент имплицитно чрез елиминиране въз основа на това, което не сте посочили като елементи за агрегиране (shippeddate) и разпространение (shippedyear). Може би интуитивно сте очаквали колоната shipperid да бъде колоната за групиране, защото се появява в списъка SELECT, но както можете да видите в плана, на практика получавате много по-дълъг списък от колони, включително orderid, който е колоната с първичен ключ в таблицата източник. Това означава, че вместо да получавате ред на изпращач, вие получавате ред на поръчка. Тъй като в списъка SELECT сте посочили само колоните shipperid, [2017], [2018] и [2019], не виждате останалите, което добавя към объркването. Но останалите взеха участие в подразбиращото се групиране.
Това, което би могло да бъде страхотно, е ако синтаксисът на оператора PIVOT поддържа клауза, в която можете изрично да посочите елемента групиране/по редове. Нещо като това:
ИЗБЕРЕТЕОТ PIVOT( ( ) ЗА IN( ) НА РЕДОВЕ ) КАТО <псевдоним>;
Въз основа на този синтаксис ще използвате следния код, за да се справите с нашата задача:
ИЗБЕРЕТЕ shipperid, [2017], [2018], [2019] ОТ Продажби. Поръчки КРЪСТНО ПРИЛАГАНЕ( СТОЙНОСТИ(ГОДИНА(дата на доставка)) ) КАТО D(година на доставка) ОСНОВНА (БРОЙ(дата на доставка) ЗА година на доставка В([2017]) , [2018], [2019]) НА РЕДОВЕ shipperid ) AS P;
Тук можете да намерите елемент за обратна връзка с предложение за подобряване на синтаксиса на оператора PIVOT. За да направи това подобрение ненарушаваща промяна, тази клауза може да се направи незадължителна, като по подразбиране е съществуващото поведение. Има и други предложения за подобряване на синтаксиса на оператора PIVOT, като го направи по-динамичен и като поддържа множество агрегати.
Междувременно има най-добра практика, която може да ви помогне да избегнете грешката. Използвайте табличен израз като CTE или производна таблица, където проектирате само трите елемента, които трябва да участвате в операцията на въртене, и след това използвайте израза на таблицата като вход за оператора PIVOT. По този начин вие напълно контролирате групиращия елемент. Ето общия синтаксис, който следва тази най-добра практика:
СAS( SELECT , , FROM )SELECT FROM PIVOT( ( ) FOR IN (spread_col>) ) ) AS <псевдоним>;
Приложено към нашата задача, вие използвате следния код:
WITH C AS( ИЗБЕРЕТЕ shipperid, YEAR(дата на доставка) AS shippedyear, shippeddate FROM Sales.Orders)ИЗБЕРЕТЕ shipperid, [2017], [2018], [2019]ОТ C PIVOT( COUNT(дата на доставка) FOR shippedyear IN([ 2017], [2018], [2019]) ) AS P;
Този път получавате само три реда с резултати, както се очаква:
shipperid 2017 2018 2019 ----------- ----------- ----------- -3 51 125 731 36 130 792 56 143 116
Друга възможност е да използвате старото и класическо стандартно решение за завъртане с помощта на групирана заявка и CASE изрази, като така:
ИЗБЕРЕТЕ СИГНАЛ на изпращане, БРОЙ(СЛУЧАЙ, КОГАТО ИЗПРАЩЕНГОДИНА =2017, ТОГАВА 1 КРАЙ) КАТО [2017], БРОЙ(СЛУЧАЙ, КОГАТО ИЗПРАЩЕНГОДИНА =2018, ТОГАВА 1 КРАЙ) КАТО [2018], БРОЙ (СЛУЧАЙ, КОГАТО ГОДИНА НА ИЗПЛАТИВАНЕ =2019 ГОДИНА ТОГАВА 19) [2019]ОТ Sales.Orders CROSS APPLY( СТОЙНОСТИ(ГОДИНА(дата на доставка)) ) КАТО D(година на доставка)WHERE shippeddate НЕ Е NULLGROUP BY shipperid;
С този синтаксис и трите въртящи се стъпки и свързаните с тях елементи трябва да са изрични в кода. Въпреки това, когато имате голям брой разпределящи се стойности, този синтаксис обикновено е многословен. В такива случаи хората често предпочитат да използват оператора PIVOT.
Неявно премахване на NULL с UNPIVOT
Следващият елемент в тази статия е по-скоро капан, отколкото бъг. Това е свързано със собствения T-SQL оператор UNPIVOT, който ви позволява да отменяте данните от състояние на колони към състояние на редове.
Ще използвам таблица, наречена CustOrders като мои примерни данни. Използвайте следния код, за да създадете, попълнете и заявите тази таблица, за да покажете съдържанието й:
ПРОСТЪПНЕТЕ ТАБЛИЦА, АКО СЪЩЕСТВУВА dbo.CustOrders;GO WITH C AS( SELECT custid, YEAR(orderdate) AS orderyearyear, val FROM Sales.OrderValues)SELECT custid, [2017], [2018], [2019]INTO dbo. C PIVOT( SUM(val) ЗА поръчкагодина IN([2017], [2018], [2019]) ) AS P; SELECT * FROM dbo.CustOrders;
Този код генерира следния изход:
custid 2017 2018 2019------- ---------- ---------- ----------1 NULL 2022,50 2250,502 88,80 799,75 514.403 403.20 5960.78 660.004 1379.00 6406.90 5604.755 4324.40 13849.02 6754.166 NULL 1079.80 2160.007 9986.20 7817.88 730.008 982.00 3026.85 224.009 4074.28 11208.36 6680.6110 1832.80 7630.25 11338.5611 479.40 3179.50 2431.0012 NULL 238.00 1576.8013 100.80 NULL NULL14 1674.22 6516.40 4158.2615 2169.00 1128.00 513.7516 NULL 787.60 931.5017 533.60 420.00 2809.6118 268.80 487.00 860.1019 950.00 4514,35 9296,6920 15568,07 48096,27 41210,65...
Тази таблица съдържа общите стойности на поръчките за клиент и година. NULL представляват случаите, когато клиентът не е имал никаква поръчка през целевата година.
Да предположим, че искате да отмените центрирането на данните от таблицата CustOrders, връщайки ред за клиент и година, с колона за резултат, наречена val, съдържаща общата стойност на поръчката за текущия клиент и година. Всяка задача за отмяна обикновено включва три елемента:
- Имената на съществуващите колони източник, които премахвате:[2017], [2018], [2019] в нашия случай
- Име, което присвоявате на целевата колона, която ще съдържа имената на изходните колони:orderyear в нашия случай
- Име, което присвоявате на целевата колона, която ще съдържа стойностите на изходната колона:val в нашия случай
Ако решите да използвате оператора UNPIVOT, за да се справите със задачата за отмяна, първо разбирате горните три елемента и след това използвате следния синтаксис:
ИЗБЕРЕТЕ, , ОТ UNPIVOT( FOR IN( ) ) КАТО <псевдоним>;
Приложено към нашата задача, вие използвате следната заявка:
ИЗБЕРЕТЕ custid, orderyear, valFROM dbo.CustOrders UNPIVOT( val FOR orderyear IN([2017], [2018], [2019]) ) AS U;
Тази заявка генерира следния изход:
Custid Orderyear val------- ---------- ----------1 2018 2022.501 2019 2250.502 2017 88.802 2018 799.752 2019 514.403 2017 423.183 60.208 60.208 60.208 2017 1379.004 2018 6406.904 2019 5604.755 2017 4324.405 2018 13849.025 2019 6754.166 2018 1079.806 2019 2160.007 2017 9986.207 2018 7817.887Разглеждайки изходните данни и резултата от заявката, забелязвате ли какво липсва?
Дизайнът на оператора UNPIVOT включва имплицитно елиминиране на редове с резултати, които имат NULL в колоната със стойности — val в нашия случай. Разглеждайки плана за изпълнение на тази заявка, показан на фигура 2, можете да видите как операторът Filter премахва редовете с NULL в колоната val (Expr1007 в плана).
Фигура 2:План за повторно отваряне на заявка с имплицитно премахване на NULL
Понякога това поведение е желателно и в този случай не е необходимо да правите нищо специално. Проблемът е, че понякога искате да запазите редовете с NULL. Подводният камък е, когато искате да запазите NULL и дори не осъзнавате, че операторът UNPIVOT е предназначен да ги премахва.
Това, което би могло да бъде страхотно, е ако операторът UNPIVOT имаше незадължителна клауза, която ви позволява да укажете дали искате да премахнете или запазите NULL, като първото е по подразбиране за обратна съвместимост. Ето пример за това как може да изглежда този синтаксис:
ИЗБЕРЕТЕ <колове_таблици, с изключение на изходни_колове>, <имена_кола>, <източна_кола>ОТ <източна_таблица> UNPIVOT( <кола_стойности> ЗА <кола_на_имена> IN(<изходни_колове>) [ОТКЛЮЧВАНЕ НА НУЛИ | ЗАДЪРЗВАНЕ НА НУЛВИТЕ] ) КАТО <псевдоним>;предварително>Ако искате да запазите NULL, въз основа на този синтаксис ще използвате следната заявка:
ИЗБЕРЕТЕ custid, orderyear, valFROM dbo.CustOrders UNPIVOT( val FOR orderyear IN([2017], [2018], [2019]) KEEP NULLS ) КАТО U;Тук можете да намерите елемент за обратна връзка с предложение за подобряване на синтаксиса на UNPIVOT оператора по този начин.
Междувременно, ако искате да запазите редовете с NULL, трябва да измислите решение. Ако настоявате да използвате оператора UNPIVOT, трябва да приложите две стъпки. В първата стъпка дефинирате табличен израз въз основа на заявка, която използва функцията ISNULL или COALESCE, за да замените NULL във всички колони без завъртане със стойност, която обикновено не може да се появи в данните, например -1 в нашия случай. Във втората стъпка използвате функцията NULLIF във външната заявка срещу колоната със стойности, за да замените обратното -1 с NULL. Ето пълния код на решението:
С C AS( SELECT custid, ISNULL([2017], -1.0) AS [2017], ISNULL([2018], -1.0) AS [2018], ISNULL([2019], -1.0) AS [2019 ] FROM dbo.CustOrders)SELECT custid, orderyear, NULLIF(val, -1.0) AS valFROM C UNPIVOT( val FOR orderyear IN([2017], [2018], [2019]) ) AS U;Ето изхода от тази заявка, показващ, че редовете с NULL в колоната val са запазени:
Custid orderyear val------- ---------- ----------1 2017 NULL1 2018 2022.501 2019 2250.502 2017 88.802 2018 799.752 2019 514.4703 031.80 30.80 2019 660.004 2017 1379.004 2018 6406.904 2019 5604.755 2017 4324.405 2018 13849.025 2019 6754.166 2017 NULL6 2018 1079.806 2019 2160.007 2017 9986.207 2018 7817.887 2019 730.00...Този подход е неудобен, особено когато имате голям брой колони за развъртане.
Алтернативно решение използва комбинация от оператора APPLY и клаузата VALUES. Конструирате ред за всяка колона без завъртане, като една колона представлява колоната с целеви имена (година на поръчка в нашия случай), а друга представлява колоната за целеви стойности (val в нашия случай). Вие предоставяте постоянната година за колоната с имена и съответната корелирана колона източник за колоната със стойности. Ето пълния код на решението:
ИЗБЕРЕТЕ custid, orderyear, valFROM dbo.CustOrders КРЪСТНО ПРИЛАГАНЕ ( СТОЙНОСТИ(2017, [2017]), (2018, [2018]), (2019, [2019]) ) КАТО A(година на поръчка, val);предварително>Хубавото тук е, че освен ако не се интересувате от премахването на редовете с NULL в колоната val, не е нужно да правите нищо специално. Тук няма неявна стъпка, която премахва редовете с NULLS. Освен това, тъй като псевдонимът на колоната val е създаден като част от клаузата FROM, той е достъпен за клаузата WHERE. Така че, ако се интересувате от премахването на NULL, можете да бъдете изрични за това в клаузата WHERE, като взаимодействате директно с псевдонима на колоната със стойности, както следва:
ИЗБЕРЕТЕ custid, orderyear, valFROM dbo.CustOrders CROSS APPLY ( VALUES(2017, [2017]), (2018, [2018]), (2019, [2019]) ) КАТО A(orderyear, val)КЪДЕ Е val NOT NULL;Въпросът е, че този синтаксис ви дава контрол дали искате да запазите или премахнете NULL. Той е по-гъвкав от оператора UNPIVOT по друг начин, като ви позволява да обработвате множество неотклонени мерки като val и qty. Моят фокус в тази статия обаче беше клопката, включваща NULL, така че не навлизах в този аспект.
Заключение
Дизайнът на операторите PIVOT и UNPIVOT понякога води до грешки и подводни камъни във вашия код. Синтаксисът на оператора PIVOT не ви позволява изрично да посочите групиращия елемент. Ако не осъзнавате това, може да се окажете с нежелани групиращи елементи. Като най-добра практика се препоръчва да използвате табличен израз като вход за оператора PIVOT и затова изрично контролирате какво е групиращият елемент.
Синтаксисът на оператора UNPIVOT не ви позволява да контролирате дали да премахвате или запазвате редове с NULL в колоната със стойности на резултата. Като решение можете да използвате неудобно решение с функциите ISNULL и NULLIF, или решение, базирано на оператора APPLY и клаузата VALUES.
Споменах също два елемента за обратна връзка с предложения за подобряване на операторите PIVOT и UNPIVOT с по-ясни опции за контрол на поведението на оператора и неговите елементи.