毫无疑问,ADO.NET 向人们提供了一种功能强大、仿真数据库的对象模型,它可以将数据记录保存到内存中。尤其是ADO.net 的 DataSet 类,它不但在功能上相当于数据库表的集中存储器(central repository),而且支持表间的各种约束和逻辑关系。进一步说来,DataSet 对象其实是一种离线数据容器。
乍一看,只要把 DataSet 类的所有特性联合起来,就能消除 SQL 查询命令中的复杂子句,比如那些泛滥成灾且层层嵌套的 INNER JOIN子句或者 GROUP BY 子句等。复杂的子句可以分解成两个或更多个相互独立的简单子句,而将每个简单子句的查询结果分别保存在不同的 DataTable 对象中;以后只要分析这些内存数据之间的约束和逻辑关系,就能重建原先表之间必要的“参照完整性”(referential integrity)。
举个例子:你可以把客户(Customers)表与订单(Orders)表分别保存到两个不同的 DataTable 对象中,然后通过 DataRelation 对象进行绑定 (bind) 。这样, SQL Server (或其它 DBMS 系统) 就免除了 INNER JOIN 子句带来的沉重负担;更重要的是,网络传输负荷也因此而大大减轻。象这样简化 SQL 查询的方案固然行之有效,却并不一定总是最佳选择,尤其是当你的数据库规模庞大而且更新频繁时。
本文将为大家介绍另一种用于简化 SQL 查询的技术,它充分利用 ADO.NET 的内存数据对象减轻了用户和 DBMS 系统的负担。
分解 SQL 查询命令
许多有关 ADO.NET 的书籍,比如 David Sceppa 的大作《Programming ADO.NET Core Reference》(微软出版社),都建议把复杂的 SQL 查询命令分解成若干简单的子查询,然后把各个子查询的返回结果分别保存到同一个 DataSet 容器内部的若干个 DataTable 对象中。请看一个实例。
假设你需要获取一些客户订单信息,要求订单是提交于指定年份而且按客户进行分组,还要求订单中至少包含 30 件商品。同时,你还希望获取每个订单的提交者(employee)名字以及客户(customer)的公司名。你可以用下列 SQL 查询语句来实现它:
DECLARE @TheYear int
SET @TheYear = 1997
SELECT o.customerid, od.orderid, o.orderdate, o.shippeddate,
SUM(od.quantity*od.unitprice) AS price,
c.companyname, e.lastname FROM Orders AS o
INNER JOIN Customers AS c ON c.customerid=o.customerid
INNER JOIN Employees AS e ON e.employeeid=o.employeeid
INNER JOIN [Order Details] AS od ON o.orderid=od.orderid
WHERE Year(o.orderdate) = @TheYear AND od.orderid=o.orderid
GROUP BY o.customerid, c.companyname, od.orderid,
o.orderdate, o.shippeddate, e.lastname
HAVING SUM(od.quantity) >30
ORDER BY o.customerid
暂且抛开你所用的 ADO 或者 ADO.NET吧。用最原始的命令提交方式执行上述 SQL 查询,可以看到如图 1 所示的结果集:
图 1. 第一个 SQL 查询命令的输出结果,由 SQL Server Query Analyzer 生成并显示。
在本次查询中,以一条子句为核心,而另外两条 INNER JOIN 子句起辅助作用。核心子句的功能是从数据库中查询所有提交于指定年份、至少包含 30 件商品的订单。核心子句如下:
SELECT o.customerid, o.orderid, o.orderdate,
o.shippeddate, SUM(od.quantity*od.unitprice) AS price, o.employeeid
FROM orders AS o
INNER JOIN [Order Details] AS od ON o.orderid=od.orderid
WHERE Year(o.orderdate) = @TheYear AND od.orderid=o.orderid
GROUP BY o.customerid, o.orderid, o.orderdate, o.shippeddate,
o.employeeid
HAVING SUM(od.quantity) >30
ORDER BY o.customerid
在返回结果集中,客户和提交者均用 ID 来表示。然而,本例需要的是客户的公司名(compayname)和提交者的名字(lastname)。末尾的 ORDER BY o.customerid 语句显得特别简单,可是其功能却很重要:由于客户公司名和提交者名字所含的字符较多,使用该语句就能避免它们的重复出现,从而得到更紧凑的结果集。
综上所述,整个 SQL 查询可以被分解成 3 条子查询命令—— 1 条核心子查询,用于获取订单记录;2 条辅助子查询,用于建立提交者ID - 提交者名字和客户ID - 客户公司名两个对照表,即:
SELECT employeeid, lastname FROM Employees
SELECT customerid, companyname FROM Customers
以下 ADO.NET 代码演示了如何把这 3 条子查询的返回结果集保存到 DataSet 对象中。
Dim conn As SqlConnection = New SqlConnection(connString)
Dim adapter As SqlDataAdapter = New SqlDataAdapter()
conn.Open()
adapter.SelectCommand = New SqlCommand(cmdCore, conn)
adapter.SelectCommand.Parameters.Add("@TheYear", 1997)
adapter.SelectCommand.Parameters.Add("@TheQuantity", 30)
adapter.Fill(ds, "Orders")
adapter.SelectCommand = New SqlCommand(cmdCust, conn)
adapter.Fill(ds, "Customers")
adapter.SelectCommand = New SqlCommand(cmdEmpl, conn)
adapter.Fill(ds, "Employees")
conn.Close()
请注意:在连续执行 SQL 查询命令时,你通常都要自行操作数据库连接,以免出现多余的open/close 操作。本例的 adapter.Fill 方法会自动执行 open/close 操作,除非你设置 adapter.SelectCommmand 属性把某个连接显式关联到 adapter 对象之上。
为了建立内存数据表之间的关系链,你可以创建两个关系,把 employeeid (提交者的ID) 关联到 lastname (提交者的名字),把 customerid (客户的ID) 关联到 companyname (客户的公司名)。一般情况下,可以用 DataRelation 对象在同一 DataSet 对象内创建两个独立表之间的一对多关系。然而,本例却需要建立多对一关系,这是很少见的。其实,只要把一对多关系中的父表(Orders) 变成子表,而把子表(Employees、Customers) 变成父表就行了。
图 2. 关系中的父表与子表角色互换
ADO.NET 中的 DataRelation 对象相当灵活,足以构建多对一关系。每生成一个DataRelation 对象,ADO.NET 都会在后台为之建立一致性约束,以免父表内的键值重复。当然了,一旦重复的键值出现,ADO.NET 就会抛出(throw)一个例外。请看下列代码:
Dim relOrder2Employees As DataRelation
relOrder2Employees = New DataRelation("Orders2Employees", _
ds.Tables("Orders").Columns("employeeid"), _
ds.Tables("Employees").Columns("employeeid"))
ds.Relations.Add(relOrder2Employees)
此处的 DataRelation 对象构造器初始化了三个参数:第一个是关系名称,后面两个是 DataColumn 对象,分别代表构成关系的两个列(column):前一个 DataColumn 对象代表父列,后一个 DataColumn 对象代表子列。一旦构造器发现父列中不存在合法记录,便会激活(raise) 一个 ArgumentException 例外。消除此例外最简单的解决方案是在构造器中添加一个布尔值作为第四参数:
relOrder2Employees = New DataRelation("Orders2Employees", _
ds.Tables("Orders").Columns("employeeid"), _
ds.Tables("Employees").Columns("employeeid"), _
False)
当构造器的第四参数值为 false 时,ADO.NET 就不会建立一致性约束,而后者正是引发 ArgumentException 例外的罪魁祸首。
设置了数据关系之后,你就可以用列表达式 (computed column) 给 Orders 表添加两列以显示其内容了。理论上,这么做完全符合逻辑:
Dim orders As DataTable = ds.Tables("Orders")
orders.Columns.Add("Employee", GetType(String), _
"Child(Orders2Employees).lastname")
orders.Columns.Add("Customer", GetType(String), _
"Child(Orders2Customers).companyname")
可惜,它根本行不通。更糟的是,当它运行到包含 Child 的代码时,就会抛出 (throw) 一条 “句法错误”信息,而这条出错信息很容易误导程序员。(有关列表达式的更多信息,请参阅《last month's column》。)
为什么会出错?因为只有当父列存在一致性约束时,才允许在列表达式中使用 Child 关键字。这一点在开发文档中并未明确指出,然而它却是事实,而且非常重要。令人不解的是,你不但可以顺利地访问 Orders 表任一行的子元素,还能直接访问 Employees 表或 Customers 表的任一列。以下代码可以证明这一点:
Dim orders As DataTable = ds.Tables("Orders")
Dim employee As DataRow = orders.Rows(0).GetChildRows(relOrder2Employees)
MsgBox employee("lastname")
因此,所谓的“句法错误”,并不代表你无法建立多对一关系。它只是提醒你:除非事先建立一致性约束,否则就不能在列表达式中使用 Child 关键字。
在初始关系中,Orders 表是父表。然而,为了从 ID 获取提交者名字或客户公司名,你就必须改变诸表所扮演的角色:让 Orders 表充当子表,而让 Employees 表、Customers 表充当父表。为了确保做到这一点,你必须改变 DataRelation 对象构造器代码中的列名,并且象这样使用列表达式:
Dim orders As DataTable = ds.Tables("Orders")
orders.Columns.Add("Employee", GetType(String), _
"Parent(Orders2Employees).lastname")
orders.Columns.Add("Customer", GetType(String), _
"Parent(Orders2Customers).companyname")
小结:在本例中,我们把一个复杂的 SQL 查询分解成 3 个较为简单的子查询,从而消除了两个 INNER JOIN 语句,减轻了数据库服务器的负担;更重要的是,大大减少了从服务器到客户端的网络传输负荷。看来,这似乎是最好的解决方案了?
替代方案
前面的解决方案是以对照表为基础的,而且在对照表的生成过程中没有进行数据过滤。一旦对照表的规模过大,会有什么后果呢?难道你愿意为了得到区区数百个提交者的名字就从服务器下载 10,000 条记录?难道你甘心下载那一大堆冗余数据?更何况,那些冗余数据对你毫无用处!
可是,请换个角度想一想。对照表在应用程序的整个生命周期中往往都是有价值的。也换言之,虽然对单独一次查询来说,下载许多记录以构建完整的对照表未免过于奢侈,但是它对整个应用程序来说也未必不是公平交易。
既然这样,我们何不尝试用另一种技术来缩减对照表的规模呢?最容易想到的方案莫过于借助 WHERE 子句来缩小结果集了。非常不幸,此方案要么难以实现,要么效果欠佳,尤其是在对照表诸列并不包括你所要查询的对象时。例如:为了对提交者的名字进行过滤,你就必须对其它表进行联合查询——比如 Order 表和 Order Details(订单细节) 表。我认为,最佳方案是重新获取上次 SQL 查询的返回结果集,并从中解析出每个提交者的信息。也就是说,完成前述 SQL 查询之后,再次发送一个几乎相同的查询命令,令数据库服务器重新运行分解后的子查询。这样,数据库将以最小的查询代价返回完全相同的数据。更妙的是,SQL 服务器还特别设置了查询优化引擎,使得此类重复查询的代价减到最低。
SELECT DISTINCT t.customerid, t.companyname FROM
(SELECT o.customerid, o.orderid, o.orderdate, o.shippeddate,
SUM(od.quantity*od.unitprice) AS price,
c.companyname, o.employeeid
FROM orders AS o
INNER JOIN Customers AS c ON c.customerid=o.customerid
INNER JOIN [Order Details] AS od ON o.orderid=od.orderid
WHERE Year(o.orderdate) = @TheYear AND od.orderid=o.orderid
GROUP BY o.customerid, o.orderid, c.companyname,
o.orderdate, o.shippeddate, o.employeeid
HAVING SUM(od.quantity) >30) AS t
总而言之,以若干简单查询为基础而设计的资料检索代码最大的优点是:它把数据联结 (joining) 的重任由服务器转移到了客户端。另一方