Den här artikeln är den femte delen i en serie om T-SQL-buggar, fallgropar och bästa praxis. Tidigare täckte jag determinism, subqueries, joins och windowing. Den här månaden tar jag upp pivotering och unpivoting. Tack Erland Sommarskog, Aaron Bertrand, Alejandro Mesa, Umachandar Jayachandran (UC), Fabiano Neves Amorim, Milos Radivojevic, Simon Sabin, Adam Machanic, Thomas Grohser, Chan Ming Man och Paul White för att du delar med dig av dina förslag!
I mina exempel kommer jag att använda en exempeldatabas som heter TSQLV5. Du kan hitta skriptet som skapar och fyller denna databas här, och dess ER-diagram här.
Implicit gruppering med PIVOT
När människor vill pivotera data med T-SQL använder de antingen en standardlösning med en grupperad fråga och CASE-uttryck, eller den egenutvecklade PIVOT-tabelloperatorn. Den största fördelen med PIVOT-operatören är att den tenderar att resultera i kortare kod. Den här operatören har dock några brister, bland dem en inneboende designfälla som kan resultera i buggar i din kod. Här kommer jag att beskriva fällan, den potentiella buggen och en bästa praxis som förhindrar buggen. Jag kommer också att beskriva ett förslag för att förbättra PIVOT-operatörens syntax på ett sätt som hjälper till att undvika buggen.
När du pivoterar data är det tre steg som är involverade i lösningen, med tre associerade element:
- Grupp baserad på ett element för gruppering/på rader
- Spredning baserat på ett spridning/på cols-element
- Aggregera baserat på ett aggregerings-/dataelement
Följande är syntaxen för PIVOT-operatorn:
VÄLJFRÅN PIVOT( ( ) FOR IN( ) ) AS ;
Utformningen av PIVOT-operatorn kräver att du explicit specificerar aggregerings- och spridningselementen, men låter SQL Server implicit räkna ut grupperingselementet genom eliminering. Vilka kolumner som än visas i källtabellen som tillhandahålls som indata till PIVOT-operatorn, blir de implicit grupperingselementet.
Anta till exempel att du vill fråga tabellen Sales.Orders i TSQLV5-exempeldatabasen. Du vill returnera avsändar-ID på rader, levererade år i kolumner och antalet beställningar per avsändare och år som aggregat.
Många människor har svårt att lista ut PIVOT-operatörens syntax, och detta resulterar ofta i att data grupperas efter oönskade element. Som ett exempel med vår uppgift, anta att du inte inser att grupperingselementet bestäms implicit, och du kommer på följande fråga:
SELECT shipperid, [2017], [2018], [2019]FRÅN Sales.Orders CROSS APPLY( VALUES(YEAR(shippeddate)) ) AS D(shippedyear) PIVOT( COUNT(shippeddate) FOR shipped year IN([2017] , [2018], [2019]) ) AS P;
Det finns bara tre avsändare närvarande i data, med avsändar-ID 1, 2 och 3. Så du förväntar dig att bara se tre rader i resultatet. Den faktiska frågeutgången visar dock många fler rader:
fraktare 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 01 0 1 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 rader påverkade)
Vad hände?
Du kan hitta en ledtråd som hjälper dig att ta reda på felet i koden genom att titta på frågeplanen som visas i figur 1.
Figur 1:Planera för pivotfråga med implicit gruppering
Låt inte användningen av CROSS APPLY-operatorn med VALUES-satsen i frågan förvirra dig. Detta görs helt enkelt för att beräkna resultatkolumnen shippedyear baserat på källkolumnen shippeddate, och hanteras av den första Compute Scalar-operatören i planen.
Inmatningstabellen till PIVOT-operatören innehåller alla kolumner från tabellen Sales.Orders, plus resultatkolumnen leveransår. Som nämnts bestämmer SQL Server grupperingselementet implicit genom eliminering baserat på vad du inte angav som aggregerings- (leveransdatum) och spridningselement (levererat år). Du kanske intuitivt förväntade dig att shipperid-kolumnen skulle vara grupperingskolumnen eftersom den visas i SELECT-listan, men som du kan se i planen fick du i praktiken en mycket längre lista med kolumner, inklusive orderid, som är den primära nyckelkolumnen i källtabellen. Det betyder att istället för att få en rad per avsändare får du en rad per beställning. Eftersom du i SELECT-listan endast angav kolumnerna shipperid, [2017], [2018] och [2019], ser du inte resten, vilket ökar förvirringen. Men resten deltog i den underförstådda grupperingen.
Det som skulle kunna vara bra är om syntaxen för PIVOT-operatorn stödde en klausul där du uttryckligen kan ange elementet gruppering/på rader. Något så här:
VÄLJFRÅN PIVOT( ( ) FÖR IN( ) ON ROWS ) AS ;
Baserat på denna syntax skulle du använda följande kod för att hantera vår uppgift:
SELECT shipperid, [2017], [2018], [2019]FRÅN Sales.Orders CROSS APPLY( VALUES(YEAR(shippeddate)) ) AS D(shippedyear) PIVOT( COUNT(shippeddate) FOR shipped year IN([2017] , [2018], [2019]) ON ROWS shipperid ) SOM P;
Du kan hitta ett feedbackobjekt med ett förslag för att förbättra PIVOT-operatörens syntax här. För att göra denna förbättring till en obruten förändring, kan den här klausulen göras valfri, där standard är det befintliga beteendet. Det finns andra förslag för att förbättra PIVOT-operatörens syntax genom att göra den mer dynamisk och genom att stödja flera aggregat.
Under tiden finns det en bästa praxis som kan hjälpa dig att undvika buggen. Använd ett tabelluttryck som en CTE eller en härledd tabell där du bara projicerar de tre element som du behöver för att vara involverade i pivotoperationen, och använd sedan tabelluttrycket som indata till PIVOT-operatorn. På så sätt kontrollerar du helt och hållet grupperingselementet. Här är den allmänna syntaxen efter denna bästa praxis:
WITHAS( SELECT , , FROM )SELECT FRÅN PIVOT( ( ) FOR IN( ) ) AS ;
Tillämpad på vår uppgift använder du följande kod:
WITH C AS( SELECT shipperid, YEAR(shippeddate) AS shippedyear, shippeddate FROM Sales.Orders)SELECT shipperid, [2017], [2018], [2019]FROM C PIVOT( COUNT(shippeddate) FOR shipped year IN([ 2017], [2018], [2019]) ) AS P;
Den här gången får du bara tre resultatrader som förväntat:
fraktare 2017 2018 2019------------- ---------- ------------------ ---------- -3 51 125 731 36 130 792 56 143 116
Ett annat alternativ är att använda den gamla och klassiska standardlösningen för att pivotera med hjälp av en grupperad fråga och CASE-uttryck, som så:
SELECT shipperid, COUNT(CASE WHEN shippedyear =2017 THEN 1 END) AS [2017], COUNT(CASE WHEN shippedyear =2018 THEN 1 END) AS [2018], COUNT(CASE WHEN shipped year =2019 THEN 1END) 2019Med denna syntax måste alla tre svängningsstegen och deras associerade element vara explicita i koden. Men när du har ett stort antal spridningsvärden tenderar denna syntax att vara utförlig. I sådana fall föredrar människor ofta att använda PIVOT-operatören.
Underförstått borttagning av NULLs med UNPIVOT
Nästa punkt i den här artikeln är mer av en fallgrop än en bugg. Det har att göra med den egenutvecklade T-SQL UNPIVOT-operatorn, som låter dig avpivotera data från ett tillstånd av kolumner till ett tillstånd av rader.
Jag kommer att använda en tabell som heter CustOrders som min exempeldata. Använd följande kod för att skapa, fylla i och fråga den här tabellen för att visa dess innehåll:
SLIP TABELL OM FINNS dbo.CustOrders;GO WITH C AS( SELECT custid, YEAR(orderdate) AS orderyearyear, val FROM Sales.OrderValues)SELECT custid, [2017], [2018], [2019]INTO dbo.CustOrdersFROM C PIVOT( SUMMA(värde) FÖR orderårår IN([2017], [2018], [2019]) ) SOM P; VÄLJ * FRÅN dbo.CustOrders;Denna kod genererar följande utdata:
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...Denna tabell innehåller de totala ordervärdena per kund och år. NULL representerar fall där en kund inte hade någon orderaktivitet under målåret.
Anta att du vill avpivotera data från CustOrders-tabellen och returnera en rad per kund och år, med en resultatkolumn som kallas val som innehåller det totala ordervärdet för den aktuella kunden och året. Varje opivoterande uppgift involverar i allmänhet tre element:
- Namnen på de befintliga källkolumnerna som du avpivoterar:[2017], [2018], [2019] i vårt fall
- Ett namn du tilldelar målkolumnen som kommer att innehålla källkolumnnamnen:orderyear i vårt fall
- Ett namn du tilldelar målkolumnen som kommer att innehålla källkolumnvärdena:val i vårt fall
Om du bestämmer dig för att använda UNPIVOT-operatorn för att hantera opivoteringsuppgiften, räknar du först ut de tre ovanstående elementen och använder sedan följande syntax:
VÄLJ, , FRÅN UNPIVOT( FOR IN( ) ) AS ; Tillämpad på vår uppgift använder du följande fråga:
SELECT custid, orderyear, valFROM dbo.CustOrders UNPIVOT( val FOR orderyear IN([2017], [2018], [2019]) ) AS U;Den här frågan genererar följande utdata:
beställningsår val------- ---------- ----------1 2018 2022.501 2019 2250.502 2017 88.802 2018 799.752 2019 514.403 2017 3020170 69.403 2017 4020170 303.602 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 2019 730.00...När du tittar på källdata och frågeresultat, märker du vad som saknas?
Utformningen av UNPIVOT-operatorn innebär en implicit eliminering av resultatrader som har en NULL i värdekolumnen—val i vårt fall. Om du tittar på exekveringsplanen för den här frågan som visas i figur 2 kan du se filteroperatorn ta bort raderna med NULL i valkolumnen (Expr1007 i planen).
Figur 2:Planera för unpivot-fråga med implicit borttagning av NULL
Ibland är detta beteende önskvärt, i så fall behöver du inte göra något speciellt. Problemet är att ibland vill du behålla raderna med NULL. Fallgropen är när du vill behålla NULL och du inte ens inser att UNPIVOT-operatören är utformad för att ta bort dem.
Vad som skulle kunna vara bra är om UNPIVOT-operatören hade en valfri klausul som gjorde att du kunde ange om du vill ta bort eller behålla NULL, med den förra som standard för bakåtkompatibilitet. Här är ett exempel på hur den här syntaxen kan se ut:
VÄLJ, , FRÅN UNPIVOT( FOR IN( ) [REMOVE NULLS | KEEP NULLS] ) AS ; Om du ville behålla NULLs, baserat på denna syntax skulle du använda följande fråga:
SELECT custid, orderyear, valFROM dbo.CustOrders UNPIVOT( val FOR orderyear IN([2017], [2018], [2019]) KEEP NULLS ) AS U;Du kan hitta ett feedbackobjekt med ett förslag för att förbättra UNPIVOT-operatörens syntax på detta sätt här.
Under tiden, om du vill behålla raderna med NULL, måste du komma på en lösning. Om du insisterar på att använda UNPIVOT-operatören måste du tillämpa två steg. I det första steget definierar du ett tabelluttryck baserat på en fråga som använder ISNULL- eller COALESCE-funktionen för att ersätta NULL i alla opivoterade kolumner med ett värde som normalt inte kan visas i data, t.ex. -1 i vårt fall. I det andra steget använder du NULLIF-funktionen i den yttre frågan mot värdekolumnen för att ersätta baksidan -1 med en NULL. Här är den fullständiga lösningskoden:
WITH 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;Här är resultatet av denna fråga som visar att rader med NULL i valkolumnen bevaras:
beställningsår val------- ---------- ----------1 2017 NULL1 2018 2022.501 2019 2250.502 2017 88.802 2018 799.752 2019 514.1703 390.403 390.503 8250.502 2017 2019 660.004 2017 1379.004 2018 6406.904 2019 5604.755 2017 4324.405 2018 13849.025 2019 6754.166 2018 2018 2018.00 ... <Det här tillvägagångssättet är besvärligt, särskilt när du har ett stort antal kolumner att avpivotera.
En alternativ lösning använder en kombination av APPLY-operatorn och VALUES-satsen. Du konstruerar en rad för varje opivoterad kolumn, där en kolumn representerar kolumnen för målnamn (beställningsår i vårt fall), och en annan representerar kolumnen för målvärden (val i vårt fall). Du anger det konstanta året för namnkolumnen och den relevanta korrelerade källkolumnen för värdekolumnen. Här är den fullständiga lösningskoden:
SELECT custid, orderyear, valFROM dbo.CustOrders CROSS APPLY ( VALUES(2017, [2017]), (2018, [2018]), (2019, [2019]) ) AS A(orderyear, val);Det fina här är att om du inte är intresserad av att ta bort raderna med NULL i valkolumnen behöver du inte göra något speciellt. Det finns inget implicit steg här som tar bort raderna med NULLS. Dessutom, eftersom valkolumnaliaset skapas som en del av FROM-satsen, är den tillgänglig för WHERE-satsen. Så om du är intresserad av att ta bort NULL:erna kan du vara tydlig om det i WHERE-satsen genom att direkt interagera med värdekolumnaliaset, som så:
SELECT custid, orderyear, valFROM dbo.CustOrders CROSS APPLY ( VALUES(2017, [2017]), (2018, [2018]), (2019, [2019]) ) SOM A(orderår, val)WHERE val IS INTE NULL;Poängen är att den här syntaxen ger dig kontroll över om du vill behålla eller ta bort NULL. Det är mer flexibelt än UNPIVOT-operatören på ett annat sätt, vilket låter dig hantera flera opivoterade åtgärder som både val och antal. Mitt fokus i den här artikeln var dock fallgropen som involverade NULLs så jag kom inte in på den här aspekten.
Slutsats
Utformningen av PIVOT- och UNPIVOT-operatörerna leder ibland till buggar och fallgropar i din kod. PIVOT-operatorns syntax låter dig inte explicit ange grupperingselementet. Om du inte inser detta kan du sluta med oönskade grupperingselement. Som en bästa praxis rekommenderas det att du använder ett tabelluttryck som indata till PIVOT-operatorn, och det är därför som uttryckligen kontrollerar vad som är grupperingselementet.
UNPIVOT-operatorns syntax låter dig inte styra om du vill ta bort eller behålla rader med NULL i kolumnen för resultatvärden. Som en lösning använder du antingen en besvärlig lösning med funktionerna ISNULL och NULLIF, eller en lösning baserad på APPLY-operatorn och VALUES-satsen.
Jag nämnde också två återkopplingspunkter med förslag för att förbättra PIVOT- och UNPIVOT-operatörerna med mer explicita alternativ för att kontrollera operatörens beteende och dess delar.