Allow random queries to be paginated

This commit is contained in:
Zoe Roux 2023-10-25 00:52:51 +02:00
parent e8b929d4ca
commit e13f9c6aa8
7 changed files with 92 additions and 41 deletions

View File

@ -90,9 +90,6 @@ namespace Kyoo.Abstractions.Models
{ {
Items = items; Items = items;
This = url + query.ToQueryString(); This = url + query.ToQueryString();
if (!(query.TryGetValue("sortBy", out string? sort) && sort.Contains("random")))
{
if (items.Count > 0 && query.ContainsKey("afterID")) if (items.Count > 0 && query.ContainsKey("afterID"))
{ {
query["afterID"] = items.First().Id.ToString(); query["afterID"] = items.First().Id.ToString();
@ -105,8 +102,6 @@ namespace Kyoo.Abstractions.Models
query["afterID"] = items.Last().Id.ToString(); query["afterID"] = items.Last().Id.ToString();
Next = url + query.ToQueryString(); Next = url + query.ToQueryString();
} }
}
query.Remove("afterID"); query.Remove("afterID");
First = url + query.ToQueryString(); First = url + query.ToQueryString();
} }

View File

@ -57,7 +57,7 @@ namespace Kyoo.Abstractions.Controllers
public record Conglomerate(params Sort<T>[] List) : Sort<T>; public record Conglomerate(params Sort<T>[] List) : Sort<T>;
/// <summary>Sort randomly items</summary> /// <summary>Sort randomly items</summary>
public record Random() : Sort<T>; public record Random(int seed) : Sort<T>;
/// <summary>The default sort method for the given type.</summary> /// <summary>The default sort method for the given type.</summary>
public record Default : Sort<T>; public record Default : Sort<T>;
@ -73,10 +73,13 @@ namespace Kyoo.Abstractions.Controllers
if (string.IsNullOrEmpty(sortBy) || sortBy == "default") if (string.IsNullOrEmpty(sortBy) || sortBy == "default")
return new Default(); return new Default();
if (sortBy == "random") if (sortBy == "random")
return new Random(); return new Random(new System.Random().Next(int.MinValue, int.MaxValue));
if (sortBy.Contains(',')) if (sortBy.Contains(','))
return new Conglomerate(sortBy.Split(',').Select(From).ToArray()); return new Conglomerate(sortBy.Split(',').Select(From).ToArray());
if (sortBy.StartsWith("random:"))
return new Random(int.Parse(sortBy["random:".Length..]));
string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy; string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy;
string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null; string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null;
bool desendant = order switch bool desendant = order switch

View File

@ -28,6 +28,7 @@ using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Core.Api; using Kyoo.Core.Api;
using Kyoo.Postgresql;
using Kyoo.Utils; using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -109,8 +110,9 @@ namespace Kyoo.Core.Controllers
return _Sort(query, DefaultSort, then); return _Sort(query, DefaultSort, then);
case Sort<T>.By(var key, var desc): case Sort<T>.By(var key, var desc):
return _SortBy(query, x => EF.Property<T>(x, key), desc, then); return _SortBy(query, x => EF.Property<T>(x, key), desc, then);
case Sort<T>.Random: case Sort<T>.Random(var seed):
return _SortBy(query, x => EF.Functions.Random(), false, then); // NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function
return _SortBy(query, x => DatabaseContext.MD5(seed + x.Id.ToString()), false, then);
case Sort<T>.Conglomerate(var sorts): case Sort<T>.Conglomerate(var sorts):
IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false); IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false);
foreach (Sort<T> sort in sorts.Skip(1)) foreach (Sort<T> sort in sorts.Skip(1))
@ -136,6 +138,8 @@ namespace Kyoo.Core.Controllers
: (greaterThan ? Expression.GreaterThan : Expression.LessThan); : (greaterThan ? Expression.GreaterThan : Expression.LessThan);
} }
private record SortIndicator(string key, bool desc, string? seed);
/// <summary> /// <summary>
/// Create a filter (where) expression on the query to skip everything before/after the referenceID. /// Create a filter (where) expression on the query to skip everything before/after the referenceID.
/// The generalized expression for this in pseudocode is: /// The generalized expression for this in pseudocode is:
@ -164,45 +168,66 @@ namespace Kyoo.Core.Controllers
ParameterExpression x = Expression.Parameter(typeof(T), "x"); ParameterExpression x = Expression.Parameter(typeof(T), "x");
ConstantExpression referenceC = Expression.Constant(reference, typeof(T)); ConstantExpression referenceC = Expression.Constant(reference, typeof(T));
IEnumerable<Sort<T>.By> GetSortsBy(Sort<T> sort) void GetRandomSortKeys(string seed, out Expression left, out Expression right)
{
MethodInfo concat = typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) });
Expression id = Expression.Call(Expression.Property(x, "ID"), nameof(int.ToString), null);
Expression xrng = Expression.Call(concat, Expression.Constant(seed), id);
right = Expression.Call(typeof(DatabaseContext), nameof(DatabaseContext.MD5), null, Expression.Constant($"{seed}{reference.Id}"));
left = Expression.Call(typeof(DatabaseContext), nameof(DatabaseContext.MD5), null, xrng);
}
IEnumerable<SortIndicator> GetSortsBy(Sort<T> sort)
{ {
return sort switch return sort switch
{ {
Sort<T>.Default => GetSortsBy(DefaultSort), Sort<T>.Default => GetSortsBy(DefaultSort),
Sort<T>.By @sortBy => new[] { sortBy }, Sort<T>.By @sortBy => new[] { new SortIndicator(sortBy.Key, sortBy.Desendant, null) },
Sort<T>.Conglomerate(var list) => list.SelectMany(GetSortsBy), Sort<T>.Conglomerate(var list) => list.SelectMany(GetSortsBy),
Sort<T>.Random => throw new ArgumentException("Impossible to paginate randomly sorted items."), Sort<T>.Random(var seed) => new[] { new SortIndicator("random", false, seed.ToString()) },
_ => Array.Empty<Sort<T>.By>(), _ => Array.Empty<SortIndicator>(),
}; };
} }
// Don't forget that every sorts must end with a ID sort (to differentiate equalities). // Don't forget that every sorts must end with a ID sort (to differentiate equalities).
Sort<T>.By id = new(x => x.Id); IEnumerable<SortIndicator> sorts = GetSortsBy(sort)
IEnumerable<Sort<T>.By> sorts = GetSortsBy(sort).Append(id); .Append(new SortIndicator("Id", false, null));
BinaryExpression filter = null; BinaryExpression filter = null;
List<Sort<T>.By> previousSteps = new(); List<SortIndicator> previousSteps = new();
// TODO: Add an outer query >= for perf // TODO: Add an outer query >= for perf
// PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic // PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic
foreach ((string key, bool desc) in sorts) foreach ((string key, bool desc, string? seed) in sorts)
{ {
BinaryExpression compare = null; BinaryExpression compare = null;
PropertyInfo property = typeof(T).GetProperty(key); PropertyInfo property = key != "random"
? typeof(T).GetProperty(key)
: null;
// Comparing a value with null always return false so we short opt < > comparisons with null. // Comparing a value with null always return false so we short opt < > comparisons with null.
if (property!.GetValue(reference) == null) if (property != null && property.GetValue(reference) == null)
{ {
previousSteps.Add(new Sort<T>.By(key, desc)); previousSteps.Add(new SortIndicator(key, desc, seed));
continue; continue;
} }
// Create all the equality statements for previous sorts. // Create all the equality statements for previous sorts.
foreach ((string pKey, bool pDesc) in previousSteps) foreach ((string pKey, bool pDesc, string? pSeed) in previousSteps)
{ {
BinaryExpression pcompare = Expression.Equal( BinaryExpression pcompare;
if (pSeed == null)
{
pcompare = Expression.Equal(
Expression.Property(x, pKey), Expression.Property(x, pKey),
Expression.Property(referenceC, pKey) Expression.Property(referenceC, pKey)
); );
}
else
{
GetRandomSortKeys(pSeed, out Expression left, out Expression right);
pcompare = Expression.Equal(left, right);
}
compare = compare != null compare = compare != null
? Expression.AndAlso(compare, pcompare) ? Expression.AndAlso(compare, pcompare)
: pcompare; : pcompare;
@ -210,14 +235,21 @@ namespace Kyoo.Core.Controllers
// Create the last comparison of the statement. // Create the last comparison of the statement.
Func<Expression, Expression, BinaryExpression> comparer = _GetComparisonExpression(desc, next, false); Func<Expression, Expression, BinaryExpression> comparer = _GetComparisonExpression(desc, next, false);
MemberExpression xkey = Expression.Property(x, key); Expression xkey;
MemberExpression rkey = Expression.Property(referenceC, key); Expression rkey;
if (seed == null)
{
xkey = Expression.Property(x, key);
rkey = Expression.Property(referenceC, key);
}
else
GetRandomSortKeys(seed, out xkey, out rkey);
BinaryExpression lastCompare = ApiHelper.StringCompatibleExpression(comparer, xkey, rkey); BinaryExpression lastCompare = ApiHelper.StringCompatibleExpression(comparer, xkey, rkey);
// Comparing a value with null always return false for nulls so we must add nulls to the results manually. // Comparing a value with null always return false for nulls so we must add nulls to the results manually.
// Postgres sorts them after values so we will do the same // Postgres sorts them after values so we will do the same
// We only add this condition if the column type is nullable // We only add this condition if the column type is nullable
if (Nullable.GetUnderlyingType(property.PropertyType) != null) if (property != null && Nullable.GetUnderlyingType(property.PropertyType) != null)
{ {
BinaryExpression equalNull = Expression.Equal(xkey, Expression.Constant(null)); BinaryExpression equalNull = Expression.Equal(xkey, Expression.Constant(null));
lastCompare = Expression.OrElse(lastCompare, equalNull); lastCompare = Expression.OrElse(lastCompare, equalNull);
@ -231,7 +263,7 @@ namespace Kyoo.Core.Controllers
? Expression.OrElse(filter, compare) ? Expression.OrElse(filter, compare)
: compare; : compare;
previousSteps.Add(new(key, desc)); previousSteps.Add(new SortIndicator(key, desc, seed));
} }
return Expression.Lambda<Func<T, bool>>(filter!, x); return Expression.Lambda<Func<T, bool>>(filter!, x);
} }

View File

@ -24,7 +24,6 @@ using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using JetBrains.Annotations; using JetBrains.Annotations;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Utils;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api
{ {
@ -53,7 +52,7 @@ namespace Kyoo.Core.Api
[NotNull] Expression left, [NotNull] Expression left,
[NotNull] Expression right) [NotNull] Expression right)
{ {
if (left is not MemberExpression member || ((PropertyInfo)member.Member).PropertyType != typeof(string)) if (left.Type != typeof(string))
return operand(left, right); return operand(left, right);
MethodCallExpression call = Expression.Call(typeof(string), "Compare", null, left, right); MethodCallExpression call = Expression.Call(typeof(string), "Compare", null, left, right);
return operand(call, Expression.Constant(0)); return operand(call, Expression.Constant(0));

View File

@ -41,6 +41,13 @@ namespace Kyoo.Postgresql
/// </remarks> /// </remarks>
public abstract class DatabaseContext : DbContext public abstract class DatabaseContext : DbContext
{ {
/// <summary>
/// Calculate the MD5 of a string, can only be used in database context.
/// </summary>
/// <param name="str">The string to hash</param>
/// <returns>The hash</returns>
public static string MD5(string str) => throw new NotSupportedException();
/// <summary> /// <summary>
/// All collections of Kyoo. See <see cref="Collection"/>. /// All collections of Kyoo. See <see cref="Collection"/>.
/// </summary> /// </summary>

View File

@ -2,6 +2,7 @@
<PropertyGroup> <PropertyGroup>
<AssemblyName>Kyoo.Postgresql</AssemblyName> <AssemblyName>Kyoo.Postgresql</AssemblyName>
<RootNamespace>Kyoo.Postgresql</RootNamespace> <RootNamespace>Kyoo.Postgresql</RootNamespace>
<Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -18,12 +18,14 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using EFCore.NamingConventions.Internal; using EFCore.NamingConventions.Internal;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Utils; using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using Npgsql; using Npgsql;
namespace Kyoo.Postgresql namespace Kyoo.Postgresql
@ -104,6 +106,18 @@ namespace Kyoo.Postgresql
modelBuilder.HasPostgresEnum<Genre>(); modelBuilder.HasPostgresEnum<Genre>();
modelBuilder.HasPostgresEnum<ItemKind>(); modelBuilder.HasPostgresEnum<ItemKind>();
modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5)))
.HasTranslation(args =>
new SqlFunctionExpression(
"md5",
args,
nullable: true,
argumentsPropagateNullability: new[] { false },
type: args[0].Type,
typeMapping: args[0].TypeMapping
)
);
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
} }