diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index ccd505b7..f19e490d 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -21,10 +21,7 @@ namespace DataLayer.Configurations entity.Ignore(nameof(Book.Narrators)); entity.Ignore(nameof(Book.AudioFormat)); entity.Ignore(nameof(Book.TitleWithSubtitle)); - //// these don't seem to matter - //entity.Ignore(nameof(Book.AuthorNames)); - //entity.Ignore(nameof(Book.NarratorNames)); - //entity.Ignore(nameof(Book.HasPdfs)); + entity.Ignore(b => b.Categories); // OwnsMany: "Can only ever appear on navigation properties of other entity types. // Are automatically loaded, and can only be tracked by a DbContext alongside their owner." @@ -58,24 +55,6 @@ namespace DataLayer.Configurations // owns it 1:1, store in same table b_udi.OwnsOne(udi => udi.Rating); }); - - entity - .Metadata - .FindNavigation(nameof(Book.ContributorsLink)) - // PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field - .SetPropertyAccessMode(PropertyAccessMode.Field); - - entity - .Metadata - .FindNavigation(nameof(Book.SeriesLink)) - // PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field - .SetPropertyAccessMode(PropertyAccessMode.Field); - - entity - .Metadata - .FindNavigation(nameof(Book.CategoriesLink)) - // PropertyAccessMode.Field : Categories is a get-only property, not a field, so use its backing field - .SetPropertyAccessMode(PropertyAccessMode.Field); } } } \ No newline at end of file diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index d42a0bdd..b8bcce6b 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using Dinah.Core; using Microsoft.EntityFrameworkCore; @@ -37,7 +38,7 @@ namespace DataLayer public string Subtitle { get; private set; } private string _titleWithSubtitle; public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}"; - public string Description { get; private set; } + public string Description { get; private set; } public int LengthInMinutes { get; private set; } public ContentType ContentType { get; private set; } public string Locale { get; private set; } @@ -73,8 +74,8 @@ namespace DataLayer string description, int lengthInMinutes, ContentType contentType, - IEnumerable authors, - IEnumerable narrators, + IEnumerable authors, + IEnumerable narrators, string localeName) { // validate @@ -82,7 +83,7 @@ namespace DataLayer var productId = audibleProductId.Id; ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId)); - // assign as soon as possible. stuff below relies on this + // assign as soon as possible. stuff below relies on this AudibleProductId = productId; Locale = localeName; @@ -90,42 +91,34 @@ namespace DataLayer // non-ef-ctor init.s UserDefinedItem = new UserDefinedItem(this); - _contributorsLink = new HashSet(); - _categoriesLink = new HashSet(); + ContributorsLink = new HashSet(); + CategoriesLink = new HashSet(); _seriesLink = new HashSet(); _supplements = new HashSet(); // simple assigns UpdateTitle(title, subtitle); - Description = description?.Trim() ?? ""; + Description = description?.Trim() ?? ""; LengthInMinutes = lengthInMinutes; ContentType = contentType; // assigns with biz logic ReplaceAuthors(authors); ReplaceNarrators(narrators); - } - public void UpdateTitle(string title, string subtitle) + } + public void UpdateTitle(string title, string subtitle) { Title = title?.Trim() ?? ""; Subtitle = subtitle?.Trim() ?? ""; _titleWithSubtitle = null; - } + } - #region contributors, authors, narrators - // use uninitialised backing fields - this means we can detect if the collection was loaded - private HashSet _contributorsLink; - // i'd like this to be internal but migration throws this exception when i try: - // Value cannot be null. - // Parameter name: property - public IEnumerable ContributorsLink - => _contributorsLink? - .OrderBy(bc => bc.Order) - .ToList(); + #region contributors, authors, narrators + internal HashSet ContributorsLink { get; private set; } - public IEnumerable Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList(); - public IEnumerable Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList(); - public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name; + public IEnumerable Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList(); + public IEnumerable Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList(); + public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name; public void ReplaceAuthors(IEnumerable authors, DbContext context = null) => replaceContributors(authors, Role.Author, context); @@ -138,63 +131,65 @@ namespace DataLayer ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors)); // the edge cases of doing local-loaded vs remote-only got weird. just load it - if (_contributorsLink is null) - getEntry(context).Collection(s => s.ContributorsLink).Load(); + if (ContributorsLink is null) + getEntry(context).Collection(s => s.ContributorsLink).Load(); + + var isIdentical + = ContributorsLink + .ByRole(role) + .Select(c => c.Contributor) + .SequenceEqual(newContributors); - var roleContributions = getContributions(role); - var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors); if (isIdentical) return; - _contributorsLink.RemoveWhere(bc => bc.Role == role); + ContributorsLink.RemoveWhere(bc => bc.Role == role); addNewContributors(newContributors, role); } - private void addNewContributors(IEnumerable newContributors, Role role) + private void addNewContributors(IEnumerable newContributors, Role role) { byte order = 0; var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++)); var newContributions = new HashSet(newContributionsEnum); - _contributorsLink.UnionWith(newContributions); + ContributorsLink.UnionWith(newContributions); } - private List getContributions(Role role) - => ContributorsLink - .Where(a => a.Role == role) - .OrderBy(a => a.Order) - .ToList(); #endregion - private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry getEntry(DbContext context) - { - ArgumentValidator.EnsureNotNull(context, nameof(context)); + private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry getEntry(DbContext context) + { + ArgumentValidator.EnsureNotNull(context, nameof(context)); - var entry = context.Entry(this); + var entry = context.Entry(this); - if (!entry.IsKeySet) - throw new InvalidOperationException("Could not load a valid Book from database"); + if (!entry.IsKeySet) + throw new InvalidOperationException("Could not load a valid Book from database"); - return entry; - } - #region categories - private HashSet _categoriesLink; - public IEnumerable CategoriesLink => _categoriesLink?.ToList(); - public void UpsertCategories(CategoryLadder ladder) - { - ArgumentValidator.EnsureNotNull(ladder, nameof(ladder)); + return entry; + } - var singleBookCategory = _categoriesLink.SingleOrDefault(bc => bc.CategoryLadder.Equals(ladder)); + #region categories + internal HashSet CategoriesLink { get; private set; } - if (singleBookCategory is null) - _categoriesLink.Add(new BookCategory(this, ladder)); - else + private ReadOnlyCollection _categoriesReadOnly; + public ReadOnlyCollection Categories + { + get { - for (var i = 0; i < ladder._categories.Count; i++) - { - //Update the category name - singleBookCategory.CategoryLadder._categories[i].Name = ladder._categories[i].Name; - } + if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true) + _categoriesReadOnly = CategoriesLink.ToList().AsReadOnly(); + return _categoriesReadOnly; } + } + public void SetCategoryLadders(IEnumerable ladders) + { + ArgumentValidator.EnsureNotNull(ladders, nameof(ladders)); + + //Replace all existing category ladders. + //Some books make have duplocate ladders + CategoriesLink.Clear(); + CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l))); } #endregion diff --git a/Source/DataLayer/EfClasses/Category.cs b/Source/DataLayer/EfClasses/Category.cs index 7e992f26..e4654b3b 100644 --- a/Source/DataLayer/EfClasses/Category.cs +++ b/Source/DataLayer/EfClasses/Category.cs @@ -1,6 +1,9 @@ using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using Dinah.Core; +#nullable enable namespace DataLayer { public class AudibleCategoryId @@ -16,12 +19,21 @@ namespace DataLayer public class Category { internal int CategoryId { get; private set; } - public string AudibleCategoryId { get; private set; } + public string? AudibleCategoryId { get; private set; } - public string Name { get; internal set; } + public string? Name { get; internal set; } internal List _categoryLadders = new(); - public IReadOnlyCollection CategoryLadders => _categoryLadders.AsReadOnly(); + private ReadOnlyCollection? _categoryLaddersReadOnly; + public ReadOnlyCollection CategoryLadders + { + get + { + if (_categoryLaddersReadOnly?.SequenceEqual(_categoryLadders) is not true) + _categoryLaddersReadOnly = _categoryLadders.AsReadOnly(); + return _categoryLaddersReadOnly; + } + } private Category() { } /// special id class b/c it's too easy to get string order mixed up diff --git a/Source/DataLayer/EfClasses/CategoryLadder.cs b/Source/DataLayer/EfClasses/CategoryLadder.cs index 27f067ad..c2f6e52f 100644 --- a/Source/DataLayer/EfClasses/CategoryLadder.cs +++ b/Source/DataLayer/EfClasses/CategoryLadder.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +#nullable enable namespace DataLayer { public class CategoryLadder : IEquatable @@ -11,10 +12,19 @@ namespace DataLayer internal int CategoryLadderId { get; private set; } internal List _categories; - public ReadOnlyCollection Categories => _categories.AsReadOnly(); + private ReadOnlyCollection? _categoriesReadOnly; + public ReadOnlyCollection Categories + { + get + { + if (_categoriesReadOnly?.SequenceEqual(_categories) is not true) + _categoriesReadOnly = _categories.AsReadOnly(); + return _categoriesReadOnly; + } + } - private HashSet _booksLink; - public IEnumerable BooksLink => _booksLink?.ToList(); + private HashSet? _booksLink; + public IEnumerable? BooksLink => _booksLink?.ToList(); private CategoryLadder() { _categories = new(); } public CategoryLadder(List categories) { @@ -32,16 +42,16 @@ namespace DataLayer return hashCode.ToHashCode(); } - public bool Equals(CategoryLadder other) - { - if (other?._categories is null) - return false; + public bool Equals(CategoryLadder? other) + => other?._categories is not null + && Equals(other._categories.Select(c => c.AudibleCategoryId)); - return Equals(other._categories.Select(c => c.AudibleCategoryId)); - } - public bool Equals(IEnumerable categoryIds) - => _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds); - public override bool Equals(object obj) => obj is CategoryLadder other && Equals(other); + public bool Equals(IEnumerable? categoryIds) + => categoryIds is not null + && _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds); + + public override bool Equals(object? obj) + => obj is CategoryLadder other && Equals(other); public override string ToString() => string.Join(" > ", _categories.Select(c => c.Name)); } diff --git a/Source/DataLayer/EntityExtensions.cs b/Source/DataLayer/EntityExtensions.cs index d91e7eb1..02e56d66 100644 --- a/Source/DataLayer/EntityExtensions.cs +++ b/Source/DataLayer/EntityExtensions.cs @@ -7,8 +7,13 @@ using System.Threading.Tasks; namespace DataLayer { public static class EntityExtensions - { - public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle); + { + public static IEnumerable ByRole(this IEnumerable contributors, Role role) + => contributors + .Where(a => a.Role == role) + .OrderBy(a => a.Order); + + public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle); public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name)); public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name)); diff --git a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs b/Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.Designer.cs similarity index 96% rename from Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs rename to Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.Designer.cs index f9adfb8b..7147cb51 100644 --- a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs +++ b/Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.Designer.cs @@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace DataLayer.Migrations { [DbContext(typeof(LibationContext))] - [Migration("20230717220642_AddCategoriesList")] - partial class AddCategoriesList + [Migration("20230718214617_AddCategoryLadder")] + partial class AddCategoryLadder { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -144,14 +144,6 @@ namespace DataLayer.Migrations b.HasIndex("AudibleCategoryId"); b.ToTable("Categories"); - - b.HasData( - new - { - CategoryId = -1, - AudibleCategoryId = "", - Name = "" - }); }); modelBuilder.Entity("DataLayer.CategoryLadder", b => @@ -163,12 +155,6 @@ namespace DataLayer.Migrations b.HasKey("CategoryLadderId"); b.ToTable("CategoryLadders"); - - b.HasData( - new - { - CategoryLadderId = -1 - }); }); modelBuilder.Entity("DataLayer.Contributor", b => diff --git a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs b/Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.cs similarity index 95% rename from Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs rename to Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.cs index 0121e911..00f2d899 100644 --- a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs +++ b/Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.cs @@ -5,7 +5,7 @@ namespace DataLayer.Migrations { /// - public partial class AddCategoriesList : Migration + public partial class AddCategoryLadder : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -26,6 +26,11 @@ namespace DataLayer.Migrations name: "IX_Books_CategoryId", table: "Books"); + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "CategoryId", + keyValue: -1); + migrationBuilder.DropColumn( name: "ParentCategoryCategoryId", table: "Categories"); @@ -94,11 +99,6 @@ namespace DataLayer.Migrations onDelete: ReferentialAction.Cascade); }); - migrationBuilder.InsertData( - table: "CategoryLadders", - column: "CategoryLadderId", - value: -1); - migrationBuilder.CreateIndex( name: "IX_BookCategory_BookId", table: "BookCategory", @@ -140,12 +140,10 @@ namespace DataLayer.Migrations nullable: false, defaultValue: 0); - migrationBuilder.UpdateData( + migrationBuilder.InsertData( table: "Categories", - keyColumn: "CategoryId", - keyValue: -1, - column: "ParentCategoryCategoryId", - value: null); + columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" }, + values: new object[] { -1, "", "", null }); migrationBuilder.CreateIndex( name: "IX_Categories_ParentCategoryCategoryId", diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index bafd6dfa..e246306f 100644 --- a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs @@ -141,14 +141,6 @@ namespace DataLayer.Migrations b.HasIndex("AudibleCategoryId"); b.ToTable("Categories"); - - b.HasData( - new - { - CategoryId = -1, - AudibleCategoryId = "", - Name = "" - }); }); modelBuilder.Entity("DataLayer.CategoryLadder", b => @@ -160,12 +152,6 @@ namespace DataLayer.Migrations b.HasKey("CategoryLadderId"); b.ToTable("CategoryLadders"); - - b.HasData( - new - { - CategoryLadderId = -1 - }); }); modelBuilder.Entity("DataLayer.Contributor", b => diff --git a/Source/DataLayer/QueryObjects/CategoryQueries.cs b/Source/DataLayer/QueryObjects/CategoryQueries.cs new file mode 100644 index 00000000..9ac733b0 --- /dev/null +++ b/Source/DataLayer/QueryObjects/CategoryQueries.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq; + +namespace DataLayer +{ + public static class CategoryQueries + { + public static IQueryable GetCategoryLadders(this LibationContext context) + => context.CategoryLadders.Include(c => c._categories); + } +} diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index 40dc3fa3..f3813e1c 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -188,13 +188,15 @@ namespace DtoImporterService if (item.CategoryLadders is not null) { + var ladders = new List(); foreach (var ladder in item.CategoryLadders.Select(cl => cl.Ladder).Where(l => l?.Length > 0)) { var categoryIds = ladder.Select(l => l.CategoryId).ToList(); - var cata = categoryImporter.LadderCache.Single(c => c.Equals(categoryIds)); - - book.UpsertCategories(cata); + ladders.Add(categoryImporter.LadderCache.Single(c => c.Equals(categoryIds))); } + //Set all ladders at once so ladders that have been + //removed by audible can be removed from the DB + book.SetCategoryLadders(ladders); } } diff --git a/Source/DtoImporterService/CategoryImporter.cs b/Source/DtoImporterService/CategoryImporter.cs index 7f6c50fd..c8605870 100644 --- a/Source/DtoImporterService/CategoryImporter.cs +++ b/Source/DtoImporterService/CategoryImporter.cs @@ -12,23 +12,15 @@ namespace DtoImporterService { protected override IValidator Validator => new CategoryValidator(); - private Dictionary Cache { get; set; } = new(); + private Dictionary CategoryCache { get; set; } = new(); public HashSet LadderCache { get; private set; } = new(); public CategoryImporter(LibationContext context) : base(context) { } protected override int DoImport(IEnumerable importItems) { - // get distinct - var categoryIds = importItems - .Select(i => i.DtoItem) - .GetCategoriesDistinct() - .Select(c => c.CategoryId) - .Distinct() - .ToList(); - // load db existing => .Local - loadLocal_categories(categoryIds); + loadLocal_categories(); // upsert var categoryLadders = importItems @@ -41,14 +33,11 @@ namespace DtoImporterService return qtyNew; } - private void loadLocal_categories(List categoryIds) + private void loadLocal_categories() { // load existing => local - Cache = DbContext.Categories - .Where(c => categoryIds.Contains(c.AudibleCategoryId)) - .ToDictionarySafe(c => c.AudibleCategoryId); - - LadderCache = DbContext.CategoryLadders.ToHashSet(); + LadderCache = DbContext.GetCategoryLadders().ToHashSet(); + CategoryCache = LadderCache.SelectMany(cl => cl.Categories).ToDictionarySafe(c => c.AudibleCategoryId); } // only use after loading contributors => local @@ -65,10 +54,9 @@ namespace DtoImporterService var id = ladder[i].CategoryId; var name = ladder[i].CategoryName; - if (!Cache.TryGetValue(id, out var category)) + if (!CategoryCache.TryGetValue(id, out var category)) { category = addCategory(id, name); - qtyNew++; } categories.Add(category); @@ -111,7 +99,7 @@ namespace DtoImporterService var entityEntry = DbContext.Categories.Add(category); var entity = entityEntry.Entity; - Cache.Add(entity.AudibleCategoryId, entity); + CategoryCache.Add(entity.AudibleCategoryId, entity); return entity; } catch (Exception ex)