65.9K
CodeProject 正在变化。 阅读更多。
Home

IDNameObjects API

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.50/5 (2投票s)

2016年8月26日

CPOL

12分钟阅读

viewsIcon

7466

用于 Entity Framework 的 C# 扩展 API,旨在简化下拉列表、组合框等的使用。

引言

当你处理类似下拉列表的东西时,在大多数情况下,你真正需要的是 ID+Name 对的列表。这些扩展方法实现了 ID+Name 逻辑的典型任务,并且可以大大减少代码量。

源代码 可在 GitHub 上找到

使用代码

让我们从最后开始。如果你是一名 Web 开发人员,你可能知道有时你不应该用所有可能的值来填充 select-list,因为它们有很多。当用户只需要选择一个时,创建包含数千个项目的列表意味着过多的流量和过大的数据库负载。典型的解决方案:分批检索数据,按需检索。例如,用户可以开始输入项目名称,Web 页面会自动发送 ajax 请求并获取一些过滤后的数据。更好的解决方案是将这些过滤后的数据分成“页面”:当用户滚动已检索项目的列表到底部时,Web 页面会自动请求下一部分数据。

当我们谈论 JQuery 时,所有这些都可以借助 Select2 来实现。在服务器端,你应该创建一个适当的查询来获取请求的数据,将其转换为 Select2 格式并返回结果。你可以参考 这篇文章作为示例。

考虑以下简单的模型。

public class Book
{
    public int ID { get; set; }

    public string Title { get; set; }

    // ...some more properties that are of no importance right now
}

接收 ajax 请求的方法可能看起来像这样

    [HttpGet]
    public ActionResult GetBooks(string search, int pageNumber, int pageSize)
    {
        // suppose the db variable is of a proper DbContext-derived class with the DbSet<Book> Books property
        var books = db.Books
                        .Where(b => b.Contains(search))       // filter the titles by substring
                        .OrderBy(b => b.Title)                // order titles by name
                        .Skip(pageSize * (pageNumber-1))      // go to the proper virtual "page"
                        .Take(pageSize)                       // take all the items from this page
                        .ToList();                            // load the result set to List
        // skip the rest for now
    }

如上例所示,我们所做的实际上是从数据库获取数据——但尚未转换为 Select2 格式。你可以编写一个将 Book 实体转换为 Select2 对象(它应该有两个属性:idtext)的方法,或者你可以在调用 ToList() 之前立即创建匿名对象——无论如何你都必须做一些特殊的事情。当你需要为另一个模型(例如 Movie、Actor 或 Car——任何具有名称且可以通过某个键标识的东西)添加相同的逻辑时——你将不得不重复这段代码。

……或者你可以简单地写这个

    var select2result = db.Books.ToSelect2PageResult(search, pageNumber, pageSize);

用一行代码,你就可以获得所有准备好以这种方式发送回来的数据

    return new JsonResult
    {
        Data = new { result = select2result },
        JsonRequestBehavior = JsonRequestBehavior.AllowGet
    };

注意:select2result 变量的类型是 Select2PageResult,它由一个兼容 Select2 的 items 列表和一个名为 more 的布尔属性组成。如果 more 为 true,则表示数据库中还有更多相关数据(即这还不是最后一页)。此值可用于 Select2 分页。在源代码的 misc 文件夹中,有一个 JavaScript 函数可用于 ajax 请求-响应。

所有这些魔术是如何实现的

实际上,我撒谎了:你不能立即将这个魔术应用于我们的 Book 模型。如果 Title 属性被命名为 Name,你可以这样做。但是你不必更改你的模型来适应这个逻辑。你只需要稍微调整一下,就像这样

    public class Book
    {
        public int ID { get; set; }

        [IDNameObjectProperty("Name")]
        public string Title { get; set; }

        // ...some more properties that are of no importance right now
    }

好了。现在你可以施展魔法了。IDNameObjects API 现在知道该取什么作为 ID,什么作为 Name。

你甚至可以这样做

    public class Book
    {
        [Key]
        public string ISBN { get; set; }

        [IDNameObjectProperty("Name")]
        public string Title { get; set; }

        // ...some more properties that are of no importance right now
    }

