#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);
}
}
}
}