Added configurators
All checks were successful
HopFrame CI / build (push) Successful in 2m10s
HopFrame CI / test (push) Successful in 1m27s

This commit is contained in:
2026-02-22 19:32:33 +01:00
parent 79ed400185
commit b2a029d50b
40 changed files with 1837 additions and 1 deletions

View File

@@ -0,0 +1,11 @@
namespace HopFrame.Core.Configuration;
/**
* The configuration for the library
*/
public sealed class HopFrameConfig {
/** The configurations for the table repositories */
public IList<TableConfig> Tables { get; set; } = new List<TableConfig>();
internal HopFrameConfig() {}
}

View File

@@ -0,0 +1,41 @@
namespace HopFrame.Core.Configuration;
/**
* The configuration for a single property
*/
public class PropertyConfig {
/** The unique identifier for the property (usually the real property name in the model) */
public required string Identifier { get; init; }
/** The displayed name of the Property */
public required string DisplayName { get; set; }
/** The type of the property */
public required Type Type { get; set; }
/** Determines if the property will appear in the table */
public bool Listable { get; set; } = true;
/** Determines if the table can be sorted by the property */
public bool Sortable { get; set; } = true;
/** Determines if the table can be searched by the property */
public bool Searchable { get; set; } = true;
/**
* Determines if the value of the property can be edited
* (if true the value can still be set during creation)
*/
public bool Editable { get; set; } = true;
/** Determines if the property is visible in the creation or edit dialog */
public bool Creatable { get; set; } = true;
/** Determines if the actual value should be displayed (useful for passwords) */
public bool DisplayValue { get; set; } = true;
/** The place (from left to right) that the property will appear in the table and editor */
public int OrderIndex { get; set; }
internal PropertyConfig() {}
}

View File

@@ -0,0 +1,32 @@
namespace HopFrame.Core.Configuration;
/**
* The configuration for a table
*/
public class TableConfig {
/** The unique identifier for the table (usually the name of the model) */
public required string Identifier { get; init; }
/** The configurations for the properties of the model */
public IList<PropertyConfig> Properties { get; set; } = new List<PropertyConfig>();
/** The type of the model */
public required Type TableType { get; set; }
/** The type identifier for the repository */
public required Type RepositoryType { get; set; }
/** the url of the table page */
public required string Route { get; set; }
/** The displayed name of the table */
public required string DisplayName { get; set; }
/** A short description for the table */
public string? Description { get; set; }
/** The place (from top to bottom) that the table will appear in on the sidebar */
public int OrderIndex { get; set; }
internal TableConfig() {}
}

View File

@@ -0,0 +1,51 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Helpers;
using HopFrame.Core.Repositories;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Core.Configurators;
/**
* The configurator for the <see cref="HopFrameConfig"/>
*/
public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection services) {
/** The internal config that is modified */
public HopFrameConfig Config { get; } = config;
/// <summary>
/// Adds a new table to the configuration based on the provided repository
/// </summary>
/// <typeparam name="TRepository">The repository that handles the table</typeparam>
/// <typeparam name="TModel">The type of the model</typeparam>
/// <param name="configurator">The configurator for the table</param>
public HopFrameConfigurator AddRepository<TRepository, TModel>(Action<TableConfigurator<TModel>>? configurator = null) where TRepository : IHopFrameRepository where TModel : notnull {
var table = ConfigurationHelper.InitializeTable(Config, typeof(TRepository), typeof(TModel));
Config.Tables.Add(table);
services.TryAddScoped(typeof(TRepository));
configurator?.Invoke(new TableConfigurator<TModel>(table));
return this;
}
/// <summary>
/// Adds a new table to the configuration
/// </summary>
/// <param name="config">The configuration for the table</param>
/// <param name="configurator">The configurator for the table</param>
/// <typeparam name="TModel">The model of the table</typeparam>
/// <exception cref="ArgumentException">Is thrown when configuration validation fails</exception>
public HopFrameConfigurator AddTable<TModel>(TableConfig config, Action<TableConfigurator<TModel>>? configurator = null) where TModel : notnull {
if (typeof(TModel) != config.TableType)
throw new ArgumentException($"Table type for table '{config.Identifier}' does not mach requested type '{typeof(TModel).Name}'!");
var errors = ConfigurationHelper.ValidateTable(Config, config).ToArray();
if (errors.Length != 0)
throw new ArgumentException($"Table '{config.Identifier}' has some validation errors:\n\t{string.Join("\n\t", errors)}");
Config.Tables.Add(config);
services.TryAddScoped(config.RepositoryType);
configurator?.Invoke(new TableConfigurator<TModel>(config));
return this;
}
}

