using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Exceptions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; namespace Kyoo.Database { /// /// The database handle used for all local repositories. /// This is an abstract class. It is meant to be implemented by plugins. This allow the core to be database agnostic. /// /// /// It should not be used directly, to access the database use a or repositories. /// public abstract class DatabaseContext : DbContext { /// /// All libraries of Kyoo. See . /// public DbSet Libraries { get; set; } /// /// All collections of Kyoo. See . /// public DbSet Collections { get; set; } /// /// All shows of Kyoo. See . /// public DbSet Shows { get; set; } /// /// All seasons of Kyoo. See . /// public DbSet Seasons { get; set; } /// /// All episodes of Kyoo. See . /// public DbSet Episodes { get; set; } /// /// All tracks of Kyoo. See . /// public DbSet Tracks { get; set; } /// /// All genres of Kyoo. See . /// public DbSet Genres { get; set; } /// /// All people of Kyoo. See . /// public DbSet People { get; set; } /// /// All studios of Kyoo. See . /// public DbSet Studios { get; set; } /// /// All providers of Kyoo. See . /// public DbSet Providers { get; set; } /// /// The list of registered users. /// public DbSet Users { get; set; } /// /// All people's role. See . /// public DbSet PeopleRoles { get; set; } /// /// Episodes with a watch percentage. See . /// public DbSet WatchedEpisodes { get; set; } /// /// The list of library items (shows and collections that are part of a library - or the global one). /// /// /// This set is ready only, on most database this will be a view. /// public DbSet LibraryItems { get; set; } /// /// Get all metadataIDs (ExternalIDs) of a given resource. See . /// /// The metadata of this type will be returned. /// A queryable of metadata ids for a type. public DbSet MetadataIds() where T : class, IMetadata { return Set(MetadataName()); } /// /// Add a many to many link between two resources. /// /// Types are order dependant. You can't inverse the order. Please always put the owner first. /// The ID of the first resource. /// The ID of the second resource. /// The first resource type of the relation. It is the owner of the second /// The second resource type of the relation. It is the contained resource. public async Task AddLinks(int first, int second) where T1 : class, IResource where T2 : class, IResource { await Set>(LinkName()) .AddAsync(new Dictionary { [LinkNameFk()] = first, [LinkNameFk()] = second }); } /// /// The default constructor /// protected DatabaseContext() { } /// /// Create a new using specific options /// /// The options to use. protected DatabaseContext(DbContextOptions options) : base(options) { } /// /// Get the name of the metadata table of the given type. /// /// The type related to the metadata /// The name of the table containing the metadata. protected abstract string MetadataName() where T : IMetadata; /// /// Get the name of the link table of the two given types. /// /// The owner type of the relation /// The child type of the relation /// The name of the table containing the links. protected abstract string LinkName() where T : IResource where T2 : IResource; /// /// Get the name of a link's foreign key. /// /// The type that will be accessible via the navigation /// The name of the foreign key for the given resource. protected abstract string LinkNameFk() where T : IResource; /// /// Set basic configurations (like preventing query tracking) /// /// An option builder to fill. protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); } /// /// Build the metadata model for the given type. /// /// The database model builder /// The type to add metadata to. private void _HasMetadata(ModelBuilder modelBuilder) where T : class, IMetadata { modelBuilder.SharedTypeEntity(MetadataName()) .HasKey(MetadataID.PrimaryKey); modelBuilder.SharedTypeEntity(MetadataName()) .HasOne() .WithMany(x => x.ExternalIDs) .HasForeignKey(x => x.ResourceID) .OnDelete(DeleteBehavior.Cascade); } /// /// Create a many to many relationship between the two entities. /// The resulting relationship will have an available method. /// /// The database model builder /// The first navigation expression from T to T2 /// The second navigation expression from T2 to T /// The owning type of the relationship /// The owned type of the relationship private void _HasManyToMany(ModelBuilder modelBuilder, Expression>> firstNavigation, Expression>> secondNavigation) where T : class, IResource where T2 : class, IResource { modelBuilder.Entity() .HasMany(secondNavigation) .WithMany(firstNavigation) .UsingEntity>( LinkName(), x => x .HasOne() .WithMany() .HasForeignKey(LinkNameFk()) .OnDelete(DeleteBehavior.Cascade), x => x .HasOne() .WithMany() .HasForeignKey(LinkNameFk()) .OnDelete(DeleteBehavior.Cascade) ); } /// /// Set database parameters to support every types of Kyoo. /// /// The database's model builder. protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity() .Ignore(x => x.ForPeople); modelBuilder.Entity() .HasMany(x => x.Seasons) .WithOne(x => x.Show) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasMany(x => x.Episodes) .WithOne(x => x.Show) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasMany(x => x.Episodes) .WithOne(x => x.Season) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasMany(x => x.Tracks) .WithOne(x => x.Episode) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(x => x.Studio) .WithMany(x => x.Shows) .OnDelete(DeleteBehavior.SetNull); _HasManyToMany(modelBuilder, x => x.Providers, x => x.Libraries); _HasManyToMany(modelBuilder, x => x.Collections, x => x.Libraries); _HasManyToMany(modelBuilder, x => x.Shows, x => x.Libraries); _HasManyToMany(modelBuilder, x => x.Shows, x => x.Collections); _HasManyToMany(modelBuilder, x => x.Genres, x => x.Shows); modelBuilder.Entity() .HasMany(x => x.Watched) .WithMany("Users") .UsingEntity(x => x.ToTable(LinkName())); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); modelBuilder.Entity() .HasKey(x => new { User = x.UserID, Episode = x.EpisodeID }); modelBuilder.Entity().Property(x => x.Slug).IsRequired(); modelBuilder.Entity().Property(x => x.Slug).IsRequired(); modelBuilder.Entity().Property(x => x.Slug).IsRequired(); modelBuilder.Entity().Property(x => x.Slug).IsRequired(); modelBuilder.Entity().Property(x => x.Slug).IsRequired(); modelBuilder.Entity().Property(x => x.Slug).IsRequired(); modelBuilder.Entity().Property(x => x.Slug).IsRequired(); modelBuilder.Entity().Property(x => x.Slug).IsRequired(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.ShowID, x.SeasonNumber }) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.ShowID, x.SeasonNumber, x.EpisodeNumber, x.AbsoluteNumber }) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => new { x.EpisodeID, x.Type, x.Language, x.TrackIndex, x.IsForced }) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .HasIndex(x => x.Slug) .IsUnique(); modelBuilder.Entity() .Property(x => x.Slug) .ValueGeneratedOnAddOrUpdate(); modelBuilder.Entity() .Property(x => x.Slug) .ValueGeneratedOnAddOrUpdate(); modelBuilder.Entity() .Property(x => x.Slug) .ValueGeneratedOnAddOrUpdate(); } /// /// Return a new or an in cache temporary object wih the same ID as the one given /// /// If a resource with the same ID is found in the database, it will be used. /// will be used otherwise /// The type of the resource /// A resource that is now tracked by this context. public T GetTemporaryObject(T model) where T : class, IResource { T tmp = Set().Local.FirstOrDefault(x => x.ID == model.ID); if (tmp != null) return tmp; Entry(model).State = EntityState.Unchanged; return model; } /// /// Save changes that are applied to this context. /// /// A duplicated item has been found. /// The number of state entries written to the database. public override int SaveChanges() { try { return base.SaveChanges(); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } /// /// Save changes that are applied to this context. /// /// Indicates whether AcceptAllChanges() is called after the changes /// have been sent successfully to the database. /// A duplicated item has been found. /// The number of state entries written to the database. public override int SaveChanges(bool acceptAllChangesOnSuccess) { try { return base.SaveChanges(acceptAllChangesOnSuccess); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } /// /// Save changes that are applied to this context. /// /// The message that will have the /// (if a duplicate is found). /// A duplicated item has been found. /// The number of state entries written to the database. public int SaveChanges(string duplicateMessage) { try { return base.SaveChanges(); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(duplicateMessage); throw; } } /// /// Save changes that are applied to this context. /// /// Indicates whether AcceptAllChanges() is called after the changes /// have been sent successfully to the database. /// A to observe while waiting for the task to complete /// A duplicated item has been found. /// The number of state entries written to the database. public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = new()) { try { return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } /// /// Save changes that are applied to this context. /// /// A to observe while waiting for the task to complete /// A duplicated item has been found. /// The number of state entries written to the database. public override async Task SaveChangesAsync(CancellationToken cancellationToken = new()) { try { return await base.SaveChangesAsync(cancellationToken); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } /// /// Save changes that are applied to this context. /// /// The message that will have the /// (if a duplicate is found). /// A to observe while waiting for the task to complete /// A duplicated item has been found. /// The number of state entries written to the database. public async Task SaveChangesAsync(string duplicateMessage, CancellationToken cancellationToken = default) { try { return await base.SaveChangesAsync(cancellationToken); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(duplicateMessage); throw; } } /// /// Save changes if no duplicates are found. If one is found, no change are saved but the current changes are no discarded. /// The current context will still hold those invalid changes. /// /// A to observe while waiting for the task to complete /// The number of state entries written to the database or -1 if a duplicate exist. public async Task SaveIfNoDuplicates(CancellationToken cancellationToken = default) { try { return await SaveChangesAsync(cancellationToken); } catch (DuplicatedItemException) { return -1; } } /// /// Return the first resource with the given slug that is currently tracked by this context. /// This allow one to limit redundant calls to during the /// same transaction and prevent fails from EF when two same entities are being tracked. /// /// The slug of the resource to check /// The type of entity to check /// The local entity representing the resource with the given slug if it exists or null. [CanBeNull] public T LocalEntity(string slug) where T : class, IResource { return ChangeTracker.Entries() .FirstOrDefault(x => x.Entity.Slug == slug) ?.Entity; } /// /// Check if the exception is a duplicated exception. /// /// The exception to check /// True if the exception is a duplicate exception. False otherwise protected abstract bool IsDuplicateException(Exception ex); /// /// Delete every changes that are on this context. /// private void DiscardChanges() { foreach (EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Detached)) { entry.State = EntityState.Detached; } } /// /// Perform a case insensitive like operation. /// /// An accessor to get the item that will be checked. /// The second operator of the like format. /// The type of the item to query /// An expression representing the like query. It can directly be passed to a where call. public abstract Expression> Like(Expression> query, string format); } }