Страницы

Показаны сообщения с ярлыком CLR. Показать все сообщения
Показаны сообщения с ярлыком CLR. Показать все сообщения

четверг, 3 августа 2017 г.

Проектирование OLAP-кубов и многомерные CLR-сборки

Тренируясь в решении задач проектирования OLAP-кубов, я неожиданно нашел интересное и полезное применение многомерной clr-сборки для mdx-запросов. Рассмотрим следующий пример.

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


if object_id ( 'dbo.Dates', 'U' ) is null
begin
    create table dbo.Dates
    (
        dtDate date not null,
        constraint PK_Dates_dtDate primary key clustered ( dtDate asc ) on [primary]
    ) on [primary]
end
go

if object_id ( 'dbo.AttrData', 'U' ) is null
begin
    create table dbo.AttrData
    (
        iAttrId int not null,
        constraint PK_AttrData_iAttrid primary key clustered ( iAttrId asc ) on [primary]
    ) on [primary]
end
go

if object_id ( 'dbo.Data', 'U' ) is null
begin
    create table dbo.Data
    (
        iDataId int not null,
        iAttrId int not null,
        constraint PK_Data_iDataId primary key clustered ( iDataId asc ) on [primary],
        constraint FK_Data_iAttrId foreign key ( iAttrId ) references dbo.AttrData ( iAttrId )
            on update cascade on delete no action
    ) on [primary]
end
go

if object_id ( 'dbo.FactStore', 'U' ) is null
begin
    create table dbo.FactStore
    (
        iDataId    int   not null,
        dtDate     date  not null,
        dtDateNext date  not null,
        fSum       float not null,
        constraint PK_FactStore_dtDate_dtDateNext_iDataId primary key clustered
            ( dtDate asc, dtDateNext desc, iDataId asc ) on [primary],
        constraint AK_FactStore_iDataId_dtDate unique nonclustered
            ( iDataId asc, dtDate asc ) on [primary],
        constraint FK_FactStore_iDataId foreign key ( iDataId ) references dbo.Data ( iDataId )
            on update cascade on delete no action,
        constraint FK_FactStore_dtDate foreign key ( dtDate ) references dbo.Dates ( dtDate )
            on update no action on delete no action,
        constraint FK_FactStore_dtDateNext foreign key ( dtDateNext ) references dbo.Dates ( dtDate )
            on update no action on delete no action
    ) on [primary]
end

Таблица dbo.FactsStore это таблица фактов, dbo.Data - сущности, dbo.AttrData и dbo.Dates - измерения. Создадим вспомогательную таблицу для возможности хранения данных в кубе только на уровне атрибутов iDataId без хранения самих iDataId:


if object_id ( 'dbo.Facts', 'U' ) is null
begin
    create table dbo.Facts
    (
        dtDate     date  not null,
        dtDateNext date  not null,
        fSum       float not null,
        iAttrId    int   not null,
        constraint PK_Facts_dtDate_dtDateNext_iAttrId primary key clustered
            ( dtDate asc, dtDateNext desc, iAttrId asc ) on [primary],
        constraint AK_Facts_iAttrId_dtDate unique nonclustered ( iAttrId asc, dtDate asc ) on [primary],
        constraint FK_Facts_iAttrId foreign key ( iAttrId ) references dbo.AttrData ( iAttrId )
            on update cascade on delete no action,
        constraint FK_Facts_dtDate foreign key ( dtDate ) references dbo.Dates ( dtDate )
            on update no action on delete no action,
        constraint FK_Facts_dtDateNext foreign key ( dtDateNext ) references dbo.Dates ( dtDate )
            on update no action on delete no action
    ) on [primary]
end
go

Создадим многомерную базу данных на основе этой реляционной схемы. Надо отметить, что для простоты в этом примере помимо измерения времени есть только одно измерение. Однако в реальности измерений может быть намного больше: десятки и сотни. Поэтому в целях уменьшения объема хранимых ячеек в кубе можно хранить данные так же как и в таблице фактов dbo.FactStore, то есть не на каждый день, а только за даты изменения мер. Ниже мы также посмотрим какие есть подводные камни на этом пути и как их обойти.

В среде Visual Studio создадим новый проект типа Analysis Services Multidimensional and Data Mining Project. Затем требуется выполнить такие тривиальные операции как создание источника данных и представления источника данных. После этого все готово для создания измерений. В измерениях можно и нужно создавать иерархии, но это не является главной темой статьи, поэтому таблицы измерений простые, содержат всего по одному атрибуту, в связи с чем пользовательские иерархии создавать не будем. Для удобства имена измерений и атрибутов оставим такими же как и в реляционной базе данных.

Создадим куб Facts на основе таблицы dbo.Facts. В нем будет одна группа мер с одной мерой fSum. Измерения времени в кубе назовем Dates и DatesNext. Приступим к разработке запросов. Для получения суммы fSum в группировке по iAttrId на определенный день (например, на 2017-05-12) в реляционной базе данных используется такой запрос:


select data.iAttrId, fSum = sum ( facts.fSum )
from
    dbo.FactStore facts
        inner join
    dbo.Data data on facts.iDataId = data.iDataId
where '2017-05-12' between facts.dtDate and facts.dtDateNext
group by data.iAttrId

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


select
    Measures.fSum on columns,
    non empty AttrData.iAttrId.iAttrId on rows
from Facts
where
    (
        { Dates.dtDate.dtDate.[yyyy-mm-dd_min] : Dates.dtDate.dtDate.[2017-05-12] },
        { DatesNext.dtDate.dtDate.[2017-05-12] : DatesNext.dtDate.dtDate.[yyyy-mm-dd_max] }
    )

В запрос вместо символов yyyy-mm-dd_min, yyyy-mm-dd_max нужно подставить минимальную и максимальную дату во временном измерении. Здесь мы натыкаемся на проблему, которая состоит в том, что при большом числе ячеек в кубе и большом числе дней операция пересечения временных измерений начинает работать очень медленно. Выход из этой ситуации можно найти, применяя секционирование. Чтобы облегчить операцию поиска пересечений, было бы хорошо работать не с самого начала измерения Dates, а с некоторой не очень далекой точки, и не до самого конца измерения DatesNext, а до определенной концевой не очень далекой точки. Достичь этого можно следующим образом. Представим себе, что есть некоторый интервал, например, месяц. И за каждый такой месяц физически есть своя история изменения меры fSum для сущностей iDataId. То есть на каждую дату начала секции (месяца) в таблице хранятся состояния fSum для всех имеющихся в этот день сущностей. Если есть iDataId, у которого fSum не меняется с 20.05 по 05.06, то в таблице dbo.FactStore хранится не одна, а 2 строки. У первой будет дата окончания 31.05, у второй дата начала 01.06. С одной стороны это создает некоторую избыточность. Но с другой стороны облегчает и работу последующих запросов mdx и работу с самой реляционной таблицей: можно работать с месяцами как с различными секциями одной секционированной таблицы, что облегчает перезагрузку данных за периоды, уменьшает уровень блокировок. Данный подход является компромиссом между хранением данных на каждый день и хранением за периоды без секционирования. Наличие срезов на начало секций создает определенную избыточность, однако при не очень коротких секциях сокращение объема за счет хранения данных в пределах секции за период очень значительно по сравнению с хранением атрибутов на каждый день. Секционируя таблицу фактов, конечно, нужно секционировать и куб.

Для реализации этого подхода на стороне Analysis Services необходимо наполнить таблицу dbo.Facts. Делать это лучше посекционно. Код ниже показывает пример того как это можно сделать для одного месяца (05.2017).


if object_id ( 'tempdb..#Buf', 'U' ) is not null
begin
    drop table #Buf
end
select dates.dtDate, data.iAttrid, fSum = sum ( fSum )
from
    dbo.FactStore facts
        inner join
    dbo.Dates dates on dates.dtDate between facts.dtDate and facts.dtDateNext
        inner join
    dbo.Data data on data.iDataId = facts.iDataId
where facts.dtDate between '2017-05-01' and '2017-05-31'
group by dates.dtDate, data.iAttrId

create unique clustered index IX_iAttrId_dtDate on #Buf ( iAttrId asc, dtDate asc ) on [primary]

if object_id ( 'tempdb..#BufDiff', 'U' ) is not null
begin
    drop table #BufDiff
end
select iAttrId, dtDate, fSum
    into #BufDiff
