web-dev-qa-db-fra.com

Sélectionnez une chaîne CSV comme plusieurs colonnes

J'utilise SQL Server 2014 et j'ai une table avec une colonne contenant une chaîne CSV :

110,200,310,130,null

La sortie du tableau ressemble à ceci:

select result

Je veux sélectionner la deuxième colonne comme plusieurs colonnes, en plaçant chaque élément de la chaîne CSV dans une colonne distincte, comme ceci:

expected result

J'ai donc créé une fonction pour fractionner une chaîne:

create FUNCTION [dbo].[fn_splitstring]
(
    @List nvarchar(2000),
    @SplitOn nvarchar(5)
)  
RETURNS @RtnValue table 
(
    Id int identity(1,1),
    Value nvarchar(100)
) 
AS  
BEGIN 
    while (Charindex(@SplitOn,@List)>0)
    begin
        insert into @RtnValue (value)
        select 
            Value = ltrim(rtrim(Substring(@List,1,Charindex(@SplitOn,@List)-1)))

        set @List = Substring(@List,Charindex(@SplitOn,@List)+len(@SplitOn),len(@List))
    end
    insert Into @RtnValue (Value)
    select Value = ltrim(rtrim(@List))

    return
END

Je voudrais l'utiliser comme ceci:

select Val , (select value from tvf_split_string(cchar1,','))  from table1

Mais le code ci-dessus ne fonctionnera évidemment pas, car la fonction renverra plus d'une ligne, ce qui provoquera le renvoi de plus d'une valeur par la sous-requête et la rupture du code.

Je peux utiliser quelque chose comme:

select Val ,
(select value from tvf_split_string(cchar1,',') order by id offset 0 rows fetch next 1 rows only ) as col1,
(select value from tvf_split_string(cchar1,',') order by id offset 1 rows fetch next 1 rows only ) as col2,
................
 from table1

mais je ne pense pas que ce soit une bonne approche.

Quelle est la bonne façon de procéder?

6
Reza

