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 FilterByFtsQuery( this IQueryable query, string input, User user, Config.InstanceSection config, DatabaseContext db ) { var parsed = SearchQueryParser.Parse(input); var caseSensitivity = parsed.OfType().LastOrDefault()?.Value ?? CaseFilterType.Insensitive; var matchType = parsed.OfType().LastOrDefault()?.Value ?? MatchFilterType.Substring; query = query.ApplyFromFilters(parsed.OfType().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)); } /// /// The input variable host, or null if it matches the configured web or account domain. /// [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 ApplyAfterFilter(this IQueryable query, AfterFilter filter) => query.Where(p => p.CreatedAt >= filter.Value.ToDateTime(TimeOnly.MinValue).ToUniversalTime()); private static IQueryable ApplyBeforeFilter(this IQueryable query, BeforeFilter filter) => query.Where(p => p.CreatedAt < filter.Value.ToDateTime(TimeOnly.MinValue).ToUniversalTime()); private static IQueryable ApplyWordFilter( this IQueryable query, WordFilter filter, CaseFilterType caseSensitivity, MatchFilterType matchType ) => query.Where(p => p.FtsQueryPreEscaped(PreEscapeFtsQuery(filter.Value, matchType), filter.Negated, caseSensitivity, matchType)); private static IQueryable ApplyCwFilter( this IQueryable query, CwFilter filter, CaseFilterType caseSensitivity, MatchFilterType matchType ) => query.Where(p => FtsQueryPreEscapedMatch(p.Cw, PreEscapeFtsQuery(filter.Value, matchType), filter.Negated, caseSensitivity, matchType)); private static IQueryable ApplyMultiWordFilter( this IQueryable 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 ApplyFromFilters( this IQueryable query, List filters, Config.InstanceSection config, DatabaseContext db ) { if (filters.Count == 0) return query; var expr = ExpressionExtensions.False(); 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 ApplyInstanceFilter( this IQueryable 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 ApplyMentionFilter( this IQueryable query, MentionFilter filter, Config.InstanceSection config, DatabaseContext db ) => query.Where(p => p.Mentions.UserSubqueryContains(filter.Value, filter.Negated, config, db)); private static IQueryable ApplyReplyFilter( this IQueryable 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 ApplyInFilter( this IQueryable 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 ApplyInBookmarksFilter( this IQueryable 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 ApplyInLikesFilter( this IQueryable 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 ApplyInReactionsFilter( this IQueryable 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 ApplyInInteractionsFilter( this IQueryable 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 ApplyMiscFilter(this IQueryable 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 ApplyVisibilityFilter(this IQueryable query, VisibilityFilter filter) { 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 ApplyFollowersFilter(this IQueryable 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 ApplyFollowingFilter(this IQueryable query, User user, bool negated) => query.Where(p => negated ? !p.User.IsFollowedBy(user) : p.User.IsFollowedBy(user)); private static IQueryable ApplyRepliesFilter(this IQueryable query, bool negated) => query.Where(p => negated ? p.Reply == null : p.Reply != null); private static IQueryable ApplyBoostsFilter(this IQueryable query, bool negated) => query.Where(p => negated ? !p.IsPureRenote : p.IsPureRenote); private static IQueryable ApplyAttachmentFilter(this IQueryable query, AttachmentFilter filter) => filter.Negated ? query.ApplyNegatedAttachmentFilter(filter) : query.ApplyRegularAttachmentFilter(filter); private static IQueryable ApplyRegularAttachmentFilter(this IQueryable 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 ApplyNegatedAttachmentFilter(this IQueryable 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 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 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 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); }