diff --git a/Kyoo.Common/Utility/Merger.cs b/Kyoo.Common/Utility/Merger.cs index cd860ea3..1918ba41 100644 --- a/Kyoo.Common/Utility/Merger.cs +++ b/Kyoo.Common/Utility/Merger.cs @@ -22,12 +22,13 @@ namespace Kyoo /// The second enumerable to merge, if items from this list are equals to one from the first, they are not kept /// Equality function to compare items. If this is null, duplicated elements are kept /// The two list merged as an array - public static T[] MergeLists(IEnumerable first, - IEnumerable second, - Func isEqual = null) + [ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)] + public static T[] MergeLists([CanBeNull] IEnumerable first, + [CanBeNull] IEnumerable second, + [CanBeNull] Func isEqual = null) { if (first == null) - return second.ToArray(); + return second?.ToArray(); if (second == null) return first.ToArray(); if (isEqual == null) @@ -36,6 +37,31 @@ namespace Kyoo return list.Concat(second.Where(x => !list.Any(y => isEqual(x, y)))).ToArray(); } + /// + /// Merge two dictionary, if the same key is found on both dictionary, the values of the first one is kept. + /// + /// The first dictionary to merge + /// The second dictionary to merge + /// The type of the keys in dictionaries + /// The type of values in the dictionaries + /// A dictionary containing the result of the merge. + [ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)] + public static IDictionary MergeDictionaries([CanBeNull] IDictionary first, + [CanBeNull] IDictionary second) + { + if (first == null) + return second; + if (second == null) + return first; + Dictionary merged = new(); + merged.EnsureCapacity(first.Count + second.Count); + foreach ((T key, T2 value) in first) + merged.Add(key, value); + foreach ((T key, T2 value) in second) + merged.TryAdd(key, value); + return merged; + } + /// /// Set every fields of first to those of second. Ignore fields marked with the attribute /// At the end, the OnMerge method of first will be called if first is a @@ -63,16 +89,32 @@ namespace Kyoo } /// - /// Set every default values of first to the value of second. ex: {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "test"}. + /// Set every non-default values of seconds to the corresponding property of second. + /// Dictionaries are handled like anonymous objects with a property per key/pair value + /// (see for more details). /// At the end, the OnMerge method of first will be called if first is a /// - /// The object to complete - /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. - /// Filter fields that will be merged + /// + /// This does the opposite of . + /// + /// + /// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"} + /// + /// + /// The object to complete + /// + /// + /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. + /// + /// + /// Filter fields that will be merged + /// /// Fields of T will be completed /// /// If first is null - public static T Complete([NotNull] T first, [CanBeNull] T second, Func where = null) + public static T Complete([NotNull] T first, + [CanBeNull] T second, + [InstantHandle] Func where = null) { if (first == null) throw new ArgumentNullException(nameof(first)); @@ -93,7 +135,19 @@ namespace Kyoo object defaultValue = property.GetCustomAttribute()?.Value ?? property.PropertyType.GetClrDefault(); - if (value?.Equals(defaultValue) == false && value != property.GetValue(first)) + if (value?.Equals(defaultValue) != false || value == property.GetValue(first)) + continue; + if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>))) + { + Type[] dictionaryTypes = Utility.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>)) + .GenericTypeArguments; + property.SetValue(first, Utility.RunGenericMethod( + typeof(Merger), + nameof(MergeDictionaries), + dictionaryTypes, + value, property.GetValue(first))); + } + else property.SetValue(first, value); } @@ -103,17 +157,28 @@ namespace Kyoo } /// - /// An advanced function. /// This will set missing values of to the corresponding values of . - /// Enumerable will be merged (concatenated). + /// Enumerable will be merged (concatenated) and Dictionaries too. /// At the end, the OnMerge method of first will be called if first is a . /// - /// The object to complete - /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. + /// + /// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "test"} + /// + /// + /// The object to complete + /// + /// + /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. + /// + /// + /// Filter fields that will be merged + /// /// Fields of T will be merged /// [ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)] - public static T Merge([CanBeNull] T first, [CanBeNull] T second) + public static T Merge([CanBeNull] T first, + [CanBeNull] T second, + [InstantHandle] Func where = null) { if (first == null) return second; @@ -125,6 +190,9 @@ namespace Kyoo .Where(x => x.CanRead && x.CanWrite && Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null); + if (where != null) + properties = properties.Where(where); + foreach (PropertyInfo property in properties) { object oldValue = property.GetValue(first); @@ -133,6 +201,16 @@ namespace Kyoo if (oldValue?.Equals(defaultValue) != false) property.SetValue(first, newValue); + else if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>))) + { + Type[] dictionaryTypes = Utility.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>)) + .GenericTypeArguments; + property.SetValue(first, Utility.RunGenericMethod( + typeof(Merger), + nameof(MergeDictionaries), + dictionaryTypes, + oldValue, newValue)); + } else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType) && property.PropertyType != typeof(string)) { diff --git a/Kyoo.CommonAPI/LocalRepository.cs b/Kyoo.CommonAPI/LocalRepository.cs index 0187e0dd..67a75e12 100644 --- a/Kyoo.CommonAPI/LocalRepository.cs +++ b/Kyoo.CommonAPI/LocalRepository.cs @@ -234,16 +234,23 @@ namespace Kyoo.Controllers finally { Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; + Database.ChangeTracker.Clear(); } } /// /// An overridable method to edit relation of a resource. /// - /// The non edited resource - /// The new version of . This item will be saved on the databse and replace - /// A boolean to indicate if all values of resource should be discarded or not. - /// + /// + /// The non edited resource + /// + /// + /// The new version of . + /// This item will be saved on the database and replace + /// + /// + /// A boolean to indicate if all values of resource should be discarded or not. + /// protected virtual Task EditRelations(T resource, T changed, bool resetOld) { return Validate(resource); @@ -254,7 +261,9 @@ namespace Kyoo.Controllers /// It is also called on the default implementation of /// /// The resource that will be saved - /// You can throw this if the resource is illegal and should not be saved. + /// + /// You can throw this if the resource is illegal and should not be saved. + /// protected virtual Task Validate(T resource) { if (typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute() != null) diff --git a/Kyoo.Tests/Database/SpecificTests/CollectionsTests.cs b/Kyoo.Tests/Database/SpecificTests/CollectionsTests.cs index 73691bf7..3fb9e72f 100644 --- a/Kyoo.Tests/Database/SpecificTests/CollectionsTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/CollectionsTests.cs @@ -1,5 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Kyoo.Controllers; using Kyoo.Models; +using Microsoft.EntityFrameworkCore; using Xunit; using Xunit.Abstractions; @@ -33,5 +38,161 @@ namespace Kyoo.Tests.Database { _repository = Repositories.LibraryManager.CollectionRepository; } + + [Fact] + public async Task CreateWithEmptySlugTest() + { + Collection collection = TestSample.GetNew(); + collection.Slug = ""; + await Assert.ThrowsAsync(() => _repository.Create(collection)); + } + + [Fact] + public async Task CreateWithNumberSlugTest() + { + Collection collection = TestSample.GetNew(); + collection.Slug = "2"; + Collection ret = await _repository.Create(collection); + Assert.Equal("2!", ret.Slug); + } + + [Fact] + public async Task CreateWithoutNameTest() + { + Collection collection = TestSample.GetNew(); + collection.Name = null; + await Assert.ThrowsAsync(() => _repository.Create(collection)); + } + + [Fact] + public async Task CreateWithExternalIdTest() + { + Collection collection = TestSample.GetNew(); + collection.ExternalIDs = new[] + { + new MetadataID + { + Provider = TestSample.Get(), + Link = "link", + DataID = "id" + }, + new MetadataID + { + Provider = TestSample.GetNew(), + Link = "new-provider-link", + DataID = "new-id" + } + }; + await _repository.Create(collection); + + Collection retrieved = await _repository.Get(2); + await Repositories.LibraryManager.Load(retrieved, x => x.ExternalIDs); + Assert.Equal(2, retrieved.ExternalIDs.Count); + KAssert.DeepEqual(collection.ExternalIDs.First(), retrieved.ExternalIDs.First()); + KAssert.DeepEqual(collection.ExternalIDs.Last(), retrieved.ExternalIDs.Last()); + } + + [Fact] + public async Task EditTest() + { + Collection value = await _repository.Get(TestSample.Get().Slug); + value.Name = "New Title"; + value.Images = new Dictionary + { + [Images.Poster] = "poster" + }; + await _repository.Edit(value, false); + + await using DatabaseContext database = Repositories.Context.New(); + Collection retrieved = await database.Collections.FirstAsync(); + + KAssert.DeepEqual(value, retrieved); + } + + [Fact] + public async Task EditMetadataTest() + { + Collection value = await _repository.Get(TestSample.Get().Slug); + value.ExternalIDs = new[] + { + new MetadataID + { + Provider = TestSample.Get(), + Link = "link", + DataID = "id" + }, + }; + await _repository.Edit(value, false); + + await using DatabaseContext database = Repositories.Context.New(); + Collection retrieved = await database.Collections + .Include(x => x.ExternalIDs) + .ThenInclude(x => x.Provider) + .FirstAsync(); + + KAssert.DeepEqual(value, retrieved); + } + + [Fact] + public async Task AddMetadataTest() + { + Collection value = await _repository.Get(TestSample.Get().Slug); + value.ExternalIDs = new List + { + new() + { + Provider = TestSample.Get(), + Link = "link", + DataID = "id" + }, + }; + await _repository.Edit(value, false); + + { + await using DatabaseContext database = Repositories.Context.New(); + Collection retrieved = await database.Collections + .Include(x => x.ExternalIDs) + .ThenInclude(x => x.Provider) + .FirstAsync(); + + KAssert.DeepEqual(value, retrieved); + } + + value.ExternalIDs.Add(new MetadataID + { + Provider = TestSample.GetNew(), + Link = "link", + DataID = "id" + }); + await _repository.Edit(value, false); + + { + await using DatabaseContext database = Repositories.Context.New(); + Collection retrieved = await database.Collections + .Include(x => x.ExternalIDs) + .ThenInclude(x => x.Provider) + .FirstAsync(); + + KAssert.DeepEqual(value, retrieved); + } + } + + [Theory] + [InlineData("test")] + [InlineData("super")] + [InlineData("title")] + [InlineData("TiTlE")] + [InlineData("SuPeR")] + public async Task SearchTest(string query) + { + Collection value = new() + { + Slug = "super-test", + Name = "This is a test title", + }; + await _repository.Create(value); + ICollection ret = await _repository.Search(query); + KAssert.DeepEqual(value, ret.First()); + } } } \ No newline at end of file diff --git a/Kyoo.Tests/Database/SpecificTests/LibraryItemTest.cs b/Kyoo.Tests/Database/SpecificTests/LibraryItemTest.cs index e6a913d2..f3e031b2 100644 --- a/Kyoo.Tests/Database/SpecificTests/LibraryItemTest.cs +++ b/Kyoo.Tests/Database/SpecificTests/LibraryItemTest.cs @@ -79,9 +79,10 @@ namespace Kyoo.Tests.Database [Fact] public async Task GetDuplicatedSlugTests() { - await _repositories.LibraryManager.Create(new Collection() + await _repositories.LibraryManager.Create(new Collection { - Slug = TestSample.Get().Slug + Slug = TestSample.Get().Slug, + Name = "name" }); await Assert.ThrowsAsync(() => _repository.Get(TestSample.Get().Slug)); } diff --git a/Kyoo.Tests/Database/SpecificTests/LibraryTests.cs b/Kyoo.Tests/Database/SpecificTests/LibraryTests.cs index 12449b5e..79277f61 100644 --- a/Kyoo.Tests/Database/SpecificTests/LibraryTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/LibraryTests.cs @@ -61,7 +61,7 @@ namespace Kyoo.Tests.Database Library library = TestSample.GetNew(); library.Slug = "2"; Library ret = await _repository.Create(library); - Assert.Equal("2!", library.Slug); + Assert.Equal("2!", ret.Slug); } [Fact] diff --git a/Kyoo.Tests/Database/TestSample.cs b/Kyoo.Tests/Database/TestSample.cs index d8b06333..ff79ccf9 100644 --- a/Kyoo.Tests/Database/TestSample.cs +++ b/Kyoo.Tests/Database/TestSample.cs @@ -18,6 +18,20 @@ namespace Kyoo.Tests Paths = new [] {"/a/random/path"} } }, + { + typeof(Collection), + () => new Collection + { + ID = 2, + Slug = "new-collection", + Name = "New Collection", + Overview = "A collection created by new sample", + Images = new Dictionary + { + [Images.Thumbnail] = "thumbnail" + } + } + }, { typeof(Show), () => new Show diff --git a/Kyoo.Tests/Utility/MergerTests.cs b/Kyoo.Tests/Utility/MergerTests.cs index 285532c2..4e0ed6e7 100644 --- a/Kyoo.Tests/Utility/MergerTests.cs +++ b/Kyoo.Tests/Utility/MergerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Kyoo.Models; @@ -135,6 +136,68 @@ namespace Kyoo.Tests.Utility Assert.Equal(3, ret.Numbers[3]); } + private class MergeDictionaryTest + { + public int ID { get; set; } + + public Dictionary Dictionary { get; set; } + } + + [Fact] + public void GlobalMergeDictionariesTest() + { + MergeDictionaryTest test = new() + { + ID = 5, + Dictionary = new Dictionary + { + [2] = "two" + } + }; + MergeDictionaryTest test2 = new() + { + Dictionary = new Dictionary + { + [3] = "third" + } + }; + MergeDictionaryTest ret = Merger.Merge(test, test2); + Assert.True(ReferenceEquals(test, ret)); + Assert.Equal(5, ret.ID); + + Assert.Equal(2, ret.Dictionary.Count); + Assert.Equal("two", ret.Dictionary[2]); + Assert.Equal("third", ret.Dictionary[3]); + } + + [Fact] + public void GlobalMergeDictionariesDuplicatesTest() + { + MergeDictionaryTest test = new() + { + ID = 5, + Dictionary = new Dictionary + { + [2] = "two" + } + }; + MergeDictionaryTest test2 = new() + { + Dictionary = new Dictionary + { + [2] = "nope", + [3] = "third" + } + }; + MergeDictionaryTest ret = Merger.Merge(test, test2); + Assert.True(ReferenceEquals(test, ret)); + Assert.Equal(5, ret.ID); + + Assert.Equal(2, ret.Dictionary.Count); + Assert.Equal("two", ret.Dictionary[2]); + Assert.Equal("third", ret.Dictionary[3]); + } + [Fact] public void GlobalMergeListDuplicatesResourcesTest() { @@ -208,5 +271,99 @@ namespace Kyoo.Tests.Utility Assert.Equal(1, ret[0]); Assert.Equal(2, ret[1]); } + + [Fact] + public void MergeDictionariesTest() + { + Dictionary first = new() + { + [1] = "test", + [5] = "value" + }; + Dictionary second = new() + { + [3] = "third", + }; + IDictionary ret = Merger.MergeDictionaries(first, second); + + Assert.Equal(3, ret.Count); + Assert.Equal("test", ret[1]); + Assert.Equal("value", ret[5]); + Assert.Equal("third", ret[3]); + } + + [Fact] + public void MergeDictionariesDuplicateTest() + { + Dictionary first = new() + { + [1] = "test", + [5] = "value" + }; + Dictionary second = new() + { + [3] = "third", + [5] = "new-value", + }; + IDictionary ret = Merger.MergeDictionaries(first, second); + + Assert.Equal(3, ret.Count); + Assert.Equal("test", ret[1]); + Assert.Equal("value", ret[5]); + Assert.Equal("third", ret[3]); + } + + [Fact] + public void CompleteTest() + { + Genre genre = new() + { + ID = 5, + Name = "merged" + }; + Genre genre2 = new() + { + Name = "test" + }; + Genre ret = Merger.Complete(genre, genre2); + Assert.True(ReferenceEquals(genre, ret)); + Assert.Equal(5, ret.ID); + Assert.Equal("test", genre.Name); + Assert.Null(genre.Slug); + } + + [Fact] + public void CompleteDictionaryTest() + { + Collection collection = new() + { + ID = 5, + Name = "merged", + Images = new Dictionary + { + [Images.Logo] = "logo", + [Images.Poster] = "poster" + } + + }; + Collection collection2 = new() + { + Name = "test", + Images = new Dictionary + { + [Images.Poster] = "new-poster", + [Images.Thumbnail] = "thumbnails" + } + }; + Collection ret = Merger.Complete(collection, collection2); + Assert.True(ReferenceEquals(collection, ret)); + Assert.Equal(5, ret.ID); + Assert.Equal("test", ret.Name); + Assert.Null(ret.Slug); + Assert.Equal(3, ret.Images.Count); + Assert.Equal("new-poster", ret.Images[Images.Poster]); + Assert.Equal("thumbnails", ret.Images[Images.Thumbnail]); + Assert.Equal("logo", ret.Images[Images.Logo]); + } } } \ No newline at end of file diff --git a/Kyoo/Controllers/Repositories/CollectionRepository.cs b/Kyoo/Controllers/Repositories/CollectionRepository.cs index 42483b08..016028cb 100644 --- a/Kyoo/Controllers/Repositories/CollectionRepository.cs +++ b/Kyoo/Controllers/Repositories/CollectionRepository.cs @@ -42,7 +42,7 @@ namespace Kyoo.Controllers public override async Task> Search(string query) { return await _database.Collections - .Where(_database.Like(x => x.Name, $"%{query}%")) + .Where(_database.Like(x => x.Name + " " + x.Slug, $"%{query}%")) .OrderBy(DefaultSort) .Take(20) .ToListAsync(); @@ -53,7 +53,6 @@ namespace Kyoo.Controllers { await base.Create(obj); _database.Entry(obj).State = EntityState.Added; - obj.ExternalIDs.ForEach(x => _database.MetadataIds().Attach(x)); await _database.SaveChangesAsync($"Trying to insert a duplicated collection (slug {obj.Slug} already exists)."); return obj; } @@ -62,24 +61,34 @@ namespace Kyoo.Controllers protected override async Task Validate(Collection resource) { await base.Validate(resource); - await resource.ExternalIDs.ForEachAsync(async x => - { - x.Provider = await _providers.CreateIfNotExists(x.Provider); - x.ProviderID = x.Provider.ID; - _database.Entry(x.Provider).State = EntityState.Detached; - }); + + if (string.IsNullOrEmpty(resource.Slug)) + throw new ArgumentException("The collection's slug must be set and not empty"); + if (string.IsNullOrEmpty(resource.Name)) + throw new ArgumentException("The collection's name must be set and not empty"); + + if (resource.ExternalIDs != null) + { + foreach (MetadataID id in resource.ExternalIDs) + { + id.Provider = await _providers.CreateIfNotExists(id.Provider); + id.ProviderID = id.Provider.ID; + _database.Entry(id.Provider).State = EntityState.Detached; + } + _database.MetadataIds().AttachRange(resource.ExternalIDs); + } } /// protected override async Task EditRelations(Collection resource, Collection changed, bool resetOld) { + await Validate(resource); + if (changed.ExternalIDs != null || resetOld) { await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync(); resource.ExternalIDs = changed.ExternalIDs; } - - await base.EditRelations(resource, changed, resetOld); } ///