Suchen Sie übergeordnete Zeilen mit identischen Sätzen untergeordneter Zeilen

9

Angenommen, ich habe eine Struktur wie diese:

Rezepttabelle

RecipeID
Name
Description

RecipeIngredients-Tabelle

RecipeID
IngredientID
Quantity
UOM

Der Schlüssel RecipeIngredientsist (RecipeID, IngredientID).

Was sind einige gute Möglichkeiten, um doppelte Rezepte zu finden? Ein doppeltes Rezept enthält genau die gleichen Zutaten und Mengen für jede Zutat.

Ich habe darüber nachgedacht FOR XML PATH, die Zutaten in einer einzigen Spalte zu kombinieren. Ich habe dies noch nicht vollständig untersucht, aber es sollte funktionieren, wenn ich sicher bin, dass die Zutaten / Stückzahlen / Mengen in derselben Reihenfolge sortiert sind und ein geeignetes Trennzeichen haben. Gibt es bessere Ansätze?

Es gibt 48.000 Rezepte und 200.000 Zutatenreihen.

Sack
quelle

Antworten:

7

Für das folgende angenommene Schema und Beispieldaten

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 

Dies bevölkerte 205.009 Zutatenreihen und 42.613 Rezepte. Dies ist aufgrund des zufälligen Elements jedes Mal etwas anders.

Es werden relativ wenige Dupes angenommen (Ausgabe nach einem Beispiellauf waren 217 doppelte Rezeptgruppen mit zwei oder drei Rezepten pro Gruppe). Der pathologischste Fall, der auf den Zahlen im OP basiert, wären 48.000 exakte Duplikate.

Ein Skript zum Einrichten ist

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)

Das Folgende wurde in beiden Fällen in weniger als einer Sekunde auf meinem Computer abgeschlossen.

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 

Eine Einschränkung

Ich nahm an, dass die Länge der verketteten Zeichenfolge 896 Bytes nicht überschreiten wird. Wenn dies der Fall ist, wird zur Laufzeit ein Fehler ausgegeben, anstatt stillschweigend fehlzuschlagen. Sie müssen den Primärschlüssel (und den implizit erstellten Index) aus der #tempTabelle entfernen . Die maximale Länge der verketteten Zeichenfolge in meinem Testaufbau betrug 125 Zeichen.

Wenn die verkettete Zeichenfolge zu lang ist, um sie zu indizieren, kann die Leistung der endgültigen XML PATHAbfrage, die die identischen Rezepte konsolidiert, schlecht sein. Das Installieren und Verwenden einer benutzerdefinierten CLR-Zeichenfolgenaggregation wäre eine Lösung, da dies die Verkettung mit einem Durchgang der Daten anstelle eines nicht indizierten Self-Joins bewirken könnte.

SELECT YourClrAggregate(RecipeId)
FROM #Concat
GROUP BY concatenated

Ich habe es auch versucht

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) 

Dies funktioniert akzeptabel, wenn relativ wenige Duplikate vorhanden sind (weniger als eine Sekunde für die ersten Beispieldaten), ist jedoch im pathologischen Fall schlecht, da die anfängliche Aggregation für alle genau die gleichen Ergebnisse liefert RecipeIDund es daher nicht schafft, die Anzahl der Duplikate zu verringern Vergleiche überhaupt.

Martin Smith
quelle
Ich bin mir nicht sicher, ob es sinnvoll ist, "leere" Rezepte zu vergleichen, aber ich habe meine Abfrage auch in diesem Sinne geändert, bevor ich sie endgültig veröffentlicht habe, da dies die Lösungen von @ ypercube waren.
Andriy M
@AndriyM - Joe Celko vergleicht es mit Division durch Null in seinem Artikel über relationale Division
Martin Smith
10

Dies ist eine Verallgemeinerung des relationalen Teilungsproblems. Keine Ahnung, wie effizient dies sein wird:

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

Ein anderer (ähnlicher) Ansatz:

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

Und noch eine, andere:

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

Getestet bei SQL-Fiddle


Mit den CHECKSUM()und CHECKSUM_AGG()Funktionen, Test auf SQL-Fiddle-2 :
( ignorieren diese , wie es Fehlalarme geben kann )

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ᵀᴹ
quelle
Die Ausführungspläne sind irgendwie beängstigend.
Ypercubeᵀᴹ
Dies ist der Kern meiner Frage, wie das geht. Der Ausführungsplan könnte jedoch ein Deal-Breaker für meine spezielle Situation sein.
Poke
1
CHECKSUMund CHECKSUM_AGGSie müssen immer noch nach Fehlalarmen suchen.
Martin Smith
Für eine reduzierte Version der Beispieldaten in meiner Antwort mit 470 Rezepten und 2057 Zutatenzeilen hat Abfrage 1 Table 'RecipeIngredients'. Scan count 220514, logical reads 443643und Abfrage 2 Table 'RecipeIngredients'. Scan count 110218, logical reads 441214. Der dritte scheint relativ niedrigere Lesevorgänge zu haben als diese beiden, aber immer noch gegen die vollständigen Beispieldaten habe ich die Abfrage nach 8 Minuten abgebrochen.
Martin Smith
Sie sollten dies beschleunigen können, indem Sie zuerst die Anzahl vergleichen. Grundsätzlich kann ein Rezeptpaar nicht genau die gleichen Zutaten enthalten, wenn die Anzahl der Zutaten nicht identisch ist.
TomTom