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表示写法,就是找到A类里的导航属性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住宿类是从表类,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
直接复制到数据库执行查询,发现它会返回一条主表数据和两条相关联的从表数据。除非必须查出外键记录才使用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,请保持关注。本章源码
EF Code First 系列文章导航: