Consulta que detalla las diferencias entre filas para una gran cantidad de datos


15

Tengo varias tablas grandes, cada una con> 300 columnas. La aplicación que estoy usando crea "archivos" de filas cambiadas al hacer una copia de la fila actual en una tabla secundaria.

Considere un ejemplo trivial:

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

Tabla de archivo:

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

Antes de ejecutar cualquier actualización dbo.bigtable, se crea una copia de la fila dbo.bigtable_archivey luego dbo.bigtable.UpdateDatese actualiza con la fecha actual.

Por lo tanto UNION, juntar las dos tablas y agruparlas PKcrea una línea de tiempo de cambios, cuando se ordena por UpdateDate.

Deseo crear un informe que detalle las diferencias entre filas, ordenadas por UpdateDate, agrupadas por PK, en el siguiente formato:

PK,   UpdateDate,  ColumnName,  Old Value,   New Value

Old Valuey New Valuepueden ser las columnas relevantes emitidas a VARCHAR(MAX)(no hay TEXToBYTE columnas columnas involucradas), ya que no necesito hacer ningún procesamiento posterior de los valores en sí.

Por el momento, no puedo pensar en una forma sensata de hacer esto para una gran cantidad de columnas, sin recurrir a generar las consultas mediante programación; es posible que tenga que hacer esto.

Abierto a muchas ideas, así que agregaré una recompensa a la pregunta después de 2 días.

Respuestas:


15

Esto no se verá bonito, especialmente teniendo en cuenta las más de 300 columnas y la falta de disponibilidad LAG, ni es probable que funcione extremadamente bien, pero solo como algo para comenzar, probaría el siguiente enfoque:

  • UNION Las dos mesas.
  • Para cada PK en el conjunto combinado, obtenga su "encarnación" anterior de la tabla de archivo (la implementación a continuación usa OUTER APPLY+ TOP (1)como un hombre pobreLAG ).
  • varchar(max)Convierta cada columna de datos y desvincule en pares, es decir, el valor actual y el anteriorCROSS APPLY (VALUES ...) funciona bien para esta operación).
  • Finalmente, filtre los resultados en función de si los valores de cada par difieren entre sí.

El Transact-SQL de lo anterior como lo veo:

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

Si desconecta los datos a una tabla temporal

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

Se podría coincidir con las filas de encontrar nuevas y viejas valor con un auto unirse en PK, ColumnNamey Version = Version + 1.

La parte no tan bonita es, por supuesto, deshacer las 300 columnas en la tabla temporal de las dos tablas base.

XML al rescate para hacer las cosas menos incómodas.

Es posible desenredar datos con XML sin tener que saber qué columnas reales hay en la tabla que estarán sin enredar. Los nombres de columna deben ser válidos como nombres de elementos en XML o fallará.

La idea es crear un XML para cada fila que tenga todos los valores para esa fila.

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 está ahí para crear elementos para columnas con NULL .

El XML se puede triturar usando nodes('*') para obtener una fila para cada columna y usar local-name(.)para obtener el nombre del elemento y text()obtener el 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)

Solución completa a continuación. Tenga en cuenta que Versionse invierte. 0 = Última versión.

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

Te sugiero otro enfoque.

Aunque no puede cambiar la aplicación actual, puede cambiar el comportamiento de la base de datos.

Si es posible, agregaría dos GATILLOS a las tablas actuales.

Un INSTEAD OF INSERT en dbo.bigtable_archive que agrega el nuevo registro solo si actualmente no existe.

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

Y un disparador DESPUÉS DE INSERTAR en bigtable que hace exactamente el mismo trabajo, pero usando datos de 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, he configurado un pequeño ejemplo aquí con estos valores iniciales:

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  

Ahora debe insertar en bigtable_archive todos los registros pendientes de 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  

Ahora, la próxima vez que la aplicación intente insertar un registro en la tabla bigtable_archive, los desencadenantes detectarán si existe y se evitará la inserción.

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, ahora puede obtener la línea de tiempo de los cambios consultando solo la tabla de archivo. Y la aplicación nunca se dará cuenta de que un disparador está silenciosamente haciendo el trabajo bajo las sábanas.

dbfiddle aquí


4