from
(
    select
        iAttrId,
        dtDate,
        fSum,
        dtDate_Prev = lag ( dtDate ) over ( partition by iAttrId order by dtDate asc ),
        fSum_Prev = lag ( fSum ) over ( partition by iAttrId order by dtDate asc )
) data
    ( dtDate <> dateadd ( day, 1, dtDate_Prev ) or dtDate_Prev is null )
    or
    ( fSum <> fSum_Prev or fSum_Prev is null )
create unique clustered index IX_iAttrId_dtDate on #BufDiff ( iAttrId asc, dtDate asc ) on [primary]

if object_id ( 'tempdb..#BufEnds', 'U' ) is not null
begin
    drop table #BufEnds
end
select iAttrId, dtDate
    into #BufEnds
from #Buf curr
where not exists
(
    select *
    from #Buf nxt
    where
        curr.dtDate = dateadd ( day, -1, nxt.dtDate )
        and
        curr.iAttrId = nxt.iAttrId
)
create unique clustered index IX_iAttrId_dtDate on #BufEnds ( iAttrId asc, dtDate asc ) on [primary]

if object_id ( 'tempdb..#BufHist', 'U' ) is not null
begin
    drop table #BufHist
end
select buf.iAttrId, buf.dtDate, buf.fSum, dates.dtDateNext
    into #BufHist
from
    #BufDiff buf
        inner join
    (
        select iAttrId, dtDate,
            dtDateNext = isnull
            (
                dateadd ( day, -1, lead ( dtDate ) over ( partition by iAttrId order by dtDate asc ) ),
                '9999-12-31'
            )
    ) dates on
        buf.iAttrId = dates.iAttrId and
        buf.dtDate = dates.dtDate
create unique clustered index IX_iAttrId_dtDate_dtDateNext on #BufHist ( iAttrId asc, dtDate asc, dtDateNext desc ) on [primary]

update hist with ( tablock )
    set hist.dtDateNext = ends.dtDate
    from
        #BufHist hist
            inner join
        #BufEnds ends on
            hist.iAttrId = ends.iAttrId and
            ends.dtDate between hist.dtDate and dateadd ( day, -1, hist.dtDateNext )

/* вместо delete лучше секцинировать dbo.Facts и использовать switch partition (до ms sql 2016 для этого требуется вспомогательная буферная таблица) */
delete dbo.Facts with ( tablock )
    where dtDate between '2017-05-01' and '2017-05-31'

insert into dbo.Facts ( iAttrId, dtDate, dtDateNext, fSum )
    select iAttrId, dtDate, dtDateNext, fSum
    from #BufHist

В результате запрос на T-SQL перепишется так:


select data.iAttrId, fSum = sum ( facts.fSum )
from
    dbo.FactStore facts
        inner join
    dbo.Data data on facts.iDataId = data.iDataId
where
    dtDate between '2017-05-01' and '2017-05-31' and
    '2017-05-12' between facts.dtDate and facts.dtDateNext
group by data.iAttrId

, а запрос на MDX будет выглядеть так:

select
    Measures.fSum on columns,
    AttrData.iAttrId.iAttrId on rows
from Facts
where
    (
        { Dates.dtDate.dtDate.[2017-05-01] : Dates.dtDate.dtDate.[2017-05-12] },
        { DatesNext.dtDate.dtDate.[2017-05-12] : DatesNext.dtDate.dtDate.[2017-05-31] }
    )

Продолжим разработку запросов. Очень часто данные надо агрегировать не для одного дня, а для многих дней из определенного периода. Например, если требуется получить данные в группировке по дню и iAttrId за 05.2017, то на T-SQL можно написать такой запрос:


select data.iAttrId, dates.dtDate, fSum = sum ( facts.fSum )
from
    dbo.FactStore facts
        inner join
    dbo.Data data on facts.iDataId = data.iDataId
        inner join
    dbo.Dates dates on dates.dtDate between facts.dtDate and facts.dtDateNext
where
    dates.dtDate between '2017-05-01' and '2017-05-31' and
    facts.dtDate between '2017-05-01' and '2017-05-31'
group by data.iAttrId, dates.dtDate

Чтобы переписать этот запрос на MDX напишем для начала такую заготовку:


select
    Measures.fSum on columns,
    non empty
    (
        AttrData.iAttrId.iAttrId
        *
        (
            { Dates.dtDate.dtDate.[2017-05-01] : Dates.dtDate.dtDate.[2017-05-31] },
            { DatesNext.dtDate.dtDate }
        )
    ) on rows
from Facts

Данный запрос предоставляет нужные значения атрибутов, но не на каждый день периода, а только за те дни, когда для атрибута менялась мера. Честно говоря, не уверен, что средствами mdx, можно переписать запрос так, чтобы он из одной ячейки, соответствующей периоду в 5 дней, смог вернуть 5 ячеек на каждый день внутри периода. Предлагаю решить эту задачу, используя CLR-сборку, содержащую метод, который принимает на вход и возвращает многомерное множество. В коде предполагается, что во входном множестве первая координата это дата начала, а вторая - дата окончания. Исходный код выглядит следующим образом:


using System;
using System.Data;
using Microsoft.AnalysisServices;
using Microsoft.AnalysisServices.AdomdServer;

namespace Tasks
{
    public Set ConvertPeriods(Set InputSet)
    {
        SetBuilder SetB = new SetBuilder();
        foreach (Microsoft.AnalysisServices.AdomdServer.Tuple tp in InputSet.Tuples)
        {
            System.DateTime DateStart = System.DateTime.Parse(tp.Members[0].Name),
                DateEnd = System.DateTime.Parse(tp.Members[1].Name);

            for (int i = 0; i < (DateEnd - DateStart).TotalDays + 1; i++)
            {
                Expression exp = new Expression();

                System.DateTime dt = DateStart.AddDays(i);
                exp.ExpressionText = "Dates.dtDate.dtDate.[" + dt.Year.ToString() + "-" +
                    (dt.Month.ToString().Length == 1 ? "0" : "") + dt.Month.ToString() + "-" +
                    (dt.Day.ToString().Length == 1 ? "0" : "") + dt.Day.ToString() + "]";

                Member mem = exp.CalculateMdxObject(null).ToMember();
                TupleBuilder tpBuild = new TupleBuilder();

                tpBuild.Add(mem);
                for (int h = 2; h < tp.Members.Count; h++)
                {
                    tpBuild.Add(tp.Members[h]);
                }
                SetB.Add(tpBuild.ToTuple());
            }
        }
    }
}

Используя Management Studio или XML/A, можно развернуть сборку в многомерной базе данных Analysis Services. Пусть она называется MdTasks. Тогда запрос переписывается в таком виде:


select
    Measures.fSum on columns,
    non empty MdTasks.ConvertPeriods
    (
        nonempty
        (
            (
                { Dates.dtDate.dtDate.[2017-05-01] : Dates.dtDate.dtDate.[2017-05-31] },
                { DatesNext.dtDate.dtDate }
            )
            *
            AttrData.iAttrId.iAttrId
        )
    ) on rows
from Facts

Множество, передаваемое clr-функции, обернуто в функцию nonempty для ускорения работы метода. Теперь и на стороне MDX можно получать агрегаты на каждый день из кубов, хранящих данные в экономном формате за периоды изменения мер!


пятница, 25 декабря 2015 г.

Реализация объектно-ориентированных типов данных

В Transact-SQL можно реализовать разнообразные пользовательские типы данных, основанные на clr-сборках. У этих типов могут быть свои полноценные методы и свойства (в том числе статические). Рассмотрим пример реализации списка. Потребуется реализовать структуру, реализующую интерфейс INullable. Также требуется обеспечить собственную сериализацию, поскольку работать придется со ссылочным типом, списком. Для этого потребуется реализовать интерфейс IBinarySerializer, с методами Read и Write. Обязательно также реализовать методы ToString, Parse, Null, свойство IsNull:

using System;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;
using System.Collections.Generic;
using System.Linq;
using System.IO;

[Serializable]
[Microsoft.SqlServer.Server.SqlUserDefinedType(Format.UserDefined, MaxByteSize=-1)]
public struct SqlList : INullable, IBinarySerialize
{
    private bool m_Null;
    private List<string> m_lst;

    public override string ToString()
    {
        if (this.IsNull)
        {
            return "null";
        }
        return string.Join(",", m_lst);
    }

    public bool IsNull
    {
        get
        {
            return m_Null;
        }
    }