Pour résoudre ce problème, vous aurez probablement besoin d'un code de procédure supplémentaire. Différentes bases de données ont différents ensembles de fonctions de chaîne intégrées (comme vous le savez). Ainsi, pour trouver une solution à cela, j'ai écrit un code de "preuve de concept" qui est plutôt générique et utilise uniquement la fonction SUBSTR () (l'équivalent est SUBSTRING () dans MS SQL).

Lorsque vous regardez LOAD DATA ... (MySQL), vous pouvez voir que nous avons besoin d'un fichier .csv et d'une table existante. En utilisant ma fonction, vous pourrez sélectionner des sections du fichier .csv, en passant le nom de la colonne plus 2 entiers: un pour le numéro du "délimiteur de gauche", et un pour le numéro de "droite- délimiteur côté main ". (Ça a l'air horrible ...).

Exemple: supposons que nous ayons une valeur séparée par des virgules qui ressemble à ceci, et qu'elle soit stockée dans une colonne appelée csv:

 aaa,111,zzz

Si nous voulons "extraire" 111 de cela, nous appelons la fonction comme ceci:

 select split(csv, 1, 2) ... ;
 -- 1: start at the first comma
 -- 2: end at the second comma

Le "début" et la "fin" de la chaîne peuvent être sélectionnés comme ceci:

 select split(csv, 0, 1) ... ; -- from the start of the string (no comma) up to the first comma
 select split(csv, 2, 0) ... ; -- from the second comma right up to the end of the string

Je sais que le code de la fonction n'est pas parfait et qu'il peut être simplifié par endroits (par exemple, dans Oracle, nous devrions utiliser INSTR (), qui peut trouver une occurrence particulière d'une partie d'une chaîne). De plus, il n'y a aucune exception pour le moment. Ce n'est qu'une première ébauche. Voici ...

create or replace function split(
  csvstring varchar2
, lcpos number 
, rcpos number )
return varchar2
is
  slen pls_integer := 0 ;  -- string length
  comma constant varchar2(1) := ',' ;
  currentchar varchar2(1) := '' ;
  commacount pls_integer := 0 ;
  firstcommapos pls_integer := 0 ;
  secondcommapos pls_integer := 0 ;
begin

  slen := length(csvstring);

  -- special case: leftmost value
  if lcpos = 0 then
     firstcommapos := 0 ;
     for i in 1 .. slen
     loop    
        currentchar := substr(csvstring, i, 1) ;
        if currentchar = comma then
           secondcommapos := i - 1 ; 
           exit ;
        end if ;
     end loop ;
     return substr(csvstring, 1, secondcommapos) ;
  end if ;

  -- 2 commas somewhere in the middle of the string
  if lcpos > 0 and rcpos > 0 then
     for i in 1 .. slen
     loop    
        currentchar := substr(csvstring, i, 1) ;
        if currentchar = comma then
           commacount := commacount + 1;
           if commacount = lcpos then 
              firstcommapos := i ; 
           end if ;
           if commacount = rcpos then
              secondcommapos := i ;
           end if ;
        end if ;
     end loop ;
     return substr(csvstring, firstcommapos + 1, (secondcommapos-1) - firstcommapos ) ;
  end if ; 

  -- special case: rightmost value
  if rcpos = 0 then
     secondcommapos := slen ;
     for i in reverse 1 .. slen  -- caution: count DOWN!
     loop    
        currentchar := substr(csvstring, i, 1) ;
        if currentchar = comma then
           firstcommapos := i + 1  ; 
           exit ;
        end if ;
     end loop ;
     return substr(csvstring, firstcommapos, secondcommapos-(firstcommapos-1)) ;
  end if ;

end split;

Essai:

-- test table, test data
create table csv (
  id number generated always as identity primary key
, astring varchar2(256) 
);

-- insert some test data
begin
  insert into csv (astring) values ('123,456,88789,null,null');
  insert into csv (astring) values ('123,456,99789,1234,null');
  insert into csv (astring) values ('123,456,00789,1234,null');
  insert into csv (astring) values ('1,2222,77789,null,null');
  insert into csv (astring) values ('11,222,88789,null,');
  insert into csv (astring) values ('111,22,99789,,');
  insert into csv (astring) values ('1111,2,00789,oooo,null');
end;

-- testing:
select 
  split(astring,0,1) col1
, split(astring,1,2) col2
, split(astring,2,3) col3
, split(astring,3,4) col4
, split(astring,4,0) col5
from csv

-- output
COL1    COL2    COL3    COL4    COL5
123     456     88789   null    null
123     456     99789   1234    null
123     456     00789   1234    null
1       2222    77789   null    null
11      222     88789   null    -
111     22      99789   -       -
1111    2       00789   oooo    null

... La fonction semble exagérée. Cependant, si nous écrivons plus de code procédural, le SQL en fonction de celui-ci devient plutôt "élégant". Bonne chance dans le traitement de votre csv!

3
stefan

Un mot sur les performances des différents types de fonctions

De manière générale, les fonctions scalaires et les mTVF (Multi-Statement Table Valued Functions) sont un peu un problème de performances intégré. Il est préférable, si vous le pouvez, d'utiliser les iTVF (Inline Table Valued Functions) car leur code est réellement inclus dans le plan d'exécution (un peu comme une VUE mais paramétré) au lieu d'être exécuté séparément. Plutôt que de reproduire un article entier ici, veuillez consulter ce qui suit pour une preuve même lorsqu'un iTVF est utilisé comme iSF (fonction scalaire en ligne). Dans de nombreux cas, les fonctions scalaires peuvent être 7 fois plus lentes que les iTVF même lorsque le code est identique. Voici le lien, auquel vous pouvez désormais accéder sans avoir à vous inscrire pour devenir membre. Pour résumer, si votre fonction contient le mot "BEGIN", ce ne sera PAS un iTVF haute performance.

Comment rendre les FDU scalaires plus rapides (Spackle SQL)


Données de test pour SQL Server 2008 ou version ultérieure:

En passant au problème d'origine signalé par @Reza et en empruntant fortement au code fourni par @stefan pour générer des exemples de lignes de données, voici les données de test que nous utiliserons. Je le fais dans une table temporaire afin que nous puissions facilement supprimer la table de test sans avoir à craindre de supprimer accidentellement une vraie table.

--===== If the test table exists, drop it to make reruns in SSMS eaiser
     IF OBJECT_ID('tempdb..#CSV','U') IS NOT NULL
   DROP TABLE #CSV
;
--===== Create the test table.
 CREATE TABLE #CSV --Using a Temp Table just for demo purposes
        (
         ID         INT IDENTITY(1,1) --or whatever your PK is
        ,AString    VARCHAR(8000)
        )
        ;
--===== Insert some test data
 INSERT INTO #CSV
        (AString)
 SELECT AString
   FROM (
        VALUES   ('123,456,88789,null,null')
                ,('123,456,99789,1234,null')
                ,('123,456,00789,1234,null')
                ,('1,2222,77789,null,null')
                ,('11,222,88789,null,')
                ,('111,22,99789,,')
                ,('1111,2,00789,oooo,null')
        ) v (AString)
;

Données de test pour SQL Server 2005 ou version ultérieure:

Si vous utilisez toujours SQL Server 2005, ne désespérez pas. Tout le code y fonctionnera toujours. Nous avons juste besoin de changer la façon dont nous générons les données de test car la clause VALUES ne pouvait pas gérer la façon dont nous avons généré les données ci-dessus jusqu'en 2008. À la place, vous devez utiliser une série d'instructions SELECT/UNION ALL. Il n'y a pratiquement aucune différence de performances entre les deux si vous avez besoin d'un plus grand volume de données et la méthode SELECT/UNION ALL fonctionne toujours très bien jusqu'en 2016.

Voici le code de génération de table de test modifié pour fonctionner pour 2005.

--===== If the test table exists, drop it to make reruns in SSMS eaiser
     IF OBJECT_ID('tempdb..#CSV','U') IS NOT NULL
   DROP TABLE #CSV
;
--===== Create the test table.
 CREATE TABLE #CSV --Using a Temp Table just for demo purposes
        (
         ID         INT IDENTITY(1,1) --or whatever your PK is
        ,AString    VARCHAR(8000)
        )
        ;
--===== Insert some test data
 INSERT INTO #CSV
        (AString)
 SELECT AString
   FROM (
         SELECT '123,456,88789,null,null' UNION ALL
         SELECT '123,456,99789,1234,null' UNION ALL
         SELECT '123,456,00789,1234,null' UNION ALL
         SELECT '1,2222,77789,null,null'  UNION ALL
         SELECT '11,222,88789,null,'      UNION ALL
         SELECT '111,22,99789,,'          UNION ALL
         SELECT '1111,2,00789,oooo,null'
        ) v (AString)
;

Choisir un séparateur

Tout d'abord, SQL Server n'est pas le meilleur outil à utiliser pour les manipulations de chaînes. Il a été prouvé à plusieurs reprises qu'un SQLCLR correctement écrit fumera à peu près n'importe quelle tentative de fractionnement de chaîne par rapport aux solutions T-SQL pures. Les problèmes associés aux solutions SQLCLR sont 1) de trouver celui qui est correctement écrit (renvoie correctement l'ensemble de résultats attendu) et 2) il est parfois difficile de convaincre les DBA résidents d'autoriser l'utilisation de SQLCLR. Si vous en trouvez un qui est correctement écrit pour renvoyer les résultats attendus, cela vaut vraiment la peine de passer du temps à essayer de convaincre le DBA car il gérera généralement les types de données MAX et se souciera peu si vous le transmettez les données VARCHAR ou NVARCHAR, sans parler d'être plus rapides que toute solution T-SQL que j'ai jamais vue.

Si vous n'avez pas à vous soucier de l'utilisation de NVARCHAR et que vous n'avez pas à vous soucier des délimiteurs à plusieurs caractères (ou pouvez changer le délimiteur "en cours de route") et que vous n'avez pas à vous soucier des types de données MAX (limité à 8K chaînes), alors la fonction DelimitedSplit8K peut être pour vous. Vous pouvez trouver l'article correspondant ainsi que les tests de "cardinalité élevée" appropriés à l'URL suivante.

Tally OH! Une fonction améliorée "Splitter CSV" SQL 8K

Je vous dirai également que le code de génération de données de test à cardinalité élevée dans cet article s'est rompu lorsque SQL Server 2012 est sorti en raison de modifications apportées à la façon dont XML peut être utilisé pour concaténer. Wayne Sheffield a effectué des tests distincts pour le comparer à la nouvelle fonction de séparateur en 2016 et a effectué une réparation du code de génération de données de test dans l'article suivant. Notez qu'il y a également 3 autres répartiteurs qui sont disponibles dans l'article, dont 2 ne sont disponibles qu'en 2016. Il teste également par rapport à un SQLCLR et répertorie certaines des différences fonctionnelles (lacunes dans certains cas) de chaque répartiteur.

Fractionnement de chaînes dans SQL Server 2016

Il y a aussi un article par Eirikur Eiriksson qui explore non seulement une amélioration sérieuse des performances, mais explore également le fractionnement des "vrais CSV" qui peuvent être inclus des délimiteurs entre les "qualificateurs de texte" (bien qu'à des frais considérables de performances). Voici le lien vers cet article.

Profiter des avantages des fonctions Window dans T-SQL

Malgré tout, même il démontre qu'un SQLCLR est beaucoup plus rapide que tout ce que vous pouvez faire en utilisant uniquement T-SQL. Je recommande cette méthode au lieu de toute méthode T-SQL, mais FAITES des tests de fonctionnalité étendus pour vous assurer qu'elle retournera ce que vous attendez dans certains des cas "étranges".


Résolution du problème signalé

D'accord. Désolé pour le détournement de longue haleine sur les séparateurs. Revenons au problème d'origine.

Nous devons faire deux choses:

  1. Fractionnez les CSV de manière à savoir de quelle ligne provient chaque élément fractionné.

  2. Réassemblez les éléments divisés en colonnes distinctes dans la même ligne pour chaque ligne que nous divisons.

Tout d'abord, nous devons créer une fonction de séparation. Voici une modification de DelimitedSplit8K sur laquelle je travaille (enfin, pas récemment ;-)) qui supportera toujours SQL Server 2005. La grande différence est d'utiliser un classement binaire pour les performances supplémentaires. Ce rendu n'est pas encore entièrement testé (je l'utilise personnellement depuis septembre 2013) mais, compte tenu de la simplicité des changements et du fait qu'aucun d'entre eux ne modifie réellement la fonctionnalité, je pense que vous le trouverez satisfaisant pour vos besoins.

CREATE FUNCTION [dbo].[DelimitedSplit8K]
/**********************************************************************************************************************
 Purpose:
 Given a string containing multiple elements separated by a single character delimiter and that single character
 delimiter, this function will split the string and return a table of the single elements (Item) and the element
 position within the string (ItemNumber). 

 Notes:
 1. Performance of this function approaches that of a CLR.
 2. Note that this code implicitly converts NVARCHAR to VARCHAR and that conversion may NOT be faithful.

 Revision History:
 Note that this code is a modification of a well proven function created as a community effort and initially documented
 at the following URL (http://www.sqlservercentral.com/articles/Tally+Table/72993/). This code is still undergoing 
 tests. Although every care has certainly been taken to ensure its accuracy, you are reminded to do your own tests to 
 ensure that this function is suitable for whatever application you might use it for.
 --Jeff Moden, 01 Sep 2013 
**********************************************************************************************************************/
--===== Define I/O parameters
        (@pString VARCHAR(8000) , @pDelimiter CHAR(1)) --DO NOT USE MAX DATA-TYPES HERE!  IT WILL KILL PERFORMANCE!
RETURNS TABLE WITH SCHEMABINDING AS
 RETURN
--===== "Inline" CTE Driven "Tally Table" produces values from 1 up to 10,000...
     -- enough to cover VARCHAR(8000).
  WITH E1(N) AS (--==== Itzik Ben-Gan style of a cCTE (Cascading CTE) and
                     -- should not be confused with a much slower rCTE (Recursive CTE).
                 SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL
                 SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL
                 SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
                ),                                      --10E+1 or 10 rows
       E4(N) AS (SELECT 1 FROM E1 a, E1 b, E1 c, E1 d), --10E+4 or 10,000 rows max
 cteTally(N) AS ( --=== This provides the "base" CTE and limits the number of rows right up front
                     -- for both a performance gain and prevention of accidental "overruns"
                 SELECT TOP (ISNULL(DATALENGTH(@pString),0)) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) FROM E4
                ),
cteStart(N1) AS ( --=== This returns N+1 (starting position of each "element" just once for each delimiter)
                 SELECT 1 UNION ALL
                 SELECT CASE WHEN SUBSTRING(@pString,t.N,1) = @pDelimiter COLLATE Latin1_General_BIN THEN t.N+1 END --added short circuit for casting
                   FROM cteTally t 
                  WHERE SUBSTRING(@pString,t.N,1) = @pDelimiter COLLATE Latin1_General_BIN
                ),
cteLen(N1,L1)AS ( --=== Return start position and length (for use in substring).
                     -- The ISNULL/NULLIF combo handles the length for the final of only element.
                 SELECT s.N1,
                        ISNULL(NULLIF(CHARINDEX(@pDelimiter ,@pString COLLATE Latin1_General_BIN,s.N1) ,0)-s.N1,8000) 
                   FROM cteStart s
                )
--===== Do the actual split.
 SELECT ItemNumber = ROW_NUMBER() OVER(ORDER BY l.N1),
        Item       = SUBSTRING(@pString, l.N1, l.L1)
   FROM cteLen l
;
GO

Une fois que ce mauvais garçon est en place, des problèmes comme ceux du message d'origine deviennent triviaux à résoudre en utilisant soit un PIVOT soit un CROSS TAB. Je préfère la méthode CROSS TAB plutôt ancienne car elle est souvent plus rapide (parfois de manière significative) qu'un PIVOT réel. Veuillez consulter l'article suivant sur les tests qui y sont effectués (il est assez ancien et devrait probablement être mis à jour pour les mesures de performances, bien qu'il explique comment utiliser les deux méthodes).

