Pull to refresh

ASP.NET MVC – работаем с MySQL через ADO.NET

Reading time34 min
Views16K
Legacy технологии
Предупреждение: и ASP.NET MVC устарел и ADO.NET тоже. Рекомендуется использовать ASP.NET Core с современным ORM. Но если вам интересно, то читайте.

Уже, наверное, раза три подбираюсь к ASP.NET MVC. После десяти лет с ASP.NET WebForms немного сложно переходить именно к технологии MVC, поскольку отличий столько, что скорее проще перечислить, что у этих технологий общего – это разве что библиотеки .NET Framework. Я не буду писать тут – лучше или хуже MVC чем WebForms, просто они обе хороши, и на обеих технологиях можно построить хорошее приложение. Свои мысли по поводу необходимости TDD я тоже пока оставлю при себе, хотя их есть у меня.

А сейчас я буду говорить о стандартнейшей задаче – обычной работе с данными: просмотре в табличном виде списка записей, добавлении, изменении и удалении данных (операции CRUD). Однако практически во всех книгах и во многих решениях в интернете для ASP.NET MVC почему-то рассматривается вариант исключительно через ORM (Object Relation Mapping): или Entity Framework (EF) или LINQ для SQL. Технологии отличные, спору нет, наконец-то программист может и не разбираться – а как вообще эта самая реляционная СУБД (которой он, скорее всего, пользуется) вообще работает, и даже SQL, по идее, знать уже необязательно: прокладка в виде EF и коннектора для СУБД разберутся между собой. «Вот оно счастье – нет его краше». Но тем программистам, которые не боятся прямой работы с базой данных через механизм ADO.NET, зачастую непонятно – а с чего вообще начинать в ASP.NET MVC и надо ли.

Плюс к этому, у меня, например, вначале вызвало дикую ломку отсутствие удобного компонента для отображения данных в таблице-гриде. Подразумевается, что это всё должен реализовывать сам разработчик или брать в менеджере пакетов что-то подходящее. Если вас, как и меня, компонент GridView в ASP.NET WebForms устраивал более чем, то для MVC сложно найти что-то более-менее сопоставимое, разве что Grid.mvc. Но вот доверия у меня к подобным компонентам маловато, чтобы их использовать для достаточно крупного проекта. В случае их использования, программист начинает зависеть от другого разработчика, который, во-первых, неизвестно как написал этот компонент (качественно ли?), а, во-вторых, неизвестно когда и как будет его дорабатывать. Расширять компонент и пилить дальше иногда вроде бы даже можно, но, в случае доработки оного разработчиком, мы вынуждены либо опять перелопачивать свой код, либо замораживать обновление компонента на определенной версии. А если разработчик устранит какую-либо уязвимость – всё равно придется переходить на новую версию. Даже переход на новые версии коннектора MySQL вызывает определенные проблемы, хотя его всё-таки крупная компания разрабатывает, а что там с многочисленными «велосипедами» в менеджере пакетов Nuget?

Итак, попытаемся научиться писать под ASP.NET MVC, при этом будем работать с данными, которые обрабатываются СУБД MySQL. Весь код можно взять вот по этому адресу, ниже этот код будет частично презентован с небольшими ссылками и пояснениями. А здесь можно посмотреть на работу приложения.

Создаем базу данных в MySQL


