Запит із деталізацією відмінностей між рядками для великого обсягу даних


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 дні.

Відповіді:


15

Це не буде виглядати досить, особливо з огляду на більш ніж 300 стовпців і недоступність LAG, і це не може бути надзвичайно добре, але так само, як щось почати, я б спробував наступний підхід:

  • UNION дві таблиці.
  • Для кожного ПК у комбінованому наборі отримуйте його попереднє "втілення" з таблиці архівів (реалізація нижче використовує 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

Я б запропонував вам інший підхід.

Хоча ви не можете змінити поточну програму, можливо, ви можете змінити поведінку бази даних.

Якщо можливо, я би додав два TRIGGERS до поточних таблиць.

Один 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

І тригер ПІСЛЯ ВСТАВЛЕННЯ на 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 | кол3
: ------------------ | : - | : --- | ---: | : ---
01.02.2017 00:00:00 | ABC | С3 | 1 | С1  

UpdateDate | ПК | col1 | col2 | кол3
: ------------------ | : - | : --- | ---: | : ---
01.01.2017 00:00:00 | ABC | С1 | 1 | С1  

Тепер слід вставити у bigtable_archive всі очікувані записи з bigtable.

INSERT INTO bigtable_archive
SELECT *
FROM   bigtable
WHERE  UpdateDate >= '20170102';
SELECT * FROM bigtable_archive;
GO
UpdateDate | ПК | col1 | col2 | кол3
: ------------------ | : - | : --- | ---: | : ---
01.01.2017 00:00:00 | ABC | С1 | 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 | кол3
: ------------------ | : - | : --- | ---: | : ---
01.01.2017 00:00:00 | ABC | С1 | 1 | С1  
01.02.2017 00:00:00 | ABC | С3 | 1 | С1  

Очевидно, тепер ви можете отримати шкалу змін, запитуючи лише архівну таблицю. І додаток ніколи не зрозуміє, що тригер спокійно виконує роботу під кришками.

dbfiddle тут


4

Робочу пропозицію, з деякими зразковими даними, можна знайти @ rextester: bigtable unpivot


Суть операції:

1 - використовувати систематичні стовпці та xml для динамічного генерування списків стовпців для операції unpivot; всі значення будуть перетворені у varchar (max), w / NULL будуть перетворені в рядок 'NULL' (це адресується проблемою з непропущеним пропуском NULL значень)

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

  • Чому таблиця темп проти CTE (через з пунктом)? стурбований потенційною проблемою ефективності для великого обсягу даних та самостійним приєднанням до CTE без використання корисної схеми індексу / хешування; тимчасова таблиця дозволяє створити індекс, який повинен покращити продуктивність при самозаєднанні [див. повільне самозаключення CTE ]
  • Дані записуються до # стовпців у порядку PK + ColName + UpdateDate, що дозволяє нам зберігати значення PK / Colname у сусідніх рядках; стовпець ідентичності ( позбавлення ) дозволяє нам самостійно приєднатись до цих послідовних рядків через rid = rid + 1

3 - Виконайте самостійне приєднання таблиці #temp для отримання потрібного результату

Вирізка-вставка з рекстестера ...

Створіть деякі зразкові дані та нашу таблицю # стовпців:

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
;

І результати:

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

Примітка: вибачення ... не вдалося розібрати простий спосіб вирізати n-вставити вихідний рекстестер в блок коду. Я відкритий для пропозицій.


Потенційні проблеми / проблеми:

1 - перетворення даних у загальний варшар (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

Незважаючи на те, що точність даних може бути збережена, потрібно буде трохи більше кодування (наприклад, кастинг на основі типів даних джерельних стовпців); на даний момент я вирішив дотримуватися загального варшара (макс.) відповідно до рекомендації ОП (і припущення, що ОП знає дані досить добре, щоб знати, що ми не будемо стикатися з будь-якими проблемами втрати точності даних).

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


1

Цей підхід використовує динамічний запит для створення sql для отримання змін. SP приймає назву таблиці та схеми та надає потрібний результат.

Припущення полягають у тому, що стовпці PK та UpdateDate є у всіх таблицях. І всі таблиці архівів мають формат originalTableName + "_archive" ..

NB: Я не перевіряв його на продуктивність.

Примітка: оскільки для цього використовується динамічний sql, я повинен додати застереження щодо безпеки / введення 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'

Якщо я не помиляюсь, це не вловлює декілька змін, внесених до одного і того ж рядка?
Мікаель Ерікссон

це правильно .. кілька стовпців, оновлених одночасно, не будуть захоплені. буде зафіксовано лише перший стовпець зі зміною.
Дхармендар Кумар 'ДК'

1

Я використовую AdventureWorks2012`, Production.ProductCostHistory and 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 - таблицю історії. Для розгортання важливо перетворити його в той самий тип.

Ви можете взяти будь-яке інше ім’я таблиці з меншою назвою стовпця, щоб зрозуміти мій сценарій. Будь-яке пояснення потрібно мені пінг.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.