Удобная организация DAL с помощью t4 на C#

Автор: Черняев Константин
Опубликовано: 13.11.2012
Версия текста: 1.0
Постановка проблемы
Общая идея
Методы вставки
Методы удаления
Методы обновления
Методы слияния
Методы выборки данных
Метод выборки по Id-полям
Метод выборки полного набора объектов
Простые методы поиска
Расширяемые простые методы поиска
Методы настраиваемого поиска
Класс Db
Как использовать генератор
Многотабличные DTO
Резюмируем перечень возможностей
Ограничения и выводы
Исходники на GitHub

Лучший способ устранить проблему – 
сделать невозможной причину появления 
ее причины появления.
Одна из главных мыслей, 
которую уловил автор из ТРИЗ.

Постановка проблемы

Всегда, даже при уже выбранной архитектуре приложения, явно или не явно, встает вопрос – как организовывать слой доступа к данным (Data Access Layer, DAL). Конечно, в случае малого проекта самое простое – сделать простой класс Db и написать в нем на чистом ADO.NET несколько методов. Но в процессе развития проекта следование такому подходу ведет к сложностям:

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

Возникает вопрос – как сделать DAL

Это концептуальные задачи, а конкретные пожелания, какой DAL был бы удобен:

Для решения указанных проблем существует целый класс решений – Object-Relation Mapping tools (ORM). Все они отлично подходят, если во всем проекте допускается использовать некий объект, позволяющий исполнить любой запрос к БД из любого места кода (DataContext в LINQ to SQL, ObjectContext в Entity Framework), «размазывая» таким образом логику выборки данных по всему коду приложения. Обычно в более-менее больших проектах такое «размазывание» не допускается, и создается класс-фасад, назовем его Db, в котором сосредотачивают детерминированные методы, касающиеся доступа к БД. Здесь и далее под «детерминированным методом» подразумевается, что набор SQL-запросов, которые он может выполнить в БД, ограничен и максимально узок.

Для пользователей этого фасада преимущество от использования любого ORM – только в наличии (автосгенерированных) DTO-классов, которые удобно использовать во всем приложении. Для создателя же фасада преимущество заключается, кроме автогенерации DTO-классов, в удобстве написания этого класса (по сравнению с native ADO.NET) и некоторых других плюсах, зависящих от конкретной реализации. Удобство написания кода очень важно для скорости разработки и легкости поддержки, но класс-фасад все равно нужно писать. А он обычно имеет весьма много методов – как минимум по 4 (по модели CRUD) на каждую сущность, поддерживаемую в режиме чтения-записи, но зачастую гораздо больше в связи с разнообразностью методов выборки.

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

Формат описания приспособлен для ручного ввода. Он создан максимально user-friendly, простым и немногословным, основан на xml. С помощью прилагающейся xsd-схемы при использовании Visual Studio работают подсказки intellisense.

Детальный список преимуществ по сравнению с существующими ORM представлен в конце статьи.

Общая идея

Общая идея предлагаемой реализации слоя DAL заключается в том, чтобы в простом почти плоском xml-формате описать структуру данных и с помощью tt-автогенерации получить всю необходимую инфраструктуру для удобной работы с DTO-объектом. Рассмотрим простейший пример такого xml-описания в предлагаемом формате без взаимодействия с БД:

    <dto name='DummyDTO'>
        <p name='Prop' type='int'/>
        <p name='IntProp' type='int' nullable='true'/>
        <p name='StringProp' type='string'/>
        <p name='ListProptype='List&lt;string&gt;'/>
        <p name='ArProp' type='string[]' summary='array property'/>
    </dto>

На выходе генератора для этого xml получаем такой простой DTO-класс:

    [Serializable]
    public class DummyDTO : DtoDbBase<DummyDTO>
    {
        public int Prop { getset; }
        publicint? IntProp { get; set; }
        public string StringProp { getset; }
        public List<string> ListProp { getset; }
        /// <summary>
        ///  array property
        /// </summary>
        public string[] ArProp { getset; }
        protected override bool InteriorEquals(DummyDTO other){return true;}
    }

Все поля класса получаются из тегов p, а метод InteriorEquals используется для сравнения (как в операторе ==, так и в методах Equals), но сравнивает он только primary key-поля (чтобы их отметить, надо к ним добавить атрибут id=''). Такое частичное сравнение на практике потребовалось гораздо чаще, чем полное (то есть всех полей). Те действия, где нужно сравнение всех полей (пример таких действий – обновление только измененных полей, журналирование изменений) выполяются другими, более удобными методами, о которых будет рассказано ниже. Тип-параметр при наследовании нужен, чтобы этот метод был строго типизирован.

Кроме этого, появляется класс-фасад public partial class Db : DbBase с методами поддержки чтения данных из БД:

Таким образом, очень просто получается вызвать из БД любую процедуру или представление (view), возвращающую таблицу со структурой, соответствующей DummyDTO, и одним из этих методов сразу вернуть объект.

Зачастую удобно добавить в объект поле, которого нет в соответствующей объекту таблице. Для этого нужно добавить атрибут-маркер notfromdb:

        <pname='NotFromDbProp'type='int'notfromdb='' />

и тогда оно не будет читаться из SqlDataReader в методе Read<DTO name>. На практике такие поля удобно использовать вместе с кастомизированным чтением из БД, для чего нужно определять метод Exec<DTO name>CustomOne, и появляется возможность читать из БД произвольный набор данных, преобразуя его в необходимый DTO. Например, если к объекту должны прилагаться какие-то списки данных (например, к объекту компании может прилагаться список ее сотрудников, или к объекту сотрудника – список его контактов), то простой Read<DTO name> не сможет их прочитать, поскольку он читает только скалярные значения. Поэтому необходимо будет объявить в DTO новое notfromdb-поле, а в классе Db поле Find<DTO name>SelectTemplate с кастомизированным SQL-запросом, выбирающим дополнительным оператором select этот список, и в Exec<DTO name>CustomOne прочитать его в объявленное notfromdb-поле. Подробнее об этом ниже, в разделе о методах выборки.

Иногда бывает удобно, чтобы имя поля в классе DTO отличалось от имени в таблице и SQL-запросах. Этого можно добиться, пометив соответствующий тег p атрибутом dbname='<prop name in Db>'. Например, в старых проектах часто бывает, что БД осталась «в наследство», и в ней много устаревших имен, несоответствующих смыслу, а менять их опасно, так как может быть много запутанных связей (особенно, если разные приложения обращаются напрямую к таблицам в различных участках кода). В таких случаях очень удобно изменить имя поля в приложении в одном месте (в xml-описании DTO), запустить tt-автогенерацию, и исправить все, что в Visual Studio будет подчеркнуто красным цветом как несуществующее поле (со старым именем).

Чтобы указать, какой таблице (или view) соответствует DTO, нужно в теге dto указать атрибут tableName='DummyTable dt'.

ПРЕДУПРЕЖДЕНИЕ

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

Методы вставки

Чтобы появился метод вставки

        public DummyDTO Insert(DummyDTO x) 

необходимо указать атрибут тега dtoinsertable='' и хотя бы одно поле пометить этим же атрибутом. Тогда в классе DTO cгенерируется конструктор с параметрами для всех помеченных полей, и вставка происходит с указанием в SQL-запросе только этих полей. Метод по умолчанию возвращает вставленный объект – с установленными identity-полями, если они есть, полями со значениями по умолчанию или установленными через триггер. Если возврат вставленного объекта не нужен, тег dto можно пометить insertable='void', тогда и метод будет void.

Стоит заметить, что метод с возвращением объекта реализован с обходом бага SQL Server’а, который выдает ошибку на запрос вида insert output inserted.*, если у таблицы есть триггер. Для обхода этого бага используется табличная переменная. Ценой этого способа возврата вставленного объекта является то, что будет делаться дополнительный оператор select по primary key-столбцам, которые нужно пометить атрибутом id=''. Поэтому, если возвращаемое значение использоваться не будет, лучше указать insertable='void'.

Кроме этого метода одиночной вставки, генерируется метод вставки коллекции

        public List<DummyDTO> Insert(IEnumerable<DummyDTO> x)

Комментарии по поводу void аналогичны.

ПРИМЕЧАНИЕ

Стоит отметить, что все объекты вставляются одним запросом, что положительно скажется на производительности при активном использовании этого метода.

Кроме того, генерируется метод partial void InsertAddon(DummyDTO t), который можно определить в основном классе Db, если нужно произвести какое-то дополнительное действие после вставки (например, журналирование, или таким образом можно вставить дополнительные данные в другие таблицы на основе notfromdb-полей).

Методы удаления

Метод удаления по идентификатору

         publicint DeleteDummyDTO(int id_, int userDeleterId) 

принимает столбцы primary key таблицы и идентификатор пользователя, удаляющего объект, который передается в метод

         partialvoid DeleteDummyDTOAddon(int userDeleterId, int id_)

Последний, в отличие от InsertAddon, вызовется перед удалением (а не после). И так же его можно определить, а можно не определять – в зависимости от нужности дополнительных действий. Для включения генерации метода удаления по primary key необходимо в теге dto указать атрибут-маркер deleteableById и указать id-поля.

Бывают ситуации, когда необходимо удаление по другому критерию. Например, может встать задача удалить все записи логов старше недели или удалить всех уволенных сотрудников со всеми их данными в других таблицах. Для таких случаев удобно объявлять собственные методы удаления, с помощью которых можно как подменить только условие where, так и полностью изменить весь SQL-запрос или вызвать хранимую процедуру. Для этого в теге dto необходимо указать тег deleteMethod, в нем объявить параметры и SQL-запрос. Можно не указывать весь SQL-запрос, а только подменить условие where, или просто использовать хранимую процедуру:

        <deleteMethod nameAddon='ByPropAndIks' summary='удалить'>
            <p name='Prop'/>
            <p name='iks' type='int'/>
            <sql type='WhereClause'>
                d.IKS=@iks  -- this can be any where clause
            </sql>
        </deleteMethod>

Параметры (теги p) могут браться из полей самого DTO – тогда не нужно указывать атрибут type, или могут быть дополнительными – тогда обязательно указать атрибут type, означающий .NET-тип. Параметру можно указать атрибут sqlName, чтобы имя параметра в SQL-запросе не совпадало с желаемым именем параметра метода. Это удобно для хранимых процедур.

Значением атрибута type у тега sql можно указать Text, Procedure или WhereClause. Значение Text означает, что подменяется весь SQL-запрос (на случай, например, если надо дополнительно удалить данные из связанных таблиц), Procedure – что там указывается имя хранимой процедуры, а WhereClause – что указанный текст надо подставить как условие where, оставляя начало запроса «delete from <table alias> from <table name> <table alias> where». В примере стоит обратить внимание на псевдоним таблицы d – он берется из атрибута tableName, указанного выше.

Имя метода формируется по формуле Delete<DTO name><nameAddon>, где nameAddon берется из атрибута nameAddon.

Методов удаления можно объявить сколько угодно, главное, чтобы они не конфликтовали по правилам C#.

Методы обновления

Чтобы появилась возможность обновлять данные в БД, тег dto необходимо пометить как updateable='' (значение атрибута может быть 'void', тогда соответствующий update-метод будет void – иначе он возвращает измененный объект, прочитанный из базы после обновления), и так же для хотя бы одного поля в этом dto-теге. Сгенерируется класс

    public class <DTO name>Updater : UpdateBase

отвечающий за передачу значений изменяемых полей. Суть его предназначения заключена в коллекции

        public IEnumerable<KeyValuePair<stringobject>> ChangedProps

представляющей из себя набор пар «ключ-значение». Для ее заполнения используются поля, соответствующие тегам, помеченным атрибутом updateable, например:

        string _stringNulProp;
        public string StringNulProp {
            get { return _stringNulProp; }
            set { Changed["StringNulProp"] = _stringNulProp = value; }
        }

Словарь Dictionary<string, object> Changed на самом деле является источником для вышеупомянутого свойства ChangedProps. Кроме этих свойств, отвечающих за перенос значений изменяемых полей, генерируются id-поля, чтобы метод смог идентифицировать, какую строку в БД надо обновить, например:

        public int Prop { getprivate set; }

Берутся они, конечно, из полей, помеченных как id=''. Основной конструктор Updater-класса на вход принимает набор значений всех id-полей.

Таким образом, инициализация объекта Updater-класса состоит из двух шагов:

  1. Вызвать конструктор, передав ему primary key-поля.
  2. Присвоить по одному значения всех полей, которые надо обновить (или с помощью object initializer, что на самом деле одно и то же).

Бывают ситуации, когда объект должен быть обновлен в разных местах разными методами, с разными наборами обновляемых полей. Чтобы вынести вопрос о том, какие поля надо обновлять в одной ситуации, а какие – в другой на уровень формирования xml-описания, можно с помощью xml-разметки сделать дополнительные конструкторы Updater-класса. Для этого нужно перечислить через запятую в атрибуте updateable их имена, как бы объединив updateable-поля в именованные группы. Полезно это делать, чтобы не забыть, когда какие поля обновлять, а, объединив их в разноименные группы (имя группы будет в summary–теге конструктора), для каждой группы будет свой конструктор. И забыть передать какое-то значение станет невозможно.

Рассмотрим базовый класс для Updater-класса:

    public abstract class UpdateBase

Его основная задача – хранить вышеупомянутый словарь-коллекцию изменяемых значений:

        Dictionary<stringobject> Changed

Кроме этого, в нем есть свойства:

        public bool IsEmpty { get { return Changed.Count == 0; } }
        public string ChangedAsString

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

В Updater-классе генерируется метод

        public bool ExcludeEquals(DummyDTO x)

который по значениям полей данного объекта исключает из коллекции Changed неизмененные значения и возвращает значение IsEmpty после этого. Это не очень важно для процедуры обновления в БД, но может быть важно для журналирования. Кроме того, если после этого IsEmpty становится истиной (то есть ExcludeEquals вернет true), то запрос в БД на обновление можно вообще не совершать – это может несколько улучшить производительность за счет исключения лишнего коннекта с БД.

Перейдем собственно к методу обновления:

        public DummyDTO Update(DummyDTOUpdater xu)
        public List<DummyDTO> Update(IEnumerable<DummyDTOUpdater> xu)

Как упоминалось выше, эти методы могут быть void, если тег dto помечен как updateable='void'. Действия методов очевидны – данный на вход объект (коллекция объектов) содержит в себе значения primary key и упомянутую коллекцию Changed, и по этим данным генерируется соответствующий SQL-оператор update. Все значения передаются как параметры, чтобы не множить бесконечно кешируемые планы и исключить sql injection, а наборы изменяемых столбцов могут быть разные. Возвращает метод, если он не void, аналогично методу вставки, измененный объект (список объектов), считанный из БД после исполнения SQL-оператора. Как и в случае метода вставки, учитывается баг SQL Server’а, который не позволяет делать запросы вида update output inserted.* на таблицы, у которых существуют триггеры – а ведь кеш ASP.NET с зависимостью SqlCacheDependency работает именно через триггеры. Так как этот вид зависимости очень удобно использовать для редко изменяемых данных, важно учитывать вышеобозначенный баг.

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

        partial void UpdateAddon(DummyDTOUpdater xu)

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

Например, так в итоге может выглядеть обновление:

    public partial class Db
… 
        partial void UpdateAddon([NotNull]DummyDTOUpdater xu)
        {
            logger.Log(string.Format("Id={0} changed: ", xu.Prop)
+ xu.ChangedAsString);
        }

…
            DummyDTO d = GetCurrent();

            // инициализируем ПК
            DummyDTOUpdater du = new DummyDTOUpdater(d.Prop);
            // заполняем возможно измененные поля
            du.IntProp = int.Parse(textBoxForInt.Text); 
            du.StringNulProp = textBoxForString.Text.TrimOrNullIfEmpty();
            if (!du.ExcludeEquals(d))
                Db.Update(du);

Конечно, метод ExcludeEquals не обязательно использовать. В примере присутствует текущий объект, d, и поэтому можно вызвать du.ExcludeEquals(d), но если его в текущем участке кода нет, то дополнительный запрос за ним в БД может «не окупиться».

Методы слияния

В ситуациях, когда нужно обновить или вставить какой-то объект в зависимости от того, присутствует ли он уже, удобно использовать методы слияния (SQL-оператор merge). Для объявления метода слияния нужно в тег dto добавить подтег mergeMethod. Например:

        <mergeMethod nameAddon='ByInt'>
            <joinby name='IntProp' />
            <exclude name='StringProp' />
        </mergeMethod>

В результате будет сгенерирован метод (предположим, имя DTO – DummyDTO)

        public DummyDTO MergeByInt(DummyDTO x),

который выполнит SQL-оператор merge, используя условие соединения по указанным в тегах joinby полям, или, если таковых нет, по id-полям. Поля, указанные в тегах exclude, не будут участвовать в обновлениях и вставках при слиянии, так же как и notfromdb-поля. Можно указать, чтобы метод не возвращал значение – надо тег mergeMethod пометить атрибутом custom='void', если же указать custom='', то метод вернет объект, используя FindDummyDTOSelectTemplate и ExecDummyDTOCustomOne, которые описаны в параграфе о методе выборки по id-полям. Можно добавить атрибут useUpdatedDate='', и в SQL-запросе будет указание в столбец UpdatedDate проставить текущую дату, если было изменено хотя бы одно поле (то есть поставить дату последнего изменения). Если имя столбца для этой цели другое, нужно указать его в значении атрибута.

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

Примеры использования метода слияния:

Методы выборки данных

Метод выборки по Id-полям

Обычно для любого DTO объекта необходим метод поиска по primary key-полям. Чтобы он появился, необходимо тег dto пометить атрибутом generateFindByIdMethod=''. Появится метод:

        public DummyDTO FindDummyDTOById(int id_) 

В случае, когда у DTO имеются notfromdb-поля, чтобы была возможность их заполнять внутри метода FindDummyDTOById, нужно указать generateFindByIdMethod='custom'. Тогда метод будет использовать поле stringFindDummyDTOSelectTemplate и метод DummyDTO ExecDummyDTOCustomOne(SqlCommand com), и их необходимо вручную определить. Поле FindDummyDTOSelectTemplate используется как шаблон SQL-запроса (в который с помощью string.Format подставляется условие where), а метод ExecDummyDTOCustomOne – как пользовательский метод чтения. Например:

        const string FindAttributeDTOSelectTemplate = @"select a.* from dbo.Attributes a where {0} ; select u.* from dbo.Units u join dbo.Attributes a on u.UnitId = a.UnitId    where {0}";
 
 
        static AttributeDTO ExecAttributeDTOCustomOne(SqlCommand com)
        {
            using (SqlDataReader r = com.ExecuteReader())
                if (r.Read())
                {
                    AttributeDTO a = ReadAttributeDTO(r);
                    r.NextResult();
                    r.Read();
                    a.Unit = ReadUnitDTO(r);
                    return a;
                }
            return null;
        }

В примере объявлено одно дополнительное поле в DTO:

        <p name='Unit' type='UnitDTO' notfromdb=''/>,

которое читается из SqlDataReader из второго возвращенного DataSet’а.

Метод выборки полного набора объектов

Если необходимо из БД прочитать весь набор объектов одного типа, целесообразно использовать метод

         public List<DummyDTO> GetAllDummyDTO()

Этот метод возвращает результат запроса "select * from dbo.[DummyTable]". Для его генерации необходимо в xml-описании дополнить атрибуты тега dto: tableName='DummyTable dt'generateGetAllMethod=''. Можно в значении второго атрибута указать предложение order (например, generateGetAllMethod='order by Prop desc, Prop2 asc'), и метод вернет упорядоченный набор.

Если у DTO имеются notfromdb-поля, то dto нужно пометить как generateFindByIdMethod='custom', чтобы была возможность их заполнять при чтении из БД. Тогда сигнатура метода несколько поменяется:

        public List<DummyDTO> GetAllDummyDTO(bool selectCustom = false)

Если в параметре selectCustom передано true, то на каждый из прочитанных объектов делается еще один запрос с помощью FindDummyDTOSelectTemplate и ExecDummyDTOCustomOne. Заметим, что это оправдано в случае небольших редко меняющихся наборов, например, справочников. Если же передано false, то метод будет быстрым (выполнится только один запрос к БД), но без заполнения notfromdb-полей.

Простые методы поиска

Рассмотрим простейшие методы поиска объектов – по конкретным точным значениям полей выбранного DTO. Например, поиск одного пользователя по логину, списка пользователей по роли, в случае, если роль привязана к пользователю столбцом в таблице пользователей (если же привязка осуществлена иначе, необходимо использовать методы поиска из двух следующих параграфов).

Для объявления простого метода поиска нужно в тег dto добавить подтег findMethod. Пример поиска по двум полям класса:

        <findMethod nameAddon='ByIntAndString' >
            <p name='IntProp'/>
            <p name='StringProp'/>
        </findMethod>

В этом случае будет сгенерирован метод

        public List<DummyDTO> FindDummyDTOByIntAndString(int intprop,
            string stringprop)

который по данным значениям полей вернет список найденных объектов. Атрибут nameAddon можно не указывать, тогда будет работать перегрузка методов, и надо будет следить, чтобы сигнатуры методов были различны. У тега findMethod могут быть указаны атрибуты: custom – объект будет читаться с помощью описанных выше FindDummyDTOSelectTemplate и ExecDummyDTOCustomOne, orderby – возвращенный список будет отсортирован по указанному полю, return – можно указать значение one или list (по умолчанию list), что означает вернуть соответственно один объект (если, например, этот метод поиска по уникальному полю) или список.

ПРИМЕЧАНИЕ

Полем простого поиска может быть только поле, не помеченное как notfromdb.

Методов поиска можно определить сколько угодно.

Расширяемые простые методы поиска

Иногда бывает необходимо, чтобы поиск по конкретным точным значениям принимал дополнительные параметры, не являющиеся непосредственно полями объекта, или чтобы условием на данное значение было сравнение, отличное от равенства. Например, если нужно найти всех сотрудников, принятых на работу позже данной конкретной даты (если дата является полем объекта, точное значение передано, но условие «больше», а не «равно»). Или сотрудников, которые за последний данный интервал времени проявили активность (создали постов в блоге, закрыли задач) меньше какого-то порога. Метод из предыдущего параграфа не сможет реализовать такой поиск.

В xml-описании DTO можно объявить метод поиска по конкретным значениям, поичем не только полей объекта. Кроме того, можно подставить произвольный текст SQL-запроса. Используется тот же тег findMethod, но можно добавить дополнительные параметры (теги p), и подменить условие where или весь SQL-запрос, или вовсе использовать хранимую процедуру:

        <findMethod nameAddon='Where' custom='' orderby='IntProp' return='list'>
            <p name='IntProp'/>
            <p name='elseparam' sqlName='iks' type='int'/>
            <sql type='WhereClause'>
                d.IKS=@iks  -- this can be any custom where clause
            </sql>
        </findMethod>

У дополнительных параметров (то есть не являющихся полями DTO) нужно указать атрибут type, означающий .NET-тип параметра, и можно указать sqlName, если имя SQL-параметра не совпадает с желаемым именем параметра метода. Обычно атрибут sqlName нужен при использовании хранимой процедуры.

В атрибуте type у тега sql можно указать Text, Procedure или WhereClause.

Методы настраиваемого поиска

Концепция

Недостаток описанных методов поиска заключается в том, что им обязательно нужно задать все их параметры. С помощью предлагаемого инструмента автогенерации можно объявить метод поиска по произвольному набору полей. Для определенности назовем его finder-методом, чтобы отличать от методов, описанных выше.

Для включения генерации finder-метода нужно соответствующий тег dto пометить атрибутом-маркером generateFinder=''. В этом случае необходимо, чтобы присутствовало хотя бы одно id-поле, атрибут tableName и хотя бы одно поле, участвующее в поиске. Чтобы пометить поле как участвующее в поиске, нужно пометить соответствующий тег p атрибутом finder. Если тип этого поля – строка (string), то по умолчанию поиск по нему будет вестись по вхождению строки-параметра. Если же тип – целое число, то условием будет равенство чисел, в случае, если данная строка-параметр является числом. Важно обратить внимание, что на все такие поля (целые числа и строковые) будет одна строка поиска. То есть запрос будет наподобие «у каких пользователей емейл или почта содержат “yandex.ru”» или «у каких компаний один из их адресов или город (если он является отдельным полем) содержат “москва”».

Если тип поля – DateTime, то по умолчанию поиск будет вестись по диапазону дат, причем один из концов диапазона может быть не задан. В остальных случаях по умолчанию поиск будет вестись по точному равенству заданному значению. Понятно, что «остальные случаи» - это практически только булев тип. Таким образом, в простейшем случае finder-метод позволяет только спросить «у каких объектов все указанные строковые поля содержат указанную подстроку, указанные числовые поля равны данному числу (если указанная подстрока является числом), указанные поля даты находятся в данном интервале (для каждого поля свой), а указанные булевы поля равны данному (для каждого поля свое значение)».

Если задано несколько полей поиска (как было указано, поиск строк и целых чисел выполняется по одной «строке поиска»), то их условия (если заданы при вызове) соединяются логическим И.

Это базовая функциональность описываемого метода. Расширить это поведение можно такими способами:

        <finderAddonFields>
            <p name='UserFIO' type='string'
                summary='Поиск компании по ФИО работника'/>
            <p name='VisitorUserIds' type='List&lt;int&gt;'
                canFindByNull='true' />
        </finderAddonFields>

Предположим, что последний пример относится к DTO, соответствующему организации. В этом случае сгенерированный метод позволяет спросить «найти организации, в которых работают люди с таким ФИО и которых посещали конкретные люди (по идентификатору) из данного набора». Указанные поля генерируются в специальном finder-классе, с помощью которого производится поиск. Поле canFindByNull='true' позволяет спросить «какие организации никто не посещал?», то есть провести поиск не по значению, а по отсутствию значения.

Работает все это с помощью finder-класса:

    public class DummyDTOFinder : FinderBase<DummyDTOFinder>

Его класс-родитель:

    public abstract class FinderBase<T> where T : FinderBase<T>

Класс-параметр нужен, чтобы были строго типизированные методы проверки на равенство:

        public static bool operator ==(FinderBase<T> left, FinderBase<T> right)
        protected abstract bool InteriorEquals(T other);

Второй метод определяется в каждом конкретном finder-классе, и в его автогенерируемой реализации сравниваются все поля – чтобы можно было, в случае равенства, не делать повторный запрос поиска в БД. Стоит упомянуть, что списковые поля сравниваются полностью корректно – не учитывается кратность вхождения и порядок элементов, поскольку и результат поиска от этого не зависит. Кроме того, в родителе определен метод клонирования

        public virtual T Clone()

который по умолчанию клонирует через MemberwiseClone(). А если необходимо, в конкретном классе клонирование доопределяется до глубокого, чтобы списки и объекты копировались наряду со скалярами.

Как было указано выше, строковые и целочисленные поля, помеченные как finder='', участвуют в поиске по общей для всех подстроке (числу для целочисленных). Поэтому строка для этого вынесена в родитель:

        public string SearchString { getset; }

Для тех finder-классов, где нет ни одного такого finder-поля, SearchString просто не используется в сгенерированных методах.

Примеры полей поиска

Конкретные finder-классы содержат все необходимые поля поиска, примеры:

1.

        public List<int?> SupplierId { getset; }

Тут поле в xml-описании было указано так:

    <p name='SupplierId' type='int?' finder='liststandart' />

2.

          public bool? IsActive { getset; } 

Тут поле bool и указан атрибут finder='':

3.

        public DateTime? CreatedDateBegin { getset; }
        public DateTime? CreatedDateEnd { getset; } 

Тут поле описано так:

    <p name='CreatedDate' type='DateTime' finder=''/>

4.

        public string SupplierName { getset; }
        public bool SearchByNullSupplierName { getset; } 