CREATE SCHEMA `example`;
USE `example`;
CREATE TABLE `users` (
  `UserID` int(4) unsigned NOT NULL AUTO_INCREMENT,
  `SupporterTier` enum('None','Silver','Gold','Platinum') NOT NULL DEFAULT 'None',
  `Loginname` varchar(255) NOT NULL,
  `LanguageID` int(4) unsigned NOT NULL DEFAULT '2',
  `Email` varchar(255) DEFAULT NULL,
  `LastLoginDate` datetime DEFAULT NULL,
  PRIMARY KEY (`UserID`),
  KEY `i_Loginname` (`Loginname`),
  KEY `i_Language_idx` (`LanguageID`),
  CONSTRAINT `i_Language` FOREIGN KEY (`LanguageID`) REFERENCES `languages` (`LanguageID`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
CREATE TABLE `languages` (
  `LanguageID` int(4) unsigned NOT NULL AUTO_INCREMENT,
  `LanguageName` varchar(50) NOT NULL DEFAULT '',
  `Culture` varchar(10) DEFAULT NULL,
  PRIMARY KEY (`LanguageID`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

База данных (БД) будет простая и представлена всего двумя таблицами, со одной связью «один-ко-многим» между ними по полю LanguageID. Этим я собираюсь усложнить ситуацию для необходимости использования выпадающего списка выбора одного из языков для пользователя. Также, для усложнения, для пользователя введём еще поле SupporterTier, которое будет определять уровень поддержки пользователя посредством перечисления (enum). А для того, чтобы у нас в проекте использовались почти все типы данных, добавим ещё поле LastLoginDate типа «дата/время», которое будет заполняться самим приложением при входе пользователя (в данном проекте не отражено).

Создаем проект




Выбираем «MVC». Можно и «Пустой», но у нас учебное, а не реальное приложение, поэтому это нам поможет сразу, без лишних телодвижений, интегрировать в наше приложение Bootstrap и JQuery.



Получаем уже заполненные папки Content, Fonts, Scripts, а также файлы BundleConfig.cs и FilterConfig.cs в каталоге App_Start с регистрациями связок и фильтров ASP.NET MVC. В пустом проекте там есть только регистрация маршрутов в файле RouteConfig.cs. В Global.asax.cs также добавятся вызовы методов, описанных в этих файлах:

FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);

Настраиваем инфраструктуру и всю обвязку


Для работы с СУБД MySQL добавляем библиотеку MySql.Data: либо вручную, если коннектор mysql-connector-net-8.0.18 уже установлен на компьютер, либо из менеджера пакетов Nuget:



Добавляем в файл Web.config конфигурацию строки подключения к СУБД MySQL:

<connectionStrings>
  <add name="example" providerName="MySql.Data.MySqlClient" connectionString="server=localhost;Port=3306;user id=develop;Password=develop;persistsecurityinfo=True;database=example;CharSet=utf8;SslMode=none" />
</connectionStrings>

Добавляем в раздел <appSettings> строку со ссылкой на добавленную строку подключения: <add key="ConnectionString" value="example" />
Добавляем в приложение новый каталог Domain, в нём создаем новый статический класс Base (в файле Base.cs), в котором идут обращения к этим параметрам:

public static class Base
{
    private static string ConnectionString
    {
        get
        { return System.Configuration.ConfigurationManager.AppSettings["ConnectionString"]; }
    }
 
    public static string strConnect
    {
        get
        { return System.Configuration.ConfigurationManager.ConnectionStrings[ConnectionString].ConnectionString; }
    }
}

Мне нравится иметь в приложении некий базовый класс со ссылками на параметры приложения и какими-нибудь стандартными функциями, которые можно было бы вызывать из всего приложения.
Название строки подключения определено в параметрах приложения, чтобы в дальнейшем было проще работать со строкой подключения к СУБД: быстро переключаться между разными базами данных и менять параметры подключения. Кроме этого, использование названия строки подключения в параметре приложения, удобно использовать для публикации приложения в Microsoft Azure – там можно задать параметр для службы приложения, которая используется для публикации, и в нём определить нужную строку подключения, которая заранее определена в <connectionStrings>. Тогда при публикации можно не использовать трансформацию файла web.config.

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



В файле-макете страницы _Layout.cshtml (стандартно располагается в каталоге Views\Shared и в дальнейшем используется для всех страниц данного проекта) можно теперь использовать эти переменные (см. например, Example_Users.Properties.Resources.Title):

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
   <meta charset="utf-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>@ViewBag.Title – @Example_Users.Properties.Resources.Title</title>
   @Styles.Render("~/Content/css")
   @Scripts.Render("~/bundles/modernizr")
</head>
<body>
   <div class="container body-content">
       <h1 class="page-header"><a href="/">@Example_Users.Properties.Resources.Title</a></h1>
       @RenderBody()
       <hr />
       <footer>
           <p> @DateTime.Now.Year – @Example_Users.Properties.Resources.Author</p>
       </footer>
   </div>
   @Scripts.Render("~/bundles/jquery")
   @Scripts.Render("~/bundles/bootstrap")
   @RenderSection("scripts", required: false)
</body>
</html>

Также в этом файле видим прикрепление каскадной таблицы стилей подключенного Bootstrap и библиотеки скриптов JQuery. Содержимое всех представлений будет генерироваться в месте размещения вызова функции RenderBody().

M — значит модель


Добавляем файл UserClass.cs в каталог Domain:

[DisplayName("User")]
public class UserClass
{
    [Key]
    [HiddenInput(DisplayValue=false)]
    public int UserID { get; set; }
 
    [Required(ErrorMessage="Please enter a login name")]
    [Display(Name = "Login")]
    public string Loginname { get; set; }
 
    public virtual LanguageClass Language { get; set; }
 
    [EmailAddress(ErrorMessage = "Please enter a valid email")]
    public string Email { get; set; }
 
    [UIHint("Enum")]
    [EnumDataType(typeof(Supporter))]
    [Required(ErrorMessage = "Please select supporter tier")]
    [Display(Name = "Supporter")]
    public Supporter SupporterTier { get; set; }
 
    [HiddenInput(DisplayValue = true)]
    [ScaffoldColumn(false)]
    [Display(Name = "Last login")]
    public DateTime? LastLoginDate { get; set; }
 
    [HiddenInput(DisplayValue = false)]
    public bool IsLastLogin
    {
        get { return LastLoginDate != null && LastLoginDate > DateTime.MinValue; }
    }
 
    public UserClass() {}
 
    public UserClass(int UserID, string Loginname, LanguageClass Language, string Email, DateTime? LastLoginDate, Supporter SupporterTier)
    {
        this.UserID = UserID;
        this.Loginname = Loginname;
        this.Language = Language;
        this.Email = Email;
        this.LastLoginDate = LastLoginDate;
        this.SupporterTier = SupporterTier;
    }
}
 
public enum Supporter
{
    [Display(Name="")]
    None = 1,
    Silver = 2,
    Gold = 3,
    Platinum = 4
}

А также файл LanguageClass.cs в тот же каталог:
[DisplayName("Language")]
public class LanguageClass
{
    [Key]
    [HiddenInput(DisplayValue = false)]
    [Required(ErrorMessage = "Please select a language")]
    [Range(1, int.MaxValue, ErrorMessage = "Please select a language")]
    public int LanguageID { get; set; }
 
    [Display(Name = "Language")]
    public string LanguageName { get; set; }
 
    public LanguageClass() {}
 
    public LanguageClass(int LanguageID, string LanguageName)
    {
        this.LanguageID = LanguageID;
        this.LanguageName = LanguageName;
    }
}

Тут можно видеть, что свойства классов повторяют структуру таблицы Users и Languages в СУБД. Для типа перечисления создан enum Supporter, чтобы его можно было использовать для свойства класса SupporterTier аналогичного поля таблицы БД. Для полей UserID, LanguageID можно видеть, что они заданы, как первичный ключ, точно так же, как и в БД. Для этого использован атрибут [Key].

Все остальные атрибуты имеют отношение скорее к представлениям (view), использующим этот класс. И если мы собираемся использовать хелперы для формирования HTML-тегов для этих свойств (что я лично однозначно рекомендовал бы), то нам придется очень тщательно устанавливать эти атрибуты, чтобы получить то, что нам надо. В частности, вот то, что понадобилось для этого проекта:

  • [DisplayName] – используется как отображаемое на экран имя для класса. Иногда может быть полезно, в данном проекте специально добавил использование хелпера Html.DisplayNameForModel для демонстрации.
  • [Display] со свойством Name – используется как отображаемое на экран название свойства класса. Еще есть полезное свойство Order, позволяющее упорядочить последовательность отображения свойств класса в форме с использованием хелперов (по умолчанию сортировка по порядку определения свойств класса, поэтому в данном проекте свойство Order не использовалось).
  • [HiddenInput] со свойством DisplayValue. Используется для свойств, которые либо не надо показывать в формах и списках вообще (DisplayValue=false, отрисовываются как теги input с типом hidden), либо для свойств, которые всё-таки надо отображать, но в виде неизменяемого текста (DisplayValue=true, отрисовывается как чистый текст, без тегов)
  • [ScaffoldColumn] – указывает, отображать ли поле в хелперах редактирования (например, Html.EditorForModel). Если false – то в форме не будут отображены ни описание свойства класса, ни его значение. Тут нельзя использовать [HiddenInput(DisplayValue = false)], потому что в этом случае значения данного свойства класса вообще не будут отображены не только в формах ввода информации, но и в табличных отображениях. В данном случае это потребовалось для свойства LastLoginDate, которое не вводится вручную, а заполняется где-то автоматически, но видеть нам его всё-таки надо.
  • [Required] – для проверки того, введено ли значение для свойства класса, с текстом сообщения об ошибке в свойстве ErrorMessage и свойством AllowEmptyStrings позволяющим вводить пустые строки.
  • [EmailAddress] – аналогичный, по сути, атрибут для проверки корректности почтового адреса.

Классы модели БД готовы, переходим к представлениям (классы моделей именно для представлений опишем ниже).

V — значит вендетта представление


В каталоге Views создаем каталог Users для наших представлений. Все наши представления используют стандартный (определен в файле _ViewStart.cshtml в каталоге Views) макет _Layout.cshtml, расположенный в каталоге Views\Shared. Создаем представление Index (файл Index.cshtml в каталоге Users):



И пишем код:

@model Example_Users.Models.UsersGrid
@{
   ViewBag.Title = "Users page";
}
@using (@Html.BeginForm())
{
   <div>
       <h3>Users list:</h3>
       @if (TempData["message"] != null)
       {<div class="text-success">@TempData["message"]</div>}
       @if (TempData["error"] != null)
       {<div class="text-warning"><span class="alert">ERROR:</span> @TempData["error"]</div>}
       @Html.Partial("List")
       <p>
           <input type="submit" name="onNewUser" value="New user" />
           @*@Html.ActionLink("New user", "New", "Users")*@
       </p>
   </div>
}

Если мы хотим, чтобы данное представление запускалось по умолчанию, то в файл RouteConfig.cs вносим изменение для Default:

routes.MapRoute(name: "Default", url: "{controller}/{action}/{id}",
defaults: new { controller = "Users", action = "Index", id = UrlParameter.Optional });

В самом представлении надо обратить внимание на строчку с Html.Partial("List"). Это нужно для отрисовки в данном месте специального отдельного общего частичного представления, расположенного в файле List.cshtml в каталоге Views\Shared. Собственно оно представляет из себя именно таблицу-грид для отображения данных из нашей таблицы БД users:

@model Example_Users.Models.UsersGrid
@using Example_Users.Domain
<div class="table-responsive">
    <table class="table table-bordered table-hover">
        <thead>
            <tr>
                <th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().Loginname).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().Loginname).ToString())))
                    @Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().Loginname).ToString())
                </th>
                <th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().Language.LanguageName).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().Language).ToString())))
                    @Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().Language).ToString())
                </th>
                <th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().Email).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().Email).ToString())))
                    @Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().Email).ToString())
                </th>
                <th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().SupporterTier).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().SupporterTier).ToString())))
                    @Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().SupporterTier).ToString())
                </th>
                <th>@Html.ActionLink(Html.DisplayNameFor(m => Model.Users.First().LastLoginDate).ToString(), "Index", Request.QueryString.ToRouteValueDictionary("sortOrder", Model.SortingInfo.NewOrder(Html.NameFor(m => Model.Users.First().LastLoginDate).ToString())))
                    @Html.SortIdentifier(Model.SortingInfo.currentSort, Html.NameFor(m => Model.Users.First().LastLoginDate).ToString())
                </th>
            </tr>
        </thead>
        <tbody>
            @foreach (var item in Model.Users)
            {
            <tr>
                <td>@Html.ActionLink(item.Loginname, "Edit", "Users", new { UserID = item.UserID }, null)</td>
                <td>@Html.DisplayFor(modelitem => item.Language.LanguageName)</td>
                <td>@Html.DisplayFor(modelitem => item.Email)</td>
                <td class="@Html.DisplayFor(modelitem => item.SupporterTier)">@if (item.SupporterTier != Supporter.None) {@Html.DisplayFor(modelitem => item.SupporterTier);}</td>
                <td>@if (item.IsLastLogin) {@Html.DisplayFor(modelitem => item.LastLoginDate)}</td>
            </tr>
            }
        </tbody>
    </table>
</div>
@if (Model.PagingInfo.totalPages > 1)
{
    <ul class="pagination">
        @Html.PageLinks(Model.PagingInfo, x => Url.Action("Index", new { page = x, sortOrder = Model.SortingInfo.currentSort }))
    </ul>
}

Видно, что в заголовке таблицы данных используются хелперы Html.DisplayNameFor для отображения названий колонок и для этого приходится указывать ссылку на свойство объекта класса. Поскольку при формировании заголовка таблицы у нас есть только объект Model.Users, который представляет собой список объектов типа UserClass, то приходится применять следующий способ: выбирать первую строку этого списка, как объект класса UserClass. Например, для имени пользователя: Model.Users.First().Loginname. Поскольку у свойства Loginname класса Users указан атрибут [Display(Name = "Login")], то в названии колонки будет выведено именно «Login»:



Что еще интересно в представлении List? Блок с foreach, понятно, отрисовывает объекты класса UserClass, которые находятся в списке Users, полученного из контроллера. А интересны тут объекты SortingInfo и PagingInfo в нашей модели UsersGrid. А эти объекты нужны нам для организации сортировки данных (используется в заголовке таблицы в тегах <th>) и организации постраничного вывода информации (используется внизу страницы, под таблицей). Именно поэтому мы не используем в качестве модели чисто список объектов типа IEnumerable<UserClass>. А в качестве модели используем класс UsersGrid, который расположили в файле UsersGrid.cs в каталоге Model.

public class UsersGrid
{
    public IEnumerable<UserClass> Users { get; set; }
    public PagingInfo PagingInfo { get; set; }
    public SortingInfo SortingInfo { get; set; }
}

И сами классы PagingInfo и SortingInfo в файле GridInfo.cs в том же месте.

public class PagingInfo
{
    // всего строк в выборке
    public int totalItems { get; set; }
    // сколько отображать на страницу
    public int itemsPerPage { get; set; }
    // текущая страница
    public int currentPage { get; set; }
    // сколько максимально можно отобразить ссылок на страницы таблицы
    public int showPages { get; set; }
    // всего страниц
    public int totalPages
    {
        get { return (int)Math.Ceiling((decimal)totalItems / itemsPerPage); }
    }
    // сколько отобразить ссылок на страницы слева и справа от текущей
    public int pagesSide
    {
        get { return (int)Math.Truncate((decimal)showPages / 2); }
    }
}
 
public class SortingInfo
{
    // название поля, по которому идёт сортировка
    public string currentOrder { get; set; }
    // направление сортировки
    public SortDirection currentDirection { get; set; }
    // получение строки параметра для передачи
    public string currentSort
    {
        get { return currentDirection != SortDirection.Descending ? currentOrder : currentOrder + "_desc"; }
    }
    // генерация нового порядка сортировки для столбцов (если уже была сортировка по столбцу - делаем обратную сортировку)
    public string NewOrder(string columnName)
    {
        return columnName == currentOrder && currentDirection != SortDirection.Descending ? columnName + "_desc" : columnName;
    }
}

А для использования в представлениях добавлены специальные хелперы в файле GridHelpers.cs (каталог HtmlHelpers):

public static class GridHelpers
{
    // Отображаем пейджер в виде 1 ... 3 4 5 ... Last
    public static MvcHtmlString PageLinks(this HtmlHelper html, PagingInfo pagingInfo, Func<int, string> pageUrl)
    {
        StringBuilder result = new StringBuilder();
        if (pagingInfo.currentPage > pagingInfo.pagesSide + 1)
        {// первая страница
            TagBuilder li = new TagBuilder("li");
            li.AddCssClass("page-item");
 
            TagBuilder tag = new TagBuilder("a");
            tag.MergeAttribute("href", pageUrl(1));
            tag.InnerHtml = "1";
 
            li.InnerHtml = tag.ToString();
            result.Append(li.ToString());
        }
        int page1 = pagingInfo.currentPage - pagingInfo.pagesSide;
        int page2 = pagingInfo.currentPage + pagingInfo.pagesSide;
        if (page1 < 1)
        {
            page2 = page2 - page1 + 1;
            page1 = 1;
        }
        if (page2 > pagingInfo.totalPages) page2 = pagingInfo.totalPages;
        if (page1 > 2)
        {// ...
            TagBuilder li = new TagBuilder("li");
            li.AddCssClass("page-item");
 
            TagBuilder tag = new TagBuilder("span");
            tag.InnerHtml = "...";
            tag.AddCssClass("page-item");
            tag.AddCssClass("disabled");
 
            li.InnerHtml = tag.ToString();
            result.Append(li.ToString());
        }
        for (int i = page1; i <= page2; i++)
        {// страницы
            TagBuilder li = new TagBuilder("li");
            li.AddCssClass("page-item");
            if (i == pagingInfo.currentPage) li.AddCssClass("active");
 
            TagBuilder tag = new TagBuilder("a");
            tag.AddCssClass("page-link");
            tag.MergeAttribute("href", pageUrl(i));
            tag.InnerHtml = i.ToString();
 
            li.InnerHtml = tag.ToString();
            result.Append(li.ToString());
        }
        if (page2 < pagingInfo.totalPages)
        {// ... и последняя страница
            TagBuilder li = new TagBuilder("li");
            li.AddCssClass("page-item");
 
            TagBuilder tag = new TagBuilder("span");
            tag.InnerHtml = "...";
            tag.AddCssClass("page-item");
            tag.AddCssClass("disabled");
            li.InnerHtml = tag.ToString();
            result.Append(li.ToString());
 
            li = new TagBuilder("li");
            li.AddCssClass("page-item");
 
            tag = new TagBuilder("a");
            tag.MergeAttribute("href", pageUrl(pagingInfo.totalPages));
            tag.InnerHtml = pagingInfo.totalPages.ToString();
 
            li.InnerHtml = tag.ToString();
            result.Append(li.ToString());
        }
        return MvcHtmlString.Create(result.ToString());
    }
 
