Signum Framework 教程 第三部分 - Southwind 加载






4.62/5 (7投票s)
在本部分中,我们将编写加载应用程序,以将数据从 Northwind 移动到 Southwind。
- 下载 Signum Framework 2.0 二进制文件 - 2.17 MB(也在codeplex.com)
- 在 GitHub 上下载 Signum Framework 2.0 源代码
- 下载 Southwind 第 3 部分加载 - 183.8 KB(也在github.com)
- Microsoft Northwind 示例数据库备份(也在microsoft.com)
关于 Signum 框架 2.0
Signum Framework 是一个应用程序框架,用于构建以数据为中心的 Windows 和 Web 应用程序。它提倡代码优先的工作流,并专注于可组合性,以便在项目之间共享代码。
我们刚刚发布了 Signum Framework 2.0,并且正在准备一系列教程来解释它的功能。
关于本系列
在本系列教程中,我们将致力于一个稳定的应用程序:Southwind。
Southwind 是 Northwind 的 Signum 版本,Northwind 是 Microsoft SQL Server 提供的著名示例数据库。
在本系列教程中,我们将创建整个应用程序,包括实体、业务逻辑、Windows (WPF) 和 Web (MVC) 用户界面、数据加载以及任何其他值得解释的方面。
如果您想了解更多关于 Signum Framework 的原理,请参阅之前的教程
- Signum Framework 原理
- Signum Framework 教程 第一部分 – Southwind 实体
- Signum Framework 教程 第二部分 – Southwind 逻辑
- Signum Framework 教程第 3 部分 – Southwind 加载
在本教程中,我们将重点介绍将数据从 Northwind 数据库移动到新的 Southwind。
引言
应用程序停滞不前的主要原因之一是人们害怕更改数据库,导致花费大量金钱来维护未正确规范化或受约束的旧怪物。
此外,应用程序被太多人修改,并且实体上缺乏集中式的验证规则,使得无法依赖数据应持有的任何类型的不变性,从而使任何修改变得更加困难。
Signum Framework 提供了创建新应用程序的工具,但还通过使用 Signum.Utilities、LINQ 和 CSV 文件帮助您将旧数据移动到新模式中。
为了读取旧数据库 (Northwind),我们将使用 LINQ to SQL 而不是 LINQ to Signum。Signum Framework 是在 Greenfield 项目中创建以数据为中心应用程序的绝佳工具,但对于查询旧数据库完全无用。
Northwind 数据库虽然简单,但结构良好,合理规范化,表关系恰当,数据相当同质。这将使我们在本教程中的工作更轻松。
在实际场景中,如果您的旧数据库看起来像这样,我会很高兴。最常见的情况是,您将不得不对模式进行更彻底的更改,并在加载应用程序中对数据进行更多转换。
加载数据
让我们转到 Southwind.Load,添加新项目,LINQ to SQL 类,使用服务器资源管理器连接到 Northwind,并将所有表拖到设计器界面中。排列好图表后,它应该看起来像这样
让我们开始加载一些数据吧!
加载区域和地区
在 Program 类中,为了加载 Region
表,我们只需将 LoadXXX 方法重命名为 LoadRegions
。
然后,在该方法内部创建一个 NorthwindDataContext
对象并查询 Regions
属性,为每个结果创建一个 RegionDN
实体,如下所示
static void LoadRegions()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
db.Regions.Select(r => new RegionDN
{
Description = r.RegionDescription.Trim(),
}).SaveList();
}
}
即使这段代码运行正常,它也无法保留 Northwind 数据库中的 Id
。
在数据加载场景中,如果可能的话,保留 ID 通常很有趣,以避免用临时的 Old_Id
字段污染我们的实体或保留单独的映射文件。
为此,我们需要暂时禁用表的标识,并手动设置 Id 属性。
Administrator
静态类是危险情况下的数据库对应物,不应在生产中使用,但对于加载应用程序中的此类脏操作非常有用。
代码将如下所示
static void LoadRegions()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
Administrator.SaveListDisableIdentity(db.Regions.Select(r =>
Administrator.SetId(r.RegionID, new RegionDN
{
Description = r.RegionDescription.Trim(),
})));
}
}
如果我们运行加载应用程序,选择加载并选择第一个方法(0 – Load Regions
),代码将运行,新记录将写入数据库。
让我们为地区创建一个类似的方法
static void LoadTerritories()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
var regionDic = Database.RetrieveAll<RegionDN>().ToDictionary(a => a.Id);
Administrator.SaveListDisableIdentity(db.Territories.Select(r =>
Administrator.SetId(int.Parse(r.TerritoryID), new TerritoryDN
{
Description = r.TerritoryDescription,
Region = regionDic[r.RegionID]
})));
}
}
现在我们把这个方法添加到控制台菜单 (ConsoleSwitch
) 并运行它……哎呀
看来 Northwind 数据库中“New York”被写了两次,现在更有趣了。为了删除重复项,我们需要按描述对地区进行分组,如下所示
var territories = (from t in db.Territories.ToList()
group t by t.TerritoryDescription into g
select new
{
Description = g.Key.Trim(),
Id = g.Select(t => t.TerritoryID).Order().First(),
RegionID = g.Select(r => r.RegionID).Distinct().Single(),
}).ToList();
Administrator.SaveListDisableIdentity(territories.Select(t =>
Administrator.SetId(int.Parse(t.Id), new TerritoryDN
{
Description = t.Description,
Region = regionDic[t.RegionID]
})));
加载员工
员工是一个更大的实体,所以会稍微复杂一些。我们创建 LoadEmployees
方法,其结构与之前的相同,并将其添加到控制台菜单。
第一个问题是如何处理重复的地区。我们需要一种方法将重复的地区 ID 转换为非重复的地区 ID。我们可以使用此 Linq to Object 查询创建一个字典,如下所示
var duplicateMapping = (from t in db.Territories.ToList()
group int.Parse(t.TerritoryID) by t.TerritoryDescription into g
where g.Count() > 1
let min = g.Min()
from item in g.Except(new[] { min })
select new
{
Min = min,
Item = item
}).ToDictionary(a => a.Item, a => a.Min);
我们在这里所做的是按地区描述对地区 ID 进行分组。对于包含多个元素(重复项)的组,我们选择最小的 ID 并从其他所有元素创建一个字典,指向最小的 ID。这并不复杂!
第二个问题是我们必须为新字段(userName
和 passwordHash
)发明一些值。让我们将“firstName.lastName”用作每个用户的默认用户名和密码。正如我们在上一个教程中所做的那样,我们将不得不使用 Security.EncodePassword
来创建 MD5 密码哈希。
代码应该如下所示
var territoriesDic = Database.RetrieveAll<TerritoryDN>().ToDictionary(a => a.Id);
Administrator.SaveListDisableIdentity(
from e in db.Employees
let userName = (e.FirstName + "." + e.LastName).ToLower()
select
Administrator.SetId(e.EmployeeID, new EmployeeDN
{
UserName = userName,
PasswordHash = Security.EncodePassword(userName),
BirthDate = e.BirthDate,
FirstName = e.FirstName,
LastName = e.LastName,
TitleOfCourtesy = e.TitleOfCourtesy,
HomePhone = e.HomePhone,
Extension = e.Extension,
HireDate = e.HireDate,
Photo = e.Photo.ToArray(),
PhotoPath = e.PhotoPath,
Address = new AddressDN
{
Address = e.Address,
City = e.City,
Country = e.Country,
Region = e.Region,
},
Notes = e.Notes,
Territories = (from id in e.EmployeeTerritories.Select(a=>int.Parse(a.TerritoryID)).ToList()
select territoriesDic[duplicateMapping.TryGet(id, id)]).Distinct().ToMList(),
}));
如果我们尝试运行此代码,我们将收到每个实体的验证错误摘要,基本上有许多重复的以下错误
The length of Region has to be greater than or equal to 3
The length of Country has to be greater than or equal to 3
Region is not set
在这种情况下,我们的验证规则过于严格,让我们修改 AddressDN
中 Country
和 Region
属性上的 StringLengthValidator
以允许 2 个字符的长度。
在 Region
属性上,我们还需要将 AllowNulls
设置为 true 并从字段中删除 NotNullable
,如下所示:
[SqlDbType(Size = 15)] //previously [NotNullable]
string region;
[StringLengthValidator(AllowNulls = true, Min = 3, Max = 15)]
public string Region
{
get { return region; }
set { Set(ref region, value, () => Region); }
}
由于上次更改将修改模式,我们需要在继续之前生成新的同步脚本
Generating script...Already synchronized!
哎呀!我们预期会有一些变化,但它说一切正常。原因是 EmbeddedEntities
为了表达空引用,会添加一个额外的布尔字段 HasValue
,并强制所有其他字段可为空。
然而,在这种情况下,AddressDN
在 EmployeeDN
上是强制性的,就像 CustomerDN
、OrderDN
或 SupplierDN
一样,所以我们可以在 AddressDN
类型的每个属性上添加 NotNullValidator
,并在字段上添加 NotNullable
,如下所示
[NotNullable]
AddressDN address;
[NotNullValidator]
public AddressDN Address
{
get { return address; }
set { Set(ref address, value, () => Address); }
}
我们再试一次同步
ALTER TABLE CompanyDN ALTER COLUMN Address_Address NVARCHAR(60) NOT NULL;
ALTER TABLE CompanyDN ALTER COLUMN Address_City NVARCHAR(15) NOT NULL;
ALTER TABLE CompanyDN ALTER COLUMN Address_PostalCode NVARCHAR(10) NOT NULL;
ALTER TABLE CompanyDN ALTER COLUMN Address_Country NVARCHAR(15) NOT NULL;
ALTER TABLE CompanyDN DROP COLUMN Address_HasValue;
ALTER TABLE EmployeeDN ALTER COLUMN Address_Address NVARCHAR(60) NOT NULL;
ALTER TABLE EmployeeDN ALTER COLUMN Address_City NVARCHAR(15) NOT NULL;
ALTER TABLE EmployeeDN ALTER COLUMN Address_PostalCode NVARCHAR(10) NOT NULL;
ALTER TABLE EmployeeDN ALTER COLUMN Address_Country NVARCHAR(15) NOT NULL;
ALTER TABLE EmployeeDN DROP COLUMN Address_HasValue;
ALTER TABLE SupplierDN ALTER COLUMN Address_Address NVARCHAR(60) NOT NULL;
ALTER TABLE SupplierDN ALTER COLUMN Address_City NVARCHAR(15) NOT NULL;
ALTER TABLE SupplierDN ALTER COLUMN Address_PostalCode NVARCHAR(10) NOT NULL;
ALTER TABLE SupplierDN ALTER COLUMN Address_Country NVARCHAR(15) NOT NULL;
ALTER TABLE SupplierDN DROP COLUMN Address_HasValue;
ALTER TABLE PersonDN ALTER COLUMN Address_Address NVARCHAR(60) NOT NULL;
ALTER TABLE PersonDN ALTER COLUMN Address_City NVARCHAR(15) NOT NULL;
ALTER TABLE PersonDN ALTER COLUMN Address_PostalCode NVARCHAR(10) NOT NULL;
ALTER TABLE PersonDN ALTER COLUMN Address_Country NVARCHAR(15) NOT NULL;
ALTER TABLE PersonDN DROP COLUMN Address_HasValue;
ALTER TABLE OrderDN ALTER COLUMN ShipAddress_Address NVARCHAR(60) NOT NULL;
ALTER TABLE OrderDN ALTER COLUMN ShipAddress_City NVARCHAR(15) NOT NULL;
ALTER TABLE OrderDN ALTER COLUMN ShipAddress_PostalCode NVARCHAR(10) NOT NULL;
ALTER TABLE OrderDN ALTER COLUMN ShipAddress_Country NVARCHAR(15) NOT NULL;
ALTER TABLE OrderDN DROP COLUMN ShipAddress_HasValue;
完美,注意所有 AddressDN
字段都变成了 NOT NULL
,除了 Region
。让我们运行脚本来做出更改。
我们必须解决的最后一个问题是重新组合 ReportsTo
层次结构。由于存在对表本身的外部键,因此不可能添加对尚未加载的管理器的引用。
相反,我们将创建一个小循环,在所有员工加载完毕后重新组合层次结构,如下所示
var pairs = (from e in db.Employees
where e.ReportsTo != null
select new { e.EmployeeID, e.ReportsTo });
foreach (var pair in pairs)
{
EmployeeDN employee = Database.Retrieve<EmployeeDN>(pair.EmployeeID);
employee.ReportsTo = new Lite<EmployeeDN>(pair.ReportsTo.Value);
employee.Save();
}
在这段代码中,我们查询 Northwind 以获取表示层次结构的对,然后我们使用 Database.Retrieve
来检索 Employee
,我们手动创建 Lite
,然后我们使用 Save
来更新实体。
注意:在业务逻辑中手动处理 Id 比使用 Lites 风险更大,因为 Lites 包含类型信息,有助于防止错误。然而,在这种情况下,我们别无选择,因为我们正在读取 LINQ to SQL 数据库。
好的,有了这段代码,我们应该能够将 Employees
加载到我们的 Southwind 数据库中。
在继续加载其他实体并使 Program
类变得一团糟之前,让我们将 LoadRegions
、LoadTerritories
和 LoadEmployees
方法移动到一个新的 EmployeeLoader
静态类中。我们需要将这些方法设为 public 并更新 Main
方法中的 SwitchConsole
菜单。
加载产品
这次我们一开始就做好事情,创建一个 ProductLoader
静态类。在那里我们将开始创建 LoadSuppliers
,它将看起来像之前的那些
public static void LoadSuppliers()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
Administrator.SaveListDisableIdentity(db.Suppliers.Select(s =>
Administrator.SetId(s.SupplierID, new SupplierDN
{
CompanyName = s.CompanyName,
ContactName = s.ContactName,
ContactTitle = s.ContactTitle,
Phone = s.Phone,
Fax = s.Fax,
HomePage = s.HomePage,
Address = new AddressDN
{
Address = s.Address,
City = s.City,
Region = s.Region,
PostalCode = s.PostalCode,
Country = s.Country
},
})));
}
}
如果我们把方法添加到菜单,然后尝试运行它,我们会得到一堆验证错误,所有这些都像这样重复
Phone does not have a valid Telephone format
Fax does not have a valid Telephone format
Home Page is not set
Fax is not set
首先,请注意 SaveListDisableIdentity
事务性——就像 Database
或 Administrator
类中的任何其他方法一样——因此在发生异常时不会进行任何更改。
前两个错误是因为 Telephone
验证器不允许电话号码中出现点号('.'),只允许数字、空格、连字符和括号。在这种情况下,我们只需将点号替换为空格。
如果我们查看 Northwind 数据,我们会发现 HomePage
字段包含很少且异构的数据,不值得加载。让我们更改字段和属性属性,以允许 SupplierDN
实体中的空值并将其保留为空白。
读取 CSV 文件
对于没有传真的客户,我们会使其变得更复杂。假设新应用程序有一个业务需求,即使用传真向供应商下订单,因此我们必须保持该字段为必填项。
经过一长串电子邮件往来,我们终于得到了一份包含缺失传真号码的 Excel 文件。它看起来像这样。
在 Excel 中,我们将文件另存为 CSV 文件,放在 Southwind.Load
目录中(SupplierFaxes.csv)。
然后,在 Visual Studio 中,我们包含该文件(解决方案资源管理器中的“显示所有文件”图标 -> 右键单击该文件 -> “包含在项目中”),并在属性中将“复制到输出目录”设置为“如果较新则复制”。
让我们看一下文件。根据您的 **区域设置**,值将由逗号“,”或分号“;”分隔,小数将使用点“.”或逗号“,”。在这种情况下,文件是在西班牙语计算机上生成的。
还需要查看文件的 **编码** (文件 -> 高级保存选项)。在本例中,为西欧 (Windows) – 代码页 1252。
一旦我们知道区域性和编码,加载文件就很容易了。让我们创建一个类,其中包含每个列的公共字段,顺序相同。
public class SupplierFaxCSV
{
public int SupplierID;
public string Fax;
}
然后,在我们的 LoadSuppliers
方法中,我们使用 CSV.ReadCSV
方法读取文件的内容。
List<SupplierFaxCSV> faxes = CSV.ReadCVS<SupplierFaxCSV>(
"SupplierFaxes.csv", Encoding.GetEncoding(1252),
CultureInfo.GetCultureInfo("es"), true);
注意我们如何明确地写出所有参数。
- 我们将
Encoding
设置为 Codepege 1252,否则将是 Unicode。 CultureInfo
为西班牙语,否则为您的当前区域性。- 我们明确告诉它我们想跳过读取文件的第一行(标题),即使这是默认值。
然后我们创建一个字典,以便在加载客户时使用
var faxDic = faxes.ToDictionary(r => r.SupplierID, r => r.Fax);
最后,让我们更新加载供应商的查询
(…)
Phone = s.Phone.Replace(".", " "),
Fax = faxDic[s.SupplierID].Replace(".", " "),
HomePage = s.HomePage,
(…)
让我们编译并生成一个新的同步脚本,以更新字段 HomePage
的可空性。
ALTER TABLE SupplierDN ALTER COLUMN HomePage NVARCHAR(MAX) NULL;
然后该方法应按预期加载供应商。
加载类别和产品
加载类别不应该有任何困难,我们只需创建这样一个方法,将其添加到菜单并运行即可
public static void LoadCategories()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
Administrator.SaveListDisableIdentity(db.Categories.Select(s =>
Administrator.SetId(s.CategoryID, new CategoryDN
{
CategoryName = s.CategoryName,
Description = s.Description,
Picture = s.Picture.ToArray(),
})));
}
}
加载产品稍微复杂一点。让我们创建一个类似的方法
public static void LoadProducts()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
Administrator.SaveListDisableIdentity(db.Products.Select(s =>
Administrator.SetId(s.ProductID, new ProductDN
{
ProductName = s.ProductName,
Supplier = new Lite<SupplierDN>(s.SupplierID.Value),
Category = new Lite<CategoryDN>(s.CategoryID.Value),
QuantityPerUnit = s.QuantityPerUnit,
UnitPrice = s.UnitPrice.Value,
UnitsInStock = s.UnitsInStock.Value,
ReorderLevel = s.ReorderLevel.Value,
Discontinued = s.Discontinued,
})));
}
}
请注意,Northwind 数据库允许某些不应为空的字段为空,但由于数据不包含任何空值,我们可以安全地使用 Value
属性。
此外,在供应商和类别的情况下,我们再次手动创建了 Lite,并利用了我们正在加载具有旧 ID 的实体这一事实。
如果我们尝试运行此代码,我们将收到一些验证错误,所有这些都像这样
Units In Stock has to be greater than 0
我们的 UnitsInStock
验证器有一个小错误,我们应该允许 0 是有效的。让我们将 ComparisonType.GreaterThan
更改为 ComparisonType.GreaterThanOrEqual
。
有了这个修复,我们应该能够加载产品(不需要更改模式)。
加载客户
在之前的教程中,我们决定将客户分为两个不同的类,PersonDN
和 CompanyDN
。你可以为此发明任何业务原因,但我们这样做只是为了解释 Signum Framework 中继承的工作原理。
现在,在加载应用程序中,我们必须使用某些标准来拆分数据。我们选择将所有 ContactTitle
为“Owner”的客户设为 PersonsDN
,否则为 CompanyDN
。
现在让我们从加载公司开始。像往常一样,我们创建一个 CustomerLoader
静态类,并添加一个类似这样的方法
public static void LoadCompanies()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
db.Customers.Where(c => !c.ContactTitle.Contains("Owner")).Select(c =>
new CompanyDN
{
CompanyName = c.CompanyName,
ContactName = c.ContactName,
ContactTitle = c.ContactTitle,
Address = new AddressDN
{
Address = c.Address,
City = c.City,
Region = c.Region,
PostalCode = c.PostalCode,
Country = c.Country,
},
Phone = c.Phone.Replace(".", " "),
Fax = c.Fax.Replace(".", " "),
}).SaveList();
}
}
请注意,这次我们没有尝试保留旧 ID,因为它是客户的一串字母,而是使用 Database.SaveList
,这也是一个扩展方法。
请注意我们如何利用经验来解决电话和传真号码中带点的问题
此外,这次我们应该允许传真号码是可选的(字段和属性都可选),如果我们看一下数据,ContactTitle 比我们创建实体时预期的要长一点(10 个字符),所以让我们将其改为 30 个字符。
这些更改将影响模式,因此我们先创建一个同步脚本。
ALTER TABLE CompanyDN ALTER COLUMN ContactTitle NVARCHAR(30) NOT NULL;
ALTER TABLE CompanyDN ALTER COLUMN Fax NVARCHAR(24) NULL;
ALTER TABLE PersonDN ALTER COLUMN Fax NVARCHAR(24) NULL;
如果我们尝试加载,我们将得到一个错误:
Postal Code is not set
爱尔兰有一家公司没有邮政编码,稍加研究你就会发现爱尔兰人不用邮政编码(生活就是这么复杂!),所以我们改进一下 AddressDN
来处理这种情况。
我们不想仅仅因为爱尔兰就将 PostalCode
设为可选,相反,我们将使其在国家是“爱尔兰”时可选。为了做到这一点,我们必须在验证器中将 AllowNulls = true
,并从字段中删除 NotNullable
,如下所示
[SqlDbType(Size = 10)]
string postalCode;
[StringLengthValidator(AllowNulls = true, Min = 3, Max = 10)]
public string PostalCode
{
get { return postalCode; }
set { Set(ref postalCode, value, () => PostalCode); }
}
但我们可以像上一个教程中那样,在 Address
类中重写 PropertyValidation
,如下所示
protected override string PropertyValidation(PropertyInfo pi)
{
if (pi.Is(() => PostalCode))
{
if(string.IsNullOrEmpty(postalCode) && Country != "Ireland")
return Signum.Entities.Properties.Resources._0IsNotSet.Formato(pi.NiceName());
}
return null;
}
看看我们如何使用 Signum.Entities
中的资源来创建错误消息,以便我们利用所有本地化的错误消息。
我们还在 PropertyInfo
上使用了扩展方法 NiceName
。Signum framework 提供了一个基础设施,通过使用属性、资源文件和 NiceName
/NiceToString
方法来本地化类型和属性名称以及枚举值。这些本地化将用于用户界面、动态查询、错误消息和自动生成的帮助。
其中一些更改将修改我们的模式,因此让我们同步一下
ALTER TABLE CompanyDN ALTER COLUMN Address_PostalCode NVARCHAR(10) NULL;
ALTER TABLE EmployeeDN ALTER COLUMN Address_PostalCode NVARCHAR(10) NULL;
ALTER TABLE SupplierDN ALTER COLUMN Address_PostalCode NVARCHAR(10) NULL;
ALTER TABLE PersonDN ALTER COLUMN Address_PostalCode NVARCHAR(10) NULL;
ALTER TABLE OrderDN ALTER COLUMN ShipAddress_PostalCode NVARCHAR(10) NULL;
如果我们再试一次,现在公司应该能够毫无问题地加载。
CorruptEntity(损坏的实体)
现在我们关注 PersonDN
。一个类似之前的方法应该可以完成工作,让我们试试看
public static void LoadPersons()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
db.Customers.Where(c => c.ContactTitle.Contains("Owner")).Select(c =>
new PersonDN
{
FirstName = c.ContactName.Substring(0, c.ContactName.LastIndexOf(' ')),
LastName = c.ContactName.Substring(c.ContactName.LastIndexOf(' ') + 1),
DateOfBirth = null,
Title = null,
Address = new AddressDN
{
Address = c.Address,
City = c.City,
Region = c.Region,
PostalCode = c.PostalCode,
Country = c.Country,
},
Phone = c.Phone.Replace(".", " "),
Fax = c.Fax.Replace(".", " "),
}).SaveList();
}
}
请注意我们现在如何选择名称为所有者的客户,以及我们如何将 ContactName
分割为名字和姓氏。
然而我们有一个问题:我们没有 DateOfBirth
或 Title
。
假设有一个重要的业务需求是自动向我们的个人客户发送生日信件,为此我们需要这些字段。然而,在这种情况下,询问他们的出生日期不是一个选项。
我们希望能够加载客户,但下次他们下单时,这些字段会显示为错误,因此他们必须修复它们。
Signum Framework 允许通过使用 Corruption
类和验证属性上的 DisableOnCorrupt
属性来实现此行为。
我们首先要做的是,通过移除字段上的 NotNullable
属性,并设置 StringLengthValidator
的 DisableOnCorrupt=true
,来允许标题在数据库中为空。
应该看起来像这样:
[SqlDbType(Size = 10)]
string title;
[StringLengthValidator(AllowNulls = true, Min = 3, Max = 10, DisableOnCorrupt=true)]
public string Title
{
get { return title; }
set { Set(ref title, value, () => Title); }
}
此外,我们必须使 DateOfBirth
可为空(在属性和字段上)。并在属性上添加一个 NotNullValidator
,该验证器也为 DisableOnCorrupt
。
如果我们能继承自 CorruptEntity
,这已经足够了,但在这种情况下,由于 PersonDN
已经继承自 CustomerDN
,因此这不是一个选项,所以我们必须手动编程该模式。
为此,我们必须在 PersonDN
实体中创建一个名为“Corrupt”的新布尔类型字段。它应该看起来像这样
bool corrupt;
public bool Corrupt
{
get { return corrupt; }
set { Set(ref corrupt, value, () => Corrupt); }
}
这是一个普通的实体字段,也将有助于了解哪个 PerdonDN
包含无效数据。
然后我们必须覆盖实体全局验证的方式
public override string IdentifiableIntegrityCheck()
{
using (this.Corrupt ? Corruption.Allow() : null)
{
return base.IdentifiableIntegrityCheck();
}
}
这样,如果用户将实体设置为损坏状态,那么验证规则将在允许损坏的上下文中执行,这将影响具有 DisableOnCorrupt=true
的验证属性,但您也可以在自定义验证逻辑中使用此上下文信息,使用 Corruption.Strict
属性。
最后,我们必须找到一种方法,如果实体在严格模式下通过验证,则自动将 Corrupt = false
。我们可以通过重写 PreSaving
方法来实现,如下所示
protected internal override void PreSaving(Action graphModified)
{
base.PreSaving(ref graphModified);
if (this.Corrupt && string.IsNullOrEmpty(base.IdentifiableIntegrityCheck()))
{
this.Corrupt = false;
}
}
这样做应该足够了,我们来同步一下模式
ALTER TABLE PersonDN ALTER COLUMN Title NVARCHAR(10) NULL;
ALTER TABLE PersonDN ALTER COLUMN DateOfBirth DATETIME NULL;
ALTER TABLE PersonDN ADD Corrupt BIT NOT NULL; -- DEFAULT( );
由于我们还没有任何人员,所以不需要为 Corrupt 编写 DEFAULT 值。
最后,在尝试加载人员之前,我们必须将 Corrupt = true
以允许每个实体损坏。如果实体未损坏,它将自动关闭。
有了这段代码,PersonDN
客户应该能够被加载。
挂接到引擎
为了给程序员提供更多的控制和扩展点,引擎提供了两种方式来在引擎操作之前或之后挂接用户代码
- 重写实体上的方法:当要运行的代码不依赖于数据库或服务器中可用的任何其他资源时,此方法很方便。
- 处理
EntityEvents
类中公开的事件:如果存在这些依赖项,则此方法很方便。
注意:每个类都有一组 EntityEvents
,可以通过 EntityEvent
方法访问,以及一个使用 EntityEventsGlobal
的全局事件,两者都在 Schema
对象上。
PreSaving(保存前)
在保存之前,图中的每个实体都会调用此方便的方法。该方法也会在验证之前调用。
如果该方法设置了实体或集合类型的属性(不仅仅是值),并且图发生了修改,则应将 graphModified 参数设置为 true(不要设置为 false!),以便验证并保存更改。
EntityEvents
还有一个事件,允许 PreSaving
的服务器端版本,以及一个在之后抛出的 Saving
事件。事件的确切顺序如下
保存顺序
- 图已创建
- 实体(Entitie)的
PreSaving
虚方法。如果graphModified = true
,则重新创建图 EntityEvent
的PreSaving
事件。如果graphModified = true
,则重新创建图- 实体图被验证。
EntityEvent
的Saving
事件。- 存储到数据库中。
PostRetrieving(检索后)
当您想要在实体从数据库中检索后执行代码时,可以使用对称的虚方法 PostRetrieving
。在 EntityEvents
中,还有另外一对方法,它们按以下顺序执行
- EntityEvents 的
Retrieving
事件。 - 从数据库检索。
- 实体(Entitie)的
PostRetrieving
方法。 - EntityEvents 的
Retrieved
事件。
Deleting(删除中)
EntityEvents
上的 Delete
事件在删除一组实体之前触发。
FilterQuery(过滤查询)
最后,FilterQuery
事件允许在每次调用 Database.Query
时,通过添加一个隐藏的 Where 子句来全局过滤所有查询。此过滤器仅对每个类可用,而不是全局可用。
加载承运人
加载承运人应该没有问题。让我们创建一个新的 OrderLoader
类并添加一个类似这样的方法
public static void LoadShippers()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
Administrator.SaveListDisableIdentity(db.Shippers.Select(s =>
Administrator.SetId(s.ShipperID, new ShipperDN
{
CompanyName = s.CompanyName,
Phone = s.Phone,
})));
}
}
我们将其添加到菜单中,应该可以正常工作。
加载订单
就像我们创建实体时一样,按照依赖关系,我们将完成订单的加载。
加载 CustomersDN
存在一些小困难
- 我们忘记在上次教程中创建 decimal 类型的
Freight
属性,现在我们来创建它。 - 我们将需要一个嵌入式子查询来加载
OrderDetail
集合。
一段类似这样的代码应该可以完成工作
public static void LoadOrders()
{
using (NorthwindDataContext db = new NorthwindDataContext())
{
Administrator.SaveListDisableIdentity(db.Orders.Select(o =>
Administrator.SetId(o.OrderID, new OrderDN
{
Employee = new Lite<EmployeeDN>(o.EmployeeID.Value),
OrderDate = o.OrderDate.Value,
RequiredDate = o.RequiredDate.Value,
ShippedDate = o.ShippedDate,
ShipVia = new Lite<ShipperDN>(o.ShipVia.Value),
ShipName = o.ShipName,
Freight = o.Freight,
ShipAddress = new AddressDN
{
Address = o.ShipAddress,
City = o.ShipCity,
Region = o.ShipRegion,
PostalCode = o.ShipPostalCode,
Country = o.ShipCountry,
},
Details = o.Order_Details.Select(od=>new OrderDetailsDN
{
Discount = od.Discount,
Product = new Lite<ProductDN>(od.ProductID),
Quantity = od.Quantity,
UnitPrice = od.UnitPrice,
}).ToMList(),
Customer = null,
})));
}
}
有大量的订单需要加载,所以这次我们不用查询和 Administrator.SaveListDisableIdentity
,而是使用正常的 foreach 循环,手动 DisableIdentity
,并创建 Transaction
。
我们还可以使用 ProgressEnumerator
来显示方法运行时完成的百分比。
经过这些修改,代码将如下所示
using(Transaction tr = new Transaction())
using (Administrator.DisableIdentity<OrderDN>())
{
IProgressInfo info;
foreach (Order o in db.Orders.ToProgressEnumerator(out info))
{
Administrator.SetId(o.OrderID, new OrderDN
{
Employee = new Lite<EmployeeDN>(o.EmployeeID.Value),
(…)
Customer = null
}).Save();
SafeConsole.WriteSameLine(info.ToString());
}
tr.Commit();
}
然而,我们必须面对一个更大的问题:加载 Customer 属性。
加载客户更困难,原因有三
- 它是一个完整的实体,而不是
Lite
,所以我们需要先检索它。 - 它是一个多态关系。有时客户将是
PersonDN
,有时是CompanyDN
。 - 这次 ID 不一致。
由于 Signum Framework 强制实体的主键类型为 int,名称为 Id,我们无法保留客户的旧 Id。现在我们必须面对将旧 Id,如 'ALFKI'、'ANATR' 转换为 [PersonDN, 1]
、[Customer, 3]
等对的问题,也许可以使用这样的字典:
Dictionary<string, Tuple<Type, int>> customerMapping;
然而,Signum Framework 已经提供了一种优雅的方式来使用这种类型-ID 对:Lites
事实上,Lites 有两种类型,静态类型(Lite
中的 T)和另一种 RuntimeType
,它可以赋值给 T(就是 T 或者继承自 T)。如果我们尝试使用一个不继承自静态类型的 RuntimeType
,我们将得到一个运行时错误。
通过这种方式,我们可以表示一个 Lite
,它引用 Id=4 的 PersonDN
,或者 Id=1 的 CompanyDN
。所以我们Instead 将使用这个字典
Dictionary<string, Lite<CustomerDN>> customerMapping;
为了填充这样的字典,我们需要创建一个查询,获取旧 ID(Northwind)和 Lites(Southwind)。不幸的是,Linq 提供程序都无法访问另一个数据库,所以我们必须在内存中通过 ContactName 进行连接。让我们从查询 Northwind 开始
var northwind = db.Customers.Select(a => new { a.CustomerID, a.ContactName }).ToList();
此外,正如我们在之前的教程中看到的,实体上的 ToLite
方法也可以通过明确添加不同的 T 参数来创建“多态 Lite”。因此对于公司来说,它将是:
var companies = Database.Query<CompanyDN>().Select(c => new
{
Lite = c.ToLite<CustomerDN>(),
c.ContactName
}).ToList();
And for PersonsDN we will have to re-compose the ContactName:
var persons = Database.Query<PersonDN>().Select(p => new
{
Lite = p.ToLite<CustomerDN>(),
ContactName = p.FirstName + " " + p.LastName
}).ToList();
最后,为了在内存中连接列表并创建字典,我们需要一个类似这样的查询
Dictionary<string, Lite<CustomerDN>> customerMapping =
(from n in northwind
join s in companies.Concat(persons) on n.ContactName equals s.ContactName
select new KeyValuePair<string, Lite<CustomerDN>>(n.CustomerID, s.Lite)).ToDictionary();
脏活 #1
我现在必须承认,通过 ContactName
连接数据,至少可以说是一个危险的黑客行为。
一种更安全的可能性是,在我们的 CustomerDN
实体上包含一个字符串类型的 OldCustomerID
属性(这样它将在 PersonDN
和 CompanyDN
表中创建字段)。加载实体后,我们可以删除该属性并同步,或者只是保留它以供将来参考。
然而,在这种情况下,我选择这种方式是为了展示如何在内存中连接来自不同数据库的数据。
有了这个有用的字典,我们可以修改查询来加载客户,如下所示
Customer = customerMapping[o.CustomerID].RetrieveAndForget();
使用 RetrieveAndForget
,我们可以从数据库中加载 CustomerDN
(一个接一个),而无需加载 Lite。如果我们只使用 Retrieve
,Lite 将为将来缓存实体(Lite.EntityOrNull
),从而线性增加内存消耗。
脏活 #2
此外,如果 CustomerDN
的数量可以放入内存,我会考虑将字典从 string 类型改为 CustomerDN
(而不是 Lite),并通过一次查询全部填充,而不是一个一个地检索每个客户。
我选择这种方式是为了展示 Lite 如何拥有静态类型和运行时类型。
在加载之前,我们需要同步以创建新的 Freight
属性。
ALTER TABLE OrderDN ADD Freight DECIMAL(18,2) NOT NULL -- DEFAULT( );
然后,当我们尝试加载订单时,我们会遇到一些恼人的错误,所有这些都像这样
Discount should be multiple of 5%
看起来有些 OrderDetails
不符合规则
- 1 个 6%
- 1 个 4%
- 3 个 3% 折扣
- 1 个 1%
这次我们不能使用损坏的实体,因为这些问题无法修复(因为这会影响订单的价格,而这是不允许的)。
同样不允许删除 5% 的多重验证规则,因为我们不希望将来出现这种折扣。
那我们该怎么办?
唯一的解决方案是在订单中创建一个新的 IsLegacy
属性,并且只允许旧订单中的此类折扣。这看起来比实际更容易。
为此,我们必须修改在 OrderDetailsDN
上进行的 PropertyValidation
,当订单是旧版时禁用它……但 OrderDetailDN
没有对其父级的引用!
我们面临的危机有很多不同的解决方案
我们可以从 OrderDetailsDN
创建对 OrderDN
的引用,但这会在我们的数据模型中造成冗余。即使我们避免在数据库中表示该引用(通过在字段上添加 IgnoreAttribute
),我们仍然必须管理保持这种冗余的复杂性。
另一种选择是在 OrderDetailsDN
上创建一个 ValidateDiscount
事件,由 OrderDN
捕获,但附加和分离事件具有相同的复杂性。
幸运的是,Signum.Entities 提供了一种声明性方法来执行此操作。只需在 OrderDN
上的“details”字段上添加 [ValidateChildProperty]
属性,并覆盖 ChildPropertyValidation
,我们就可以控制子实体属性的验证消息。
基本实体将管理所有这些复杂性以保持事件附加。
在这种情况下,让我们将代码从 OrderDetailsDN.PropertyValidation
移动到 OrderDN.ChildPropertyValidation
,并进行一些更改,如下所示
protected override string ChildPropertyValidation(ModifiableEntity sender,
PropertyInfo pi, object propertyValue)
{
OrderDetailsDN details = sender as OrderDetailsDN;
if (details != null && !IsLegacy && pi.Is(() => details.Discount))
{
if ((details.Discount * 100) % 5 != 0)
return "Discount should be mutiple of 5%";
}
return base.ChildPropertyValidation(sender, pi, propertyValue);
}
最后一步是在我们的 LoadOrders 查询中将 IsLegacy 设置为 true。
最后,我们只需再次同步数据库以添加最新的更改。
ALTER TABLE OrderDN ADD Freight DECIMAL(18,2) NOT NULL -- DEFAULT( );
ALTER TABLE OrderDN ADD IsLegacy BIT NOT NULL -- DEFAULT( );
有了这段代码,订单应该就可以进入您的数据库了!
结论
作为集中验证、代码优先方法、模式同步和用户界面生产力提升的回报,Signum Framework 强制您创建新的模式并将数据加载到其中。
关于如何加载旧数据的教程并不多,由于这是 Signum Framework 中强制性的一步,我们认为引导您完成此过程是公平的。
然而,由于 Southwind 模式被设计为 Northwind 的模拟,所以数据并没有真正的大转换,但我们确实看到了一些有趣的技巧
- 保留旧的 Id
- 使用 CSV 文件完善我们的数据
- 合并来自不同数据库的信息并处理旧的非数字 ID
- 处理 Lite 和继承。
此外,我们还学到了更多关于验证系统为了让数据进入而能做什么的事情
- 再次使用属性和属性验证
- 使用 DisableOnCorrupt 禁用某些实体的某些验证规则,这样我们就可以在生产环境之后延迟修复某些旧数据。
- 使用 ChildPropertyValidation 使父实体对子实体添加验证规则。
在接下来的教程中,我们将重点关注用户界面。首先,我们将使用 Razor 和新的 Signum.Web 库创建一个 ASP.Net MVC 3.0 应用程序,然后使用 Signum.Windows 创建一个 WPF 应用程序。
NotifyCollectionChangeAttribute(通知集合更改属性)
当放置在 MList
字段上时,它将当前实体的受保护方法 ChildCollectionChanged
订阅到 MList 的 CollectionChanged
事件。
NotifyChildPropertyAttribute(通知子属性属性)
当放置在 ModifiableEntity
字段或 ModifiableEntity
列表 MList
上时,它会将受保护的方法 ChildPropertyChanged
订阅到实体的 PropertyChanged
事件。
ValidateChildPropertyAttribute(验证子属性属性)
当放置在 ModifiableEntity
字段或 ModifiableEntity
列表 MList
上时,它会将受保护的方法 ChildPropertyValidation
订阅到实体的 ExternalPropertyValidation
事件。
为了保持这些事件的附加,实体执行以下操作
- 每次将实体设置到属性中、添加到集合中或设置整个集合时,都附加事件。
- 如果实体从属性中清除、从集合中移除或整个集合被清除,则分离事件。
- 避免事件字段被序列化。 (
[field:NonSerialized]
) - 反序列化后重新附加。
- 避免事件字段存储在数据库中 (
[field:IgnoreAttribute]
) - 在 PostRetrieving 中重新附加。