В самом простом примере, которой наверняка известен каждому читателю, для отображения данных из связанных таблиц мы можем применять обычный 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/
Добавить комментарий