Тут в теге dto был указан тег finderAddonFields с таким подтегом:

        <p name='SupplierName' type='string' canFindByNull='true'/>

5.

          public bool SearchByNullRoles { getset; }
public List<int> Roles { getset; }

Тут в теге dto был указан тег finderAddonFields с таким подтегом:

        <p name='Roles' type='List&lt;int&gt;' canFindByNull='true'/> ,

Причем может быть так, что поле Roles в объекте есть – добавляется поле поиска с таким именем, которое может использоваться независимо.

ПРИМЕЧАНИЕ

Для полноты понимания важно подчеркнуть, что для реализации функциональности canFindByNull='true' необходимо все-таки заводить дополнительное поле. Нельзя в общем случае в основное поле добавить null (добавить, если оно списковое, и присвоить, если нет), и реализовать такое поведение. Например, к объекту может привязываться список значений, и часть в них будет null, и, так как хотелось бы различать поиск «найти привязанные null’ы» и «найти объекты без привязок», без дополнительного поля не обойтись.

Поле public bool IsEmpty используется внутри методов поиска, и в пользовательском использовании, скорее всего, не потребуется. Название поля говорит за себя – задано ли хотя бы одно условие.

Генерируемые методы

Рассмотрим методы, реализующие собственно finder-поиск:

1.

    public List<DummyDTO> FindDummyDTOs(DummyDTOFinder f, string orderBy = null,
        int startIndex = 0, int count = int.MaxValue,
        bool needCustomSelect = false)

Последний параметр генерируется, только если тег dto помечен как generateFindByIdMethod='custom', и нужен, чтобы все объекты вернулись через FindDummyDTOById (то есть чтобы заполнились notfromdb-поля). Назначение метода – возвращать список найденных объектов из данного окна с учетом данной сортировки.

ПРИМЕЧАНИЕ

Имя столбца, или столбцов сортировки, должно быть указано из таблицы SQL Server’а, и их при этом даже не обязательно делать полями DTO.

Окно подразумевается как количество count объектов, начиная с индекса startIndex. Чтобы вернуть все объекты, можно эти параметры не указывать. Очень удобно использовать такие методы для GridView с постраничным выводом (paging).

2.

    public int FindDummyDTOCount(DummyDTOFinder f)

Возвращает количество найденных объектов. Этот метод тоже очень удобно вызывать в соответствующем методе для GridView с постраничным выводом.

3.

    public int? FindDummyDTOPageIndex(DummyDTOFinder f, int pageSize, int id_,
        string orderBy = null)

Возвращает номер страницы (окна), на которой данный объект (по данным значениям id-полей) находится с учетом сортировки. Можно использовать, например, если надо раскрыть GridView на той странице, где находится заданный объект.

4.

    static partial void GetWhereClauseAddon(DummyDTOFinder f,
        List<string> wheres, SqlCommand com);

Необходимо определить этот метод, если нужно реализовать дополнительные условия сравнения – либо указанные в finderAddonFields, либо notfromdb-поля с атрибутом finder, или поля, у которых указано finder='list'. В переданный объект SQL-команды (параметр com) можно добавлять дополнительные необходимые параметры. Например, тело функции может выглядеть так:

            if (f.SupplierIds != null && f.SupplierIds.Count > 0)
                wheres.Add(@" (select u.SupplierId from dbo.Users u where u.UserId=ptv.UserId) in (" + string.Join(",",f.SupplierIds) + ") ");

Тут проверяется коллекция (SupplierIds) в переданном объекте-финдере (f), и, если не пуста, то добавляется условие, использующее эту коллекцию. Стоит обратить внимание на псевдоним таблицы ptv. Он берется из объявления атрибута tableName тега dto.

Получается следующая логическая схема условий:

([поиск по SearchString – соединение условий по объявленным полям через ИЛИ] ИЛИ [дополнительные or-условия поиска])

И [проверка всех полей finder='liststandart' – соединение через И]

И [проверка всех полей finder='range', а так же дат – соединение через И]

И [все дополнительные условия из коллекции wheres через И]

5.

Чтобы добавить дополнительные or-условия поиска, нужно определить метод

    static partial void GetWhereClauseOrsAddon(DummyDTOFinder f,
        List<string> ors, SqlCommand com);

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

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

Класс Db

В ходе работы наверняка потребуется реализовать дополнительную функциональность для работы с БД. Это может быть инициализация класса Db, задание CommandTimeout, методы Find<DTO name>SelectTemplate, GetWhereClauseAddon, GetWhereClauseOrsAddon и вручную написанные методы. Все это необходимо определить в пользовательской partial-части класса, написанной вручную. Рассмотрим инфраструктуру, которая для этого появляется в фасаде Db (и его родителе DbBase).

1.

Конструктор

        public Db(string connectionStringName = null)
        {
            ConnectionString = string.IsNullOrEmpty(connectionStringName)
                ? ConfigurationManager.ConnectionStrings["DEFAULT"]
                    .ConnectionString
                 : ConfigurationManager.ConnectionStrings[connectionStringName]
                    .ConnectionString;
            Init();
        }
        /// <summary>ConnectionString is already configured. Here you can init CommandTimeout field.</summary>
        partial void Init();

На вход получает имя строки соединения. Если параметр не задан, используется имя по умолчанию, которое можно объявить в xml-описании:

        <objects namespace='Ns' ConnectionStringName='DEFAULT'>

2.

Метод Init можно определить на случай необходимости какой-либо инициализации. Например, как указано в комментарии в коде, если нужно изменить значение CommandTimeout, объявленное как

        protected int CommandTimeout = 30;

Это значение используется в методе

        protected SqlCommand PrepareCommand(SqlConnection con,
            CommandType ct = CommandType.Text, bool needOpenConnection = true)

