IDNameObjects API






4.50/5 (2投票s)
用于 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 对象(它应该有两个属性:id 和 text)的方法,或者你可以在调用 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 中,通常有 SelectList
和 MultiSelectList
类可以做到这一点。如果你愿意,你可以直接使用它们。或者,你可以使用 IDNameObjects 扩展。
在向你展示示例之前,我必须提醒你,我们不必将所有数据都传递给客户端。我们所需要的是获取当前选中的项目(或者,在多选列表中,是项目)。其余的将通过 GetBooks 方法(见上文)按需绘制。
让我们从 [单选] 列表开始(假设我们只需要选择一本最喜欢的书)。假设,已经选择了一本这样的书(并存储在数据库中),我们想让用户更改他的/她的选择。所以,我们这样做
Book book;
// Here should be some code to get the current book from the DB
ViewBag.BooksList = book.ToSelectList();
就是这样。真的。我是认真的。
好吧。让我们假装我们还没有 Book
对象,只有一个 bookID
(或者,如果你喜欢,isbn)值。还记得 DbContext
和 DbSet<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.FavoriteBooks
和 db.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] 属性组成时,你应该检索更多数据。但在简单的情况下,是的。
实际上,像 ToSelect2PageResult
、ToSelectList
和 ToMultiSelectList
这样的方法会自动检索所需的最少数据(即 ID+Name),如果可能的话,所以你在这里不必费心。但如果你愿意,你也可以使用 AsSimpleINOs()
和 QuickSimpleINOQuery()
等方法。它们与仅包含 ID
和 Name
两个属性的 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 上找到。