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; } }
当然,也需要给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表示写法,就是找到属性B的意思。命名a不固定,可以随意,q=>q.B也是可以的。但是B是A类的属性,故习惯用小写a。
Has方法:
- HasOptional:前者包含后者一个实例或者为null
- HasRequired:前者(A)包含后者(B)一个不为null的实例
- HasMany:前者包含后者实例的集合
With方法:
- WithOptional:后者(B)可以包含前者(A)一个实例或者null
- WithRequired:后者包含前者一个不为null的实例
- 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住宿类是外键表,这个想想也知道必须要让数据库上下文跟踪到,因为我们太可能直接操作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配置的所有外键关系默认都是级联删除的,意思就是删除主键值了,外键的所有值都自动删除:
为了演示,我们添加一个方法:
//级联删除(服务端延迟加载) 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。明确点说,这里并没有使用到数据库的级联删除,因为删除外键记录的指令都是程序发送给数据库的。因此上面的方法写的并不好,因为我们使用了贪婪加载(eager Loading),即Include方法。加载主键Destination类的时候,同时Include把两条外键记录也加载到了内存中,然后执行所有三条记录的删除操作。其实我们只需要加载主键记录到内存中就可以了,因为数据库已经打开了级联删除,我们只需要发送删除主键记录的指令到数据库,数据库会自动帮我们删除相关的外键记录。我们可以监控到如下sql:
直接复制到数据库执行查询,发现它会返回一条主键记录和两条外键记录。除非我们必须查出外键记录才使用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); // 关闭级联删除
再跑下程序,去看下数据库自然就没了级联删除。
ok,本文就到此结束,后续还有更通俗易懂的文章带你了解EF,请保持关注。本章源码
系列文章导航: