Запрос подробных различий между строками для большого количества данных

15

У меня есть несколько больших таблиц, каждая с> 300 столбцами. Приложение, которое я использую, создает «архивы» измененных строк, делая копию текущей строки во вторичной таблице.

Рассмотрим тривиальный пример:

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

Архивная таблица:

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

Перед выполнением каких-либо обновлений dbo.bigtableсоздается копия строки dbo.bigtable_archive, затем dbo.bigtable.UpdateDateобновляется текущая дата.

Таким UNIONобразом, объединение двух таблиц и группировка по ним PKсоздает временную шкалу изменений при заказе UpdateDate.

Я хочу создать отчет, детализирующий различия между строками, упорядоченными по UpdateDateгруппам PK, в следующем формате:

PK,   UpdateDate,  ColumnName,  Old Value,   New Value

Old Valueи New Valueмогут быть релевантными столбцами, приведенными к VARCHAR(MAX)( столбцы отсутствуют TEXTили BYTEвовлечены), поскольку мне не нужно выполнять какую-либо постобработку самих значений.

В настоящее время я не могу придумать разумного способа сделать это для большого количества столбцов, не прибегая к генерации запросов программным способом - возможно, мне придется это сделать.

Открыто для множества идей, поэтому я добавлю награду к вопросу через 2 дня.

Philᵀᴹ
источник

Ответы:

15

Это не будет выглядеть красиво, особенно учитывая более 300 столбцов и недоступность LAG, и вряд ли это будет работать очень хорошо, но для начала я бы попробовал следующий подход:

  • UNION две таблицы.
  • Для каждого PK в объединенном наборе, получить его предыдущее «воплощение» из архивной таблицы (реализация ниже использует OUTER APPLY + TOP (1)как бедняков LAG).
  • Приведите каждый столбец данных к varchar(max)Приведите и отключите их попарно, т. Е. Текущее и предыдущее значение ( CROSS APPLY (VALUES ...)хорошо подходит для этой операции).
  • Наконец, отфильтруйте результаты на основе того, отличаются ли значения в каждой паре друг от друга.

Transact-SQL выше, как я вижу это:

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
;
Андрей М
источник
13

Если вы отключите данные во временную таблицу

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

Вы могли бы соответствовать строкам , чтобы найти новое и старое значение с автообъединением на PK, ColumnNameиVersion = Version + 1 .

Не очень приятная часть, конечно, - это разворачивание ваших 300 столбцов во временную таблицу из двух базовых таблиц.

XML на помощь, чтобы сделать вещи менее неловко.

Можно отключить данные с помощью XML, не зная, какие фактические столбцы есть в таблице, которая будет не развернута. Имена столбцов должны быть действительными в качестве имен элементов в XML, иначе произойдет сбой.

Идея состоит в том, чтобы создать один XML для каждой строки, имеющей все значения для этой строки.

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Есть ли для создания элементов для столбцов с NULL.

Затем XML можно уничтожить, используя, nodes('*') чтобы получить по одной строке для каждого столбца и использовать local-name(.)для получения имени элемента и text()для получения значения.

  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)

Полное решение ниже. Обратите внимание, что Versionобратное. 0 = последняя версия

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;
Микаэль Эрикссон
источник
6

Я бы предложил вам другой подход.

Хотя вы не можете изменить текущее приложение, возможно, вы можете изменить поведение базы данных.

Если возможно, я бы добавил два ТРИГГЕРА в текущие таблицы.

Один INSTEAD OF INSERT в dbo.bigtable_archive, который добавляет новую запись, только если она в данный момент не существует.

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

И триггер AFTER INSERT на bigtable, который выполняет ту же работу, но использует данные 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

Хорошо, я настроил небольшой пример здесь с этими начальными значениями:

SELECT * FROM bigtable;
SELECT * FROM bigtable_archive;
UpdateDate | ПК | col1 | col2 | col3
: ------------------ | : - | : --- | ---: | : ---
01.02.2017 00:00:00 | ABC | С3 | 1 | С1  

UpdateDate | ПК | col1 | col2 | col3
: ------------------ | : - | : --- | ---: | : ---
01.01.2017 00:00:00 | ABC | C1 | 1 | С1  

Теперь вы должны вставить в bigtable_archive все ожидающие записи из bigtable.

INSERT INTO bigtable_archive
SELECT *
FROM   bigtable
WHERE  UpdateDate >= '20170102';
SELECT * FROM bigtable_archive;
GO
UpdateDate | ПК | col1 | col2 | col3
: ------------------ | : - | : --- | ---: | : ---
01.01.2017 00:00:00 | ABC | C1 | 1 | С1  
01.02.2017 00:00:00 | ABC | С3 | 1 | С1  

Теперь, когда в следующий раз приложение попытается вставить запись в таблицу bigtable_archive, триггеры обнаружат, существует ли она, и вставка будет исключена.

INSERT INTO dbo.bigtable_archive VALUES('20170102', 'ABC', 'C3', 1, 'C1');
GO
SELECT * FROM bigtable_archive;
GO
UpdateDate | ПК | col1 | col2 | col3
: ------------------ | : - | : --- | ---: | : ---
01.01.2017 00:00:00 | ABC | C1 | 1 | С1  
01.02.2017 00:00:00 | ABC | С3 | 1 | С1  

Очевидно, что теперь вы можете получить график изменений, запросив только архивную таблицу. И приложение никогда не поймет, что триггер спокойно выполняет свою работу под прикрытием.

dbfiddle здесь

McNets
источник
4

Рабочее предложение с некоторыми примерами данных можно найти @ rextester: bigtable unpivot


Суть операции:

1 - Используйте syscolumns и для xml, чтобы динамически генерировать наши списки столбцов для операции unpivot; все значения будут преобразованы в varchar (макс.), причем значения W / NULL будут преобразованы в строку 'NULL' (это устраняет проблему с пропуском значений NULL при отмене разворота)

2 - Сгенерировать динамический запрос, чтобы отключить данные во временную таблицу #columns

  • Почему временная таблица против КТРА (через с п)? озабоченность потенциальной проблемой производительности для большого объема данных и самостоятельного объединения CTE без использования схемы индекса / хеширования; временная таблица позволяет создать индекс, который должен улучшить производительность при самосоединении [см. медленное самовключение CTE ]
  • Данные записываются в #columns в порядке PK + ColName + UpdateDate, что позволяет нам хранить значения PK / Colname в соседних строках; столбец идентичности ( рид ) позволяет нам самостоятельно объединять эти последовательные строки с помощью рид = рид + 1

3 - Выполните самостоятельное соединение таблицы #temp, чтобы сгенерировать желаемый результат

Резка-склеивание от ректестера ...

Создайте пример данных и нашу таблицу #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)
);

Суть решения:

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
;

И результаты:

введите описание изображения здесь

Примечание: извинения ... не могли найти простой способ вырезать-вставить-вставить результат реекстера в блок кода. Я открыт для предложений.


Потенциальные проблемы / проблемы:

1 - преобразование данных в общий varchar (max) может привести к потере точности данных, что, в свою очередь, может означать, что мы пропускаем некоторые изменения данных; рассмотрим следующие пары datetime и float, которые при преобразовании / приведении к универсальному varchar (max) теряют свою точность (т. е. преобразованные значения одинаковы):

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

Хотя точность данных может быть сохранена, для этого потребуется немного больше кодирования (например, приведение на основе типов данных исходного столбца); на данный момент я решил придерживаться универсального varchar (max) согласно рекомендации OP (и предположить, что OP знает данные достаточно хорошо, чтобы знать, что мы не столкнемся с какими-либо проблемами потери точности данных).

2 - для действительно больших наборов данных мы рискуем выбросить некоторые ресурсы сервера, будь то пространство tempdb и / или кеш / память; основная проблема возникает из-за взрыва данных, который происходит во время разворота (например, мы переходим от 1 строки и 302 фрагментов данных к 300 строкам и 1200-1500 фрагментам данных, включая 300 копий столбцов PK и UpdateDate, 300 имен столбцов)

markp
источник
1

Этот подход использует динамический запрос для генерации SQL для получения изменений. SP берет имя таблицы и схемы и выдает желаемый результат.

Предполагается, что столбцы PK и UpdateDate присутствуют во всех таблицах. И все архивные таблицы имеют формат originalTableName + "_archive" ..

NB: я не проверял его на производительность.

NB: так как это использует динамический sql, я должен добавить предостережение о внедрении security / sql. Ограничьте доступ к SP и добавьте другие проверки, чтобы предотвратить внедрение 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

Образец звонка:

exec getTableChanges 'dbo', 'bigTable'
Дхармендар Кумар 'DK'
источник
Если я не ошибаюсь, это не ловит несколько изменений, внесенных в одну строку, верно?
Микаэль Эрикссон
это верно .. несколько столбцов, обновленных одновременно, не будут захвачены. будет зафиксирован только первый столбец с изменением.
Дхармендар Кумар 'DK'
1

Я использую AdventureWorks2012`, Production.ProductCostHistory и Production.ProductListPriceHistory в моем примере. Это может быть не идеальный пример таблицы истории, «но сценарий способен соединить желаемый вывод и правильный вывод».

     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)

Здесь, во внутреннем запросе Select, рассмотрите p как Основную таблицу и p1 как Историческую таблицу. При развороте важно преобразовать его в тот же тип.

Вы можете взять любое другое имя таблицы с меньшим именем столбца, чтобы понять мой сценарий. Любое объяснение должно затем пропинговать меня.

KumarHarsh
источник