Resolve "Advanced search" #73
60
.idea/.idea.HopFrame/.idea/workspace.xml
generated
60
.idea/.idea.HopFrame/.idea/workspace.xml
generated
@@ -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 @@
|
|||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
|
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
|
||||||
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
|
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
|
||||||
"git-widget-placeholder": "!34 on feature/repositories",
|
"git-widget-placeholder": "dev",
|
||||||
"list.type.of.created.stylesheet": "CSS",
|
"list.type.of.created.stylesheet": "CSS",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.tslint": "true",
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
Reference in New Issue
Block a user