La propuesta de trabajo, con algunos datos de muestra, se puede encontrar en @ rextester: bigtable unpivot


La esencia de la operación:

1 - Use syscolumns y for xml para generar dinámicamente nuestras listas de columnas para la operación de univivot; todos los valores se convertirán a varchar (max), con los valores NULL convertidos a la cadena 'NULL' (esto soluciona el problema con los valores NULL de omisión no dinámica)

2 - Genere una consulta dinámica para desenredar datos en la tabla temporal #columns

  • ¿Por qué una tabla temporal vs CTE (a través de una cláusula)? preocupado por un posible problema de rendimiento para un gran volumen de datos y una unión automática CTE sin esquema de índice / hash utilizable; una tabla temporal permite la creación de un índice que debería mejorar el rendimiento en la autounión [ver autounión CTE lenta ]
  • Los datos se escriben en #columnas en el orden PK + ColName + UpdateDate, lo que nos permite almacenar valores PK / Colname en filas adyacentes; una columna de identidad ( rid ) nos permite unir estas filas consecutivas mediante rid = rid + 1

3 - Realice una autounión de la tabla #temp para generar la salida deseada

Cortar y pegar desde rextester ...

Cree algunos datos de muestra y nuestra tabla #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)
);

Las entrañas de la solución:

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
;

Y los resultados:

ingrese la descripción de la imagen aquí

Nota: disculpas ... no pude encontrar una manera fácil de cortar y pegar la salida del rextester en un bloque de código. Estoy abierto a sugerencias.


Posibles problemas / preocupaciones:

1 - la conversión de datos a un genérico varchar (max) puede conducir a la pérdida de precisión de los datos, lo que a su vez puede significar que perdemos algunos cambios de datos; considere los siguientes pares de fecha y hora que, cuando se convierten / convierten al genérico 'varchar (max)', pierden su precisión (es decir, los valores convertidos son los mismos):

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

Si bien se podría mantener la precisión de los datos, requeriría un poco más de codificación (por ejemplo, conversión basada en los tipos de datos de la columna fuente); por ahora he optado por seguir con el varchar genérico (max) según la recomendación del OP (y suponiendo que el OP conoce los datos lo suficientemente bien como para saber que no nos encontraremos con ningún problema de pérdida de precisión de datos).

2: para conjuntos de datos realmente grandes, corremos el riesgo de eliminar algunos recursos del servidor, ya sea espacio temporal o caché / memoria; El problema principal proviene de la explosión de datos que ocurre durante una desconexión (por ejemplo, pasamos de 1 fila y 302 piezas de datos a 300 filas y 1200-1500 piezas de datos, incluidas 300 copias de las columnas PK y UpdateDate, 300 nombres de columnas)


1

Este enfoque utiliza la consulta dinámica para generar un sql para obtener los cambios. El SP toma un nombre de tabla y esquema y le da el resultado que desea.

Las suposiciones son que las columnas PK y UpdateDate están presentes en todas las tablas. Y todas las tablas de archivo tienen el formato originalTableName + "_archive" ..

NB: no lo he comprobado para el rendimiento.

NB: dado que esto usa sql dinámico, debería agregar una advertencia sobre la seguridad / inyección sql Restrinja el acceso a SP y agregue otras validaciones para evitar la inyección 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

Llamada de muestra:

exec getTableChanges 'dbo', 'bigTable'

Si no me equivoco, esto no detecta múltiples cambios realizados en la misma fila, ¿verdad?
Mikael Eriksson

es correcto ... no se capturarán varias columnas actualizadas al mismo tiempo. solo se capturará la primera columna con un cambio.
Dharmendar Kumar 'DK'

1

Estoy usando AdventureWorks2012`, Production.ProductCostHistory y Production.ProductListPriceHistory en mi ejemplo. Puede que no sea el ejemplo perfecto de la tabla de historial, "pero el script puede juntar la salida deseada y la salida correcta".

     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)

Aquí, en la consulta de selección interna, considere p como tabla principal y p1 como tabla de historial. En univivot es importante convertirlo al mismo tipo.

Puede tomar cualquier otro nombre de tabla con menos nombre de columna para entender mi script. Cualquier explicación necesita hacerme ping.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.