Encuentre filas principales que tengan conjuntos idénticos de filas secundarias

9

Supongamos que tengo una estructura como esta:

Mesa de recetas

RecipeID
Name
Description

Tabla RecetaIngredientes

RecipeID
IngredientID
Quantity
UOM

La clave RecipeIngredientsestá en (RecipeID, IngredientID).

¿Cuáles son algunas buenas maneras de encontrar recetas duplicadas? Una receta duplicada se define como tener exactamente el mismo conjunto de ingredientes y cantidades para cada ingrediente.

He pensado en usar FOR XML PATHpara combinar los ingredientes en una sola columna. No he explorado completamente esto, pero debería funcionar si me aseguro de que los ingredientes / UOM / cantidades estén ordenados en la misma secuencia y tengan un separador adecuado. ¿Hay mejores enfoques?

Hay 48K recetas y 200K filas de ingredientes.

dar un toque
fuente

Respuestas:

7

Para el siguiente esquema asumido y datos de ejemplo

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 

Esto llenó 205.009 filas de ingredientes y 42.613 recetas. Esto será ligeramente diferente cada vez debido al elemento aleatorio.

Supone relativamente pocos engaños (el resultado después de una ejecución de ejemplo fue 217 grupos de recetas duplicadas con dos o tres recetas por grupo). El caso más patológico basado en las cifras en el OP sería 48,000 duplicados exactos.

Un script para configurar eso es

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)

Lo siguiente se completó en menos de un segundo en mi máquina para ambos casos.

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 

Una advertencia

Supuse que la longitud de la cadena concatenada no excederá los 896 bytes. Si lo hace, generará un error en tiempo de ejecución en lugar de fallar silenciosamente. Deberá eliminar la clave primaria (y el índice creado implícitamente) de la #temptabla. La longitud máxima de la cadena concatenada en mi configuración de prueba fue de 125 caracteres.

Si la cadena concatenada es demasiado larga para indexar, el rendimiento de la XML PATHconsulta final que consolida las recetas idénticas podría ser pobre. Instalar y usar una agregación de cadena CLR personalizada sería una solución, ya que podría hacer la concatenación con una pasada de los datos en lugar de una autounión no indexada.

SELECT YourClrAggregate(RecipeId)
FROM #Concat
GROUP BY concatenated

También intenté

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) 

Esto funciona de manera aceptable cuando hay relativamente pocos duplicados (menos de un segundo para los datos del primer ejemplo) pero funciona mal en el caso patológico ya que la agregación inicial devuelve exactamente los mismos resultados para cada uno RecipeIDy, por lo tanto, no logra reducir el número de comparaciones en absoluto.

Martin Smith
fuente
No estoy seguro de si tiene mucho sentido comparar recetas "vacías", pero también cambié mi consulta a ese efecto antes de finalmente publicarlo, ya que eso fue lo que hicieron las soluciones de @ypercube.
Andriy M
@AndriyM - Joe Celko lo compara con la división por cero en su artículo de división relacional
Martin Smith
10

Esta es una generalización del problema de división relacional. No tengo idea de cuán eficiente será esto:

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

Otro enfoque (similar):

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

Y otro, diferente:

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

Probado en SQL-Fiddle


Usando las funciones CHECKSUM()y CHECKSUM_AGG(), pruebe en SQL-Fiddle-2 :
( ignore esto ya que puede dar falsos positivos )

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ᵀᴹ
fuente
Los planes de ejecución son un poco atemorizantes.
ypercubeᵀᴹ
Esto llega al meollo de mi pregunta, cómo hacer esto. Sin embargo, el plan de ejecución podría ser un factor decisivo para mi situación particular.
meter
1
CHECKSUMy CHECKSUM_AGGaún así debes dejar de buscar falsos positivos.
Martin Smith
Para una versión reducida de los datos de ejemplo en mi respuesta con 470 recetas y 2057 filas de ingredientes, la consulta 1 tiene Table 'RecipeIngredients'. Scan count 220514, logical reads 443643y la consulta 2 Table 'RecipeIngredients'. Scan count 110218, logical reads 441214. El tercero parece tener lecturas relativamente más bajas que esas dos, pero aún contra los datos de muestra completos, cancelé la consulta después de 8 minutos.
Martin Smith
Debería poder acelerar esto comparando los recuentos primero. Básicamente, un par de recetas no pueden tener exactamente el mismo conjunto de ingredientes si el recuento de ingredientes no es idéntico.
TomTom