EF⾥⼀对⼀、⼀对多、多对多关系的配置和级联删除
本章节开始了解EF的各种关系。如果你对EF⾥实体间的各种关系还不是很熟悉,可以看看我的思路,能帮你更快的理解。
I.实体间⼀对⼀的关系
添加⼀个PersonPhoto类,表⽰⽤户照⽚类
///<summary>
///⽤户照⽚类
///</summary>
public class PersonPhoto
{
[Key]
public int PersonId { get; set; }
public byte[] Photo { get; set; }
public string Caption { get; set; }  //标题
public Person PhotoOf { get; set; }
maxlength是什么意思
}
当然,也需要给Person类添加PersonPhoto的导航属性,表⽰和PersonPhoto⼀对⼀的关系:
public PersonPhoto Photo { get; set; }
直接运⾏程序会报⼀个错:
Unable to determine the principal end of an association between the types ‘Model.Per-sonPhoto’ and ‘Model.Person’. The principal end of this association must be explicitly configured using either the relationship fluent API or data annotations.
思考:为何第⼀节的Destination和Lodging类直接在类⾥加上导航属性就可以⽣成主外键关系,现在的这个不⾏呢?
解答:之前⽂章⾥的Destination和Lodging是⼀对多关系,既然是⼀对多,EF⾃然就知道设置Destination类的DestinationId为主键,同时设置Lodging类⾥的DestinationId为外键;但是现在的这个Person类和PersonPhoto类是⼀对⼀的关系,如果不⼿动指定,那么EF肯定不知道设置哪个为主键哪个为外键了,这个其实不难理解。按照逻辑Person类的PersonId肯定是主键了,直接标注[ForeignKey("PhotoOf")]即可,这是Data Annotation⽅式配置,⾃然也可以Fluent API⼀下,博主个⼈更喜欢这个⽅式。
在演⽰Fluent API如何配置Person类和PersonPhoto的⼀对⼀关系之前,先系统的学习下EF⾥实体关系配置的⽅法。EF⾥的实体关系配置分为Has和With系列的⽅法:Optional 可选的、Required 必须的、Many 多个。举例:
A.HasRequired(a => a.B).WithOptional(b => b.A);
这⾥的a=>a.B是lambda表⽰写法,就是到A类⾥的导航属性B。命名a不固定,可以随意,q=>q.B也是可以的。但是B是A类的属性,故习惯⽤⼩写a。
Has⽅法:
1. HasOptional:前者包含后者⼀个实例或者为null
2. HasRequired:前者(A)包含后者(B)⼀个不为null的实例
3. HasMany:前者包含后者实例的集合
With⽅法:
1. WithOptional:后者(B)可以包含前者(A)⼀个实例或者null
2. WithRequired:后者包含前者⼀个不为null的实例
3. WithMany:后者包含前者实例的集合
摘⾃这是较为好的理解⽅式。上⾯⼀句配置意思就是A类包含B类⼀个不为null的实例,B类包含A类⼀个实例,也可以不包含。最标准的⼀对⼀配置。ok,现在试着写下上⾯Person类和PersonPhoto类的⼀对⼀的关系如何配置:
this.HasRequired(p => p.PhotoOf).WithOptional(p => p.Photo);
再跑下程序,数据库就⽣成了,是⼀对⼀的关系。Person表可以没有对应的PersonPhoto表数据,但是PersonPhoto表每⼀条数据都必须对应⼀条Person表数据。意思就是⼈可以没有照⽚,但是有的照⽚必
须属于某个⼈。关系配置是这样的效果,其实可以随便改,也可以配置成每个⼈都必须有对应的照⽚。把上⾯的WithOptional改成WithRequired,对应到数据库⾥就是null变成了not null。
思考:这⾥并没有像之前⼀样添加⼀个实体类就同时添加到BreakAwayContext类中,但是为何照样能在数据库中⽣成PersonPhotos表?
解答:添加到BreakAwayContext类中是让数据库上下⽂能跟踪到这个类,⽅便进⾏CRUD(增查改删)。这⾥不把PersonPhoto类添加
到BreakAwayContext类中是因为程序中⼀般并不会去单独增删改查PersonPhoto类,对PersonPhoto类的操作都是先Person类,然后通过⼀对⼀的关系到PersonPhoto类,这个⽐较符合实际情况。数据库中能⽣成PersonPhotos就更好理解了,因为有这个实体类嘛。
思考:如果只需要加⼊主表类到BreakAwayContext类中,那么其他什么⼀对多,多对多的关系是不是都只要加主表类
到BreakAwayContext类中呢?
解答:还是需要根据实际情况考虑,上⾯的PersonPhoto类已经解释过了,实际情况中不太可能单独操作PersonPhoto类。⼀对多关系⾥Logding住宿类是从表类,Destination是其主表。这个想想也知道必须
要让数据库上下⽂跟踪到Lodging住宿类,因为太可能直接操作Lodging了。⽐如前台添加⼀个搜索住宿的功能,那是不是需要直接操作此从表了呢?肯定需要了。所以还是需要根据实际情况考虑。这⾥仅是个⼈观点,如有瑕疵,恳请指正。
II.实体间⼀对多的关系
之前的⽂章⾥,景点类Destination和住宿类Lodging是⼀对多的关系,这个很好理解:⼀个景点那有多个住宿的地⽅,⽽⼀个住宿的地⽅只属于⼀个景点。当然也可以没有,⼀个景点那⼀个住宿的地⽅就没有,⼀个住宿的地⽅不属于任何景点,这个也是可以的。之前的程序实现的就是互相不属于,全部可空。现在来配置下住宿的地⽅必须属于某个景点:
Data Annotations
直接在住宿类Lodging的导航属性上添加[Required]标注即可:
[Required]
public Destination Destination { get; set; }
Fluent API
this.HasMany(d => d.Lodgings).WithRequired(l => l.Destination).Map(l => l.MapKey("DestinationId"));
这⾏是在DestinationMap类⾥写的,对应到上⾯的描述,前者就是Destination,后者是Lodging。整句的意思就是:Destination类包含多个(HasMany)Lodging类实例的集合,Lodging类包含前者⼀个不为null(WithRequired)的实例。.MapKey是指定外键名的。此处如果住宿类不必须属于某个景点,那么直接把WithRequired换成WithOptional即可。查询的时候前者使⽤Inner join,后者使⽤Left join。不懂Inner、Left和Cross Join区别的点
上⾯是以Destination为前者的,当然也可以以Lodging为前者,去LodgingMap⾥写下如下配置,其实是⼀个意思:
this.HasRequired(d => d.Destination).WithMany(l => l.Lodgings).Map(l => l.MapKey("DestinationId"));
重跑下程序,⽣成的数据库Lodging表的外键已经设置成为了不可空,并外键名是指定的“DestinationId”:
官⽅给出的⼀对多的解释是这样的,其实还没我解释的通俗易懂,发个图你们感受下吧:
ok,上⾯说了⼀对多的关系,是标准的⼀对多关系,两个表⾥分别有导航属性。但是如果有列不遵循这个规则呢?
继续添加⼀个新类InternetSpecial,记录⼀些跟平常住宿价格不⼀样的类,节假⽇等。这个类不仅有导航属性Accommodation,还有主键列AccommodationId:
///<summary>
///住宿特殊价格类(节假⽇等)
///</summary>
public class InternetSpecial
{
public int InternetSpecialId { get; set; }
public int Nights { get; set; }  //⼏晚
public decimal CostUSD { get; set; }  //价钱
public DateTime FromDate { get; set; }
public DateTime ToDate { get; set; }
public int AccommodationId { get; set; }
public Lodging Accommodation { get; set; }
}
同时给住宿类Lodging添加⼀个InternetSpecial类的导航属性:
public List<InternetSpecial> InternetSpecials { get; set; }
配置好了跑下程序,⽣成的数据库表:
由表可见,不仅有AccommodationId列,还有个外键列Accommodation_LodgingId,明显这个是因为没有设置外键的原因,EF不知道要给哪个属性当外键。现在分别使⽤Data Annotation和Fluent API设置试试
Data Annotation:
[ForeignKey("Accommodation")]
public int AccommodationId { get; set; }
或者这样:
[ForeignKey("AccommodationId")]
public Lodging Accommodation { get; set; }
Fluent API:
this.HasRequired(s => s.Accommodation)
.WithMany(l => l.InternetSpecials)
.HasForeignKey(s => s.AccommodationId);  //外键
//如果实体类没定义AccommodationId,那么可以使⽤Map⽅法直接指定外键名:.Map(s => s.MapKey("AccommodationId"))
这个就不详细解释了,如果还看不懂,看看⽂章开头我分析的Has和With系列⽅法。配置好重新跑下程序,外键就是AccommodationId了,没有多余的Accommodation_LodgingId列了。
III.实体间多对多的关系
添加⼀个活动类Activity,跟旅⾏类Trip是多对多的关系。这个也不难理解:⼀个旅⾏有多个活动,⼀个活动可以属于多个旅⾏。
///<summary>
///活动类
///</summary>
public class Activity
{
public int ActivityId { get; set; }
//[Required, MaxLength(50)]
public string Name { get; set; }
public List<Trip> Trips { get; set; }    //和Trip类是多对多关系
}
跟之前的⼀样在BreakAwayContext类⾥添加Activity类,让数据库上下⽂知道Activity类:
public DbSet<CodeFirst.Model.Activity> Activitys { get; set; }
同时在Trip旅⾏类⾥添加上导航属性,形成跟Activity活动类的多对多关系
public List<Activity> Activitys { get; set; }
ok,已经可以了,跑下程序得到如下数据库:
可以看出,EF⾥的多对多关系是由第三张表来连接两个表的。ActivityTrips表连接了Activityes表和Trips表。表名列名都是默认命名,都可以⾃⼰配置。⽂章的开头已经说了那么多了,多对多肯定是⽤HasMany和WithMany⽅法,在ActivityMap类⾥写下如下Fluent API:
this.HasMany(a => a.Trips).WithMany(t => t.Activitys).Map(m =>
{
m.ToTable("TripActivities");      //中间关系表表名
m.MapLeftKey("ActivityId");        //设置Activity表在中间表主键名
m.MapRightKey("TripIdentifier");  //设置Trip表在中间表主键名
});
同样也可以在TripMap⾥配置,顺序不⼀样罢了:
this.HasMany(t => t.Activities).WithMany(a => a.Trips).Map(m =>
{
m.ToTable("TripActivities");    //中间关系表表名
m.MapLeftKey("TripIdentifier");  //设置Activity表在中间表的主键名
m.MapRightKey("ActivityId");    //设置Trip表在中间表的主键名
});
两种配置任选其⼀就可以了,重新跑下程序就可以了。都配置好了在程序⾥如何读取这个对多对的数据呢,简单写⼀句:
var tripWithActivities = context.Trips.Include("Activities").FirstOrDefault();
很明显,⽤到了Include贪婪加载把相关的外键表数据(如果有)也拿到了内存中:
是不是也需要考虑性能的问题呢?如果只需要修改主表的某个列,那贪婪加载出相关联的从表数据做什么?会发送很多冗余的sql到数据库。当然如果要根据主表从表数据的话,这么加载也是好事,超级⽅便。EF⼩组的原话是:Entity Framework took care of the joins to get across the join table without you having to be aware of its presence. In the same way, any time you do inserts, updates, or deletes within this many-to-many relationship, Entity Framework will work out the proper SQL for the join without you having to worry about it in your code.意思就是如果你配置好了主外键关系,EF会帮你⽣成合适的连表查询(join)sql,不会你再多费⼼。关于⼀对多、多对多的EF查询和效率问题,后续会有专门系列⽂章讲解。
IV.级联删除
EF配置的外键关系除了配置为Optional(可选的,也就是可空),其他默认都是级联删除的,意思就是删除主表的某个数据,相关联的从表数据都⾃动删除:
为了演⽰添加⼀个⽅法:
//级联删除(服务端延迟加载)
private static void DeleteDestinaInMemoryAndDbCascade()
{
int destinationId;
using (var context = new CodeFirst.DataAccess.BreakAwayContext())
{
var destination = new CodeFirst.Model.Destination
{
Name = "Sample Destination",
Lodgings = new List<CodeFirst.Model.Lodging>
{
new CodeFirst.Model.Lodging {Name="Lodging One"},
new CodeFirst.Model.Lodging {Name="Lodging Two"}
}
};
context.Destinations.Add(destination);  //添加测试数据
context.SaveChanges();
destinationId = destination.DestinationId;  //记住主键id
}
using (var context = new CodeFirst.DataAccess.BreakAwayContext())
{
//这⾥⽤了贪婪加载,把主键和相关的外键记录都加载到内存中了
var destination = context.Destinations.Include("Lodgings").Single(d => d.DestinationId == destinationId);
var aLodging = destination.Lodgings.FirstOrDefault();
context.Destinations.Remove(destination);
context.SaveChanges();
}
}
很简单,添加了⼀条主键数据Sample Destination,同时添加了以此主键为基础的两条外键数据:Lodging One和Lodging Two,即:添加了⼀个旅游景点,⼜添加了此旅游景点下的两个住宿的地⽅。之后延迟加载出主表数据和相关联的两条从表数据并删除,使⽤sql profiler能监测到如下sql:
第⼀条是删除主表的数据,后两条是删除相关联从表数据的sql。这种级联删除稍显⿇烦,同时加载了相关联从表的数据到内存中再发送删除命令到数据库。其实只需要加载要删除的主表记录到内存中就可以了,因为数据库已经打开了级联删除,只需要发送删除主表数据的指令到数据库,数据库会⾃动删除相关联的从表记录。可以监控到如下sql:
exec sp_executesql N'SELECT
[Project2].[DestinationId] AS [DestinationId],
[Project2].[Name] AS [Name],
[Project2].[Country] AS [Country],
[Project2].[Description] AS [Description],
[Project2].[image] AS [image],
[Project2].[C1] AS [C1],
[Project2].[LodgingId] AS [LodgingId],
[Project2].[Name1] AS [Name1],
[Project2].[Owner] AS [Owner],
[Project2].[IsResort] AS [IsResort],
[Project2].[MilesFromNearestAirport] AS [MilesFromNearestAirport],
[Project2].[PrimaryContact_PersonId] AS [PrimaryContact_PersonId],
[Project2].[SecondaryContact_PersonId] AS [SecondaryContact_PersonId],
[Project2].[DestinationId1] AS [DestinationId1]
FROM ( SELECT
[Limit1].[DestinationId] AS [DestinationId],
[Limit1].[Name] AS [Name],
[Limit1].[Country] AS [Country],
[Limit1].[Description] AS [Description],
[Limit1].[image] AS [image],
[Extent2].[LodgingId] AS [LodgingId],
[Extent2].[Name] AS [Name1],
[Extent2].[Owner] AS [Owner],
[Extent2].[IsResort] AS [IsResort],
[Extent2].[MilesFromNearestAirport] AS [MilesFromNearestAirport],
[Extent2].[PrimaryContact_PersonId] AS [PrimaryContact_PersonId],
[Extent2].[SecondaryContact_PersonId] AS [SecondaryContact_PersonId],
[Extent2].[DestinationId] AS [DestinationId1],
CASE WHEN ([Extent2].[LodgingId] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1]
FROM  (SELECT TOP (2)
[Extent1].[DestinationId] AS [DestinationId],
[Extent1].[Name] AS [Name],
[Extent1].[Country] AS [Country],
[Extent1].[Description] AS [Description],
[Extent1].[image] AS [image]
FROM [dbo].[Destinations] AS [Extent1]
WHERE [Extent1].[DestinationId] = @p__linq__0 ) AS [Limit1]
LEFT OUTER JOIN [dbo].[Lodgings] AS [Extent2] ON [Limit1].[DestinationId] = [Extent2].[DestinationId]
)  AS [Project2]
ORDER BY [Project2].[DestinationId] ASC, [Project2].[C1] ASC',N'@p__linq__0 int',@p__linq__0=3
View Code
直接复制到数据库执⾏查询,发现它会返回⼀条主表数据和两条相关联的从表数据。除⾮必须查出外键记录才使⽤Include贪婪加载,否则千万不要,EF中跟⼿写ado不⼀样,很容易⽣成很冗余的sql。这⾥其
实只需要主键的记录就可以了,修改下⽅法:
    //级联删除(仅加载主键记录)
