Работа с данными из связанных таблиц в ASP.NET MVC или разработка Lookup компонента

от автора

Разработка любого бизнес приложения так или иначе связана с обработкой определенного количества данных, выстраиванием связей между этими данными, а так же их удобным представлением. В данной статье мы рассмотрим работу с межтабличным взаимодействием в ASP.net MVC, а так же возможности по визуализации этого взаимодействия, попробуем разработать свой компонент, с одной стороны позволяющий удобно выбирать нужные данные, с другой легко конфигурироваться. Будем использовать JqGrid, для реализации поиска, сортировки и выбора связанных данных. Коснемся формирования динамических предикатов, посмотрим как можно использовать метаданные в html helper и в заключении рассмотрим уже существующие компоненты этого класса.

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

Рассмотрим пример из двух связанных таблиц: «Пользователь» и «Группа»

public class UserProfile  {         [Key]         public int UserId { get; set; }         public string UserName { get; set; }         public int? UserGroupId { get; set; }          public virtual UserGroup UserGroup { get; set; }   }    public class UserGroup     {         [Key]         public int UserGroupId { get; set; }          [DisplayName("Group Name")]         public string GroupName { get; set; }          [DisplayName("Group Description")]         public string Description { get; set; }          public virtual ICollection<UserProfile> Users { get; set; }     } 

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

        public ActionResult Index()         {             var userProfiles = _db.UserProfiles.Include(c => c.UserGroup);             return View(userProfiles.ToList());         } 

Собственно в коде контроллера представленном выше мы запрашиваем помимо данных профиля пользователя еще и связанную с этим профилем группу. Далее выведем ее в нашем View при помощи DisplayNameFor.

        @Html.DisplayNameFor(model => model.UserGroup.GroupName) 

Если нам необходимо лишь только выводить связанные данные пользователю, то этого вполне достаточно. Для редактирования, как я уже говорил, можно использовать DropDownList.Однако в нашем случае есть необходимость создать более гибкий элемент управления, и сделать его максимально простым в настройке, таким как представленный выше запрос к связанной таблице. Первое с чего мы начнем будет разработка Html helper, который позволит в удобной форме описать использование нашего компонента в представлении, и обеспечить его функционирование.

1. Разработка Html Helper для Lookup компонента

Что есть Html Helper в ASP.net MVC? По большей части это обычные методы расширения позволяющие обращаться к своему классу родителю дабы создавать HTML контент. Для отображения нашего компонента будем использовать стандартное для lookup контролов представление, а именно текстовое поле и кнопку. id записи будем хранить в скрытом поле.
Помимо html контента, html helper также позволяет обращаться к метаданным моделей и полей в которых используются, так что первое что мы сделаем это создадим атрибут, который мог бы выделить наше поле в модели, а так же снабдить его дополнительной информацией необходимой для корректной работы компонента.

Итак код LookupAttribute представлен ниже

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]     public sealed class LookupAttribute : Attribute     {         public Type Model { get; set; }         public string NameField { get; set; }     } 

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

public class UserProfile  {         [Key]         public int UserId { get; set; }         public string UserName { get; set; }         [Lookup(Model = typeof(UserGroup), NameField = "GroupName")]         public int? UserGroupId { get; set; }          public virtual UserGroup UserGroup { get; set; }   } 

Теперь видно, что мы будем ссылаться на модель UserGroup, поле для текстового представления GroupName. Однако, для того чтобы этот атрибут мог использоваться в нашем HTML Helper нам необходимо добавить его к коллекции метаданных представления. Для этого нам нужно реализовать класс наследник DataAnnotationsModelMetadataProvider и зарегистрировать его соответствующим образом.

    public class LookupMetadataExtension : DataAnnotationsModelMetadataProvider     {         protected override ModelMetadata CreateMetadata(IEnumerable<Attribute> attributes, Type containerType,              Func<object> modelAccessor, Type modelType, string propertyName)         {             var metadata = base.CreateMetadata(attributes, containerType, modelAccessor, modelType, propertyName);             var additionalValues = attributes.OfType<LookupAttribute>().FirstOrDefault();              if (additionalValues != null)             {                 metadata.AdditionalValues.Add(LookupConsts.LookupMetadata, additionalValues);             }             return metadata;         }     } 

Для того что бы получить возможность расширять метаданные поля, необходимо унаследоваться от класса DataAnnotationsModelMetadataProvider и переопределить метод CreateMetadata. Класс DataAnnotationsModelMetadataProvider реализует поставщик модели метаданных по умолчанию для ASP.NET MVC.
Все достаточно просто. Если в коллекции переданных атрибутов есть наш, то надо бы добавить его в AdditionalValues коллекции метаданных, после чего возвращаем измененную коллекцию. Для корректной работы данного класса его надо зарегистрировать. Идем в Global.asax.cs и добавляем строчку:

ModelMetadataProviders.Current = new LookupMetadataExtension(); 

