Filtering Overhaul (#2207)

* Implemented the first version of dynamic filtering which is all Extension based.

* Implemented basic generic property filter for expanded metadata filtering.

* Fixed up the code to allow for nested properties and fixed up the Contains to work only for IList's

* Started refactoring for the new approach

* More progress, need to rethink a few filters like read progress to be % based and people needs to be more explicit.

* Refactored most of the existing filtering operations into dedicate extensions for the appropriate comparisons. People still need to be reworked to be more dynamic.

* Fixed a bug with continue point where it fails on chapters or volumes tagged with a range

* Wired up a basic api path to start building groups. No and/or support yet.

* Started on the UI

* Made a bit of progress on the UI as I'm putting the pieces together about how to design it.

* Refactored names to make it more consistent. New thinking is we will have one row that will take a filter statement and manipulate it. It will emit said statement and a builder will turn into the higher level statement.

* Started working on updating code to use new inject() method.

* Fixed the code to switch the comparisons.

* Added dynamic input structure in and moved add/remove to the builder.

* Fixed an enum bug

* Hooked in basic dropdown support that is dynamic to the field. Only language is missing as that needs a DTO change (but don't want to break API)

* Fixed a bug where dropdown options wouldn't re-populate when switching fields that are both dropdowns

* Started adding metadata builder

* Fixed when typing on filter row the focus resetting

* Refactored to add an additional component which handles the compounding of filter rows.

* Started hooking up v2 dto in the UI to send to the backend.

* Started working on building group UI for and/or support.

* Lots of backend code fixes to ensure OR and AND statements combine correctly.

* More trying to figure out how to write the UI code

* Started debugging to remember what I was last doing.

* Lots of progress towards building out the UI recursively

* I got the dto to build and propagate up the chain

* Started hooking up to the actual api to fetch the data.

* Basic wire up to the backend is working.

* HasName is now complete

* Refactored SortOptions code into an extension and streamlined LimitTo to the correct place.

* Fixed a bug where Library Filters from the Group weren't actually being taken into account.

* Refactored a lot of code so builder will now export the full dto.

* Cleaned up the data flow from metadata filter to library detail

* Got the dropdown to load preset values on first load, but now it triggers twice.

* Changed so when you add a new filter, it does it at top and fixed remove

* Fixed the remove button being on the wrong row

* Cleaned up the arrays to make it easier to manage

* Cleaned up some of the backend to ensure it doesn't throw an incorrect exception

* I'm starting to tread water, taking a break

* Fixed a merge issue

* Cleaned up Docker checks.

* Default IpAddresses to empty string.

* Refactored IsDocker to be completely static

* Figured out the issue with the dropdown not working.

* Almost got it, but the event isn't being called.

* I think i might try something else. This doesn't seem to be working.

* On the new implementation, implemented remove group.

* Use enums to reduce copy/paste

* the new system is working pretty well, ill go with it and move on. Can alwasy refactor.

* Code is totally broken, but working the cache resume code with some hiccups.

* I need to take a break

* Stashing my broken code. I have an idea on how to serialize to the URL, but I need to rearchitect a lot.

* Reverted last commit

* remove domain

* Fixed up some hardcoded caching. I'm giving up on this implementation and going to a simpler version

* Refactored the backend to just allow flat filtering.

* Started refactoring the components to make it flat filtering only.

* Finished refactoring so that the base preset case will render.

* Implemented basic query functionality on desktop. Clear needs some work and url code.

* Some cleanup

* Working on filtering url encode/decode

* Interacting with filters now saves to url and can be reloaded from the url. Named filters is not hooked up.

* Fixed a double load on the library detail page.

* Moved the library filtering code out of the FilterBuilder as it needs to be handled differently.

* Fixed up how we handle library statements in the filter.

* Fixed up how links that perform a filter work.

* Refactored a bunch of linking to a search page.

* LimitTo works, my css however does not.

* Switched some code to use localized strings.

* Cleaned up some css

* Hooked up Languages and put some additional code in so that Languages will return invalid Language codes back.

* Removed a duplicate language signature.

* Hooked up ability to preload collection tag.

* Want To Read is converted

* Converted lots of code to new filtering system. Need to do Bookmarks.

* Fixed a potential bug with default filter creation.

* Hooked up the ability to disable certain filter fields from appearing.

* Added mobile drawer code and a hook for Robbie to take a look for some css.

* Converted the APIs for dashboard along with other safety fixes to ensure bad data doesn't break any of the filtering apis

* Added the backend code to handle summary query

* Converted Want to Read api properly now.

* Fixed the HasReadingProgress query

* Hooked back the Reading Progress for legacy APIs

* Fixed some bad localization strings

* Wrote the filtering code for all-bookmarks.

* OPDS is now using the new filter

* Fixed OPDS reading lists and covers not sending their images.

* Fixed up the OPDS feed and fixed a bug where libraries also weren't sending their images over OPDS

* All but dropdown options have been validated and tested.

* Fixed up some default cases for setting up the filter.

* Sorted filter fields and re-keyed to be better suited based on user's needs.

Fixed a bug where OPDS Series (from library view) wasn't showing the summary.

Moved the (Format) from the title to the description to make the UX much better for OPDS.

MOved

* don't send empty summaries in the new summary formatting

* Fixed up some default cases for setting up the filter.

* Fixed the reset button

* Fixed infinite scroller not having correct scope key

* Added localization to the new components and removed old debug code

* Styling fixes

* Fixed deep linking across the app. Made it so you can click Characters from Reading list and open a filtered search.

* A bit of styling for mobile

* Don't show language if it's not properly set

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-08-11 16:30:36 -05:00 committed by GitHub
parent bc2a12a9cd
commit 9cc5953d07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 3299 additions and 1827 deletions

View file

@ -0,0 +1,515 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using API.DTOs.Filtering.v2;
using API.Entities;
using API.Entities.Enums;
using Kavita.Common;
using Microsoft.EntityFrameworkCore;
namespace API.Extensions.QueryExtensions.Filtering;
#nullable enable
public static class SeriesFilter
{
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<string> languages)
{
if (languages.Count == 0 || !condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.Language.Equals(languages.First()));
case FilterComparison.Contains:
return queryable.Where(s => languages.Contains(s.Metadata.Language));
case FilterComparison.NotContains:
return queryable.Where(s => !languages.Contains(s.Metadata.Language));
case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.Language.Equals(languages.First()));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Language, $"{languages.First()}%"));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasReleaseYear(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int? releaseYear)
{
if (!condition || releaseYear == null) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.ReleaseYear == releaseYear);
case FilterComparison.GreaterThan:
case FilterComparison.IsAfter:
return queryable.Where(s => s.Metadata.ReleaseYear > releaseYear);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Metadata.ReleaseYear >= releaseYear);
case FilterComparison.LessThan:
case FilterComparison.IsBefore:
return queryable.Where(s => s.Metadata.ReleaseYear < releaseYear);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Metadata.ReleaseYear <= releaseYear);
case FilterComparison.IsInLast:
return queryable.Where(s => s.Metadata.ReleaseYear >= DateTime.Now.Year - (int) releaseYear);
case FilterComparison.IsNotInLast:
return queryable.Where(s => s.Metadata.ReleaseYear < DateTime.Now.Year - (int) releaseYear);
case FilterComparison.Matches:
case FilterComparison.Contains:
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
throw new KavitaException($"{comparison} not applicable for Series.ReleaseYear");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasRating(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int rating, int userId)
{
if (rating < 0 || !condition || userId <= 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Ratings.Any(r => r.Rating == rating && r.AppUserId == userId));
case FilterComparison.GreaterThan:
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Ratings.Any(r => r.Rating >= rating && r.AppUserId == userId));
case FilterComparison.LessThan:
return queryable.Where(s => s.Ratings.Any(r => r.Rating < rating && r.AppUserId == userId));
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Ratings.Any(r => r.Rating <= rating && r.AppUserId == userId));
case FilterComparison.Contains:
case FilterComparison.Matches:
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Rating");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasAgeRating(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<AgeRating> ratings)
{
if (!condition || ratings.Count == 0) return queryable;
var firstRating = ratings.First();
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.AgeRating == firstRating);
case FilterComparison.GreaterThan:
return queryable.Where(s => s.Metadata.AgeRating > firstRating);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Metadata.AgeRating >= firstRating);
case FilterComparison.LessThan:
return queryable.Where(s => s.Metadata.AgeRating < firstRating);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Metadata.AgeRating <= firstRating);
case FilterComparison.Contains:
return queryable.Where(s => ratings.Contains(s.Metadata.AgeRating));
case FilterComparison.NotContains:
return queryable.Where(s => !ratings.Contains(s.Metadata.AgeRating));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.AgeRating != firstRating);
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.AgeRating");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasAverageReadTime(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int avgReadTime)
{
if (!condition || avgReadTime < 0) return queryable;
switch (comparison)
{
case FilterComparison.NotEqual:
return queryable.Where(s => s.AvgHoursToRead != avgReadTime);
case FilterComparison.Equal:
return queryable.Where(s => s.AvgHoursToRead == avgReadTime);
case FilterComparison.GreaterThan:
return queryable.Where(s => s.AvgHoursToRead > avgReadTime);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.AvgHoursToRead >= avgReadTime);
case FilterComparison.LessThan:
return queryable.Where(s => s.AvgHoursToRead < avgReadTime);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.AvgHoursToRead <= avgReadTime);
case FilterComparison.Contains:
case FilterComparison.Matches:
case FilterComparison.NotContains:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasPublicationStatus(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<PublicationStatus> pubStatues)
{
if (!condition || pubStatues.Count == 0) return queryable;
var firstStatus = pubStatues.First();
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.PublicationStatus == firstStatus);
case FilterComparison.Contains:
return queryable.Where(s => pubStatues.Contains(s.Metadata.PublicationStatus));
case FilterComparison.NotContains:
return queryable.Where(s => !pubStatues.Contains(s.Metadata.PublicationStatus));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.PublicationStatus != firstStatus);
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.Matches:
throw new KavitaException($"{comparison} not applicable for Series.PublicationStatus");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
/// <summary>
///
/// </summary>
/// <remarks>This is more taxing on memory as the percentage calculation must be done in Memory</remarks>
/// <exception cref="KavitaException"></exception>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public static IQueryable<Series> HasReadingProgress(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int readProgress, int userId)
{
if (!condition) return queryable;
var subQuery = queryable
.Include(s => s.Progress)
.Where(s => s.Progress != null)
.Select(s => new
{
Series = s,
Percentage = Math.Truncate(((double) s.Progress
.Where(p => p != null && p.AppUserId == userId)
.Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100))
})
.AsEnumerable();
switch (comparison)
{
case FilterComparison.Equal:
subQuery = subQuery.Where(s => s.Percentage == readProgress);
break;
case FilterComparison.GreaterThan:
subQuery = subQuery.Where(s => s.Percentage > readProgress);
break;
case FilterComparison.GreaterThanEqual:
subQuery = subQuery.Where(s => s.Percentage >= readProgress);
break;
case FilterComparison.LessThan:
subQuery = subQuery.Where(s => s.Percentage < readProgress);
break;
case FilterComparison.LessThanEqual:
subQuery = subQuery.Where(s => s.Percentage <= readProgress);
break;
case FilterComparison.NotEqual:
subQuery = subQuery.Where(s => s.Percentage != readProgress);
break;
case FilterComparison.Matches:
case FilterComparison.Contains:
case FilterComparison.NotContains:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.ReadProgress");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
var ids = subQuery.Select(s => s.Series.Id).ToList();
return queryable.Where(s => ids.Contains(s.Id));
}
public static IQueryable<Series> HasTags(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> tags)
{
if (!condition || tags.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.Tags.Any(t => tags.Contains(t.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.Tags.Any(t => !tags.Contains(t.Id)));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Tags");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasPeople(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> people)
{
if (!condition || people.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.People.Any(t => !people.Contains(t.Id)));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.Matches:
throw new KavitaException($"{comparison} not applicable for Series.People");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasGenre(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> genres)
{
if (!condition || genres.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.Genres.Any(p => genres.Contains(p.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.Genres.All(p => !genres.Contains(p.Id)));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Genres");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasFormat(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<MangaFormat> formats)
{
if (!condition || formats.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => formats.Contains(s.Format));
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
return queryable.Where(s => !formats.Contains(s.Format));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Format");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> collectionTags)
{
if (!condition || collectionTags.Count == 0) return queryable;
//var first = collectionTags.First();
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.CollectionTags");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasName(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (string.IsNullOrEmpty(queryString) || !condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Name.Equals(queryString)
|| s.OriginalName.Equals(queryString)
|| s.LocalizedName.Equals(queryString)
|| s.SortName.Equals(queryString));
case FilterComparison.BeginsWith:
return queryable.Where(s => EF.Functions.Like(s.Name, $"{queryString}%")
||EF.Functions.Like(s.OriginalName, $"{queryString}%")
|| EF.Functions.Like(s.LocalizedName, $"{queryString}%")
|| EF.Functions.Like(s.SortName, $"{queryString}%"));
case FilterComparison.EndsWith:
return queryable.Where(s => EF.Functions.Like(s.Name, $"%{queryString}")
||EF.Functions.Like(s.OriginalName, $"%{queryString}")
|| EF.Functions.Like(s.LocalizedName, $"%{queryString}")
|| EF.Functions.Like(s.SortName, $"%{queryString}"));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Name, $"%{queryString}%")
||EF.Functions.Like(s.OriginalName, $"%{queryString}%")
|| EF.Functions.Like(s.LocalizedName, $"%{queryString}%")
|| EF.Functions.Like(s.SortName, $"%{queryString}%"));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Name != queryString
|| s.OriginalName != queryString
|| s.LocalizedName != queryString
|| s.SortName != queryString);
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Name");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
public static IQueryable<Series> HasSummary(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (string.IsNullOrEmpty(queryString) || !condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.Summary.Equals(queryString));
case FilterComparison.BeginsWith:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"{queryString}%"));
case FilterComparison.EndsWith:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}"));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}%"));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.Summary != queryString);
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Metadata.Summary");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
}

View file

@ -0,0 +1,53 @@
using System.Linq;
using API.DTOs.Filtering;
using API.Entities;
namespace API.Extensions.QueryExtensions.Filtering;
public static class SeriesSort
{
/// <summary>
/// Applies the correct sort based on <see cref="SortOptions"/>
/// </summary>
/// <param name="query"></param>
/// <param name="sortOptions"></param>
/// <returns></returns>
public static IQueryable<Series> Sort(this IQueryable<Series> query, SortOptions? sortOptions)
{
// If no sort options, default to using SortName
sortOptions ??= new SortOptions()
{
IsAscending = true,
SortField = SortField.SortName
};
if (sortOptions.IsAscending)
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.SortName.ToLower()),
SortField.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
_ => query
};
}
else
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.SortName.ToLower()),
SortField.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.Metadata.ReleaseYear),
_ => query
};
}
return query;
}
}

View file

@ -110,6 +110,55 @@ public static class QueryableExtensions
return condition ? queryable.Where(predicate) : queryable;
}
public static IQueryable<T> WhereLike<T>(this IQueryable<T> queryable, bool condition, Expression<Func<T, string>> propertySelector, string searchQuery)
where T : class
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
var lambda = Expression.Lambda<Func<T, bool>>(likeExpression, propertySelector.Parameters[0]);
return queryable.Where(lambda);
}
/// <summary>
/// Performs a WhereLike that ORs multiple fields
/// </summary>
/// <param name="queryable"></param>
/// <param name="propertySelectors"></param>
/// <param name="searchQuery"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
public static IQueryable<T> WhereLike<T>(this IQueryable<T> queryable, bool condition, List<Expression<Func<T, string>>> propertySelectors, string searchQuery)
where T : class
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
Expression orExpression = null;
foreach (var propertySelector in propertySelectors)
{
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
var lambda = Expression.Lambda<Func<T, bool>>(likeExpression, propertySelector.Parameters[0]);
orExpression = orExpression == null ? lambda.Body : Expression.OrElse(orExpression, lambda.Body);
}
if (orExpression == null)
{
throw new ArgumentNullException(nameof(orExpression));
}
var combinedLambda = Expression.Lambda<Func<T, bool>>(orExpression, propertySelectors[0].Parameters[0]);
return queryable.Where(combinedLambda);
}
public static IQueryable<ScrobbleEvent> SortBy(this IQueryable<ScrobbleEvent> query, ScrobbleEventSortField sort, bool isDesc = false)
{
if (isDesc)