diff --git a/back/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj b/back/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj index 283f5509..2861264a 100644 --- a/back/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj +++ b/back/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj @@ -8,6 +8,7 @@ + diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs b/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs index 03516ab0..7374cfab 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs @@ -28,6 +28,25 @@ using Sprache; namespace Kyoo.Abstractions.Models.Utils; +public static class ParseHelper +{ + public static Parser ErrorMessage(this Parser @this, string message) => + input => + { + IResult result = @this(input); + + return result.WasSuccessful + ? result + : Result.Failure(result.Remainder, message, result.Expectations); + }; + + public static Parser Error(string message) => + input => + { + return Result.Failure(input, message, Array.Empty()); + }; +} + public abstract record Filter { public static Filter? And(params Filter?[] filters) @@ -45,29 +64,29 @@ public abstract record Filter public abstract record Filter : Filter { - public record And(Filter first, Filter second) : Filter; + public record And(Filter First, Filter Second) : Filter; - public record Or(Filter first, Filter second) : Filter; + public record Or(Filter First, Filter Second) : Filter; - public record Not(Filter filter) : Filter; + public record Not(Filter Filter) : Filter; - public record Eq(string property, object value) : Filter; + public record Eq(string Property, object Value) : Filter; - public record Ne(string property, object value) : Filter; + public record Ne(string Property, object Value) : Filter; - public record Gt(string property, object value) : Filter; + public record Gt(string Property, object Value) : Filter; - public record Ge(string property, object value) : Filter; + public record Ge(string Property, object Value) : Filter; - public record Lt(string property, object value) : Filter; + public record Lt(string Property, object Value) : Filter; - public record Le(string property, object value) : Filter; + public record Le(string Property, object Value) : Filter; - public record Has(string property, object value) : Filter; + public record Has(string Property, object Value) : Filter; - public record In(string property, object[] value) : Filter; + public record In(string Property, object[] Value) : Filter; - public record Lambda(Expression> lambda) : Filter; + public record Lambda(Expression> Inner) : Filter; public static class FilterParsers { @@ -79,7 +98,8 @@ public abstract record Filter : Filter .Or(Parse.Ref(() => Gt)) .Or(Parse.Ref(() => Ge)) .Or(Parse.Ref(() => Lt)) - .Or(Parse.Ref(() => Le)); + .Or(Parse.Ref(() => Le)) + .Or(Parse.Ref(() => Has)); public static readonly Parser> CompleteFilter = Parse.Ref(() => Or) @@ -100,18 +120,70 @@ public abstract record Filter : Filter .Or(Parse.String("||")) .Token(); - public static readonly Parser> And = Parse.ChainOperator(AndOperator, Filter, (_, a, b) => new Filter.And(a, b)); + public static readonly Parser> And = Parse.ChainOperator(AndOperator, Filter, (_, a, b) => new And(a, b)); - public static readonly Parser> Or = Parse.ChainOperator(OrOperator, And.Or(Filter), (_, a, b) => new Filter.Or(a, b)); + public static readonly Parser> Or = Parse.ChainOperator(OrOperator, And.Or(Filter), (_, a, b) => new Or(a, b)); public static readonly Parser> Not = from not in Parse.IgnoreCase("not") .Or(Parse.String("!")) .Token() from filter in CompleteFilter - select new Filter.Not(filter); + select new Not(filter); - private static Parser> _GetOperationParser(Parser op, Func> apply) + private static Parser _GetValueParser(Type type) + { + if (type == typeof(int)) + return Parse.Number.Select(x => int.Parse(x) as object); + if (type == typeof(float)) + { + return + from a in Parse.Number + from dot in Parse.Char('.') + from b in Parse.Number + select float.Parse($"{a}.{b}") as object; + } + + if (type == typeof(string)) + { + return ( + 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()); + } + + if (type.IsEnum) + { + return Parse.LetterOrDigit.Many().Text().Then(x => + { + if (Enum.TryParse(type, x, true, out object? value)) + return Parse.Return(value); + return ParseHelper.Error($"Invalid enum value. Unexpected {x}"); + }); + } + + if (type == typeof(DateTime)) + { + return + from year in Parse.Digit.Repeat(4).Text().Select(int.Parse) + from yd in Parse.Char('-') + from mouth in Parse.Digit.Repeat(2).Text().Select(int.Parse) + from md in Parse.Char('-') + from day in Parse.Digit.Repeat(2).Text().Select(int.Parse) + select new DateTime(year, mouth, day) as object; + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + return ParseHelper.Error("Can't filter a list with a default comparator, use the 'has' filter."); + return ParseHelper.Error("Unfilterable field found"); + } + + private static Parser> _GetOperationParser( + Parser op, + Func> apply, + Func>? customTypeParser = null) { Parser property = Parse.LetterOrDigit.AtLeastOnce().Text(); @@ -122,52 +194,11 @@ public abstract record Filter : Filter .Select(x => x.GetProperty(prop, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)) .FirstOrDefault(); if (propInfo == null) - throw new ValidationException($"The given filter '{prop}' is invalid."); + return ParseHelper.Error>($"The given filter '{prop}' is invalid."); - Parser 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}"); - }); - } - else if (propInfo.PropertyType == typeof(DateTime)) - { - value = - from year in Parse.Digit.Repeat(4).Text().Select(int.Parse) - from yd in Parse.Char('-') - from mouth in Parse.Digit.Repeat(2).Text().Select(int.Parse) - from md in Parse.Char('-') - from day in Parse.Digit.Repeat(2).Text().Select(int.Parse) - select new DateTime(year, mouth, day) as object; - } - else if (typeof(IEnumerable).IsAssignableFrom(propInfo.PropertyType)) - throw new ValidationException("Can't filter a list with a default comparator, use the 'in' filter."); - else - throw new ValidationException("Unfilterable field found"); + Parser value = customTypeParser != null + ? customTypeParser(propInfo.PropertyType) + : _GetValueParser(propInfo.PropertyType); return from eq in op @@ -205,6 +236,22 @@ public abstract record Filter : Filter Parse.IgnoreCase("le").Or(Parse.String("<=")).Token(), (property, value) => new Le(property, value) ); + + public static readonly Parser> Has = _GetOperationParser( + Parse.IgnoreCase("has").Token(), + (property, value) => new Has(property, value), + (Type type) => + { + if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string)) + return _GetValueParser(type.GetElementType() ?? type.GenericTypeArguments.First()); + return ParseHelper.Error("Can't use 'has' on a non-list."); + } + ); + + // public static readonly Parser> In = _GetOperationParser( + // Parse.IgnoreCase("in").Token(), + // (property, value) => new In(property, value) + // ); } public static Filter? From(string? filter) @@ -212,9 +259,16 @@ public abstract record Filter : Filter if (filter == null) return null; - IResult> 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..]}"); + try + { + IResult> 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..]}"); + } + catch (ParseException ex) + { + throw new ValidationException($"Could not parse filter argument: {ex.Message}."); + } } } diff --git a/back/src/Kyoo.Abstractions/Utility/Wrapper.cs b/back/src/Kyoo.Abstractions/Utility/Wrapper.cs new file mode 100644 index 00000000..1db9b0d8 --- /dev/null +++ b/back/src/Kyoo.Abstractions/Utility/Wrapper.cs @@ -0,0 +1,47 @@ +// 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 . + +using System; +using System.Data; +using Dapper; + +namespace Kyoo.Utils; + +// Only used due to https://github.com/DapperLib/Dapper/issues/332 +public class Wrapper +{ + public object Value { get; set; } + + public Wrapper(object value) + { + Value = value; + } + + public class Handler : SqlMapper.TypeHandler + { + public override Wrapper? Parse(object value) + { + throw new NotImplementedException("Wrapper should only be used to write"); + } + + public override void SetValue(IDbDataParameter parameter, Wrapper? value) + { + parameter.Value = value?.Value; + } + } +} diff --git a/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs index 25f01a6b..dd4b8cdd 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs @@ -202,6 +202,13 @@ namespace Kyoo.Core.Controllers return $"({ret})"; } + object P(object value) + { + if (value is Enum) + return new Wrapper(value); + return value; + } + FormattableString Process(Filter fil) { return fil switch @@ -209,12 +216,13 @@ namespace Kyoo.Core.Controllers Filter.And(var first, var second) => $"({Process(first)} and {Process(second)})", Filter.Or(var first, var second) => $"({Process(first)} or {Process(second)})", Filter.Not(var inner) => $"(not {Process(inner)})", - Filter.Eq(var property, var value) => Format(property, $"= {value}"), - Filter.Ne(var property, var value) => Format(property, $"!= {value}"), - Filter.Gt(var property, var value) => Format(property, $"> {value}"), - Filter.Ge(var property, var value) => Format(property, $">= {value}"), - Filter.Lt(var property, var value) => Format(property, $"< {value}"), - Filter.Le(var property, var value) => Format(property, $"> {value}"), + Filter.Eq(var property, var value) => Format(property, $"= {P(value)}"), + Filter.Ne(var property, var value) => Format(property, $"!= {P(value)}"), + Filter.Gt(var property, var value) => Format(property, $"> {P(value)}"), + Filter.Ge(var property, var value) => Format(property, $">= {P(value)}"), + Filter.Lt(var property, var value) => Format(property, $"< {P(value)}"), + Filter.Le(var property, var value) => Format(property, $"> {P(value)}"), + Filter.Has(var property, var value) => $"{P(value)} = any({_Property(property, config):raw})", Filter.Lambda(var lambda) => throw new NotSupportedException(), }; } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs index 07b886e5..135d6054 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs @@ -28,7 +28,6 @@ using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Utils; -using Kyoo.Core.Api; using Kyoo.Postgresql; using Kyoo.Utils; using Microsoft.EntityFrameworkCore; @@ -133,6 +132,7 @@ namespace Kyoo.Core.Controllers Filter.Ge(var property, var value) => Expression.GreaterThanOrEqual(Expression.Property(x, property), Expression.Constant(value)), Filter.Lt(var property, var value) => Expression.LessThan(Expression.Property(x, property), Expression.Constant(value)), Filter.Le(var property, var value) => Expression.LessThanOrEqual(Expression.Property(x, property), Expression.Constant(value)), + Filter.Has(var property, var value) => Expression.Call(typeof(Enumerable), "Contains", new[] { value.GetType() }, Expression.Property(x, property), Expression.Constant(value)), Filter.Lambda(var lambda) => ExpressionArgumentReplacer.ReplaceParams(lambda.Body, lambda.Parameters, x), }; } diff --git a/back/src/Kyoo.Core/Kyoo.Core.csproj b/back/src/Kyoo.Core/Kyoo.Core.csproj index c423c5d2..ff3ed4e0 100644 --- a/back/src/Kyoo.Core/Kyoo.Core.csproj +++ b/back/src/Kyoo.Core/Kyoo.Core.csproj @@ -8,7 +8,7 @@ - + diff --git a/back/src/Kyoo.Postgresql/Kyoo.Postgresql.csproj b/back/src/Kyoo.Postgresql/Kyoo.Postgresql.csproj index be3a7ff6..2a1ee40a 100644 --- a/back/src/Kyoo.Postgresql/Kyoo.Postgresql.csproj +++ b/back/src/Kyoo.Postgresql/Kyoo.Postgresql.csproj @@ -6,7 +6,7 @@ - + diff --git a/back/src/Kyoo.Postgresql/PostgresModule.cs b/back/src/Kyoo.Postgresql/PostgresModule.cs index 5fe9bdf8..bc942734 100644 --- a/back/src/Kyoo.Postgresql/PostgresModule.cs +++ b/back/src/Kyoo.Postgresql/PostgresModule.cs @@ -25,6 +25,7 @@ using InterpolatedSql.SqlBuilders; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; using Kyoo.Postgresql.Utils; +using Kyoo.Utils; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -88,6 +89,7 @@ namespace Kyoo.Postgresql SqlMapper.AddTypeHandler(typeof(Dictionary), new JsonTypeHandler>()); SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); + SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true; }