Trova le righe principali con set identici di righe secondarie


9

Supponiamo che io abbia una struttura come questa:

Tabella delle ricette

RecipeID
Name
Description

Tabella ingredienti ricetta

RecipeID
IngredientID
Quantity
UOM

La chiave RecipeIngredientsè (RecipeID, IngredientID).

Quali sono alcuni buoni modi per trovare ricette duplicate? Una ricetta duplicata è definita come avere lo stesso identico insieme di ingredienti e quantità per ciascun ingrediente.

Ho pensato di usare FOR XML PATHper combinare gli ingredienti in una singola colonna. Non l'ho esplorato completamente, ma dovrebbe funzionare se mi assicuro che gli ingredienti / UOM / quantità siano ordinati nella stessa sequenza e abbiano un separatore adeguato. Ci sono approcci migliori?

Esistono 48.000 ricette e 200.000 righe di ingredienti.

Risposte:


7

Per il seguente schema presunto e dati di esempio

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 

Questo popolato 205.009 righe di ingredienti e 42.613 ricette. Questo sarà leggermente diverso ogni volta a causa dell'elemento casuale.

Presuppone relativamente pochi duplicati (l'output dopo un'esecuzione di esempio era 217 gruppi di ricette duplicati con due o tre ricette per gruppo). Il caso più patologico basato sulle cifre del PO sarebbero 48.000 duplicati esatti.

Uno script per configurarlo è

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)

Quanto segue completato in meno di un secondo sulla mia macchina per entrambi i casi.

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 

Un avvertimento

Ho assunto che la lunghezza della stringa concatenata non supererà 896 byte. In tal caso, verrà generato un errore in fase di esecuzione anziché un errore silenzioso. Sarà necessario rimuovere la chiave primaria (e l'indice implicitamente creato) dalla #temptabella. La lunghezza massima della stringa concatenata nella mia configurazione di test era di 125 caratteri.

Se la stringa concatenata è troppo lunga per essere indicizzata, le prestazioni della XML PATHquery finale che consolidano le ricette identiche potrebbero essere scadenti. L'installazione e l'utilizzo di un'aggregazione di stringhe CLR personalizzata sarebbe una soluzione in quanto potrebbe concatenare un passaggio dei dati anziché un self join non indicizzato.

SELECT YourClrAggregate(RecipeId)
FROM #Concat
GROUP BY concatenated

Ho anche provato

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) 

Funziona in modo accettabile quando ci sono relativamente pochi duplicati (meno di un secondo per i dati del primo esempio) ma funziona male nel caso patologico poiché l'aggregazione iniziale restituisce esattamente gli stessi risultati per ogni RecipeIDe quindi non riesce a ridurre il numero di confronti a tutti.


Non sono sicuro che abbia molto senso confrontare ricette "vuote", ma ho modificato anche la mia query in tal senso prima di pubblicarla, visto che era quello che hanno fatto le soluzioni di @ ypercube.
Andriy M,

@AndriyM - Joe Celko lo confronta con la divisione per zero nel suo articolo di divisione relazionale
Martin Smith,

10

Questa è una generalizzazione del problema della divisione relazionale. Non ho idea di quanto sarà efficiente:

; 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;

Un altro approccio (simile):

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
        ) ;

E un altro, diverso:

; 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 ;

Testato su SQL-Fiddle


Utilizzando le funzioni CHECKSUM()e CHECKSUM_AGG(), test in SQL-Fiddle-2 :
( ignorare questo dato che può dare falsi positivi )

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  ;


I piani di esecuzione sono in qualche modo spaventosi.
ypercubeᵀᴹ

Questo è al centro della mia domanda, su come farlo. Il piano di esecuzione potrebbe tuttavia essere un problema per la mia situazione particolare.
colpì il

1
CHECKSUMe CHECKSUM_AGGti lascia comunque la necessità di verificare la presenza di falsi positivi.
Martin Smith,

Per una versione ridotta dei dati di esempio nella mia risposta con 470 ricette e 2057 righe di ingredienti la query 1 ha Table 'RecipeIngredients'. Scan count 220514, logical reads 443643e la query 2 Table 'RecipeIngredients'. Scan count 110218, logical reads 441214. Il terzo sembra avere letture relativamente più basse di quelle due, ma nonostante i dati di esempio completi ho annullato la query dopo 8 minuti.
Martin Smith,

Dovresti essere in grado di accelerarlo confrontando prima i conteggi. Fondamentalmente una coppia di ricette non può avere esattamente lo stesso set di ingredienti se il conteggio degli ingredienti non è identico.
TomTom,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.