Consulta detalhando diferenças entre linhas para uma grande quantidade de dados

15

Eu tenho várias tabelas grandes, cada uma com mais de 300 colunas. O aplicativo que estou usando cria "arquivos" de linhas alteradas, fazendo uma cópia da linha atual em uma tabela secundária.

Considere um exemplo trivial:

CREATE TABLE dbo.bigtable
(
  UpdateDate datetime,
  PK varchar(12) PRIMARY KEY,
  col1 varchar(100),
  col2 int,
  col3 varchar(20),
  .
  .
  .
  colN datetime
);

Tabela de arquivamento:

CREATE TABLE dbo.bigtable_archive
(
  UpdateDate datetime,
  PK varchar(12) NOT NULL,
  col1 varchar(100),
  col2 int,
  col3 varchar(20),
  .
  .
  .
  colN datetime
);

Antes de qualquer atualização ser executada dbo.bigtable, uma cópia da linha é criada dbo.bigtable_archivee dbo.bigtable.UpdateDateatualizada com a data atual.

Portanto, UNIONagrupar as duas tabelas e agrupar por PKcria uma linha do tempo das alterações, quando solicitadas por UpdateDate.

Desejo criar um relatório detalhando as diferenças entre linhas, ordenadas por UpdateDate, agrupadas por PK, no seguinte formato:

PK,   UpdateDate,  ColumnName,  Old Value,   New Value

Old Valuee New Valuepodem ser as colunas relevantes expressos a um VARCHAR(MAX)(não existem TEXTou BYTEcolunas envolvidos), como eu não precisa fazer qualquer pós-processamento dos próprios valores.

No momento, não consigo pensar em uma maneira sensata de fazer isso para uma grande quantidade de colunas, sem recorrer a gerar as consultas programaticamente - talvez seja necessário fazer isso.

Aberto a muitas idéias, então adicionarei uma recompensa à pergunta após 2 dias.

Philᵀᴹ
fonte

Respostas:

15

Isso não vai parecer bonito, especialmente considerando as mais de 300 colunas e a indisponibilidade de LAG, nem é provável que ele funcione muito bem, mas, como algo para começar, eu tentaria a seguinte abordagem:

  • UNION as duas mesas.
  • Para cada PK no conjunto combinado, obtenha sua "encarnação" anterior na tabela de arquivamento (a implementação abaixo usa OUTER APPLY+ TOP (1)como um homem pobre LAG).
  • Lance cada coluna de dados varchar(max)e desmonte-os em pares, ou seja, o valor atual e o anterior ( CROSS APPLY (VALUES ...)funciona bem para esta operação).
  • Por fim, filtre os resultados com base em se os valores em cada par diferem entre si.

O Transact-SQL do acima, como eu o vejo:

WITH
  Combined AS
  (
    SELECT * FROM dbo.bigtable
    UNION ALL
    SELECT * FROM dbo.bigtable_archive
  ) AS derived,
  OldAndNew AS
  (
    SELECT
      this.*,
      OldCol1 = last.Col1,
      OldCol2 = last.Col2,
      ...
    FROM
      Combined AS this
      OUTER APPLY
      (
        SELECT TOP (1)
          *
        FROM
          dbo.bigtable_archive
        WHERE
          PK = this.PK
          AND UpdateDate < this.UpdateDate
        ORDER BY
          UpdateDate DESC
      ) AS last
  )
SELECT
  t.PK,
  t.UpdateDate,
  x.ColumnName,
  x.OldValue,
  x.NewValue
