Récemment, j’ai du faire un petit exercice relativement simple : importer des données en base depuis un fichier CSV.
Avec le bulk insert, c’est assez simple :
BULK INSERT #Bulk FROM 'C:\Temp\FichierAImporter.csv' WITH ( FIELDTERMINATOR = ';', ROWTERMINATOR = '\n', CODEPAGE = 'ACP', ROWS_PER_BATCH = 35000 )
Là où ça s’est compliqué, c’est la génération des identifiants fonctionnels avant leur insertion dans les "vraies" tables.
Pour simplifier un peu, voici le cas.
Je dois importer les nouvelles adresses de clients.
J’ai deux champs : Client et Adresse qui sont chargées depuis le fichier CSV.
Mais surtout, j’ai deux identifiants fonctionnels à créer.
Le premier est un identifiant interne, de la forme "FR00000000" dont la partie numérique est incrémentée pour chaque entrée. Le prochain identifiant est lisible depuis une table.
Le second identifiant est externe, c’est un numérique incrémenté par client.
C’est à dire que le client #1 possèdant déjà 50 adresses, la suivants sera la 51, la prochaine pour le client #2 est la 6ème.
La bonne vieille méthode, c’est de faire des boucles pour alimenter tout le bazar.
Mais comme on est dans une base de données, c’est quand même nettement mieux de faire de l’ensembliste.
C’est là que RANK et ROW_NUMBER viennent à la rescousse !
Les choix moches
La première solution, la plus violent et aussi la plus moche, c’est de faire une boucle avec un curseur.
C’est mal.
C’est peu performant et ça parcours les lignes un par une. Donc s’il y en a beaucoup, ça va être juste délirant.
La seconde solution, c’est de faire un while.
C’est mal.
Grosso modo pour les mêmes raisons que le curseur.
Un SGBDR, c’est quand même pour traiter des données en masse, pas à l’unité.
Le coup de la table variable
Une autre idée (d’un collègue qui a eu un cas légèrement différent) est de passer par une table avec tous les identifiants générés.
Inspiré de ce post : SQL SELECT to get the first N positive integers.
DECLARE @taillePlage INT = 5; DECLARE @prefixe CHAR(2) = 'FR'; DECLARE @debutPlage NUMERIC(9,0); SET @debutPlage = 12345; DECLARE @IdInternes TABLE ( NUMART VARCHAR(10) ); WITH plage AS ( SELECT 0 AS num UNION ALL SELECT num + 1 FROM plage WHERE num < @taillePlage - 1 ) INSERT INTO @IdInternes(NUMART) SELECT @prefixe + REPLICATE('0', 8 - LEN(@debutPlage + num)) + CAST(@debutPlage + num AS VARCHAR) FROM plage; SELECT * FROM @IdInternes;
Ici, la CTE (Common Table Expression) est assez utile et permet d’éviter une boucle disgracieuse.
C’est plutôt bien, mais ne réponds pas encore à mon besoin (ici, la difficulté va être d’affecter les identifiants, le problème n’est donc que décalé).
Le tout en un
La solution que j’ai (mis du temps) à apporter est la suivante :
(oui, c’est tout d’un bloc, mais commenté en même temps !)
-- Simulacre que Bulk Insert SELECT T.Client, T.Adresse INTO #Bulk FROM ( SELECT 'Client #1' AS Client, 'Adresse #1' AS Adresse UNION SELECT 'Client #1', 'Adresse #2' UNION SELECT 'Client #1', 'Adresse #3' UNION SELECT 'Client #2', 'Adresse #1' UNION SELECT 'Client #2', 'Adresse #2' UNION SELECT 'Client #3', 'Adresse #1' UNION SELECT 'Client #4', 'Adresse #1' UNION SELECT 'Client #5', 'Adresse #1' ) T -------------------------------------------------------------- -- Ajout des colonnes qui vont nous être utiles. -- Pour réaliser les calculs suivants. -- Pour le Seed, on alimente directement le champ. -- Il est utilisé pour identifier de façon unique la ligne. ALTER TABLE #Bulk ADD Seed UNIQUEIDENTIFIER DEFAULT NEWID() NOT NULL ALTER TABLE #Bulk ADD RowId INT NULL ALTER TABLE #Bulk ADD RankId INT NULL ALTER TABLE #Bulk ADD IdInterne VARCHAR(10) ALTER TABLE #Bulk ADD IdExterne INT -------------------------------------------------------------- -- Calcul des ROW_NUMBER et RANK UPDATE #Bulk SET #Bulk.RowId = t.ROW_NUMBER, #Bulk.RankId = t.RANK FROM #Bulk JOIN ( SELECT -- Le ROW_NUMBER est utilisé indifféremment du client, -- on l’ordonne juste pour la forme, donc. ROW_NUMBER() OVER(ORDER BY Client) - 1 AS ROW_NUMBER, -- Le RANK est par client, -- il faut donc les grouper ensemble via PARTITION -- et ordonner sur un critère (ici, Adresse). RANK() OVER(PARTITION BY Client ORDER BY Adresse) AS RANK, #Bulk.Seed FROM #Bulk ) AS t ON t.Seed = #Bulk.Seed -- Le ROW_NUMBER numérote à partir de 1, mais comme on va l'utiliser -- pour additionner à partir du prochain identifiant on va partir de zéro, -- donc soustraire 1. -- Le RANK numérote également à partir de 1, -- mais ici, on additionnera à partir de l'existant. -------------------------------------------------------------- -- Calcul de l'identifiant externe. -- La variable @NextIdInterne correspond au prochain identifiant. DECLARE @NextIdInterne INT = 12345; UPDATE #Bulk SET IdInterne = 'FR' + REPLICATE('0', 8 - LEN(@NextIdInterne + #Bulk.RowId)) + CAST(@NextIdInterne + #Bulk.RowId AS VARCHAR); -- Le REPLICATE va permettre d'ajouter autant de 0 que requit -- pour avoir des identifiants de taille fixe -------------------------------------------------------------- -- Calcul de l'identifiant externe. -- Common Table Expression simule la récupération de données -- depuis une table des adresses. -- NOTE: l'instruction précédente DOIT finir par un ; -- pour que la CTE fonctionne WITH IdentifiantsExternes AS ( SELECT 'Client #1' AS Client, 50 AS Identifiant UNION SELECT 'Client #2' AS Client, 5 AS Identifiant UNION SELECT 'Client #3' AS Client, 1 AS Identifiant UNION SELECT 'Client #4' AS Client, 10 AS Identifiant ) -- Une CTE DOIT être utilisée à l'instruction qui la suit. -- Elle peut donc être utilisée directement ou son résultat -- placé dans une table (temporaire/variable...). UPDATE #Bulk SET IdExterne = ISNULL(Identifiant, 0) + RankId FROM #Bulk LEFT JOIN IdentifiantsExternes ON #Bulk.Client = IdentifiantsExternes.Client -------------------------------------------------------------- SELECT * FROM #Bulk DROP TABLE #Bulk
Pour avoir quelques exemples : T-SQL RANK() , DENSE_RANK() , NTILE(), ROW_NUMBER().
Cadeau bonux
J’ai eu une erreur de typo dans une requête qui a plus ou moins cette tête :
SELECT FirstName, LastName INTO TempUsers -- Attendu : #TempUsers FROM Users
Et là, on a eu la surprise de voir que le SELECT INTO ne faisait pas que servait pas qu’à créer des tables temporaires.
Et oui, dans ce cas, il m’a créé une jolie table physique avec le résultat de ma requête et surtout une structure rigoureusement identique (sans les relations PF/FK cependant).
Je ne sais pas vous, mais moi, je savais pas ! ^^