请注意,有一个 IDNameObjectProperty("ID") 属性的支持,但由于我们使用 Entity Framework,并且在这种情况下,我们必须使用 Key 属性,因此这将是多余的。

另外请注意,最后一个示例中 [ID] 属性的类型是 string,而不是 int。IDNameObjects API 支持两者(以及可能还有其他类型——谁知道呢)。然而,[Name] 属性始终被视为 string。

再说一句话,就此结束:IDNameObjects API 会识别一个名为 BookID 的标识符。但目前还没有支持派生类的这种命名(但你可以使用属性来处理它)。

创建选择列表

在所有这些 Select2+Ajax 之前,我们真正需要的是准备 HTML select/option 标签。在 ASP.NET MVC 中,通常有 SelectListMultiSelectList 类可以做到这一点。如果你愿意,你可以直接使用它们。或者,你可以使用 IDNameObjects 扩展。

在向你展示示例之前,我必须提醒你,我们不必将所有数据都传递给客户端。我们所需要的是获取当前选中的项目(或者,在多选列表中,是项目)。其余的将通过 GetBooks 方法(见上文)按需绘制。

让我们从 [单选] 列表开始(假设我们只需要选择一本最喜欢的书)。假设,已经选择了一本这样的书(并存储在数据库中),我们想让用户更改他的/她的选择。所以,我们这样做

    Book book;
    // Here should be some code to get the current book from the DB
    ViewBag.BooksList = book.ToSelectList();

就是这样。真的。我是认真的。

好吧。让我们假装我们还没有 Book 对象,只有一个 bookID(或者,如果你喜欢,isbn)值。还记得 DbContextDbSet<Book> 属性吗?

    ViewBag.BooksList = db.Books.ToSelectList(bookID);

如果出于某种原因,你想创建一个完整的选项列表,因此不通过 ajax 请求获取数据(比如说,列表太短,不值得麻烦),你可以这样做

    ViewBag.BooksList = db.Books.ToSelectList(bookID, false);

对于多选列表,一切都差不多(嗯,几乎)。所以,我们必须选择几本(零本到全部)书

    // object reader is of class Reader that consists of ICollection<Book> FavoriteBooks navigational property
    ViewBag.BooksList = reader.FavoriteBooks.ToMultiSelectList();

    // if we have an IList of selected book identificators, i.e. int[] ids or List<int> ids
    // (in case of string ISBN key we would use IList<string>)
    ViewBag.BooksList = db.Books.ToMultiSelectList(ids);

    // the same, but with all items to choose from
    ViewBag.BooksList = db.Books.ToMultiSelectList(ids, false);

现在注意 reader.FavoriteBooksdb.Books 之间的区别。前者是 ICollection,后者是 IQueryable。当你进行此调用时...

    ViewBag.BooksList = db.Books.ToMultiSelectList();

...你将获得完整的书籍列表,没有选中任何项目(对于 ICollection,你只会获得选中的项目)。

里面有什么

我想你现在有一些问题,我会尝试回答它们。首先……

所有列表如何排序

到目前为止,默认顺序一直应用于所有需要的地方。默认顺序只是按 [Name] 属性升序排序。当然,有些情况下这种行为不合适。你有一个选项(甚至几个选项)来处理它。

首先,你可以更改模型的默认顺序。这可能有点棘手,但好消息是只需要做一次。

让我们稍微修改一下我们的 Book 模型

    public class Book
    {
        public int ID { get; set; }

        [IDNameObjectProperty("Name")]
        public string Title { get; set; }

        public int Year { get; set; }

        // ...some more properties that are of no importance right now
    }

假设我们希望默认顺序如下:首先按年份,然后按标题。我们可以通过向我们的模型添加一个类方法来实现

    [IDNameObjectMethod("OrderBySelector")]
    public static Expression<Func<T, object>> TypeHereAnyNameYouWant<T>()
        where T : Book
    {
        return b => b.Year.ToString() + b.Title;
    }

注意 1:你可以将此方法命名为 OrderBySelector,并省略属性

    public static Expression<Func<T, object>> OrderBySelector<T>()
        where T : Book
    {
        return b => b.Year.ToString() + b.Title;
    }

注意 2:实际上你可以稍微简化一下,不使用泛型,像这样

    public static Expression<Func<Book, object>> OrderBySelector()
    {
        return b => b.Year.ToString() + b.Title;
    }

但是,如果你打算从 Book 类派生一个新模型并为其使用相同的排序方法,你应该使用泛型版本。

注意 3:这里你仅限于使用 LINQ 支持的表达式。另一方面,我不明白为什么你不能在这里玩表达式树。

正如你所注意到的,我们实际上将两个不同的 order-by 表达式合并到一个中。从数据库的角度来看,这可能不太好,所以有一个第二个选项,它也更用户友好

    [IDNameObjectMethod("Order")]
    public static IQueryable<T> SomeMethod<T>(IQueryable<T> set)
        where T : Book
    {
        return set
            .OrderBy(b => b.Year)
            .ThenBy(b => b.Title);
    }

在这里,你可以组合任何 EF Fluent API 排序扩展方法。

但是有一个缺点:这样定义的默认顺序将无法在处理 ICollection 的 IDNameObjects API 方法中使用(也许我将来会修复它)。

第三个选项:使用显式排序。大多数 API 方法都支持可选的 lambda 表达式参数,如下所示

    var select2result = db.Books.ToSelect2PageResult(search, pageNumber, pageSize, b => b.Year.ToString() + b.Title);
    // ...
    ViewBag.BooksList = db.Books.ToMultiSelectList(ids, b => b.Year.ToString() + b.Title);

显式排序在默认排序不适合你的情况下可能很有用。例如,你想在多选列表中以一种顺序显示选定的书籍,但当你组合 ajax 响应来填充可用选项时,你希望项目以另一种方式排序。

是的,这里再次存在限制(一个 order-by 升序表达式),但我的猜测它对大多数情况来说已经足够了。但是,如果有一个非常特殊的情况,你可以尝试第四个选项,即结合 IDNameObjects 扩展方法和标准的 EF fluent API。我稍后会向你展示如何做到这一点。

数据是如何过滤的

默认情况下(隐式地),当你向 IDNameObjects API 方法传递字符串 "search" 参数时,这意味着 [Name] 属性应该包含此参数作为子字符串,不区分大小写。此外,当此搜索参数“为 null 或空”时,过滤器将返回所有项目而不是零个。我认为这种行为在大多数情况下是合适的。但是,当然,情况总是有例外的。那么,如何自定义过滤?

第一个选项:将默认的 [Name] 属性过滤从 String.Contains() 方法切换到 String.StartsWith() 方法。这可以通过此类属性实现

    [IDNameObject(NameSearchType = "StartsWith")]
    public class Book
    {
        // ...
    }

