Finished advanced search functionality
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="RiderProjectSettingsUpdater">
|
||||
<option name="singleClickDiffPreview" value="1" />
|
||||
<option name="unhandledExceptionsIgnoreList" value="1" />
|
||||
<option name="vcsConfiguration" value="3" />
|
||||
</component>
|
||||
</project>
|
||||
116
.idea/.idea.HopFrame/.idea/workspace.xml
generated
116
.idea/.idea.HopFrame/.idea/workspace.xml
generated
@@ -12,13 +12,19 @@
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="">
|
||||
<change afterPath="$PROJECT_DIR$/src/HopFrame.Web/Services/ISearchSuggestionProvider.cs" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/HopFrame.Web/Services/Implementation/SearchSuggestionProvider.cs" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/ISearchExpressionBuilder.cs" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/SearchExpressionBuilder.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/projectSettingsUpdater.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/projectSettingsUpdater.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/ServiceCollectionExtensions.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/ServiceCollectionExtensions.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/RepositoryTableManager.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/RepositoryTableManager.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/TableManager.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/TableManager.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/ServiceCollectionExtensions.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/ServiceCollectionExtensions.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/User.cs" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/User.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Services/Implementation/SearchSuggestionProvider.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Services/Implementation/SearchSuggestionProvider.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/ContextExplorerTests.cs" beforeDir="false" afterPath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/ContextExplorerTests.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/DisplayPropertyTests.cs" beforeDir="false" afterPath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/DisplayPropertyTests.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/TableManagerTests.cs" beforeDir="false" afterPath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/TableManagerTests.cs" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -117,7 +123,7 @@
|
||||
"associatedIndex": 3
|
||||
}</component>
|
||||
<component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" />
|
||||
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
|
||||
<component name="ProjectLevelVcsManager">
|
||||
<OptionsSetting value="false" id="Update" />
|
||||
<ConfirmationsSetting value="2" id="Add" />
|
||||
</component>
|
||||
@@ -125,28 +131,28 @@
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
".NET Launch Settings Profile.HopFrame.Testing.Api: https.executor": "Run",
|
||||
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
|
||||
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
|
||||
".NET Project.HopFrame.Testing.executor": "Run",
|
||||
"72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
|
||||
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
|
||||
"git-widget-placeholder": "!35 on feature/advanced-search",
|
||||
"list.type.of.created.stylesheet": "CSS",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
".NET Launch Settings Profile.HopFrame.Testing.Api: https.executor": "Run",
|
||||
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
|
||||
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
|
||||
".NET Project.HopFrame.Testing.executor": "Run",
|
||||
"72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
|
||||
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
|
||||
"git-widget-placeholder": "!35 on feature/advanced-search",
|
||||
"list.type.of.created.stylesheet": "CSS",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</component>
|
||||
}]]></component>
|
||||
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
|
||||
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
|
||||
@@ -272,15 +278,8 @@
|
||||
<workItem from="1744725284649" duration="60000" />
|
||||
<workItem from="1744916016381" duration="66000" />
|
||||
<workItem from="1744916106166" duration="49000" />
|
||||
<workItem from="1744966207145" duration="5064000" />
|
||||
</task>
|
||||
<task id="LOCAL-00004" summary="Started working on listing page">
|
||||
<option name="closed" value="true" />
|
||||
<created>1736885531216</created>
|
||||
<option name="number" value="00004" />
|
||||
<option name="presentableId" value="LOCAL-00004" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1736885531216</updated>
|
||||
<workItem from="1744966207145" duration="5231000" />
|
||||
<workItem from="1751713720880" duration="7160000" />
|
||||
</task>
|
||||
<task id="LOCAL-00005" summary="Added entry saving support">
|
||||
<option name="closed" value="true" />
|
||||
@@ -666,7 +665,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1742063374318</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="53" />
|
||||
<task id="LOCAL-00053" summary="Started working on search suggestions">
|
||||
<option name="closed" value="true" />
|
||||
<created>1744971440348</created>
|
||||
<option name="number" value="00053" />
|
||||
<option name="presentableId" value="LOCAL-00053" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1744971440348</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="54" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -696,7 +703,6 @@
|
||||
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
|
||||
<component name="VcsManagerConfiguration">
|
||||
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
||||
<MESSAGE value="Added n-m relation mapping" />
|
||||
<MESSAGE value="Fixed wrong element selection for action buttons" />
|
||||
<MESSAGE value="Implemented primitive change reversion" />
|
||||
<MESSAGE value="Implemented deferred entry manipulation" />
|
||||
@@ -721,6 +727,38 @@
|
||||
<MESSAGE value="Added support for custom repositories" />
|
||||
<MESSAGE value="Added documentation for custom repos and exporter plugin" />
|
||||
<MESSAGE value="Implemented sql search + negatable searches" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Implemented sql search + negatable searches" />
|
||||
<MESSAGE value="Started working on search suggestions" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Started working on search suggestions" />
|
||||
</component>
|
||||
<component name="XDebuggerManager">
|
||||
<breakpoint-manager>
|
||||
<breakpoints>
|
||||
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
|
||||
<properties exception="System.OperationCanceledException" breakIfHandledByOtherCode="false" displayValue="System.OperationCanceledException" />
|
||||
<option name="timeStamp" value="1" />
|
||||
</breakpoint>
|
||||
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
|
||||
<properties exception="System.Threading.Tasks.TaskCanceledException" breakIfHandledByOtherCode="false" displayValue="System.Threading.Tasks.TaskCanceledException" />
|
||||
<option name="timeStamp" value="2" />
|
||||
</breakpoint>
|
||||
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
|
||||
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
|
||||
<option name="timeStamp" value="3" />
|
||||
</breakpoint>
|
||||
<line-breakpoint enabled="true" type="DotNet Breakpoints">
|
||||
<url>file://$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/SearchExpressionBuilder.cs</url>
|
||||
<line>102</line>
|
||||
<properties documentPath="C:\Users\leon\Documents\Projekte\HopFrame\src\HopFrame.Core\Services\Implementations\SearchExpressionBuilder.cs" containingFunctionPresentation="Method 'BuildSearchExpression'">
|
||||
<startOffsets>
|
||||
<option value="4460" />
|
||||
</startOffsets>
|
||||
<endOffsets>
|
||||
<option value="4503" />
|
||||
</endOffsets>
|
||||
</properties>
|
||||
<option name="timeStamp" value="12" />
|
||||
</line-breakpoint>
|
||||
</breakpoints>
|
||||
</breakpoint-manager>
|
||||
</component>
|
||||
</project>
|
||||
@@ -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);
|
||||
var exp = searchExpressionBuilder.BuildSearchExpression(config, searchTerm, parameter);
|
||||
|
||||
Expression? combinedExpression = null;
|
||||
if (exp is null)
|
||||
return ([], 0);
|
||||
|
||||
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));
|
||||
|
||||
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)
|
||||
|
||||
@@ -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() ||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using HopFrame.Core.Config;
|
||||
using HopFrame.Core.Services;
|
||||
using HopFrame.Core.Services.Implementations;
|
||||
using HopFrame.Tests.Core.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
@@ -121,6 +123,7 @@ public class ContextExplorerTests {
|
||||
var dbContext = new MockDbContext();
|
||||
var provider = new Mock<IServiceProvider>();
|
||||
provider.Setup(p => p.GetService(typeof(MockDbContext))).Returns(dbContext);
|
||||
provider.Setup(p => p.GetService(typeof(ISearchExpressionBuilder))).Returns(new Mock<ISearchExpressionBuilder>().Object);
|
||||
var contextExplorer = new ContextExplorer(config, provider.Object, new Logger<ContextExplorer>(new LoggerFactory()));
|
||||
|
||||
// Act
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DisplayPropertyTests {
|
||||
_explorerMock = new Mock<IContextExplorer>();
|
||||
_config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||
_tableManager =
|
||||
new TableManager<object>(contextMock.Object, _config, _explorerMock.Object, _providerMock.Object);
|
||||
new TableManager<object>(contextMock.Object, _config, _explorerMock.Object, _providerMock.Object, new SearchExpressionBuilder(_explorerMock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -10,6 +10,9 @@ using Moq;
|
||||
namespace HopFrame.Tests.Core.Services;
|
||||
|
||||
public class TableManagerTests {
|
||||
|
||||
private Mock<ISearchExpressionBuilder> _searchBuilderMock = new();
|
||||
|
||||
private Mock<DbContext> CreateMockDbContext<TModel>(List<TModel> data) where TModel : class {
|
||||
var dbContext = new Mock<DbContext>();
|
||||
var dbSet = CreateMockDbSet(data);
|
||||
@@ -51,7 +54,7 @@ public class TableManagerTests {
|
||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||
var explorer = new Mock<IContextExplorer>();
|
||||
var provider = new Mock<IServiceProvider>();
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||
|
||||
// Act
|
||||
var result = (await manager.LoadPage(1, 2)).ToArray();
|
||||
@@ -61,32 +64,6 @@ public class TableManagerTests {
|
||||
Assert.Equal("Item3", ((MockModel)result[0]).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_ReturnsMatchingData() {
|
||||
// Arrange
|
||||
var data = new List<MockModel> {
|
||||
new MockModel { Id = 1, Name = "Item1" },
|
||||
new MockModel { Id = 2, Name = "Item2" },
|
||||
new MockModel { Id = 3, Name = "TestItem" }
|
||||
};
|
||||
var dbContext = CreateMockDbContext(data);
|
||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||
config.Properties.Add(new PropertyConfig(typeof(MockModel).GetProperty("Name")!, config, 0)
|
||||
{ Searchable = true });
|
||||
var explorer = new Mock<IContextExplorer>();
|
||||
var provider = new Mock<IServiceProvider>();
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
||||
|
||||
// Act
|
||||
var (result, totalPages) = await manager.Search("Test", 0, 2);
|
||||
|
||||
// Assert
|
||||
var collection = result as object[] ?? result.ToArray();
|
||||
Assert.Single(collection);
|
||||
Assert.Equal("TestItem", ((MockModel)collection.First()).Name);
|
||||
Assert.Equal(1, totalPages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TotalPages_ReturnsCorrectPageCount() {
|
||||
// Arrange
|
||||
@@ -99,7 +76,7 @@ public class TableManagerTests {
|
||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||
var explorer = new Mock<IContextExplorer>();
|
||||
var provider = new Mock<IServiceProvider>();
|
||||
var manager = new TableManager<MockModel>(dbContext, config, explorer.Object, provider.Object);
|
||||
var manager = new TableManager<MockModel>(dbContext, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||
await dbContext.Models.AddRangeAsync(data);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
@@ -121,7 +98,7 @@ public class TableManagerTests {
|
||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||
var explorer = new Mock<IContextExplorer>();
|
||||
var provider = new Mock<IServiceProvider>();
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||
var item = data.First();
|
||||
|
||||
// Act
|
||||
@@ -142,7 +119,7 @@ public class TableManagerTests {
|
||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||
var explorer = new Mock<IContextExplorer>();
|
||||
var provider = new Mock<IServiceProvider>();
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||
|
||||
// Act
|
||||
await manager.EditItem(data.First());
|
||||
@@ -159,7 +136,7 @@ public class TableManagerTests {
|
||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||
var explorer = new Mock<IContextExplorer>();
|
||||
var provider = new Mock<IServiceProvider>();
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||
var newItem = new MockModel { Id = 3, Name = "NewItem" };
|
||||
|
||||
// Act
|
||||
|
||||
Reference in New Issue
Block a user