diff --git a/Oqtane.Server/Extensions/QueryableExtensions.cs b/Oqtane.Server/Extensions/QueryableExtensions.cs new file mode 100644 index 00000000..f0658b55 --- /dev/null +++ b/Oqtane.Server/Extensions/QueryableExtensions.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Linq; +using System; + +namespace Oqtane.Extensions +{ + public static class QueryableExtensions + { + public static IQueryable FilterByItems(this IQueryable query, IEnumerable items, + Expression> filterPattern, bool isOr) + { + Expression predicate = null; + foreach (var item in items) + { + var itemExpr = Expression.Constant(item); + var itemCondition = ExpressionReplacer.Replace(filterPattern.Body, filterPattern.Parameters[1], itemExpr); + if (predicate == null) + predicate = itemCondition; + else + { + predicate = Expression.MakeBinary(isOr ? ExpressionType.OrElse : ExpressionType.AndAlso, predicate, + itemCondition); + } + } + + predicate ??= Expression.Constant(false); + var filterLambda = Expression.Lambda>(predicate, filterPattern.Parameters[0]); + + return query.Where(filterLambda); + } + + class ExpressionReplacer : ExpressionVisitor + { + readonly IDictionary _replaceMap; + + public ExpressionReplacer(IDictionary replaceMap) + { + _replaceMap = replaceMap ?? throw new ArgumentNullException(nameof(replaceMap)); + } + + [return: NotNullIfNotNull(nameof(node))] + public override Expression Visit(Expression node) + { + if (node != null && _replaceMap.TryGetValue(node, out var replacement)) + return replacement; + return base.Visit(node); + } + + public static Expression Replace(Expression expr, Expression toReplace, Expression toExpr) + { + return new ExpressionReplacer(new Dictionary { { toReplace, toExpr } }).Visit(expr); + } + + public static Expression Replace(Expression expr, IDictionary replaceMap) + { + return new ExpressionReplacer(replaceMap).Visit(expr); + } + + public static Expression GetBody(LambdaExpression lambda, params Expression[] toReplace) + { + if (lambda.Parameters.Count != toReplace.Length) + throw new InvalidOperationException(); + + return new ExpressionReplacer(Enumerable.Range(0, lambda.Parameters.Count) + .ToDictionary(i => (Expression)lambda.Parameters[i], i => toReplace[i])).Visit(lambda.Body); + } + } + } +} diff --git a/Oqtane.Server/Repository/SearchContentRepository.cs b/Oqtane.Server/Repository/SearchContentRepository.cs index 380afd66..53d736a5 100644 --- a/Oqtane.Server/Repository/SearchContentRepository.cs +++ b/Oqtane.Server/Repository/SearchContentRepository.cs @@ -4,6 +4,9 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Oqtane.Extensions; +using Oqtane.Infrastructure; using Oqtane.Models; using Oqtane.Shared; @@ -24,79 +27,80 @@ namespace Oqtane.Repository var keywords = SearchUtils.GetKeywords(searchQuery.Keywords); - // using dynamic SQL for query performance (this could be replaced with linq if the exact query structure can be replicated) - var parameters = new List(); - parameters.Add(searchQuery.SiteId); - - var query = "SELECT sc.*, Count "; - query += "FROM ( "; - query += "SELECT sc.SearchContentId, SUM(Count) AS Count "; - query += "FROM SearchContent sc "; - query += "INNER JOIN SearchContentWord scw ON sc.SearchContentId = scw.SearchContentId "; - query += "INNER JOIN SearchWord sw ON scw.SearchWordId = sw.SearchWordId "; - query += "WHERE sc.SiteId = {0} "; - if (keywords.Count > 0) - { - query += "AND ( "; - for (int index = 0; index < keywords.Count; index++) + var searchContents = db.SearchContentWord + .AsNoTracking() + .Include(item => item.SearchContent) + .Include(item => item.SearchWord) + .Where(item => item.SearchContent.SiteId == searchQuery.SiteId) + .FilterByItems(keywords, (item, keyword) => item.SearchWord.Word.StartsWith(keyword), true) + .GroupBy(item => new { - query += (index == 0 ? "" : "OR ") + "Word LIKE {" + parameters.Count + "} "; - parameters.Add(keywords[index] + "%"); - } - query += " ) "; - } - query += "GROUP BY sc.SearchContentId "; - query += ") AS Scores "; - query += "INNER JOIN SearchContent sc ON sc.SearchContentId = Scores.SearchContentId "; + item.SearchContent.SearchContentId, + item.SearchContent.SiteId, + item.SearchContent.EntityName, + item.SearchContent.EntityId, + item.SearchContent.Title, + item.SearchContent.Description, + item.SearchContent.Body, + item.SearchContent.Url, + item.SearchContent.Permissions, + item.SearchContent.ContentModifiedBy, + item.SearchContent.ContentModifiedOn, + item.SearchContent.AdditionalContent, + item.SearchContent.CreatedOn + }) + .Select(result => new SearchContent + { + SearchContentId = result.Key.SearchContentId, + SiteId = result.Key.SiteId, + EntityName = result.Key.EntityName, + EntityId = result.Key.EntityId, + Title = result.Key.Title, + Description = result.Key.Description, + Body = result.Key.Body, + Url = result.Key.Url, + Permissions = result.Key.Permissions, + ContentModifiedBy = result.Key.ContentModifiedBy, + ContentModifiedOn = result.Key.ContentModifiedOn, + AdditionalContent = result.Key.AdditionalContent, + CreatedOn = result.Key.CreatedOn, + Count = result.Sum(group => group.Count) + }); + if (searchQuery.Properties != null && searchQuery.Properties.Any()) { - query += "LEFT JOIN SearchContentProperty scp ON sc.SearchContentId = scp.SearchContentId "; + searchContents = searchContents.Include(item => item.SearchContentProperties); } - query += "WHERE sc.SiteId = {0} "; + if (!string.IsNullOrEmpty(searchQuery.IncludeEntities)) { - query += "AND sc.EntityName IN ( "; - var entities = searchQuery.IncludeEntities.Split(',', StringSplitOptions.RemoveEmptyEntries); - for (int index = 0; index < entities.Length; index++) - { - query += (index == 0 ? "" : ", ") + "{" + parameters.Count + "} "; - parameters.Add(entities[index]); - } - query += " ) "; + searchContents = searchContents.Where(item => searchQuery.IncludeEntities.Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(item.EntityName)); } + if (!string.IsNullOrEmpty(searchQuery.ExcludeEntities)) { - query += "AND sc.EntityName NOT IN ( "; - var entities = searchQuery.ExcludeEntities.Split(',', StringSplitOptions.RemoveEmptyEntries); - for (int index = 0; index < entities.Length; index++) - { - query += (index == 0 ? "" : ", ") + "{" + parameters.Count + "} "; - parameters.Add(entities[index]); - } - query += " ) "; + searchContents = searchContents.Where(item => !searchQuery.ExcludeEntities.Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(item.EntityName)); } - if (searchQuery.FromDate.ToString() != DateTime.MinValue.ToString()) + + if (searchQuery.FromDate.Date != DateTime.MinValue.Date) { - query += "AND sc.ContentModifiedOn >= {" + parameters.Count + "} "; - parameters.Add(searchQuery.FromDate); + searchContents = searchContents.Where(item => item.ContentModifiedOn >= searchQuery.FromDate); } - if (searchQuery.ToDate.ToString() != DateTime.MaxValue.ToString()) + + if (searchQuery.ToDate.Date != DateTime.MaxValue.Date) { - query += "AND sc.ContentModifiedOn <= {" + parameters.Count + "} "; - parameters.Add(searchQuery.ToDate); + searchContents = searchContents.Where(item => item.ContentModifiedOn <= searchQuery.ToDate); } + if (searchQuery.Properties != null && searchQuery.Properties.Any()) { foreach (var property in searchQuery.Properties) { - query += "AND ( scp.Key = {" + parameters.Count + "} "; - parameters.Add(property.Key); - query += "AND scp.Value = {" + parameters.Count + "} ) "; - parameters.Add(property.Value); + searchContents = searchContents.Where(item => item.SearchContentProperties.Any(p => p.Name == property.Key && p.Value == property.Value)); } } - return await db.SearchContent.FromSql(FormattableStringFactory.Create(query, parameters.ToArray())).ToListAsync(); + return await searchContents.ToListAsync(); } public SearchContent AddSearchContent(SearchContent searchContent) diff --git a/Oqtane.Shared/Models/SearchContentWord.cs b/Oqtane.Shared/Models/SearchContentWord.cs index e5c7a092..6361c328 100644 --- a/Oqtane.Shared/Models/SearchContentWord.cs +++ b/Oqtane.Shared/Models/SearchContentWord.cs @@ -20,5 +20,7 @@ namespace Oqtane.Models public DateTime ModifiedOn { get; set; } public SearchWord SearchWord { get; set; } + + public SearchContent SearchContent { get; set; } } }