Znajdź wiersze nadrzędne, które mają identyczne zestawy wierszy podrzędnych

9

Załóżmy, że mam taką strukturę:

Tabela przepisów

RecipeID
Name
Description

RecipeIngredients table

RecipeID
IngredientID
Quantity
UOM

Klucz RecipeIngredientsjest włączony (RecipeID, IngredientID).

Jakie są dobre sposoby na znalezienie zduplikowanych przepisów? Zduplikowana recepta jest zdefiniowana jako zawierająca dokładnie taki sam zestaw składników i ilości dla każdego składnika.

Myślałem o FOR XML PATHpołączeniu składników w jedną kolumnę. Nie w pełni to zbadałem, ale powinno działać, jeśli upewnię się, że składniki / jednostki masy / ilości są posortowane w tej samej kolejności i mają odpowiedni separator. Czy są lepsze podejścia?

Istnieje 48 000 przepisów i 200 000 wierszy składników.

szturchać
źródło

Odpowiedzi:

7

Dla następujących założonych schematów i przykładowych danych

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 

W tym zaludniono 205,009 rzędów składników i 42 613 przepisów. Będzie to nieco inne za każdym razem ze względu na losowy element.

Zakłada się względnie niewiele kopii (dane wyjściowe po przykładowym przebiegu wyniosły 217 zduplikowanych grup przepisów z dwoma lub trzema przepisami na grupę). Najbardziej patologicznym przypadkiem opartym na liczbach w PO byłoby 48 000 dokładnych duplikatów.

Skrypt do skonfigurowania to

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)

Poniższe czynności zostały wykonane w moim komputerze w niecałą sekundę w obu przypadkach.

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 

Jedno zastrzeżenie

Zakładałem, że długość połączonego łańcucha nie przekroczy 896 bajtów. Jeśli to zrobi, spowoduje to błąd w czasie wykonywania, a nie cichą awarię. Musisz usunąć klucz podstawowy (i domyślnie utworzony indeks) z #temptabeli. Maksymalna długość połączonego łańcucha w moim zestawie testowym wynosiła 125 znaków.

Jeśli połączony ciąg jest zbyt długi, aby go zindeksować, wydajność ostatniego XML PATHzapytania konsolidującego identyczne przepisy może być słaba. Zainstalowanie i użycie niestandardowej agregacji ciągów CLR byłoby jednym rozwiązaniem, ponieważ mogłoby to połączyć dane za pomocą jednego przejścia danych, a nie zindeksowanego nieindeksowanego połączenia.

SELECT YourClrAggregate(RecipeId)
FROM #Concat
GROUP BY concatenated

Też próbowałem

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) 

Działa to akceptowalnie, gdy jest stosunkowo mało duplikatów (mniej niż sekunda w przypadku danych z pierwszego przykładu), ale działa źle w przypadku patologii, ponieważ początkowa agregacja zwraca dokładnie takie same wyniki dla każdego, RecipeIDa zatem nie udaje się zmniejszyć liczby porównania w ogóle.

Martin Smith
źródło
Nie jestem pewien, czy porównywanie „pustych” przepisów ma sens, ale zmieniłem również moje zapytanie w tym celu, zanim w końcu je opublikowałem, ponieważ to właśnie zrobiły rozwiązania @ ypercube.
Andriy M
@AndriyM - Joe Celko porównuje to do dzielenia przez zero w swoim artykule na temat relacji relacyjnych
Martin Smith
10

Jest to uogólnienie problemu podziału relacyjnego. Nie mam pojęcia, jak efektywne to będzie:

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

Inne (podobne) podejście:

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

I inny, inny:

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

Testowane w SQL-Fiddle


Korzystając z funkcji CHECKSUM()i CHECKSUM_AGG(), przetestuj w SQL-Fiddle-2 :
( zignoruj ​​to, ponieważ może to dać fałszywie pozytywne wyniki )

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ᵀᴹ
źródło
Plany wykonania są trochę przerażające.
ypercubeᵀᴹ
To stanowi sedno mojego pytania, jak to zrobić. Plan wykonania może jednak stanowić przełom w mojej szczególnej sytuacji.
poke
1
CHECKSUMi CHECKSUM_AGGnadal musisz sprawdzać, czy nie ma wyników fałszywie pozytywnych.
Martin Smith
W przypadku skróconej wersji przykładowych danych w mojej odpowiedzi z 470 przepisami i 2057 wierszami składników zapytanie 1 zawiera Table 'RecipeIngredients'. Scan count 220514, logical reads 443643zapytanie 2 Table 'RecipeIngredients'. Scan count 110218, logical reads 441214. Trzeci wydaje się mieć relatywnie niższe odczyty niż te dwa, ale nadal przy pełnych przykładowych danych anulowałem zapytanie po 8 minutach.
Martin Smith
Powinieneś być w stanie to przyspieszyć, porównując najpierw liczby. Zasadniczo para przepisów nie może mieć dokładnie tego samego zestawu składników, jeśli liczba składników nie jest identyczna.
TomTom