Finished advanced search functionality

This commit is contained in:
2025-07-05 15:11:02 +02:00
parent 68a4479c2d
commit 66d03513eb
13 changed files with 272 additions and 151 deletions

View File

@@ -2,6 +2,7 @@
<project version="4"> <project version="4">
<component name="RiderProjectSettingsUpdater"> <component name="RiderProjectSettingsUpdater">
<option name="singleClickDiffPreview" value="1" /> <option name="singleClickDiffPreview" value="1" />
<option name="unhandledExceptionsIgnoreList" value="1" />
<option name="vcsConfiguration" value="3" /> <option name="vcsConfiguration" value="3" />
</component> </component>
</project> </project>

View File

@@ -12,13 +12,19 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment=""> <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.Core/Services/ISearchExpressionBuilder.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/HopFrame.Web/Services/Implementation/SearchSuggestionProvider.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$/.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" 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/Services/Implementation/SearchSuggestionProvider.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Services/Implementation/SearchSuggestionProvider.cs" 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$/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$/testing/HopFrame.Testing/Models/User.cs" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/User.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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -117,7 +123,7 @@
&quot;associatedIndex&quot;: 3 &quot;associatedIndex&quot;: 3
}</component> }</component>
<component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" /> <component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true"> <component name="ProjectLevelVcsManager">
<OptionsSetting value="false" id="Update" /> <OptionsSetting value="false" id="Update" />
<ConfirmationsSetting value="2" id="Add" /> <ConfirmationsSetting value="2" id="Add" />
</component> </component>
@@ -125,28 +131,28 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;.NET Launch Settings Profile.HopFrame.Testing.Api: https.executor&quot;: &quot;Run&quot;, ".NET Launch Settings Profile.HopFrame.Testing.Api: https.executor": "Run",
&quot;.NET Launch Settings Profile.HopFrame.Testing.executor&quot;: &quot;Run&quot;, ".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
&quot;.NET Launch Settings Profile.HopFrame.Testing: https.executor&quot;: &quot;Run&quot;, ".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
&quot;.NET Project.HopFrame.Testing.executor&quot;: &quot;Run&quot;, ".NET Project.HopFrame.Testing.executor": "Run",
&quot;72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor&quot;: &quot;Debug&quot;, "72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;b5f11219-dfc4-47a1-b02c-90ab603034fb.executor&quot;: &quot;Debug&quot;, "b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
&quot;dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor&quot;: &quot;Debug&quot;, "dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
&quot;git-widget-placeholder&quot;: &quot;!35 on feature/advanced-search&quot;, "git-widget-placeholder": "!35 on feature/advanced-search",
&quot;list.type.of.created.stylesheet&quot;: &quot;CSS&quot;, "list.type.of.created.stylesheet": "CSS",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, "node.js.detected.package.tslint": "true",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "nodejs_package_manager_path": "npm",
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;, "settings.editor.selected.configurable": "preferences.pluginManager",
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot; "vue.rearranger.settings.migration": "true"
} }
}</component> }]]></component>
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https"> <component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile"> <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" /> <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="1744725284649" duration="60000" />
<workItem from="1744916016381" duration="66000" /> <workItem from="1744916016381" duration="66000" />
<workItem from="1744916106166" duration="49000" /> <workItem from="1744916106166" duration="49000" />
<workItem from="1744966207145" duration="5064000" /> <workItem from="1744966207145" duration="5231000" />
</task> <workItem from="1751713720880" duration="7160000" />
<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>
</task> </task>
<task id="LOCAL-00005" summary="Added entry saving support"> <task id="LOCAL-00005" summary="Added entry saving support">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -666,7 +665,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1742063374318</updated> <updated>1742063374318</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -696,7 +703,6 @@
<component name="UnityProjectConfiguration" hasMinimizedUI="false" /> <component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" /> <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="Fixed wrong element selection for action buttons" />
<MESSAGE value="Implemented primitive change reversion" /> <MESSAGE value="Implemented primitive change reversion" />
<MESSAGE value="Implemented deferred entry manipulation" /> <MESSAGE value="Implemented deferred entry manipulation" />
@@ -721,6 +727,38 @@
<MESSAGE value="Added support for custom repositories" /> <MESSAGE value="Added support for custom repositories" />
<MESSAGE value="Added documentation for custom repos and exporter plugin" /> <MESSAGE value="Added documentation for custom repos and exporter plugin" />
<MESSAGE value="Implemented sql search + negatable searches" /> <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> </component>
</project> </project>

