Rechercher des lignes parent qui ont des ensembles identiques de lignes enfants

9

Supposons que j'ai une structure comme celle-ci:

Tableau des recettes

RecipeID
Name
Description

Tableau des ingrédients de recette

RecipeID
IngredientID
Quantity
UOM

La clé RecipeIngredientsest (RecipeID, IngredientID).

Quels sont les bons moyens de trouver des recettes en double? Une recette en double est définie comme ayant exactement le même ensemble d'ingrédients et de quantités pour chaque ingrédient.

J'ai pensé à utiliser FOR XML PATHpour combiner les ingrédients dans une seule colonne. Je n'ai pas complètement exploré cela, mais cela devrait fonctionner si je m'assure que les ingrédients / UOM / quantités sont triés dans la même séquence et ont un séparateur approprié. Existe-t-il de meilleures approches?

Il y a 48K recettes et 200K lignes d'ingrédients.

poussée
la source

Réponses:

7

Pour le schéma supposé suivant et les exemples de données

CREATE TABLE dbo.RecipeIngredients
    (
      RecipeId INT NOT NULL ,
      IngredientID INT NOT NULL ,
      Quantity INT NOT NULL ,
      UOM INT NOT NULL ,
      CONSTRAINT RecipeIngredients_PK 
          PRIMARY KEY ( RecipeId, IngredientID ) WITH (IGNORE_DUP_KEY = ON)
    ) ;

INSERT INTO dbo.RecipeIngredients
SELECT TOP (210000) ABS(CRYPT_GEN_RANDOM(8)/50000),
                     ABS(CRYPT_GEN_RANDOM(8) % 100),
                     ABS(CRYPT_GEN_RANDOM(8) % 10),
                     ABS(CRYPT_GEN_RANDOM(8) % 5)
FROM master..spt_values v1,                     
     master..spt_values v2


SELECT DISTINCT RecipeId, 'X' AS Name
INTO Recipes 
FROM  dbo.RecipeIngredients 

Cela comprenait 205 009 lignes d'ingrédients et 42 613 recettes. Ce sera légèrement différent à chaque fois en raison de l'élément aléatoire.

Il suppose relativement peu de dupes (la sortie après un exemple de série était de 217 groupes de recettes en double avec deux ou trois recettes par groupe). Le cas le plus pathologique basé sur les chiffres du PO serait 48 000 doublons exacts.

Un script pour le configurer est

DROP TABLE dbo.RecipeIngredients,Recipes
GO

CREATE TABLE Recipes(
RecipeId INT IDENTITY,
Name VARCHAR(1))

INSERT INTO Recipes 
SELECT TOP 48000 'X'
FROM master..spt_values v1,                     
     master..spt_values v2

CREATE TABLE dbo.RecipeIngredients
    (
      RecipeId INT NOT NULL ,
      IngredientID INT NOT NULL ,
      Quantity INT NOT NULL ,
      UOM INT NOT NULL ,
      CONSTRAINT RecipeIngredients_PK 
          PRIMARY KEY ( RecipeId, IngredientID )) ;

INSERT INTO dbo.RecipeIngredients
SELECT RecipeId,IngredientID,Quantity,UOM
FROM Recipes
CROSS JOIN (SELECT 1,1,1 UNION ALL SELECT 2,2,2 UNION ALL  SELECT 3,3,3 UNION ALL SELECT 4,4,4) I(IngredientID,Quantity,UOM)

Les opérations suivantes ont été effectuées en moins d'une seconde sur ma machine dans les deux cas.

CREATE TABLE #Concat
  (
     RecipeId     INT,
     concatenated VARCHAR(8000),
     PRIMARY KEY (concatenated, RecipeId)
  )

INSERT INTO #Concat
SELECT R.RecipeId,
       ISNULL(concatenated, '')
FROM   Recipes R
       CROSS APPLY (SELECT CAST(IngredientID AS VARCHAR(10)) + ',' + CAST(Quantity AS VARCHAR(10)) + ',' + CAST(UOM AS VARCHAR(10)) + ','
                    FROM   dbo.RecipeIngredients RI
                    WHERE  R.RecipeId = RecipeId
                    ORDER  BY IngredientID
                    FOR XML PATH('')) X (concatenated);

WITH C1
     AS (SELECT DISTINCT concatenated
         FROM   #Concat)
SELECT STUFF(Recipes, 1, 1, '')
FROM   C1
       CROSS APPLY (SELECT ',' + CAST(RecipeId AS VARCHAR(10))
                    FROM   #Concat C2
                    WHERE  C1.concatenated = C2.concatenated
                    ORDER  BY RecipeId
                    FOR XML PATH('')) R(Recipes)
WHERE  Recipes LIKE '%,%,%'

DROP TABLE #Concat 

Une mise en garde

J'ai supposé que la longueur de la chaîne concaténée ne dépasserait pas 896 octets. Si tel est le cas, une erreur sera générée au moment de l'exécution plutôt que d'échouer silencieusement. Vous devrez supprimer la clé primaire (et l'index créé implicitement) de la #temptable. La longueur maximale de la chaîne concaténée dans ma configuration de test était de 125 caractères.

