Finished advanced search functionality
This commit is contained in:
@@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions {
|
||||
services.AddScoped<IContextExplorer, ContextExplorer>();
|
||||
services.TryAddScoped<IHopFrameAuthHandler, DefaultAuthHandler>();
|
||||
services.TryAddScoped<ICallbackEmitter, CallbackEmitter>();
|
||||
services.AddScoped<ISearchExpressionBuilder, SearchExpressionBuilder>();
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
9
src/HopFrame.Core/Services/ISearchExpressionBuilder.cs
Normal file
9
src/HopFrame.Core/Services/ISearchExpressionBuilder.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using HopFrame.Core.Config;
|
||||
|
||||
namespace HopFrame.Core.Services;
|
||||
|
||||
public interface ISearchExpressionBuilder {
|
||||
Expression? BuildSearchExpression(TableConfig table, string searchTerm, ParameterExpression parameter);
|
||||
}
|
||||
@@ -50,12 +50,12 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
|
||||
|
||||
if (context is DbContextConfig) {
|
||||
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
|
||||
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider) as ITableManager;
|
||||
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
|
||||
}
|
||||
|
||||
if (context is RepositoryGroupConfig repoConfig) {
|
||||
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
|
||||
return Activator.CreateInstance(type, repo, this, provider) as ITableManager;
|
||||
return Activator.CreateInstance(type, repo, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
|
||||
|
||||
if (context is DbContextConfig) {
|
||||
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
|
||||
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider) as ITableManager;
|
||||
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
|
||||
}
|
||||
|
||||
if (context is RepositoryGroupConfig repoConfig) {
|
||||
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
|
||||
return Activator.CreateInstance(type, repo, this, provider) as ITableManager;
|
||||
return Activator.CreateInstance(type, repo, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using HopFrame.Core.Repositories;
|
||||
|
||||
namespace HopFrame.Core.Services.Implementations;
|
||||
|
||||
public class RepositoryTableManager<TModel, TKey>(IHopFrameRepository<TModel, TKey> repo, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class {
|
||||
public class RepositoryTableManager<TModel, TKey>(IHopFrameRepository<TModel, TKey> repo, IContextExplorer explorer, IServiceProvider provider, ISearchExpressionBuilder searchExpressionBuilder) : ITableManager where TModel : class {
|
||||
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
|
||||
return await repo.LoadPage(page, perPage);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class RepositoryTableManager<TModel, TKey>(IHopFrameRepository<TModel, TK
|
||||
return await repo.GetOne((TKey)key);
|
||||
}
|
||||
public async Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
|
||||
var manager = new TableManager<TModel>(null!, null!, explorer, provider);
|
||||
var manager = new TableManager<TModel>(null!, null!, explorer, provider, searchExpressionBuilder);
|
||||
return await manager.DisplayProperty(item, prop, value, enumerableValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using HopFrame.Core.Config;
|
||||
|
||||
namespace HopFrame.Core.Services.Implementations;
|
||||
|
||||
internal sealed class SearchExpressionBuilder(IContextExplorer explorer) : ISearchExpressionBuilder {
|
||||
private readonly struct SearchPart {
|
||||
public string? Property { get; init; }
|
||||
public string Term { get; init; }
|
||||
public bool Negated { get; init; }
|
||||
}
|
||||
|
||||
private Expression AddPropertySearchExpression(PropertyInfo property, ParameterExpression parameter, string searchTerm, PropertyConfig config) {
|
||||
Expression propertyAccess = Expression.Property(parameter, property);
|
||||
|
||||
if (config.IsEnumerable) { //Call Count() extension method before checking the search term
|
||||
propertyAccess = Expression.Property(propertyAccess, config.Info.PropertyType.GetProperty(nameof(List<object>.Count))!);
|
||||
}
|
||||
|
||||
var toStringCall = Expression.Call(propertyAccess, nameof(ToString), Type.EmptyTypes);
|
||||
var searchExpression = Expression.Call(
|
||||
toStringCall,
|
||||
typeof(string).GetMethod(config.IsEnumerable ? nameof(string.Equals) : nameof(string.Contains), [typeof(string)])!,
|
||||
Expression.Constant(searchTerm));
|
||||
|
||||
return searchExpression;
|
||||
}
|
||||
|
||||
private Expression AddForeignPropertySearchExpression(PropertyInfo navigationProperty, PropertyInfo displayedProperty, ParameterExpression parameter, string searchTerm) {
|
||||
var navigationAccess = Expression.Property(parameter, navigationProperty);
|
||||
var nullCheck = Expression.NotEqual(navigationAccess, Expression.Constant(null));
|
||||
var displayedPropertyAccess = Expression.Property(navigationAccess, displayedProperty);
|
||||
|
||||
var toStringCall = Expression.Call(displayedPropertyAccess, nameof(ToString), Type.EmptyTypes);
|
||||
var searchExpression = Expression.Call(
|
||||
toStringCall,
|
||||
typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) })!,
|
||||
Expression.Constant(searchTerm));
|
||||
|
||||
return Expression.AndAlso(nullCheck, searchExpression);
|
||||
}
|
||||
|
||||
private IEnumerable<PropertyInfo> GetSuitableProperties(TableConfig table) {
|
||||
Type[] validTypes = [typeof(string), typeof(Guid), typeof(DateTime), typeof(DateOnly), typeof(TimeOnly)];
|
||||
|
||||
return table.Properties
|
||||
.Where(prop => !prop.IsVirtualProperty)
|
||||
.Where(prop => prop.List)
|
||||
.Where(prop => prop.Searchable)
|
||||
.Where(prop => prop.Info.PropertyType.IsEnum || validTypes.Contains(prop.Info.PropertyType) || prop.IsEnumerable)
|
||||
.Select(prop => prop.Info);
|
||||
}
|
||||
|
||||
private IEnumerable<(PropertyInfo navigation, PropertyInfo display)> GetSuitableForeignProperties(TableConfig table) {
|
||||
return table.Properties
|
||||
.Where(prop => prop.List)
|
||||
.Where(prop => prop.IsRelation)
|
||||
.Where(prop => prop.Searchable)
|
||||
.Where(prop => prop.DisplayedProperty != null)
|
||||
.Select(prop => (prop.Info, explorer
|
||||
.GetTable(prop.Info.PropertyType)!.Properties
|
||||
.Find(p => p.Info.Name == prop.DisplayedProperty!.Name)!
|
||||
.Info));
|
||||
}
|
||||
|
||||
private IEnumerable<SearchPart> ExtractSearchParts(string searchTerm) {
|
||||
var rawParts = searchTerm.Split(' ');
|
||||
var parts = new List<SearchPart>();
|
||||
|
||||
foreach (var part in rawParts) {
|
||||
if (string.IsNullOrWhiteSpace(part))
|
||||
continue;
|
||||
|
||||
if (!part.Contains('=')) {
|
||||
var negated = part.StartsWith('!');
|
||||
|
||||
parts.Add(new() {
|
||||
Term = negated ? part[1..] : part,
|
||||
Negated = negated,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var split = part.Split('=');
|
||||
var term = string.Join('=', split[1..]);
|
||||
var termNegated = term.StartsWith('!');
|
||||
|
||||
parts.Add(new() {
|
||||
Property = split[0],
|
||||
Term = termNegated ? term[1..] : term,
|
||||
Negated = termNegated
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public Expression? BuildSearchExpression(TableConfig table, string searchTerm, ParameterExpression parameter) {
|
||||
var properties = GetSuitableProperties(table).ToArray();
|
||||
var foreignProperties = GetSuitableForeignProperties(table).ToArray();
|
||||
|
||||
var parts = ExtractSearchParts(searchTerm);
|
||||
|
||||
Expression? expression = null;
|
||||
foreach (var part in parts) {
|
||||
Expression? subExp = null;
|
||||
|
||||
if (part.Property is null) {
|
||||
foreach (var property in properties) {
|
||||
var exp = AddPropertySearchExpression(property, parameter, part.Term, table.Properties.First(p => p.Info == property));
|
||||
subExp = subExp is null
|
||||
? exp
|
||||
: Expression.OrElse(subExp, exp);
|
||||
}
|
||||
|
||||
foreach (var property in foreignProperties) {
|
||||
var exp = AddForeignPropertySearchExpression(property.navigation, property.display, parameter, part.Term);
|
||||
subExp = subExp is null
|
||||
? exp
|
||||
: Expression.OrElse(subExp, exp);
|
||||
}
|
||||
|
||||
if (subExp is null)
|
||||
continue;
|
||||
}
|
||||
|
||||
var prop = properties.FirstOrDefault(p => p.Name == part.Property);
|
||||
if (prop is not null) {
|
||||
subExp = AddPropertySearchExpression(prop, parameter, part.Term, table.Properties.First(p => p.Info == prop));
|
||||
}
|
||||
|
||||
var forProp = foreignProperties.FirstOrDefault(p => p.navigation.Name == part.Property);
|
||||
if (forProp.navigation is not null) {
|
||||
subExp = AddForeignPropertySearchExpression(forProp.navigation, forProp.display, parameter, part.Term);
|
||||
}
|
||||
|
||||
if (subExp is null)
|
||||
continue;
|
||||
|
||||
if (part.Negated)
|
||||
subExp = Expression.Not(subExp);
|
||||
|
||||
expression = expression is null
|
||||
? subExp
|
||||
: Expression.AndAlso(expression, subExp);
|
||||
}
|
||||
|
||||
return expression;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Reflection.Metadata;
|
||||
using HopFrame.Core.Config;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -8,7 +9,7 @@ using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
namespace HopFrame.Core.Services.Implementations;
|
||||
|
||||
internal sealed class TableManager<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class {
|
||||
internal sealed class TableManager<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider, ISearchExpressionBuilder searchExpressionBuilder) : ITableManager where TModel : class {
|
||||
|
||||
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
@@ -22,70 +23,13 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
|
||||
public async Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
|
||||
var isNegative = searchTerm.StartsWith('!');
|
||||
if (isNegative)
|
||||
searchTerm = searchTerm[1..];
|
||||
|
||||
Type[] validTypes = [typeof(string), typeof(Guid)];
|
||||
|
||||
var parameter = Expression.Parameter(typeof(TModel), "x");
|
||||
var properties = config.Properties
|
||||
.Where(prop => !prop.IsVirtualProperty)
|
||||
.Where(prop => prop.List)
|
||||
.Where(prop => prop.Info.PropertyType.IsEnum || validTypes.Contains(prop.Info.PropertyType))
|
||||
.Select(prop => prop.Info);
|
||||
|
||||
Expression? combinedExpression = null;
|
||||
var exp = searchExpressionBuilder.BuildSearchExpression(config, searchTerm, parameter);
|
||||
|
||||
foreach (var property in properties) {
|
||||
var propertyAccess = Expression.Property(parameter, property);
|
||||
var toStringCall = Expression.Call(propertyAccess, nameof(ToString), Type.EmptyTypes);
|
||||
var searchExpression = Expression.Call(
|
||||
toStringCall,
|
||||
typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!,
|
||||
Expression.Constant(searchTerm));
|
||||
|
||||
combinedExpression = combinedExpression == null
|
||||
? searchExpression
|
||||
: Expression.OrElse(combinedExpression, searchExpression);
|
||||
}
|
||||
|
||||
var foreignProperties = config.Properties
|
||||
.Where(prop => prop.List)
|
||||
.Where(prop => prop.IsRelation)
|
||||
.Where(prop => prop.DisplayedProperty != null)
|
||||
.Select(prop => (prop.Info, explorer
|
||||
.GetTable(prop.Info.PropertyType)!.Properties
|
||||
.Find(p => p.Info.Name == prop.DisplayedProperty!.Name)!
|
||||
.Info));
|
||||
if (exp is null)
|
||||
return ([], 0);
|
||||
|
||||
foreach (var (navigationProperty, displayedProperty) in foreignProperties) {
|
||||
var navigationAccess = Expression.Property(parameter, navigationProperty);
|
||||
var nullCheck = Expression.NotEqual(navigationAccess, Expression.Constant(null));
|
||||
var displayedPropertyAccess = Expression.Property(navigationAccess, displayedProperty);
|
||||
|
||||
var toStringCall = Expression.Call(displayedPropertyAccess, nameof(ToString), Type.EmptyTypes);
|
||||
var searchExpression = Expression.Call(
|
||||
toStringCall,
|
||||
typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) })!,
|
||||
Expression.Constant(searchTerm));
|
||||
|
||||
var safeSearchExpression = Expression.AndAlso(nullCheck, searchExpression);
|
||||
|
||||
combinedExpression = combinedExpression == null
|
||||
? safeSearchExpression
|
||||
: Expression.OrElse(combinedExpression, safeSearchExpression);
|
||||
}
|
||||
|
||||
if (combinedExpression == null)
|
||||
return (
|
||||
await LoadPage(page, perPage),
|
||||
await TotalPages(perPage));
|
||||
|
||||
if (isNegative)
|
||||
combinedExpression = Expression.Not(combinedExpression);
|
||||
|
||||
var lambda = Expression.Lambda<Func<TModel, bool>>(combinedExpression, parameter);
|
||||
var lambda = Expression.Lambda<Func<TModel, bool>>(exp, parameter);
|
||||
var result = await IncludeForeignKeys(table)
|
||||
.Where(lambda)
|
||||
.Skip(page * perPage)
|
||||
@@ -98,7 +42,7 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
|
||||
|
||||
return (result, (int)Math.Ceiling(totalEntries / (double)perPage));
|
||||
}
|
||||
|
||||
|
||||
public async Task<int> TotalPages(int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
return (int)Math.Ceiling(await table.CountAsync() / (double)perPage);
|
||||
|
||||
@@ -300,12 +300,17 @@
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private void SearchSuggestionSelected(string? suggestion) {
|
||||
private async Task SearchSuggestionSelected(string? suggestion) {
|
||||
if (string.IsNullOrWhiteSpace(suggestion)) return;
|
||||
_searchTerm = SearchSuggestions.CompleteSearchSuggestion(_config!, _searchTerm ?? string.Empty, suggestion);
|
||||
_searchBox!.Value = _searchTerm;
|
||||
_searchBox.FocusAsync();
|
||||
UpdateSearchSuggestions();
|
||||
|
||||
if (!suggestion.EndsWith('='))
|
||||
await OnSearch(new() {
|
||||
Value = _searchTerm
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateSearchSuggestions() {
|
||||
|
||||
@@ -4,7 +4,7 @@ using HopFrame.Web.Helpers;
|
||||
|
||||
namespace HopFrame.Web.Services.Implementation;
|
||||
|
||||
public sealed class SearchSuggestionProvider(IContextExplorer explorer, IServiceProvider provider) : ISearchSuggestionProvider {
|
||||
public sealed class SearchSuggestionProvider : ISearchSuggestionProvider {
|
||||
|
||||
public IEnumerable<string> GenerateSearchSuggestions(TableConfig table, string searchText) {
|
||||
if (table.ContextConfig is not DbContextConfig) return [];
|
||||
@@ -27,15 +27,6 @@ public sealed class SearchSuggestionProvider(IContextExplorer explorer, IService
|
||||
|
||||
if (property.Info.PropertyType == typeof(TimeOnly))
|
||||
return [TimeOnly.FromDateTime(DateTime.Now).ToString()];
|
||||
|
||||
if (property.IsRelation) {
|
||||
var manager = explorer.GetTableManager(table.TableType);
|
||||
var entries = manager!.LoadPage(0, 100).Result;
|
||||
return entries
|
||||
.Select(e => manager.DisplayProperty(e, property).Result)
|
||||
.Distinct()
|
||||
.Take(10);
|
||||
}
|
||||
}
|
||||
|
||||
if (searchText.Length != 0 && !searchText.EndsWith(' '))
|
||||
@@ -45,6 +36,7 @@ public sealed class SearchSuggestionProvider(IContextExplorer explorer, IService
|
||||
var searchableProperties = table.Properties
|
||||
.Where(p => !p.IsVirtualProperty)
|
||||
.Where(p => p.List)
|
||||
.Where(p => p.Searchable)
|
||||
.Where(p =>
|
||||
p.Info.PropertyType.IsEnum ||
|
||||
p.Info.PropertyType.IsNumeric() ||
|
||||
|
||||
Reference in New Issue
Block a user