private static void DeleteDestinationInMemeryAndDbCascade()
{
int destinationId;
using (var context = new CodeFirst.DataAccess.BreakAwayContext())
{
var destination = new CodeFirst.Model.Destination
{
Name = "Sample Destination",
Lodgings = new List<CodeFirst.Model.Lodging>
{
new CodeFirst.Model.Lodging {Name="Lodging One"},
new CodeFirst.Model.Lodging {Name="Lodging Two"}
}
};
context.Destinations.Add(destination);
context.SaveChanges();
destinationId = destination.DestinationId;
}
using (var context = new CodeFirst.DataAccess.BreakAwayContext())
{
var destination = context.Destinations
.Single(d => d.DestinationId == destinationId);  //只取⼀条主键记录
context.Destinations.Remove(destination);  //然后移除主键记录,外键记录⼜数据库级联删除
context.SaveChanges();
}
}
监控的sql⼲⼲净净,只会查出主表数据。
exec sp_executesql N'SELECT TOP (2)
[Extent1].[DestinationId] AS [DestinationId],
[Extent1].[Name] AS [Name],
[Extent1].[Country] AS [Country],
[Extent1].[Description] AS [Description],
[Extent1].[image] AS [image]
FROM [dbo].[Destinations] AS [Extent1]
WHERE [Extent1].[DestinationId] = @p__linq__0',N'@p__linq__0 int',@p__linq__0=1
补充:这⾥只查⼀条记录却使⽤SELECT TOP (2)... 是保证能查到记录。
删除sql更⼲净,只删除主表数据,相关联的从表数据删除由数据库级联删除完成:
exec sp_executesql N'delete [dbo].[Destinations]
where ([DestinationId] = @0)',N'@0 int',@0=1
级联删除虽然⽅便,但是并不常⽤。试想我们在博客园写了很多随笔,为不同随笔加了不同的标签好区分和管理。某⼀天突然发现之前定的某个标签并不合理,但是这个标签已经在很多随笔⾥⽤了,如果此时删除标签,数据库级联的把标注此标签的随笔都删了,这个肯定不合适。应该是标签删了,之前贴过此标签的⽂章没了这个标签,这个才符合逻辑。
数据库⾥可以可视化的设置不级联删除,Fluent API配置此外键关系时可以设置不级联删除:
this.HasMany(d => d.Lodgings).WithRequired(l => l.Destination)
.Map(l => l.MapKey("DestinationId")) //⼀对多并指定外键名
.WillCascadeOnDelete(false);  // 关闭级联删除
再跑下程序,去看下数据库本外键⾃然就没了级联删除。
园友提供了⼀个很好的建议:考虑到EF中的级联删除并不常⽤,所以可以在全局⾥关掉所有主外键关系的级联删除,如果需要可以打开某个主外键的级联删除。
@郭明锋:好⽂章,很久没有看到这么好的EF⽂章了,推荐
EF默认开启级联删除,确实是挺的设置,所以我的做法是在上下⽂的OnModelCreating⽅法中
modelBuilder.Conventions.Remove<OneToManyCascadeDeleteConvention>();
移除这个默认约定,再在需要开启级联删除的FluentAPI关系映射中⽤. WillCascadeOnDelete(true) 单独开启
ok,本⽂就到此结束,后续还有更通俗易懂的⽂章介绍EF,请保持关注。本章
1.
2.
3.
4.
5.