Encontre linhas pai que possuem conjuntos idênticos de linhas filho

9

Suponha que eu tenha uma estrutura como esta:

Tabela de receitas

RecipeID
Name
Description

Tabela RecipeIngredients

RecipeID
IngredientID
Quantity
UOM

A chave RecipeIngredientsé (RecipeID, IngredientID).

Quais são algumas boas maneiras de encontrar receitas duplicadas? Uma receita duplicada é definida como tendo exatamente o mesmo conjunto de ingredientes e quantidades para cada ingrediente.

Pensei em usar FOR XML PATHpara combinar os ingredientes em uma única coluna. Ainda não o explorei completamente, mas deve funcionar se garantir que os ingredientes / UOMs / quantidades sejam classificados na mesma sequência e possuam um separador adequado. Existem abordagens melhores?

Existem 48 mil receitas e 200 mil linhas de ingredientes.

cutucar
fonte

Respostas:

7

Para o seguinte esquema assumido e dados de exemplo

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 

Este povoou 205.009 linhas de ingredientes e 42.613 receitas. Isso será ligeiramente diferente a cada vez devido ao elemento aleatório.

Pressupõe relativamente poucos enganos (a saída após uma execução de exemplo foi de 217 grupos de receitas duplicados com duas ou três receitas por grupo). O caso mais patológico baseado nos números do PO seria 48.000 duplicatas exatas.

Um script para configurar isso é

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)

O seguinte foi concluído em menos de um segundo na minha máquina nos dois 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 

Uma ressalva

Eu assumi que o comprimento da seqüência concatenada não excederá 896 bytes. Se isso ocorrer, ocorrerá um erro no tempo de execução, em vez de falhar silenciosamente. Você precisará remover a chave primária (e o índice criado implicitamente) da #temptabela. O comprimento máximo da sequência concatenada na minha configuração de teste era de 125 caracteres.

Se a sequência concatenada for muito longa para indexar, o desempenho da XML PATHconsulta final consolidando as receitas idênticas poderá ser ruim. Instalar e usar uma agregação de cadeia CLR personalizada seria uma solução, pois isso poderia concatenar com uma passagem dos dados, em vez de uma associação automática não indexada.

SELECT YourClrAggregate(RecipeId)
FROM #Concat
GROUP BY concatenated

Eu também tentei

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) 

Isso funciona de maneira aceitável quando há relativamente poucas duplicatas (menos de um segundo para o primeiro exemplo de dados), mas apresenta um desempenho ruim no caso patológico, pois a agregação inicial retorna exatamente os mesmos resultados para todos RecipeIDe, portanto, não consegue reduzir o número de comparações.

Martin Smith
fonte
Não sei se faz muito sentido comparar receitas "vazias", mas mudei minha consulta para esse efeito também antes de finalmente publicá-la, visto que foi o que as soluções da @ ypercube fizeram.
Andriy M
@AndriyM - Joe Celko compara com divisão por zero em seu artigo divisão relacional
Martin Smith
10

Esta é uma generalização do problema de divisão relacional. Não faço ideia de quão eficiente isso será:

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

Outra abordagem (semelhante):

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

Testado no SQL-Fiddle


Usando as funções CHECKSUM()e CHECKSUM_AGG(), teste no SQL-Fiddle-2 :
( ignore isso, pois pode 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ᵀᴹ
fonte
Os planos de execução são assustadores.
ypercubeᵀᴹ
Isso está no cerne da minha pergunta, sobre como fazer isso. O plano de execução pode ser um rompimento de acordo para minha situação específica.
cutuca
11
CHECKSUMe CHECKSUM_AGGainda assim você precisa verificar se há falsos positivos.
Martin Smith
Para uma versão reduzida dos dados de exemplo na minha resposta com 470 receitas e 2057 linhas de ingredientes, a consulta 1 tem Table 'RecipeIngredients'. Scan count 220514, logical reads 443643e a consulta 2 Table 'RecipeIngredients'. Scan count 110218, logical reads 441214. O terceiro parece ter leituras relativamente mais baixas do que as duas, mas ainda com os dados completos da amostra, cancelei a consulta após 8 minutos.
Martin Smith
Você deve conseguir acelerar isso comparando as contagens primeiro. Basicamente, um par de receitas não pode ter exatamente o mesmo ingrediente definido se a contagem de ingredientes não for idêntica.
TomTom