Lite ORM库(v2)






4.87/5 (26投票s)
一个小型的ORM库
变更
- 2008 年 7 月 7 日 - 代码版本 1.0
- 2008 年 7 月 22 日 - 代码版本 1.2
- 添加了对存储过程输出参数的支持
- 修复了处理带有
private
getter/setter 的public
属性时的 bug - 跟踪开关的名称从“
liteSwitch
”更改为“lite
”
- 2008 年 7 月 28 日 - 代码版本 1.3
- 更改了过程输出参数的处理方式。现在所有参数都放入同一个数组。您需要提供一个输出参数所在索引的数组。请参阅下面的“存储过程”部分。
- 添加了泛型
- 修复了与事务相关的 bug
- 2010 年 10 月 7 日 - 添加了弃用说明部分
弃用说明
本文介绍的代码已弃用,不再维护。建议使用新版本的库。新库与本文介绍的代码不兼容。这是对整个库的一次重大重写,应该会更友好、更容易使用和扩展。可通过此处获得。
引言
这是我第二次尝试编写一个简单的 ORM 库。第一个可以在此处找到。基本思想相同,但代码已完全重新设计并从头开始编写。
这个小型库允许将 class
或 struct
映射到数据库中的单个表或视图。映射的类可以用于针对映射表执行简单的 Insert
、Update
、Delete
和 Select
语句。如今,这是非常常见的操作,因此我不认为需要更多解释。此外,该库还允许以一些有用的方式调用函数和存储过程(当然,如果您的数据库支持它们)。
代码可以处理 struct
和 class
。唯一的要求是 class
或 struct
必须有一个默认构造函数(它可以是 private
)。在本文中,class
与 struct
可互换使用。数据库表和视图也是如此。此外,本文假设我们使用的是 SQL Server 数据库引擎。
类到表的映射
要将类映射到数据库表,我们需要使用一些属性来装饰类。它们可以在根命名空间 `lite` 中找到。应将 TableAttribute
应用于类。在这里,我们指定表的名称及其所属的架构。Schema
属性可以留空,在这种情况下,表名将不会带有架构名称限定。如果 Name
属性留空,则假定类的名称与表的名称相同。
using lite;
// maps to table dbo.person
[Table]
public class Person
// maps to table dbo.users
[Table(Name="users")]
public class User
// maps to table people.person
[Table(Schema="people")]
public class Person
// maps to view people.transactView
[Table(Name="transactView",Schema="people")]
public class Purchase
要将类成员映射到表列,我们有几种选择。最常见和最明显的方法是使用 ColumnAttribute
,它可以应用于字段或属性。此属性具有我们可以指定的两个属性:Name
和 Alias
。Name
是数据库表中列的实际名称。Alias
是……正如其听起来一样,一个别名。我们在查询数据库时将使用别名而不是列名。这提供了更大的灵活性,可以在不修改大量代码的情况下更改数据库中的列名。如果未指定 Name
属性,则假定应用于该属性的类成员的名称与数据库中列的名称相同。如果未指定 Alias
属性,则别名与列名相同。
// maps to [order_id]
[Column(Name="order_id")]
private int orderId;
// maps to [customer_id]
[Column(Name="customer_id")]
public int CustomerId { get; set; }
// maps to [quantity]
[Column]
public int Quantity { get; set; }
当我们进行数据库查询时,将看到 Alias
属性的实际用法。
将类字段映射到表列的另一种方法是使用 MapAttribute
,它应用于类。此属性可以映射对当前类可见的任何字段。请注意,字段不必定义在应用 MapAttribute
的类中。这允许映射继承的字段。在代码中,此属性扩展了 ColumnAttribute
,因此继承了 Name
和 Alias
属性,它们的行为方式相同。但是,要使用 MapAttribute
,我们必须告诉它我们要映射的类成员的名称。一个示例应该会使其更清晰。
public class Person
{
protected string ssn;
}
[Table]
[Map("ssn")]
public class Student
{
[Column, ID, PK]
protected int studentNumber;
}
IDAttribute
标记标识列,PKAttribute
指定主键列。只能有一个标识,但可以有多个主键列。注意:这些属性对于 Insert
、Update
和 Delete
语句的正常运行是必需的。
为了清楚起见
- 并非所有表列都必须映射,也并非所有类成员都必须映射。
- 读写类成员用于双向:从类到数据库,从数据库到类。只读成员仅用于将数据从类发送到数据库。只写成员用于将类从数据库填充。
- 类到数据库方向是
Insert
和Update
。数据库到类是Select
语句。 - 类字段(变量)始终被视为读写。
- 具有
get
和set
方法的属性是读写的。只有get
方法的属性是只读的。只有set
方法的属性是只写的。
使用映射类
在使用映射类之前,我们需要一个对象来生成和运行 SQL 语句。这将是一个实现 IDb
接口的对象。所以,我们需要做的是设置一种方法来获取这些对象。目前,我们将假设我们正在处理 SQL Server 数据库。此库提供了 SQL Server 的实现,但您也可以编写适用于您选择的数据库的代码。现在,我们需要编写一个类作为工厂来生成 IDb
对象。这是一个非常简单的例子
using lite;
using lite.sqlserver;
// Every call to GetDb() returns a brand new IDb object with a new database
// connection under it. Depending on your needs you can easily modify
// this class to make it always return the same instance of IDb object or
// different IDb objects sharing the same connection.
public class DbFactory
{
public static readonly DbFactory Instance = new DbFactory();
private SqlProvider provider;
private DbFactory()
{
string connectString = ...; //maybe get it from config file
provider = new SqlProvider(connectString);
}
public IDb GetDb()
{
return provider.OpenDb();
}
}
此时,我们可以开始使用映射类了。因此,让我们完整地定义一个将在我们的示例中使用的一个类。
create table dbo.purchase (
purchase_id bigint identity primary key,
customer int,
product int,
quantity int,
comment nvarchar(100),
purch_date datetime not null default getdate()
)
go
[Table]
public class Purchase
{
[Column(Name="purchase_id",Alias="id"), ID, PK]
private long purchaseId;
[Column] private int customer;
[Column] private int product;
[Column] private int quantity;
[Column] private string comment;
[Column(Name="purch_date",Alias="date")]
private DateTime purchaseDate;
public Purchase()
{
purchaseDate = DateTime.Now;
}
public long Id
{
get { return purchaseId; }
}
public int Customer
{
get { return customer; }
set { customer = value; }
}
public int Product
{
get { return product; }
set { product = value; }
}
public int Quantity
{
get { return quantity; }
set { quantity = value < 0 ? 0 : value; }
}
public string Comment
{
get { return comment; }
set { comment = value; }
}
public DateTime PurchaseDate
{
get { return purchaseDate; }
}
public override bool Equals(object other)
{
return id == other.id
&& customer == other.customer
&& product == other.product
&& quantity == other.quantity
&& comment == other.comment
&& purchaseDate == other.purchaseDate;
}
public override int GetHashCode()
{
return base.GetHashCode();
}
public override string ToString()
{
return "Purchase id is " + id.ToString();
}
[Trigger(Timing.All)]
private void TriggerMethod1(object sender, TriggerEventArgs e)
{
bool truth = (this == sender);
Console.WriteLine("Trigger timing is " + e.Timing.ToString());
}
}
上面的类定义了一个触发方法 TriggerMethod1
。此方法将在 Timing
枚举指定的指定时间被调用。方法签名类似于 .NET 事件和委托的标准,但在将来的版本中可能会有所更改。这里的想法是允许在其他地方定义触发器,但为此,当然,我们定义触发器的方式需要改变。在更高版本中可能会实现类似这样的功能。
using lite;
static void Main(string[] args)
{
IDb db = DbFactory.Instance.GetDb();
Purchase p1 = new Purchase();
p1.Customer = 1;
p1.Product = 2;
p1.Quantity = 3;
p1.Comment = "Fast delivery please!";
int records = db.Insert(p1);
Console.WriteLine(p1.Id); //should not be zero
Purchase p2 = (Purchase) db.Find(typeof(Purchase), p1.Id);
Console.WriteLine( p2.Equals(p1) ); //should be true
p2.Quantity = p1.Quantity + 5;
p2.Comment = p1.Comment + " And I added 5 more items to my order.";
db.Update(p2);
records = db.Delete(p2);
Console.WriteLine(records);
db.Dispose();
}
查询
更有趣的部分是查询接口。它非常原始,但有效。IDb
类有一个工厂方法 Query()
,它返回一个 IQuery
对象。此对象帮助我们定义 Select
语句的 Where
子句。请注意,当我们约束列的值时,我们不使用列名。还记得 ColumnAttribute
的 Alias
属性吗?这就是它的用武之地。我们指定列的别名,它将在内部解析为实际的列名。这样做的好处是,我们可以在不修改任何查询的情况下更改数据库列的名称。一个示例将更容易理解。
using lite;
static void Main(string[] args)
{
IDb db = DbFactory.Instance.GetDb();
// select * from dbo.purchase where id=1
IQuery q = db.Query();
// note that we are not using the "purchase_id" to reference the column
// we are using "id" which is the alias for [purchase_id] column (see above)
q.Constrain("id").Equal(1);
IList list = db.Select(typeof(Purchase), q);
if (list.Count > 0)
{
Purchase p = (Purchase) list[0];
...
}
// select * from dbo.purchase where customer=1
IQuery q1 = db.Query();
q1.Constrain("customer").Equal(1);
list = db.Select(typeof(Purchase), q1);
// select * from dbo.purchase where customer=1 and product=2
IQuery q2 = db.Query();
q2.Constrain("customer").Equal(1).And()
.Constrain("product").Equal(2);
list = db.Select(typeof(Purchase), q2);
// select * from dbo.purchase where
// quantity<=10 and (customer=1 or product=2)
IQuery q3 = db.Query().Constrain("customer").Equal(1).Or()
.Constrain("product").Equal(2);
IQuery q4 = db.Query().Constrain("quantity").LessEqual(10).And()
.Constrain(q3);
list = db.Select(typeof(Purchase), q4);
// select * from dbo.purchase where (customer=1 and product=2)
// or (quantity>5 and purch_date>=dateadd(day,-10,getdate()))
IQuery q5 = db.Query().Constrain("customer").Equal(1).And()
.Constrain("product").Equal(2);
IQuery q6 = db.Query().Constrain("quantity").Greater(5).And()
.Constrain("date").GreaterEqual(DateTime.Now.AddDays(-10));
IQuery q7 = db.Query().Constrain(q5).Or().Constrain(q6);
list = db.Select(typeof(Purchase), q7);
// select * from dbo.purchase where comment like '%delivery%'
list = db.Select(typeof(Purchase),
db.Query().Constrain("comment").Like("%delivery%"));
// select * from dbo.purchase where
// customer in (1,5,10) order by customer asc
int[] intarray = new int[] { 1,5,10 };
// all arrays in .NET implement IList
IQuery q9 = db.Query().Constrain("customer").In(intarray)
.Order("customer", true);
list = db.Select(typeof(Purchase), q9);
// select * from dbo.purchase where product
// not in (2,3,4) order by purch_date desc
IList notin = new ArrayList();
notin.Add(2);
notin.Add(3);
notin.Add(4);
IQuery q10 = db.Query().Constrain("product").NotIn(notin)
.Order("date", false);
list = db.Select(typeof(Purchase), q10);
// select * from dbo.purchase where quantity
// is null and purch_date is not null
IQuery q11 = db.Query().Constrain("quantity").Equal(null).And()
.Constrain("date").NotEqual(null);
// .Equal(null) and .NotEqual(null) will convert to SQL's "is null"
// and "is not null" respectively
list = db.Select(typeof(Purchase), q11);
// delete from dbo.purchase where customer=1 and quantity>200
IQuery q12 = db.Query().Constrain("customer").Equal(1).And()
.Constrain("quantity").Greater(200);
list = db.Delete(typeof(Purchase), q12);
// delete from dbo.purchase
int deleted = db.Delete(typeof(Purchase), (IQuery)null);
db.Dispose();
}
语法非常原始,但也与实际的 SQL 语句非常相似,因此应该相对容易理解。
存储过程和函数
让我们继续调用函数和存储过程。要调用函数,我们使用 Call
方法;要执行存储过程,我们使用 IDb
类的 Exec
方法。函数只能返回单个值作为返回值,因此 Call
方法的签名是这样的。我们也可以使用此方法执行存储过程,但唯一返回的是过程的返回值(通常是 int
值)。Exec
方法更有趣。它可以执行指定的存储过程并返回指定类型项的列表。对该过程的唯一约束是,它应该返回一个定义在映射类或目标类型映射到的表中的列的结果集(一个示例将使其清晰)。Exec
方法的另一个重载执行指定的存储过程并返回一个 IResultSet
对象。此对象类似于 DataSet
对象,但没有那么重。底层是执行过程返回的数组(行)。我们可以使用索引或列名获取单个值。还有一个 Exec
方法版本允许我们获取已执行存储过程的输出参数的值。
create procedure dbo.get_purchases
@cust_id int,
@prod_id int
as
begin
select purchase_id, customer, product, quantity, comment, purch_date
from dbo.purchase
where customer = @cust_id and product = @prod_id
end
go
create procedure dbo.get_customer_purchases
@cust_id int
as
begin
select product, quantity, comment, purch_date
from dbo.purchase
where customer = @cust_id
end
go
create function dbo.get_purchase_quantity(@id bigint)
returns int
as
begin
declare @quantity int
select @quantity = quantity from dbo.purchase where purchase_id = @id
return @quantity
end
go
create procedure dbo.customer_summary
@cust_id int,
@products int output,
@items int output,
@last_purch_date datetime output
as
begin
-- total number of distinct products purchased
select @products = count(distinct product)
from dbo.purchase
where customer = @cust_id
-- total number of items
select @items = sum(quantity)
from dbo.purchase
where customer = @cust_id
-- last purchase date
select @last_purch_date = max(purch_date)
from dbo.purchase
where customer = @cust_id
end
go
using lite;
static void Main(string[] args)
{
IDb db = DbFactory.Instance.GetDb();
object[] values = new object[2];
values[0] = 1; // cust_id parameter
values[1] = 2; // prod_id parameter
IList list = db.Exec(typeof(Purchase), "dbo.get_puchases", values);
foreach (Purchase p in list)
{
Console.WriteLine(p.ToString());
}
IResultSet rs = db.Exec("dbo.get_customer_purchases", new object[] { 1 });
while (rs.Next())
{
object o = rs["product"];
if (o != null)
Console.WriteLine("product " + o.ToString());
o = rs["quantity"];
if (o != null)
Console.WriteLine("quantity " + o.ToString());
o = rs["comment"];
if (o != null)
Console.WriteLine("comment " + o.ToString());
o = rs["purch_date"];
if (o != null)
{
DateTime purchDate = (DateTime) o;
Console.WriteLine("purch_date " + purchDate.ToShortDateString());
}
Console.WriteLine();
}
long purchaseId = 5;
object quantity = db.Call("dbo.get_purchase_quantity",
new object[] { purchaseId });
if (quantity == null)
Console.WriteLine("no purchase with id " + purchaseId + " found");
else
{
int q = (int) quantity;
Console.WriteLine("quantity is " + q);
}
// This array contains all parameters (input and output) to the procedure.
// We initialize the output parameters with default values so that lite
// can figure out the correct type of the parameter (default is string).
object[] parameters = new object[] { 1, 0, 0, DateTime.MinValue };
// This array specifies the indices at which output parameters are.
// The values at these indices will be overwritten by the Exec method.
int[] outputs = new int[] { 1, 2, 3 };
IResultSet rs = db.Exec("dbo.customer_summary", parameters, outputs);
Console.WriteLine("Should be zero: " + rs.Rows);
// Our procedure doesn't have any code to guarantee that only non-null
// values are returned, so we need to check for nulls. The values given
// during output array initialization are overwritten and could be null.
int distinctProducts = (parameters[1] != null) ? (int) parameters[1] : 0;
int totalItems = (parameters[2] != null) ? (int) parameters[2] : 0;
DateTime? lastPurchase = (parameters[3] != null) ?
(DateTime?) parameters[3] : (DateTime?) null;
Console.WriteLine("Distinct products purchased: " + distinctProducts);
Console.WriteLine("Total number of items: " + totalItems);
Console.WriteLine("Last purchase: " +
lastPurchase.HasValue ?
lastPurchase.Value.ToShortDateString() :
"Never"
);
db.Dispose();
}
SPResultAttribute
考虑上面定义的 dbo.get_customer_purchases
过程。执行该过程会返回一个 IResultSet
对象。但是,我们可以使其返回强类型对象的列表。这里的诀窍是我们实际上无法将此对象映射到表或视图,因为不存在具有这些列的表。答案是使用 SPResultAttribute
。将此属性应用于类而不是 TableAttribute
。这将允许 IDb
对象在执行存储过程时创建该类的实例。这是一个示例。
using lite;
[SPResult]
public class CustomerPurchase
{
[Column] public int Product;
[Column] public int Quantity;
[Column] public string Comment;
[Column(Name="purch_date")] public DateTime PurchaseDate;
}
static void Main(string[] args)
{
using (IDb db = DbFactory.Instance.GetDb())
{
string procName = "dbo.get_customer_purchases";
object[] parameters = new object[] { 1 };
IList list = db.Exec(typeof(CustomerPurchase), procName, parameters);
foreach (CustomerPurchase cp in list)
{
Console.WriteLine(string.Format("{0}, {1}, {2}, {3}",
cp.Product, cp.Quantity,
cp.Comment, cp.PurchaseDate);
}
}
}
事务
所有数据库调用都发生在事务中。我们可以通过调用 IDb.Begin
方法显式开始事务。我们可以使用 IDb.Commit
和 IDb.Rollback
方法来提交和回滚我们之前启动的事务。如果我们没有显式开始事务,当我们调用修改数据库的方法时,事务将自动启动。如果数据库调用成功返回,将调用 Commit
;否则,将调用 Rollback
并重新抛出异常。在显式事务(由客户端代码启动)期间,客户端代码负责处理异常并在需要时调用 Rollback
方法。
可空类型
顺便说一句,我们可以使用可空类型(int?
、DateTime?
等)作为我们正在映射的字段或属性的类型。代码应该能够像处理常规原始类型一样处理它们。
跟踪
如果我们想查看哪些 SQL 语句被发送到数据库,我们可以简单地配置 .NET 跟踪,Lite 将输出所有生成的 SQL 语句。在执行任何命令之前,代码默认会将有关命令及其参数的信息发送到 System.Diagnostics.Trace
对象。如果未配置跟踪或已关闭,那么我们显然看不到任何内容。要启用跟踪,我们需要修改配置文件(当然,在编译时需要定义 TRACE
编译符号)。这是一个示例配置文件
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<system.diagnostics>
<trace autoflush="true" indentsize="2">
<listeners>
<remove name="Default"></remove>
<add name="console"
type="System.Diagnostics.ConsoleTraceListener"></add>
<add name="liteTraceFile"
type="System.Diagnostics.TextWriterTraceListener"
initializeData="c:\sql.txt"></add>
</listeners>
</trace>
<switches>
<add name="lite" value="true"
listener="liteTraceFile" dedicated="true"></add>
</switches>
</system.diagnostics>
</configuration>
我们处理配置文件中的 system.diagnostics
部分。这里的一切都是标准的 .NET 配置内容(请参阅 .NET 文档)。所有跟踪都通过 TraceObject
进行。默认情况下,跟踪是禁用的。有几种方法可以启用跟踪。第一种方法是将 TraceObject.Instance
的 Enabled
属性设置为 true
。通过将其设置为 false
来禁用它。另一种方法是在配置文件中创建一个名为“lite
”的开关,并将其 value
属性设置为“true
”,如上所示。
上一段中讨论的配置将写入所有定义的侦听器。“lite
”开关支持另外两个属性:“listener
”和“dedicated
”。“listener
”属性允许我们指定将跟踪信息发送到的侦听器的名称。如果“dedicated
”属性设置为“true
”,那么在“listener
”属性中指定的侦听器将从所有跟踪侦听器列表中删除,并且将仅由该库使用。没有此属性,对 Trace
对象的其他调用将能够将数据写入我们的侦听器。我们还可以通过设置 TraceObject.Instance.Listener
属性在代码中设置专用的 TraceListener
。此属性只能用于分配专用侦听器。您可以根据自己的想象来设置这些侦听器——无论哪种最适合您的情况。
有时,我们可能不希望看到由我们的代码发送到数据库的所有 SQL 语句。在这种情况下,我们可以在配置文件中禁用跟踪,并使用 API 在某些调用 IDb
对象时临时启用它。
IDb db = DbFactory.Instance.GetDb();
// enable tracing (while debugging)
TraceObject.Instance.Enabled = true;
db.Insert(...);
db.Select(...);
// disable tracing again
TraceObject.Instance.Enabled = false;
db.Dispose();
结论
好了,这几乎就是这个库提供的所有功能了。要为 SQL Server 以外的数据库编写实现,您需要实现 lite
命名空间中的所有接口。请参阅 lite.sqlserver
命名空间作为示例。如果您想添加一些新属性或提出定义表、列、触发器和过程的另一种方式,也可以在不修改 lite
命名空间中的接口的情况下实现。如果您需要添加一些新功能,您可以 just 扩展 IDb
接口并扩展 IProvider
以返回新类型的对象。这不应该破坏与现有代码的兼容性。
谢谢阅读。祝您愉快!