Si la chaîne concaténée est trop longue pour être indexée, les performances de la XML PATHrequête finale consolidant les recettes identiques pourraient bien être médiocres. L'installation et l'utilisation d'une agrégation de chaînes CLR personnalisée serait une solution car cela pourrait faire la concaténation avec un seul passage des données plutôt qu'une auto-jointure non indexée.

SELECT YourClrAggregate(RecipeId)
FROM #Concat
GROUP BY concatenated

J'ai aussi essayé

WITH Agg
     AS (SELECT RecipeId,
                MAX(IngredientID)          AS MaxIngredientID,
                MIN(IngredientID)          AS MinIngredientID,
                SUM(IngredientID)          AS SumIngredientID,
                COUNT(IngredientID)        AS CountIngredientID,
                CHECKSUM_AGG(IngredientID) AS ChkIngredientID,
                MAX(Quantity)              AS MaxQuantity,
                MIN(Quantity)              AS MinQuantity,
                SUM(Quantity)              AS SumQuantity,
                COUNT(Quantity)            AS CountQuantity,
                CHECKSUM_AGG(Quantity)     AS ChkQuantity,
                MAX(UOM)                   AS MaxUOM,
                MIN(UOM)                   AS MinUOM,
                SUM(UOM)                   AS SumUOM,
                COUNT(UOM)                 AS CountUOM,
                CHECKSUM_AGG(UOM)          AS ChkUOM
         FROM   dbo.RecipeIngredients
         GROUP  BY RecipeId)
SELECT  A1.RecipeId AS RecipeId1,
        A2.RecipeId AS RecipeId2
FROM   Agg A1
       JOIN Agg A2
         ON A1.MaxIngredientID = A2.MaxIngredientID
            AND A1.MinIngredientID = A2.MinIngredientID
            AND A1.SumIngredientID = A2.SumIngredientID
            AND A1.CountIngredientID = A2.CountIngredientID
            AND A1.ChkIngredientID = A2.ChkIngredientID
            AND A1.MaxQuantity = A2.MaxQuantity
            AND A1.MinQuantity = A2.MinQuantity
            AND A1.SumQuantity = A2.SumQuantity
            AND A1.CountQuantity = A2.CountQuantity
            AND A1.ChkQuantity = A2.ChkQuantity
            AND A1.MaxUOM = A2.MaxUOM
            AND A1.MinUOM = A2.MinUOM
            AND A1.SumUOM = A2.SumUOM
            AND A1.CountUOM = A2.CountUOM
            AND A1.ChkUOM = A2.ChkUOM
            AND A1.RecipeId <> A2.RecipeId
WHERE  NOT EXISTS (SELECT *
                   FROM   (SELECT *
                           FROM   RecipeIngredients
                           WHERE  RecipeId = A1.RecipeId) R1
                          FULL OUTER JOIN (SELECT *
                                           FROM   RecipeIngredients
                                           WHERE  RecipeId = A2.RecipeId) R2
                            ON R1.IngredientID = R2.IngredientID
                               AND R1.Quantity = R2.Quantity
                               AND R1.UOM = R2.UOM
                   WHERE  R1.RecipeId IS NULL
                           OR R2.RecipeId IS NULL) 