View File

@@ -0,0 +1,59 @@
using HopFrame.Core.Configuration;
namespace HopFrame.Core.Configurators;
/**
* The configurator for the <see cref="PropertyConfig"/>
*/
public class PropertyConfigurator(PropertyConfig config) {
/** The internal config that is modified */
public PropertyConfig Config { get; } = config;
/** <inheritdoc cref="PropertyConfig.DisplayName" /> */
public PropertyConfigurator SetDisplayName(string displayName) {
Config.DisplayName = displayName;
return this;
}
/** <inheritdoc cref="PropertyConfig.Listable" /> */
public PropertyConfigurator Listable(bool listable) {
Config.Listable = listable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Sortable" /> */
public PropertyConfigurator Sortable(bool sortable) {
Config.Sortable = sortable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Searchable" /> */
public PropertyConfigurator Searchable(bool searchable) {
Config.Searchable = searchable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Editable" /> */
public PropertyConfigurator Editable(bool editable) {
Config.Editable = editable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Creatable" /> */
public PropertyConfigurator Creatable(bool creatable) {
Config.Creatable = creatable;
return this;
}
/** <inheritdoc cref="PropertyConfig.DisplayValue" /> */
public PropertyConfigurator DisplayValue(bool displayValue) {
Config.DisplayValue = displayValue;
return this;
}
/** <inheritdoc cref="PropertyConfig.OrderIndex" /> */
public PropertyConfigurator SetOrderIndex(int index) {
Config.OrderIndex = index;
return this;
}
}

View File

@@ -0,0 +1,59 @@
using System.Linq.Expressions;
using HopFrame.Core.Configuration;
using HopFrame.Core.Helpers;
namespace HopFrame.Core.Configurators;
/**
* The configurator for the <see cref="TableConfig"/>
*/
public class TableConfigurator<TModel>(TableConfig config) where TModel : notnull {
/** The internal config that is modified */
public TableConfig Config { get; } = config;
/** <inheritdoc cref="TableConfig.Route"/> */
public TableConfigurator<TModel> SetRoute(string route) {
Config.Route = route;
return this;
}
/** <inheritdoc cref="TableConfig.DisplayName"/> */
public TableConfigurator<TModel> SetDisplayName(string displayName) {
Config.DisplayName = displayName;
return this;
}
/** <inheritdoc cref="TableConfig.Description"/> */
public TableConfigurator<TModel> SetDescription(string description) {
Config.Description = description;
return this;
}
/** <inheritdoc cref="TableConfig.OrderIndex"/> */
public TableConfigurator<TModel> SetOrderIndex(int index) {
Config.OrderIndex = index;
return this;
}
/** Returns the configurator for a property */
public PropertyConfigurator Property(string identifier) {
var prop = Config.Properties
.FirstOrDefault(p => p.Identifier == identifier);
if (prop is null)
throw new ArgumentException($"No attribute '{identifier}' found in '{Config.Identifier}'!");
return new PropertyConfigurator(prop);
}
/** <inheritdoc cref="Property"/> */
public PropertyConfigurator Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) {
var propertyName = ExpressionHelper.GetPropertyInfo(propertyExpression).Name;
var prop = Config.Properties.FirstOrDefault(p => p.Identifier == propertyName);
if (prop is null)
throw new ArgumentException($"No attribute '{propertyName}' found in '{Config.Identifier}'!");
return new PropertyConfigurator(prop);
}
}

View File

@@ -0,0 +1,75 @@
using System.Reflection;
using HopFrame.Core.Configuration;
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
namespace HopFrame.Core.Helpers;
internal static class ConfigurationHelper {
public static TableConfig InitializeTable(HopFrameConfig global, Type repositoryType, Type modelType) {
var identifier = modelType.Name;
if (global.Tables.Any(t => t.Identifier == identifier))
identifier = Guid.NewGuid().ToString();
var config = new TableConfig {
RepositoryType = repositoryType,
TableType = modelType,
Identifier = identifier,
Route = modelType.Name.ToLower(),
DisplayName = modelType.Name,
OrderIndex = global.Tables.Count
};
foreach (var property in modelType.GetProperties()) {
config.Properties.Add(InitializeProperty(config, property));
}
return config;
}
private static PropertyConfig InitializeProperty(TableConfig table, PropertyInfo property) {
var identifier = property.Name;
if (table.Properties.Any(p => p.Identifier == identifier))
identifier = Guid.NewGuid().ToString();
var config = new PropertyConfig {
Identifier = identifier,
Type = property.PropertyType,
DisplayName = property.Name,
OrderIndex = table.Properties.Count
};
return config;
}
public static IEnumerable<string> ValidateTable(HopFrameConfig global, TableConfig config) {
if (global.Tables.Any(t => t.Identifier == config.Identifier))
yield return $"Table identifier '{config.Identifier}' is not unique";
if (config.TableType is null)
yield return "TableType cannot be null";
if (config.RepositoryType is null)
yield return "RepositoryType cannot be null";
if (config.Route is null)
yield return "Route cannot be null";
if (config.DisplayName is null)
yield return "DisplayName cannot be null";
foreach (var property in config.Properties) {
if (config.Properties.Count(p => p.Identifier == property.Identifier) > 1)
yield return $"Property identifier '{property.Identifier}' is not unique";
if (property.DisplayName is null)
yield return $"Property '{property.Identifier}': DisplayName cannot be null";
if (property.Type is null)
yield return $"Property '{property.Identifier}': Type cannot be null";
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Linq.Expressions;
using System.Reflection;
namespace HopFrame.Core.Helpers;
internal static class ExpressionHelper {
public static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> propertyLambda) {
if (propertyLambda.Body is not MemberExpression member) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property.");
}
if (member.Member is not PropertyInfo propInfo) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property.");
}
var type = typeof(TSource);
if (propInfo.ReflectedType != null && type != propInfo.ReflectedType &&
!type.IsSubclassOf(propInfo.ReflectedType)) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}.");
}
if (propInfo.Name is null)
throw new ArgumentException($"Expression '{propertyLambda}' refers a not existing property.");
return propInfo;
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>true</IsPackable>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageId>HopFrame.Core</PackageId>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>HopFrame.Tests.Core</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
using System.Collections;
namespace HopFrame.Core.Repositories;
/** The base repository that provides access to the model dataset */
public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TModel : notnull {
/** <inheritdoc cref="LoadPageGenericAsync"/> */
public abstract Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default);
/** <inheritdoc/> */
public abstract Task<int> CountAsync(CancellationToken ct = default);
/** <inheritdoc cref="SearchGenericAsync"/> */
public abstract Task<IEnumerable<TModel>> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default);
/** <inheritdoc cref="CreateGenericAsync"/> */
public abstract Task CreateAsync(TModel entry, CancellationToken ct);
/** <inheritdoc cref="DeleteGenericAsync"/> */
public abstract Task DeleteAsync(TModel entry, CancellationToken ct);
/** <inheritdoc/> */
public async Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) {
return await LoadPageAsync(page, perPage, ct);
}
/** <inheritdoc/> */
public async Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
return await SearchAsync(searchTerm, page, perPage, ct);
}
/** <inheritdoc/> */
public Task CreateGenericAsync(object entry, CancellationToken ct) {
return CreateAsync((TModel)entry, ct);
}
/** <inheritdoc/> */
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
return DeleteAsync((TModel)entry, ct);
}
}

View File

@@ -0,0 +1,42 @@
using System.Collections;
#pragma warning disable CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do)
namespace HopFrame.Core.Repositories;
/** The generic repository that provides access to the model dataset */
public interface IHopFrameRepository {
/// <summary>
/// Loads a whole page of entries
/// </summary>
/// <param name="page">The index of the current page (starts at 0)</param>
/// <param name="perPage">The amount of entries that should be loaded</param>
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default);
/// <summary>
/// Returns the total amount of entries in the dataset
/// </summary>
public Task<int> CountAsync(CancellationToken ct = default);
/// <summary>
/// Searches through the whole dataset and returns a page of matching entries
/// </summary>
/// <param name="searchTerm">The search text provided by the user</param>
/// <param name="page">The index of the current page (starts at 0)</param>
/// <param name="perPage">The amount of entries that should be loaded</param>
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default);
/// <summary>
/// Saves the newly created entry to the dataset
/// </summary>
/// <param name="entry">The entry that needs to be saved</param>
public Task CreateGenericAsync(object entry, CancellationToken ct);
/// <summary>
/// Deletes the provided entry from the dataset
/// </summary>
/// <param name="entry">The entry that needs to be deleted</param>
public Task DeleteGenericAsync(object entry, CancellationToken ct);
}

View File