总的来说,目前只支持这两种方法。但是,如果你因为某种原因想更改默认的名称过滤行为,你可以这样做(第二个选项

   [IDNameObjectMethod("NameContainsPredicate")]
    public static Expression<Func<T, bool>> SomeMethod<T>(string filterString)
        where T : Book
    {
        // Check for strict equality, for example
        return b => b.Title.Equals(filterString);
    }

另一个默认过滤应用于 [ID] 属性值的 IList。自然,它被“翻译”成 SQL "IN" 操作符(例如 "WHERE [ID] IN (23, 56, 4)"),在 C# 中意味着使用 List.Contains() 方法。此外,默认过滤器足够智能,可以处理特殊情况,例如

  • IList 对象为 null(不会抛出异常,但会导致 false 表达式,即返回空结果集);
  • IList 对象为空(相同);
  • IList 对象只包含一个项目(它会生成一个简单的相等表达式,在我看来,这比 IN 操作符更可取)。

但是你可以选择手动更改此行为,方法是向你的模型添加一个适当的方法,如下所示

    [IDNameObjectMethod("IDsInListPredicate")]
    public static Expression<Func<T, bool>> SomeOtherMethod<T>(IList<int> ids)
        where T : Book
    {
        // here you have to invent an alternative filtering
        return b => ids.Contains(b.ID);
    }

复合命名怎么办

在许多情况下,[Name] 属性不会很简单,而是复合的。考虑以下示例

    public class Person
    {
        public int ID { get; set; }

        [Required]
        public string FirstName { get; set; }

        public string MiddleName { get; set; }
        private string middleNamePart { get { return (MiddleName?.Length ?? 0) > 0 ? $" {MiddleName}" : ""; } }

        [Required]
        public string LastName { get; set; }

        [IDNameObjectProperty("Name")]
        public string FullName
        {
            get
            {
                return $"{FirstName}{middleNamePart} {LastName}";
            }
        }

        // plus some other properties
    }

我们的 [Name] 属性是由另外三个属性组合而成的,重要的是,它没有映射到数据库。这意味着,在数据库表中(名为 Persons 或 People),有 FirstName、MiddleName 和 LastName 字段,但没有 FullName 字段。在大多数情况下,这没问题,但如果你尝试在 LINQ 表达式中使用 FullName,你将得到一个运行时错误。结果,IDNameObjects API 将无法应用其过滤和排序方法,这很令人恼火。

我们该怎么办?嗯,你已经知道如何自定义默认行为,所以这似乎是一个选项。是的,这是对于默认排序方法。像这样就可以做到

   public static Expression<Func<T, object>> OrderBySelector<T>()
        where T : Person
    {
        // here we chose to sort by last name first and then by first and middle names
        return p => p.LastName + " " + p.FirstName + " " + (p.MiddleName ?? "");
    }

真正的痛苦在于……嗯,每个开发人员身体那个重要部位——是复合名称过滤。编写一个 String.Contains() 的类似物,它使用你不能连接的几个字符串,并且不带有 LINQ 的限制,这已经足够难了。

替代的解决方案是强制 EF 创建一个计算字段(列),这样 IDNameObjects API 就可以在数据库级别使用它。有一个小文章介绍了如何实现这一点。

在我们的例子中,我们可以得到类似这样的结果

    // This is a new [calculated] property for our default filtering

    [DatabaseGenerated(DatabaseGeneratedOption.Computed)]
    [IDNameObjectProperty("NameDB")]
    public string SearchName { get { return $"{FirstName} {LastName}"; } private set { } }

并且不要忘记,在你的迁移文件中,你应该添加类似这样的内容

    Sql("ALTER TABLE dbo.Persons ADD SearchName AS FirstName + ' ' + LastName");

请注意,为了简化,我们跳过了 MiddleName(假设我们实际上不想按中间名搜索)。另请注意 IDNameObjectProperty("NameDB") 属性。这意味着你的模型实际上不仅有 [ID] 和 [Name] 属性,还有一个额外的 [NameDB] 属性。[Name] 属性被认为是虚拟的,而 [NameDB] 是实际的数据库数据。

复合键支持怎么样

不,还没有这样的东西。而且很可能也不会有。

如何使用 fluent API

到目前为止,我们实际使用的是一些高级 API 方法(用于创建选择列表和准备 Select2 的数据)。这些方法属于 Web API 部分(命名空间 IDNameObjects.Mvc)。

但还有更多(命名空间 IDNameObjects)。以下是一些示例。

QuickList 方法

这些方法使用默认的过滤和排序方法并将结果放入列表中

    // QuickList for name property filter
    var selectedBooks = db.Books.QuickList(search);
    
    // the same - for IList of identificators
    var selectedBooks = db.Books.QuickList(ids);
    
    // search could be paginated
    var selectedBooks = db.Books.QuickList(search, pageNumber, pageSize);

    // or like this (default pageSize is determined by a class-level attribute like [IDNameObject(PageSize = 20)])
    var selectedBooks = db.Books.QuickList(search, pageNumber);

    // additionally you can specify alternate order expression
    var selectedBooks = db.Books.QuickList(search, pageNumber, b => b.Year.ToString() + b.Title);

注意:selectedBooks 变量的类型将是 IList<Book>

QuickQuery 方法

如果你愿意,你可以应用过滤、排序和分页(即准备一个查询),但还没有创建列表

    // QuickQuery for name property filter
    var selectedBooksQuery = db.Books.QuickQuery(search);
    
    // search could be paginated
    var selectedBooksQuery = db.Books.QuickQuery(search, pageNumber, pageSize);

    // ...etc.

注意:selectedBooksQuery 变量的类型将是 IQueryable<Book>,因此你可以将其与标准的 EF fluent API 等一起使用。

默认过滤/排序方法。分页

上面的方法实际上是基于默认过滤(DefaultWhere)和排序(DefaultOrder)方法,以及一个 Page 方法。你也可以使用它们。

    // Here is the sample of special order (mix of standard EF fluent API an IDNameObjects API)
    var selectedBooks = db.Books
                            .DefaultWhere()
                            .OrderByDescending(b => b.Year)
                            .ThenBy(b => b.Title)
                            .Page(pageNumber, pageSize)            // pages are numbered from 1 for convenience
                            .ToList();

    // Compare it with the standard EF fluent API only
    var selectedBooks = db.Books
                            .Where(b => b.Title.Contains(search))
                            .OrderByDescending(b => b.Year)
                            .ThenBy(b => b.Title)
                            .Skip((pageNumber-1) * pageSize)
                            .Take(pageSize)
                            .ToList();

嗯,在这个例子中,我们似乎并没有从 IDNameObjects API 中获得太多好处,但不要忘记“search 为 null 或空”的情况。

特殊过滤
  • WhereID(bookID) - 相当于 Where(b => b.ID == bookID)
  • WhereIDsIn(ids) - 这实际上是 DefaultWhere(ids)
  • WhereIDsNotIn(ids) - 我希望这个名字不言而喻
  • WhereNameContains(search) - 与 DefaultWhere(search) 相同
  • WhereNameStartsWith(search) - 这可以DefaultWhere(search) 相同,但你必须为此设置 [IDNameObject(NameSearchType = "StartsWith")] 属性

好吧,也许你不会经常使用这些,但请注意,你可以将它们与标准的 fluent API 一起使用

    var selectedBooks = db.Books.WhereIDsNotIn(ids).WhereNameContains(search).OrderBy(b => b.Title).ToList();

优化

在某些情况下,你并不真的需要从数据库获取模型的所有数据。让我们记住我们的选择列表:为了正常工作,它们真正需要的是 ID+Name 对。嗯,这不完全正确:当你的模型由复合 [Name] 属性组成时,你应该检索更多数据。但在简单的情况下,是的。

实际上,像 ToSelect2PageResultToSelectListToMultiSelectList 这样的方法会自动检索所需的最少数据(即 ID+Name),如果可能的话,所以你在这里不必费心。但如果你愿意,你也可以使用 AsSimpleINOs()QuickSimpleINOQuery() 等方法。它们与仅包含 IDName 两个属性的 SimpleIDNameObject 类一起工作。借助它们,你可以稍微优化数据库负载。

还有更多

有一些有用的辅助方法,例如按默认过滤器计数项目或从项目列表中创建 ID 列表。有关详细信息,请参阅API 子文件夹。

异步进行

几乎忘了:对于许多先前提到的方法,都有一个异步“表亲”,以防万一。像平常一样使用它们

    var select2result = await db.Books.ToSelect2PageResultAsync(search, pageNumber, pageSize);

可以在哪里额外使用

我认为它与 Windows 列表控件配合得很好,但我不知道有任何支持部分数据检索的控件。如果有,我相信我可以编写额外的 IDNameObjects Windows API。

我也成功地将其与 Martijn Boland 的 Mvc.Paging 工具一起使用(这个分页器不需要加载数据库中的所有数据,这很好)。

这是一个例子(我们以异步方式进行)

    private async Task<IPagedList<Book>> GetCurPageListAsync(string search, int pageNumber, int pageSize)
    {
        // total number of books filtered by search substring
        int totalCnt = await db.Books.DefaultCountAsync(search);

        // list of filtered books on current page
        var curPageList = await db.Books.QuickListAsync(search, pageNumber, pageSize);

        // here we call an Mvc.Paging method that creates a paged list from a list of books
        // Note that in Mvc.Paging pages are numbered from 0
        return curPageList.ToPagedList(pageNumber-1, pageSize, totalCnt);
    }

安装

你需要另外安装 Entity Framework 包,对于 API 的 Web 部分,还需要安装 ASP.NET MVC。

这应该可以在 .NET 4.6 和 MSSQL Server 上很好地工作。尚未在 .NET Core 上进行测试。

历史

这是文章的第 1 版。

最新版本的代码应该可以在 GitHub 上找到

© . All rights reserved.