Теперь мы готовы продолжить разработку нашего HTML helper. В общем виде функция HTML helper будет выглядеть так

        public static MvcHtmlString LookupFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper,                                                                  Expression<Func<TModel, TProperty>> expression,                                                                   string filterAction, Type modelType,                                                                   String nameField,                                                                   IDictionary<string, object> htmlAttributes)         {             var fieldName = ExpressionHelper.GetExpressionText(expression);             var commonMetadata = PrepareLookupCommonMetadata(                 ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData),                  htmlHelper.ViewData.ModelMetadata, modelType, nameField);             var lookupAttribute = commonMetadata.AdditionalValues[LookupConsts.LookupMetadata] as LookupAttribute;             return LookupHtmlInternal(htmlHelper, commonMetadata, lookupAttribute, fieldName, filterAction, htmlAttributes);         } 

Отмечу, что мы так же даем пользователю возможность задать тип модели непосредственно из представления. В первой строке получаем название нашего поля, затем вызываем функцию PrepareLookupCommonMetadata. Данная функция будет рассмотрена позже, скажу только что она используется для обработки метаданных и обращению к данным связанной таблицы через эти метаданные. Строчка ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData) используя выражение expression получает метаданные текущего поля, собственно наши AdditionalValues. Далее из возвращенного объекта commonMetadata получаем наш lookupAttribute и вызываем функцию генерации HTML кода.

Теперь обратимся к функции обработки метаданных PrepareLookupCommonMetadata.

        private static ModelMetadata PrepareLookupCommonMetadata(ModelMetadata fieldMetadata,                                                                   ModelMetadata modelMetadata ,                                                                   Type modelType, String nameField)         {             LookupAttribute lookupMetadata;             if (modelType != null && nameField != null)             {                 lookupMetadata = new LookupAttribute { Model = modelType, NameField = nameField };                 if (fieldMetadata.AdditionalValues.ContainsKey(LookupConsts.LookupMetadata))                     fieldMetadata.AdditionalValues.Remove(LookupConsts.LookupMetadata);                 fieldMetadata.AdditionalValues.Add(LookupConsts.LookupMetadata, lookupMetadata);             } 

Сначала смотрим, задал ли пользователь в представлении тип и модель, если да, то обновляем данные в AdditionalValues. Идем дальше

  if (fieldMetadata.AdditionalValues != null && fieldMetadata.AdditionalValues.ContainsKey(LookupConsts.LookupMetadata))             {                 lookupMetadata = fieldMetadata.AdditionalValues[LookupConsts.LookupMetadata] as LookupAttribute;                 if (lookupMetadata != null)                 {                     var prop = lookupMetadata.Model.GetPropertyWithAttribute("KeyAttribute");                     var releatedTableKey = prop != null ? prop.Name : String.Format("{0}Id", lookupMetadata.Model.Name);                     fieldMetadata.AdditionalValues.Add("idField", releatedTableKey);                     var releatedTableMetadata =                             modelMetadata.Properties.FirstOrDefault(proper                                                                                         =>                                                                                         proper.PropertyName ==                                                                                         lookupMetadata.Model.Name);               if (releatedTableMetadata != null)                     {                         UpdateLookupColumnsInfo(releatedTableMetadata, fieldMetadata);                         UpdateNameFieldInfo(lookupMetadata.NameField, releatedTableMetadata, fieldMetadata);                     }                     else                     {                                                 throw new ModelValidationException(String.Format(                             "Couldn't find data from releated table. Lookup failed for model {0}",                             lookupMetadata.Model.Name));                     }                 }             }             else             {                 throw new ModelValidationException(String.Format("Couldn't find releated model type. Lookup field"));             }              return fieldMetadata;         } 

Проверяем что AdditionalValues имеет место быть, затем извлекаем его из коллекции метаданных. Далее при помощи метода расширения Типа GetPropertyWithAttribute получаем поле с атрибутом Key из связанной Model. Это поле будем использовать для идентификации нашей связи, т.е это поле и есть первичный ключ связанной таблицы. Если не находим его, то пытаемся сформировать сами при помощи правила- Имя модели + Id = первичный ключ. Добавляем это значение в AdditionalValues как idField. Далее пытаемся получить метаданные связанной таблицы по ее имени.
Если получили, то достанем информацию о колонках и текстовое определение связанной таблицы.
Теперь подробнее остановимся на получении информации о колонках. Этот список полей будет использоваться для вывода записей в JqGrid. Для конфигурирования этого списка создадим еще один атрибут.

    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]     public class LookupGridColumnsAttribute : Attribute     {         public string[] LookupColumns { get; set; }          public LookupGridColumnsAttribute(params string[] values)         {             LookupColumns = values;         }     } 

Теперь посмотрим на измененное представление связанной таблицы. Регистрировать LookupGridColumnsAttribute не нужно, доступ к этому типу будет возможен, через LookupAttribute используя поле Model, которое описывает тип модели.

  [LookupGridColumns(new[] { "Description" })]   public class UserGroup     {         [Key]         public int UserGroupId { get; set; }          [DisplayName("Group Name")]         public string GroupName { get; set; }          [DisplayName("Group Description")]         public string Description { get; set; }          public virtual ICollection<UserProfile> Users { get; set; }     } 

