using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Server.Implementations.Item; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Operations; using Moq; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Item; public sealed class PeopleUpdateQueryTests : SqliteDbTestFixture { private readonly CommandRecorder _recorder; private readonly Guid _itemId = Guid.NewGuid(); private readonly PeopleRepository _people; public PeopleUpdateQueryTests() : this(new CommandRecorder()) { } private PeopleUpdateQueryTests(CommandRecorder recorder) : base(recorder) { _recorder = recorder; using var context = CreateDbContext(); context.BaseItems.Add(new BaseItemEntity { Id = _itemId, Name = "Movie", Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie] }); context.SaveChanges(); _people = new PeopleRepository(CreateDbContextFactory(), new ItemTypeLookup(), Mock.Of()); } [Theory] [InlineData("Hero")] [InlineData("HERO")] public void UnchangedCredits_DoNotWriteOrLookUpAllPeople(string role) { _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, Role = "Hero" }]); _recorder.Commands.Clear(); _people.UpdatePeople(_itemId, [new PersonInfo { Name = "actor", Type = PersonKind.Actor, Role = role }]); Assert.Single(_recorder.Commands); Assert.StartsWith("SELECT", _recorder.Commands[0].Sql, StringComparison.Ordinal); using var context = CreateDbContext(); Assert.Equal("Hero", Assert.Single(context.PeopleBaseItemMap).Role); } [Fact] public void SortOrderChange_IsPersisted() { _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 1 }]); _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 2 }]); using var context = CreateDbContext(); Assert.Equal(2, Assert.Single(context.PeopleBaseItemMap).SortOrder); } [Fact] public void UpdatePeople_GeneratedSqlUsesPeopleNameIndex() { ApplyMigration(new Jellyfin.Server.Implementations.Migrations.AddPeopleNameLowerIndex()); _recorder.Commands.Clear(); _people.UpdatePeople(_itemId, [ new PersonInfo { Name = "Actor A", Type = PersonKind.Actor }, new PersonInfo { Name = "Actor B", Type = PersonKind.Actor } ]); var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("lower(\"p\".\"Name\")", StringComparison.Ordinal)); Assert.Contains(Explain(query), line => line.Contains("SEARCH p USING INDEX IX_Peoples_NameLower", StringComparison.Ordinal)); } private void ApplyMigration(Migration migration) { using var context = CreateDbContext(); foreach (var operation in migration.UpOperations.Cast()) { context.Database.ExecuteSqlRaw(operation.Sql); } } private string[] Explain(RecordedCommand query) { using var context = CreateDbContext(); using var command = context.Database.GetDbConnection().CreateCommand(); #pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters. command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql; #pragma warning restore CA2100 foreach (var value in query.Parameters) { var parameter = command.CreateParameter(); parameter.ParameterName = value.Name; parameter.Value = value.Value; command.Parameters.Add(parameter); } using var reader = command.ExecuteReader(); var plan = new List(); while (reader.Read()) { plan.Add(reader.GetString(3)); } return plan.ToArray(); } private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters); private sealed class CommandRecorder : DbCommandInterceptor { public List Commands { get; } = []; public override InterceptionResult ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) { Record(command); return result; } public override InterceptionResult NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) { Record(command); return result; } private void Record(DbCommand command) => Commands.Add(new RecordedCommand( command.CommandText, command.Parameters.Cast().Select(p => (p.ParameterName, p.Value)).ToArray())); } }