Cela fonctionne de manière acceptable quand il y a relativement peu de doublons (moins d'une seconde pour le premier exemple de données) mais fonctionne mal dans le cas pathologique car l'agrégation initiale renvoie exactement les mêmes résultats pour chaque RecipeIDet ne parvient donc pas à réduire le nombre de comparaisons du tout.

Martin Smith
la source
Je ne sais pas si cela a beaucoup de sens de comparer des recettes "vides" mais j'ai également modifié ma requête à cet effet avant de finalement la publier, car c'est ce que les solutions de @ ypercube ont fait.
Andriy M
@AndriyM - Joe Celko le compare à la division par zéro dans son article sur la division relationnelle
Martin Smith
10

Il s'agit d'une généralisation du problème de la division relationnelle. Aucune idée de l'efficacité de cette opération:

; WITH cte AS
( SELECT RecipeID_1 = r1.RecipeID, Name_1 = r1.Name,
         RecipeID_2 = r2.RecipeID, Name_2 = r2.Name  
  FROM Recipes AS r1
    JOIN Recipes AS r2
      ON r1.RecipeID <> r2.RecipeID
  WHERE NOT EXISTS
        ( SELECT 1
          FROM RecipeIngredients AS ri1
          WHERE ri1.RecipeID = r1.RecipeID 
            AND NOT EXISTS
                ( SELECT 1
                  FROM RecipeIngredients AS ri2
                  WHERE ri2.RecipeID = r2.RecipeID 
                    AND ri1.IngredientID = ri2.IngredientID
                    AND ri1.Quantity = ri2.Quantity
                    AND ri1.UOM = ri2.UOM
                )
         )
)
SELECT c1.*
FROM cte AS c1
  JOIN cte AS c2
    ON  c1.RecipeID_1 = c2.RecipeID_2
    AND c1.RecipeID_2 = c2.RecipeID_1
    AND c1.RecipeID_1 < c1.RecipeID_2;

Une autre approche (similaire):

SELECT RecipeID_1 = r1.RecipeID, Name_1 = r1.Name,
       RecipeID_2 = r2.RecipeID, Name_2 = r2.Name 
FROM Recipes AS r1
  JOIN Recipes AS r2
    ON  r1.RecipeID < r2.RecipeID 
    AND NOT EXISTS
        ( SELECT IngredientID, Quantity, UOM
          FROM RecipeIngredients AS ri1
          WHERE ri1.RecipeID = r1.RecipeID
        EXCEPT 
          SELECT IngredientID, Quantity, UOM
          FROM RecipeIngredients AS ri2
          WHERE ri2.RecipeID = r2.RecipeID
        )
    AND NOT EXISTS
        ( SELECT IngredientID, Quantity, UOM
          FROM RecipeIngredients AS ri2
          WHERE ri2.RecipeID = r2.RecipeID
        EXCEPT 
          SELECT IngredientID, Quantity, UOM
          FROM RecipeIngredients AS ri1
          WHERE ri1.RecipeID = r1.RecipeID
        ) ;

Et un autre, différent:

; WITH cte AS
( SELECT RecipeID_1 = r.RecipeID, RecipeID_2 = ri.RecipeID, 
          ri.IngredientID, ri.Quantity, ri.UOM
  FROM Recipes AS r
    CROSS JOIN RecipeIngredients AS ri
)
, cte2 AS
( SELECT RecipeID_1, RecipeID_2,
         IngredientID, Quantity, UOM
  FROM cte
EXCEPT
  SELECT RecipeID_2, RecipeID_1,
         IngredientID, Quantity, UOM
  FROM cte
)

  SELECT RecipeID_1 = r1.RecipeID, RecipeID_2 = r2.RecipeID
  FROM Recipes AS r1
    JOIN Recipes AS r2
      ON r1.RecipeID < r2.RecipeID
EXCEPT 
  SELECT RecipeID_1, RecipeID_2
  FROM cte2
EXCEPT 
  SELECT RecipeID_2, RecipeID_1
  FROM cte2 ;

Testé chez SQL-Fiddle


En utilisant les fonctions CHECKSUM()et CHECKSUM_AGG(), testez à SQL-Fiddle-2 :
( ignorez cela car cela peut donner des faux positifs )

ALTER TABLE RecipeIngredients
  ADD ck AS CHECKSUM( IngredientID, Quantity, UOM )
    PERSISTED ;

CREATE INDEX ckecksum_IX
  ON RecipeIngredients
    ( RecipeID, ck ) ;

; WITH cte AS
( SELECT RecipeID,
         cka = CHECKSUM_AGG(ck)
  FROM RecipeIngredients AS ri
  GROUP BY RecipeID
)
SELECT RecipeID_1 = c1.RecipeID, RecipeID_2 = c2.RecipeID
FROM cte AS c1
  JOIN cte AS c2
    ON  c1.cka = c2.cka
    AND c1.RecipeID < c2.RecipeID  ;

ypercubeᵀᴹ
la source
Les plans d'exécution sont assez effrayants.
ypercubeᵀᴹ
Cela va au cœur de ma question, comment faire cela. Le plan d'exécution pourrait cependant être un facteur de rupture pour ma situation particulière.
poke
1
CHECKSUMet CHECKSUM_AGGvous laisse toujours besoin de vérifier les faux positifs.
Martin Smith
Pour une version réduite des données d'exemple dans ma réponse avec 470 recettes et 2057 lignes d'ingrédients, la requête 1 a Table 'RecipeIngredients'. Scan count 220514, logical reads 443643et la requête 2 Table 'RecipeIngredients'. Scan count 110218, logical reads 441214. Le troisième semble avoir des lectures relativement inférieures à ces deux-là, mais malgré les données d'échantillonnage complètes, j'ai annulé la requête après 8 minutes.
Martin Smith
Vous devriez pouvoir accélérer cela en comparant d'abord les chiffres. Fondamentalement, une paire de recettes ne peut pas avoir exactement le même ensemble d'ingrédients si le nombre d'ingrédients n'est pas identique.
TomTom