Resolve "Advanced search" #73

Merged
leon.hoppe merged 4 commits from feature/advanced-search into dev 2025-07-05 15:18:03 +02:00
3 changed files with 101 additions and 73 deletions
Showing only changes of commit 5dec609004 - Show all commits

View File

@@ -11,7 +11,9 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</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 beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" />
</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" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@@ -30,7 +32,7 @@
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="dev" /> <entry key="$PROJECT_DIR$" value="feature/repositories" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -128,7 +130,7 @@
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;b5f11219-dfc4-47a1-b02c-90ab603034fb.executor&quot;: &quot;Debug&quot;, &quot;b5f11219-dfc4-47a1-b02c-90ab603034fb.executor&quot;: &quot;Debug&quot;,
&quot;dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor&quot;: &quot;Debug&quot;, &quot;dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor&quot;: &quot;Debug&quot;,
&quot;git-widget-placeholder&quot;: &quot;!34 on feature/repositories&quot;, &quot;git-widget-placeholder&quot;: &quot;dev&quot;,
&quot;list.type.of.created.stylesheet&quot;: &quot;CSS&quot;, &quot;list.type.of.created.stylesheet&quot;: &quot;CSS&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
@@ -259,14 +261,7 @@
<workItem from="1740742098571" duration="78000" /> <workItem from="1740742098571" duration="78000" />
<workItem from="1740742471317" duration="672000" /> <workItem from="1740742471317" duration="672000" />
<workItem from="1741974241977" duration="10854000" /> <workItem from="1741974241977" duration="10854000" />
</task> <workItem from="1742038098473" duration="990000" />
<task id="LOCAL-00002" summary="Added admin page navigation">
<option name="closed" value="true" />
<created>1736855209077</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1736855209077</updated>
</task> </task>
<task id="LOCAL-00003" summary="Added database loading logic"> <task id="LOCAL-00003" summary="Added database loading logic">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -652,7 +647,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1741985203179</updated> <updated>1741985203179</updated>
</task> </task>
<option name="localTasksCounter" value="51" /> <task id="LOCAL-00051" summary="Added documentation for custom repos and exporter plugin">
<option name="closed" value="true" />
<created>1742038459077</created>
<option name="number" value="00051" />
<option name="presentableId" value="LOCAL-00051" />
<option name="project" value="LOCAL" />
<updated>1742038459077</updated>
</task>
<option name="localTasksCounter" value="52" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -662,38 +665,17 @@
<option name="coveragePercentColumnWidth" value="129" /> <option name="coveragePercentColumnWidth" value="129" />
<option name="sortOrder" value="DESCENDING" /> <option name="sortOrder" value="DESCENDING" />
<option name="sortedColumn" value="1" /> <option name="sortedColumn" value="1" />
<option name="symbolColumnWidth" value="451" /> <option name="symbolColumnWidth" value="559" />
<coverage-tree-state> <coverage-tree-state>
<expand> <expand>
<path> <path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" /> <item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" /> <item name="Total 53% 1150/2443" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path> </path>
<path> <path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" /> <item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" /> <item name="Total 53% 1150/2443" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 0% 743/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" /> <item name="Core 40% 862/1439" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path>
<path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 0% 743/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path>
<path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 0% 743/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path>
<path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 0% 743/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Config 0% 228/228" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path> </path>
</expand> </expand>
<select /> <select />
@@ -703,7 +685,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 maximum display length" />
<MESSAGE value="Fixed test for table view" /> <MESSAGE value="Fixed test for table view" />
<MESSAGE value="Added n-m relation mapping" /> <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" />
@@ -728,6 +709,7 @@
<MESSAGE value="Fixed directory in pipeline" /> <MESSAGE value="Fixed directory in pipeline" />
<MESSAGE value="Reverted pipeline to include all jobs" /> <MESSAGE value="Reverted pipeline to include all jobs" />
<MESSAGE value="Added support for custom repositories" /> <MESSAGE value="Added support for custom repositories" />
<option name="LAST_COMMIT_MESSAGE" value="Added support for custom repositories" /> <MESSAGE value="Added documentation for custom repos and exporter plugin" />
<option name="LAST_COMMIT_MESSAGE" value="Added documentation for custom repos and exporter plugin" />
</component> </component>
</project> </project>

View File

@@ -1,5 +1,7 @@
using System.Collections; using System.Collections;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq.Expressions;
using System.Reflection.Metadata;
using HopFrame.Core.Config; using HopFrame.Core.Config;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking;
@@ -17,16 +19,84 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
.ToArrayAsync(); .ToArrayAsync();
} }
public 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 all = IncludeForeignKeys(table)
.AsEnumerable()
.Where(item => ItemSearched(item, searchTerm))
.ToList();
return Task.FromResult(( var isNegative = searchTerm.StartsWith('!');
(IEnumerable<object>)all.Skip(page * perPage).Take(perPage), if (isNegative)
(int)Math.Ceiling(all.Count / (double)perPage))); 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;
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 result = await IncludeForeignKeys(table)
.Where(lambda)
.Skip(page * perPage)
.Take(perPage)
.ToListAsync();
var totalEntries = await table
.Where(lambda)
.CountAsync();
return (result, (int)Math.Ceiling(totalEntries / (double)perPage));
} }
public async Task<int> TotalPages(int perPage = 20) { public async Task<int> TotalPages(int perPage = 20) {
@@ -61,31 +131,6 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
return await table.FindAsync(key); return await table.FindAsync(key);
} }
public async Task RevertChanges(object item) {
var entry = context.Entry((TModel)item);
await entry.ReloadAsync();
if (entry.Collections.Any()) {
context.ChangeTracker.Clear();
}
await context.SaveChangesAsync();
}
private bool ItemSearched(TModel item, string searchTerm) {
foreach (var property in config.Properties) {
if (!property.Searchable) continue;
var value = property.GetValue(item, provider);
if (value is null) continue;
var strValue = value.ToString();
if (strValue?.Contains(searchTerm) == true)
return true;
}
return false;
}
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) {
if (item is null) return string.Empty; if (item is null) return string.Empty;

View File

@@ -53,7 +53,8 @@ builder.Services.AddHopFrame(options => {
context.Table<Post>() context.Table<Post>()
.Property(p => p.Author) .Property(p => p.Author)
.Format((user, _) => $"{user.FirstName} {user.LastName}") //.Format((user, _) => $"{user.FirstName} {user.LastName}")
.SetDisplayedProperty(u => u.Username)
.SetValidator((_, _) => []); .SetValidator((_, _) => []);
context.Table<Post>() context.Table<Post>()