В список колонок, в дополнение к уже присутсвующему там по умолчанию GroupName, добавляем Description. Теперь возвращаемся к рассмотрению функции подготавливающей метаданные по колонкам.

        private static void UpdateLookupColumnsInfo(ModelMetadata releatedTableMetadata, ModelMetadata metadata)         {             IDictionary<string, string> columns = new Dictionary<string, string>();             var gridColumns = releatedTableMetadata.ModelType.GetCustomAttributeByType<LookupGridColumnsAttribute>();             if (gridColumns != null)             {                 foreach (var column in gridColumns.LookupColumns)                 {                     var metadataField =                         releatedTableMetadata.Properties.FirstOrDefault(                             propt => propt.PropertyName == column);                     if (metadataField != null)                     {                         columns.Add(column, metadataField.DisplayName);                     }                     else                     {                         throw new ModelValidationException(                             String.Format("Couldn't find column in releated table {0}",                              releatedTableMetadata.GetDisplayName()));                     }                 }                 metadata.AdditionalValues.Add("lookupColumns", columns);             }         } 

Функция в качестве аргументов принимает метаданные связанной таблицы, а так же метаданные нашего поля. В метаданных связанной таблицы пытаемся найти заданный LookupGridColumnsAttribute атрибут. Смотрим, что он не null и идем по списку колонок попутно запрашивая их метаданные для получения нужного нам для представления DisplayName соответствующей колонки. Если метаданные не обнаружены, кидаем исключение, иначе добавляем полученные данные в коллекцию columns. После того как коллекция колонок сформирована, добавляем ее в метаданные поля в виде AdditionalValues, они пригодятся нам далее.

Что же теперь самое время вернуться к нашей функции PrepareLookupCommonMetadata и рассмотреть последний вызов, а именно UpdateNameFieldInfo.

         private static void UpdateNameFieldInfo(string nameField, ModelMetadata releatedTableMetadata,              ModelMetadata commonMetadata)         {             var nameFieldMetedata =                 releatedTableMetadata.Properties.FirstOrDefault(propt => propt.PropertyName == nameField);             if (nameFieldMetedata != null)             {                 commonMetadata.AdditionalValues.Add("lookupFieldValue", nameFieldMetedata.SimpleDisplayText);                 commonMetadata.AdditionalValues.Add("lookupFieldDisplayValue", nameFieldMetedata.DisplayName);             }             else             {                 throw new ModelValidationException(String.Format("Couldn't find name field in releated table {0}",                                                                  releatedTableMetadata.GetDisplayName()));             }         } 

Данная функция получает всю информацию относительно текстового представления нашей связи, а именно, того самого поля, которое мы указали в виде «NameField = „GroupName“» в атрибуте Lookup и добавляет данную информацию в AdditionalValues метаданных нашего поля. nameFieldMetedata.SimpleDisplayText — значение поля GroupName из связанной таблицы. nameFieldMetedata.DisplayName — Название поля GroupName из связанной таблицы.

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

 private static MvcHtmlString LookupHtmlInternal(HtmlHelper htmlHelper, ModelMetadata metadata,                                                          LookupAttribute lookupMetadata, string name,                                                         string action, IDictionary<string, object> htmlAttributes)         {             if (string.IsNullOrEmpty(name))             {                 throw new ArgumentException("Error", "htmlHelper");             }              var divBuilder = new TagBuilder("div");             divBuilder.MergeAttribute("id", String.Format("{0}_{1}", name, "div"));             divBuilder.MergeAttribute("class", "form-wrapper cf");             divBuilder.MergeAttribute("type", lookupMetadata.Model.FullName);             divBuilder.MergeAttribute("nameField", lookupMetadata.NameField);             divBuilder.MergeAttribute("idField", metadata.AdditionalValues["idField"] as string);             divBuilder.MergeAttribute("nameFieldDisplay", metadata.AdditionalValues["lookupFieldDisplayValue"] as string);             divBuilder.MergeAttribute("action", action); 

Принимаем следующие аргументы. 1. htmlHelper — позволяет нам генерировать html код, 2. metadata — По сути это метаданные поля, содержащие в себе все доп. метаданные полученные на этапах сбора информации. 3. Выделенный отдельно lookupMetadata. 4. name — Имя нашего поля, как во вьюхе. 5 action — Указываем контроллер и метод, которые будут использоваться для запроса данных. 5 htmlAttributes — доп. html атрибуты, определенные программистом.
Далее смотрим, что имя поля не null и строим div содержащий основные параметры нашего поля. Остановимся на основных параметрах: type — тип модели, на которую ссылаемся, nameField — имя текстового поля из связанной таблицы, которое идентифицирует связь (в нашем случае имя группы), idField — первичный ключ связанной таблицы, nameFieldDisplay — значение текстового поля из связанной таблицы, которое идентифицирует связь ну и action — как я уже говорил это контроллер и метод, которые будут использоваться для запроса данных.

            var columnsDivBuilder = new TagBuilder("div");             columnsDivBuilder.MergeAttribute("id", String.Format("{0}_{1}", name, "columns"));             columnsDivBuilder.MergeAttribute("style", "display:none");              if (metadata.AdditionalValues.ContainsKey("lookupColumns"))             {                 var columns = ((IDictionary<string, string>)metadata.AdditionalValues["lookupColumns"]);                 var columnString = String.Empty;                 foreach (var column in columns.Keys)                 {                     var columnDiv = new TagBuilder("div");                     columnDiv.MergeAttribute("colName", column);                     columnDiv.MergeAttribute("displayName", columns[column]);                     columnString += columnDiv.ToString(TagRenderMode.SelfClosing);                 }                 columnsDivBuilder.InnerHtml = columnString;             } 

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

            var inputBuilder = new TagBuilder("input");             inputBuilder.MergeAttributes(htmlAttributes);             inputBuilder.MergeAttribute("type", "text");             inputBuilder.MergeAttribute("class", "lookup", true);             inputBuilder.MergeAttribute("id", String.Format("{0}_{1}", name, "lookup"), true);             inputBuilder.MergeAttribute("value", metadata.AdditionalValues["lookupFieldValue"] as string, true);              var hiddenInputBuilder = new TagBuilder("input");             hiddenInputBuilder.MergeAttribute("type", "hidden");             hiddenInputBuilder.MergeAttribute("name", name, true);             hiddenInputBuilder.MergeAttribute("id", name, true);             hiddenInputBuilder.MergeAttribute("value", metadata.SimpleDisplayText, true);              var buttonBuilder = new TagBuilder("input");             buttonBuilder.MergeAttribute("type", "button");             buttonBuilder.MergeAttribute("value", "Lookup");             buttonBuilder.MergeAttribute("class", "lookupbutton");             buttonBuilder.MergeAttribute("id", String.Format("{0}_{1}", name, "lookupbtn"), true); 

Формируем оставшуюся часть атрибутов, а именно поле содержащее текстовое представление нашей связи (nameField), скрытое поле содержащее id нашей связи, кнопка по которой будем открывать JqGrid c данными из связанной таблицы.
Замечу, что id текущей выбранной записи мы получаем из метаданных поля, воспользовавшись следующим вызовом metadata.SimpleDisplayText.

  divBuilder.InnerHtml = String.Format(@"{0}{1}{2}{3}", inputBuilder.ToString(TagRenderMode.SelfClosing),                                                  hiddenInputBuilder.ToString(TagRenderMode.SelfClosing),                                                  buttonBuilder.ToString(TagRenderMode.SelfClosing),                                                  columnsDivBuilder.ToString(TagRenderMode.Normal)                                                  );              return new MvcHtmlString(divBuilder.ToString(TagRenderMode.Normal));         } 