    public static IHtmlString SortIdentifier(this HtmlHelper htmlHelper, string sortOrder, string field)
    {
        if (string.IsNullOrEmpty(sortOrder) || (sortOrder.Trim() != field && sortOrder.Replace("_desc", "").Trim() != field)) return null;
        string glyph = "glyphicon glyphicon-chevron-up";
        if (sortOrder.ToLower().Contains("desc"))
        {
            glyph = "glyphicon glyphicon-chevron-down";
        }
        var span = new TagBuilder("span");
        span.Attributes["class"] = glyph;
        return MvcHtmlString.Create(span.ToString());
    }
 
    public static RouteValueDictionary ToRouteValueDictionary(this NameValueCollection collection, string newKey, string newValue)
    {
        var routeValueDictionary = new RouteValueDictionary();
        foreach (var key in collection.AllKeys)
        {
            if (key == null) continue;
            if (routeValueDictionary.ContainsKey(key))
                routeValueDictionary.Remove(key);
            routeValueDictionary.Add(key, collection[key]);
        }
        if (string.IsNullOrEmpty(newValue))
        {
            routeValueDictionary.Remove(newKey);
        }
        else
        {
            if (routeValueDictionary.ContainsKey(newKey))
                routeValueDictionary.Remove(newKey);
            routeValueDictionary.Add(newKey, newValue);
        }
        return routeValueDictionary;
    }
}

Поскольку грид с данными без сортировки и без постраничного вывода информации достаточно бесполезная вещь, а стандартного хелпера для целой таблицы данных в ASP.NET MVC нет, то приходится их создавать самостоятельно (либо брать созданный кем-то другим). В данном случае, я подсмотрел несколько реализаций в книгах по ASP.NET MVC и решений представленных в интернете. Причем, почему-то решений, объединяющих вместе хотя бы и сортировку данных и постраничный вывод, либо вовсе нет, либо я не нашел. Пришлось всё это дело осмысливать, объединять и дорабатывать до нормального состояния. Например, постраничный вывод в тех реализациях зачастую не предусматривает вывод более-менее длинного списка страниц – ну а вдруг будет несколько тысяч страниц? Привожу пример отображения для представленного выше решения:



Также нам потребуются представления для создания и модификации данных. Представление для создания объекта типа UserClass:



И код представления будет выглядеть следующим образом:

@model Example_Users.Models.UserModel
@{
    ViewBag.Title = "New " + Html.DisplayNameForModel().ToString().ToLower();
}
<h2>@ViewBag.Title</h2>
@using (@Html.BeginForm("New", "Users", FormMethod.Post))
{
    @Html.EditorFor(m => m.User);
    @Html.LabelFor(m => Model.User.Language)<br />
    @Html.DropDownListFor(m => Model.User.Language.LanguageID, Model.SelectLanguages())
    <span/>@Html.ValidationMessageFor(m => Model.User.Language.LanguageID)<br />
    <br />
    <p><input type="submit" name="action" value="Add" /> <input type="button" onclick="history.go(-1)" value="Cancel" /></p>
    @*<p>@Html.ActionLink("Back to list", "Index")</p>*@
}

В этом представлении для примера показано использование хелпера Html.EditorFor в качестве средства для генерации тегов для редактирования всех свойств объектов класса UserClass. Оно отображается следующим образом:



В этом представлении используется в качестве модели класс UserModel, а не UserClass непосредственно. Сам класс UserModel размещен в файле UserModel.cs в каталоге Models:

public class UserModel
{
    public UserClass User { get; set; }
    private IList<LanguageClass> Languages { get; set; }
 
    public UserModel() {}
    public UserModel(UserClass user, IList<LanguageClass> languages)
    {
        this.User = user;
        this.Languages = languages;
    }
 
    public IEnumerable<SelectListItem> SelectLanguages()
    {
        if (Languages != null) { return new SelectList(Languages, "LanguageID", "LanguageName"); }
        return null;
    }
}

В этот класс включен собственно сам объект UserClass и дополнительный список объектов типа LanguageClass. Этот список нам потребовался для создания выпадающего списка языков, с выбором в нём текущего языка пользователя: @Html.DropDownListFor(m => Model.User.Language.LanguageID, Model.SelectLanguages(), "")

В этом хелпере используется вызов функции SelectLanguages(), которая преобразует список языков в объект типа SelectList с уже установленными параметрами идентификатора и названия строки. Выносить генерацию этого объекта в представление было бы неверным, потому что представление по идее не должно знать об этих привязках к названиям полей. Можно было бы, конечно, сразу в контроллере сгенерировать готовый SelectList, но мне вариант с приватным списком объектов доменного класса и функцией нравится больше.

Для генерации выпадающего списка нам приходится использовать отдельные хелперы, потому что хелпер Html.EditorFor(m => m.User) не будет генерировать разметку редактирования для вложенного объекта типа LanguageClass (это можно было бы обойти написав общий шаблон для выпадающих списков, но тут мы это делать не будем…).

И поскольку у нас в представлении используется объект класса UserModel, который включает в себя еще один объект класса UserClass, то и использовать хелпер Html.EditorForModel() не удастся, поскольку хелперы не являются рекурсивными и не будут работать в данной ситуации, поэтому используется хелпер Html.EditorFor() для объекта User.

Также хочется обратить внимание на закомментированный тэг: Html.ActionLink("Back to list", "Index")

Обычно подобным образом реализуется возврат из представления для редактирования обратно в список данных. На самом деле, во-первых, на мой взгляд, это смотрится странно – когда в форме у тебя используются кнопки типа button, а кнопка возврата почему-то реализована ссылкой. Во-вторых, если мы будем использовать сортировку и постраничный вывод – придется мудрить с возвратом в тот же вид на ту же страницу – и передавать в представление не только объект UserClass, но и параметры для возврата обратно на страницу. Есть гораздо более простой способ – воспользоваться вариантом с кнопкой вида: />, которая и отправит пользователя обратно на страницу по истории браузера. Тут, безусловно, есть нюансы (например, вы уже на этой странице пытались сохранить объект, не получилось, и вот – вам уже надо дважды щелкать кнопку отмены), но данный вариант в целом неплохо работает.

И представление для редактирования объекта типа UserClass:



И его код:

@model Example_Users.Models.UserModel
@{
    ViewBag.Title = "Edit " + Html.DisplayNameForModel().ToString().ToLower();
}
<h2>@ViewBag.Title @Model.User.Loginname</h2>
@using (@Html.BeginForm("Edit", "Users", FormMethod.Post))
{
    @Html.HiddenFor(m => Model.User.UserID)
    <div>
        @Html.LabelFor(m => Model.User.Loginname)
        @Html.EditorFor(m => Model.User.Loginname)
        @Html.ValidationMessageFor(m => Model.User.Loginname)
    </div>
    <div>
        @Html.LabelFor(m => Model.User.Language)
        @Html.DropDownListFor(m => Model.User.Language.LanguageID, Model.SelectLanguages())
        @Html.ValidationMessageFor(m => Model.User.Language.LanguageID)
    </div>
    <div>
        @Html.LabelFor(m => Model.User.Email)
        @Html.EditorFor(m => Model.User.Email)
        @Html.ValidationMessageFor(m => Model.User.Email)
    </div>
    <div>
        @Html.LabelFor(m => Model.User.SupporterTier)
        @Html.EditorFor(m => Model.User.SupporterTier)
        @*@Html.EnumDropDownListFor(m => Model.User.SupporterTier)*@
        @*@Html.DropDownListFor(m => m.Model.User.SupporterTier, new SelectList(Enum.GetNames(typeof(Example_Users.Domain.Supporter))))*@
        @Html.ValidationMessageFor(m => Model.User.SupporterTier)
    </div>
    <br />
    <p><input type="submit" name="action" value="Save" /> <input type="submit" name="action" value="Remove" onclick="javascript:return confirm('Are you sure?');" /> <input type="submit" name="action" value="Cancel" /></p>
}

А в этом представлении представлен вариант использования разных хелперов для генерации тегов отдельно для каждого нужного свойства объекта класса. В данном случае можно уже сделать отображение в несколько другом – более симпатичном виде:



И в этом представлении используется другой способ возврата обратно в список. Вместо использования кнопки вида: /> используется такая же кнопка, как и остальные кнопки действия (Save, Remove): /> и возврат обратно осуществляется внутри метода контроллера, обрабатывающего действия этих кнопок (реализацию метода см. ниже).

Enum


И тут еще возник нюанс, связанный с использованием свойства класса типа enum. Дело в том, что если мы просто будем использовать хелпер Html.EditorFor(), то на форме отобразится поле ввода текстовой информации (тег вида <input type=”text”/>), а нам вообще-то нужно поле с выбором из набора значений (т.е. тег <select> с набором опций <option>).

1. Прямолинейно это решается использованием хелпера типа Html.DropDownListFor() или Html.ListBoxFor(), например, в нашем случае: @Html.DropDownListFor(m => m.Model.User.SupporterTier, new SelectList(Enum.GetNames(typeof(Example_Users.Domain.Supporter)))). Тут два минуса – каждый раз это придется прописывать индивидуально и для хелпера Html.EditorForModel() или Html.EditorFor() это не подойдет.

2. Можно создать пользовательский шаблон типа Editor. Создаем файл Supporter.cshtml в папке Views\Shared\EditorTemplates:

@model Example_Users.Domain.Supporter
@Html.DropDownListFor(m => m, new SelectList(Enum.GetNames(Model.GetType()), Model.ToString()))

Название файла должно соответствовать названию типа, либо придется писать не Html.EditorFor(m => Model.SupporterTier), а Html.EditorFor(m => Model.SupporterTier, "Supporter"). Если файл назвать по-другому, то перед описанием свойства в классе надо будет добавить подсказку [UIHint("Supporter")]. И это также придется делать, если планируется использовать Html.EditorForModel() – для него решение вида Html.EditorFor(m => Model.SupporterTier, "Supporter") не подойдет.

Мне этот вариант не подошел из-за того что, при попытке создания пользователя (запуск представления New) программа для хелперов Html.EditorFor() и Html.EditorForModel() выпадала с ошибкой: «Элемент модели, переданный в словарь, имеет значение NULL, но для этого словаря требуется элемент модели типа «Example_Users.Domain.Supporter», не имеющий значение NULL.» Причина понятна – значение для перечисления не может быть пустым, но решить проблему так и не удалось. Поэтому стал разбираться дальше.

3. Можно использовать хелпер Html.EnumDropDownListFor(), специально сделанный для перечислений. Вот тут всё хорошо, ничего дополнительно писать не нужно, отображается всё корректно, работает и при редактировании и при создании объекта. Кроме одного «но»: хелпер Html.EditorForModel() использует для отображения всех свойств хелперы Html.EditorFor() и, соответственно, не использует Html.EnumDropDownListFor(). И, как я понял, это нельзя обойти с помощью атрибутов для свойств класса – [UIHint], [DataType] и [EnumDataType] тут не сработают. Также не будут работать атрибуты для значений перечисления, то есть вместо None не получится, например, вывести пустую строку, как это определено в описании перечисления Supporter.

4. В итоге, мне подошел вариант решения, найденный на просторах интернета: создание общего шаблона для перечислений. Создаем файл Enum.cshtml в папке Views\Shared\EditorTemplates:

@using System.ComponentModel.DataAnnotations
@model Enum
@{ 
    Func<object, string> GetDisplayName = o =>
    {
        var result = null as string;
        var display = o.GetType()
                        .GetMember(o.ToString()).First()
                        .GetCustomAttributes(false)
                        .OfType<DisplayAttribute>()
                        .LastOrDefault();
        if (display != null) result = display.GetName();
        return result ?? o.ToString();
    };
    var values = Enum.GetValues(ViewData.ModelMetadata.ModelType).Cast<object>()
        .Select(v => new SelectListItem
        {
            Selected = v.Equals(Model),
            Text = GetDisplayName(v), // v.ToString(),
            Value = v.ToString()
        });
}
@Html.DropDownList("", values)

Вот тут всё вообще хорошо получилось: шаблон замечательно работает везде, где только можно. Даже [UIHint("Enum")] можно не добавлять. Причем данный общий шаблон читает атрибут [Display(Name)] для значений перечислений с помощью специальной функции.

C — значит контроллер


Добавляем файл UsersController.cs в каталог Controllers.

public class UsersController : Controller
{
    public int pageSize = 10;
    public int showPages = 15;
    public int count = 0;
 
    // отображение списка пользователей
    public ViewResult Index(string sortOrder, int page = 1)
    {
        string sortName = null;
        System.Web.Helpers.SortDirection sortDir = System.Web.Helpers.SortDirection.Ascending;
        sortOrder = Base.parseSortForDB(sortOrder, out sortName, out sortDir);
        UsersRepository rep = new UsersRepository();
        UsersGrid users = new UsersGrid {
            Users = rep.List(sortName, sortDir, page, pageSize, out count),
            PagingInfo = new PagingInfo
            {
                currentPage = page,
                itemsPerPage = pageSize,
                totalItems = count,
                showPages = showPages
            },
            SortingInfo = new SortingInfo {
                currentOrder = sortName,
                currentDirection = sortDir
            }
        };
        return View(users);
    }
 
    [ReferrerHold]
    [HttpPost]
    public ActionResult Index(string onNewUser)
    {
        if (onNewUser != null) {
            TempData["referrer"] = ControllerContext.RouteData.Values["referrer"];
            return View("New", new UserModel(new UserClass(), Languages()));
        }
        return View();
    }
 
    [ReferrerHold]
    public ActionResult New()
    {
        TempData["referrer"] = ControllerContext.RouteData.Values["referrer"];
        return View("New", new UserModel(new UserClass(), Languages()));
    }
 
