Сообщений 100    Оценка 3451        Оценить  
Система Orphus

Linq-провайдер для BLToolkit

Автор: Игорь Ткачёв
Источник: RSDN Magazine #4-2009
Опубликовано: 15.02.2010
Исправлено: 13.07.2010
Версия текста: 1.0

Введение
Подготовка к работе
Подключение провайдера базы данных
Создание модели данных
Идеология и модель программирования
Стандартные Linq запросы
Первое знакомство
Запрос к двум таблицам
Реализация стандартных возможностей
Ассоциации
Наследование
Стандартные функции .NET Framework
Дополнительные возможности BLToolkit
Select
DML операции
SQL функции
Расширение BLToolkit
SQL функции
Подстановка методов
Повторное использование выражений
Производительность
Проблемы производительности в Linq
CompiledQuery
Заключение

Демонстрационный проект

Введение

Сегодня Linq уже не нуждается в особом представлении и рекомендациях. Эта технология с каждым днём получает всё больше распространение и всё сильнее завоёвывает признание разработчиков. BLToolkit так же не остался в стороне и предлагает свой Linq-provider для доступа к базам данным. Целью данного документа как раз является предоставление информации о поддержки Linq в BLToolkit.

Подготовка к работе

Подключение провайдера базы данных

BLToolkit поддерживает следующий список баз данных:

По умолчанию библиотека поддерживает только три провайдера: MS SQL 2008, MS SQL 2005 и Microsoft Access. Связано это с тем, что эти провайдеры входят в стандартную поставку .NET, а для работоспособности остальных необходимо устанавливать дополнительные компоненты от производителей баз данных.

Для того, чтобы добавить поддержку провайдера не включенного в стандартный список необходимо выполнить следующие шаги:

  1. Добавьте ссылки на соответствующие библиотеки от производителей баз данных в ваш проект.
  2. Добавьте соответствующий файл с реализацией провайдера в проект. Исходный код всех провайдеров находится в каталоге Data\DataProvider проекта BLToolkit или в том же каталоге архива http://bltoolkit.net/download/bltoolkit_bin.zip, если вы используете скомпилированную версию BLToolkit.
  3. Зарегистрируйте BLToolkit провайдер в своём проекте одним из следующих способов:
using System;

using NUnit.Framework;

using BLToolkit.Data;
using BLToolkit.Data.DataProvider;

namespace HowTo.Data.DataProvider
{
    [TestFixture]
    public class AddDataProvider
    {
        const string connectionString = 
            "Data Source=DBHost;Port=5000;Database=BLToolkitData;Uid=sa";

        [Test]
        public void Test()
        {
            // 3rd party data provider registration.
            //
            DbManager.AddDataProvider(new SybaseDataProvider());

            // It can be configured by App.config.
            // We use this way for the demo purpose only.
            //
            DbManager.AddConnectionString(
                "Sybase",          // Provider name
                "Sybase",          // Configuration
                connectionString); // Connection string

            using (var db = new DbManager("Sybase"))
            {
            }
        }
    }
}

Либо с помощью файла конфигурации:

<?xml version="1.0"?>
<configuration>
    <configSections>
        <section name="bltoolkit" type="BLToolkit.Configuration.BLToolkitSection, BLToolkit.3"/>
    </configSections>
    <bltoolkit>
        <dataProviders>
            <add type="BLToolkit.Data.DataProvider.SybaseDataProvider, MyProjectName" />
        </dataProviders>
    </bltoolkit>
    <connectionStrings>
        <add name="Sybase" connectionString="Data Source=DBHost;Port=5000;Database=BLToolkitData;Uid=sa" providerName="Sybase.Data.AseClient"/>
    </connectionStrings>
</configuration>

Подробнее о способах конфигурации BLToolkit см. http://bltoolkit.net/doc/Data/index.htm.

Создание модели данных

Для построения Linq запросов нам потребуется модель данных в виде набора классов, описывающих структуру таблиц базы данных и связей между ними. Построение такой модели может быть выполнено следующими способами:

Мы для примеров будем использовать стандартного подопытного кролика от Microsoft - базу данных Northwind. Скрипт создания этой базы данных и модель данных для неё вы сможете найти в демонстрационном проекте этой статьи. Ниже для затравки приведён один из классов этой модели:

[TableName("Categories")]
public class Category
{
    [PrimaryKey, Identity] public int    CategoryID;
    [NotNull]              public string CategoryName;
                           public string Description;
                           public Binary Picture;

    [Association(ThisKey="CategoryID", OtherKey="CategoryID")]
    public List<Product> Products;
}

Кроме классов модели данных нам понадобится класс, объединяющий в себе все таблицы модели.

class NorthwindDB : DbManager
{
    public NorthwindDB() : base("Northwind")
    {
    }

    public Table<Northwind.Category> Category { get { return GetTable<Northwind.Category>(); } }
    // ...
}

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

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

Идеология и модель программирования

BLToolkit не является ORM в классическом понимании этого определения. Классические ORM решают по сути две задачи, предоставляемые в одном флаконе: Data Mapping и Entity Services. Первая задача - это как раз то, чем изначально должны заниматься ORM системы - перекладыванием данных из БД в объекты и обратно. Вторая, Entity Services - набор сервисов управления объектной моделью как то: обеспечение ссылочной целостности, кэширование объектов, отслеживание изменений, валидация объектов и т.п. В последнее время такие ORM стало принято называть Heavy ORM (тяжёлые ORM), а инструменты предоставляемые только лишь Data Mapping - Lightweight ORM (лёгкие ORM). BLToolkit относится ко второй категории. Поэтому вы не увидите в BLToolkit:

Вместо этого вам будут доступны:

Другими словами, BLToolkit не будет от вас пытаться скрыть работу с базой данных за объектной моделью приложения, а как раз наоборот, предоставит максимально полные и естественные средства для работы с вашей БД.

Стандартные Linq запросы

Первое знакомство

static void FirstTest()
{
    using (var db = new NorthwindDB())
    {
        var query = db.Employee;

        foreach (var employee in query)
        {
            Console.WriteLine("{0} {1}", employee.EmployeeID, employee.FirstName);
        }
    }
}

Что может быть проще? Тем не менее, давайте посмотрим на то, что у нас получилось под микроскопом. Поставим точку останова внутри цикла и взглянем на содержимое переменных query и db в отладчике.


Прежде всего нас будет интересовать свойство SqlText переменной query. Откроем Text visualizer и посмотрим содержимое SqlText.


Это текст запроса сгенерированный BLToolkit. В первой строчке закоментированы текущая строка конфигурации, имя провайдера данных и имя SQL провайдера.

Но, к сожалению, свойство SqlText доступно только, если мы работаем с переменной типа IQueriable<T> как в предыдущем случае. Это не всегда возможно, запрос в следующем примере возвращает нам значение типа int.

static void CountTest()
{
    using (var db = new NorthwindDB())
    {
        int count = db.Employee.Count();

        Console.WriteLine(count);
    }
}

В этом случае мы можем воспользоватеься свойством db.LastQuery, которое содержит текст последнего запроса к базе данных.

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

static void Main()
{
    DbManager.TraceSwitch = new TraceSwitch("DbManager", "DbManager trace switch", "Info");

    FirstTest();
    CountTest();
}

Естественно, этот режим доступен только при использовании отладочной версии BLToolkit.

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

static void SingleTableTest()
{
    using (var db = new NorthwindDB())
    {
        var query =
            from e in db.Employee
            where e.EmployeeID > 5
            orderby e.LastName, e.FirstName
            select e;

        foreach (var employee in query)
        {
            Console.WriteLine("{0} {1}, {2}", employee.EmployeeID, employee.LastName, employee.FirstName);
        }
    }
}
ПРИМЕЧАНИЕ

Далее в статье методы будут приводится не полностью, а лишь в части, относящейся к демонстрации.

Генерируемый SQL:

SELECT
    [e].[EmployeeID],
    [e].[LastName],
    [e].[FirstName],
    [e].[Title],
    [e].[TitleOfCourtesy],
    [e].[BirthDate],
    [e].[HireDate],
    [e].[Address],
    [e].[City],
    [e].[Region],
    [e].[PostalCode],
    [e].[Country],
    [e].[HomePhone],
    [e].[Extension],
    [e].[Notes],
    [e].[ReportsTo],
    [e].[PhotoPath]
FROM
    [Employees] [e]
WHERE
    [e].[EmployeeID] > 5
ORDER BY
    [e].[LastName],
    [e].[FirstName]

Запрос к двум таблицам

Далее приведены стандартные запросы к двум (или более) таблицам. Они нам понадобятся в дальнейшем для обсуждении других возможностей BLToolkit.

Select Many

"Старый" стиль Join.

from c in db.Category
from p in db.Product
where p.CategoryID == c.CategoryID
select new
{
    c.CategoryName,
    p.ProductName
};

SQL:

SELECT
    [c].[CategoryName],
    [t1].[ProductName]
FROM
    [Categories] [c], [Products] [t1]
WHERE
    [t1].[CategoryID] = [c].[CategoryID]

Inner Join

"Новый" Join.

from p in db.Product
join c in db.Category on p.CategoryID equals c.CategoryID
select new
{
    c.CategoryName,
    p.ProductName
};

SQL:

SELECT
    [t1].[CategoryName],
    [p].[ProductName]
FROM
    [Products] [p]
        INNER JOIN [Categories] [t1] ON [p].[CategoryID] = [t1].[CategoryID]

Left Join

И напоследок вот такой некрасивый Left Join.

from p in db.Product
join c in db.Category on p.CategoryID equals c.CategoryID into g
from c in g.DefaultIfEmpty()
select new
{
    c.CategoryName,
    p.ProductName
};

SQL:

SELECT
    [t1].[CategoryName],
    [p].[ProductName]
FROM
    [Products] [p]
        LEFT JOIN [Categories] [t1] ON [p].[CategoryID] = [t1].[CategoryID]

Реализация стандартных возможностей

Ассоциации

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

Ниже приведена таблица Product и её ассоциации.

[TableName("Products")]
public abstract class Product
{
    [PrimaryKey, Identity] public int      ProductID;
    [NotNull]              public string   ProductName;
                           public int?     SupplierID;
                           public int?     CategoryID;
                           public string   QuantityPerUnit;
                           public decimal? UnitPrice;
                           public short?   UnitsInStock;
                           public short?   UnitsOnOrder;
                           public short?   ReorderLevel;
                           public bool     Discontinued;

    [Association(ThisKey="ProductID",  OtherKey="ProductID")]
    public List<OrderDetail> OrderDetails;

    [Association(ThisKey="CategoryID", OtherKey="CategoryID", CanBeNull=false)]
    public Category Category;

    [Association(ThisKey="SupplierID", OtherKey="SupplierID", CanBeNull=false)]
    public Supplier Supplier;
}

Что нужно знать об ассоциациях?

Теперь посмотрим что у нас получилось:

from p in db.Product
select new
{
    p.Category.CategoryName,
    p.ProductName
};

Явное указание связи с таблицей Category исчезло, но генерируемый SQL остался прежним:

SELECT
    [t1].[CategoryName],
    [p].[ProductName]
FROM
    [Products] [p]
        INNER JOIN [Categories] [t1] ON [p].[CategoryID] = [t1].[CategoryID]

Давайте попробуем поменять у ассоциации Category значение свойства CanBeNull на true и посмотрим на результат:

SELECT
    [t1].[CategoryName],
    [p].[ProductName]
FROM
    [Products] [p]
        LEFT JOIN [Categories] [t1] ON [p].[CategoryID] = [t1].[CategoryID]

Как вы уже догадались, теперь у нас получился Left Join.

Использовать списочные ассоциации можно, например, следующим образом:

from p in db.Product
select new
{
    p.OrderDetails.Count,
    p.ProductName
};

SQL:

SELECT
    (
        SELECT
            Count(*)
        FROM
            [Order Details] [t1]
        WHERE
            [p].[ProductID] = [t1].[ProductID]
    ) as [c1],
    [p].[ProductName]
FROM
    [Products] [p]

Как было сказано выше связанные сущности не заполняются автоматически при создании объекта, но при желании это можно сделать вручную:

from o in db.Order
select new Northwind.Order
{
    OrderID  = o.OrderID,
    Customer = o.Customer
};

SQL:

SELECT
    [o].[OrderID],
    [t1].[CustomerID],
    [t1].[CompanyName],
    [t1].[ContactName],
    [t1].[ContactTitle],
    [t1].[Address],
    [t1].[City],
    [t1].[Region],
    [t1].[PostalCode],
    [t1].[Country],
    [t1].[Phone],
    [t1].[Fax]
FROM
    [Orders] [o]
        INNER JOIN [Customers] [t1] ON [o].[CustomerID] = [t1].[CustomerID]

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

from o in db.OrderDetail
select new
{
    o.Product.ProductName,
    o.Order.OrderID,
    o.Order.Employee.ReportsToEmployee.Region
};

SQL:

SELECT
    [t1].[ProductName],
    [o].[OrderID],
    [t2].[Region]
FROM
    [Order Details] [o]
        INNER JOIN [Products] [t1] ON [o].[ProductID] = [t1].[ProductID]
        INNER JOIN [Orders] [t4]
            LEFT JOIN [Employees] [t3]
                LEFT JOIN [Employees] [t2] ON [t3].[ReportsTo] = [t2].[EmployeeID]
            ON [t4].[EmployeeID] = [t3].[EmployeeID]
        ON [o].[OrderID] = [t4].[OrderID]

Обратите внимание на то, что у нас для разных таблиц используются разные типы Join. Это результат применения параметра CanBeNull.

Ещё одним интересным моментом является использования в Select поля OrderID из таблицы OrderDetails, хотя в самом Linq запросе используется поле таблицы Orders. Это как раз та самая оптимизация, которую выполняет BLToolkit для убирания лишних связей. Давайте немного изменим наш пример:

from o in db.OrderDetail
select new
{
    o.Product.ProductName,
    o.Order.OrderID,
    //o.Order.Employee.ReportsToEmployee.Region
};

SQL:

SELECT
    [t1].[ProductName],
    [o].[OrderID]
FROM
    [Order Details] [o]
        INNER JOIN [Products] [t1] ON [o].[ProductID] = [t1].[ProductID]

Так как таблица Order для доступа к Employee нам теперь не нужна, а использование OrderID из OrderDetails не меняет логики запроса, то связь с Order тоже исчезла.

Кроме всего прочего однотипные ассоциативные объекты разных сущностей можно сравнивать:

from o in db.Order
from t in db.EmployeeTerritory
where o.Employee == t.Employee
select new
{
    o.OrderID,
    o.EmployeeID,
    t.TerritoryID
};

SQL:

SELECT
    [o].[OrderID],
    [o].[EmployeeID],
    [t1].[TerritoryID]
FROM
    [Orders] [o], [EmployeeTerritories] [t1]
WHERE
    [o].[EmployeeID] = [t1].[EmployeeID]

И использовать для группировки:

from p in db.Product
group p by p.Category into g
where g.Count() == 12
select g.Key.CategoryName;

SQL:

SELECT
    [t1].[CategoryName]
FROM
    [Products] [p]
        INNER JOIN [Categories] [t1] ON [p].[CategoryID] = [t1].[CategoryID]
GROUP BY
    [t1].[CategoryID],
    [t1].[CategoryName]
HAVING
    Count(*) = 12

Для сравнения и группировки по объектам BLToolkit использует информацию о первичных ключах объекта. Если первичный ключ не задан, то BLToolkit генерирует код, сравнивающий все поля объекта друг с другом. Это, во-первых, не эффективно, а, во-вторых, не всегда возможно, т.к. не все типы в SQL можно сравнивать.

В качестве эксперимента давайте попробуем убрать у сущности Employee первичный ключ и выполним предыдущий запрос ещё раз. Полученный SQL:

SELECT
    [o].[OrderID],
    [o].[EmployeeID],
    [t3].[TerritoryID]
FROM
    [Orders] [o]
        LEFT JOIN [Employees] [t1] ON [o].[EmployeeID] = [t1].[EmployeeID],
    [EmployeeTerritories] [t3]
        LEFT JOIN [Employees] [t2] ON [t3].[EmployeeID] = [t2].[EmployeeID]
WHERE
    [o].[EmployeeID] = [t3].[EmployeeID] AND
    [t1].[LastName] = [t2].[LastName] AND
    [t1].[FirstName] = [t2].[FirstName] AND
    [t1].[Title] = [t2].[Title] AND
    [t1].[TitleOfCourtesy] = [t2].[TitleOfCourtesy] AND
    ([t1].[BirthDate] IS NULL AND [t2].[BirthDate] IS NULL OR [t1].[BirthDate] IS NOT NULL AND [t2].[BirthDate] IS NOT NULL AND [t1].[BirthDate] = [t2].[BirthDate]) AND
    ([t1].[HireDate] IS NULL AND [t2].[HireDate] IS NULL OR [t1].[HireDate] IS NOT NULL AND [t2].[HireDate] IS NOT NULL AND [t1].[HireDate] = [t2].[HireDate]) AND
    [t1].[Address] = [t2].[Address] AND
    [t1].[City] = [t2].[City] AND
    [t1].[Region] = [t2].[Region] AND
    [t1].[PostalCode] = [t2].[PostalCode] AND
    [t1].[Country] = [t2].[Country] AND
    [t1].[HomePhone] = [t2].[HomePhone] AND
    [t1].[Extension] = [t2].[Extension] AND
    [t1].[Notes] = [t2].[Notes] AND
    ([t1].[ReportsTo] IS NULL AND [t2].[ReportsTo] IS NULL OR [t1].[ReportsTo] IS NOT NULL AND [t2].[ReportsTo] IS NOT NULL AND [t1].[ReportsTo] = [t2].[ReportsTo]) AND
    [t1].[PhotoPath] = [t2].[PhotoPath]

Мало того, что на этот запрос без слёз смотреть нельзя, так он ещё и не работает, так как среди полей таблицы Employees есть поля с типом ntext, которые нельзя сравнивать.

СОВЕТ

Будьте внимательны при описании модели данных. BLToolkit использует метаинформацию о ваших сущностях для оптимизации запросов и её отсутствие может привести к построению менее оптимальных запросов.

Наследование

Поддержка наследования в BLToolkit выполнена по образу и подобию наследования в Linq To SQL. Для организации наследования требуется специальная разметка сущностей как в следующем коде:

[TableName("Products")]
[InheritanceMapping(Code="True",  Type=typeof(DiscontinuedProduct))]
[InheritanceMapping(Code="False", Type=typeof(ActiveProduct))]
public abstract class Product
{
    [PrimaryKey, Identity]                      public int      ProductID;
    [NotNull]                                   public string   ProductName;
                                                public int?     SupplierID;
                                                public int?     CategoryID;
                                                public string   QuantityPerUnit;
                                                public decimal? UnitPrice;
                                                public short?   UnitsInStock;
                                                public short?   UnitsOnOrder;
                                                public short?   ReorderLevel;
    [MapField(IsInheritanceDiscriminator=true)] public bool     Discontinued;
}

public class ActiveProduct : Product
{
}

public class DiscontinuedProduct : Product
{
}

Здесь поле Discontinued играет роль дискриминатора - признака, по которому объекты делятся на те или иные типы. Атрибут InheritanceMapping позволяет сопоставить значение этого поля и тип, которому это поле соответствует. В этом примере типы ActiveProduct и DiscontinuedProduct не имеют специфичных для типа полей, но в принципе это не запрещено.

Более подробную информацию можно найти по следующей ссылке - Linq To SQL Inheritance.

Ниже приведены несколько примеров использования наследования.

from p in db.DiscontinuedProduct
select p;

SQL:

SELECT
    [p].[ProductID],
    [p].[ProductName],
    [p].[SupplierID],
    [p].[CategoryID],
    [p].[QuantityPerUnit],
    [p].[UnitPrice],
    [p].[UnitsInStock],
    [p].[UnitsOnOrder],
    [p].[ReorderLevel],
    [p].[Discontinued]
FROM
    [Products] [p]
WHERE
    [p].[Discontinued] = 'True'

В точности такой же SQL будет сгенерирован и для следующего теста:

from c in db.Product
where c is Northwind.DiscontinuedProduct
select c;

Стандартные функции .NET Framework

BLToolkit поддерживает преобразование в SQL около четырёхсот стандартных функций .NET Framework. Сюда входят функции работы со строками, датами, математические функции, функции преобразования и конвертирования скалярных типов данных. Если какая-либо функция не имеет прямого аналога в SQL, то она заменяется на специфичный для конкретного сервера базы данных алгоритм. Ниже приведено несколько примеров.

Здесь всё просто. Свойство Length имеет прямую реализацию в SQL:

from c in db.Customer
where c.ContactName.Length > 5
select c.ContactName;

SQL:

SELECT
    [p].[ContactName]
FROM
    [Customers] [p]
WHERE
    Len([p].[ContactName]) > 5

Здесь функция Compare преобразуется в соответствующую конструкцию SQL:

from c in db.Customer
where c.ContactName.CompareTo("John") > 0
select c.ContactName;

SQL:

SELECT
    [p].[ContactName]
FROM
    [Customers] [p]
WHERE
    [p].[ContactName] > 'John'

Следующий запрос реализует на SQL функцию эквивалентную функции Math.Round в .NET. Дело в том, что эта функция по умолчанию округляет число к ближайшему чётному, поэтому мы может наблюдать такую необычную реализацию.

from o in db.Order
where Math.Round(o.Freight) >= 10
select o.Freight;

SQL:

SELECT
    [o].[Freight]
FROM
    [Orders] [o]
WHERE
    CASE
        WHEN [o].[Freight] - Floor([o].[Freight]) = 0.5 AND Floor([o].[Freight]) % 2 = 0
            THEN Floor([o].[Freight])
        ELSE Round([o].[Freight], 0)
    END >= 10

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

from o in db.Order
where Math.Round(o.OrderDetails.Sum(d => d.Quantity * d.UnitPrice)) >= 10
select o.Freight;

SQL:

SELECT
    [o].[Freight]
FROM
    [Orders] [o]
        INNER JOIN [Order Details] [t1] ON [o].[OrderID] = [t1].[OrderID]
WHERE
    CASE
        WHEN (
            SELECT
                Sum(Convert(Decimal(5,0), [t2].[Quantity]) * [t2].[UnitPrice])
            FROM
                [Order Details] [t2]
            WHERE
                [o].[OrderID] = [t2].[OrderID]
        ) - Floor((
            SELECT
                Sum(Convert(Decimal(5,0), [t3].[Quantity]) * [t3].[UnitPrice])
            FROM
                [Order Details] [t3]
            WHERE
                [o].[OrderID] = [t3].[OrderID]
        )) = 0.5 AND Floor((
            SELECT
                Sum(Convert(Decimal(5,0), [t4].[Quantity]) * [t4].[UnitPrice])
            FROM
                [Order Details] [t4]
            WHERE
                [o].[OrderID] = [t4].[OrderID]
        )) % 2 = 0
            THEN Floor((
            SELECT
                Sum(Convert(Decimal(5,0), [t5].[Quantity]) * [t5].[UnitPrice])
            FROM
                [Order Details] [t5]
            WHERE
                [o].[OrderID] = [t5].[OrderID]
        ))
        ELSE Round((
            SELECT
                Sum(Convert(Decimal(5,0), [t6].[Quantity]) * [t6].[UnitPrice])
            FROM
                [Order Details] [t6]
            WHERE
                [o].[OrderID] = [t6].[OrderID]
        ), 0)
    END >= 10

Результат уже не столь ожидаем. Поэтому такой запрос лучше переписать следующим образом:

from o in db.Order
let sum = o.OrderDetails.Sum(d => d.Quantity * d.UnitPrice)
where Math.Round(sum) >= 10
select o.Freight;

SQL:

SELECT
    [o1].[Freight] as [Freight1]
FROM
    (
        SELECT
            (
                SELECT
                    Sum(Convert(Decimal(5,0), [t1].[Quantity]) * [t1].[UnitPrice])
                FROM
                    [Order Details] [t1]
                WHERE
                    [o].[OrderID] = [t1].[OrderID]
            ) as [sum1],
            [o].[Freight]
        FROM
            [Orders] [o]
                INNER JOIN [Order Details] [t2] ON [o].[OrderID] = [t2].[OrderID]
    ) [o1]
WHERE
    CASE
        WHEN [o1].[sum1] - Floor([o1].[sum1]) = 0.5 AND Floor([o1].[sum1]) % 2 = 0
            THEN Floor([o1].[sum1])
        ELSE Round([o1].[sum1], 0)
    END >= 10

Так гораздо лучше. Фактически предыдущий запрос эквивалентен следующему:

from o in
    from o in db.Order
    select new
    {
        sum = o.OrderDetails.Sum(d => d.Quantity * d.UnitPrice),
        o
    }
where  Math.Round(o.sum) >= 10
select o.o.Freight;

Конструкция let заворачивается ещё в один Select, для которого BLToolkit создаёт подзапрос. Но подзапросы создаются не для всякого Select. Так, например, для следующего кода подзапрос создан не будет:

from o in db.Order
let sum = o.Freight
where  Math.Round(sum) >= 10
select o.Freight;

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

Дополнительные возможности BLToolkit

Кроме поддержки стандартных возможностей Linq, BLToolkit реализует ряд дополнительных расширений.

Select

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

SELECT 
    CURRENT_TIMESTAMP as [c1]

Такая функциональность в BLToolkit реализуется следующим образом:

static void SimpleSelectTest()
{
    using (var db = new NorthwindDB())
    {
        var value = db.Select(() => Sql.CurrentTimestamp);

        Console.WriteLine(value);
    }
}

DML операции

Все существующие Linq провайдеры реализуют операции манипулирования данными (Insert, Update, Delete) посредством Entity Services, что вызывает много нареканий среди разработчиков по многим причинам. BLToolkit предлагает полный набор операций DML, включая операции манипулирования множеством записей одним запросом.

Insert

Следующий метод предназначен для добавления новой записи в таблицу БД.

db.Employee.Insert(() => new Northwind.Employee
{
    FirstName = "John",
    LastName  = "Shepard",
    Title     = "Spectre",
    HireDate  = Sql.CurrentTimestamp
});

SQL:

INSERT INTO [Employees] 
(
    [FirstName],
    [LastName],
    [Title],
    [HireDate]
)
VALUES
(
    'John',
    'Shepard',
    'Spectre',
    CURRENT_TIMESTAMP
)

К сожалению, таким способом нельзя работать с объектами, у которых нет конструктора по умолчанию или есть свойства только для чтения. Поэтому для таких объектов BLToolkit предлагает альтернативный Insert:

db
    .Into(db.Employee)
        .Value(e => e.FirstName, "John")
        .Value(e => e.LastName,  "Shepard")
        .Value(e => e.Title,     "Spectre")
        .Value(e => e.HireDate,  () => Sql.CurrentTimestamp)
    .Insert();

SQL для данного запроса будет аналогичен предыдущему.

Обратите внимание на способ задания значения последнего поля - здесь вместо простого значения используется лямбда. Это необходимо для того, чтобы присвоить полю значение серверной переменной.

Следующая группа функций позволяет работать с множеством записей:

db.Region
    .Where(r => r.RegionID > 2)
    .Insert(db.Region, r => new Northwind.Region()
    {
        RegionID          = r.RegionID + 100,
        RegionDescription = "Copy Of " + r.RegionDescription
    });

И альтернативный вариант:

db.Region
    .Where(r => r.RegionID > 2)
    .Into(db.Region)
        .Value(_ => _.RegionID,          r => r.RegionID + 100)
        .Value(_ => _.RegionDescription, r => "Copy Of " + r.RegionDescription)
    .Insert();

SQL:

INSERT INTO [Region] 
(
    [RegionID],
    [RegionDescription]
)
SELECT
    [r].[RegionID] + 100,
    'Copy Of ' + [r].[RegionDescription]
FROM
    [Region] [r]
WHERE
    [r].[RegionID] > 2

InsertWithIdentity

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

var value = db.Employee.InsertWithIdentity(() => new Northwind.Employee
{
    FirstName = "John",
    LastName  = "Shepard",
    Title     = "Spectre",
    HireDate  = Sql.CurrentTimestamp
});

var value =
    db
        .Into(db.Employee)
            .Value(e => e.FirstName, "John")
            .Value(e => e.LastName,  "Shepard")
            .Value(e => e.Title,     () => "Spectre")
            .Value(e => e.HireDate,  () => Sql.CurrentTimestamp)
        .InsertWithIdentity();

SQL:

INSERT INTO [Employees] 
(
    [FirstName],
    [LastName],
    [Title],
    [HireDate]
)
VALUES
(
    'John',
    'Shepard',
    'Spectre',
    CURRENT_TIMESTAMP
)

SELECT SCOPE_IDENTITY()

Update

Следующие три метода позволяют обновлять одну или более записей в таблице.

Обновление с предикатом:

db.Employee
    .Update(
        e => e.Title == "Spectre",
        e => new Northwind.Employee
        {
            Title = "Commander"
        });

Обновление с подзапросом:

db.Employee
    .Where(e => e.Title == "Spectre")
    .Update(e => new Northwind.Employee
    {
        Title = "Commander"
    });

Обновление свойства только для чтения:

db.Employee
    .Where(e => e.Title == "Spectre")
    .Set(e => e.Title, "Commander")
    .Update();

Для всех этих запросов генерируется следующий SQL:

UPDATE
    [e]
SET
    [Title] = 'Commander'
FROM
    [Employees] [e]
WHERE
    [e].[Title] = 'Spectre'

Ещё одна операция обновления, в которой участвует одно из полей самой таблицы:

db.Employee
  .Where(e => e.Title == "Spectre")
  .Set(e => e.HireDate, e => e.HireDate.Value.AddDays(10))
  .Update();

SQL:

UPDATE
  [e]
SET
  [HireDate] = DateAdd(Day, 10, [e].[HireDate])
FROM
  [Employees] [e]
WHERE
  [e].[Title] = 'Spectre'

Delete

И в завершении операция удаления.

Удаление с предикатом:

db.Employee.Delete(e => e.Title == "Spectre");

Удаление с подзапросом:

db.Employee
    .Where(e => e.Title == "Spectre")
    .Delete();

SQL:

DELETE [e]
FROM
    [Employees] [e]
WHERE
    [e].[Title] = 'Spectre'

SQL функции

Наряду со стандартными функциями .NET Framework, BLToolkit позволяет использовать SQL функции, которые предоставляются базами данных. Два следующих примера аналогичны примерам, которые мы рассмотрели выше для стандартных функций .NET Framework, но вместо них они используют функции SQL.

Использование функции вычисления длины строки:

from c in db.Customer
where Sql.Length(c.ContactName) > 5
select c.ContactName;

SQL:

SELECT
    [c].[ContactName]
FROM
    [Customers] [c]
WHERE
    Len([c].[ContactName]) > 5

Функция округления:

from o in db.Order
where  Sql.Round(o.Freight) >= 10
select o.Freight;

SQL:

SELECT
    [o].[Freight]
FROM
    [Orders] [o]
WHERE
    Round([o].[Freight], 0) >= 10

Обратите внимание, в этот раз функция округления вызывается напрямую так как она присутствует в SQL.

Расширение BLToolkit

SQL функции

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

[SqlFunction]
[SqlFunction("Access",    "Len")]
[SqlFunction("Firebird",  "Char_Length")]
[SqlFunction("MsSql2005", "Len")]
[SqlFunction("MsSql2008", "Len")]
[SqlFunction("SqlCe",     "Len")]
[SqlFunction("Sybase",    "Len")]
public static int? Length(string str)
{
    return str == null ? null : (int?)str.Length;
}

Таким образом, SQL функция - это обычный метод, помеченный атрибутом SqlFunction. Атрибут SqlFunction включает следующие свойства:

Последний параметр нуждается в особом объяснении. Дело в том, что BLToolkit пытается минимизировать нагрузку на сервер БД и при возможности переложить выполнение определённых функций на клиента. Но это поведение не всегда бывает полезно. Если SQL функция отмечена флагом ServerSideOnly, то функция в любом случае транслируется в SQL.

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

from c in db.Customer
select Sql.Length(c.ContactName);

SQL:

SELECT
    [c].[ContactName]
FROM
    [Customers] [c]

В этом случае строка передается клиенту, где уже и вычисляется её длина.

В следующем примере мы используем функцию Sql.AsSql, чтобы предотвратить такое поведение:

from c in db.Customer
select Sql.AsSql(Sql.Length(c.ContactName));

SQL:

SELECT
    Len([c].[ContactName]) as [c1]
FROM
    [Customers] [c]

Вы наверняка уже догадались как должна выглядеть такая функция:

[SqlExpression("{0}", 0, ServerSideOnly=true)]
public static T AsSql<T>(T obj)
{
    return obj;
}

Примечательным здесь является то, что в данном случае мы используем не SqlFunction атрибут, а похожий атрибут SqlExpression. Он во многом аналогичен SqlFunction, но принимает в качестве параметра форматную строку, с помощью которой строит SQL выражение. Аргументами форматной строки являются параметры метода, а, если мы имеем дело с обобщённым методом или классом, то и обобщённые параметры. Таким образом функции Sql.AsSql будет передано два параметра: obj и T. Но так как параметр T нас в данном случае не интересует, то второй параметр атрибута SqlExpression в нашем примере как раз говорит BLToolkit, что использовать следует только один параметр с индексом 0.

Стоит так же упомянуть ещё один атрибут - SqlProperty. Фактически это просто функция без параметров. Такое свойство Sql.CurrentTimestamp мы уже встречали раньше. Вот его реализация:

[SqlProperty("CURRENT_TIMESTAMP",   ServerSideOnly = true)]
[SqlProperty("Informix", "CURRENT", ServerSideOnly = true)]
[SqlProperty("Access",   "Now",     ServerSideOnly = true)]
[SqlFunction("SqlCe",    "GetDate", ServerSideOnly = true)]
[SqlFunction("Sybase",   "GetDate", ServerSideOnly = true)]
public static DateTime CurrentTimestamp
{
    get { throw new LinqException("The 'CurrentTimestamp' is server side only property."); }
}

Атрибуты SqlFunction, SqlExpression и SqlProperty можно применять одновременно к одному методу для разных провайдеров. Вот, например, как выглядит метод TrimLeft:

[SqlExpression("DB2", "Strip({0}, L, {1})")]
[SqlFunction  (       "LTrim")]
public static string TrimLeft(string str, char? ch)
{
    return str == null || ch == null ? null : str.TrimStart(ch.Value);
}

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

Подстановка методов

SQL функции очень важные и полезные примитивы, но, к сожалению, создание более менее сложных алгоритмов, не предусмотренных SQL серверами, на них крайне затруднительны. Возьмём к примеру уже виденную нами функцию Math.Round. Её реализация на C# с использованием SQL примитивов выглядит следующим образом:

static decimal? RoundToEven(decimal? value)
{
    return
        value - Sql.Floor(value) == 0.5m && Sql.Floor(value) % 2 == 0?
            Sql.Floor(value) :
            Sql.Round(value);
}

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

Для реализации таких функций BLToolkit предлагает другой механизм.

Прежде всего нам понадобится метод, который мы будем вызывать в наших Linq запросах. Приведённый выше RoundToEven вполне подойдёт. Далее нам нужно зарегистрировать эту функцию в BLToolkit и привязать к ней необходимое выражение. Делается это следующим образом:

static void RoundToEvenTest()
{
    Expressions.MapMember<decimal?,decimal?>(
        value => RoundToEven(value),
        value =>
            value - Sql.Floor(value) == 0.5m && Sql.Floor(value) % 2 == 0?
                Sql.Floor(value) :
                Sql.Round(value));

    using (var db = new NorthwindDB())
    {
        var query =
            from o in db.Order
            let sum = o.OrderDetails.Sum(d => d.Quantity * d.UnitPrice)
            where RoundToEven(sum) >= 10
            select o.Freight;

        foreach (var item in query)
        {
            Console.WriteLine(item);
        }
    }
}

Первым аргументом метода MapMember может быть либо тип MemberInfo, либо лямбда, из которой этот MemberInfo можно легко получить. Второй параметр - это выражение, которое будет подставлено вместо первого метода, когда BLToolkit встретит такой метод в Linq выражении.

Поддержка всех стандартных функций .NET Framework в BLToolkit реализована именно таким способом.

В дополнение метод MapMember может принимать имя SQL провайдера, что позволяет реализовывать разные алгоритмы для разных серверов баз данных.

Повторное использование выражений

Приведённый выше способ хорош для реализации глобальных, универсальных функций. Но иногда требуется повторно использовать Linq выражения для конкретной бизнес логики. Для этого в BLToolkit имеется похожий механизм, позволяющей локально объявлять методы и выражения для подстановки.

Предположим у нас имеется следующий код и мы хотим выражение Count повторно использовать для различных значений ShipRegion.

from c in db.Customer
select new
{
    sum1 = c.Orders.Count(o => o.ShipRegion == "SP"),
    sum2 = c.Orders.Count(o => o.ShipRegion == "NM")
};

Это можно сделать следующим образом:

[MethodExpression("OrderCountExpression")]
static int OrderCount(Northwind.Customer customer, string region)
{
    throw new NotImplementedException();
}

static Expression<Func<Northwind.Customer,string,int>> OrderCountExpression()
{
    return (customer, region) => customer.Orders.Count(o => o.ShipRegion == region);
}

static void MethodExpressionTest()
{
    using (var db = new NorthwindDB())
    {
        var query =
            from c in db.Customer
            select new
            {
                sum1 = OrderCount(c, "SP"),
                sum2 = OrderCount(c, "NM")
            };

        foreach (var item in query)
        {
            Console.WriteLine(item);
        }
    }
}

Здесь OrderCount является методом, который мы будем использовать в нашем Linq выражении и заменять его другим подстановочным выражением. Каким именно выражением его заменять определяется атрибутом MethodExpression. Этот атрибут задаёт метод, который нам и будет возвращать результирующее выражение. В нашем примере это метод OrderCountExpression.

Ниже приведён результирующий SQL:

SELECT
    (
        SELECT
            Count(*)
        FROM
            [Orders] [o]
        WHERE
            [c].[CustomerID] = [o].[CustomerID] AND [o].[ShipRegion] = 'SP'
    ) as [c1],
    (
        SELECT
            Count(*)
        FROM
            [Orders] [o1]
        WHERE
            [c].[CustomerID] = [o1].[CustomerID] AND [o1].[ShipRegion] = 'NM'
    ) as [c2]
FROM
    [Customers] [c]

Производительность

Проблемы производительности в Linq

Возможность создания Linq провайдеров, транслирующих Linq запросы в SQL и прочие форматы несомненно является большим достижением господствующих в индустрии технологий. Но какова плата за такие достижения? В статье Building a LINQ Provider кратко описывается процесс создания провайдера подобного Linq To SQL. Кроме всего прочего эта статья описывает путь, который проходит Linq выражение от структуры, созданной компилятором, до превращения её в SQL или другой формат.

В отличии от обычного кода, компилятор не создаёт на основе Linq выражений исполняемый код, а включает вместо него код, формирующий специальную структуру, описывающую исходное Linq выражение, так называемую Expression Tree. Далее эта структура каким-то образом передаётся Linq провайдеру, который в свою очередь разбирает её и преобразует в нужный формат. Всё это делается во время выполнения программы. Т.е. вызывая метод с Linq запросом, мы вызываем код, который каждый раз сначала создаёт Expression Tree, потом передаёт его Linq провайдеру, который в свою очередь его парсит, используя сложный и ресурсоёмкий алгоритм.

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

var query =
    from e in db.Employee
    select e;

if (a > 0)
{
    query =
        from e in query
        where e.EmployeeID > id
        select e;
}

Во-вторых, значения передаваемых параметров, таких как параметр id в примере выше, встраиваются непосредственно в Expression Tree, делая эту структуру зависящей от значений параметров.

В результате мы вынуждены парсить Linq запросы каждый раз заново. Понятно, что при таком подходе невысокая скорость выполнения Linq запросов нам гарантирована.

Если обратиться к тестам сайта ORMBattle.net, например к этой диаграмме, то можно легко убедиться в справедливости данного предположения. Ниже приведена таблица с цифрами из диаграммы, которые нам будут интересны.

BLT

EF

ES

DO

L2S

SQL

Unit

LINQ Query

8749

450

933

1559

835

n/a

queries/s

Compiled LINQ Query

13222

6501

2395

8046

9114

n/a

queries/s

Native Query

17187

9728

8465

10335

n/a

18645

queries/s

Здесь по горизонтали:

По вертикали перечислены различные Linq провайдеры, предпоследняя колонка - чистый SQL, последняя - единица измерения (запросов в секунду). Максимально достижимый эталон выделен жирным - это SQL / Native Query.

Сравнивая эталон с цифрами из строки LINQ Query можно заметить, что для некоторых провайдеров замедление клиентского кода, при использовании Linq запросов, может составлять десятки! раз.

Как BLToolkit борется с этой ситуацией при условии, что алгоритм парсинга Expression Tree в BLToolkit ничуть не менее сложный и ресурсоёмкий? Мы помним, что кэшировать Expression Tree нельзя, но элементарная логика утверждает, что если очень хочется, то можно.

Военная хитрость заключается в следующем. Для каждого запроса BLToolkit берёт из кэша уже обработанные деревья и сравнивает их с исходным. При сравнении деревьев не учитываются фрагменты, которые расцениваются как параметры. Если аналогичный исходному запрос находится в кэше, то исходное Expression Tree передаётся ему для извлечения параметров, после чего запрос выполняется. Для уменьшения количества сравнений последние найденные запросы передвигаются вперёд, но главное для каждого типа возвращаемого запросом значения строится отдельный кэш. Таким образом количество сравнений деревьев сводится к минимуму, а для анонимных типов оно практически уникально. Результат вы видите в таблице - обычные Linq запросы BLToolkit работают быстрее даже скомпилированных запросов большинства Linq провайдеров.

CompiledQuery

Для решения вышеописанной проблемы многие провайдеры, включая BLToolkit, предлагают так называемые Compiled Query - компиляцию Linq запросов в лямбды, которые в дальнейшем можно использовать без повторного парсинга. Выглядит это следующим образом:

static Func<NorthwindDB,int,IEnumerable<Northwind.Employee>> _query =
    CompiledQuery.Compile<NorthwindDB,int,IEnumerable<Northwind.Employee>>((db, n) =>
        from e in db.Employee
        where e.EmployeeID > n
        orderby e.LastName, e.FirstName
        select e
    );

static void CompiledQueryTest()
{
    using (var db = new NorthwindDB())
    {
        var query = _query(db, 5);

        foreach (var item in query)
        {
            Console.WriteLine(item);
        }
    }
}

Весь запрос передаётся методу Compile класса CompileQuery, возвращающему скомпилированную лямбду, которую в дальнейшем можно использовать как обычную функцию.

При таком подходе время выполнения запросов на клиенте вырастает в разы, но и плата очевидна - более громоздкий и неочевидный код.

Заключение

В конце хочется поблагодарить всех, кто принимал участие в обсуждении и тестировании Linq провайдера BLToolkit. Отдельная благодарность всему проекту RSDN@Home, в очередной раз ставшему полигоном новых идей, и лично Андрею Корявченко за позитивную критику, выявленные ошибки и множество интересных идей.


Эта статья опубликована в журнале RSDN Magazine #4-2009. Информацию о журнале можно найти здесь
    Сообщений 100    Оценка 3451        Оценить