Все что сгенерировали упаковываем в корневой div и возвращаем html строку браузеру для отображения.

Для того чтобы воспользоваться нашим html helperом было просто, реализуем также перегрузки метода LookupFor

        public static MvcHtmlString LookupFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper,                                                          Expression<Func<TModel, TProperty>> expression)         {             var urlHelper = new UrlHelper(htmlHelper.ViewContext.RequestContext);             return LookupFor(htmlHelper, expression, urlHelper.Action("LookupData"), null, null, null);         }         public static MvcHtmlString LookupFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper,                                                                  Expression<Func<TModel, TProperty>> expression,                                                                  string filterAction)         {             return LookupFor(htmlHelper, expression, filterAction, null, null, null);         } 

Для того чтобы использовать наш html helper достаточно в представлении вызвать Html.LookupFor(model => model.UserGroupId).
Для того чтобы в представлении работал intellisense, необходимо в web.config в раздел system.web -> pages -> namespaces добавить пространство имен, в котором находится класс реализующий Ваш Html Helper, или просто разместить этот класс в одном из уже определенных пространств имен, скажем в System.Web.Helpers. Либо непосредственно в представлении указать <@using your.namespace>.

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

2. Expression и формирование динамических предикатов.

Для того чтобы создать набор базовых запросов, которые позволят разработчику легко начать использовать наш компонент в режиме «по умолчанию», нам необходимо подготовить предикаты позволяющие формировать дерево запросов во время выполнения нашего приложения. Рассмотрим класс LinqExtensions, который содержит несколько методов позволяющих в конечном счете формировать динамические Linq. Начнем с реализации метода Where.

        public static IQueryable<T> Where<T>(this IQueryable<T> source, string fieldName,              string searchString, string compareFunction)         {             if (searchString == null) searchString = String.Empty;             var param = Expression.Parameter(typeof(T));             var prop = Expression.Property(param, fieldName);             var methodcall = Expression.Call(prop,                                              typeof(String).GetMethod(compareFunction, new[] { typeof(string) }),                                              Expression.Constant(value: searchString));             var lambda = Expression.Lambda<Func<T, bool>>(methodcall, param);             var request = source.Where(lambda);             return request;         } 

Итак fieldName — поле данные из которого будем сравнивать, searchString — строка которую будем сравнивать, и функция из класса String которая будет использована для реализации сравнения. Далее разберем все подробно. Смотрим, что строка, которую нам передали не null. Если все хорошо, то определяем тип Expression.Parameter(typeof(T)); к которому будем обращаться, по сути это будет тип модели. Следующей строкой определяем свойство типа, поле из модели, которое будем использовать для сравнения. Затем формируем вызов функции compareFunction из класса string с аргументами searchString и сформированным ранее «указателем на свойство». Далее формируем лямбду и используем IQueryable контекст дабы применить к нему Where с только что сформированным предикатом. Возвращаем сформированный IQueryable.

Реализуем несколько функций, с заранее определенной функцией сравнения строк

        public static IQueryable<T> WhereStartsWith<T>(this IQueryable<T> source, string fieldName, string searchString)         {             return Where(source, fieldName, searchString, "StartsWith");         }          public static IQueryable<T> WhereContains<T>(this IQueryable<T> source, string fieldName, string searchString)         {             return Where(source, fieldName, searchString, "Contains");         } 

По образу и подобию реализуем методы Equal и NotEqual

         public static IQueryable<T> Equal<T>(this IQueryable<T> source, string fieldName, string searchString)          {              if (searchString == null) searchString = String.Empty;              var param = Expression.Parameter(typeof(T));              var prop = Expression.Property(param, fieldName);              var methodcall = Expression.Equal(prop, Expression.Constant(searchString));              var lambda = Expression.Lambda<Func<T, bool>>(methodcall, param);              var request = source.Where(lambda);              return request;          }           public static IQueryable<T> NotEqual<T>(this IQueryable<T> source, string fieldName, string searchString)          {              if (searchString == null) searchString = String.Empty;              var param = Expression.Parameter(typeof(T));              var prop = Expression.Property(param, fieldName);              var methodcall = Expression.NotEqual(prop, Expression.Constant(searchString));              var lambda = Expression.Lambda<Func<T, bool>>(methodcall, param);              var request = source.Where(lambda);              return request;          } 

Тут все по аналогии подробно останавливаться не буду.

Также нам необходимо иметь возможность динамической сортировки, так что реализуем метод ApplyOrder

        static IOrderedQueryable<T> ApplyOrder<T>(IQueryable<T> source, string property, string methodName)         {             var type = typeof(T);             var param = Expression.Parameter(type);             var pr = type.GetProperty(prop);             var expr = Expression.Property(param, type.GetProperty(prop));             var ptype = pr.PropertyType;             var delegateType = typeof(Func<,>).MakeGenericType(type, ptype);             var lambda = Expression.Lambda(delegateType, expr, param);             var result = typeof(Queryable).GetMethods().Single(                     method => method.Name == methodName                             && method.IsGenericMethodDefinition                             && method.GetGenericArguments().Length == 2                             && method.GetParameters().Length == 2)                     .MakeGenericMethod(type, ptype)                     .Invoke(null, new object[] { source, lambda });             return (IOrderedQueryable<T>)result;         }  

По аргументам: 1. Property — поле по которому будем сортировать; 2.methodName — Метод который будем использовать для сортировки. Далее формируем набор параметров. MakeGenericType в нашем случае сформирует делегат Func<T,string>, затем используем его для создания лямбды, которую передаем в качестве аргумента методу определенному как methodName и вызываем все это при помощи рефлексии.

Таким образом мы теперь в состоянии определить динамические вызовы сортирующих методов из Queryable.

 public static IOrderedQueryable<T> OrderBy<T>(this IQueryable<T> source, bool desc , string property)         {             return ApplyOrder(source, property, desc ? "OrderByDescending" : "OrderBy");         }          public static IOrderedQueryable<T> OrderBy<T>(this IQueryable<T> source, string property)         {             return ApplyOrder(source, property, "OrderBy");         }          public static IOrderedQueryable<T> OrderByDescending<T>(this IQueryable<T> source, string property)         {             return ApplyOrder(source, property, "OrderByDescending");         }          public static IOrderedQueryable<T> ThenBy<T>(this IOrderedQueryable<T> source, string property)         {             return ApplyOrder(source, property, "ThenBy");         }          public static IOrderedQueryable<T> ThenByDescending<T>(this IOrderedQueryable<T> source, string property)         {             return ApplyOrder(source, property, "ThenByDescending");         } 

На этом реализация вспомогательного класса Linq заканчивается и переходим к следующему этапу.

3. ModelBinder и конфигурация нашего компонента.

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

    public enum SearchOperator     {         Equal,         NotEqual,         Contains     }      public class FilterSettings     {         public string SearchString;         public string SearchField;         public SearchOperator Operator;     }      public class GridSettings     {         public bool IsSearch { get; set; }         public int PageSize { get; set; }         public int PageIndex { get; set; }         public string SortColumn { get; set; }         public bool Asc { get; set; }     }      public class LookupSettings     {         public Type Model { get; set; }         public FilterSettings Filter { get; set; }         public GridSettings GridSettings { get; set; }         public string IdField { get; set; }         public string NameField { get; set; }     } 

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

    public class LookupModelBinder : IModelBinder     {         public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)         {             HttpRequestBase request = controllerContext.HttpContext.Request;              var lookupSettings = new LookupSettings                 {                     Model = Type.GetType(request["modelType"]),                     IdField = request["IdField"],                     NameField = request["NameField"],                     Filter = new FilterSettings                         {                             SearchString = request["searchString"] ?? String.Empty,                             SearchField = request["searchField"]                         }                 };             if(request["searchOper"] != null)             {                 switch (request["searchOper"])                 {                     case "eq": lookupSettings.Filter.Operator = SearchOperator.Equal; break;                      case "ne": lookupSettings.Filter.Operator = SearchOperator.NotEqual; break;                      case "cn": lookupSettings.Filter.Operator = SearchOperator.Contains; break;                 }             }             lookupSettings.GridSettings = new GridSettings {Asc = request["sord"] == "asc"};             if (request["_search"] != null) lookupSettings.GridSettings.IsSearch = Convert.ToBoolean(request["_search"]);             if (request["page"] != null) lookupSettings.GridSettings.PageIndex = Convert.ToInt32(request["page"]);             if (request["rows"] != null) lookupSettings.GridSettings.PageSize = Convert.ToInt32(request["rows"]);             lookupSettings.GridSettings.SortColumn = request["sidx"];             if (lookupSettings.Filter.SearchField == null) { lookupSettings.Filter.SearchField = request["NameField"];                 lookupSettings.Filter.Operator = SearchOperator.Contains;             }               return lookupSettings;         }     } 