View File

@@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions {
services.AddScoped<IContextExplorer, ContextExplorer>(); services.AddScoped<IContextExplorer, ContextExplorer>();
services.TryAddScoped<IHopFrameAuthHandler, DefaultAuthHandler>(); services.TryAddScoped<IHopFrameAuthHandler, DefaultAuthHandler>();
services.TryAddScoped<ICallbackEmitter, CallbackEmitter>(); services.TryAddScoped<ICallbackEmitter, CallbackEmitter>();
services.AddScoped<ISearchExpressionBuilder, SearchExpressionBuilder>();
return services; return services;
} }

View 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);
}

View File

@@ -50,12 +50,12 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
if (context is DbContextConfig) { if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType); 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) { if (context is RepositoryGroupConfig repoConfig) {
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType); 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) { if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType); 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) { if (context is RepositoryGroupConfig repoConfig) {
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType); 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;
} }
} }

View File

@@ -3,7 +3,7 @@ using HopFrame.Core.Repositories;
namespace HopFrame.Core.Services.Implementations; 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) { public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
return await repo.LoadPage(page, perPage); return await repo.LoadPage(page, perPage);
} }
@@ -34,7 +34,7 @@ public class RepositoryTableManager<TModel, TKey>(IHopFrameRepository<TModel, TK
return await repo.GetOne((TKey)key); return await repo.GetOne((TKey)key);
} }
public async Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) { 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); return await manager.DisplayProperty(item, prop, value, enumerableValue);
} }
} }

View File

@@ -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;
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections; using System.Collections;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Metadata; using System.Reflection.Metadata;
using HopFrame.Core.Config; using HopFrame.Core.Config;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -8,7 +9,7 @@ using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace HopFrame.Core.Services.Implementations; 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) { public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
var table = context.Set<TModel>(); 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) { public async Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
var table = context.Set<TModel>(); 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 parameter = Expression.Parameter(typeof(TModel), "x");
var properties = config.Properties var exp = searchExpressionBuilder.BuildSearchExpression(config, searchTerm, parameter);
.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; if (exp is null)
return ([], 0);
foreach (var property in properties) { var lambda = Expression.Lambda<Func<TModel, bool>>(exp, parameter);
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 result = await IncludeForeignKeys(table) var result = await IncludeForeignKeys(table)
.Where(lambda) .Where(lambda)
.Skip(page * perPage) .Skip(page * perPage)

View File

@@ -300,12 +300,17 @@
await Reload(); await Reload();
} }
private void SearchSuggestionSelected(string? suggestion) { private async Task SearchSuggestionSelected(string? suggestion) {
if (string.IsNullOrWhiteSpace(suggestion)) return; if (string.IsNullOrWhiteSpace(suggestion)) return;
_searchTerm = SearchSuggestions.CompleteSearchSuggestion(_config!, _searchTerm ?? string.Empty, suggestion); _searchTerm = SearchSuggestions.CompleteSearchSuggestion(_config!, _searchTerm ?? string.Empty, suggestion);
_searchBox!.Value = _searchTerm; _searchBox!.Value = _searchTerm;
_searchBox.FocusAsync(); _searchBox.FocusAsync();
UpdateSearchSuggestions(); UpdateSearchSuggestions();
if (!suggestion.EndsWith('='))
await OnSearch(new() {
Value = _searchTerm
});
} }
private void UpdateSearchSuggestions() { private void UpdateSearchSuggestions() {

View File

@@ -4,7 +4,7 @@ using HopFrame.Web.Helpers;
namespace HopFrame.Web.Services.Implementation; 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) { public IEnumerable<string> GenerateSearchSuggestions(TableConfig table, string searchText) {
if (table.ContextConfig is not DbContextConfig) return []; if (table.ContextConfig is not DbContextConfig) return [];
@@ -27,15 +27,6 @@ public sealed class SearchSuggestionProvider(IContextExplorer explorer, IService
if (property.Info.PropertyType == typeof(TimeOnly)) if (property.Info.PropertyType == typeof(TimeOnly))
return [TimeOnly.FromDateTime(DateTime.Now).ToString()]; 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(' ')) if (searchText.Length != 0 && !searchText.EndsWith(' '))
@@ -45,6 +36,7 @@ public sealed class SearchSuggestionProvider(IContextExplorer explorer, IService
var searchableProperties = table.Properties var searchableProperties = table.Properties
.Where(p => !p.IsVirtualProperty) .Where(p => !p.IsVirtualProperty)
.Where(p => p.List) .Where(p => p.List)
.Where(p => p.Searchable)
.Where(p => .Where(p =>
p.Info.PropertyType.IsEnum || p.Info.PropertyType.IsEnum ||
p.Info.PropertyType.IsNumeric() || p.Info.PropertyType.IsNumeric() ||

View File

@@ -1,6 +1,8 @@
using HopFrame.Core.Config; using HopFrame.Core.Config;
using HopFrame.Core.Services;
using HopFrame.Core.Services.Implementations; using HopFrame.Core.Services.Implementations;
using HopFrame.Tests.Core.Models; using HopFrame.Tests.Core.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moq; using Moq;
@@ -121,6 +123,7 @@ public class ContextExplorerTests {
var dbContext = new MockDbContext(); var dbContext = new MockDbContext();
var provider = new Mock<IServiceProvider>(); var provider = new Mock<IServiceProvider>();
provider.Setup(p => p.GetService(typeof(MockDbContext))).Returns(dbContext); 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())); var contextExplorer = new ContextExplorer(config, provider.Object, new Logger<ContextExplorer>(new LoggerFactory()));
// Act // Act

View File

@@ -19,7 +19,7 @@ public class DisplayPropertyTests {
_explorerMock = new Mock<IContextExplorer>(); _explorerMock = new Mock<IContextExplorer>();
_config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0); _config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
_tableManager = _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] [Fact]

View File

@@ -10,6 +10,9 @@ using Moq;
namespace HopFrame.Tests.Core.Services; namespace HopFrame.Tests.Core.Services;
public class TableManagerTests { public class TableManagerTests {
private Mock<ISearchExpressionBuilder> _searchBuilderMock = new();
private Mock<DbContext> CreateMockDbContext<TModel>(List<TModel> data) where TModel : class { private Mock<DbContext> CreateMockDbContext<TModel>(List<TModel> data) where TModel : class {
var dbContext = new Mock<DbContext>(); var dbContext = new Mock<DbContext>();
var dbSet = CreateMockDbSet(data); 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 config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>(); var explorer = new Mock<IContextExplorer>();
var provider = new Mock<IServiceProvider>(); 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 // Act
var result = (await manager.LoadPage(1, 2)).ToArray(); var result = (await manager.LoadPage(1, 2)).ToArray();
@@ -61,32 +64,6 @@ public class TableManagerTests {
Assert.Equal("Item3", ((MockModel)result[0]).Name); 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] [Fact]
public async Task TotalPages_ReturnsCorrectPageCount() { public async Task TotalPages_ReturnsCorrectPageCount() {
// Arrange // Arrange
@@ -99,7 +76,7 @@ public class TableManagerTests {
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0); var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>(); var explorer = new Mock<IContextExplorer>();
var provider = new Mock<IServiceProvider>(); 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.Models.AddRangeAsync(data);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@@ -121,7 +98,7 @@ public class TableManagerTests {
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0); var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>(); var explorer = new Mock<IContextExplorer>();
var provider = new Mock<IServiceProvider>(); 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(); var item = data.First();
// Act // Act
@@ -142,7 +119,7 @@ public class TableManagerTests {
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0); var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>(); var explorer = new Mock<IContextExplorer>();
var provider = new Mock<IServiceProvider>(); 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 // Act
await manager.EditItem(data.First()); 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 config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>(); var explorer = new Mock<IContextExplorer>();
var provider = new Mock<IServiceProvider>(); 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" }; var newItem = new MockModel { Id = 3, Name = "NewItem" };
// Act // Act