MERGE um subconjunto da tabela de destino

71

Estou tentando usar uma MERGEinstrução para inserir ou excluir linhas de uma tabela, mas só quero atuar em um subconjunto dessas linhas. A documentação para MERGEpossui um aviso muito fortemente redigido:

É importante especificar apenas as colunas da tabela de destino que são usadas para fins de correspondência. Ou seja, especifique colunas da tabela de destino comparadas à coluna correspondente da tabela de origem. Não tente melhorar o desempenho da consulta filtrando as linhas na tabela de destino na cláusula ON, como especificando AND NOT target_table.column_x = value. Fazer isso pode retornar resultados inesperados e incorretos.

mas é exatamente isso que parece que tenho que fazer para fazer meu MERGEtrabalho.

Os dados que tenho são uma tabela de junção muitos-para-muitos padrão de itens para categorias (por exemplo, quais itens estão incluídos em quais categorias), assim:

CategoryId   ItemId
==========   ======
1            1
1            2
1            3
2            1
2            3
3            5
3            6
4            5

O que preciso fazer é substituir efetivamente todas as linhas de uma categoria específica por uma nova lista de itens. Minha tentativa inicial de fazer isso é assim:

MERGE INTO CategoryItem AS TARGET
USING (
  SELECT ItemId FROM SomeExternalDataSource WHERE CategoryId = 2
) AS SOURCE
ON SOURCE.ItemId = TARGET.ItemId AND TARGET.CategoryId = 2
WHEN NOT MATCHED BY TARGET THEN
    INSERT ( CategoryId, ItemId )
    VALUES ( 2, ItemId )
WHEN NOT MATCHED BY SOURCE AND TARGET.CategoryId = 2 THEN
    DELETE ;

Isso parece estar funcionando nos meus testes, mas estou fazendo exatamente o que o MSDN me adverte explicitamente para não fazer. Isso me preocupa com o fato de ter problemas inesperados mais tarde, mas não vejo outra maneira de fazer com que minhas MERGEúnicas linhas afetem o valor do campo específico ( CategoryId = 2) e ignore as linhas de outras categorias.

Existe uma maneira "mais correta" de alcançar esse mesmo resultado? E quais são os "resultados inesperados ou incorretos" sobre os quais o MSDN está me alertando?

KutuluMike
fonte
Sim, a documentação seria mais útil se tivesse um exemplo concreto de "resultados inesperados e incorretos".
AK
3
@AlexKuznetsov Há um exemplo aqui .
Paul White
@SQLKiwi obrigado pelo link - IMO a documentação seria muito melhor se fosse consultada na página original.
AK
11
@AlexKuznetsov Concordou. Infelizmente, a reorganização da BOL para 2012 quebrou isso, entre muitas outras coisas. Ele foi vinculado bastante bem na documentação do R2 2008.
Paul White

Respostas:

103

A MERGEinstrução tem uma sintaxe complexa e uma implementação ainda mais complexa, mas essencialmente a ideia é unir duas tabelas, filtrar para linhas que precisam ser alteradas (inseridas, atualizadas ou excluídas) e, em seguida, executar as alterações solicitadas. Dados os seguintes dados de amostra:

DECLARE @CategoryItem AS TABLE
(
    CategoryId  integer NOT NULL,
    ItemId      integer NOT NULL,

    PRIMARY KEY (CategoryId, ItemId),
    UNIQUE (ItemId, CategoryId)
);

DECLARE @DataSource AS TABLE
(
    CategoryId  integer NOT NULL,
    ItemId      integer NOT NULL

    PRIMARY KEY (CategoryId, ItemId)
);

INSERT @CategoryItem
    (CategoryId, ItemId)
VALUES
    (1, 1),
    (1, 2),
    (1, 3),
    (2, 1),
    (2, 3),
    (3, 5),
    (3, 6),
    (4, 5);

INSERT @DataSource
    (CategoryId, ItemId)
VALUES
    (2, 2);

Alvo