    [HttpPost]
    public ActionResult New(UserModel model)
    {
        if (ModelState.IsValid)
        {
            if (model.User == null || model.User.Language == null || model.User.Language.LanguageID == 0) RedirectToAction("Index");
            UsersRepository rep = new UsersRepository();
            if (rep.AddUser(model.User)) TempData["message"] = string.Format("{0} has been added", model.User.Loginname);
            else TempData["error"] = string.Format("{0} has not been added!", model.User.Loginname);
            if (TempData["referrer"] != null) return Redirect(TempData["referrer"].ToString());
            return RedirectToAction("Index");
        }
        else
        {
            model = new UserModel(model.User, Languages()); // почему-то при невалидной модели в данный метод приходит пустой список model.Languages, приходится перезаполнять
            return View(model);
        }
    }
 
    [ReferrerHold]
    public ActionResult Edit(int UserID)
    {
        UsersRepository rep = new UsersRepository();
        UserClass user = rep.FetchByID(UserID);
        if (user == null) return HttpNotFound();
        TempData["referrer"] = ControllerContext.RouteData.Values["referrer"];
        return View(new UserModel(user, Languages()));
    }
 
    [HttpPost]
    public ActionResult Edit(UserModel model, string action)
    {
        if (action == "Cancel")
        {
            if (TempData["referrer"] != null) return Redirect(TempData["referrer"].ToString());
            return RedirectToAction("Index");
        }
        if (ModelState.IsValid)
        {
            if (model.User == null || model.User.Language == null || model.User.Language.LanguageID == 0) RedirectToAction("Index");
            UsersRepository rep = new UsersRepository();
            if (action == "Save")
            {
                if (rep.ChangeUser(model.User)) TempData["message"] = string.Format("{0} has been saved", model.User.Loginname);
                else TempData["error"] = string.Format("{0} has not been saved!", model.User.Loginname);
            }
            if (action == "Remove")
            {
                if (rep.RemoveUser(model.User)) TempData["message"] = string.Format("{0} has been removed", model.User.Loginname);
                else TempData["error"] = string.Format("{0} has not been removed!", model.User.Loginname);
            }
            if (TempData["referrer"] != null) return Redirect(TempData["referrer"].ToString());
            return RedirectToAction("Index");
        }
        else
        {
            model = new UserModel(model.User, Languages()); 
            return View(model);
        }
    }
 
    public IList<LanguageClass> Languages()
    {
        IList<LanguageClass> languages = new List<LanguageClass>();
        LanguagesRepository rep = new LanguagesRepository();
        languages = rep.List();
        return languages;
    }
}

Разберём методы контроллера:

1) Методы Index

Отображение страницы со списком пользователей: public ViewResult Index(string sortOrder, int page = 1)

Тут два входных параметра sortOrder и page. С page всё более-менее понятно, а вот через sortOrder из адресной строки можно передать произвольную строку, которую нам потом придется запихивать в SQL-запрос и делать это напрямую нельзя. Поэтому разбираем эту строку с помощью функции (здесь приводить саму функцию не буду, можно посмотреть в файле Base.cs): sortOrder = Base.parseSortForDB(sortOrder, out sortName, out sortDir);

Далее всё банально, создаем объект репозитария UsersRepository (для работы с СУБД, разобран в следующей главе), вызываем метод List для получения списка пользователей и формируем объект класса UsersGrid из собственно полученного списка пользователей и информации необходимой для организации постраничного вывода информации и сортировки. Причём значение totalItem получаем при вызове метода List, одновременно с созданием объекта класса UsersGrid. Далее этот объект передается для отображения представлению.

Также есть другой метод Index с атрибутом [HttpPost], который нам потребовался для отработки нажатия кнопки New в представлении Index: public ActionResult Index(string onNewUser)

Входной параметр onNewUser перекликается с элементом /> в представлении Index и при клике на этой кнопке передает в функцию значение, которое мы сверяем с null. Если бы кнопок типа submit было бы в представлении Index несколько, пришлось бы проверять и само значение (в данном случае значение было бы «New user»).

После проверки мы формируем объект класса UserModel, состоящий из нового объекта UserClass и списка языков для выбора их из выпадающего списка и передаем его представлению New. Список языков получается из репозитария LanguageRepository вызовом метода List следующим образом:

public IList<LanguageClass> Languages()
{
    LanguagesRepository rep = new LanguagesRepository();
    return rep.List();
}

2) Методы New

Метод New без параметра сделан для отработки клика по ссылке New User для закомментированой строки Html.ActionLink("New user", "New", "Users") в представлении Index. Действие (открытие представления New) осуществляется аналогично предыдущему, только проверки на нажатую кнопку нет, потому что отрабатывается нажатие на ссылку.

Метод New с параметром model типа UserModel для отработки события отправки данных формы с целью сохранения нового пользователя в БД: public ActionResult New(UserModel model)

Получает из представления New заполненный объект класса UserModel, проверяет корректность данных (согласно требованиям, описанным в UserClass и LanguageClass), создает объект репозитария UsersRepository и пытается добавить новую строку в БД, вызвав функцию AddUser(model.User). Потом осуществляется возврат на предыдущую страницу (откуда был вызов представления New) и там отображается сообщение об успехе операции или о неудаче.

3) Методы Edit

Метод Edit с входным параметром UserID (идентификатор пользователя) отрабатывает щелчок по имени пользователя в списке для открытия представления редактирования данных пользователя: public ActionResult Edit(int UserID)

В методе опять же создается репозитарий UsersRepository и из него вызывается функция FetchByID(UserID) для получения объекта типа UserClass. В случае удачи создается модель – объект класса UserModel из полученного объекта и списка языков и передается в представление Edit для отображения.

Метод с входным параметром модели типа UserModel и строки action: public ActionResult Edit(UserModel model, string action)

В данный метод передается объект типа UserModel и он отрабатывает действия при нажатии на кнопки (отработка событий отправки данных формы) представления Edit. Для того чтобы понять, какая кнопка нажата используется входной параметр action, который указан в имени HTML-тегов типа input. Поэтому в методе идет сравнение значения данного параметра со значениями параметра value этих кнопок. До проверки модели на валидность проверяется значение Cancel, для выполнения действий по отмене редактирования пользователя. Этот вариант используется вместо возврата назад по истории браузера, примененный в представлении New и использующий запоминание адреса страницы, с которой перешли к представлению Edit. Для этого в контроллере продемонстрирована технология использования своего атрибута ActionFilter: класс ReferrerHoldAttribute в файле ReferrerHoldAttribute.cs (каталог HtmlAttribute):

public class ReferrerHoldAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var referrer = filterContext.RequestContext.HttpContext.Request.UrlReferrer;
        if (referrer != null) filterContext.RouteData.Values.Add("referrer", referrer);
        base.OnActionExecuting(filterContext);
    }
}

Он используется для хранения информации о той странице, куда надо вернуться после нажатия кнопки «New user» или ссылки на изменение пользователя: TempData["referrer"] = ControllerContext.RouteData.Values["referrer"];

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

В дальнейшем, в теле метода, обрабатывающего действия представлений New и Edit, извлекается запомненная информация и используется для переадресации обратно на страницу, откуда были вызваны эти представления: if (TempData["referrer"] != null) return Redirect(TempData["referrer"].ToString());

Для определения необходимости сохранения или удаления пользователя происходит сравнение параметра action со значениями Save и Remove соответственно, и вызываются из репозитария UsersRepository функции ChangeUser(model.User) и RemoveUser(model.User). Потом происходит возврат на предыдущую страницу (откуда был вызов представления Edit) и там отображается сообщение об успехе операции или о неудаче.

ADO.NET и MySQL – репозитарий


Вот тут мы наконец-то добрались до работы с СУБД. Нам надо реализовать функции добавления пользователя в таблицу Users, внесение изменений в данные пользователя, удаление пользователя из таблицы, получения пользователя по идентификатору, получения списка пользователей и получения списка языков из таблицы Languages.

В основном используются стандартные конструкции для класса MySQL.Data с использованием MySqlCommand:

using (MySqlConnection connect = new MySqlConnection(строка подключения))
{
    string sql = "текст запроса";
    using (MySqlCommand cmd = new MySqlCommand(sql, connect))
    {
        cmd.Parameters.Add("название параметра", тип параметра).Value = значение параметра;
        connect.Open();
        result = cmd.ExecuteNonQuery() >= 0; // выполняем запрос и получаем количество затронутых записей (INSERT, UPDATE, DELETE) или используем cmd.ExecuteScalar() для получения одиночного значения в результате выполнения запроса SELECT
    }
}

или MySqlDataReader для чтения строк таблицы, как результата запроса:
using (MySqlConnection connect = new MySqlConnection(строка подключения))
{
    string sql = "текст запроса";
    using (MySqlDataReader dr = cmd.ExecuteReader())
    {
        cmd.Parameters.Add("название параметра", тип параметра).Value = значение параметра;
        objConnect.Open();
        while (dr.Read())
        {// тут читаем строку по названиям столбцов
        }
    }
}

Создаем файл UserRepository.cs в каталоге Models:

public class UsersRepository
{
    public bool AddUser(UserClass user)
    {
        user.UserID = AddUser(Name: user.Loginname, LanguageID: user.Language.LanguageID, Email: user.Email, SupporterTier: user.SupporterTier);
        return user.UserID > 0;
    }
 
    public int AddUser(string Name, int LanguageID, string Email, Supporter SupporterTier)
    {
        int ID = 0;
        using (MySqlConnection connect = new MySqlConnection(Base.strConnect))
        {
            string sql = "INSERT INTO `Users` (`Loginname`, `LanguageID`, `Email`, `SupporterTier`) VALUES (@Loginname, @LanguageID, @Email, @SupporterTier)";
            using (MySqlCommand cmd = new MySqlCommand(sql, connect))
            {
                cmd.Parameters.Add("Loginname", MySqlDbType.String).Value = Name;
                cmd.Parameters.Add("LanguageID", MySqlDbType.Int32).Value = LanguageID;
                cmd.Parameters.Add("Email", MySqlDbType.String).Value = Email;
                cmd.Parameters.Add("SupporterTier", MySqlDbType.Int32).Value = SupporterTier;
                connect.Open();
                if (cmd.ExecuteNonQuery() >= 0)
                {
                    sql = "SELECT LAST_INSERT_ID() AS ID";
                    cmd.CommandText = sql;
                    int.TryParse(cmd.ExecuteScalar().ToString(), out ID);
                }
            }
        }
        return ID;
    }
 
    public bool ChangeUser(UserClass user)
    {
        return ChangeUser(ID: user.UserID, Name: user.Loginname, LanguageID: user.Language.LanguageID, Email: user.Email, SupporterTier: user.SupporterTier);
    }
 
    public bool ChangeUser(int ID, string Name, int LanguageID, string Email, Supporter SupporterTier)
    {
        bool result = false;
        if (ID > 0)
        {
            using (MySqlConnection connect = new MySqlConnection(Base.strConnect))
            {
                string sql = "UPDATE `Users` SET `Loginname`=@Loginname, `LanguageID`=@LanguageID, `Email`=@Email, `SupporterTier`=@SupporterTier WHERE UserID=@UserID";
                using (MySqlCommand cmd = new MySqlCommand(sql, connect))
                {
                    cmd.Parameters.Add("UserID", MySqlDbType.Int32).Value = ID;
                    cmd.Parameters.Add("Loginname", MySqlDbType.String).Value = Name;
                    cmd.Parameters.Add("LanguageID", MySqlDbType.Int32).Value = LanguageID;
                    cmd.Parameters.Add("Email", MySqlDbType.String).Value = Email;
                    cmd.Parameters.Add("SupporterTier", MySqlDbType.Int32).Value = SupporterTier;
                    connect.Open();
                    result = cmd.ExecuteNonQuery() >= 0;
                }
            }
        }
        return result;
    }
 
    public bool RemoveUser(UserClass user)
    {
        return RemoveUser(user.UserID);
    }
 
    public bool RemoveUser(int ID)
    {
        using (MySqlConnection connect = new MySqlConnection(Base.strConnect))
        {
            string sql = "DELETE FROM `Users` WHERE `UserID`=@UserID";
            using (MySqlCommand cmd = new MySqlCommand(sql, connect))
            {
                cmd.Parameters.Add("UserID", MySqlDbType.Int32).Value = ID;
                connect.Open();
                return cmd.ExecuteNonQuery() >= 0;
            }
        }
    }
 
    public UserClass FetchByID(int ID)
    {
        UserClass user = null;
        using (MySqlConnection objConnect = new MySqlConnection(Base.strConnect))
        {
            string strSQL = "SELECT u.`UserID`, u.`Loginname`, l.`LanguageID`, l.`LanguageName`, u.`Email`, u.`LastLoginDate`, CAST(u.`SupporterTier` AS UNSIGNED) as `SupporterTier` FROM `Users` u LEFT JOIN `Languages` l ON l.LanguageID=u.LanguageID WHERE `UserID`=@UserID";
            using (MySqlCommand cmd = new MySqlCommand(strSQL, objConnect))
            {
                objConnect.Open();
                int UserID = 0, LanguageID = 0;
                string Loginname = null, LanguageName= null, Email = String.Empty;
                Supporter SupporterTier = Supporter.None;
                DateTime? LastLoginDate = null;
                cmd.Parameters.Add("UserID", MySqlDbType.Int32).Value = ID;
                using (MySqlDataReader dr = cmd.ExecuteReader())
                {
                    if (dr.Read())
                    {
                        UserID = dr.GetInt32("UserID");
                        Loginname = dr.GetString("Loginname").ToString();
                        LanguageID = dr.GetInt32("LanguageID");
                        LanguageName = dr.GetString("LanguageName").ToString();
                        if (!dr.IsDBNull(dr.GetOrdinal("Email"))) Email = dr.GetString("Email").ToString();
                        if (!dr.IsDBNull(dr.GetOrdinal("LastLoginDate"))) LastLoginDate = dr.GetDateTime("LastLoginDate");
                        if (!dr.IsDBNull(dr.GetOrdinal("SupporterTier"))) SupporterTier = (Supporter)dr.GetInt32("SupporterTier");
                    }
                    LanguageClass language = null;
                    if (LanguageID > 0) language = new LanguageClass(LanguageID: LanguageID, LanguageName: LanguageName);
                    if (UserID > 0 && language != null && language.LanguageID > 0) user = new UserClass(UserID: UserID, Loginname: Loginname, Language: language, Email: Email, LastLoginDate: LastLoginDate, SupporterTier: (Supporter)SupporterTier);
                }
            }
        }
        return user;
    }
 
    // Стандартная и очень привлекательная практика для ASP.NET WebForms, поскольку позволяет в компонентах для отображения данных напрямую обращаться к данным в ObjectDataSource без образования специальной типизированной модели
    // Но так писать не надо, потому что в представлении мы вынуждены будем использовать "магические строки" для получения доступа к значениям в строке данных
    //public IEnumerable<DataRow> List()
    //{
    //    using (MySqlConnection objConnect = new MySqlConnection(Base.strConnect))
    //    {
    //        string strSQL = "select * from users";
    //        using (MySqlCommand objCommand = new MySqlCommand(strSQL, objConnect))
    //        {
    //            objConnect.Open();
    //            using (MySqlDataAdapter da = new MySqlDataAdapter(objCommand))
    //            {
    //                DataTable dt = new DataTable();
    //                da.Fill(dt);
    //                return dt.AsEnumerable();
    //            }
    //        }
    //    }
    //}
 
    public IList<UserClass> List(string sortOrder, System.Web.Helpers.SortDirection sortDir, int page, int pagesize, out int count)
    {
        List<UserClass> users = new List<UserClass>();
        using (MySqlConnection objConnect = new MySqlConnection(Base.strConnect))
        {
            // добавляем в запрос сортировку
            string sort = " ORDER BY ";
            // это плохая практика, потому что запрос может быть взломан при удачном встраивании в него некоей текстовой строки (inject)
            // но, к сожалению, MySQL не дает возможности использовать параметры для сортировки
            // поэтому надо экранировать кавычками, но перед этим обеспечить сначала проверку входного значения (чтобы тех же кавычек в нём не было)
            // в нашем проекте проверка значения идет в контроллере, перед построением модели
            if (sortOrder != null && sortOrder != String.Empty)
            {
                sort += "`" + sortOrder + "`";
                if (sortDir == System.Web.Helpers.SortDirection.Descending) sort += " DESC";
                sort += ",";
            }
            sort += "`UserID`"; // по умолчанию
            // добавляем в запрос отображение только части записей (отображение страницами)
            string limit = "";
            if (pagesize > 0)
            {
                int start = (page - 1) * pagesize;
                limit = string.Concat(" LIMIT ", start.ToString(), ", ", pagesize.ToString());
            }
            string strSQL = "SELECT SQL_CALC_FOUND_ROWS u.`UserID`, u.`Loginname`, l.`LanguageID`, l.`LanguageName` as `Language`, u.`Email`, u.`LastLoginDate`, CAST(u.`SupporterTier` AS UNSIGNED) as `SupporterTier` FROM `Users` u LEFT JOIN `Languages` l ON l.LanguageID=u.LanguageID" + sort + limit;
            using (MySqlCommand cmd = new MySqlCommand(strSQL, objConnect))
            {
                objConnect.Open();
                cmd.Parameters.Add("page", MySqlDbType.Int32).Value = page;
                cmd.Parameters.Add("pagesize", MySqlDbType.Int32).Value = pagesize;
                using (MySqlDataReader dr = cmd.ExecuteReader())
                {
                    while (dr.Read())
                    {
                        LanguageClass language = new LanguageClass(LanguageID: dr.GetInt32("LanguageID"), LanguageName: dr.GetString("Language").ToString());
 
                        users.Add(new UserClass(
                            UserID: dr.GetInt32("UserID"), 
                            Loginname: dr.GetString("Loginname"), 
                            Language: language,
                            Email: dr.IsDBNull(dr.GetOrdinal("Email")) ? String.Empty : dr.GetString("Email"), 
                            LastLoginDate: dr.IsDBNull(dr.GetOrdinal("LastLoginDate")) ? (DateTime?) null : dr.GetDateTime("LastLoginDate"), 
                            SupporterTier: dr.IsDBNull(dr.GetOrdinal("SupporterTier")) ? (Supporter) Supporter.None : (Supporter)dr.GetInt32("SupporterTier")));
                    }
                }
            }
            using (MySqlCommand cmdrows = new MySqlCommand("SELECT FOUND_ROWS()", objConnect))
            {
                int.TryParse(cmdrows.ExecuteScalar().ToString(), out count);
            }
        }
        return users;
    }
}

Он содержит метод AddUser для добавления пользователя, обновления данных пользователя (ChangeUser), удаления (RemoveUser), поиска пользователя (FetchByID) по идентификатору и наиболее интересный метод List для вывода постраничного списка пользователей с сортировкой. Хотелось бы дополнительно прокомментировать функцию List:

  1. Не стоит возвращать именно таблицу (DataTable), как таковую – в этом случае мы потеряем в представлении возможность обращаться к элементам класса и будем вынуждены употреблять строковые константы для обращения к значениям в строке таблицы. Т.е. это будет потенциальное место для возникновения ошибок при изменении SQL-запроса. Поэтому создается список из элементов класса UserClass.
  2. Для добавления ограничения выборки строк из запроса используется конструкция LIMIT в SQL-операторе SELECT. К сожалению, в MySQL не поддерживаются переменные в этой части, и конструкцию LIMIT приходится конструировать вручную и добавлять к запросу. Но это полбеды, поскольку там используются целочисленные значения, а вот подобная практика для конструкции ORDER BY уже чревата нюансами в части инъекции в наш запрос вредоносного кода. Например, злоумышленник может подсунуть в качестве параметра сортировки некий SQL-конструкт, который прервет нашу команду SQL точкой с запятой, а дальше будет идти уже его команда, которая также может быть выполнена. Необходима проверка и чистка входного параметра сортировки, которая у нас осуществляется в контроллере, поэтому в данном методе мы принимаем уже два разобранных значения: название столбца по которому идёт сортировка и направление сортировки.
  3. После получения списка из БД мы должны запустить ещё команду SQL вида SELECT FOUND_ROWS(), которая должна нам дать общее количество строк получаемых предыдущим запросом, в котором была конструкция SELECT SQL_CALC_FOUND_ROWS без учета ограничения в LIMIT.

Все остальные методы совершенно обычные, соответствуют приведенному выше канону.

И файл LanguageRepository.cs в каталоге Models:

public class LanguagesRepository
{
    public IList<LanguageClass> List()
    {
        List<LanguageClass> languages = new List<LanguageClass>();
        using (MySqlConnection objConnect = new MySqlConnection(Base.strConnect))
        {
            string strSQL = "SELECT `LanguageID`, `LanguageName` as `Language` FROM `Languages` ORDER BY `LanguageName`";
            using (MySqlCommand cmd = new MySqlCommand(strSQL, objConnect))
            {
                objConnect.Open();
                using (MySqlDataReader dr = cmd.ExecuteReader())
                {
                    while (dr.Read())
                    {
                        LanguageClass language = new LanguageClass(LanguageID: dr.GetInt32("LanguageID"), LanguageName: dr.GetString("Language").ToString());
                        languages.Add(language);
                    }
                }
            }
        }
        return languages;
    }
}

В нём есть только одна функция для получения списка языков из БД, создающая список из элементов класса LanguageClass.

Итого


Вот и всё – поставленная задача решена. Понятно, что не были затронуты многие вопросы: контейнер внедрения зависимостей, модульное тестирование, инструменты для мокинга, локализация и т.д. и т.п. Какие-то вещи можно было сделать по-другому или просто лучше. Но «кирпичик» который получился, вышел достаточно цельным, чтобы понять как работает ASP.NET MVC и что не так уж страшно работать с ADO.NET и с СУБД, которые не MS SQL.

P.S. И напоследок: посмотреть, как работает данный проект можно по этому адресу. А вот тут можно скачать весь проект. Ну и до кучи — ссылка на мой блог.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 14: ↑8 and ↓6+2
Comments41

Articles