    public static SqlList Null
    {
        get
        {
            SqlList lst = new SqlList();
            lst.m_Null = true;
            return lst;
        }
    }

    public static SqlList Parse(SqlString lst)
    {
        if (lst.IsNull)
            return Null;
        SqlList _lst = new SqlList();
        char[] _delim = {','};
        _lst.m_lst = lst.ToString().Split(_delim).ToList();
       
        return _lst;
    }

Добавим также статический метод для получения списка из строки с произвольным разделителем, и методы для добавления и удаления элементов в список, очистки и сортировки списка:

    public static SqlList ParseDelim(SqlString lst, SqlString delim)
    {
        if (lst.IsNull)
            return Null;
        SqlList _lst = new SqlList();
        char[] _delim = delim.ToString().ToCharArray();
        _lst.m_lst = lst.ToString().Split(_delim).ToList();

        return _lst;
    }

    public SqlList AddMember(SqlString vcNewMember)
    {
        SqlList _lst = new SqlList();
        _lst.m_lst = this.m_lst;
        _lst.m_lst.Add(vcNewMember.ToString());
        return _lst;
    }

    public SqlList RemoveMember(SqlString vcMember)
    {
        SqlList _lst = new SqlList();
        _lst.m_lst = this.m_lst;
        _lst.m_lst.Remove(vcMember.ToString());
        return _lst;
    }

    public SqlList Clear()
    {
        SqlList _lst = new SqlList();
        _lst.m_lst = this.m_lst;
        _lst.m_lst.Clear();
        return _lst;
    }

    public SqlList Sort()
    {
        SqlList _lst = new SqlList();
        _lst.m_lst = this.m_lst;
        _lst.m_lst.Sort();
        return _lst;
    }

Для удобства добавим метод для извлечения определенного члена списка по номеру, метод для проверки равенства двух экземпляров и свойство для определения числа элементов:

    public SqlString GetItem(SqlInt32 iItem)
    {
        return (SqlString)this.m_lst.ElementAt((int)iItem);
    }

    public SqlBoolean Equal(SqlList lst)
    {
        if (this.m_lst == null || lst.m_lst == null)
        {
            return false;
        }
        if(this.m_lst.Count != lst.m_lst.Count)
        {
            return false;
        }
        for (int i = 0; i < this.m_lst.Count - 1; i++)
        {
            if (this.m_lst.ElementAt(i) != lst.m_lst.ElementAt(i))
            {
                return false;
            }
        }
        return true;
    }

    public SqlInt32 GetCount
    {
        get
        {
            return this.m_lst.Count;
        }
    }

Остается реализовать методы Read, Write;

    public void Read(BinaryReader r)
    {
        if (r == null) throw new ArgumentNullException("r");
        var count = r.ReadInt32();
        m_lst = new List<string>(count);
        for (int i = 0; i < count; i++)
        {
            m_lst.Add(r.ReadString());
        }
    }

    public void Write(BinaryWriter w)
    {
        if (w == null) throw new ArgumentNullException("w");
        w.Write(m_lst.Count);
        foreach (string b in m_lst)
        {
            w.Write(b);
        }
    }
}

Теперь можно перейти к развертыванию сборки и тестированию типа:

if not exists
(
       select *
       from sys.assemblies
       where name = N'Lists'
)
begin
       create assembly Lists
       from 'C:\ListLib.dll'
end
else if
(
       select clr.binFile
       from openrowset ( bulk 'C:\ListLib.dll', single_blob ) clr ( binFile )
)
<> 
(
       select asmfile.content
       from
             sys.assemblies asm
                    inner join
             sys.assembly_files asmfile on asm.assembly_id = asmfile.assembly_id
       where asm.name = N'Lists'
)
begin
       alter assembly Lists
       from 'C:\ListLib.dll'
end
go

if type_id ( N'dbo.List' ) is null
begin
       create type List
       external name Lists.SqlList
end
go

Проверим работу методов, создадим экземпляры структуры с помощью метода ParseDelim, вставим в список несколько строковых элементов, отсортируем их и извлечем:

declare @lst dbo.List, @lst1 dbo.List, @lst2 dbo.List
set @lst = dbo.List::ParseDelim(N'd,c,b', ',')
set @lst = @lst.AddMember(N'a')
select @lst.GetItem(0), @lst.GetItem(1), @lst.GetItem(2), @lst.GetItem(3)
set @lst = @lst.Sort()
select @lst.GetItem(0), @lst.GetItem(1), @lst.GetItem(2), @lst.GetItem(3)

