#pragma warning disable RS0030 // Do not use banned APIs using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; namespace Jellyfin.Database.Implementations; /// /// Contains a number of query related extensions. /// /// /// Every helper here binds its values through . Values embedded as bare /// constants are inlined into the SQL as literals, which gives each distinct value its own entry in EF's /// compiled query cache and its own statement for the database to plan. /// public static class JellyfinQueryHelperExtensions { private static readonly MethodInfo _containsMethodGenericCache = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static).First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2); private static readonly MethodInfo _efParameterInstruction = typeof(EF).GetMethod(nameof(EF.Parameter), BindingFlags.Public | BindingFlags.Static)!; private static readonly ConcurrentDictionary _containsQueryCache = new(); private static readonly ConcurrentDictionary _efParameterCache = new(); /// /// Builds an optimised query checking one property against a list of values while maintaining an optimal query. /// /// The entity. /// The property type to compare. /// The source query. /// The list of items to check. An empty list matches nothing. /// Property expression. /// A Query. public static IQueryable WhereOneOrMany(this IQueryable query, IReadOnlyList oneOf, Expression> property) { return query.Where(OneOrManyExpressionBuilder(oneOf, property)); } /// /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query. /// /// The entity. /// The property type to compare. /// The list of items to check. An empty list matches nothing. /// Property expression. /// A Query. public static Expression> OneOrManyExpressionBuilder(this IReadOnlyList oneOf, Expression> property) { ArgumentNullException.ThrowIfNull(oneOf); ArgumentNullException.ThrowIfNull(property); var parameter = Expression.Parameter(typeof(TEntity), "item"); property = ParameterReplacer.Replace, Func>(property, property.Parameters[0], parameter); if (oneOf.Count == 0) { // Fail closed, and without asking the database to unpack an empty collection to prove it. return Expression.Lambda>(Expression.Constant(false), parameter); } if (oneOf.Count == 1) { var value = Expression.Call( null, EfParameterFor(typeof(TProperty)), Expression.Constant(oneOf[0], typeof(TProperty))); return Expression.Lambda>( typeof(TProperty).IsValueType ? Expression.Equal(property.Body, value) : Expression.ReferenceEqual(property.Body, value), parameter); } var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); // Binding the whole collection as one parameter keeps the statement identical for any element // count, instead of emitting one placeholder per element. return Expression.Lambda>( Expression.Call( null, containsMethodInfo, Expression.Call(null, EfParameterFor(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter); } private static MethodInfo EfParameterFor(Type type) { return _efParameterCache.GetOrAdd(type, static (key) => _efParameterInstruction.MakeGenericMethod(key)); } /// /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup. /// /// The source query. /// The database context. /// The type of item value to reference. /// The list of BaseItem ids to check matches. /// If set an exclusion check is performed instead. /// A Query. public static IQueryable WhereReferencedItem( this IQueryable baseQuery, JellyfinDbContext context, ItemValueType itemValueType, IReadOnlyList referenceIds, bool invert = false) { return baseQuery.WhereReferencedItem(context, [itemValueType], referenceIds, invert); } /// /// Builds a query that checks referenced ItemValues of any of the given types for a cross BaseItem lookup. /// /// The source query. /// The database context. /// The types of item value to reference. /// The list of BaseItem ids to check matches. /// If set an exclusion check is performed instead. /// A Query. /// /// Matching is on CleanName alone. Genre/artist/album etc items do not set an ItemValue of their own /// type, so the referenced item's Type is never consulted and ids whose names clean to the same value /// are interchangeable across types. /// public static IQueryable WhereReferencedItem( this IQueryable baseQuery, JellyfinDbContext context, IReadOnlyList itemValueTypes, IReadOnlyList referenceIds, bool invert = false) { ArgumentNullException.ThrowIfNull(context); // Flat sub-selects rather than a correlated .Any(...Any(...)). var referencedCleanValues = context.BaseItems .Where(OneOrManyExpressionBuilder(referenceIds, e => e.Id)) .Select(e => e.CleanName); var matchingItemIds = context.ItemValuesMap .Where(OneOrManyExpressionBuilder(itemValueTypes, m => m.ItemValue.Type)) .Where(m => referencedCleanValues.Contains(m.ItemValue.CleanValue)) .Select(m => m.ItemId); return invert ? baseQuery.Where(e => !matchingItemIds.Contains(e.Id)) : baseQuery.Where(e => matchingItemIds.Contains(e.Id)); } /// /// Filters items that have any of the specified providers, optionally restricted to given values. /// /// The source query. /// Dictionary mapping provider names to values to match. An empty value array matches any value for that provider. /// A filtered query. public static IQueryable WhereHasAnyProviderIds( this IQueryable baseQuery, IReadOnlyDictionary providerIds) { return baseQuery.WhereProviderMatch(Flatten(providerIds), false); } /// /// Filters items that have any of the specified providers, optionally restricted to a given value. /// /// The source query. /// Dictionary mapping provider names to optional values. An empty value matches any value for that provider. /// A filtered query. public static IQueryable WhereHasAnyProviderId( this IQueryable baseQuery, IReadOnlyDictionary providerIds) { return baseQuery.WhereProviderMatch(providerIds, false); } /// /// Excludes items that have any of the specified providers, optionally restricted to a given value. /// /// The source query. /// Dictionary mapping provider names to optional values. An empty value excludes any value for that provider. /// A filtered query. public static IQueryable WhereExcludeProviderIds( this IQueryable baseQuery, IReadOnlyDictionary providerIds) { return baseQuery.WhereProviderMatch(providerIds, true); } private static IEnumerable> Flatten(IReadOnlyDictionary providerIds) { ArgumentNullException.ThrowIfNull(providerIds); foreach (var (provider, values) in providerIds) { if (values is null || values.Length == 0) { yield return new KeyValuePair(provider, string.Empty); continue; } foreach (var value in values) { yield return new KeyValuePair(provider, value); } } } /// /// Matches items against a set of (provider, value) pairs, where an empty value means any value for /// that provider. Emits a single EXISTS over the provider collection with the predicates OR'd, rather /// than one subquery per predicate group. /// private static IQueryable WhereProviderMatch( this IQueryable baseQuery, IEnumerable> providerIds, bool invert) { ArgumentNullException.ThrowIfNull(providerIds); var existenceOnly = new List(); var specificValues = new List(); foreach (var (provider, value) in providerIds) { if (string.IsNullOrEmpty(value)) { existenceOnly.Add(provider); } else { specificValues.Add(provider + ":" + value); } } if (existenceOnly.Count == 0 && specificValues.Count == 0) { return baseQuery; } var predicate = ProviderPredicate(existenceOnly, specificValues); // NOT EXISTS rather than NOT IN: the latter yields no rows at all if the subquery can produce NULL. return invert ? baseQuery.Where(e => !e.Provider!.AsQueryable().Any(predicate)) : baseQuery.Where(e => e.Provider!.AsQueryable().Any(predicate)); } private static Expression> ProviderPredicate( IReadOnlyList existenceOnly, IReadOnlyList specificValues) { var byProvider = existenceOnly.OneOrManyExpressionBuilder(p => p.ProviderId); var byPair = specificValues.OneOrManyExpressionBuilder(p => p.ProviderId + ":" + p.ProviderValue); // Both builders mint their own parameter; rebind so the two bodies can share one lambda. var parameter = byProvider.Parameters[0]; var reboundPair = ParameterReplacer.Replace, Func>(byPair, byPair.Parameters[0], parameter); return Expression.Lambda>( Expression.OrElse(byProvider.Body, reboundPair.Body), parameter); } internal static class ParameterReplacer { // Produces an expression identical to 'expression' // except with 'source' parameter replaced with 'target' expression. internal static Expression Replace( Expression expression, ParameterExpression source, ParameterExpression target) { return new ParameterReplacerVisitor(source, target) .VisitAndConvert(expression); } private sealed class ParameterReplacerVisitor : ExpressionVisitor { private readonly ParameterExpression _source; private readonly ParameterExpression _target; public ParameterReplacerVisitor(ParameterExpression source, ParameterExpression target) { _source = source; _target = target; } internal Expression VisitAndConvert(Expression root) { return (Expression)VisitLambda(root); } protected override Expression VisitLambda(Expression node) { // Leave all parameters alone except the one we want to replace. var parameters = node.Parameters.Select(p => p == _source ? _target : p); return Expression.Lambda(Visit(node.Body), parameters); } protected override Expression VisitParameter(ParameterExpression node) { // Replace the source with the target, visit other params as usual. return node == _source ? _target : base.VisitParameter(node); } } } }