Tableaux croisés et pivots, Partie 1 - Conversion de lignes en colonnes

Voici le code qui utilise la fonction ci-dessus et un onglet CROSS pour résoudre le problème d'origine publié sur ce fil.

--===== Do the split for each row and repivot to columns using a 
     -- high performance CROSS TAB. It uses the function only once
     -- for each row, which is another advantage iTVFs have over
     -- Scalare Functions.
 SELECT  csv.ID
        ,Col1 = MAX(CASE WHEN ca.ItemNumber = 1 THEN Item ELSE '' END)
        ,Col2 = MAX(CASE WHEN ca.ItemNumber = 2 THEN Item ELSE '' END)
        ,Col3 = MAX(CASE WHEN ca.ItemNumber = 3 THEN Item ELSE '' END)
        ,Col4 = MAX(CASE WHEN ca.ItemNumber = 4 THEN Item ELSE '' END)
        ,Col5 = MAX(CASE WHEN ca.ItemNumber = 5 THEN Item ELSE '' END)
   FROM #CSV csv
  CROSS APPLY dbo.DelimitedSplit8K(csv.AString,',') ca
  GROUP BY csv.ID
;
GO

Voici l'ensemble de résultats du code ci-dessus en utilisant les données de test données.

Results

Si vous avez des questions ou avez besoin d'éclaircissements à ce sujet, n'hésitez pas à les poser.

7
Jeff Moden