select @lst.GetCount














Можно наполнить второй список и проверить работу метода Equals:

set @lst1 = dbo.List::ParseDelim('a,b,c', ',')
set @lst1 = @lst1.AddMember('d')
select @lst.Equal(@lst1)

воскресенье, 22 ноября 2015 г.

CLR-агрегирующие функции

Рассмотрим задачу, в которой для таблицы с группами строк требуется найти максимальное по модулю значение вместе со знаком найденного элемента.
Пример:

if object_id ( N'tempdb..#data', N'U' ) is not null
begin
       drop table #data
end
go

create table #data
(
       iGrpId int    not null,
       iRowId int    not null,
       iAttr  float  not null,
       primary key clustered ( iGrpId asc, iRowId asc ) on [primary]
) on [primary]
go

insert into #data
(
       iGrpId,
       iRowId,
       iAttr
)
values
       ( 1, 1, 20 ),
       ( 1, 2, -20 ),
       ( 1, 3, 30 ),
       ( 2, 1, 10 ),
       ( 2, 2, 20 ),
       ( 2, 3, -10 ),
       ( 2, 4, -30 )
go

Требуется написать запрос для получения таких результатов:









Чтобы получить такой результат максимально удобным способом, используя один запрос, создадим clr-агрегирующую функцию. Ниже представлен ее код.

using System;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;

[Serializable]
[Microsoft.SqlServer.Server.SqlUserDefinedAggregateAttribute
(
    Microsoft.SqlServer.Server.Format.Native,
    IsInvariantToNulls = true,
    IsInvariantToDuplicates = false,
    IsInvariantToOrder = false
)]
public struct AbsOperations
{
    private SqlDouble dMaxVal;
    private bool bNullExists;
    private bool bAllNull;

    public void Init()
    {
        dMaxVal = 0;
        bNullExists = false;
        bAllNull = false;
    }

    public void Accumulate(SqlDouble Value)
    {
        if (Value.IsNull)
        {
            bNullExists = true;
        }
        else
        {
            double dMaxVal_ = double.Parse(dMaxVal.ToString()), Value_ = double.Parse(Value.ToString());
            bNullExists = false;
            if(Math.Abs(dMaxVal_) < Math.Abs(Value_))
            {
                dMaxVal = Value;
            }
        }
    }

    public void Merge(AbsOperations grp)
    {
        if (grp.dMaxVal.IsNull)
        {
            if (grp.bNullExists)
            {
                bNullExists = true;
            }
            if (!grp.bAllNull)
            {
                bAllNull = false;
            }

            double dMaxVal_ = double.Parse(dMaxVal.ToString()), Value = double.Parse(grp.dMaxVal.ToString());
            if (Math.Abs(dMaxVal_) < Math.Abs(Value))
            {
                dMaxVal = grp.dMaxVal;
            }
        }
    }

    public SqlDouble Terminate()
    {
        if (bAllNull)
        {
            return SqlDouble.Null;
        }
        return dMaxVal;
    }
}

В структуре имеется ряд методов. Методы Init и Terminate отвечают за первоначальную инициализацию и возврат значения для группы соответственно. Логика инкапсулирована в методе Accumulate. В случае если вычисления в группе распараллеливаются на несколько потоков, их результаты надо в конце собрать в одно значение. Этой цели служит метод Merge. Развернем сборку и создадим пользовательскую агрегирующую функцию:

if not exists
(
       select *
       from sys.assemblies
       where name = N'AbsOperations'
)
begin
       create assembly AbsOperations
             from 'C:\В\Программирование\Проекты\tmp\SqlServerProject4\bin\Debug\SqlServerProject4.dll'
             with permission_set = safe
end
go

if object_id ( N'dbo.GetMaxAbs', N'AF' ) is null
begin
       create aggregate dbo.GetMaxAbs ( @fRow float )
       returns float
       external name AbsOperations.AbsOperations
end
go

Теперь можно написать запрос для решения задачи;

select iGrpId, AgrgrAttr = dbo.GetMaxAbs ( iAttr )
from #data
group by iGrpId

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