╔════════════╦════════╗
 CategoryId  ItemId 
╠════════════╬════════╣
          1       1 
          2       1 
          1       2 
          1       3 
          2       3 
          3       5 
          4       5 
          3       6 
╚════════════╩════════╝

Fonte

╔════════════╦════════╗
 CategoryId  ItemId 
╠════════════╬════════╣
          2       2 
╚════════════╩════════╝

O resultado desejado é substituir os dados no destino por dados da origem, mas apenas para CategoryId = 2. Seguindo a descrição MERGEfornecida acima, devemos escrever uma consulta que une a origem e o destino apenas nas chaves e filtrar as linhas apenas nas WHENcláusulas:

MERGE INTO @CategoryItem AS TARGET
USING @DataSource AS SOURCE ON 
    SOURCE.ItemId = TARGET.ItemId 
    AND SOURCE.CategoryId = TARGET.CategoryId
WHEN NOT MATCHED BY SOURCE 
    AND TARGET.CategoryId = 2 
    THEN DELETE
WHEN NOT MATCHED BY TARGET 
    AND SOURCE.CategoryId = 2 
    THEN INSERT (CategoryId, ItemId)
        VALUES (CategoryId, ItemId)
OUTPUT 
    $ACTION, 
    ISNULL(INSERTED.CategoryId, DELETED.CategoryId) AS CategoryId,
    ISNULL(INSERTED.ItemId, DELETED.ItemId) AS ItemId
;

Isso fornece os seguintes resultados:

╔═════════╦════════════╦════════╗
 $ACTION  CategoryId  ItemId 
╠═════════╬════════════╬════════╣
 DELETE            2       1 
 INSERT            2       2 
 DELETE            2       3 
╚═════════╩════════════╩════════╝
╔════════════╦════════╗
 CategoryId  ItemId 
╠════════════╬════════╣
          1       1 
          1       2 
          1       3 
          2       2 
          3       5 
          3       6 
          4       5 
╚════════════╩════════╝

O plano de execução é: Plano de mesclagem

Observe que as duas tabelas são verificadas completamente. Podemos achar isso ineficiente, porque apenas as linhas CategoryId = 2serão afetadas na tabela de destino. É aqui que entram os avisos no Books Online. Uma tentativa equivocada de otimizar para tocar apenas as linhas necessárias no destino é:

MERGE INTO @CategoryItem AS TARGET
USING 
(
    SELECT CategoryId, ItemId
    FROM @DataSource AS ds 
    WHERE CategoryId = 2
) AS SOURCE ON
    SOURCE.ItemId = TARGET.ItemId
    AND TARGET.CategoryId = 2
WHEN NOT MATCHED BY TARGET THEN
    INSERT (CategoryId, ItemId)
    VALUES (CategoryId, ItemId)
WHEN NOT MATCHED BY SOURCE THEN
    DELETE
OUTPUT 
    $ACTION, 
    ISNULL(INSERTED.CategoryId, DELETED.CategoryId) AS CategoryId,
    ISNULL(INSERTED.ItemId, DELETED.ItemId) AS ItemId
;

A lógica na ONcláusula é aplicada como parte da junção. Nesse caso, a associação é uma associação externa completa (consulte esta entrada do Books Online para saber o porquê). A aplicação da verificação da categoria 2 nas linhas de destino como parte de uma junção externa resulta em linhas com um valor diferente sendo excluído (porque elas não correspondem à origem):

╔═════════╦════════════╦════════╗
 $ACTION  CategoryId  ItemId 
╠═════════╬════════════╬════════╣
 DELETE            1       1 
 DELETE            1       2 
 DELETE            1       3 
 DELETE            2       1 
 INSERT            2       2 
 DELETE            2       3 
 DELETE            3       5 
 DELETE            3       6 
 DELETE            4       5 
╚═════════╩════════════╩════════╝

╔════════════╦════════╗
 CategoryId  ItemId 
╠════════════╬════════╣
          2       2 
╚════════════╩════════╝

A causa raiz é o mesmo motivo pelo qual os predicados se comportam de maneira diferente em uma ONcláusula de junção externa do que se especificado na WHEREcláusula. A MERGEsintaxe (e a implementação da junção, dependendo das cláusulas especificadas) apenas tornam mais difícil perceber que isso é verdade.

As orientações nos Manuais Online (expandidas na entrada Otimizando desempenho ) oferecem orientações que garantirão que a semântica correta seja expressa usando a MERGEsintaxe, sem que o usuário precise necessariamente entender todos os detalhes da implementação ou que explique as maneiras pelas quais o otimizador pode legitimamente reorganizar coisas por razões de eficiência de execução.

A documentação oferece três maneiras possíveis de implementar a filtragem antecipada:

A especificação de uma condição de filtragem na WHENcláusula garante resultados corretos, mas pode significar que mais linhas são lidas e processadas a partir das tabelas de origem e destino do que o estritamente necessário (como visto no primeiro exemplo).

A atualização através de uma visualização que contém a condição de filtragem também garante resultados corretos (já que as linhas alteradas devem estar acessíveis para atualização através da visualização), mas isso requer uma visualização dedicada e uma que siga as condições ímpares para atualizar as visualizações.

O uso de uma expressão de tabela comum acarreta riscos semelhantes à adição de predicados à ONcláusula, mas por razões ligeiramente diferentes. Em muitos casos, será seguro, mas requer análise especializada do plano de execução para confirmar isso (e testes práticos extensivos). Por exemplo:

WITH TARGET AS 
(
    SELECT * 
    FROM @CategoryItem
    WHERE CategoryId = 2
)
MERGE INTO TARGET
USING 
(
    SELECT CategoryId, ItemId
    FROM @DataSource
    WHERE CategoryId = 2
) AS SOURCE ON
    SOURCE.ItemId = TARGET.ItemId
    AND SOURCE.CategoryId = TARGET.CategoryId
WHEN NOT MATCHED BY TARGET THEN
    INSERT (CategoryId, ItemId)
    VALUES (CategoryId, ItemId)
WHEN NOT MATCHED BY SOURCE THEN
    DELETE
OUTPUT 
    $ACTION, 
    ISNULL(INSERTED.CategoryId, DELETED.CategoryId) AS CategoryId,
    ISNULL(INSERTED.ItemId, DELETED.ItemId) AS ItemId
;

Isso produz resultados corretos (não repetidos) com um plano mais ideal:

Mesclar plano 2

O plano lê apenas linhas da categoria 2 da tabela de destino. Isso pode ser uma consideração importante de desempenho se a tabela de destino for grande, mas é muito fácil cometer erros usando a MERGEsintaxe.

Às vezes, é mais fácil gravar as MERGEoperações DML separadas. Essa abordagem pode até ter um desempenho melhor que um único MERGE, fato que muitas vezes surpreende as pessoas.

DELETE ci
FROM @CategoryItem AS ci
WHERE ci.CategoryId = 2
AND NOT EXISTS 
(
    SELECT 1 
    FROM @DataSource AS ds 
    WHERE 
        ds.ItemId = ci.ItemId
        AND ds.CategoryId = ci.CategoryId
);

INSERT @CategoryItem
SELECT 
    ds.CategoryId, 
    ds.ItemId
FROM @DataSource AS ds
WHERE
    ds.CategoryId = 2;
Paul White
fonte
Sei que essa é uma pergunta muito antiga ... mas qualquer chance que você possa elaborar sobre "Usar uma expressão de tabela comum acarreta riscos semelhantes à adição de predicados à cláusula ON, mas por razões ligeiramente diferentes". Sei que a BOL também possui um aviso igualmente vago "Esse método é semelhante à especificação de critérios de pesquisa adicionais na cláusula ON e pode produzir resultados incorretos. Recomendamos que você evite usar esse método ...". O método CTE parece resolver meu caso de uso, no entanto, estou me perguntando se há um cenário que não estou considerando.
Henry Lee