FROM
  OldAndNew AS t
  CROSS APPLY
  (
    VALUES
    ('Col1', CAST(t.OldCol1 AS varchar(max), CAST(t.Col1 AS varchar(max))),
    ('Col2', CAST(t.OldCol2 AS varchar(max), CAST(t.Col2 AS varchar(max))),
    ...
  ) AS x (ColumnName, OldValue, NewValue)
WHERE
  NOT EXISTS (SELECT x.OldValue INTERSECT x.NewValue)
ORDER BY
  t.PK,
  t.UpdateDate,
  x.ColumnName
;
Andriy M
fonte
13

Se você não dinamizar os dados em uma tabela temporária

create table #T
(
  PK varchar(12) not null,
  UpdateDate datetime not null,
  ColumnName nvarchar(128) not null,
  Value varchar(max),
  Version int not null
);

Você pode combinar as linhas para encontrar um valor novo e antigo com uma associação automática PK,ColumnName e Version = Version + 1.

A parte não tão bonita é, é claro, fazer o descompasso de suas 300 colunas na tabela temporária das duas tabelas base.

XML para o resgate para tornar as coisas menos complicadas.

É possível dinamizar os dados com XML sem precisar saber quais colunas reais existem na tabela que não serão dinâmicas. Os nomes das colunas devem ser válidos como nomes de elementos em XML ou ocorrerão falhas.

A idéia é criar um XML para cada linha com todos os valores para essa linha.

select bt.PK,
       bt.UpdateDate,
       (select bt.* for xml path(''), elements xsinil, type) as X
from dbo.bigtable as bt;
<UpdateDate xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">2001-01-03T00:00:00</UpdateDate>
<PK xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">PK1</PK>
<col1 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">c1_1_3</col1>
<col2 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">3</col2>
<col3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:nil="true" />
<colN xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">2001-01-03T00:00:00</colN>

elements xsinil existe para criar elementos para colunas com NULL .

O XML pode ser fragmentado usando nodes('*') para obter uma linha para cada coluna e usar local-name(.)o nome do elemento get e text()obter o valor.

  select C1.PK,
         C1.UpdateDate,
         T.X.value('local-name(.)', 'nvarchar(128)') as ColumnName,
         T.X.value('text()[1]', 'varchar(max)') as Value
  from C1
    cross apply C1.X.nodes('row/*') as T(X)

Solução completa abaixo. Observe que Versionestá invertido. 0 = Última versão.

create table #X
(
  PK varchar(12) not null,
  UpdateDate datetime not null,
  Version int not null,
  RowData xml not null
);

create table #T
(
  PK varchar(12) not null,
  UpdateDate datetime not null,
  ColumnName nvarchar(128) not null,
  Value varchar(max),
  Version int not null
);


insert into #X(PK, UpdateDate, Version, RowData)
select bt.PK,
       bt.UpdateDate,
       0,
       (select bt.* for xml path(''), elements xsinil, type)
from dbo.bigtable as bt
union all
select bt.PK,
       bt.UpdateDate,
       row_number() over(partition by bt.PK order by bt.UpdateDate desc),
       (select bt.* for xml path(''), elements xsinil, type)
from dbo.bigtable_archive as bt;

with C as 
(
  select X.PK,
         X.UpdateDate,
         X.Version,
         T.C.value('local-name(.)', 'nvarchar(128)') as ColumnName,
         T.C.value('text()[1]', 'varchar(max)') as Value
  from #X as X
    cross apply X.RowData.nodes('*') as T(C)
)
insert into #T (PK, UpdateDate, ColumnName, Value, Version)
select C.PK,
       C.UpdateDate,
       C.ColumnName,
       C.Value,
       C.Version
from C 
where C.ColumnName not in (N'PK', N'UpdateDate');

/*
option (querytraceon 8649);

The above query might need some trick to go parallel.
For the testdata I had on my machine exection time is 16 seconds vs 2 seconds
https://sqlkiwi.blogspot.com/2011/12/forcing-a-parallel-query-execution-plan.html
http://dataeducation.com/next-level-parallel-plan-forcing-an-alternative-to-8649/

*/

select New.PK,
       New.UpdateDate,
       New.ColumnName,
       Old.Value as OldValue,
       New.Value as NewValue
from #T as New
  left outer join #T as Old
    on Old.PK = New.PK and
       Old.ColumnName = New.ColumnName and
       Old.Version = New.Version + 1;
Mikael Eriksson
fonte
6

Eu sugiro outra abordagem.

Embora você não possa alterar o aplicativo atual, pode ser que você possa alterar o comportamento do banco de dados.

Se possível, eu adicionaria dois TRIGGERS às tabelas atuais.

Um INSTEAD OF INSERT no dbo.bigtable_archive que adiciona o novo registro apenas se ele não existir no momento.

CREATE TRIGGER dbo.IoI_BTA
ON dbo.bigtable_archive
INSTEAD OF INSERT
AS
BEGIN
    IF NOT EXISTs(SELECT 1 
                  FROM dbo.bigtable_archive bta
                  INNER JOIN inserted i
                  ON  bta.PK = i.PK
                  AND bta.UpdateDate = i.UpdateDate)
    BEGIN
        INSERT INTO dbo.bigtable_archive
        SELECT * FROM inserted;
    END
END

E um gatilho AFTER INSERT no bigtable que faz exatamente o mesmo trabalho, mas usando dados do bigtable.

CREATE TRIGGER dbo.IoI_BT
ON dbo.bigtable
AFTER INSERT
AS
BEGIN
    IF NOT EXISTS(SELECT 1 
                  FROM dbo.bigtable_archive bta
                  INNER JOIN inserted i
                  ON  bta.PK = i.PK
                  AND bta.UpdateDate = i.UpdateDate)
    BEGIN
        INSERT INTO dbo.bigtable_archive
        SELECT * FROM inserted;
    END
END

Ok, eu configurei um pequeno exemplo aqui com esses valores iniciais:

SELECT * FROM bigtable;
SELECT * FROM bigtable_archive;
UpdateDate | PK col1 | col2 col3
: ------------------ | : - | : --- | ---: | : ---
02/01/2017 00:00:00 | Abc C3 1 | C1  

UpdateDate | PK col1 | col2 col3
: ------------------ | : - | : --- | ---: | : ---
01/01/2017 00:00:00 | Abc C1 1 | C1  

Agora você deve inserir no bigtable_archive todos os registros pendentes do bigtable.

INSERT INTO bigtable_archive
SELECT *
FROM   bigtable
WHERE  UpdateDate >= '20170102';
SELECT * FROM bigtable_archive;
GO
UpdateDate | PK col1 | col2 col3
: ------------------ | : - | : --- | ---: | : ---
01/01/2017 00:00:00 | Abc C1 1 | C1  
02/01/2017 00:00:00 | Abc C3 1 | C1  

Agora, na próxima vez que o aplicativo tentar inserir um registro na tabela bigtable_archive, os gatilhos detectarão se ele existe e a inserção será evitada.

INSERT INTO dbo.bigtable_archive VALUES('20170102', 'ABC', 'C3', 1, 'C1');
GO
SELECT * FROM bigtable_archive;
GO
UpdateDate | PK col1 | col2 col3
: ------------------ | : - | : --- | ---: | : ---
01/01/2017 00:00:00 | Abc C1 1 | C1  
02/01/2017 00:00:00 | Abc C3 1 | C1  

Obviamente agora você pode obter a linha do tempo das alterações consultando apenas a tabela de arquivamento. E o aplicativo nunca perceberá que um gatilho está silenciosamente fazendo o trabalho debaixo das cobertas.

dbfiddle aqui

McNets
fonte
4

Trabalhando proposta, w / alguns dados de exemplo, pode ser encontrada @ rextester: UNPIVOT bigtable


A essência da operação:

1 - Use syscolumns e for xml para gerar dinamicamente nossas listas de colunas para a operação não dinâmica; todos os valores serão convertidos em varchar (max), com NULLs sendo convertidos na string 'NULL' (isso soluciona problemas com o pivô ignorando valores NULL)

2 - Gere uma consulta dinâmica para descompactar dados na tabela temp #columns

  • Por que uma tabela temporária vs CTE (via with cláusula)? preocupado com um possível problema de desempenho para um grande volume de dados e uma auto-junção CTE sem esquema de índice / hash utilizável; uma tabela temporária permite a criação de um índice que deve melhorar o desempenho da associação automática [consulte a associação lenta da CTE ]
  • Os dados são gravados em #columns na ordem PK + ColName + UpdateDate, permitindo armazenar valores de PK / Colname em linhas adjacentes; uma coluna de identidade ( rid ) nos permite ingressar nessas linhas consecutivas via rid = rid + 1

3 - Realize uma junção automática da tabela #temp para gerar a saída desejada

Recortar e colar do rextester ...

Crie alguns dados de amostra e nossa tabela #columns:

CREATE TABLE dbo.bigtable
(UpdateDate datetime      not null
,PK         varchar(12)   not null
,col1       varchar(100)      null
,col2       int               null
,col3       varchar(20)       null
,col4       datetime          null
,col5       char(20)          null
,PRIMARY KEY (PK)
);

CREATE TABLE dbo.bigtable_archive
(UpdateDate datetime      not null
,PK         varchar(12)   not null
,col1       varchar(100)      null
,col2       int               null
,col3       varchar(20)       null
,col4       datetime          null
,col5       char(20)          null
,PRIMARY KEY (PK, UpdateDate)
);

insert into dbo.bigtable         values ('20170512', 'ABC', NULL, 6, 'C1', '20161223', 'closed')

insert into dbo.bigtable_archive values ('20170427', 'ABC', NULL, 6, 'C1', '20160820', 'open')
insert into dbo.bigtable_archive values ('20170315', 'ABC', NULL, 5, 'C1', '20160820', 'open')
insert into dbo.bigtable_archive values ('20170212', 'ABC', 'C1', 1, 'C1', '20160820', 'open')
insert into dbo.bigtable_archive values ('20170109', 'ABC', 'C1', 1, 'C1', '20160513', 'open')

insert into dbo.bigtable         values ('20170526', 'XYZ', 'sue', 23, 'C1', '20161223', 're-open')

insert into dbo.bigtable_archive values ('20170401', 'XYZ', 'max', 12, 'C1', '20160825', 'cancel')
insert into dbo.bigtable_archive values ('20170307', 'XYZ', 'bob', 12, 'C1', '20160825', 'cancel')
insert into dbo.bigtable_archive values ('20170223', 'XYZ', 'bob', 12, 'C1', '20160820', 'open')
insert into dbo.bigtable_archive values ('20170214', 'XYZ', 'bob', 12, 'C1', '20160513', 'open')
;

create table #columns
(rid        int           identity(1,1)
,PK         varchar(12)   not null
,UpdateDate datetime      not null
,ColName    varchar(128)  not null
,ColValue   varchar(max)      null
,PRIMARY KEY (rid, PK, UpdateDate, ColName)
);

A coragem da solução:

declare @columns_max varchar(max),
        @columns_raw varchar(max),
        @cmd         varchar(max)

select  @columns_max = stuff((select ',isnull(convert(varchar(max),'+name+'),''NULL'') as '+name
                from    syscolumns
                where   id   = object_id('dbo.bigtable')
                and     name not in ('PK','UpdateDate')
                order by name
                for xml path(''))
            ,1,1,''),
        @columns_raw = stuff((select ','+name
                from    syscolumns
                where   id   = object_id('dbo.bigtable')
                and     name not in ('PK','UpdateDate')
                order by name
                for xml path(''))
            ,1,1,'')


select @cmd = '
insert #columns (PK, UpdateDate, ColName, ColValue)
select PK,UpdateDate,ColName,ColValue
from
(select PK,UpdateDate,'+@columns_max+' from bigtable
 union all
 select PK,UpdateDate,'+@columns_max+' from bigtable_archive
) p
unpivot
  (ColValue for ColName in ('+@columns_raw+')
) as unpvt
order by PK, ColName, UpdateDate'

--select @cmd

execute(@cmd)

--select * from #columns order by rid
;

select  c2.PK, c2.UpdateDate, c2.ColName as ColumnName, c1.ColValue as 'Old Value', c2.ColValue as 'New Value'
from    #columns c1,
        #columns c2
where   c2.rid                       = c1.rid + 1
and     c2.PK                        = c1.PK
and     c2.ColName                   = c1.ColName
and     isnull(c2.ColValue,'xxx')   != isnull(c1.ColValue,'xxx')
order by c2.UpdateDate, c2.PK, c2.ColName
;

E os resultados:

insira a descrição da imagem aqui

Nota: desculpas ... não consegui descobrir uma maneira fácil de cortar e colar a saída do rextester em um bloco de código. Estou aberto a sugestões.


Potenciais questões / preocupações:

1 - a conversão de dados em um varchar genérico (máximo) pode levar à perda da precisão dos dados, o que, por sua vez, pode significar que perdemos algumas alterações nos dados; considere os seguintes pares datetime e float que, quando convertidos / convertidos no genérico 'varchar (max)', perdem sua precisão (ou seja, os valores convertidos são os mesmos):

original value       varchar(max)
-------------------  -------------------
06/10/2017 10:27:15  Jun 10 2017 10:27AM
06/10/2017 10:27:18  Jun 10 2017 10:27AM

    234.23844444                 234.238
    234.23855555                 234.238

    29333488.888            2.93335e+007
    29333499.999            2.93335e+007

Embora a precisão dos dados pudesse ser mantida, seria necessário um pouco mais de codificação (por exemplo, conversão com base nos tipos de dados da coluna de origem); por enquanto, optei por manter o varchar genérico (máximo) de acordo com a recomendação do OP (e supor que o OP conheça os dados suficientemente bem para saber que não encontraremos problemas de perda de precisão de dados).

2 - para conjuntos de dados realmente grandes, corremos o risco de estourar alguns recursos do servidor, seja espaço tempdb e / ou cache / memória; O problema principal vem da explosão de dados que ocorre durante um ponto não dinâmico (por exemplo, passamos de 1 linha e 302 partes de dados para 300 linhas e 1200-1500 partes de dados, incluindo 300 cópias das colunas PK e UpdateDate, 300 nomes de colunas)

markp
fonte
1

Essa abordagem usa consulta dinâmica para gerar um sql para obter as alterações. O SP usa um nome de tabela e esquema e fornece a saída que você deseja.

As suposições são que as colunas PK e UpdateDate estão presentes em todas as tabelas. E todas as tabelas de arquivamento têm o formato originalTableName + "_archive" ..

NB: Eu não verifiquei o desempenho.

Nota: como ele usa sql dinâmico, devo acrescentar ressalvas sobre segurança / injeção de sql. Restrinja o acesso ao SP e adicione outras validações para evitar a injeção de sql.

    CREATE proc getTableChanges
    @schemaname  varchar(255),
    @tableName varchar(255)
    as

    declare @strg nvarchar(max), @colNameStrg nvarchar(max)='', @oldValueString nvarchar(max)='', @newValueString nvarchar(max)=''

    set @strg = '
    with cte as (

    SELECT  * , ROW_NUMBER() OVER(partition by PK ORDER BY UpdateDate) as RowNbr
    FROM    (

        SELECT  *
        FROM    [' + @schemaname + '].[' + @tableName + ']

        UNION

        SELECT  *
        FROM    [' + @schemaname + '].[' + @tableName + '_archive]

        ) a

    )
    '


    SET @strg = @strg + '

    SELECT  a.pk, a.updateDate, 
    CASE '

    DECLARE @colName varchar(255)
    DECLARE cur CURSOR FOR
        SELECT  COLUMN_NAME
        FROM    INFORMATION_SCHEMA.COLUMNS
        WHERE TABLE_SCHEMA = @schemaname
        AND TABLE_NAME = @tableName
        AND COLUMN_NAME NOT IN ('PK', 'Updatedate')

    OPEN cur
    FETCH NEXT FROM cur INTO @colName 

    WHILE @@FETCH_STATUS = 0
    BEGIN

        SET @colNameStrg  = @colNameStrg  + ' when a.' + @colName + ' <> b.' + @colName + ' then ''' + @colName + ''' '
        SET @oldValueString = @oldValueString + ' when a.' + @colName + ' <> b.' + @colName + ' then cast(a.' + @colName + ' as varchar(max))'
        SET @newValueString = @newValueString + ' when a.' + @colName + ' <> b.' + @colName + ' then cast(b.' + @colName + ' as varchar(max))'


    FETCH NEXT FROM cur INTO @colName 
    END

    CLOSE cur
    DEALLOCATE cur


    SET @colNameStrg = @colNameStrg  + '    END as ColumnChanges '
    SET @oldValueString = 'CASE ' + @oldValueString + ' END as OldValue'
    SET @newValueString = 'CASE ' + @newValueString + ' END as NewValue'

    SET @strg = @strg + @colNameStrg + ',' + @oldValueString + ',' + @newValueString

    SET @strg = @strg + '
        FROM    cte a join cte b on a.PK = b.PK and a.RowNbr + 1 = b.RowNbr 
        ORDER BY  a.pk, a.UpdateDate
    '

    print @strg

    execute sp_executesql @strg


    go

Exemplo de chamada:

exec getTableChanges 'dbo', 'bigTable'
Dharmendar Kumar 'DK'
fonte
Se não me engano, isso não captura várias alterações feitas na mesma linha, certo?
Mikael Eriksson
isso mesmo. várias colunas atualizadas ao mesmo tempo não serão capturadas. somente a primeira coluna com uma alteração será capturada.
Dharmendar Kumar 'DK'
1

Estou usando o AdventureWorks2012`, Production.ProductCostHistory e Production.ProductListPriceHistory no meu exemplo. Pode não ser o exemplo perfeito da tabela de histórico, "mas o script é capaz de reunir a saída desejada e a saída correta".

     DECLARE @sql NVARCHAR(MAX)
    ,@columns NVARCHAR(Max)
    ,@table VARCHAR(200) = 'ProductCostHistory'
    ,@Schema VARCHAR(200) = 'Production'
    ,@Archivecolumns NVARCHAR(Max)
    ,@ColForUnpivot NVARCHAR(Max)
    ,@ArchiveColForUnpivot NVARCHAR(Max)
    ,@PKCol VARCHAR(200) = 'ProductID'
    ,@UpdatedCol VARCHAR(200) = 'modifiedDate'
    ,@Histtable VARCHAR(200) = 'ProductListPriceHistory'
SELECT @columns = STUFF((
            SELECT ',CAST(p.' + QUOTENAME(column_name) + ' AS VARCHAR(MAX)) AS ' + QUOTENAME(column_name)
            FROM information_schema.columns
            WHERE table_name = @table
                AND column_name NOT IN (
                    @PKCol
                    ,@UpdatedCol
                    )
            ORDER BY ORDINAL_POSITION
            FOR XML PATH('')
            ), 1, 1, '')
    ,@Archivecolumns = STUFF((
            SELECT ',CAST(p1.' + QUOTENAME(column_name) + ' AS VARCHAR(MAX)) AS ' + QUOTENAME('A_' + column_name)
            FROM information_schema.columns
            WHERE table_name = @Histtable
                AND column_name NOT IN (
                    @PKCol
                    ,@UpdatedCol
                    )
            ORDER BY ORDINAL_POSITION
            FOR XML PATH('')
            ), 1, 1, '')
    ,@ColForUnpivot = STUFF((
            SELECT ',' + QUOTENAME(column_name)
            FROM information_schema.columns
            WHERE table_name = @table
                AND column_name NOT IN (
                    @PKCol
                    ,@UpdatedCol
                    )
            ORDER BY ORDINAL_POSITION
            FOR XML PATH('')
            ), 1, 1, '')
    ,@ArchiveColForUnpivot = STUFF((
            SELECT ',' + QUOTENAME('A_' + column_name)
            FROM information_schema.columns
            WHERE table_name = @Histtable
                AND column_name NOT IN (
                    @PKCol
                    ,@UpdatedCol
                    )
            ORDER BY ORDINAL_POSITION
            FOR XML PATH('')
            ), 1, 1, '')

--SELECT @columns   ,@Archivecolumns    ,@ColForUnpivot
SET @sql = N' 
    SELECT ' + @PKCol + ', ColumnName,
            OldValue,NewValue,' + @UpdatedCol + '
    FROM    (  
    SELECT p.' + @PKCol + '
        ,p.' + @UpdatedCol + '
        ,' + @columns + '
        ,' + @Archivecolumns + '
    FROM ' + @Schema + '.' + @table + ' p
    left JOIN ' + @Schema + '.' + @Histtable + ' p1 ON p.' + @PKCol + ' = p1.' + @PKCol + '

  ) t
    UNPIVOT (
        OldValue
        FOR ColumnName in (' + @ColForUnpivot + ')
    ) up

     UNPIVOT (
        NewValue
        FOR ColumnName1 in (' + @ArchiveColForUnpivot + ')
    ) up1

--print @sql
EXEC (@sql)

Aqui na consulta interna Select, considere p como tabela principal e p1 como tabela de histórico.

Você pode usar qualquer outro nome de tabela com menos nome de coluna para entender meu script. Qualquer explicação precisa ser executada com o ping.

KumarHarsh
fonte