参考资料:
微软MVP杨旭教程:https://www.bilibili.com/video/BV1xa4y1v7rR?p=7
- 添加关系数据
- 用其它Context来添加关系数据
Attach()
与变化追踪- Add()、Update()、Attach()处理主键的对比
- 加载关联数据
- 预加载,
Include()
和ThenInclude()
- 显式加载,
Entry()
、Collection()
、Reference()
、Load()
与Query()
- 懒加载
- 使用关联数据的一些属性作为查询条件
- 多对多关系查询
- 预加载,
- 修改关系数据
- 离线状态
- 设置多对多的关系
- 删除多对多的关系
- 修改多对多关系
- 设置一对一关系
- 修改一对一关系
添加关系数据
目前一个League对应多个Club,一个Club又对应多个Player。
计划从数据库中查出League-"Serie A",然后往Serie A这个联赛中添加一个Club。观察League类中只有Id、Name、Country三个简单的属性,显然无法给League添加一个Club,但可以给Club添加一个League。可以new一个Club,把它的League属性设为前面查出来的Serie A,它们就关联起来了。
using var context = new DemoDbContext();
var serieA = context.Leagues.SingleOrDefault(x => x.Name == "Serie A");
var juventus = new Club
{
League = serieA,
Name = "Juventus",
City = "Torino",
DateOfEstablished = new DateTime(1897, 11, 1)
};
context.Clubs.Add(juventus);
var count = context.SaveChanges();
Console.WriteLine(count);
这就是第一种方法:在新建立的对象上指定导航属性的值,或者它引用的对象。
第二种,一个Club,它有多个球员Player,如何建立Player到Club的关系?Player类中没有与Club有关的属性,但Club类中有一个集合类的导航属性到Player,可以通过它建立Player和Club的关系。在new Club的时候,把Player属性赋上。可以向下面代码一样直接new新的Player,也可以查询出已有的player,感觉实际应用中应该基本都是查询出来的Player。
修改上面代码:
var juventus = new Club
{
League = serieA,
Name = "Juventus",
City = "Torino",
DateOfEstablished = new DateTime(1897, 11, 1),
Players = new List
{
new Player
{
Name = "C. Ronaldo",
DateOfBirth = new DateTime(1985, 2, 5)
}
}
};
然后再尝试往现有的俱乐部中添加一个人员。
Club中有一个集合类导航属性Players,可以直接通过它的Add方法来添加一个新的球员:
var juventus = context.Clubs.SingleOrDefault(x => x.Name == "Juventus");
juventus.Players.Add(new Player
{
Name = "Gonzalo Higuain",
DateOfBirth = new DateTime(1987, 12, 10)
});
var count = context.SaveChanges();
因为Club是被Context变化追踪的,上一篇中写的取消追踪的代码被我注释掉了,所以一旦添加了新的球员,Context就会知道这个球员是新加的,并且不用再new的Player里设置外键,因为直接用的juventus.Players.Add()。
注意:真实的项目中一般不使用Single()和First(),而是使用SingleOrDefault()和FirstOrDefault()。
用其它Context来添加关系数据
我们尝试用另一个新new出来的Context来给尤文图斯俱乐部添加球员。当然是先用原来的Context查出俱乐部来,新Context并没有追踪原Context查出来的Club。
using var context = new DemoDbContext();
var juventus = context.Clubs.SingleOrDefault(x => x.Name == "Juventus");
juventus.Players.Add(new Player
{
Name = "Matthijs de Ligt",
DateOfBirth = new DateTime(1999, 8, 12)
});
{
using var newContext = new DemoDbContext();
newContext.Clubs.Update(juventus);
var count = newContext.SaveChanges();
Console.WriteLine(count);
}
juventus对新new的Context来说就相当于离线的数据,就像是JSON里面传出来的。修改这种数据,应该使用Update()
方法。执行UpDate()
方法时,它就会发现new的Player是没有Id的,它是一个新的Player,而且跟juventus有一个外键的关系。这些对新Context来说都可以理解出来。
运行一下,发现存在一些问题:
我们只想添加一个球员,但它把整个尤文图斯俱乐部都更新(Update)了一遍。
Attach()
与变化追踪
通过调用DbSet或者Context上的Add()、Update()、Remove()
方法,都会有变化追踪这种效果,实际上还有Attach()
方法,这个方法也经常使用。Attach是附加的意思,我们把前面代码中的Update改成Attach,来把这个俱乐部附加上。
附加上这个尤文图斯对象后,这个对象处于未修改的状态,所以Context不会对它进行修改,但却已经追踪了。Context会发现这个对象中有一个Player,是没有主键的,所以这个Player属于新增的对象,Context就会针对这个新增的对象生成INSERT语句,然后插入到数据库里。我们在new一个新Player尝试一下:
juventus.Players.Add(new Player
{
Name = "Miralem Pjanic",
DateOfBirth = new DateTime(1990, 4, 2)
});
{
using var newContext = new DemoDbContext();
newContext.Clubs.Attach(juventus);
var count = newContext.SaveChanges();
Console.WriteLine(count);
}
再运行,发现没有Update语句了。INSERT中把新Player的ClubId设为了2。
尝试一下能否直接赋予外键。我们给C罗添加一个简历Resume,已知C罗的PlayerId为1。
var resume = new Resume
{
PlayerId = 1,
Description = "..."
};
context.Resumes.Add(resume);
var count = context.SaveChanges();
Console.WriteLine(count);
执行后发现是可以的。
Add()、Update()、Attach()处理主键的对比
参考上表,Add()
传进去的数据有主键的话,也就是主键这个属性有值的话,就是把这条数据添加到数据库里。但如果这个表的主键我们设置为自动生成的话,我们又手动赋了主键,就会抛出一个异常。
如果Update()
方法传进去的数据有主键的话,就会修改原来的主键,换成新赋的主键,而不是新增。
但如果有主键的数据使用Attach()
方法附加到Context上,就不会发生任何变化。
没有主键的数据使用这三个方法,都是添加数据。包括它们关联的数据,如果没有主键,也都是这个效果。
加载关联数据
加载关联数据有三种方法
- 预加载,Eager loading
- 显式加载,Explicit loading
- 懒加载,Lazy loading
预加载,Include()
和ThenInclude()
尝试查询所有俱乐部,并且顺便把它所属的联赛也查询出来,需要使用Include()
:
var clubs = context.Clubs
.Where(x => x.Id > 0)
.Include(x => x.League)
.ToList();
同时可以添加限定条件,使用Where()
。注意顺序,要先Where()
,再Include()
,最后可以ToList()
或者FirstOrDefault()
等。
如果用DbSet的Find()
方法,就无法使用Include()
。
如果想顺便把俱乐部里的球员也查出来,就再加一个Include()
:
var clubs = context.Clubs
.Where(x => x.Id > 0)
.Include(x => x.League)
.Include(x => x.Players)
.ToList();
这样就把Club关联的两个属性:Leagues和Players都加进来了。如果还想再把Player的关联属性也加进来,当然就不能再使用Include()
了,因为Include()
是针对Club的关联属性。所以对Player,可以使用另外一个方法叫ThenInclude()
,相当于级联地添加关联数据:
var clubs = context.Clubs
.Where(x => x.Id > 0)
.Include(x => x.League)
.Include(x => x.Players)
.ThenInclude(y => y.Resume)
.ToList();
如果想把Players的GamePlayers这个关联属性也添加进来,这时候就要注意了。如果继续写ThenInclude()
,这个Then就是针对Resume的,而不是Players的了。可以再写一次Include()
和ThenInclude()
这种语句,相当于把后面的ThenInclude()
关联到新的Include()
:
var clubs = context.Clubs
.Where(x => x.Id > 0)
.Include(x => x.League)
.Include(x => x.Players)
.ThenInclude(y => y.Resume)
.Include(x => x.Players)
.ThenInclude(y => y.GamePlayers)
.ToList();
如果还要继续查GamePlayer的关联属性,这时候就可以在后面用ThenInclude()
了,它关联的是GamePlayer:
var clubs = context.Clubs
.Where(x => x.Id > 0)
.Include(x => x.League)
.Include(x => x.Players)
.ThenInclude(y => y.Resume)
.Include(x => x.Players)
.ThenInclude(y => y.GamePlayers)
.ThenInclude(z => z.Game)
.ToList();
可以看一下这个查询结果生成的SQL语句:
再使用另一种形式,我们只查询出部分表的特定的部分属性,只获取我们想要的部分信息,而且我们想要对我们的关联数据再按条件查询,这就用到了Select()
,还使用了匿名类,其中 关联数据还使用了Where()
:
var info = context.Clubs
.Where(x => x.Id > 0)
.Select(x => new
{
x.Id,
LeagueName = x.League.Name,
x.Name,
Players = x.Players
.Where(p => p.DateOfBirth > new DateTime(1990, 1, 1))
})
.ToList();
// Context无法变化追踪匿名类,只能追踪在它识别的在它里面声明的或者未声明的关联类
这种查询结果是一个匿名类,无法被Context变化追踪。但其中的Players
如果发生任何变化,是可以识别出来的。
显式加载,Entry()
、Collection()
、Reference()
、Load()
与Query()
再举一个例子。先把一个Club查出来,但不包含它的任何关联数据,这样这个Club就在内存里了:
var info = context.Clubs.First(); // 查询之后info就在内存中了
然后再把它关联的数据查出来。看一下Club类,它关联的数据有League和Players这两个,一个是单个的,一个是集合的。
对集合的Players,把info放在Entry()
(入口)的参数中。因为关联属性Players是一个集合,所以用Collection()
方法,再使用Load()
就会进行查询。
context.Entry(info)
.Collection(x => x.Players)
.Load();
对这种集合导航属性,我们加载的时候还可以加上一些过滤条件,在Collection()
后面使用Query()
,再在Query()
后加上Where()
条件:
context.Entry(info)
.Collection(x => x.Players)
.Query()
.Where(x => x.DateOfBirth > new DateTime(1990, 1, 1))
.Load();
对单个的关联属性,不再使用Collection()
,而是使用Reference()
:
context.Entry(info)
.Reference(x => x.League)
.Load();
这种显式加载有一个缺点,只能针对单个数据进行加载,比如这里的info就是一个Club。如果式针对一个List
,则无法使用这种形式。
懒加载
懒加载在EF Core中默认是关闭的,可以手动开启,但会引起很多问题,使用的很少。
使用关联数据的一些属性作为查询条件
查Club的时候,可以使用它的关联属性League的Name属性作为查询条件:
var data = context.Clubs
.Where(x => x.League.Name.Contains("e"))
.ToList();
多对多关系查询
Game和Player之间通过一个中间类GamePlayer形成了多对多关系。无法直接查出多对多关系,但可以间接查出来。可以在查Player时把GamePlayer给Include进来,再通过它查Game的数据。
我们先建立一个这种关系。
using var context = new DemoDbContext();
var player = context.Players
.Where(p => p.Name == "C. Ronaldo").FirstOrDefault();
var game = new Game
{
Round = 2
};
// context.Games.Add(game);
var gamePlayer = new GamePlayer
{
Game = game,
Player = player
};
context.GamePlayers.Add(gamePlayer);
var count = context.SaveChanges();
Console.WriteLine(count);
可以看到其中我们Add Game的语句被注释掉了,但最终Context还是为我们Add了一条Game数据,比较智能。然后进行间接多对多查询:
var data = context.Players
.Where(p => p.Id > 0)
.Include(p => p.GamePlayers)
.ThenInclude(x => x.Game)
.ToList();
假设我们的GamePlayer并没有在Context中声明为DbSet,可以使用Context.Set
来查它:
var gamePlayers = context.Set()
.Where(x => x.Player.Id > 0)
.ToList();
修改关系数据
查出一个Club,并且查出它关联的League,然后修改这个League的Name:
using var context = new DemoDbContext();
var club = context.Clubs.Include(x => x.League).First();
club.League.Name += "@";
var count = context.SaveChanges();
Console.WriteLine(count);
可以看到EF Core非常智能,它追踪到了League,并且生成了相应的Update语句。
离线状态
查一下Game,Game和Player时多对多的关系,中间通过一个GamePlayer关联,我们都查出来。然后取出这个Game的第一个GamePlayer,作为需要修改的对象,改一下它的名称。
因为我们要模拟离线状态,比如说是从JSON中获取的数据,所以我们new一个新的Context来修改,并使用Update()
方法。
using var context = new DemoDbContext();
var game = context.Games
.Include(x => x.GamePlayers)
.ThenInclude(y => y.Player)
.First();
var firstPlayer = game.GamePlayers[0].Player;
firstPlayer.Name += "$";
{
var newContext = new DemoDbContext();
newContext.Players.Update(firstPlayer);
newContext.SaveChanges();
}
使用Update方法会更新firstPlayer上除了主键之外所有的属性,实际上我们可以接受。
我们意愿是修改一条数据,但实际上出现了两个Update操作。如果我们这个Game下有两个GamePlayer的话,可能会出现三个Update操作,一个是对Game的,两个是对Player的。UpDate()
会把它的参数的所有关联的对象都更新一遍。
解决方法:
{
var newContext = new DemoDbContext();
newContext.Entry(firstPlayer).State = EntityState.Modified;
// newContext.Players.Update(firstPlayer);
newContext.SaveChanges();
}
用COntext的Entry()
方法,把firstPlayer传进去,Entry上面有一个状态(state)字段,把它设置为EntityState.Modified
就行了。相当于手动设置它的状态,这样它就只会修改这一个firstPlayer数据,我理解为它不再级联地追踪其他数据。
再运行,发现只有一个查询语句和一个Update语句了。
设置多对多的关系
我们创建Game和Player的多对多的关系。直到GameId和PlayerId,就可以new一个它们之间的GamePlayer,然后把外键都设上值就可以了。
using var context = new DemoDbContext();
var gamePlayer = new GamePlayer
{
GameId = 1,
PlayerId = 3
};
context.Add(gamePlayer);
context.SaveChanges();
可以看到即使我们没有用GamePlayers这个DbSet,也可以直接添加数据。
第二种情况,我们先把Game查询出来,Game中有GamePlayers这个导航属性,用这个导航属性添加GamePlayer就可以。这样因为是通过Game来添加,就不必设置GameId,只设置PlayerId即可:
var game = context.Games.FirstOrDefault();
game.GamePlayers.Add(new GamePlayer
{
PlayerId = 4
});
删除多对多的关系
我们直到GameId为1和PlayerId为4的Game和Player存在关系,想要删除这个关系,可以new一个GamePlayer,把它两个外键分别设为1和4,然后调用``Remove()`方法把这个GamePlayer删掉。更好的办法是从数据库中查出1和4的这个关系然后删掉。
using var context = new DemoDbContext();
var gamePlayer = context.GamePlayers
.Where(x => x.GameId == 1 && x.PlayerId == 4)
.FirstOrDefault();
context.GamePlayers.Remove(gamePlayer);
context.SaveChanges();
修改多对多关系
比如把GameId=1和PlayerId=4的关系改为GameId=1和PlayerId=3的关系,实际上是不行的,我们不能通过EF Core修改它的联合主键值,可以通过SQL语句来修改。如果硬要使用EF Core的话,可以分成两步,首先删除原来1和4的关系,再建立新关系。
using var context = new DemoDbContext();
var gamePlayer = context.GamePlayers
.Where(x => x.GameId == 1 && x.PlayerId == 4)
.FirstOrDefault();
context.GamePlayers.Remove(gamePlayer);
var game = context.Games
.Where(x => x.Id == 1)
.FirstOrDefault();
var player = context.Players
.Where(x => x.Id == 3)
.FirstOrDefault();
var newGamePlayer = new GamePlayer
{
GameId = game.Id,
PlayerId = player.Id
};
context.GamePlayers.Add(newGamePlayer);
context.SaveChanges();
设置一对一关系
从数据库中取出一个Player,给它搞一个Resume,新new的也可以,因为它处于变化追踪的状态,新new的Resume不必再手动添加到数据库,可以直接就顺便保存进去了:
var player = context.Players
.Where(x => x.Id == 2)
.FirstOrDefault();
player.Resume = new Resume
{
Description = "1234"
};
context.SaveChanges();
离线状态:
using var context = new DemoDbContext();
var player = context.Players
.AsNoTracking()
.OrderBy(x => x.Id)
.LastOrDefault();
player.Resume = new Resume
{
Description = "4321"
};
{
using var newContext = new DemoDbContext();
newContext.Attach(player);
newContext.SaveChanges();
}
这里如果不用Attach()
而是用Update()
的话,会把Player的除主键外所有属性都更新。所以选择Attach()
。EF Core知道新的Resume是刚生成的,数据库中还没有,所以即使使用Attach()
,依然会把新Resume执行INSERT语句持久化到数据库中。
修改一对一关系
但如果我们想修改某个Player的Resume,数据库中的Player已经有Resume,再给new一个新的Resume,按照上面操作来执行,就会报异常:
using var context = new DemoDbContext();
var player = context.Players
.AsNoTracking()
.OrderBy(x => x.Id)
.LastOrDefault();
player.Resume = new Resume
{
Description = "12121212121212"
};
{
using var newContext = new DemoDbContext();
newContext.Attach(player);
newContext.SaveChanges();
}
新赋的值的索引在数据库中已经存在。我猜想是因为查询的时候没有把Player的Resume查询出来。我们用另外一种方式来修改Player的Resume。不再使用离线模式,修改一下查询的语句,用Include把Player关联的Resume查处来,因为使用了Include,Player和Resume在内存里存在,再修改就不会有错误了。
using var context = new DemoDbContext();
var player = context.Players
.Include(x => x.Resume)
.OrderBy(x => x.Id)
.LastOrDefault();
player.Resume = new Resume
{
Description = "12121212121212"
};
context.SaveChanges();
可以看到先查询出Player和Resume来,因为要更换新Resume,老的Resume不再依赖Player,也就不应该再存在,就先删掉老的Resume,再Insert新的Resume。如果不写Include,也会报错。