using System; using System.Collections.Generic; using System.Linq; using Dinah.Core; using Microsoft.EntityFrameworkCore; namespace DataLayer { public class AudibleProductId { public string Id { get; } public AudibleProductId(string id) { ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id)); Id = id; } } public class Book { // implementation detail. set by db only. only used by data layer internal int BookId { get; private set; } // immutable public string AudibleProductId { get; private set; } public string Title { get; private set; } public string Description { get; private set; } public int LengthInMinutes { get; private set; } // mutable public string PictureId { get; set; } // book details public bool IsAbridged { get; private set; } public DateTime? DatePublished { get; private set; } // non-null. use "empty pattern" internal int CategoryId { get; private set; } public Category Category { get; private set; } public string[] CategoriesNames => Category == null ? new string[0] : Category.ParentCategory == null ? new[] { Category.Name } : new[] { Category.ParentCategory.Name, Category.Name }; public string[] CategoriesIds => Category == null ? null : Category.ParentCategory == null ? new[] { Category.AudibleCategoryId } : new[] { Category.ParentCategory.AudibleCategoryId, Category.AudibleCategoryId }; // is owned, not optional 1:1 public UserDefinedItem UserDefinedItem { get; private set; } // is owned, not optional 1:1 /// The product's aggregate community rating public Rating Rating { get; private set; } = new Rating(0, 0, 0); // ef-ctor private Book() { } // non-ef ctor /// special id class b/c it's too easy to get string order mixed up public Book( AudibleProductId audibleProductId, string title, string description, int lengthInMinutes, IEnumerable authors, IEnumerable narrators) { // validate ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId)); var productId = audibleProductId.Id; ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId)); // assign as soon as possible. stuff below relies on this AudibleProductId = productId; ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title)); // non-ef-ctor init.s UserDefinedItem = new UserDefinedItem(this); _contributorsLink = new HashSet(); _seriesLink = new HashSet(); _supplements = new HashSet(); // since category/id is never null, nullity means it hasn't been loaded CategoryId = Category.GetEmpty().CategoryId; // simple assigns Title = title; Description = description; LengthInMinutes = lengthInMinutes; // assigns with biz logic ReplaceAuthors(authors); ReplaceNarrators(narrators); } #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(); public IEnumerable Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList(); public string AuthorNames => string.Join(", ", Authors.Select(a => a.Name)); public IEnumerable Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList(); public string NarratorNames => string.Join(", ", Narrators.Select(n => n.Name)); public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name; public void ReplaceAuthors(IEnumerable authors, DbContext context = null) => replaceContributors(authors, Role.Author, context); public void ReplaceNarrators(IEnumerable narrators, DbContext context = null) => replaceContributors(narrators, Role.Narrator, context); public void ReplacePublisher(Contributor publisher, DbContext context = null) => replaceContributors(new List { publisher }, Role.Publisher, context); private void replaceContributors(IEnumerable newContributors, Role role, DbContext context = null) { ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors)); // the edge cases of doing local-loaded vs remote-only got weird. just load it if (_contributorsLink == null) { ArgumentValidator.EnsureNotNull(context, nameof(context)); if (!context.Entry(this).IsKeySet) throw new InvalidOperationException("Could not add contributors"); context.Entry(this).Collection(s => s.ContributorsLink).Load(); } var roleContributions = getContributions(role); var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors); if (isIdentical) return; _contributorsLink.RemoveWhere(bc => bc.Role == role); addNewContributors(newContributors, 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); } private List getContributions(Role role) => ContributorsLink .Where(a => a.Role == role) .OrderBy(a => a.Order) .ToList(); #endregion #region series private HashSet _seriesLink; public IEnumerable SeriesLink => _seriesLink?.ToList(); public string SeriesNames { get { // first: alphabetical by name var withNames = _seriesLink .Where(s => !string.IsNullOrWhiteSpace(s.Series.Name)) .Select(s => s.Series.Name) .OrderBy(a => a) .ToList(); // then un-named are alpha by series id var nullNames = _seriesLink .Where(s => string.IsNullOrWhiteSpace(s.Series.Name)) .Select(s => s.Series.AudibleSeriesId) .OrderBy(a => a) .ToList(); var all = withNames.Union(nullNames).ToList(); return string.Join(", ", all); } } public void UpsertSeries(Series series, float? index = null, DbContext context = null) { ArgumentValidator.EnsureNotNull(series, nameof(series)); // our add() is conditional upon what's already included in the collection. // therefore if not loaded, a trip is required. might as well just load it if (_seriesLink == null) { ArgumentValidator.EnsureNotNull(context, nameof(context)); if (!context.Entry(this).IsKeySet) throw new InvalidOperationException("Could not add series"); context.Entry(this).Collection(s => s.SeriesLink).Load(); } var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series); if (singleSeriesBook == null) _seriesLink.Add(new SeriesBook(series, this, index)); else singleSeriesBook.UpdateIndex(index); } #endregion #region supplements private HashSet _supplements; public IEnumerable Supplements => _supplements?.ToList(); public bool HasPdfs => Supplements.Any(); public void AddSupplementDownloadUrl(string url) { // supplements are owned by Book, so no need to Load(): // 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." ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url)); if (!_supplements.Any(s => url.EqualsInsensitive(url))) _supplements.Add(new Supplement(this, url)); } #endregion public void UpdateProductRating(float overallRating, float performanceRating, float storyRating) => Rating.Update(overallRating, performanceRating, storyRating); public void UpdateBookDetails(bool isAbridged, DateTime? datePublished) { // don't overwrite with default values IsAbridged |= isAbridged; DatePublished = datePublished ?? DatePublished; } public void UpdateCategory(Category category, DbContext context = null) { // since category is never null, nullity means it hasn't been loaded if (Category != null || CategoryId == Category.GetEmpty().CategoryId) { Category = category; return; } if (context == null) throw new Exception("need context"); context.Entry(this).Reference(s => s.Category).Load(); Category = category; } } }