Entity Framework Core Automatically Adds Missing Entity Relationships
Relationships in Entity Framework Core typically are represented by a pair of Navigation Properties. The parent in the relationship has a list of children, and the child has a reference to its parent. Normally setting up this kind of bi-directional relationship between entities requires that we add child to the parent collection and point the child to the parent. But when using EFCore our DbContext
will often add a missing relationship if we create one, but not both of these references.
To look at this behavior, we will use the entities Author
and Book
below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Author
{
public int Id { get; set; }
public string Name { get; set; }
public List<Book> Books {get; set;} = new();
public string AllTitles =>
Books.Any() ?
"Books contains " + string.Join(", ", Books.Select(x => $"'{x.Title}'")) :
"Has no linked books.";
}
public class Book
{
public int Id { get; set; }
public Author Author { get; set; }
public string Title { get; set; }
}
Because we are looking at behavior of the DbContext
and not the underlying database, we can use the Microsoft.EntityFrameworkCore.InMemory
NuGet package, which is most often used in unit testing.
Below, we will create and track a single Author
instance. Then we will test four variations on creating a Book
to see how they behave.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
private static void Main(string[] args)
{
var dbOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.EnableSensitiveDataLogging()
.Options;
using (var ctx = new AppDbContext(dbOptions))
{
var author = new Author { Name = "Terry Mancour" };
ctx.Add(author);
// First, lets setup all the relationships for the book Spellmonger.
var book = new Book { Author = author, Title = "Spellmonger" };
author.Books.Add(book);
// Book with an Author specified. We don't add it to the Author's Book collection
// but do add the book to dbContext tracking.
var notLinkedToAuthor = new Book { Author = author, Title = "UnLinked" };
ctx.Add(notLinkedToAuthor);
// Book with an Author specified. We don't add it to the Author's Book collection or
// add the book to dbContext tracking.
var untracked = new Book { Author = author, Title = "Untracked" };
// For the 'Authorless' book, we only add it to the author's book collection, but
// leave Book.Author null.
var authorlessBook = new Book { Title = "Authorless" };
author.Books.Add(authorlessBook);
Console.WriteLine("======= Before Db Save ========");
Console.WriteLine($"{author.Name}: {author.AllTitles}");
foreach (var b in author.Books)
{
Console.WriteLine($"'{b.Title}' written by {b.Author?.Name}");
}
ctx.SaveChanges();
Console.WriteLine("\r\n======= After Db Save ========");
Console.WriteLine($"{author.Name}: {author.AllTitles}");
foreach (var b in author.Books)
{
Console.WriteLine($"'{b.Title}' written by {b.Author?.Name}");
}
}
using (var ctx = new AppDbContext(dbOptions))
{
var authors = ctx.Authors.Include(x => x.Books).ToList();
Console.WriteLine("\r\n======= Retrieved from Db ========");
foreach (var author in authors)
{
Console.WriteLine($"{author.Name}: {author.AllTitles}");
}
}
}
// Console Output:
// ======= Before Db Save ========
// Terry Mancour: Books contains 'Spellmonger', 'UnLinked', 'Authorless'
// 'Spellmonger' written by Terry Mancour
// 'UnLinked' written by Terry Mancour
// 'Authorless' written by
// ======= After Db Save ========
// Terry Mancour: Books contains 'Spellmonger', 'UnLinked', 'Authorless'
// 'Spellmonger' written by Terry Mancour
// 'UnLinked' written by Terry Mancour
// 'Authorless' written by Terry Mancour
// ======= Retrieved from Db ========
// Terry Mancour: Books contains 'UnLinked', 'Spellmonger', 'Authorless'
We can see a few different types of behavior here that deserve discussion.
- Spellmonger is our standard example where both sides of the relationship are setup in our code. Notice how this book becomes tracked by our
ctx
when it is added to theAuthor
’s collection of books. - UnLinked in itself, is a complete
Book
. It has a title and anAuthor
. When we add this to ourctx
tracking, EFCore adds theBook
to theAuthor.Books
list right away. - UnTracked is identical to the example above, except we do not add it to our
ctx
. Becausectx
has no knowledge this entity exists, it doesn’t add relationships or persist it to the database. - Authorless is created with only a title. When we add it to
Author.Books
ourctx
begins tracking thisBook
but it does not populate theBook.Author
property. The relationship is added only after we save to the database.
As you can see, in both our UnLinked and Authorless examples, EFCore is adding missing relationships for us, but we need to be cautious when relying on this behavior because the two examples behave differently. Once we save to the database, the relationship between any two tracked Entities should be complete, but before saving the status of the relationships will depend on which process you followed. This feature is quite useful, but I think its worth thinking about how and when to use this automatic linking.
In the next post, we will see additional caution is warranted, because this behavior can totally bypass business logic you may create to control when new relationships can be created.
Leave a Comment
Your email address will not be published. Required fields are marked *