который создает и подготавливает SqlCommand и используется во всех командах. Таким образом, если объявить

        partial void Init(){ CommandTimeout = 60; }

то во всех запросах к БД таймаут станет минутой.

3.

Метод передачи параметра-списка:

        protected static void AddListParam<T>(string name, IEnumerable<T> l,
            SqlCommand com)

Использует объявленные в БД типы IntList и StringList (определенные как create type IntList as table(i int null)). В автогенерируемых методах не используется ввиду последующих возможных неразрешимых проблем с производительностью. В своих методах его рекомендуется использовать с большой осторожностью.

4.

Очевидные «велосипедные» методы, облегчающие написание дополнительных методов, обращающихся к БД:

ExecuteNonQuery, ExecuteScalar, ExecuteList, ExecuteReader

У них есть перегрузки с различными сигнатурами на все случаи жизни. Все поддерживают передачу списковых параметров. Все объявлены как protected, чтобы интерфейс класса был строго типизирован и без указания SQL-скриптов или имен хранимых процедур. Методы ExecuteList получают на вход Func<IDataRecord,T> reader, который по SqlDataReader возвращает нужный объект – и список таких объектов будет возвращен. Если reader не передан, то будет браться первый столбец (например, если нужно считать список чисел, а не объектов). Методы ExecuteReader получают на вход Action<SqlDataReader> reader, и с помощью данного SqlDataReader выполняют действия, которые не укладываются в подход предыдущего метода – например, когда SQL-запрос возвращает несколько наборов данных.

Как использовать генератор

Очень удобно делать так:

1. Все общие классы (DbBase, DtoDbBase, FinderBase, UpdatBase) выносим в отдельный проект My.Common, и его вместе с генератором DTO.tt и схемой DTO.xsd (еще необходим вспомогательный файл Common.tt) сохраняем в отдельном git-репозитарии, назовем его My.Common.

2. В репозитариях с конкретными проектами делаем репозитарий My.Common субмодулем.

3. В конкретном проекте, отвечающим за слой DAL, создаем файл IncludeDTO.tt с таким простым содержанием:

<#@ includefile="../My.Common/DTO.tt" #>

Чтобы работал intellisense, достаточно в проект положить не сам файл DTO.xsd, а ссылку на него:


4. В том же проекте создаем файлы вида DTO*.xml – можно их создавать много, группируя объекты по разным файлам.

5. Нацеливаем на них DTO.xsd, и с помощью intellisense можно писать dto-теги.

6. Запускаем генерацию.

7. Создаем Db.cs (public partial classDb).

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

Проект 1.

Проект 2.

На одну DTO-сущность может генерироваться до 3 файлов (сам DTO, updater, finder), не считая региона (#region) в Db.GeneratedMethods.cs.

ПРИМЕЧАНИЕ

Иногда встает задача небольшого изменения схемы БД – добавить столбец, добавить таблицу, иногда с добавлением notfromdb-поля в существующий DTO, изменить тип или даже имя существующего столбца. И все они оказываются очень простыми в рамках такой организации DAL – что не решается «само», после правки xml и перегенерации, то подсказывает компилятор (например, когда изменилось имя поля в классе).

Многотабличные DTO

Рассмотрим довольно редкую, но возможную ситуацию. Предположим, две таблицы в БД имеют полностью одинаковые схемы, за маленьким отличием – одно поле ссылается (по внешнему ключу) в первой таблице на одну таблицу, а во второй – на другую. Например, если атрибуты нужно привязывать к двум разнородным сущностям, то для сохранения целостности БД можно создать две таблицы со значениями атрибутов – одна будет ссылаться на таблицу первой сущности, а другая – на таблицу второй сущности. Но схемы будут полностью одинаковы (целый идентификатор атрибута и его строковое значение).

В рамках подхода организации DAL, описываемого в данной статье, существует возможность для двух таких одинаковых таблиц сделать один DTO-класс. Для этого в атрибуте tableName тега dto нужно через запятую перечислить пары вида «имя таблицы псевдоним», например:

      tableName='AttributesByNomenclatureGroups abng, AttributesByNomenclatures abn, AttributesByBids abb'

Предусмотрено, что имена столбцов primary key в таких таблицах могут отличаться, поскольку обычная практика называть identity-столбец <имя таблицы>Id. Для задания различных их имен нужно в атрибуте id соответствующего поля (тега p) перечислить через запятую имена столбцов. Как результат генерируется enum, для приведенного примера такой:

        public enum AttributeValueDTOTable
        {AttributesByNomenclatureGroups, AttributesByNomenclatures,
             AttributesByBids }

и в методах появляется дополнительный параметр этого типа. В настоящее время есть существенные ограничения – многотабличные DTO не поддерживают никакие методы выборки, только вставку, обновление, удаление и слияние. То есть пока подразумевается, что такие объекты читаются только как notfromdb-поля в других DTO-объектах, и их не нужно выбирать непосредственно.

Резюмируем перечень возможностей

Ниже приведен список возможностей предлагаемого подхода.

Класс-фасад Db:

  1. Автоматически создается класс-фасад для доступа к БД, содержащий только детерминированные методы. Он объявлен как partial, поэтому его можно расширять пользовательскими методами, не вписавшимися в модель генератора.
  2. Для написания пользовательских методов доступа в БД со сложной логикой или возвращающих сложные типы есть много хелпер-методов. Для каждого DTO есть методы чтения из IDataRecord и чтения списка этих DTO из IDataReader. Возможна передача параметра-списка в SQL-скрипты. Есть перегрузки ExecuteNonQuery, ExecuteScalar, ExecuteList, ExecuteReader с удобными сигнатурами, в том числе с читающим делегатом на случай сложного запроса со сложным возвращаемым типом или несколькими DataSet’ами.
  3. При чтении поля из БД можно преобразовать значение (атрибут addonprocessfunction).

Возможности генерации классов DTO:

  1. Можно подменить имя столбца (чтобы имя поля в классе DTO отличалось от имени столбца в таблице БД).
  2. Можно сделать, чтобы имя класса DTO отличалось от имени таблицы.
  3. Можно сделать, чтобы не все столбцы таблицы присутствовали в соответствующем ей классе DTO.
  4. Можно объявить дополнительные поля в DTO, не являющиеся столбцами в соответствующей ему таблице, и инициализировать их в пользовательском методе при чтении из БД. То есть, например, можно объявить списковое поле и заполнять его связанными по внешнему ключу данными (даже если внешнего ключа нет и связь только логическая, или данные должны быть отфильтрованы) или собирать из нескольких таблиц. Можно сопоставить полю и одиночный объект и инициализировать его в пользовательском методе.
  5. Можно один класс DTO привязать к нескольким таблицам с одинаковой схемой. В параметре методов вставки, удаления и обновления тогда нужно передать указание, с какой таблицей работать.
  6. Можно объявить класс DTO без привязки к таблице БД.
  7. Есть поддержка составного первичного ключа.
  8. Можно добавить тег summary к почти любым полям, методам и классам.
  9. Xml-описание может быть разделено на несколько файлов для группировки объектов и легкости редактирования.

Возможности вставки:

  1. Метод вставки возвращает вставленный объект с проставленными значениями по умолчанию.
  2. Метод вставки может принимать коллекцию объектов и вставляет их в одном SQL-запросе.
  3. У класса DTO есть конструктор для создания объекта для передачи в метод вставки, чтобы невозможно было его вызвать, забыв проинициализировать одно поле и получить ошибку только при исполнении. Для вставки не нужно инициализировать поля со значениями по умолчанию.
  4. Можно объявить дополнительный пользовательский метод, который будет выполняться после каждой вставки (InsertAddon).

Возможности удаления:

  1. Можно объявить несколько методов удаления с разными критериями – можно подменить условие where или весь SQL-запрос, или вызвать хранимую процедуру.
  2. В методах удаления можно объявить дополнительные параметры (не являющиеся полями DTO) и использовать их в SQL-запросе удаления.
  3. Для удаления можно объявить дополнительный пользовательский метод, который выполнится перед удалением (DeleteAddon).

Возможности обновления:

  1. Операция обновления может принимать коллекцию.
  2. Обновлять можно произвольный набор столбцов, и можно сделать так, чтобы не ко всем столбцам таблицы был доступ на обновление.
  3. Среди полей, которые возможно обновить, можно выделить именованные группы (возможно, пересекающиеся), и для каждой их них у updater-объекта будет создан отдельный конструктор с соответствующими полям параметрами, чтобы невозможно было забыть проинициализировать какое-то поле.
  4. После инициализации updater-объекта можно исключить из него неизмененные поля (метод ExcludeEquals). Можно не слать запрос в бд, если ни один столбец не был изменен.
  5. Можно объявить дополнительный пользовательский метод, который выполнится после обновления (UpdateAddon).

Возможности слияния:

  1. Можно объявить методы слияния (merge).
  2. Методы слияния могут проставлять дату последнего обновления – а если не было изменено ни одного поля, не проставлять.
  3. Можно объявить несколько методов слияния. Различия могут быть в условии соединения (если есть несколько уникальных индексов), и исключенных полях, если не все поля нужно обновлять при слиянии.

Возможности выборки:

  1. Можно создать несколько детерминированных методов поиска, возвращающих один и тот же тип, но производящих выборку из разных представлений (view) или с помощью различных хранимых процедур или SQL-запросов (возвращающих одинаковый набор столбцов).
  2. Возможность объявить различные детерминированные методы поиска с помощью заданных в xml-описании произвольных SQL-запросов. Параметры этих методов могут быть не только полями DTO.
  3. Методы поиска могут возвращать одиночный объект.
  4. Возможен поиск по произвольному набору полей данного класса – по точному значению или по диапазону на каждое поле.
  5. Возможен поиск по вхождению значения поля в заданный список значений.
  6. Возможен поиск по вхождению данной подстроки в любое строковое поле таблицы.
  7. Методы поиска могут принимать дополнительные параметры (не являющиеся полями DTO), которым можно поставить в соответствие дополнительные where-условия – если нужен поиск объекта по связанным данным в других таблицах.
  8. Методы поиска – оконные (настраиваемый поиск). То есть можно получить количество всех найденных элементов и из них получить объекты заданного окна.
  9. Можно одним методом получить номер окна, в котором находится искомый объект – если нужно открыть GridView на нужной странице.

Ограничения и выводы

Основное достоинство предложенного способа организации слоя DAL заключается в том, что очень просто получается добавить основную функциональнось, связанную с работой с БД – вставку, удаление, обновление, слияние и выборку описанных DTO-объектов. Все, что выходит за рамки этих операций, нужно описывать в дополнительных методах, для которых придется писать код вручную. Однако на практике это приходится делать для весьма малой доли функциональности приложений. Определенным неудобством является то, что подход не позволяет провести несколько операций в одной транзакции – для этого тоже придется писать дополнительные методы вручную.

Но по сравнению с другими нетривиальными способами организации DAL (то есть не использующими непосредственно ADO.NET), в том числе и с помощью современных ORM, можно отметить важные достоинства, очень упрощающие программирование:

Исходники на GitHub

Небольшой показательный пример проекта можно посмотреть здесь. Проект с генератором DTO.tt лежит тут, а сам генератор - тут (необходимы файл-хелпер и xsd-схема).


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