26

The other table contain references data with well know ID. The use case is to read data from file, create entities then insert them in batch. I don't need to query anything first, so all entities are "disconnected" from context.

Simple exemple:

public class Post
{
    public int ID { get; set; }
    public string Text { get; set; }
    public virtual ICollection<Tag> Tags { get; set; } 
}

public class Tag
{
    public int ID { get; set; }
    [Required]
    public string Label { get; set;}
    public virtual ICollection<Post> Posts { get; set; } 
}

First try

List<Post> posts = new List<Post>();

loop
  var post = new Post { Text = "some text"});
  post.Tags.Add(new Tag {ID = 1});  
  post.Tags.Add(new Tag {ID = 2});
  posts.Add(post);
...

context.Posts.AddRange(posts);
context.SaveChange();

Error because EF try to update the tags record by setting the other tag column to null. I don't want EF to update the tag table anyway, only the join table.

Second try
After reading Long story short: Use Foreign key and it will save your day, I did not find a way to make it work with a collection of FKs because in my case it's a many to many relation.

Third try
Instead of using context.Post.AddRange(posts);, I attach only the parent entity:

var post = new Post { Text = "some text"});
post.Tags.Add(new Tag {ID = 1});
post.Tags.Add(new Tag {ID = 2});
context.Posts.Attach(post).State = EntityState.Added;
context.SaveChanges();

That worked. Post is inserted, and the joining table PostsTags contain the relation data, with the Tags table left untouched. BUT that will not work in batch (same context) because I can't then create another post with the same tag. The context "track" the tags by their ID, so I can't insert a "new" one with the same ID.

Fourth try
What I'm doing right now is instead of adding a new Tag post.Tags.Add(new Tag {ID = 1});, I add the tag from the db post.Tags.Add(context.Tags.Find(1)); That means many trips to database, for information that is already knows.

Others options I think of is to keep a local dictionnary of tag that are already attached to context, change context between each post, find a way to insert data directly into the entity type that represent the join table, query all references beforehand (but some references tables contains thousand of elements) or simply juste use raw sql query.

I can't imagine that there is no simple way to insert a model with Fk ids, like it work for a one to many by using a Foreign Key property.

Thank you

Jean-Francois Rondeau
  • 1,008
  • 2
  • 13
  • 15

2 Answers2

13

The issue will be due to the tracking, or lack of tracking on the Tags. Since you don't want to query the database, then you can opt to Attach tag instances that you can guarantee are legal tag rows. If you have a reasonable # of Tag IDs to use you could create and attach the full set to reference. Otherwise you could derive it from the data IDs coming in.

I.e. if we have 20 Tags to select from, ID 1-20:

for (int tagId = 1; tagId <= 20; tagId++)
{
    var tag = new Tag { Id = tagId };
    context.Tags.Attach(tag);
}

We don't need to track these tags separately in a list. Once associated with the DbContext we can use context.Tags, or to be extra cautious about reads, context.Tags.Local then when populating your Posts:

var post = new Post { Text = "some text"});
post.Tags.Add(context.Tags.Local.Single(x => x.Id == 1));  
post.Tags.Add(context.Tags.Local.Single(x => x.Id == 2));  
posts.Add(post);
//...

context.Posts.AddRange(posts);

If you have a large # of tags and pass a structure in for the posts that nominate the Tag IDs you want to associate with each new post, then you can build a list from that:

var tags = postViewModels.SelectMany(x => x.TagIds)
    .Distinct()
    .Select(t => new Tag { Id == t)).ToList();

Such as the case where a provided set of post ViewModels contains a list of TagIds. We select all of the distinct Tag IDs, then build Tags to associate.

The caveat here is if the DbContext might already by tracking a Tag with any of the desired IDs. Calling Attach for a Tag that the DbContext might already have loaded will result in an exception. Whether you build a complete set of tags or build a set from the provided post, the solution should check the DbContext for any locally cached/tracked tags and only attach ones that aren't already tracked.

var tags = postViewModels.SelectMany(x => x.TagIds)
    .Distinct()
    .Select(t => new Tag { Id == t))
    .ToList();
foreach(var tag in tags)
{
    if (!context.Tags.Local.Any(x => x.TagId == tag.Id))
        context.Tags.Attach(tag);
}

There may be a better way to build the Tags to attach to exclude existing tracked tags (such as using Except, though that requires an EqualityComparer) but we guard against attaching a Tag that is already tracked. From there we create the Posts and associate the desired tags as per the first example using context.Tags.Local. Every tag referenced in each post should have been attached or already tracked and available.

The remaining caveat here is that this assumes that the provided Tag actually exists in the database. We don't want to set the attached Tag's EntityState to anything like Added or Modified to avoid creating incomplete/invalid or replacing data in the Tags table.

Steve Py
  • 26,149
  • 3
  • 25
  • 43
  • Tanks @stevepy. You too then don't know a way to acheive that without managing Tag entities state. That is what I mean by 'local dictionnary of tag that are already attached to context', because tag are read from data file at the same time as their Post data. At least you explain well that solution for reader, if there is no better one. – Jean-Francois Rondeau Jan 11 '21 at 13:33
  • Especially for a many-to-many or one-to-many relationship you do need to deal with the entities, though only so far as they are associated to the DbContext that you will be making changes on. `Attach` does this so the DbContext does not need to go to the DB, however we must consider already tracked instances using the `.Local`, which again doesn't hit the DB. To associate an existing tag to a new post, the DbContext needs to be aware of it, but doesn't need to reload it from data state. – Steve Py Jan 11 '21 at 21:40
2

When you have Many-to-Many Relationship between Post and Tag, EFCore Automatically adds a table to store that relationship. You will need to add that table manually using a POCO and then use that table to add your relationship. As shown here

Here is the relevant code:

Create a class that contains the relationship.

public class PostTag
{

    public int PostId { get; set; }
    public Post Post { get; set; }

    public int TagId { get; set; }
    public Tag Tag { get; set; }
}

Then in OnModelCreate method, Add the relationship like this:

protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<PostTag>()
            .HasKey(t => new { t.PostId, t.TagId });

        modelBuilder.Entity<PostTag>()
            .HasOne(pt => pt.Post)
            .WithMany(p => p.PostTags)
            .HasForeignKey(pt => pt.PostId);

        modelBuilder.Entity<PostTag>()
            .HasOne(pt => pt.Tag)
            .WithMany(t => t.PostTags)
            .HasForeignKey(pt => pt.TagId);
    }

Alternatively, you can use annotations to set the relationship using [ForeignKey(nameof())] attribute

Then you can manually add the PostTag entity with the required data.

Anup Sharma
  • 2,053
  • 20
  • 30
  • I don't dow-nvote unless an answer is quite bad, and this answer is certainly not bad. I agree anyone down-voting should explain why. I suspect this is why we see so many "answers" being offered by comments. :( Regarding your solution there are a couple downsides. Firstly this would change the "many" side of each relationship to be an `IColection` instead of `ICollection` and `ICollection`... – Steve Py Jan 11 '21 at 21:47
  • Secondly, while exposing a linking entity /w FK and navigation property should allow for something like `post.PostTags.Add(new PostTag { PostId = post.Id, TagId = tagId });` this can lead to potential odd bugs when editing post/tag associations. I.e. Changing a TagId will have different behaviour if the Tag navigation property is eager0loaded/tracked vs. if it is not. I always recommend using either navigation properties with shadow FKs or just FKs, never both. FKs alone would provide an easy solution for a new post, but wouldn't be much help overall. Nav properties leaves the same issue. – Steve Py Jan 11 '21 at 22:08
  • @StevePy OP needed a solution where he can set just the ID of the association which he apparently already has and not make a trip to DB to fetch the entity. And till the last version of netcore (3.1), we had only this way to manage many-to-many relationship. Hence suggested this way. But I totally agree with your point about editing the association which usually needs quite a bit of careful navigation(no pun intended). – Anup Sharma Jan 12 '21 at 08:11
  • Given the OP's configuration is otherwise working, just with issues due to setting essentially a new Tag object with an ID and either overwriting data and that they were using Post.Tags and Tag.Posts, my guess was the OP was using EF Core 5.0 which does now support many-to-many without declaring linking entities. (Finally!) That would leave the usual culprit of trying to update the relationships using untracked entities. – Steve Py Jan 12 '21 at 10:35
  • Effectively, I didn't declare any linking entities, but I read in the doc that EF use a king of Property Bag "internally", and maybe working with this can be a solution, but I can't figure that out. And since that is hidden in the core, it is probably not the correct or best way to set list of FKs. – Jean-Francois Rondeau Jan 12 '21 at 15:44