Для реализации биндинга нам необходимо унаследоваться от класса IModelBinder и реализовать функцию BindModel, где controllerContext — Контекст, в котором функционирует контроллер. Сведения о контексте включают информацию о контроллере, HTTP-содержимом, контексте запроса и данных маршрута. bindingContext — Контекст, в котором привязана модель. Контекст содержит такие сведения, как объект модели, имя модели, тип модели, фильтр свойств и поставщик значений. Мы получаем HttpRequestBase и используем этот объект для получения данных переданных в запросе. Далее формируем структуру модели настроек и возвращаем полученный класс. Для того, чтобы биндинг начал работать его нужно зарегистрировать, так что пройдем в Global.asax.cs и добавим соответствующий вызов.

 ModelBinders.Binders.Add(typeof(LookupSettings), new LookupModelBinder()); 

В итоге, после всех регистраций, мой Global.asax.cs выглядит следующим образом:

        protected void Application_Start()         {             AreaRegistration.RegisterAllAreas();             ModelMetadataProviders.Current = new LookupMetadataExtension();             ModelBinders.Binders.Add(typeof(LookupSettings), new LookupModelBinder());             WebApiConfig.Register(GlobalConfiguration.Configuration);             FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);             RouteConfig.RegisterRoutes(RouteTable.Routes);             BundleConfig.RegisterBundles(BundleTable.Bundles);             AuthConfig.RegisterAuth();         } 

Теперь в контроллере мы можем использовать следующую запись для обращения к аргументам пришедшим от лукапа.

public virtual ActionResult LookupData([ModelBinder(typeof(LookupModelBinder))] LookupSettings settings) 

На этом работу с объектом конфигурирования мы заканчиваем и переходим к следующему этапу:

4. Реализация общего MVC контроллера для Lookup контрола.

Для большинства лукапов, которые мы используем в нашем приложении нет нужды в какой- то сложной конфигурации, фильтрации или сортировки, так что разработаем объект реализующий базовую сортировку и поиск в не зависимости от типа пришедшего из компонента, а так же контроллер использующий этот объект для организации доступа к данным в режиме «по умолчанию». Начнем с класса LookupDataResolver. Этот класс будет отвечать за операции поиска, сортировки в режиме «по умолчанию». Отмечу, что наш компонент помимо выбора элемента из грида, должен обеспечивать разрешение элемента по текстовому значению введенному в соответствующее поле.

В виду того, что тип определяется только в режиме выполнения, реализуем функцию, которая будет типизировать нашу модель в виде дженерик аргумента и вызывать функцию соответствующую запросу. Так что мы сможем использовать следующий код dbContext.Set().AsQueryable(); для формирования базового запроса.

Рассмотрим функцию LookupMethodCall.

        private static ActionResult LookupMethodCall(string methodName, LookupSettings settings,                                         DbContext dbContext,                                         OnAfterQueryPrepared onAfterQueryPrepared)         {             var methodLookupCall = typeof(LookupDataResolver).             GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static);             methodLookupCall = methodLookupCall.MakeGenericMethod(settings.Model);             var lookupSettings = Expression.Parameter(typeof(LookupSettings), "settings");             var dbCtx = Expression.Parameter(typeof(DbContext), "dbContext");             var funct = Expression.Parameter(typeof(OnAfterQueryPrepared), "onAfterQueryPrepared");             var lookupSearch = Expression.Lambda(                     Expression.Call(                         null,                         methodLookupCall,                         lookupSettings, dbCtx, funct),                     lookupSettings, dbCtx, funct);             var lookupSearchDelegate = (Func<LookupSettings, DbContext, OnAfterQueryPrepared, JsonResult>)                 lookupSearch.Compile();             return lookupSearchDelegate(settings, dbContext, onAfterQueryPrepared);         } 

Сначала мы ищем в текущем типе метод methodName. После этого при помощи функции MakeGenericMethod подготавливаем нашу модель для использования в виде дженерик аргумента. Формируем параметры: settings (полученная из лукапа сущность настроек), dbContext (контекст для обращения к бд), onAfterQueryPrepared (делегат, который будет вызван сразу после формирования базового запроса к бд. Он нужен для добавления доп. фильтров, если они необходимы). Далее создаем соответствующую лямбду, которая будет осуществлять вызов нашего метода, после чего компилируем ее и вызываем.