@@ -0,0 +1,22 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Configurators;
using HopFrame.Core.Services;
using HopFrame.Core.Services.Implementation;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Core;
/** An extension class to provide access to the setup of the library */
public static class ServiceCollectionExtensions {
/** Configures the library using the provided configurator */
public static void AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator) {
var config = new HopFrameConfig();
services.AddSingleton(config);
services.AddTransient<IConfigAccessor, ConfigAccessor>();
configurator.Invoke(new HopFrameConfigurator(config, services));
}
}

View File

@@ -0,0 +1,33 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories;
namespace HopFrame.Core.Services;
/** A service used to access configs and repositories provided by the <see cref="HopFrameConfig"/> */
public interface IConfigAccessor {
/// <summary>
/// Searches through the config and returns the table with the specified identifier if it exists
/// </summary>
/// <param name="identifier">The identifier of the table</param>
public TableConfig? GetTableByIdentifier(string identifier);
/// <summary>
/// Searches through the config and returns the table with the specified route if it exists
/// </summary>
/// <param name="route">The route of the table</param>
public TableConfig? GetTableByRoute(string route);
/// <summary>
/// Searches through the config and returns the table with the specified type if it exists
/// </summary>
/// <param name="type">The model type for the table</param>
public TableConfig? GetTableByType(Type type);
/// <summary>
/// Loads the repository for the specified table
/// </summary>
/// <param name="table">The table to load the repository for</param>
public IHopFrameRepository LoadRepository(TableConfig table);
}

View File

@@ -0,0 +1,25 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Core.Services.Implementation;
internal sealed class ConfigAccessor(HopFrameConfig config, IServiceProvider services) : IConfigAccessor {
public TableConfig? GetTableByIdentifier(string identifier) {
return config.Tables.FirstOrDefault(t => t.Identifier == identifier);
}
public TableConfig? GetTableByRoute(string route) {
return config.Tables.FirstOrDefault(t => t.Route == route);
}
public TableConfig? GetTableByType(Type type) {
return config.Tables.FirstOrDefault(t => t.TableType == type);
}
public IHopFrameRepository LoadRepository(TableConfig table) {
return (IHopFrameRepository)services.GetRequiredService(table.RepositoryType);
}
}