Pull to refresh

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

Reading time27 min
Views29K
Разработка любого бизнес приложения так или иначе связана с обработкой определенного количества данных, выстраиванием связей между этими данными, а так же их удобным представлением. В данной статье мы рассмотрим работу с межтабличным взаимодействием в 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.

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

Tags:
Hubs:
+11
Comments8

Articles