Реализуем функции выполняющие вызов метода соответствующего запросу, при помощи функции LookupMethodCall. BasicLookup для разрешения текста введенного пользователем в лукап, будет обращаться к дженерик функции LookupSearch. BasicGrid обеспечит сортировку и поиск в гриде, вызывает дженерик функцию LookupDataForGrid.

        public static ActionResult BasicLookup(LookupSettings settings,                                                DbContext dbContext,                                                OnAfterQueryPrepared onAfterQueryPrepared)         {             return LookupMethodCall("LookupSearch", settings, dbContext, onAfterQueryPrepared);         }         public static ActionResult BasicGrid(LookupSettings settings,                                               DbContext dbContext,                                               OnAfterQueryPrepared onAfterQueryPrepared)         {             return LookupMethodCall("LookupDataForGrid", settings, dbContext, onAfterQueryPrepared);         } 

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

        private static JsonResult LookupSearch<T>(LookupSettings settings, DbContext dbContext,              OnAfterQueryPrepared onAfterQueryPrepared) where T : class         {             var modelType = typeof(T);             var request = dbContext.Set<T>().AsQueryable();             if (onAfterQueryPrepared != null)             {                 var query = onAfterQueryPrepared(request, settings);                 if (query != null) request = query.Cast<T>();             }             request = request.WhereStartsWith(settings.Filter.SearchField, settings.Filter.SearchString);             return new JsonResult             {                 Data = request.ToList().Select(t => new                 {                     label = modelType.GetProperty(settings.NameField).GetValue(t).ToString(),                     id = modelType.GetProperty(settings.IdField).GetValue(t).ToString()                 }).ToList(),                 ContentType = null,                 ContentEncoding = null,                 JsonRequestBehavior = JsonRequestBehavior.AllowGet             };         } 

Итак, получаем типизированный Queryable из dbContext для соответствующей модели, смотрим определен ли делегат, если да, то вызываем его и используем возвращенный им запрос для дальнейшего формирования query. Далее все просто, используем WhereStartsWith для формирования запроса. Используем значения из сущности настроек settings.Filter.SearchField, settings.Filter.SearchString соответственно для определения поля и строки по которой производится фильтрация. В заключении формируем результирующий массив, используя рефлексию для получения данных из полей экземпляра t по типу модели modelType.
Возвращаем только две колонки: label — текстовое представление связанной записи и id — первичный ключ.
Если значений будет больше одного, то текст в контроле будет серым, это будет свидетельствовать о том, что разрешение записи не удалось и нужно обратиться к более детальному представлению.

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

        private static JsonResult LookupDataForGrid<T>(LookupSettings settings, DbContext dbContext,                                          OnAfterQueryPrepared onAfterQueryPrepared) where T : class         {             var modelType = typeof(T);             var pageIndex = settings.GridSettings.PageIndex - 1;             var pageSize = settings.GridSettings.PageSize;             var request = dbContext.Set<T>().AsQueryable();             if (onAfterQueryPrepared != null)             {                 var query = onAfterQueryPrepared(request, settings);                 if (query != null) request = query.Cast<T>();             }             if (settings.GridSettings.IsSearch)             {                 switch (settings.Filter.Operator)                 {                     case SearchOperator.Equal:                         request = request.Equal(settings.Filter.SearchField, settings.Filter.SearchString); break;                     case SearchOperator.NotEqual:                         request = request.NotEqual(settings.Filter.SearchField, settings.Filter.SearchString); break;                     case SearchOperator.Contains:                         request = request.WhereContains(settings.Filter.SearchField, settings.Filter.SearchString); break;                 }             }              var totalRecords = request.Count();             var totalPages = (int)Math.Ceiling(totalRecords / (float)pageSize);              var userGroups = request                .OrderBy(!settings.GridSettings.Asc, settings.GridSettings.SortColumn)                .Skip(pageIndex * pageSize)                .Take(pageSize);              return new JsonResult             {                 Data = new                 {                     total = totalPages,                     settings.GridSettings.PageIndex,                     records = totalRecords,                     rows = (                             userGroups.AsEnumerable().Select(t => new                             {                                 id = modelType.GetProperty(settings.IdField).GetValue(t).ToString(),                                 cell = GetDataFromColumns(modelType, settings, t)                              }).ToList())                 },                 ContentType = null,                 ContentEncoding = null,                 JsonRequestBehavior = JsonRequestBehavior.AllowGet             };         } 

Функция реализуется по аналогии с LookupSearch, тут мы добавляем обработку постраничного разбиения, базовой сортировки и поиска. Список значений по колонкам получаем при помощи функции GetDataFromColumns. Данная функция использует атрибут LookupGridColumnsAttribute для определения списка колонок, которые ожидает наш грид. Ниже приводится ее код:

        private static IEnumerable<string> GetDataFromColumns(Type model, LookupSettings settings, object instance)         {             var dataArray = new List<string>                 {                     model.GetProperty(settings.IdField).GetValue(instance).ToString(),                     model.GetProperty(settings.NameField).GetValue(instance).ToString()                 };             var gridColumns = model.GetCustomAttributeByType<LookupGridColumnsAttribute>();             if (gridColumns != null)             {                 dataArray.AddRange(from column in gridColumns.LookupColumns                                     select model.GetProperty(column).GetValue(instance)                                     into val where val != null                                     select val.ToString());             }             return dataArray;         } 

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

Теперь настало время реализовать базовый контроллер, который обеспечит функционирование всех лукап контролов на форме в режиме «по умолчанию»

 public class LookupBasicController : Controller     {         protected virtual DbContext GetDbContext         {             get { throw new NotImplementedException("You have to implement this method to return correct db context"); }         }          protected virtual IQueryable LookupBaseQuery(IQueryable query, LookupSettings settings)         {             return null;         }          public virtual ActionResult LookupData([ModelBinder(typeof(LookupModelBinder))] LookupSettings settings)         {             return LookupDataResolver.BasicLookup(settings, GetDbContext, LookupBaseQuery);         }          public virtual ActionResult LookupDataGrid([ModelBinder(typeof(LookupModelBinder))] LookupSettings settings)         {             return LookupDataResolver.BasicGrid(settings, GetDbContext, LookupBaseQuery);         } 

Для корректной работы в классе наследнике необходимо переопределить контекст базы данных и если Вы планируете расширять запросы по умолчанию, то и функцию LookupBaseQuery. Данная функция используется для вызова из LookupSearch и LookupDataForGrid при формировании базового query. Отмечу также, что имена функций в контроллере, к которым обращается JS для получения данных, могут быть определенны во время конфигурации html helper. Однако, имя функции выполняющей получение данных для jqGrid формируется по следующему шаблону: Имя указанное при конфигурировании html helper + Grid. По умолчанию JS будет обращаться к функциям LookupData и LookupDataGrid.

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

5. Пример использования

Рассмотрим модели, которые были описаны в начале статьи. Применим к связи наш компонент.

    [Table("UserProfile")]     public class UserProfile     {         [Key]         [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]         public int UserId { get; set; }         public string UserName { get; set; }          [Lookup(Model = typeof(UserGroup), NameField = "GroupName")]         public int? UserGroupId { get; set; }          public virtual UserGroup UserGroup { get; set; }     }      [LookupGridColumns(new[] { "Description" })]     public class UserGroup     {         [Key]         public int UserGroupId { get; set; }          [DisplayName("Group Name")]         public string GroupName { get; set; }          [DisplayName("Group Description")]         public string Description { get; set; }          public virtual ICollection<UserProfile> Users { get; set; }     } 

Итак, у нас есть UserProfile в котором мы добавляем Lookup ссылку на UserGroup и определяем какое поле будем использовать для текстового представления данной записи. В таблице UserGroud добавляем атрибут LookupGridColumns в котором указываем доп. колонки, которые хотели бы видеть в представлении. Собственно это все, теперь переходим к контроллеру.

 public class UserListController : LookupBasicController     {         private readonly DataBaseContext _db = new DataBaseContext();          protected override DbContext GetDbContext         {             get { return _db; }         } 

Наследуемся от LookupBasicController и переопределяем GetDbContext для того, чтобы дать LookupBasicController доступ к контексту бд.

        public ActionResult Edit(int id = 0)         {             UserProfile userprofile = _db.UserProfiles.Include(c => c.UserGroup)                 .SingleOrDefault(x => x.UserId == id);             if (userprofile == null)             {                 return HttpNotFound();             }             return View(userprofile);         } 

Добавили запрос к связанным данным из таблицы UserGroup.
На этом настройка контроллера заканчивается и мы переходим к представлению.

@using TestApp.Models @model UserProfile  @{     ViewBag.Title = "Edit"; } @Styles.Render("~/Content/JqGrid")   <h2>Edit</h2>  @using (Html.BeginForm()) {     @Html.ValidationSummary(true)      <fieldset>         <legend>UserProfile</legend>          @Html.HiddenFor(model => model.UserId)          <div class="editor-label">             @Html.LabelFor(model => model.UserName)         </div>         <div class="editor-field">             @Html.EditorFor(model => model.UserName)             @Html.ValidationMessageFor(model => model.UserName)         </div>          <div class="editor-label">             @Html.LabelFor(model => model.UserGroupId)         </div>         <div class="editor-field">             @Html.LookupFor(model => model.UserGroupId)              @Html.ValidationMessageFor(model => model.UserGroupId )         </div>          <p>             <input type="submit" value="Save" />         </p>     </fieldset> }  <div>     @Html.ActionLink("Back to List", "Index") </div>    @section Scripts {     @Scripts.Render("~/bundles/lookup")     @Scripts.Render("~/bundles/jqueryval")     @Scripts.Render("~/bundles/jqueryui")     @Scripts.Render("~/bundles/jqgrid") } 

Здесь нужно не забыть добавить доп. скрипты типа jqgrid, lookup и т.д. Подробнее рассмотреть представление Вы сможете воспользовавшись исходниками прилагаемыми к статье.

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

Выглядит все это так:

6. Заключение

В заключении, хочу сказать, мы потратили определенное время на поиски соответствующего нашим запросам компонента, в итоге остановились на продукте ASP.net MVC Awesome 3.5. Отмечу, что компонент MVC Awesome Lookup достаточно гибкий, и позволяет выполнять различно рода настройки, но ввиду того, что было принято решение разрабатывать все с нуля, рекомендовать его не могу, так как в работе не использовал. Посмотреть пример использования и код можно здесь: Awe Lookup. У них так же имеется поддержка мультивыбора.

Исходный код компонента и тестовое приложение рассмотренные в статье, можно скачать тут: TestApp.zip.

Надеюсь, материал был Вам интересен!

ссылка на оригинал статьи http://habrahabr.ru/post/190482/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *