Parse new filters

This commit is contained in:
Zoe Roux 2023-11-24 00:17:52 +01:00
parent e9aaa184cf
commit edc6d11824
8 changed files with 202 additions and 271 deletions

View File

@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Sprache" Version="2.3.1" />
<PackageReference Include="System.ComponentModel.Composition" Version="7.0.0" />
</ItemGroup>
</Project>

View File

@ -17,8 +17,13 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Sprache;
namespace Kyoo.Abstractions.Models.Utils;
public abstract record Filter
@ -46,24 +51,154 @@ public abstract record Filter<T> : Filter
public record Eq(string property, object value) : Filter<T>;
public record Ne<T2>(string property, T2 value) : Filter<T>;
public record Ne(string property, object value) : Filter<T>;
public record Gt<T2>(string property, T2 value) : Filter<T>;
public record Gt(string property, object value) : Filter<T>;
public record Ge<T2>(string property, T2 value) : Filter<T>;
public record Ge(string property, object value) : Filter<T>;
public record Lt<T2>(string property, T2 value) : Filter<T>;
public record Lt(string property, object value) : Filter<T>;
public record Le<T2>(string property, T2 value) : Filter<T>;
public record Le(string property, object value) : Filter<T>;
public record Has<T2>(string property, T2 value) : Filter<T>;
public record Has(string property, object value) : Filter<T>;
public record In(string property, object[] value) : Filter<T>;
public record Lambda(Expression<Func<T, bool>> lambda) : Filter<T>;
public static Filter<T> From(string filter)
public static class FilterParsers
{
public static readonly Parser<Filter<T>> Filter =
Parse.Ref(() => Bracket)
.Or(Parse.Ref(() => Not))
.Or(Parse.Ref(() => Eq))
.Or(Parse.Ref(() => Ne))
.Or(Parse.Ref(() => Gt))
.Or(Parse.Ref(() => Ge))
.Or(Parse.Ref(() => Lt))
.Or(Parse.Ref(() => Le));
public static readonly Parser<Filter<T>> CompleteFilter =
Parse.Ref(() => Or)
.Or(Parse.Ref(() => And))
.Or(Filter);
public static readonly Parser<Filter<T>> Bracket =
from open in Parse.Char('(').Token()
from filter in CompleteFilter
from close in Parse.Char(')').Token()
select filter;
public static readonly Parser<IEnumerable<char>> AndOperator = Parse.IgnoreCase("and")
.Or(Parse.String("&&"))
.Token();
public static readonly Parser<IEnumerable<char>> OrOperator = Parse.IgnoreCase("or")
.Or(Parse.String("||"))
.Token();
public static readonly Parser<Filter<T>> And = Parse.ChainOperator(AndOperator, Filter, (_, a, b) => new Filter<T>.And(a, b));
public static readonly Parser<Filter<T>> Or = Parse.ChainOperator(OrOperator, And.Or(Filter), (_, a, b) => new Filter<T>.Or(a, b));
public static readonly Parser<Filter<T>> Not =
from not in Parse.IgnoreCase("not")
.Or(Parse.String("!"))
.Token()
from filter in CompleteFilter
select new Filter<T>.Not(filter);
private static Parser<Filter<T>> _GetOperationParser(Parser<object> op, Func<string, object, Filter<T>> apply)
{
Parser<string> property = Parse.LetterOrDigit.AtLeastOnce().Text();
return property.Then(prop =>
{
PropertyInfo? propInfo = typeof(T).GetProperty(prop, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
if (propInfo == null)
throw new ValidationException($"The given filter {property} is invalid.");
Parser<object> value;
if (propInfo.PropertyType == typeof(int))
value = Parse.Number.Select(x => int.Parse(x) as object);
else if (propInfo.PropertyType == typeof(float))
{
value =
from a in Parse.Number
from dot in Parse.Char('.')
from b in Parse.Number
select float.Parse($"{a}.{b}") as object;
}
else if (propInfo.PropertyType == typeof(string))
{
value = (
from lq in Parse.Char('"').Or(Parse.Char('\''))
from str in Parse.AnyChar.Where(x => x is not '"' and not '\'').Many().Text()
from rq in Parse.Char('"').Or(Parse.Char('\''))
select str
).Or(Parse.LetterOrDigit.Many().Text());
}
else if (propInfo.PropertyType.IsEnum)
{
value = Parse.LetterOrDigit.Many().Text().Select(x =>
{
if (Enum.TryParse(propInfo.PropertyType, x, true, out object? value))
return value!;
throw new ValidationException($"Invalid enum value. Unexpected {x}");
});
}
// TODO: Support arrays
else
throw new ValidationException("Unfilterable field found");
return
from eq in op
from val in value
select apply(prop, val);
});
}
public static readonly Parser<Filter<T>> Eq = _GetOperationParser(
Parse.IgnoreCase("eq").Or(Parse.String("=")).Token(),
(property, value) => new Eq(property, value)
);
public static readonly Parser<Filter<T>> Ne = _GetOperationParser(
Parse.IgnoreCase("ne").Or(Parse.String("!=")).Token(),
(property, value) => new Ne(property, value)
);
public static readonly Parser<Filter<T>> Gt = _GetOperationParser(
Parse.IgnoreCase("gt").Or(Parse.String(">")).Token(),
(property, value) => new Gt(property, value)
);
public static readonly Parser<Filter<T>> Ge = _GetOperationParser(
Parse.IgnoreCase("ge").Or(Parse.String(">=")).Token(),
(property, value) => new Ge(property, value)
);
public static readonly Parser<Filter<T>> Lt = _GetOperationParser(
Parse.IgnoreCase("lt").Or(Parse.String("<")).Token(),
(property, value) => new Lt(property, value)
);
public static readonly Parser<Filter<T>> Le = _GetOperationParser(
Parse.IgnoreCase("le").Or(Parse.String("<=")).Token(),
(property, value) => new Le(property, value)
);
}
public static Filter<T>? From(string? filter)
{
if (filter == null)
return null;
IResult<Filter<T>> ret = FilterParsers.CompleteFilter.End().TryParse(filter);
if (ret.WasSuccessful)
return ret.Value;
throw new ValidationException($"Could not parse filter argument: {ret.Message}. Not parsed: {filter[ret.Remainder.Position..]}");
}
}

View File

@ -21,7 +21,7 @@ namespace Kyoo.Abstractions.Controllers
/// <summary>
/// Information about the pagination. How many items should be displayed and where to start.
/// </summary>
public struct Pagination
public class Pagination
{
/// <summary>
/// The count of items to return.

View File

@ -80,6 +80,7 @@ namespace Kyoo.Core
options.Filters.Add<ExceptionFilter>();
options.ModelBinderProviders.Insert(0, new SortBinder.Provider());
options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider());
options.ModelBinderProviders.Insert(0, new FilterBinder.Provider());
})
.AddNewtonsoftJson(x =>
{

View File

@ -48,9 +48,6 @@ namespace Kyoo.Core
{
switch (context.Exception)
{
case ArgumentException ex:
context.Result = new BadRequestObjectResult(new RequestError(ex.Message));
break;
case ValidationException ex:
context.Result = new BadRequestObjectResult(new RequestError(ex.Message));
break;

View File

@ -1,204 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using JetBrains.Annotations;
using Kyoo.Abstractions.Models;
namespace Kyoo.Core.Api
{
/// <summary>
/// A static class containing methods to parse the <c>where</c> query string.
/// </summary>
public static class ApiHelper
{
/// <summary>
/// Make an expression (like
/// <see cref="Expression.LessThan(System.Linq.Expressions.Expression,System.Linq.Expressions.Expression)"/>
/// compatible with strings). If the expressions are not strings, the given <paramref name="operand"/> is
/// constructed but if the expressions are strings, this method make the <paramref name="operand"/> compatible with
/// strings.
/// </summary>
/// <param name="operand">
/// The expression to make compatible. It should be something like
/// <see cref="Expression.LessThan(System.Linq.Expressions.Expression,System.Linq.Expressions.Expression)"/> or
/// <see cref="Expression.Equal(System.Linq.Expressions.Expression,System.Linq.Expressions.Expression)"/>.
/// </param>
/// <param name="left">The first parameter to compare.</param>
/// <param name="right">The second parameter to compare.</param>
/// <returns>A comparison expression compatible with strings</returns>
public static BinaryExpression StringCompatibleExpression(
Func<Expression, Expression, BinaryExpression> operand,
Expression left,
Expression right)
{
if (left.Type != typeof(string))
return operand(left, right);
MethodCallExpression call = Expression.Call(typeof(string), "Compare", null, left, right);
return operand(call, Expression.Constant(0));
}
/// <summary>
/// Parse a <c>where</c> query for the given <typeparamref name="T"/>. Items can be filtered by any property
/// of the given type.
/// </summary>
/// <param name="where">The list of filters.</param>
/// <param name="defaultWhere">
/// A custom expression to initially filter a collection. It will be combined with the parsed expression.
/// </param>
/// <typeparam name="T">The type to create filters for.</typeparam>
/// <exception cref="ArgumentException">A filter is invalid.</exception>
/// <returns>An expression representing the filters that can be used anywhere or compiled</returns>
public static Expression<Func<T, bool>>? ParseWhere<T>(Dictionary<string, string>? where,
Expression<Func<T, bool>>? defaultWhere = null)
{
if (where == null || where.Count == 0)
return defaultWhere;
ParameterExpression param = defaultWhere?.Parameters.First() ?? Expression.Parameter(typeof(T));
Expression? expression = defaultWhere?.Body;
foreach ((string key, string desired) in where)
{
if (key == null || desired == null)
throw new ArgumentException("Invalid key/value pair. Can't be null.");
string value = desired;
string operand = "eq";
if (desired.Contains(':'))
{
operand = desired.Substring(0, desired.IndexOf(':'));
value = desired.Substring(desired.IndexOf(':') + 1);
}
PropertyInfo? property = typeof(T).GetProperty(key, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase);
if (property == null)
throw new ArgumentException($"No filterable parameter with the name {key}.");
MemberExpression propertyExpr = Expression.Property(param, property);
ConstantExpression? valueExpr = null;
bool isList = typeof(IEnumerable).IsAssignableFrom(propertyExpr.Type) && propertyExpr.Type != typeof(string);
if (operand != "ctn" && !typeof(IResource).IsAssignableFrom(propertyExpr.Type) && !isList)
{
Type propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
object? val;
try
{
val = string.IsNullOrEmpty(value) || value.Equals("null", StringComparison.OrdinalIgnoreCase)
? null
: Convert.ChangeType(value, propertyType);
}
catch (InvalidCastException)
{
throw new ArgumentException("Comparing two different value's type.");
}
valueExpr = Expression.Constant(val, property.PropertyType);
}
Expression condition = operand switch
{
"eq" when isList => _ContainsResourceExpression(propertyExpr, value),
"ctn" => _ContainsResourceExpression(propertyExpr, value),
"eq" when valueExpr == null => _ResourceEqual(propertyExpr, value),
"not" when valueExpr == null => _ResourceEqual(propertyExpr, value, true),
"eq" => Expression.Equal(propertyExpr, valueExpr),
"not" => Expression.NotEqual(propertyExpr, valueExpr),
"lt" => StringCompatibleExpression(Expression.LessThan, propertyExpr, valueExpr!),
"lte" => StringCompatibleExpression(Expression.LessThanOrEqual, propertyExpr, valueExpr!),
"gt" => StringCompatibleExpression(Expression.GreaterThan, propertyExpr, valueExpr!),
"gte" => StringCompatibleExpression(Expression.GreaterThanOrEqual, propertyExpr, valueExpr!),
_ => throw new ArgumentException($"Invalid operand: {operand}")
};
expression = expression != null
? Expression.AndAlso(expression, condition)
: condition;
}
return Expression.Lambda<Func<T, bool>>(expression!, param);
}
private static Expression _ResourceEqual(Expression parameter, string value, bool notEqual = false)
{
MemberExpression field;
ConstantExpression valueConst;
if (int.TryParse(value, out int id))
{
field = Expression.Property(parameter, "ID");
valueConst = Expression.Constant(id);
}
else
{
field = Expression.Property(parameter, "Slug");
valueConst = Expression.Constant(value);
}
return notEqual
? Expression.NotEqual(field, valueConst)
: Expression.Equal(field, valueConst);
}
private static Expression _ContainsResourceExpression(MemberExpression xProperty, string value)
{
if (xProperty.Type == typeof(string))
{
// x.PROPRETY.Contains(value);
return Expression.Call(xProperty, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, Expression.Constant(value));
}
// x.PROPERTY is either a List<> or a []
Type inner = xProperty.Type.GetElementType() ?? xProperty.Type.GenericTypeArguments.First();
if (inner.IsAssignableTo(typeof(string)))
{
return Expression.Call(typeof(Enumerable), "Contains", new[] { inner }, xProperty, Expression.Constant(value));
}
if (inner.IsEnum && Enum.TryParse(inner, value, true, out object? enumValue))
{
return Expression.Call(typeof(Enumerable), "Contains", new[] { inner }, xProperty, Expression.Constant(enumValue));
}
if (!inner.IsAssignableTo(typeof(IResource)))
throw new ArgumentException("Contain (ctn) not appliable for this property.");
// x => x.PROPERTY.Any(y => y.Slug == value)
Expression? ret = null;
ParameterExpression y = Expression.Parameter(inner, "y");
foreach (string val in value.Split(','))
{
LambdaExpression lambda = Expression.Lambda(_ResourceEqual(y, val), y);
Expression iteration = Expression.Call(typeof(Enumerable), "Any", new[] { inner }, xProperty, lambda);
if (ret == null)
ret = iteration;
else
ret = Expression.AndAlso(ret, iteration);
}
return ret!;
}
}
}

View File

@ -0,0 +1,57 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.Reflection;
using System.Threading.Tasks;
using Kyoo.Abstractions.Models.Utils;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
namespace Kyoo.Core.Api;
public class FilterBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
ValueProviderResult fields = bindingContext.ValueProvider.GetValue(bindingContext.FieldName);
try
{
object? filter = bindingContext.ModelType.GetMethod(nameof(Filter<object>.From))!
.Invoke(null, new object?[] { fields.FirstValue });
bindingContext.Result = ModelBindingResult.Success(filter);
return Task.CompletedTask;
}
catch (TargetInvocationException ex)
{
throw ex.InnerException!;
}
}
public class Provider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context.Metadata.ModelType.Name == "Filter`1")
{
return new BinderTypeModelBinder(typeof(FilterBinder));
}
return null!;
}
}
}

View File

@ -1,56 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Threading;
using System.Threading.Tasks;
using Kyoo.Utils;
using Xunit;
namespace Kyoo.Tests.Utility
{
public class TaskTests
{
[Fact]
public async Task ThenTest()
{
await Assert.ThrowsAsync<ArgumentException>(() => Task.FromResult(1)
.Then(_ => throw new ArgumentException()));
Assert.Equal(1, await Task.FromResult(1)
.Then(_ => { }));
static async Task<int> Faulted()
{
await Task.Delay(1);
throw new ArgumentException();
}
await Assert.ThrowsAsync<ArgumentException>(() => Faulted().Then(_ => KAssert.Fail()));
static async Task<int> Infinite()
{
await Task.Delay(100000);
return 1;
}
CancellationTokenSource token = new();
token.Cancel();
await Assert.ThrowsAsync<TaskCanceledException>(() => Task.Run(Infinite, token.Token)
.Then(_ => { }));
}
}
}