EF: Fixing multiple same-entity references

This commit is contained in:
Zoe Roux 2021-08-02 00:41:05 +02:00
parent 6566b717f6
commit 928a8d2147
9 changed files with 209 additions and 34 deletions

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using JetBrains.Annotations;
using Kyoo.Controllers; using Kyoo.Controllers;
using Kyoo.Models; using Kyoo.Models;
using Kyoo.Models.Exceptions; using Kyoo.Models.Exceptions;
@ -503,6 +504,23 @@ namespace Kyoo
} }
} }
/// <summary>
/// Return the first resource with the given slug that is currently tracked by this context.
/// This allow one to limit redundant calls to <see cref="IRepository{T}.CreateIfNotExists"/> during the
/// same transaction and prevent fails from EF when two same entities are being tracked.
/// </summary>
/// <param name="slug">The slug of the resource to check</param>
/// <typeparam name="T">The type of entity to check</typeparam>
/// <returns>The local entity representing the resource with the given slug if it exists or null.</returns>
[CanBeNull]
public T LocalEntity<T>(string slug)
where T : class, IResource
{
return ChangeTracker.Entries<T>()
.FirstOrDefault(x => x.Entity.Slug == slug)
?.Entity;
}
/// <summary> /// <summary>
/// Check if the exception is a duplicated exception. /// Check if the exception is a duplicated exception.
/// </summary> /// </summary>
@ -515,14 +533,12 @@ namespace Kyoo
/// </summary> /// </summary>
private void DiscardChanges() private void DiscardChanges()
{ {
foreach (EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Unchanged foreach (EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Detached))
&& x.State != EntityState.Detached))
{ {
entry.State = EntityState.Detached; entry.State = EntityState.Detached;
} }
} }
/// <summary> /// <summary>
/// Perform a case insensitive like operation. /// Perform a case insensitive like operation.
/// </summary> /// </summary>

View File

@ -71,9 +71,9 @@ namespace Kyoo.Controllers
{ {
foreach (MetadataID id in resource.ExternalIDs) foreach (MetadataID id in resource.ExternalIDs)
{ {
id.Provider = await _providers.CreateIfNotExists(id.Provider); id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID; id.ProviderID = id.Provider.ID;
_database.Entry(id.Provider).State = EntityState.Unchanged;
} }
_database.MetadataIds<Collection>().AttachRange(resource.ExternalIDs); _database.MetadataIds<Collection>().AttachRange(resource.ExternalIDs);
} }

View File

@ -170,9 +170,9 @@ namespace Kyoo.Controllers
{ {
foreach (MetadataID id in resource.ExternalIDs) foreach (MetadataID id in resource.ExternalIDs)
{ {
id.Provider = await _providers.CreateIfNotExists(id.Provider); id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID; id.ProviderID = id.Provider.ID;
_database.Entry(id.Provider).State = EntityState.Unchanged;
} }
_database.MetadataIds<Episode>().AttachRange(resource.ExternalIDs); _database.MetadataIds<Episode>().AttachRange(resource.ExternalIDs);
} }

View File

@ -62,7 +62,6 @@ namespace Kyoo.Controllers
{ {
await base.Create(obj); await base.Create(obj);
_database.Entry(obj).State = EntityState.Added; _database.Entry(obj).State = EntityState.Added;
obj.ExternalIDs.ForEach(x => _database.MetadataIds<People>().Attach(x));
await _database.SaveChangesAsync($"Trying to insert a duplicated people (slug {obj.Slug} already exists)."); await _database.SaveChangesAsync($"Trying to insert a duplicated people (slug {obj.Slug} already exists).");
return obj; return obj;
} }
@ -71,23 +70,35 @@ namespace Kyoo.Controllers
protected override async Task Validate(People resource) protected override async Task Validate(People resource)
{ {
await base.Validate(resource); await base.Validate(resource);
await resource.ExternalIDs.ForEachAsync(async id =>
if (resource.ExternalIDs != null)
{ {
id.Provider = await _providers.CreateIfNotExists(id.Provider); foreach (MetadataID id in resource.ExternalIDs)
{
id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID; id.ProviderID = id.Provider.ID;
_database.Entry(id.Provider).State = EntityState.Detached; }
}); _database.MetadataIds<People>().AttachRange(resource.ExternalIDs);
await resource.Roles.ForEachAsync(async role => }
if (resource.Roles != null)
{ {
role.Show = await _shows.Value.CreateIfNotExists(role.Show); foreach (PeopleRole role in resource.Roles)
{
role.Show = _database.LocalEntity<Show>(role.Show.Slug)
?? await _shows.Value.CreateIfNotExists(role.Show);
role.ShowID = role.Show.ID; role.ShowID = role.Show.ID;
_database.Entry(role.Show).State = EntityState.Detached; _database.Entry(role).State = EntityState.Added;
}); }
}
} }
/// <inheritdoc /> /// <inheritdoc />
protected override async Task EditRelations(People resource, People changed, bool resetOld) protected override async Task EditRelations(People resource, People changed, bool resetOld)
{ {
await Validate(changed);
if (changed.Roles != null || resetOld) if (changed.Roles != null || resetOld)
{ {
await Database.Entry(resource).Collection(x => x.Roles).LoadAsync(); await Database.Entry(resource).Collection(x => x.Roles).LoadAsync();
@ -98,9 +109,7 @@ namespace Kyoo.Controllers
{ {
await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync(); await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync();
resource.ExternalIDs = changed.ExternalIDs; resource.ExternalIDs = changed.ExternalIDs;
} }
await base.EditRelations(resource, changed, resetOld);
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -107,9 +107,9 @@ namespace Kyoo.Controllers
{ {
foreach (MetadataID id in resource.ExternalIDs) foreach (MetadataID id in resource.ExternalIDs)
{ {
id.Provider = await _providers.CreateIfNotExists(id.Provider); id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID; id.ProviderID = id.Provider.ID;
_database.Entry(id.Provider).State = EntityState.Unchanged;
} }
_database.MetadataIds<Season>().AttachRange(resource.ExternalIDs); _database.MetadataIds<Season>().AttachRange(resource.ExternalIDs);
} }

View File

@ -102,9 +102,9 @@ namespace Kyoo.Controllers
{ {
foreach (MetadataID id in resource.ExternalIDs) foreach (MetadataID id in resource.ExternalIDs)
{ {
id.Provider = await _providers.CreateIfNotExists(id.Provider); id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID; id.ProviderID = id.Provider.ID;
_database.Entry(id.Provider).State = EntityState.Detached;
} }
_database.MetadataIds<Show>().AttachRange(resource.ExternalIDs); _database.MetadataIds<Show>().AttachRange(resource.ExternalIDs);
} }
@ -113,9 +113,9 @@ namespace Kyoo.Controllers
{ {
foreach (PeopleRole role in resource.People) foreach (PeopleRole role in resource.People)
{ {
role.People = await _people.CreateIfNotExists(role.People); role.People = _database.LocalEntity<People>(role.People.Slug)
?? await _people.CreateIfNotExists(role.People);
role.PeopleID = role.People.ID; role.PeopleID = role.People.ID;
_database.Entry(role.People).State = EntityState.Detached;
_database.Entry(role).State = EntityState.Added; _database.Entry(role).State = EntityState.Added;
} }
} }

View File

@ -54,7 +54,6 @@ namespace Kyoo.Controllers
{ {
await base.Create(obj); await base.Create(obj);
_database.Entry(obj).State = EntityState.Added; _database.Entry(obj).State = EntityState.Added;
obj.ExternalIDs.ForEach(x => _database.MetadataIds<Studio>().Attach(x));
await _database.SaveChangesAsync($"Trying to insert a duplicated studio (slug {obj.Slug} already exists)."); await _database.SaveChangesAsync($"Trying to insert a duplicated studio (slug {obj.Slug} already exists).");
return obj; return obj;
} }
@ -63,12 +62,16 @@ namespace Kyoo.Controllers
protected override async Task Validate(Studio resource) protected override async Task Validate(Studio resource)
{ {
await base.Validate(resource); await base.Validate(resource);
await resource.ExternalIDs.ForEachAsync(async x => if (resource.ExternalIDs != null)
{ {
x.Provider = await _providers.CreateIfNotExists(x.Provider); foreach (MetadataID id in resource.ExternalIDs)
x.ProviderID = x.Provider.ID; {
_database.Entry(x.Provider).State = EntityState.Detached; id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
}); ?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID;
}
_database.MetadataIds<Studio>().AttachRange(resource.ExternalIDs);
}
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -1,5 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers; using Kyoo.Controllers;
using Kyoo.Models; using Kyoo.Models;
using Microsoft.EntityFrameworkCore;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
@ -33,5 +37,134 @@ namespace Kyoo.Tests.Database
{ {
_repository = Repositories.LibraryManager.PeopleRepository; _repository = Repositories.LibraryManager.PeopleRepository;
} }
[Fact]
public async Task CreateWithExternalIdTest()
{
People value = TestSample.GetNew<People>();
value.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "new-provider-link",
DataID = "new-id"
}
};
await _repository.Create(value);
People retrieved = await _repository.Get(2);
await Repositories.LibraryManager.Load(retrieved, x => x.ExternalIDs);
Assert.Equal(2, retrieved.ExternalIDs.Count);
KAssert.DeepEqual(value.ExternalIDs.First(), retrieved.ExternalIDs.First());
KAssert.DeepEqual(value.ExternalIDs.Last(), retrieved.ExternalIDs.Last());
}
[Fact]
public async Task EditTest()
{
People value = await _repository.Get(TestSample.Get<People>().Slug);
value.Name = "New Name";
value.Images = new Dictionary<int, string>
{
[Images.Poster] = "new-poster"
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
People retrieved = await database.People.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task EditMetadataTest()
{
People value = await _repository.Get(TestSample.Get<People>().Slug);
value.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
People retrieved = await database.People
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task AddMetadataTest()
{
People value = await _repository.Get(TestSample.Get<People>().Slug);
value.ExternalIDs = new List<MetadataID>
{
new()
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
People retrieved = await database.People
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
value.ExternalIDs.Add(new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "link",
DataID = "id"
});
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
People retrieved = await database.People
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
}
[Theory]
[InlineData("Me")]
[InlineData("me")]
[InlineData("na")]
public async Task SearchTest(string query)
{
People value = new()
{
Slug = "slug",
Name = "name",
};
await _repository.Create(value);
ICollection<People> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First());
}
} }
} }

View File

@ -104,6 +104,20 @@ namespace Kyoo.Tests
[Images.Logo] = "logo" [Images.Logo] = "logo"
} }
} }
},
{
typeof(People),
() => new People
{
ID = 2,
Slug = "new-person-name",
Name = "New person name",
Images = new Dictionary<int, string>
{
[Images.Logo] = "Old Logo",
[Images.Poster] = "Old poster"
}
}
} }
}; };