开发人员正在研发的许多项目都涉及编写由基本的 SELECT/FROM/WHERE 类型的语句派生而来的复杂 SQL 语句。其中一种情形是需要编写在 FROM 子句内使用派生表(也称为内联视图)的 Transact-SQL (T-SQL) 查询。这一常规使开发人员能获取一个行集,并立即将该行集加入到 SELECT 语句中的其他表、视图和用户定义函数中。另一种方案是使用视图而不是派生表。这两种方案都有其各自的优势和劣势。
当使用 SQL Server™ 2005 时,我更倾向于第三种方案,就是使用通用表表达式 (CTE)。CTE 能改善代码的可读性(以及可维护性),且不会有损其性能。此外,与早期版本的 SQL Server 相比,它们使得用 T-SQL 编写递归代码简单了许多。
首先,我将介绍 CTE 的工作原理以及可用它们来应对的情况。接着我将讨论使用 CTE 相对于使用传统的 T-SQL 构造的优势,如派生表、视图和自定义过程。在本期专栏中,我将给出示例并解释它们的使用方法和适用情况。我还将演示 CTE 是如何处理递归逻辑并定义递归 CTE 的运行方式的。请注意,我在本专栏中讨论的所有代码都可从《MSDN® 杂志》网站下载获得,而且它们还使用 SQL Server 2005 附带的 Northwind 和 AdventureWorks 数据库。
视图、派生表和 CTE
如果查询需要在一组数据中进行选取,而这些数据在数据库中并不是以表的形式存在,则 CTE 可能非常有用。例如,您可能想要编写一个针对一组聚合数据的查询,该聚合数据基于客户及其订单来计算值。这些聚合数据可能会将 Customers、Orders 和 Order Details 表联接在一起,以计算订单的总和以及平均值。此外,您可能想要查询聚合的行集。一个方法是创建一个视图,首先收集聚合数据,然后针对该视图编写一个查询。另一个方法是使用派生表针对聚合数据编写一个查询 通过将 SQL 语句移到 FROM 子句中并对其进行查询,可实现这一点。
视图通常用来分解大型的查询,以便用更易读的方式来查询它们。例如,一个视图可以表示一个 SELECT 语句,该语句会将 10 个表联接起来,选择许多列,然后根据涉及的一组逻辑来过滤行。接着,可以通过其他 SELECT 语句在整个数据库中查询该视图。此抽象使由该视图表征的行集更容易访问,而且无需在临时表中复制或存储数据。
假定权限许可,这个视图还能在整个数据库中被重复使用。例如,在图 1 中,已经创建了一个视图,并为另一个 T-SQL 语句所使用。然而,当您想要收集数据并且只使用一次的时候,视图未必是最佳解决方案。由于视图是存在于数据库中、适用于所有批处理的数据库对象,那么创建仅用于单个 T-SQL 批处理的视图就有些多余。
Figure 1 被查询的视图
1 2 CREATE VIEW vwMyView AS 3 SELECT 4 EmployeeID, COUNT(*) AS NumOrders, MAX(OrderDate) AS MaxDate 5 FROM Orders 6 GROUP BY EmployeeID 7 GO 8 9 SELECT 10 e.EmployeeID, oe.NumOrders, oe.MaxDate, e.ReportsTo AS ManagerID, 11 om.NumOrders, om.MaxDate12 FROM 13 Employees AS e14 INNER JOIN vwMyView AS oe ON e.EmployeeID = oe.EmployeeID15 INNER JOIN vwMyView AS om ON e.ReportsTo = om.EmployeeID
另一种方法是创建派生表(也称为内联视图)。要创建派生表,在由括号包围的 FROM 子句中移动 SELECT 语句即可。接着就能像表或视图一样查询或者联接它。图 2 中的代码解决的查询与图 1 所解决的相同,但使用的是派生表而不是视图。尽管只能在派生表所在的语句中访问它们,但是,表通常使查询变得更难以阅读和维护。如果想要在同一个批处理中多次使用派生表,此问题会变得更加严重,因为随后必须复制和粘贴派生表才能重复使用它。
Figure 2 使用派生表的查询
1 SELECT 2 e.EmployeeID, oe.NumOrders, oe.MaxDate, e.ReportsTo AS ManagerID, 3 om.NumOrders, om.MaxDate 4 FROM 5 Employees AS e 6 INNER JOIN 7 (SELECT EmployeeID, COUNT(*), MAX(OrderDate) 8 FROM Orders 9 GROUP BY EmployeeID) AS oe(EmployeeID, NumOrders, MaxDate)10 ON e.EmployeeID = oe.EmployeeID11 LEFT JOIN 12 (SELECT EmployeeID, COUNT(*), MAX(OrderDate)13 FROM Orders14 GROUP BY EmployeeID) AS om(EmployeeID, NumOrders, MaxDate)15 ON e.ReportsTo = om.EmployeeID
CTE 非常适用于此类情形,因为它提升了 T-SQL 的可读性(就像视图一样),而且能在同一个批处理后紧跟的查询中多次使用。当然,超出该范围它就不适用了。另外,CTE 是语言级别的构造,也就是说 SQL Server 不会在内部创建临时表或虚拟表。每次在紧随其后的查询中引用 CTE 的底层查询时都会调用它。
因此,同样的情形也能用 CTE 来编写,如图 3 所示。EmpOrdersCTE 收集聚合数据,然后在紧随 CTE 之后的查询中使用该数据。使用 CTE 之后,图 3 中的代码令查询变得非常易读(就像视图一样),而且并没有创建系统对象来存储元数据。
Figure 3 使用 CTE 查询
1 ;WITH EmpOrdersCTE (EmployeeID, NumOrders, MaxDate) AS 2 ( 3 SELECT EmployeeID, COUNT(*), MAX(OrderDate) 4 FROM Orders 5 GROUP BY EmployeeID 6 ) 7 8 SELECT 9 e.EmployeeID, oe.NumOrders, oe.MaxDate,10 e.ReportsTo AS ManagerID, om.NumOrders, om.MaxDate11 FROM 12 Employees AS e13 INNER JOIN EmpOrdersCTE oe ON e.EmployeeID = oe.EmployeeID14 LEFT JOIN EmpOrdersCTE om ON e.ReportsTo = om.EmployeeID
CTE 的结构
现在我将用一个简单的 CTE 为例来演示如何构造 CTE。CTE 以 WITH 关键字开始。然而,如果 CTE 不是批处理中的第一个语句,则必须在 WITH 关键字前添加一个分号。作为最佳做法,我倾向于在所有的 CTE 之前都加上一个分号作为前缀,我发现这种一致的方式比起必须牢记是否需要添加分号来,要容易得多。
WITH 关键字后面是 CTE 的名称,接着是一个列别名的可选列表。列别名对应于 CTE 内的 SELECT 语句返回的列。可选列别名的后面是 AS 关键字,这是必需的。AS 关键字后面是用括号括起来、定义 CTE 的查询表达式。
请看这个示例:
1 ;WITH myCTE (CustID, Co) AS2 (3 SELECT CustomerID, CompanyName FROM Customers4 )5 SELECT CustID, Co FROM myCTE
CustomerID 和 CompanyName 列的别名为 CustID 和 Co。接着跟随 CTE 其后的是通过列别名引用 CTE 的 SELECT 语句。
理解 CTE
在设计 CTE 之前,必须理解它的工作原理和遵循的规则。本章节描述了 CTE 的适用情况,以及在 CTE 内什么是可以使用的,什么是不可以使用的。对于初学者来说,可以在 T-SQL 批处理、用户自定义函数、存储过程、触发器或视图中创建并使用 CTE。
CTE 仅能被紧随其后的语句所引用。这意味着如果要使用 CTE,则必须紧随 T-SQL 批处理中的 CTE 之后编写引用 CTE 的查询。例如,以下批处理会产生错误:
1 ;WITH myCTE (CustID, Co) AS2 (3 SELECT CustomerID, CompanyName FROM Customers4 )5 SELECT CompanyName FROM Customers WHERE CustomerID = 'ALFKI'6 SELECT CustID, Co FROM myCTE
在本段代码中,myCTE 仅供紧随其后的第一个查询使用。当第二个查询引用 myCTE 时,CTE 不在范围之内,并且引发异常(对象名“myCTE”无效)。
另请注意,因为 CTE 预期由另一个可能随之肯定要重新处理数据的查询引用,所以 CTE 的查询不能含有 ORDER 和 COMPUTE 之类的语句。然而,诸如 FOR XML 的复杂语句仍可用来定义和运行 CTE。例如,您可以使用 FOR XML 子句来查询 CTE 并返回其结果,如下所示。
1 ;WITH myCTE AS2 (3 SELECT c.CustomerID, c.CompanyName, o.OrderID, o.OrderDate4 FROM Customers c 5 INNER JOIN Orders o ON c.CustomerID = o.CustomerID6 )7 8 SELECT CustomerID, CompanyName, OrderID, OrderDate 9 FROM myCTE FOR XML AUTO
一旦定义了 CTE,跟随其后的首个查询便能多次引用它。这一功能在某个查询需要多次引用 CTE 时尤为有用。图 3 中的代码示例演示了查询如何引用 EmpOrdersCTE 两次,以便能获取员工和主管的信息。当需要多次引用同一行集时,这非常有用;引用 CTE 两次比复制该查询要简单得多。
CTE 并不一定由 SELECT 语句使用;任何引用 CTE 所生成行集的语句都可使用它。这意味着 CTE 后面可以跟随使用 CTE 的 SELECT、INSERT、UPDATE 或 DELETE 语句。您也可以在使用 CTE 的查询中使用只进和快照光标。
此外,CTE 后面也可以跟随另一个 CTE。在想要把中间结果聚集到行集时,可使用这种技术从其他 CTE 构建 CTE。当创建从其他 CTE 构建的 CTE 时,请用逗号分隔 CTE 的定义。
图 4 所示的示例定义了 EmpOrdersCTE,它收集了一个员工列表和每个员工的订单总数。第二个 CTE 的名称是 MinMaxOrdersCTE,它查询第一个 EmpOrdersCTE 并在行集上执行聚合函数来确定员工订单的平均数、最小数和最大数。
Figure 4 引用另一个 CTE 的 CTE
;WITH EmpOrdersCTE (EmployeeID, NumOrders)AS( SELECT EmployeeID, COUNT(*) FROM Orders GROUP BY EmployeeID),MinMaxOrdersCTE (Mn, Mx, Diff)AS( SELECT MIN(NumOrders), MAX(NumOrders), AVG(NumOrders) FROM EmpOrdersCTE)SELECT Mn, Mx, DiffFROM MinMaxOrdersCTE
通过用逗号来分隔,在 WITH 关键字后面可以定义多个 CTE。每个 CTE 都可以被紧随其后的 CTE 所引用,形成层接的构建关系。CTE 定义后面的数据操作语言 (DML) 语句也可引用 WITH 子句中定义的任何 CTE。
递归规则
CTE 还可用于实现递归算法。在需要编写调用其本身的算法时,递归逻辑很有用——这通常用来遍历一组嵌套的数据。编写递归逻辑可能很复杂,特别是使用 T-SQL 之类的语言的时候。然而,这正是 CTE 旨在解决的特别问题之一。创建递归 CTE 的基本公式如下所示:
- 创建一个返回顶层(这是定位点成员)的查询。
- 编写一个递归查询(这是递归成员)。
- 通过 UNION 将第一个查询与递归查询结合起来。
- 确保存在没有行会被返回的情况(这是终止检查)。
您的递归 CTE 如下所示:
1 ;WITH myRecursiveCTE(col1, col2, ... coln) AS2 ( 3 -- 定位点成员 查询4 UNION ALL5 -- 递归成员 查询来自CTE的自身数据6 )
当编写不涉及 CTE 的自定义递归过程时,必须包含一个显式终止子句。这个终止子句负责确保递归算法最后将终止,并弹出了递归调用堆栈。若无此子句,您的代码最终将无限循环下去。
CTE 可从两个方面帮助处理终止子句。首先是一个隐式终止子句,当递归成员返回零记录时出现。此时,递归成员查询不会递归调用 CTE,取而代之的弹出了递归调用堆栈。其次是能显式设置 MAXRECURSION 层。
MAXRECURSION 层可以在含有 CTE 的批处理中或通过服务器端设置(服务器范围的设置默认值为 100,除非您更改它)显式设置。这个设置限制了 CTE 可递归调用其本身的次数。如果达到限制次数,则将引发异常。设置 MAXRECURSION 层的语法是在 SELECT 语句中的 CTE 后面使用 OPTION 子句,如下所示:
-- DEFINE YOUR CTE HERESELECT * FROM EmpCTEOPTION (MAXRECURSION 7)
在设计递归 CTE 时,还有一些其他规则也得铭记在心。递归 CTE 必须包含定位点成员和递归成员。这两种成员必须拥有相同数量的列,而且同属于这两种成员的列必须具有匹配的数据类型。递归成员只能引用 CTE 一次,并且成员不能使用下列子句或关键字:
- SELECT DISTINCT
- GROUP BY
- HAVING
- TOP
- LEFT/RIGHT OUTER JOIN
递归偏移
在数据和行集方面,递归用于解决您需要在不同情况下针对同一组数据重复执行同一逻辑时所遇到的问题。例如,假设您需要找出所有销售人员以及他们的上级,然后以分层顺序返回数据。图 5 演示了一个使用 CTE 的解决方案,该方案通过递归来收集销售副总裁下属的员工的列表。
Figure 5 递归收集销售人员
1 ;WITH EmpCTE(EmployeeID, EmployeeFirstName, 2 EmployeeLastName, MgrID, SalesLevel) 3 AS 4 ( 5 -- 定位点成员 6 SELECT EmployeeID, FirstName, LastName, ReportsTo, 0 7 FROM Employees 8 WHERE EmployeeID = 2 -- Start with the VP of Sales 9 10 UNION ALL11 12 -- 递归成员13 SELECT 14 e.EmployeeID, e.FirstName, e.LastName, e.ReportsTo, m.SalesLevel+115 FROM 16 Employees AS e17 INNER JOIN EmpCTE m ON e.ReportsTo = m.EmployeeID18 )19 20 -- 使用CTE21 SELECT EmployeeID, EmployeeFirstName, EmployeeLastName, 22 MgrID, SalesLevel 23 FROM EmpCTE
除了少数其他方面,图 5 中所示的递归 CTE 看起来很像标准 CTE。其中标准 CTE 包含一个定义行集的查询,递归 CTE 定义两个查询定义。第一个查询定义(即定位点成员)定义了将在调用 CTE 时执行的查询。第二个查询定义,即递归成员,定义了一个返回与定位点成员相同的列和数据类型的查询。递归成员还检索接下来将被用于递归回调到 CTE 的值。查询的结果通过 UNION 语句结合在一起。
图 5 中的 EmpCTE 显示了收集销售副总裁的员工记录的定位点成员 (EmployeeID = 2)。定位点成员查询的最后一列返回 0 值,这表示分层顺序的第 0 层,也就是最顶层。递归成员查询收集向其各自上级汇报的员工的列表。这通过联接 Employees 表和 EmpCTE 来实现。
从递归成员中可以检索相同的列,但是 SalesLevel 列的计算方式是:取当前员工的主管,收集主管的 SalesLevel,然后在其基础上增加 1。表达式 m.SalesLevel+1 为所有直接向销售副总裁汇报的员工(从定位点成员中检索到)的 SalesLevel 赋值 1。然后,所有向那些员工汇报的员工的 SalesLevel 值变为 2。对随后销售机构层次结构中的每一后续层次,其 SalesLevel 都以此方式递增。
补充: 目前仅有Sql Server 2005、2008、2012、Oracle11g、IBM DB2和PostegreSQL8.4支持CTE;
MySQL、SQLite和Infomix暂时不支持。
结束语
比起那些在查询中使用复杂的派生表或引用那些在 T-SQL 批处理外部定义的视图的方案,CTE 使得编写 T-SQL 更具可读性。此外,CTE 还为解决使用递归算法的过程中遇到的难题提供了一个更先进的工具。不管您使用的是非递归 CTE 还是递归 CTE,您都会发现 CTE 可以帮您应对许多常见开发情况,并且可在不损害性能的情况下提升可读性。