using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Nuuru.Server.Auth;
using Nuuru.Server.Data;
using Nuuru.Server.Models.Booru;
using Nuuru.Server.Services.Search.Nodes;
namespace Nuuru.Server.Services.Search;
///
/// Builds EF Core queries from search AST nodes.
///
public class SearchQueryBuilder
{
private static readonly TimeSpan TagIdCacheDuration = TimeSpan.FromMinutes(5);
private readonly ApplicationDbContext _context;
private readonly ICurrentUserContext _userContext;
private readonly IMemoryCache? _cache;
public SearchQueryBuilder(ApplicationDbContext context, ICurrentUserContext userContext, IMemoryCache? cache = null)
{
_context = context;
_userContext = userContext;
_cache = cache;
}
///
/// Builds an IQueryable from the parsed search result.
///
/// The parsed search result.
public IQueryable BuildQuery(SearchParseResult parseResult)
{
var query = _context.BooruPosts
.Include(p => p.Uploader)
.Include(p => p.PostTags)
.ThenInclude(pt => pt.Tag)
.ThenInclude(t => t.Category)
.AsQueryable();
// Only include Votes when the controversial sort needs them — avoids loading
// all vote records for every post on normal queries
if (parseResult.OrderBy?.Field == "controversial")
{
query = query.Include(p => p.Votes);
}
return BuildQueryCore(query, parseResult);
}
///
/// Builds a lightweight query for counting (no includes).
///
public IQueryable BuildCountQuery(SearchParseResult parseResult)
{
var query = _context.BooruPosts.AsQueryable();
return BuildQueryCore(query, parseResult);
}
///
/// Applies only the filter nodes from a parse result to an existing query,
/// without adding ordering, approval, or trash logic.
///
public IQueryable ApplyFilterNodes(IQueryable query, SearchParseResult parseResult)
{
if (parseResult.RootNode != null)
query = ApplyNode(query, parseResult.RootNode);
return query;
}
///
/// Applies visibility filters (trash and approval) based on user context and search query.
///
public IQueryable ApplyVisibilityFilters(IQueryable query, SearchParseResult? parseResult = null)
{
var canViewTrash = _userContext.HasPermission(Permissions.Admin.ViewTrash);
var explicitlyRequestsTrashed = parseResult != null && ContainsPositiveTrashedStatusFilter(parseResult.RootNode);
// Exclude trashed posts by default. Only include them when explicitly requested
// and the current user has permission to view the trash.
if (!(canViewTrash && explicitlyRequestsTrashed))
{
query = query.Where(p => !p.IsTrashed);
}
// Filter for approval based on current user context
var canSeeUnapproved = _userContext.HasPermission(Permissions.Moderation.ApprovePost);
if (!canSeeUnapproved)
{
var currentUserId = _userContext.UserId;
if (currentUserId.HasValue)
{
// Use UploaderId FK directly — avoids INNER JOIN to AspNetUsers on count queries
query = query.Where(p => p.IsApproved || p.UploaderId == currentUserId.Value);
}
else
{
query = query.Where(p => p.IsApproved);
}
}
return query;
}
///
/// Walks the entire AST and pre-resolves all tag names to IDs in async batch queries,
/// populating the IMemoryCache so that subsequent sync resolvers are instant cache hits.
/// Call this once before BuildQuery/BuildCountQuery to avoid blocking DB I/O during query building.
///
public async Task PreResolveTagIdsAsync(SearchParseResult parseResult)
{
if (_cache == null || parseResult.RootNode == null)
return;
var plainNames = new HashSet();
var categoryNames = new Dictionary>();
var wildcardPrefixes = new HashSet();
var categoryWildcardKeys = new HashSet();
CollectTagNames(parseResult.RootNode, plainNames, categoryNames, wildcardPrefixes, categoryWildcardKeys);
// Batch resolve plain tags — skip already-cached names
var uncachedPlain = plainNames.Where(n => !_cache.TryGetValue($"tag_ids:{n}", out _)).ToList();
if (uncachedPlain.Count > 0)
{
var results = await _context.BooruTags
.Where(t => uncachedPlain.Contains(t.Name))
.Select(t => new { t.Name, t.Id })
.ToListAsync();
var found = new HashSet();
foreach (var group in results.GroupBy(x => x.Name))
{
_cache.Set($"tag_ids:{group.Key}", group.Select(x => x.Id).ToList(), TagIdCacheDuration);
found.Add(group.Key);
}
foreach (var name in uncachedPlain.Where(n => !found.Contains(n)))
_cache.Set($"tag_ids:{name}", new List(), TagIdCacheDuration);
}
// Batch resolve category tags per category — skip already-cached
foreach (var (slug, names) in categoryNames)
{
var uncached = names.Where(n => !_cache.TryGetValue($"tag_ids:{slug}:{n}", out _)).ToList();
if (uncached.Count == 0) continue;
var results = await _context.BooruTags
.Where(t => t.Category != null && t.Category.Slug == slug && uncached.Contains(t.Name))
.Select(t => new { t.Name, t.Id })
.ToListAsync();
var found = new HashSet();
foreach (var group in results.GroupBy(x => x.Name))
{
_cache.Set($"tag_ids:{slug}:{group.Key}", group.Select(x => x.Id).ToList(), TagIdCacheDuration);
found.Add(group.Key);
}
foreach (var name in uncached.Where(n => !found.Contains(n)))
_cache.Set($"tag_ids:{slug}:{name}", new List(), TagIdCacheDuration);
}
// Pre-resolve plain wildcard prefixes (e.g. "variant" from "variant:*" parsed as WildcardTagNode)
foreach (var prefix in wildcardPrefixes)
{
var cacheKey = $"tag_ids_wildcard:{prefix}";
if (_cache.TryGetValue(cacheKey, out _)) continue;
var ids = await _context.BooruTags
.Where(t => t.Name.StartsWith(prefix))
.Select(t => t.Id)
.ToListAsync();
_cache.Set(cacheKey, ids, TagIdCacheDuration);
}
// Pre-resolve category wildcard keys (e.g. "nas:*", "variant:*" from CategoryTagNode)
foreach (var key in categoryWildcardKeys)
{
var cacheKey = $"tag_ids_cat_wildcard:{key}";
if (_cache.TryGetValue(cacheKey, out _)) continue;
var parts = key.Split(':', 2);
var catSlug = parts[0];
var tagName = parts[1];
IQueryable tagQuery = _context.BooruTags.AsNoTracking();
if (catSlug != "*")
{
if (catSlug.EndsWith('*'))
{
var catPrefix = catSlug.Substring(0, catSlug.Length - 1);
tagQuery = tagQuery.Where(t => t.Category != null && t.Category.Slug.StartsWith(catPrefix));
}
else
tagQuery = tagQuery.Where(t => t.Category != null && t.Category.Slug == catSlug);
}
if (tagName != "*")
{
if (tagName.EndsWith('*'))
{
var namePrefix = tagName.Substring(0, tagName.Length - 1);
tagQuery = tagQuery.Where(t => t.Name.StartsWith(namePrefix));
}
else
tagQuery = tagQuery.Where(t => t.Name == tagName);
}
var ids = await tagQuery.Select(t => t.Id).ToListAsync();
_cache.Set(cacheKey, ids, TagIdCacheDuration);
}
}
private static void CollectTagNames(SearchNode node, HashSet plain,
Dictionary> category,
HashSet? wildcardPrefixes = null,
HashSet? categoryWildcardKeys = null)
{
switch (node)
{
case TagNode tn:
plain.Add(tn.Name);
break;
case WildcardTagNode wn:
wildcardPrefixes?.Add(wn.Prefix);
break;
case CategoryTagNode cn:
bool hasWildcard = cn.CategorySlug == "*" || cn.CategorySlug.EndsWith('*')
|| cn.Name == "*" || cn.Name.EndsWith('*');
if (hasWildcard)
categoryWildcardKeys?.Add($"{cn.CategorySlug}:{cn.Name}");
else
{
if (!category.TryGetValue(cn.CategorySlug, out var names))
category[cn.CategorySlug] = names = [];
names.Add(cn.Name);
}
break;
case AndNode an:
foreach (var child in an.Children) CollectTagNames(child, plain, category, wildcardPrefixes, categoryWildcardKeys);
break;
case OrNode on:
foreach (var child in on.Children) CollectTagNames(child, plain, category, wildcardPrefixes, categoryWildcardKeys);
break;
}
}
///
/// Resolves tag names to IDs, using IMemoryCache when available.
/// After PreResolveTagIdsAsync, all lookups are instant cache hits.
///
private List ResolveTagIdsByName(List tagNames)
{
if (_cache == null)
{
return _context.BooruTags
.Where(t => tagNames.Contains(t.Name))
.Select(t => t.Id)
.ToList();
}
var result = new List();
var uncachedNames = new List();
foreach (var name in tagNames)
{
if (_cache.TryGetValue($"tag_ids:{name}", out List? cached))
result.AddRange(cached!);
else
uncachedNames.Add(name);
}
if (uncachedNames.Count > 0)
{
var lookupResults = _context.BooruTags
.Where(t => uncachedNames.Contains(t.Name))
.Select(t => new { t.Name, t.Id })
.ToList();
var foundNames = new HashSet();
foreach (var group in lookupResults.GroupBy(x => x.Name))
{
var ids = group.Select(x => x.Id).ToList();
_cache.Set($"tag_ids:{group.Key}", ids, TagIdCacheDuration);
result.AddRange(ids);
foundNames.Add(group.Key);
}
foreach (var name in uncachedNames.Where(n => !foundNames.Contains(n)))
_cache.Set($"tag_ids:{name}", new List(), TagIdCacheDuration);
}
return result;
}
///
/// Resolves category:name wildcard patterns to tag IDs, with caching.
///
private List ResolveCategoryWildcardTagIds(string categorySlug, string tagName)
{
var cacheKey = $"tag_ids_cat_wildcard:{categorySlug}:{tagName}";
if (_cache != null && _cache.TryGetValue(cacheKey, out List? cached))
return cached!;
IQueryable tagQuery = _context.BooruTags.AsNoTracking();
if (categorySlug != "*")
{
if (categorySlug.EndsWith('*'))
{
var catPrefix = categorySlug.Substring(0, categorySlug.Length - 1);
tagQuery = tagQuery.Where(t => t.Category != null && t.Category.Slug.StartsWith(catPrefix));
}
else
tagQuery = tagQuery.Where(t => t.Category != null && t.Category.Slug == categorySlug);
}
if (tagName != "*")
{
if (tagName.EndsWith('*'))
{
var namePrefix = tagName.Substring(0, tagName.Length - 1);
tagQuery = tagQuery.Where(t => t.Name.StartsWith(namePrefix));
}
else
tagQuery = tagQuery.Where(t => t.Name == tagName);
}
var ids = tagQuery.Select(t => t.Id).ToList();
_cache?.Set(cacheKey, ids, TagIdCacheDuration);
return ids;
}
///
/// Resolves wildcard prefix to matching tag IDs, with caching.
///
private List ResolveWildcardTagIds(string prefix)
{
var cacheKey = $"tag_ids_wildcard:{prefix}";
if (_cache != null && _cache.TryGetValue(cacheKey, out List? cached))
return cached!;
var ids = _context.BooruTags
.Where(t => t.Name.StartsWith(prefix))
.Select(t => t.Id)
.ToList();
_cache?.Set(cacheKey, ids, TagIdCacheDuration);
return ids;
}
///
/// Resolves category:name tag pairs to IDs, using IMemoryCache when available.
///
private List ResolveCategoryTagIds(string categorySlug, List tagNames)
{
if (_cache == null)
{
return _context.BooruTags
.Where(t => t.Category != null && t.Category.Slug == categorySlug && tagNames.Contains(t.Name))
.Select(t => t.Id)
.ToList();
}
var result = new List();
var uncachedNames = new List();
foreach (var name in tagNames)
{
if (_cache.TryGetValue($"tag_ids:{categorySlug}:{name}", out List? cached))
result.AddRange(cached!);
else
uncachedNames.Add(name);
}
if (uncachedNames.Count > 0)
{
var lookupResults = _context.BooruTags
.Where(t => t.Category != null && t.Category.Slug == categorySlug && uncachedNames.Contains(t.Name))
.Select(t => new { t.Name, t.Id })
.ToList();
var foundNames = new HashSet();
foreach (var group in lookupResults.GroupBy(x => x.Name))
{
var ids = group.Select(x => x.Id).ToList();
_cache.Set($"tag_ids:{categorySlug}:{group.Key}", ids, TagIdCacheDuration);
result.AddRange(ids);
foundNames.Add(group.Key);
}
foreach (var name in uncachedNames.Where(n => !foundNames.Contains(n)))
_cache.Set($"tag_ids:{categorySlug}:{name}", new List(), TagIdCacheDuration);
}
return result;
}
private IQueryable BuildQueryCore(IQueryable query, SearchParseResult parseResult)
{
query = ApplyVisibilityFilters(query, parseResult);
// Apply filter nodes
if (parseResult.RootNode != null)
{
query = ApplyNode(query, parseResult.RootNode);
}
// Apply ordering
query = ApplyOrdering(query, parseResult.OrderBy);
return query;
}
private static bool ContainsPositiveTrashedStatusFilter(SearchNode? node)
{
if (node == null)
return false;
return node switch
{
StatusFilterNode statusNode => !statusNode.Negated && statusNode.Status == "trashed",
AndNode andNode => andNode.Children.Any(ContainsPositiveTrashedStatusFilter),
OrNode orNode => orNode.Children.Any(ContainsPositiveTrashedStatusFilter),
_ => false
};
}
private IQueryable ApplyNode(IQueryable query, SearchNode node)
{
return node switch
{
TagNode tn => ApplyTagFilter(query, tn),
CategoryTagNode cn => ApplyCategoryTagFilter(query, cn),
WildcardTagNode wn => ApplyWildcardFilter(query, wn),
AndNode an => ApplyAndFilter(query, an),
OrNode on => ApplyOrFilter(query, on),
RatingFilterNode rn => ApplyRatingFilter(query, rn),
CategoryFilterNode cn => ApplyCategoryFilter(query, cn),
UploaderFilterNode un => ApplyUploaderFilter(query, un),
NumericRangeFilterNode nf => ApplyNumericFilter(query, nf),
DateRangeFilterNode df => ApplyDateFilter(query, df),
FileTypeFilterNode ff => ApplyFileTypeFilter(query, ff),
StatusFilterNode sf => ApplyStatusFilter(query, sf),
_ => query
};
}
private IQueryable ApplyStatusFilter(IQueryable query, StatusFilterNode node)
{
var status = node.Status;
return status switch
{
"approved" => node.Negated ? query.Where(p => !p.IsApproved) : query.Where(p => p.IsApproved),
"pending" => node.Negated ? query.Where(p => p.IsApproved) : query.Where(p => !p.IsApproved),
"featured" => node.Negated ? query.Where(p => !p.IsFeatured) : query.Where(p => p.IsFeatured),
"locked" => node.Negated ? query.Where(p => !p.CommentsLocked) : query.Where(p => p.CommentsLocked),
"trashed" => node.Negated ? query.Where(p => !p.IsTrashed) : query.Where(p => p.IsTrashed),
_ => query
};
}
#region Tag Filters
private IQueryable ApplyTagFilter(IQueryable query, TagNode node)
{
var tagIds = ResolveTagIdsByName([node.Name]);
return ApplyTagIdFilter(query, tagIds, node.Negated);
}
private IQueryable ApplyWildcardFilter(IQueryable query, WildcardTagNode node)
{
var prefix = node.Prefix;
if (node.Negated)
return query.Where(p => !p.PostTags.Any(pt => pt.Tag.Name.StartsWith(prefix)));
else
return query.Where(p => p.PostTags.Any(pt => pt.Tag.Name.StartsWith(prefix)));
}
private IQueryable ApplyCategoryTagFilter(IQueryable query, CategoryTagNode node)
{
var categorySlug = node.CategorySlug;
var tagName = node.Name;
// Fast path: no wildcards, use cached resolver
bool hasWildcard = categorySlug == "*" || categorySlug.EndsWith('*')
|| tagName == "*" || tagName.EndsWith('*');
if (!hasWildcard)
{
var matchingTagIds = ResolveCategoryTagIds(categorySlug, [tagName]);
return ApplyTagIdFilter(query, matchingTagIds, node.Negated);
}
// Wildcard path: resolve to tag IDs (cached) and use ANY() for direct index lookup
var wildcardTagIds = ResolveCategoryWildcardTagIds(categorySlug, tagName);
return ApplyTagIdFilter(query, wildcardTagIds, node.Negated);
}
private IQueryable ApplyTagIdFilter(IQueryable query, List tagIds, bool negated)
{
if (tagIds.Count == 0)
return negated ? query : query.Where(p => false);
// Use NOT EXISTS / EXISTS instead of NOT IN / IN — PostgreSQL generates an anti-join
// which can use the PostTag(PostId, TagId) index and short-circuit per row,
// whereas NOT IN materializes the entire subquery result set first.
return negated
? query.Where(p => !_context.Set().Any(pt => pt.PostId == p.Id && tagIds.Contains(pt.TagId)))
: query.Where(p => _context.Set().Any(pt => pt.PostId == p.Id && tagIds.Contains(pt.TagId)));
}
#endregion
#region Boolean Filters
private IQueryable ApplyAndFilter(IQueryable query, AndNode node)
{
// Batch all simple negated tags into a single exclusion query
// instead of N separate DB roundtrips + N NOT IN subqueries
var negatedTagNames = new List();
var negatedCategoryTags = new List();
// Conditional exclusions: {-excluded ~ whitelist} OR groups that can be merged
// Key = whitelist tag IDs (sorted for grouping), Value = list of excluded tag IDs
var conditionalExclusions = new List<(List ExcludedIds, List WhitelistIds)>();
var remaining = new List();
foreach (var child in node.Children)
{
switch (child)
{
case TagNode { Negated: true } tn:
negatedTagNames.Add(tn.Name);
break;
case CategoryTagNode { Negated: true } cn
when !cn.CategorySlug.EndsWith('*') && cn.CategorySlug != "*"
&& !cn.Name.EndsWith('*') && cn.Name != "*":
negatedCategoryTags.Add(cn);
break;
case OrNode on when TryExtractConditionalExclusion(on, out var excluded, out var whitelist):
conditionalExclusions.Add((excluded, whitelist));
break;
default:
remaining.Add(child);
break;
}
}
// Resolve all negated tag IDs (plain + category) and combine into a single NOT EXISTS subquery
var allExcludedTagIds = new List();
if (negatedTagNames.Count > 0)
{
allExcludedTagIds.AddRange(ResolveTagIdsByName(negatedTagNames));
}
if (negatedCategoryTags.Count > 0)
{
foreach (var group in negatedCategoryTags.GroupBy(cn => cn.CategorySlug))
{
var slug = group.Key;
var names = group.Select(cn => cn.Name).ToList();
allExcludedTagIds.AddRange(ResolveCategoryTagIds(slug, names));
}
}
if (allExcludedTagIds.Count > 0)
{
query = query.Where(p => !_context.Set()
.Any(pt => pt.PostId == p.Id && allExcludedTagIds.Contains(pt.TagId)));
}
// Merge conditional exclusions that share the same whitelist tags into single subquery pairs.
// {-A ~ W} AND {-B ~ W} AND {-C ~ W} becomes:
// NOT EXISTS(PostTag WHERE PostId AND TagId IN [A,B,C]) OR EXISTS(PostTag WHERE PostId AND TagId IN [W])
// This reduces 2N correlated subqueries to 2 per unique whitelist set.
if (conditionalExclusions.Count > 0)
{
var grouped = conditionalExclusions
.GroupBy(ce => string.Join(",", ce.WhitelistIds.Order()))
.ToList();
foreach (var group in grouped)
{
var mergedExcluded = group.SelectMany(ce => ce.ExcludedIds).Distinct().ToList();
var whitelistIds = group.First().WhitelistIds;
if (mergedExcluded.Count == 0) continue;
if (whitelistIds.Count == 0)
{
// No whitelist — pure exclusion, fold into main NOT EXISTS
query = query.Where(p => !_context.Set()
.Any(pt => pt.PostId == p.Id && mergedExcluded.Contains(pt.TagId)));
}
else
{
// Conditional: exclude unless whitelisted
// Use NOT(EXISTS AND NOT EXISTS) instead of NOT EXISTS OR EXISTS
// so PostgreSQL can short-circuit: posts without excluded tags skip the whitelist check entirely
query = query.Where(p =>
!(_context.Set().Any(pt => pt.PostId == p.Id && mergedExcluded.Contains(pt.TagId))
&& !_context.Set().Any(pt => pt.PostId == p.Id && whitelistIds.Contains(pt.TagId))));
}
}
}
// Apply remaining filters normally
foreach (var child in remaining)
{
query = ApplyNode(query, child);
}
return query;
}
///
/// Detects OR groups matching the pattern {-excluded1 -excluded2 ... ~ whitelist1 whitelist2 ...}
/// where all children are simple (non-wildcard) tag or category:tag nodes.
/// Returns pre-resolved tag IDs for both sides.
///
private bool TryExtractConditionalExclusion(OrNode node, out List excludedIds, out List whitelistIds)
{
excludedIds = [];
whitelistIds = [];
if (node.Children.Count < 2)
return false;
var negatedNames = new List();
var negatedCategoryTags = new List();
var positiveNames = new List();
var positiveCategoryTags = new List();
foreach (var child in node.Children)
{
switch (child)
{
case TagNode tn:
(tn.Negated ? negatedNames : positiveNames).Add(tn.Name);
break;
case CategoryTagNode cn:
{
bool isWildcard = cn.CategorySlug == "*" || cn.CategorySlug.EndsWith('*')
|| cn.Name == "*" || cn.Name.EndsWith('*');
if (isWildcard)
{
var wcIds = ResolveCategoryWildcardTagIds(cn.CategorySlug, cn.Name);
(cn.Negated ? excludedIds : whitelistIds).AddRange(wcIds);
}
else
{
(cn.Negated ? negatedCategoryTags : positiveCategoryTags).Add(cn);
}
break;
}
case WildcardTagNode wn:
{
var wcIds = ResolveWildcardTagIds(wn.Prefix);
(wn.Negated ? excludedIds : whitelistIds).AddRange(wcIds);
break;
}
default:
return false; // Complex nodes — bail out
}
}
if (negatedNames.Count == 0 && negatedCategoryTags.Count == 0 && excludedIds.Count == 0)
return false;
// Resolve IDs
if (negatedNames.Count > 0)
excludedIds.AddRange(ResolveTagIdsByName(negatedNames));
foreach (var group in negatedCategoryTags.GroupBy(cn => cn.CategorySlug))
excludedIds.AddRange(ResolveCategoryTagIds(group.Key, group.Select(cn => cn.Name).ToList()));
if (positiveNames.Count > 0)
whitelistIds.AddRange(ResolveTagIdsByName(positiveNames));
foreach (var group in positiveCategoryTags.GroupBy(cn => cn.CategorySlug))
whitelistIds.AddRange(ResolveCategoryTagIds(group.Key, group.Select(cn => cn.Name).ToList()));
return excludedIds.Count > 0;
}
private IQueryable ApplyOrFilter(IQueryable query, OrNode node)
{
// For simple OR of tags, use an optimized approach
var simpleTags = node.Children
.OfType()
.Where(t => !t.Negated)
.Select(t => t.Name)
.ToList();
if (simpleTags.Count == node.Children.Count && simpleTags.Count > 0)
{
var tagIds = ResolveTagIdsByName(simpleTags);
if (tagIds.Count == 0)
return query.Where(p => false);
return query.Where(p => _context.Set()
.Any(pt => pt.PostId == p.Id && tagIds.Contains(pt.TagId)));
}
// Complex OR - fall back to applying each filter separately and combining
// This is less efficient but handles complex cases
return BuildOrExpression(query, node);
}
private IQueryable BuildOrExpression(IQueryable query, OrNode node)
{
if (node.Children.Count == 0)
return query;
var param = Expression.Parameter(typeof(Post), "p");
Expression? combinedExpr = null;
foreach (var child in node.Children)
{
var childExpr = BuildNodeExpression(param, child);
if (childExpr != null)
{
combinedExpr = combinedExpr == null
? childExpr
: Expression.OrElse(combinedExpr, childExpr);
}
}
if (combinedExpr == null)
return query;
var lambda = Expression.Lambda>(combinedExpr, param);
return query.Where(lambda);
}
private Expression? BuildNodeExpression(ParameterExpression param, SearchNode node)
{
return node switch
{
TagNode tn => BuildTagExpression(param, tn),
CategoryTagNode cn => BuildCategoryTagExpression(param, cn),
WildcardTagNode wn => BuildWildcardExpression(param, wn),
AndNode an => BuildAndExpression(param, an),
StatusFilterNode sn => BuildStatusExpression(param, sn),
RatingFilterNode rn => BuildRatingExpression(param, rn),
CategoryFilterNode cn => BuildCategoryExpression(param, cn),
UploaderFilterNode un => BuildUploaderExpression(param, un),
FileTypeFilterNode fn => BuildFileTypeExpression(param, fn),
_ => null
};
}
private Expression? BuildStatusExpression(ParameterExpression param, StatusFilterNode node)
{
Expression? statusExpression = node.Status switch
{
"approved" => Expression.Property(param, nameof(Post.IsApproved)),
"pending" => Expression.Not(Expression.Property(param, nameof(Post.IsApproved))),
"featured" => Expression.Property(param, nameof(Post.IsFeatured)),
"locked" => Expression.Property(param, nameof(Post.CommentsLocked)),
"trashed" => Expression.Property(param, nameof(Post.IsTrashed)),
_ => null
};
if (statusExpression == null)
return null;
return node.Negated ? Expression.Not(statusExpression) : statusExpression;
}
private static Expression BuildRatingExpression(ParameterExpression param, RatingFilterNode node)
{
// p.Rating == rating (or p.Rating != rating when negated)
var ratingProp = Expression.Property(param, nameof(Post.Rating));
var ratingValue = Expression.Constant(node.Rating);
Expression expr = Expression.Equal(ratingProp, ratingValue);
return node.Negated ? Expression.Not(expr) : expr;
}
private static Expression BuildCategoryExpression(ParameterExpression param, CategoryFilterNode node)
{
// p.Category == category (or p.Category != category when negated)
var categoryProp = Expression.Property(param, nameof(Post.Category));
var categoryValue = Expression.Constant(node.Category);
Expression expr = Expression.Equal(categoryProp, categoryValue);
return node.Negated ? Expression.Not(expr) : expr;
}
private static Expression BuildUploaderExpression(ParameterExpression param, UploaderFilterNode node)
{
// p.Uploader.UserName != null && p.Uploader.UserName.ToLower() == username
var uploaderProp = Expression.Property(param, nameof(Post.Uploader));
var userNameProp = Expression.Property(uploaderProp, "UserName");
var notNull = Expression.NotEqual(userNameProp, Expression.Constant(null, typeof(string)));
var toLowerMethod = typeof(string).GetMethod(nameof(string.ToLower), Type.EmptyTypes)!;
var toLowerCall = Expression.Call(userNameProp, toLowerMethod);
var usernameConst = Expression.Constant(node.Username);
var equals = Expression.Equal(toLowerCall, usernameConst);
Expression expr = Expression.AndAlso(notNull, equals);
return node.Negated ? Expression.Not(expr) : expr;
}
private static Expression BuildFileTypeExpression(ParameterExpression param, FileTypeFilterNode node)
{
// p.MimeType == mimeType (or p.MimeType != mimeType when negated)
var mimeTypeProp = Expression.Property(param, nameof(Post.MimeType));
var mimeTypeValue = Expression.Constant(node.MimeType);
Expression expr = Expression.Equal(mimeTypeProp, mimeTypeValue);
return node.Negated ? Expression.Not(expr) : expr;
}
///
/// Builds a tag filter expression using pre-resolved tag IDs instead of joining through
/// BooruTags by name. This eliminates the INNER JOIN BooruTags in correlated subqueries,
/// generating: p.PostTags.Any(pt => pt.TagId == id)
/// instead of: p.PostTags.Any(pt => pt.Tag.Name == 'name') which requires a JOIN.
///
private Expression BuildTagExpression(ParameterExpression param, TagNode node)
{
var tagIds = ResolveTagIdsByName([node.Name]);
return BuildTagIdExistsExpression(param, tagIds, node.Negated);
}
private Expression BuildWildcardExpression(ParameterExpression param, WildcardTagNode node)
{
var matchingTagIds = ResolveWildcardTagIds(node.Prefix);
return BuildTagIdExistsExpression(param, matchingTagIds, node.Negated);
}
private Expression BuildCategoryTagExpression(ParameterExpression param, CategoryTagNode node)
{
// For non-wildcard category tags, use the cached resolver
bool hasWildcard = node.CategorySlug == "*" || node.CategorySlug.EndsWith('*')
|| node.Name == "*" || node.Name.EndsWith('*');
if (!hasWildcard)
{
var tagIds = ResolveCategoryTagIds(node.CategorySlug, [node.Name]);
return BuildTagIdExistsExpression(param, tagIds, node.Negated);
}
// Wildcard path: resolve to cached tag IDs for direct index lookup (no joins)
var wcIds = ResolveCategoryWildcardTagIds(node.CategorySlug, node.Name);
return BuildTagIdExistsExpression(param, wcIds, node.Negated);
}
///
/// Builds: p.PostTags.Any(pt => pt.TagId == id) for single ID, or
/// p.PostTags.Any(pt => tagIds.Contains(pt.TagId)) for multiple IDs.
/// Generates SQL: EXISTS (SELECT 1 FROM PostTag WHERE PostId = p.Id AND TagId = @id)
/// — no JOIN to BooruTags needed.
///
private static Expression BuildTagIdExistsExpression(ParameterExpression param, List tagIds, bool negated)
{
if (tagIds.Count == 0)
return Expression.Constant(negated); // negated + no tags = true (no match to exclude), positive + no tags = false
var postTags = Expression.Property(param, nameof(Post.PostTags));
var ptParam = Expression.Parameter(typeof(PostTag), "pt");
var tagIdProp = Expression.Property(ptParam, nameof(PostTag.TagId));
Expression condition;
if (tagIds.Count == 1)
{
// pt.TagId == guid — simplest possible expression
condition = Expression.Equal(tagIdProp, Expression.Constant(tagIds[0]));
}
else
{
// tagIds.Contains(pt.TagId)
var containsMethod = typeof(List).GetMethod(nameof(List.Contains), [typeof(Guid)])!;
condition = Expression.Call(Expression.Constant(tagIds), containsMethod, tagIdProp);
}
var anyLambda = Expression.Lambda>(condition, ptParam);
var anyMethod = typeof(Enumerable).GetMethods()
.First(m => m.Name == "Any" && m.GetParameters().Length == 2)
.MakeGenericMethod(typeof(PostTag));
Expression anyCall = Expression.Call(anyMethod, postTags, anyLambda);
if (negated)
anyCall = Expression.Not(anyCall);
return anyCall;
}
private Expression? BuildAndExpression(ParameterExpression param, AndNode node)
{
Expression? combined = null;
foreach (var child in node.Children)
{
var childExpr = BuildNodeExpression(param, child);
if (childExpr != null)
{
combined = combined == null ? childExpr : Expression.AndAlso(combined, childExpr);
}
}
return combined;
}
#endregion
#region Meta Filters
private IQueryable ApplyRatingFilter(IQueryable query, RatingFilterNode node)
{
if (node.Negated)
return query.Where(p => p.Rating != node.Rating);
return query.Where(p => p.Rating == node.Rating);
}
private IQueryable ApplyCategoryFilter(IQueryable query, CategoryFilterNode node)
{
if (node.Negated)
return query.Where(p => p.Category != node.Category);
return query.Where(p => p.Category == node.Category);
}
private IQueryable ApplyUploaderFilter(IQueryable query, UploaderFilterNode node)
{
var username = node.Username;
if (node.Negated)
return query.Where(p => p.Uploader.UserName != null && p.Uploader.UserName.ToLower() != username);
return query.Where(p => p.Uploader.UserName != null && p.Uploader.UserName.ToLower() == username);
}
private IQueryable ApplyNumericFilter(IQueryable query, NumericRangeFilterNode node)
{
return node.Field switch
{
"id" => ApplyIntRangeFilter(query, p => p.Id, node),
"score" => ApplyIntRangeFilter(query, p => p.Score, node),
"width" => ApplyNullableIntRangeFilter(query, p => p.Width, node),
"height" => ApplyNullableIntRangeFilter(query, p => p.Height, node),
"filesize" => ApplyLongRangeFilter(query, p => p.FileSize, node),
"tagcount" => ApplyIntRangeFilter(query, p => p.TagCount, node),
_ => query
};
}
private static IQueryable ApplyIntRangeFilter(IQueryable query, Expression> selector, NumericRangeFilterNode node)
{
var min = node.Min.HasValue ? (int)node.Min.Value : (int?)null;
var max = node.Max.HasValue ? (int)node.Max.Value : (int?)null;
return ApplyGenericRangeFilter(query, selector, min, max, node.Negated);
}
private static IQueryable ApplyNullableIntRangeFilter(IQueryable query, Expression> selector, NumericRangeFilterNode node)
{
// For nullable fields like width/height, we treat null as 0 for range comparisons
var min = node.Min.HasValue ? (int)node.Min.Value : (int?)null;
var max = node.Max.HasValue ? (int)node.Max.Value : (int?)null;
var param = selector.Parameters[0];
// Replace p.Width with (p.Width ?? 0)
var coalesce = Expression.Coalesce(selector.Body, Expression.Constant(0, typeof(int?)));
var converted = Expression.Convert(coalesce, typeof(int));
var newSelector = Expression.Lambda>(converted, param);
return ApplyGenericRangeFilter(query, newSelector, min, max, node.Negated);
}
private static IQueryable ApplyLongRangeFilter(IQueryable query, Expression> selector, NumericRangeFilterNode node)
{
return ApplyGenericRangeFilter(query, selector, node.Min, node.Max, node.Negated);
}
private static IQueryable ApplyGenericRangeFilter(
IQueryable query,
Expression> selector,
TValue? min,
TValue? max,
bool negated) where TValue : struct, IComparable
{
if (negated)
{
if (min.HasValue && max.HasValue)
{
return query.Where(CombineWithOr(
CombineWithComparison(selector, Expression.LessThan, min.Value),
CombineWithComparison(selector, Expression.GreaterThan, max.Value)
));
}
if (min.HasValue)
{
return query.Where(CombineWithComparison(selector, Expression.LessThan, min.Value));
}
if (max.HasValue)
{
return query.Where(CombineWithComparison(selector, Expression.GreaterThan, max.Value));
}
}
else
{
if (min.HasValue)
{
query = query.Where(CombineWithComparison(selector, Expression.GreaterThanOrEqual, min.Value));
}
if (max.HasValue)
{
query = query.Where(CombineWithComparison(selector, Expression.LessThanOrEqual, max.Value));
}
}
return query;
}
private static Expression> CombineWithComparison(
Expression> selector,
Func comparison,
TValue value)
{
var constant = Expression.Constant(value, typeof(TValue));
var body = comparison(selector.Body, constant);
return Expression.Lambda>(body, selector.Parameters);
}
private static Expression> CombineWithOr(
Expression> expr1,
Expression> expr2)
{
var parameter = expr1.Parameters[0];
var visitor = new ParameterReplacer(expr2.Parameters[0], parameter);
var body2 = visitor.Visit(expr2.Body);
var combined = Expression.OrElse(expr1.Body, body2);
return Expression.Lambda>(combined, parameter);
}
private class ParameterReplacer(ParameterExpression oldParam, ParameterExpression newParam) : ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node)
{
return node == oldParam ? newParam : base.VisitParameter(node);
}
}
private IQueryable ApplyDateFilter(IQueryable query, DateRangeFilterNode node)
{
if (node.Negated)
{
if (node.Min.HasValue && node.Max.HasValue)
{
var min = node.Min.Value;
var max = node.Max.Value;
return query.Where(p => p.UploadedAt < min || p.UploadedAt > max);
}
if (node.Min.HasValue)
{
var min = node.Min.Value;
return query.Where(p => p.UploadedAt < min);
}
if (node.Max.HasValue)
{
var max = node.Max.Value;
return query.Where(p => p.UploadedAt > max);
}
}
else
{
if (node.Min.HasValue)
{
var min = node.Min.Value;
query = query.Where(p => p.UploadedAt >= min);
}
if (node.Max.HasValue)
{
var max = node.Max.Value;
query = query.Where(p => p.UploadedAt <= max);
}
}
return query;
}
private IQueryable ApplyFileTypeFilter(IQueryable query, FileTypeFilterNode node)
{
var mimeType = node.MimeType;
if (node.Negated)
return query.Where(p => p.MimeType != mimeType);
return query.Where(p => p.MimeType == mimeType);
}
#endregion
#region Ordering
private static IQueryable ApplyOrdering(IQueryable query, OrderByNode? orderBy)
{
if (orderBy == null)
return query.OrderByDescending(p => p.UploadedAt).ThenByDescending(p => p.Id);
return orderBy.Field switch
{
"date" => orderBy.Descending
? query.OrderByDescending(p => p.UploadedAt).ThenByDescending(p => p.Id)
: query.OrderBy(p => p.UploadedAt).ThenBy(p => p.Id),
"id" => orderBy.Descending
? query.OrderByDescending(p => p.Id)
: query.OrderBy(p => p.Id),
"score" => orderBy.Descending
? query.OrderByDescending(p => p.Score).ThenByDescending(p => p.Id)
: query.OrderBy(p => p.Score).ThenBy(p => p.Id),
"filesize" => orderBy.Descending
? query.OrderByDescending(p => p.FileSize).ThenByDescending(p => p.Id)
: query.OrderBy(p => p.FileSize).ThenBy(p => p.Id),
"tagcount" => orderBy.Descending
? query.OrderByDescending(p => p.TagCount).ThenByDescending(p => p.Id)
: query.OrderBy(p => p.TagCount).ThenBy(p => p.Id),
"random" => query.OrderBy(_ => EF.Functions.Random()),
"controversial" => query
.OrderByDescending(p => Math.Log(1 + p.Votes.Count) * (1.0 - (double)Math.Abs(p.Score) / (p.Votes.Count + 2)))
.ThenByDescending(p => p.Votes.Count),
_ => query.OrderByDescending(p => p.UploadedAt).ThenByDescending(p => p.Id)
};
}
#endregion
}