扩展 C# 以支持编译时 SQL 语法






4.84/5 (28投票s)
你是否曾希望无需使用字符串或后期绑定就能真正地将 SQL 功能嵌入到你的 C# 代码中?想象一下,完全用 C# 编写复杂的 Where 子句。
背景
我经常收到评论,询问为什么在 LINQ 和 Entity Framework 已经存在的情况下我还要创建这个代码。需要注意的是,本文的发布日期是2005年中期,远在 LINQ 公布之前。因此,实际项目已不再需要,因为它已被 C# 和 VB.NET 的发展所取代。然而,该项目和文章仍然作为一种说明性示例,展示了如何扩展语言,使其超越大多数人的想象。而且,由于自2005年以来语言已经发展了许多其他功能,如今的可能性甚至更大。
引言
你是否曾希望无需使用字符串或后期绑定就能真正地将 SQL 功能嵌入到你的 C# 代码中?想象一下,完全用 C# 编写复杂的 Where
子句。
xQuery.Where =
(CustomerTbl.Col.NameFirst == "Chad" | CustomerTbl.Col.NameFirst == "Hadi")
& CustomerTbl.Col.CustomerID > 100 & CustomerTbl.Col.Tag != View.Null;
仔细看。这是 C# 代码,不是 SQL。它在编译时解析和绑定,但在运行时评估。在本文中,我将介绍这种方法以及完整的源代码供你使用。
C Omega
C Omega 是微软的一项研究性编程语言。C Omega 用于测试和尝试 C# 未来版本的新想法。C Omega 支持许多与 Indy.Data 提供的功能类似的项目。但是,Indy.Data 提供了 C Omega 中没有的一些功能。最重要的是,Indy.Data 现在可用,并且可以用于生产代码。C Omega 是一个研究项目,最多只会集成到 C# 的未来版本中。
事实上,如果我们看一下 C Omega 的一些示例代码
struct {
SqlString CustomerID;
SqlString ContactName;
}* res
= select CustomerID, ContactName from DB.Customers;
foreach( row in res ) {
Console.WriteLine("{0,-12} {1}", row.CustomerID, row.ContactName);
}
它看起来非常像 Indy.Data
[Select("select `CustomerID`, `ContactName` from `Customers`")]
public class CustomerRow : View.Row {
public DbInt32 CustomerID;
public DbString ContactName;
}
using (Query xCustomers = new Query(_DB, typeof(CustomerRow))) {
foreach (CustomerRow xCustomer in xCustomers) {
Console.WriteLine("{0,-12} {1}", row.CustomerID, row.ContactName);
}
}
Indy.Data 的语法并不完全像 C Omega,但有了 C Omega,微软拥有更改语言的完全灵活性。而 Indy.Data 则受限于 C# 的扩展方式。幸运的是,C# 的扩展能力比大多数人意识到的要大得多。
未来的文章
主程序集名为 Indy.Data,依赖于 System.Data
(ADO.NET)。我为围绕这个库的许多文章准备了笔记和计划,包括技术方面、用法以及方法论。然而,为了首先发布“想法”,我正在写这篇基础介绍性文章。我知道本文中有很多内容没有涵盖,但别担心,它们将在未来的文章中出现。
既非鱼也非肉
严格来说,Indy.Data 既不是 O/R 映射器也不是代码生成器。相反,Indy.Data 是一种不同的、同时又相似的东西。在本文中,这一点尚不完全清楚,因此,对于本文而言,请将 Indy.Data 仅视为一个数据访问库 (DAL)。
Indy.Data 支持代码生成,以使 DAL 对象与数据库保持同步,但它不会创建业务逻辑的映射,也不会在生成过程中生成自己的 SQL。
Indy.Data 也不是 O/R 映射器。Indy.Data 更灵活,因为它不限于参数化查询或存储过程。Indy.Data 也不提供查询构造和映射,而是依赖数据库中现有的对象或提供的 SQL。
Indy.Data 为数据库表、视图、存储过程或 SQL 语句提供一对一的对象包装器。这些对象可以随时重新生成,以与数据库更改保持同步。在未来的文章中,我将解释 Indy.Data 如何以略微不同的方式执行代码生成器或 O/R 映射器的相同功能。然而,在本篇文章的范围内,请假设 Indy.Data 是 ADO.NET command
对象的先进实现。Indy.Data 适用于所有可以使用 ADO.NET command
对象的地方。
极限数据库
ADO.NET 是一个很好的数据库连接库。但是,使用 ADO.NET 仍然采用标准方法,即数据连接不是类型安全的,并且与数据库的绑定是松散的。字段通过字符串字面量或数字索引进行绑定。所有类型都将被转换为所需的类型。数据库的更改将导致应用程序中的错误。然而,由于松散绑定,这些错误直到运行时才会发现。除非可以执行每一个执行点和逻辑组合的测试,否则错误将不会出现,直到客户发现它们。
因此,作为开发人员,我们已经习惯于永远不要更改数据库。这导致数据库效率低下,包含过时数据,包含重复数据,以及为了添加新功能而包含许多 hack。事实上,这是一种错误的方法,但我们都已习惯于接受这是开发中的一个事实。
然而,如果我们像处理其他代码一样使用紧密绑定的方法,我们可以升级和更新我们的数据库,使其与我们的系统共同成长。只需更改数据库,然后重新编译。您的系统将在编译时找到所有新创建的冲突,然后可以轻松地更改功能以满足新的需求。我称之为“极限数据库”或 XDB,这与极限编程或 XP 一致。
使用内置的 ADO.NET 命令从查询中读取如下所示
IDbCommand xCmd = _DB.DbConnection.CreateCommand();
xCmd.CommandText = "select \"CustomerID\", \"NameLast\", \"CountryName\""
+ " from \"Customer\" C"
+ " join \"Country\" Y on Y.\"CountryID\" = C.\"CountryID\""
+ " where \"NameLast\" = @PNameLast1 or \"NameLast\" = @PNameLast2";
xCmd.Connection = _DB.DbConnection;
xCmd.Transaction = xTx.DbTransaction;
IDbDataParameter xParam1 = xCmd.CreateParameter();
xParam1.ParameterName = "@NameLast1";
xParam1.Value = "Hower";
xCmd.Parameters.Add(xParam1);
IDbDataParameter xParam2 = xCmd.CreateParameter();
xParam2.ParameterName = "@PNameLast2";
xParam2.Value = "Hauer";
xCmd.Parameters.Add(xParam2);
using (IDataReader xReader = xCmd.ExecuteReader()) {
while (xReader.Read()) {
Console.WriteLine(xReader["CustomerID"] + ": " + xReader["CountryName"]);
}
}
使用 Indy.Data 编写的相同代码如下所示
using (CustomerQry xCustomers = new CustomerQry(_DB)) {
xCustomers.Where = CustomerQry.Col.NameLast == "Hower"
| CustomerQry.Col.NameLast == "Hauer";
foreach (CustomerQry.Row xCustomer in xCustomers) {
Console.WriteLine(xCustomer.CustomerID + ": " + xCustomer.CountryName);
}
}
首先,让我们忽略它更短的事实。标准的 ADO.NET 也可以被包装在一个对象或方法中。我想指出的是,标准的 ADO.NET 需要使用文本字符串。事实上,上面列出的代码有一个错误。“@NameLast1
”应该是“@PNameLast1
”。标准 ADO.NET 代码中的错误直到代码执行时才会被检测到,并且追溯起来会更困难。而 Indy.Data 代码中的任何错误都会立即被编译器捕获,并精确定位。这使得数据库可以在开发过程中轻松演进,并且在编译时发现和定位错误,从而大大减少了 bug。
这是否可以通过代码生成或 O/R 映射器来完成?是的,但通常它们提供以下之一:
- 方法调用,这会产生一个不代表
Where
子句的笨拙语法,尤其是在遇到复杂子句时。 - 字符串参数,这又使我们回到了后期绑定的接口,导致运行时发现 bug。
- 数据库参数 - 如果被正确解释,参数可以提供类型安全和早期绑定,许多工具不仅做到这一点,而且依赖于这种行为。问题在于,参数严重限制了
Where
子句的灵活性,并且经常导致创建许多版本的单个对象,带有许多参数变体。
通过 Indy.Data 的方法,可以根据需要保留完整的 Where
子句形成自由度,同时保留类型安全、早期绑定和清晰语法的全部好处。
一种语言
传统上,构建数据库系统的开发人员不仅要学习一门开发语言(C#),还要精通 SQL 和存储过程语言(如 T-SQL)。要开发复杂的系统,开发人员不仅必须精通这三者,还必须在这三种语言之间分割系统逻辑。即使你有一个 DA 编写所有的存储过程和 SQL,它仍然会将你的系统逻辑分成多种语言。
使用 Indy.Data,仍然会使用 SQL,但是使用的 SQL 被简化为基本形式并隔离成离散的块(查询和视图)。然后,所有的系统逻辑都可以用 C# 编写。因为 C# 代码现在可以用来修改基本的 SQL 构建块。总之,现在你可以使用一种语言 C# 来开发你的系统。这是通过扩展 C# 语言来处理 Where
子句和 SQL 语言的其他变体来实现的。
视图
Indy.Data 的主要对象是 View
。
视图类型
表/视图
视图可以存在于数据库中的表或视图。
存储过程
对返回结果集的存储过程的支持尚未实现。对这些类型的存储过程的支持即将推出。
查询
自定义 SQL 可以创建并作为视图或存储过程存储在数据库中。然而,在开发过程中,管理此类视图,特别是修改视图,可能会非常困难。在这种情况下,Indy.Data 还允许开发人员指定外部于数据库的本地 SQL 语句,这些语句随后被嵌入到 View
中。但是,SQL 保持与代码分离和隔离。
生成
视图类由外部实用程序生成。该实用程序配置为检查数据库并扫描数据库中的表、视图、存储过程和外部 SQL 语句。从中,它生成视图基类。从这些基类中,它还生成用户扩展的默认 shell。
由于生成器将类分为两部分,因此可以在不覆盖自定义代码的情况下重新生成基类。该生成器目前正在移植和更新以支持 Firebird 和 SQL Server。我预计将在几天内发布。
示例
在这第一篇文章中,所有的例子都基于一个简单的数据库。我将在以后的文章中扩展这些例子。这些例子展示了对单个表的各项操作。Indy.Data 可以接受视图以及 SQL 语句。
许多例子都来自 NUnit 测试项目。因此,您将在示例代码中看到大量的断言和其他检查。
数据库
对于这些例子,使用了以下数据库。
CREATE TABLE "Customer" (
"CustomerID" INTEGER NOT NULL,
"NameFirst" VARCHAR(40) NOT NULL,
"NameLast" VARCHAR(40) NOT NULL,
"Tag" INTEGER
);
CREATE TABLE "Country" (
"CountryID" "KeyDmn" NOT NULL,
"CountryName" VARCHAR(50) NOT NULL,
"ISOCode" CHAR(2) CHARACTER SET ASCII NOT NULL,
CONSTRAINT "PK_Country" PRIMARY KEY ("CountryID"),
CONSTRAINT "UNQ_Country_1" UNIQUE ("ISOCode"),
CONSTRAINT "UNQ_Country_2" UNIQUE ("CountryName")
);
代码示例
从视图读取
从视图读取的基本形式如下
using (CustomerTbl xCustomers = new CustomerTbl(_DB)) {
xCustomers.SelectAll();
foreach (CustomerTbl.Row xCustomer in xCustomers) {
Console.WriteLine(xCustomer.NameFirst + " " + xCustomer.NameLast);
}
}
一旦选择了视图,就可以使用 foreach
循环遍历结果集中的行。行不会加载到内存中,而是在需要时逐个获取。
SelectAll
是 Indy.Data 内置的一个安全机制。在读取视图之前,必须选择某些内容。此规则已添加到 SelectAll
方法中,因为我们的系统中出现了许多 bug,开发人员忘记选择数据并执行了完整的查询集扫描。因此,如果从一个未被选择的视图读取,Indy.Data 会抛出异常。在这种情况下,我们希望扫描整个视图,因此调用了 SelectAll
。
手动读取
using (CustomerTbl xCustomers = new CustomerTbl(_DB)) {
xCustomers.SelectAll();
CustomerTbl.Row xCustomer1 = (CustomerTblRow)xCustomers.Read();
CustomerTbl.Row xCustomer2 = (CustomerTblRow)xCustomers.Read();
Console.WriteLine(xCustomer1.NameFirst + " " + xCustomer2.NameFirst);
}
使用 Read
方法而不是 foreach
。Read
在到达结果集末尾时返回 null
,并且也可以与 while
或其他循环机制一起使用。
由于行类与读取器分离,因此可以保存和缓存行。例如,您可能希望比较当前行与上一行。使用 Indy.Data,这很容易,您只需要存储对前一行的引用。
访问列
在前面的示例中,可以轻松访问列
string s = xCustomer1.NameFirst
NameFirst
是一个 string
,与列匹配。其他列可能是 int
、decimal
或任何其他类型。但是,每列都有更多属性。现在让我们看一下 Tag
列。
using (CustomerTbl xCustomers = new CustomerTbl(_DB)) {
xCustomers.SelectAll();
foreach (CustomerTbl.Row xCustomer in xCustomers) {
if (!xCustomer.Tag.IsNull) {
Console.WriteLine(xCustomer.Tag);
}
else {
Console.WriteLine(xCustomer.NameFirst + " " + xCustomer.NameLast);
}
}
}
请注意,xCustomer.Tag
返回一个 int
,但 xCustomer.Tag.IsNull
返回一个 bool
。对于那些使用过 ADO.NET DataSets 的人来说,Indy.Data 提供的行和列的清晰隔离将是受欢迎的。
Null (空值)
“Null 是一种状态,而不是一个值”。
幸运的是,所有主要数据库都遵循这一点。然而,几乎所有的数据访问层都将访问 null
值视为错误。这迫使开发人员用 if
语句包装所有此类访问,并导致许多 bug。Null 确实是一种状态,然而 Indy.Data 在存在 null
时会返回一个默认值。但是,您可以显式检测 null
的值。例如,如果 Tag
为 null
Tag == 0
Tag.IsNull == true
因此,您可以在不引发异常的情况下读取 Tag
的值,但仍可检测 null
作为一种状态。数字返回 0,而字符串返回 ""。在我看来,这极大地减少了 bug,但并未降低 null
的有用性,也没有违反 null
是一种状态的事实。这是 Indy.Data 中一个有意的设计特性,而不是一个意外的副作用或妥协。
插入一行
using (CustomerTbl xCustomers = new CustomerTbl(_DB)) {
CustomerTbl.Row xCustomer = new CustomerTbl.Row();
xCustomer.CustomerID = xCustomers.NewKey();
xCustomer.NameFirst = "First";
xCustomer.NameLast = "Last";
xCustomers.Insert(xCustomer);
}
更新一行
using (CustomerTbl xCustomers = new CustomerTbl(_DB)) {
xCustomers.SelectFirstName("Chad");
CustomerTbl.Row xCustomer = xCustomers.Read();
// SQL Server can only have one data command per
// connection so we must close before update
xCustomers.Close();
xCustomer.NameLast = "Hauer";
xCustomers.Update(xCustomer);
}
更新一行类似于插入。但是,要更新一行,我们必须首先以某种方式从数据库获取该行。在此更新中,选择了姓氏为 Chad
的第一行。然后更改其姓氏。在更新期间,仅更新姓氏。Indy.Data 检测到已更新哪些列,并发出高效的 SQL 来仅更新这些列。
过滤数据
在更新 Row
时,调用了 SelectFirstName
。这是一个在 CustomerTbl
的用户代码部分添加的方法。Indy.Data 可以允许开发人员在此处指定 Where
子句的代码,但出于方法论原因(将在未来的文章中解释),它将它们声明为 protected
。这意味着所有过滤条件必须隔离到方法中,并添加到视图的用户代码中。SelectFirstName
的代码如下
Where = Col.NameFirst == aName;
请注意,Where
子句是纯 C# 代码,而不是动态 SQL。这是一个非常简单的例子,但它可以做得更复杂。请注意,甚至支持括号分组。
Where =
(CustomerTbl.Col.NameFirst == "Chad" | CustomerTbl.Col.NameFirst == "Hadi")
& CustomerTbl.Col.CustomerID > aMinID & CustomerTbl.Col.Tag != View.Null;
甚至 LIKE
运算符也被映射到了 %
Where = Col.NameFirst % aName;
Where
子句甚至可以动态构建。
Where = CustomerTbl.Col.CustomerID > aMinID;
if (aNullTagsOnly) {
_Where = _Where & CustomerTbl.Col.Tag == View.Null;
}
当然,括号和其他运算符仍然适用。在上面的代码中也可以使用 &
或 |
,以及括号分组。
其他操作
Indy.Data 支持许多其他操作和功能。本文仅为介绍,我将在不久的将来撰写其他文章来扩展这些内容。
DataBindings
使用 WinForms 或 WebForms 进行 DataBindings?该库也可以支持。它甚至可以用于填充 DataSet
以用于多层应用程序。我将在另一篇文章中介绍。
支持的数据库
该库被设计为数据库无关的,并且可以使用 ADO.NET 与任何 SQL 数据库配合使用。目前,该库已在 Firebird 和 SQL Server 上进行了测试。该库应与 Oracle、Interbase 等配合使用,只需少量修改。
实施说明
根
Indy.Data 的根源可以追溯到我最初于 1998 年在 Delphi 中构建的一个库。当然,因为 Delphi 不支持隐式转换器、运算符重载以及我使用的许多其他功能,所以它远不如 Indy.Data 那么流畅。在 .NET 中的第一个实现创建于 2003 年。
.NET 2.0
该库目前是用 .NET 1.1 编写的。在许多地方,它将极大地受益于泛型、部分类和 ADO.NET 数据提供程序工厂的使用。当 .NET 2.0 发布时,Indy.Data 将更新以利用这些功能。
C#
该库是为 C# 设计的。它也可以用于其他语言,但是像 Visual Basic .NET 这样的语言不支持隐式转换和其他 C# 语言功能,而这些功能是该库所使用的。
开源
Indy.Data 是开源的。如果您有兴趣使用或贡献 Indy.Data,我们已建立了一个 Yahoo Group,您可以在其中 加入。