373 lines
19 KiB
C#
373 lines
19 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Text.RegularExpressions;
|
|
using EntityFrameworkCore.Projectables;
|
|
using Iceshrimp.Backend.Core.Configuration;
|
|
using Iceshrimp.Backend.Core.Database;
|
|
using Iceshrimp.Backend.Core.Database.Tables;
|
|
using Iceshrimp.Backend.Core.Helpers;
|
|
using Iceshrimp.EntityFrameworkCore.Extensions;
|
|
using Iceshrimp.Parsing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Iceshrimp.Backend.Core.Extensions;
|
|
|
|
public static class QueryableFtsExtensions
|
|
{
|
|
public static IQueryable<Note> FilterByFtsQuery(
|
|
this IQueryable<Note> query, string input, User user, Config.InstanceSection config, DatabaseContext db
|
|
)
|
|
{
|
|
var parsed = SearchQueryParser.Parse(input);
|
|
var caseSensitivity = parsed.OfType<CaseFilter>().LastOrDefault()?.Value ?? CaseFilterType.Insensitive;
|
|
var matchType = parsed.OfType<MatchFilter>().LastOrDefault()?.Value ?? MatchFilterType.Substring;
|
|
|
|
query = query.ApplyFromFilters(parsed.OfType<FromFilter>().ToList(), config, db);
|
|
|
|
return parsed.Aggregate(query, (current, filter) => filter switch
|
|
{
|
|
CaseFilter => current,
|
|
MatchFilter => current,
|
|
FromFilter => current,
|
|
AfterFilter afterFilter => current.ApplyAfterFilter(afterFilter),
|
|
AttachmentFilter attachmentFilter => current.ApplyAttachmentFilter(attachmentFilter),
|
|
BeforeFilter beforeFilter => current.ApplyBeforeFilter(beforeFilter),
|
|
InFilter inFilter => current.ApplyInFilter(inFilter, user, db),
|
|
InstanceFilter instanceFilter => current.ApplyInstanceFilter(instanceFilter, config),
|
|
MentionFilter mentionFilter => current.ApplyMentionFilter(mentionFilter, config, db),
|
|
MiscFilter miscFilter => current.ApplyMiscFilter(miscFilter, user),
|
|
VisibilityFilter visibilityFilter => current.ApplyVisibilityFilter(visibilityFilter),
|
|
ReplyFilter replyFilter => current.ApplyReplyFilter(replyFilter, config, db),
|
|
CwFilter cwFilter => current.ApplyCwFilter(cwFilter, caseSensitivity, matchType),
|
|
WordFilter wordFilter => current.ApplyWordFilter(wordFilter, caseSensitivity, matchType),
|
|
MultiWordFilter multiWordFilter =>
|
|
current.ApplyMultiWordFilter(multiWordFilter, caseSensitivity, matchType),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(filter))
|
|
});
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static (string username, string? host) UserToTuple(string filter, Config.InstanceSection config)
|
|
{
|
|
filter = filter.TrimStart('@');
|
|
var split = filter.Split('@');
|
|
var username = split[0].ToLowerInvariant();
|
|
var host = split.Length > 1 ? split[1] : null;
|
|
return (username, LocalDomainCheck(host, config));
|
|
}
|
|
|
|
/// <returns>
|
|
/// The input variable host, or null if it matches the configured web or account domain.
|
|
/// </returns>
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static string? LocalDomainCheck(string? host, Config.InstanceSection config) =>
|
|
host == null || host == config.WebDomain || host == config.AccountDomain ? null : host;
|
|
|
|
private static IQueryable<Note> ApplyAfterFilter(this IQueryable<Note> query, AfterFilter filter)
|
|
=> query.Where(p => p.CreatedAt >= filter.Value.ToDateTime(TimeOnly.MinValue).ToUniversalTime());
|
|
|
|
private static IQueryable<Note> ApplyBeforeFilter(this IQueryable<Note> query, BeforeFilter filter)
|
|
=> query.Where(p => p.CreatedAt < filter.Value.ToDateTime(TimeOnly.MinValue).ToUniversalTime());
|
|
|
|
private static IQueryable<Note> ApplyWordFilter(
|
|
this IQueryable<Note> query, WordFilter filter, CaseFilterType caseSensitivity, MatchFilterType matchType
|
|
) => query.Where(p => p.FtsQueryPreEscaped(PreEscapeFtsQuery(filter.Value, matchType), filter.Negated,
|
|
caseSensitivity, matchType));
|
|
|
|
private static IQueryable<Note> ApplyCwFilter(
|
|
this IQueryable<Note> query, CwFilter filter, CaseFilterType caseSensitivity, MatchFilterType matchType
|
|
) => query.Where(p => FtsQueryPreEscapedMatch(p.Cw, PreEscapeFtsQuery(filter.Value, matchType), filter.Negated,
|
|
caseSensitivity, matchType));
|
|
|
|
private static IQueryable<Note> ApplyMultiWordFilter(
|
|
this IQueryable<Note> query, MultiWordFilter filter, CaseFilterType caseSensitivity, MatchFilterType matchType
|
|
) => filter.Negated
|
|
? query.Where(p => !p.FtsQueryOneOf(filter.Values, caseSensitivity, matchType))
|
|
: query.Where(p => p.FtsQueryOneOf(filter.Values, caseSensitivity, matchType));
|
|
|
|
private static IQueryable<Note> ApplyFromFilters(
|
|
this IQueryable<Note> query, List<FromFilter> filters, Config.InstanceSection config, DatabaseContext db
|
|
)
|
|
{
|
|
if (filters.Count == 0) return query;
|
|
var expr = ExpressionExtensions.False<Note>();
|
|
expr = filters.Aggregate(expr, (current, filter) => current
|
|
.Or(p => p.User.UserSubqueryMatches(filter.Value, filter.Negated, config, db)));
|
|
return query.Where(expr);
|
|
}
|
|
|
|
private static IQueryable<Note> ApplyInstanceFilter(
|
|
this IQueryable<Note> query, InstanceFilter filter, Config.InstanceSection config
|
|
) => query.Where(p => filter.Negated
|
|
? p.UserHost != LocalDomainCheck(filter.Value, config)
|
|
: p.UserHost == LocalDomainCheck(filter.Value, config));
|
|
|
|
private static IQueryable<Note> ApplyMentionFilter(
|
|
this IQueryable<Note> query, MentionFilter filter, Config.InstanceSection config, DatabaseContext db
|
|
) => query.Where(p => p.Mentions.UserSubqueryContains(filter.Value, filter.Negated, config, db));
|
|
|
|
private static IQueryable<Note> ApplyReplyFilter(
|
|
this IQueryable<Note> query, ReplyFilter filter, Config.InstanceSection config, DatabaseContext db
|
|
) => query.Where(p => p.Reply != null
|
|
&& p.Reply.User.UserSubqueryMatches(filter.Value, filter.Negated, config, db));
|
|
|
|
private static IQueryable<Note> ApplyInFilter(
|
|
this IQueryable<Note> query, InFilter filter, User user, DatabaseContext db
|
|
)
|
|
{
|
|
return filter.Value switch
|
|
{
|
|
InFilterType.Likes => query.ApplyInLikesFilter(user, filter.Negated, db),
|
|
InFilterType.Bookmarks => query.ApplyInBookmarksFilter(user, filter.Negated, db),
|
|
InFilterType.Reactions => query.ApplyInReactionsFilter(user, filter.Negated, db),
|
|
InFilterType.Interactions => query.ApplyInInteractionsFilter(user, filter.Negated, db),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null)
|
|
};
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
|
|
private static IQueryable<Note> ApplyInBookmarksFilter(
|
|
this IQueryable<Note> query, User user, bool negated, DatabaseContext db
|
|
) => query.Where(p => negated
|
|
? !db.Users.First(u => u == user).HasBookmarked(p)
|
|
: db.Users.First(u => u == user).HasBookmarked(p));
|
|
|
|
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
|
|
private static IQueryable<Note> ApplyInLikesFilter(
|
|
this IQueryable<Note> query, User user, bool negated, DatabaseContext db
|
|
) => query.Where(p => negated
|
|
? !db.Users.First(u => u == user).HasLiked(p)
|
|
: db.Users.First(u => u == user).HasLiked(p));
|
|
|
|
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
|
|
private static IQueryable<Note> ApplyInReactionsFilter(
|
|
this IQueryable<Note> query, User user, bool negated, DatabaseContext db
|
|
) => query.Where(p => negated
|
|
? !db.Users.First(u => u == user).HasReacted(p)
|
|
: db.Users.First(u => u == user).HasReacted(p));
|
|
|
|
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
|
|
private static IQueryable<Note> ApplyInInteractionsFilter(
|
|
this IQueryable<Note> query, User user, bool negated, DatabaseContext db
|
|
) => query.Where(p => negated
|
|
? !db.Users.First(u => u == user).HasInteractedWith(p)
|
|
: db.Users.First(u => u == user).HasInteractedWith(p));
|
|
|
|
private static IQueryable<Note> ApplyMiscFilter(this IQueryable<Note> query, MiscFilter filter, User user)
|
|
{
|
|
return filter.Value switch
|
|
{
|
|
MiscFilterType.Followers => query.ApplyFollowersFilter(user, filter.Negated),
|
|
MiscFilterType.Following => query.ApplyFollowingFilter(user, filter.Negated),
|
|
MiscFilterType.Renotes => query.ApplyBoostsFilter(filter.Negated),
|
|
MiscFilterType.Replies => query.ApplyRepliesFilter(filter.Negated),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(filter))
|
|
};
|
|
}
|
|
|
|
private static IQueryable<Note> ApplyVisibilityFilter(this IQueryable<Note> query, VisibilityFilter filter)
|
|
{
|
|
if (filter.Value is VisibilityFilterType.Local)
|
|
return query.Where(p => p.LocalOnly == !filter.Negated);
|
|
|
|
var visibility = filter.Value switch
|
|
{
|
|
VisibilityFilterType.Public => Note.NoteVisibility.Public,
|
|
VisibilityFilterType.Home => Note.NoteVisibility.Home,
|
|
VisibilityFilterType.Followers => Note.NoteVisibility.Followers,
|
|
VisibilityFilterType.Specified => Note.NoteVisibility.Specified,
|
|
_ => throw new ArgumentOutOfRangeException()
|
|
};
|
|
|
|
return filter.Negated
|
|
? query.Where(p => p.Visibility != visibility)
|
|
: query.Where(p => p.Visibility == visibility);
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
|
|
private static IQueryable<Note> ApplyFollowersFilter(this IQueryable<Note> query, User user, bool negated)
|
|
=> query.Where(p => negated ? !p.User.IsFollowing(user) : p.User.IsFollowing(user));
|
|
|
|
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
|
|
private static IQueryable<Note> ApplyFollowingFilter(this IQueryable<Note> query, User user, bool negated)
|
|
=> query.Where(p => negated ? !p.User.IsFollowedBy(user) : p.User.IsFollowedBy(user));
|
|
|
|
private static IQueryable<Note> ApplyRepliesFilter(this IQueryable<Note> query, bool negated)
|
|
=> query.Where(p => negated ? p.Reply == null : p.Reply != null);
|
|
|
|
private static IQueryable<Note> ApplyBoostsFilter(this IQueryable<Note> query, bool negated)
|
|
=> query.Where(p => negated ? !p.IsPureRenote : p.IsPureRenote);
|
|
|
|
private static IQueryable<Note> ApplyAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
|
|
=> filter.Negated ? query.ApplyNegatedAttachmentFilter(filter) : query.ApplyRegularAttachmentFilter(filter);
|
|
|
|
private static IQueryable<Note> ApplyRegularAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
|
|
{
|
|
if (filter.Value is AttachmentFilterType.Media)
|
|
return query.Where(p => p.AttachedFileTypes.Count != 0);
|
|
if (filter.Value is AttachmentFilterType.Poll)
|
|
return query.Where(p => p.HasPoll);
|
|
|
|
if (filter.Value is AttachmentFilterType.Image or AttachmentFilterType.Video or AttachmentFilterType.Audio)
|
|
{
|
|
return query.Where(p => p.AttachedFileTypes.Count != 0
|
|
&& EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
|
|
}
|
|
|
|
if (filter.Value is AttachmentFilterType.File)
|
|
{
|
|
return query.Where(p => p.AttachedFileTypes.Count != 0
|
|
&& (!EF.Functions.ILike(p.RawAttachments,
|
|
GetAttachmentILikeQuery(AttachmentFilterType.Image))
|
|
|| !EF.Functions.ILike(p.RawAttachments,
|
|
GetAttachmentILikeQuery(AttachmentFilterType.Video))
|
|
|| !EF.Functions.ILike(p.RawAttachments,
|
|
GetAttachmentILikeQuery(AttachmentFilterType.Audio))));
|
|
}
|
|
|
|
throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null);
|
|
}
|
|
|
|
private static IQueryable<Note> ApplyNegatedAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
|
|
{
|
|
if (filter.Value is AttachmentFilterType.Media)
|
|
return query.Where(p => p.AttachedFileTypes.Count == 0);
|
|
if (filter.Value is AttachmentFilterType.Poll)
|
|
return query.Where(p => !p.HasPoll);
|
|
if (filter.Value is AttachmentFilterType.Image or AttachmentFilterType.Video or AttachmentFilterType.Audio)
|
|
return query.Where(p => !EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
|
|
|
|
if (filter.Value is AttachmentFilterType.File)
|
|
{
|
|
return query.Where(p => EF.Functions
|
|
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Image))
|
|
|| EF.Functions
|
|
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Video))
|
|
|| EF.Functions
|
|
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Audio)));
|
|
}
|
|
|
|
throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null);
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static string GetAttachmentILikeQuery(AttachmentFilterType filter)
|
|
{
|
|
return filter switch
|
|
{
|
|
AttachmentFilterType.Image => "%image/%",
|
|
AttachmentFilterType.Video => "%video/%",
|
|
AttachmentFilterType.Audio => "%audio/%",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter, null)
|
|
};
|
|
}
|
|
|
|
[Projectable]
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static bool UserSubqueryMatches(
|
|
this User user, string filter, bool negated, Config.InstanceSection config, DatabaseContext db
|
|
) => negated
|
|
? !UserSubquery(UserToTuple(filter, config), db).Contains(user)
|
|
: UserSubquery(UserToTuple(filter, config), db).Contains(user);
|
|
|
|
[Projectable]
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static bool UserSubqueryContains(
|
|
this IEnumerable<string> userIds, string filter, bool negated, Config.InstanceSection config, DatabaseContext db
|
|
) => negated
|
|
? userIds.All(p => p != UserSubquery(UserToTuple(filter, config), db).Select(i => i.Id).FirstOrDefault())
|
|
: userIds.Any(p => p == UserSubquery(UserToTuple(filter, config), db).Select(i => i.Id).FirstOrDefault());
|
|
|
|
[Projectable]
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static IQueryable<User> UserSubquery((string username, string? host) filter, DatabaseContext db) =>
|
|
db.Users.Where(p => p.UsernameLower == filter.username
|
|
&& p.Host == (filter.host != null ? filter.host.ToPunycodeLower() : null));
|
|
|
|
[Projectable]
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static bool FtsQueryPreEscaped(
|
|
this Note note, string query, bool negated, CaseFilterType caseSensitivity, MatchFilterType matchType
|
|
) => negated
|
|
? matchType.Equals(MatchFilterType.Substring)
|
|
? FtsQueryMatchSubstringPreEscaped(note.Text, query, negated, caseSensitivity)
|
|
&& FtsQueryMatchSubstringPreEscaped(note.Cw, query, negated, caseSensitivity)
|
|
&& FtsQueryMatchSubstringPreEscaped(note.CombinedAltText, query, negated, caseSensitivity)
|
|
: FtsQueryMatchWordPreEscaped(note.Text, query, negated, caseSensitivity)
|
|
&& FtsQueryMatchWordPreEscaped(note.Cw, query, negated, caseSensitivity)
|
|
&& FtsQueryMatchWordPreEscaped(note.CombinedAltText, query, negated, caseSensitivity)
|
|
: matchType.Equals(MatchFilterType.Substring)
|
|
? FtsQueryMatchSubstringPreEscaped(note.Text, query, negated, caseSensitivity)
|
|
|| FtsQueryMatchSubstringPreEscaped(note.Cw, query, negated, caseSensitivity)
|
|
|| FtsQueryMatchSubstringPreEscaped(note.CombinedAltText, query, negated, caseSensitivity)
|
|
: FtsQueryMatchWordPreEscaped(note.Text, query, negated, caseSensitivity)
|
|
|| FtsQueryMatchWordPreEscaped(note.Cw, query, negated, caseSensitivity)
|
|
|| FtsQueryMatchWordPreEscaped(note.CombinedAltText, query, negated, caseSensitivity);
|
|
|
|
[Projectable]
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static bool FtsQueryPreEscapedMatch(
|
|
string? match, string query, bool negated,
|
|
CaseFilterType caseSensitivity, MatchFilterType matchType
|
|
) => matchType.Equals(MatchFilterType.Substring)
|
|
? caseSensitivity.Equals(CaseFilterType.Sensitive)
|
|
? negated
|
|
? !EF.Functions.Like(match!, "%" + query + "%", @"\")
|
|
: EF.Functions.Like(match!, "%" + query + "%", @"\")
|
|
: negated
|
|
? !EF.Functions.ILike(match!, "%" + query + "%", @"\")
|
|
: EF.Functions.ILike(match!, "%" + query + "%", @"\")
|
|
: caseSensitivity.Equals(CaseFilterType.Sensitive)
|
|
? negated
|
|
? !Regex.IsMatch(match!, "\\y" + query + "\\y")
|
|
: Regex.IsMatch(match!, "\\y" + query + "\\y")
|
|
: negated
|
|
? !Regex.IsMatch(match!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|
|
: Regex.IsMatch(match!, "\\y" + query + "\\y", RegexOptions.IgnoreCase);
|
|
|
|
internal static string PreEscapeFtsQuery(string query, MatchFilterType matchType)
|
|
=> matchType.Equals(MatchFilterType.Substring)
|
|
? EfHelpers.EscapeLikeQuery(query)
|
|
: EfHelpers.EscapeRegexQuery(query);
|
|
|
|
[Projectable]
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static bool FtsQueryOneOf(
|
|
this Note note, IEnumerable<string> words, CaseFilterType caseSensitivity, MatchFilterType matchType
|
|
) => words.Select(p => PreEscapeFtsQuery(p, matchType))
|
|
.Any(p => note.FtsQueryPreEscaped(p, false, caseSensitivity, matchType));
|
|
|
|
[Projectable]
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static bool FtsQueryMatchSubstringPreEscaped(
|
|
string? match, string query, bool negated, CaseFilterType caseSensitivity
|
|
) => caseSensitivity.Equals(CaseFilterType.Sensitive)
|
|
? negated
|
|
? !EF.Functions.Like(match!, "%" + query + "%", @"\")
|
|
: EF.Functions.Like(match!, "%" + query + "%", @"\")
|
|
: negated
|
|
? !EF.Functions.ILike(match!, "%" + query + "%", @"\")
|
|
: EF.Functions.ILike(match!, "%" + query + "%", @"\");
|
|
|
|
[Projectable]
|
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
|
Justification = "Projectable chain must have consistent visibility")]
|
|
internal static bool FtsQueryMatchWordPreEscaped(
|
|
string? match, string query, bool negated, CaseFilterType caseSensitivity
|
|
) => caseSensitivity.Equals(CaseFilterType.Sensitive)
|
|
? negated
|
|
? match == null || !Regex.IsMatch(match, "\\y" + query + "\\y")
|
|
: match != null && Regex.IsMatch(match, "\\y" + query + "\\y")
|
|
: negated
|
|
? match == null || !Regex.IsMatch(match, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|
|
: match != null && Regex.IsMatch(match, "\\y" + query + "\\y", RegexOptions.IgnoreCase);
|
|
}
|