6 Commits

Author SHA1 Message Date
e9e9fbf5e9 Added sorting
All checks were successful
HopFrame CI / build (push) Successful in 55s
HopFrame CI / test (push) Successful in 1m6s
2026-02-25 21:25:34 +01:00
ff2634ff41 Started working on frontend
All checks were successful
HopFrame CI / build (push) Successful in 44s
HopFrame CI / test (push) Successful in 52s
2026-02-25 16:33:46 +01:00
d2082ef33c Unified docstrings
All checks were successful
HopFrame CI / build (push) Successful in 47s
HopFrame CI / test (push) Successful in 49s
2026-02-23 19:54:35 +01:00
6730d57771 Added ef core integration
All checks were successful
HopFrame CI / build (push) Successful in 46s
HopFrame CI / test (push) Successful in 50s
2026-02-23 16:20:32 +01:00
e8ac7eb88a Merge pull request 'Added configurators' (#81) from feature/config into dev
All checks were successful
HopFrame CI / build (push) Successful in 39s
HopFrame CI / test (push) Successful in 47s
Reviewed-on: #81
2026-02-22 19:40:08 +01:00
b2a029d50b Added configurators
All checks were successful
HopFrame CI / build (push) Successful in 2m10s
HopFrame CI / test (push) Successful in 1m27s
2026-02-22 19:32:33 +01:00
67 changed files with 3398 additions and 1 deletions

46
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,46 @@
name: HopFrame CI
on:
push:
branches:
- "**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.x"
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --configuration Release --no-restore
test:
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.x"
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --configuration Release --no-restore
- name: Run tests
run: dotnet test --configuration Release --no-build --verbosity normal

View File

@@ -1 +1,12 @@
<Solution />
<Solution>
<Folder Name="/debug/">
<Project Path="debug/TestApplication/TestApplication.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/HopFrame.Core/HopFrame.Core.csproj" />
<Project Path="src/HopFrame.Web/HopFrame.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<ResourcePreloader/>
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["TestApplication.styles.css"]"/>
<ImportMap/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<ReconnectModal/>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
@inherits LayoutComponentBase
@Body
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,20 @@
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br/>Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<button id="components-resume-button" class="components-pause-visible">
Resume
</button>
<p class="components-resume-failed-visible">
Failed to resume the session.<br/>Please reload the page.
</p>
</div>
</dialog>

View File

@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

View File

@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
location.reload();
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

@@ -0,0 +1,35 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId) {
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,7 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.

View File

@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>

View File

@@ -0,0 +1,11 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using TestApplication
@using TestApplication.Components
@using TestApplication.Components.Layout

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using TestApplication.Models;
namespace TestApplication;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options) {
public DbSet<User> Users { get; set; }
public DbSet<Post> Posts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Post>()
.HasKey(p => p.Id);
modelBuilder.Entity<User>()
.HasKey(u => u.Id);
modelBuilder.Entity<User>()
.HasMany(u => u.Posts)
.WithOne(p => p.Sender)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace TestApplication.Models;
public class Post {
[Key]
public Guid Id { get; } = Guid.CreateVersion7();
public required User Sender { get; set; }
[MaxLength(5000)]
public required string Message { get; set; }
}

View File

@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
namespace TestApplication.Models;
public class User {
[Key]
public Guid Id { get; } = Guid.CreateVersion7();
public int Index { get; set; }
[EmailAddress, MaxLength(25)]
public required string Email { get; set; }
[MaxLength(25)]
public required string Username { get; set; }
[MaxLength(64)]
public required string Password { get; set; }
[MaxLength(25)]
public required string FirstName { get; set; }
[MaxLength(25)]
public required string LastName { get; set; }
[MaxLength(255)]
public string? Description { get; set; }
public required DateOnly Birth { get; set; } = DateOnly.FromDateTime(DateTime.Today);
public List<Post> Posts { get; set; } = new();
}

View File

@@ -0,0 +1,88 @@
using HopFrame.Core.EFCore;
using HopFrame.Web;
using Microsoft.EntityFrameworkCore;
using TestApplication;
using TestApplication.Components;
using TestApplication.Models;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddDbContext<DatabaseContext>(options => {
options.UseInMemoryDatabase("testing");
});
builder.Services.AddHopFrame(config => {
config.AddDbContext<DatabaseContext>();
config.Table<User>(table => {
table.SetDescription("The user dataset. It contains all information for the users of the application.");
table.Property(u => u.Password)
.Listable(false);
table.SetPreferredProperty(u => u.Username);
});
config.Table<Post>(table => {
table.SetDescription("The posts dataset. It contains all posts sent via the application.");
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment()) {
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
await using (var scope = app.Services.CreateAsyncScope()) {
var context = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
foreach (var i in Enumerable.Range(1, 100)) {
var firstName = Faker.Name.First();
var lastName = Faker.Name.Last();
context.Users.Add(new() {
Email = $"{firstName}.{lastName}@gmail.com".ToLower(),
Username = $"{firstName}.{lastName}".ToLower(),
FirstName = firstName,
LastName = lastName,
Description = Faker.Lorem.Paragraph(),
Birth = DateOnly.FromDateTime(Faker.Identification.DateOfBirth()),
Password = Faker.RandomNumber.Next(100000L, 99999999999999999L).ToString(),
Index = i
});
}
await context.SaveChangesAsync();
context.Posts.Add(new() {
Message = Faker.Lorem.Paragraph(),
Sender = context.Users.First()
});
context.Posts.Add(new() {
Message = Faker.Lorem.Paragraph(),
Sender = context.Users.Skip(1).First()
});
await context.SaveChangesAsync();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddHopFrame();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5281",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7126;http://localhost:5281",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HopFrame.Core\HopFrame.Core.csproj" />
<ProjectReference Include="..\..\src\HopFrame.Web\HopFrame.Web.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Faker.Net" Version="2.0.163" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,38 @@
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}

View File

@@ -0,0 +1,11 @@
namespace HopFrame.Core.Configuration;
/// <summary>
/// The configuration for the library
/// </summary>
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,93 @@
namespace HopFrame.Core.Configuration;
/// <summary>
/// The configuration for a single property
/// </summary>
public class PropertyConfig {
/// [GENERATED] The unique identifier for the property (usually the real property name in the model)
public required string Identifier { get; init; }
/// [GENERATED] The displayed name of the Property
public required string DisplayName { get; set; }
/// [GENERATED] The real type of the property
public required Type Type { get; set; }
/// [GENERATED] The type as wich the property should be treated
public required PropertyType PropertyType { 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;
/// <summary>
/// Determines if the value of the property can be edited<br/>
/// (if true the value can still be set during creation)
/// </summary>
public bool Editable { get; set; } = true;
/// Determines if the property is visible in the creation or edit dialog
public bool Creatable { get; set; } = true;
/// [GENERATED] The place (from left to right) that the property will appear in the table and editor
public int OrderIndex { get; set; }
/// [GENERATED] The table that owns this property
public TableConfig Table { get; set; }
internal PropertyConfig() {}
}
/// <summary>
/// Used to distinguish between different input types in the frontend. <br/>
/// Binary Format: First byte is used for additional properties, second byte identifies the real type
/// </summary>
[Flags]
public enum PropertyType : byte {
/// Used together with another type to indicate that the value can be null
Nullable = 0b10000000,
/// Used together with another type to indicate that the property is a relation
Relation = 0b01000000,
/// Used together with another type to indicate that the value is enumerable
List = 0b00100000,
/// Indicates that the value is numeric
Numeric = 0x01,
/// Indicates that the value is a boolean
Boolean = 0x02,
/// Indicates that the value is a timestamp
DateTime = 0x03,
/// Indicates that the value is a date
DateOnly = 0x04,
/// Indicates that the value is a time of day
TimeOnly = 0x05,
/// Indicates that the value is a list of fixed values
Enum = 0x06,
/// Indicates that the value is a string
Text = 0x07,
/// Indicates that the value is an email
Email = 0x08,
/// Indicates that the value is a long string
TextArea = 0x09,
/// Indicates that the value should be hidden
Password = 0x0A,
/// Indicates that the value is a phone number
PhoneNumber = 0x0B
}

View File

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

View File

@@ -0,0 +1,70 @@
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;
/// <summary>
/// The configurator for the <see cref="HopFrameConfig"/>
/// </summary>
public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection services) {
/// The internal config that is modified
public HopFrameConfig Config { get; } = config;
internal IServiceCollection Services { get; } = services;
/// <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 : class {
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 : class {
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;
}
/// <summary>
/// Loads the configurator for an existing table in the configuration
/// </summary>
/// <param name="configurator">The configurator for the table</param>
/// <typeparam name="TModel">The model of the table</typeparam>
/// <exception cref="ArgumentException">Is thrown when no table with the requested type was found</exception>
public TableConfigurator<TModel> Table<TModel>(Action<TableConfigurator<TModel>>? configurator = null) where TModel : class {
var table = Config.Tables.FirstOrDefault(t => t.TableType == typeof(TModel));
if (table is null)
throw new ArgumentException($"Table '{typeof(TModel).Name}' not found");
var modeller = new TableConfigurator<TModel>(table);
configurator?.Invoke(modeller);
return modeller;
}
}

View File

@@ -0,0 +1,62 @@
using HopFrame.Core.Configuration;
namespace HopFrame.Core.Configurators;
/// <summary>
/// The configurator for the <see cref="PropertyConfig"/>
/// </summary>
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.OrderIndex" />
public PropertyConfigurator SetOrderIndex(int index) {
Config.OrderIndex = index;
return this;
}
/// <summary>
/// Sets the property type. The predefined modifiers (like nullable) persist.
/// If the property is a list or any other generic type, please use the enumerated type.
/// </summary>
public PropertyConfigurator SetType(PropertyType type) {
Config.PropertyType = (PropertyType)(((byte)Config.PropertyType & 0xF0) | ((byte)type & 0x0F));
return this;
}
}

View File

@@ -0,0 +1,71 @@
using System.Linq.Expressions;
using HopFrame.Core.Configuration;
using HopFrame.Core.Helpers;
namespace HopFrame.Core.Configurators;
/// <summary>
/// The configurator for the <see cref="TableConfig"/>
/// </summary>
public class TableConfigurator<TModel>(TableConfig config) where TModel : class {
/// 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(string)"/>
public PropertyConfigurator Property(Expression<Func<TModel, object>> 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);
}
/// <inheritdoc cref="TableConfig.PreferredProperty"/>
public TableConfigurator<TModel> SetPreferredProperty(Expression<Func<TModel, object>> 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}'!");
Config.PreferredProperty = prop.Identifier;
return this;
}
}

View File

@@ -0,0 +1,34 @@
using System.Reflection;
using HopFrame.Core.Configuration;
using HopFrame.Core.Helpers;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Core.EFCore;
internal static class DbConfigPopulator {
public static string ConfigureRepository(HopFrameConfig global, IServiceCollection services, Type contextType, PropertyInfo tableProperty) {
var modelType = tableProperty.PropertyType.GenericTypeArguments.First();
var repoType = typeof(EfCoreRepository<,>).MakeGenericType(modelType, contextType);
services.AddScoped(repoType);
var table = ConfigurationHelper.InitializeTable(global, repoType, modelType);
global.Tables.Add(table);
return table.Identifier;
}
public static void CheckForRelations(HopFrameConfig global, TableConfig table) {
foreach (var property in table.Properties) {
var type = property.Type;
if ((property.PropertyType & PropertyType.List) != 0 && type.IsGenericType) {
type = type.GenericTypeArguments.First();
}
if (global.Tables.Any(t => t.TableType == type))
property.PropertyType |= PropertyType.Relation;
}
}
}

View File

@@ -0,0 +1,53 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories;
using HopFrame.Core.Services;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Core.EFCore;
internal class EfCoreRepository<TModel, TContext>(TContext context, IConfigAccessor accessor) : HopFrameRepository<TModel> where TModel : class where TContext : DbContext {
public override async Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default) {
var set = context.Set<TModel>();
var query = set
.AsNoTracking()
.Skip(page * perPage)
.Take(perPage);
return await IncludeForeignKeys(query)
.ToArrayAsync(ct);
}
private IQueryable<TModel> IncludeForeignKeys(IQueryable<TModel> query) {
var table = accessor.GetTableByType(typeof(TModel))!;
return table.Properties
.Where(p => (p.PropertyType & PropertyType.Relation) != 0)
.Aggregate(query, (current, property) => current.Include(property.Identifier));
}
public override async Task<int> CountAsync(CancellationToken ct = default) {
var set = context.Set<TModel>();
return await set.CountAsync(ct);
}
public override Task<IEnumerable<TModel>> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
return LoadPageAsync(page, perPage, ct); //TODO: Implement search functionality
}
public override async Task CreateAsync(TModel entry, CancellationToken ct = default) {
await context.AddAsync(entry, ct);
await context.SaveChangesAsync(ct);
}
public override async Task UpdateAsync(TModel entry, CancellationToken ct = default) {
context.Update(entry);
await context.SaveChangesAsync(ct);
}
public override async Task DeleteAsync(TModel entry, CancellationToken ct = default) {
context.Remove(entry);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,44 @@
using System.Linq.Expressions;
using HopFrame.Core.Configuration;
using HopFrame.Core.Configurators;
using HopFrame.Core.Helpers;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Core.EFCore;
/// Adds useful extensions to the <see cref="HopFrameConfigurator"/> to add managed <see cref="DbContext"/> repositories
public static class HopFrameConfiguratorExtensions {
/// <summary>
/// Adds managed repositories for the selected (or all if none provided) tables
/// </summary>
/// <param name="configurator">The configurator for the current <see cref="HopFrameConfig"/></param>
/// <param name="includedTables">The tables that should be configured (if none are provided, all tables will be added)</param>
/// <typeparam name="TDbContext">The already configured and injectable database context</typeparam>
public static HopFrameConfigurator AddDbContext<TDbContext>(this HopFrameConfigurator configurator, params Expression<Func<TDbContext, object>>[] includedTables) where TDbContext : DbContext {
var contextType = typeof(TDbContext);
var properties = contextType.GetProperties()
.Where(p => p.PropertyType.IsGenericType)
.Where(p => p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));
if (includedTables.Length != 0) {
properties = includedTables.Select(ExpressionHelper.GetPropertyInfo);
}
var tableIdentifiers = new List<string>();
foreach (var tableProperty in properties) {
var identifier = DbConfigPopulator.ConfigureRepository(configurator.Config, configurator.Services, contextType, tableProperty);
tableIdentifiers.Add(identifier);
}
var createdTables = configurator.Config.Tables
.Where(t => tableIdentifiers.Contains(t.Identifier))
.ToArray();
foreach (var createdTable in createdTables) {
DbConfigPopulator.CheckForRelations(configurator.Config, createdTable);
}
return configurator;
}
}

View File

@@ -0,0 +1,130 @@
using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Runtime.CompilerServices;
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() + 's',
DisplayName = modelType.Name + 's',
OrderIndex = global.Tables.Count
};
foreach (var property in modelType.GetProperties()) {
config.Properties.Add(InitializeProperty(config, property));
}
return config;
}
public 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,
PropertyType = InferPropertyType(property.PropertyType, property),
Table = table
};
if (property.CustomAttributes.Any(a => a.AttributeType == typeof(KeyAttribute)))
table.PreferredProperty = config.Identifier;
return config;
}
public static PropertyType InferPropertyType(Type realType, PropertyInfo info) {
byte modifiers = 0;
if (Nullable.GetUnderlyingType(realType) != null) {
modifiers |= (byte)PropertyType.Nullable;
realType = Nullable.GetUnderlyingType(realType)!;
}
if (info.CustomAttributes.Any(a => a.AttributeType == typeof(NullableAttribute))) {
modifiers |= (byte)PropertyType.Nullable;
}
if ((realType.IsAssignableTo(typeof(IEnumerable)) || realType.IsAssignableTo(typeof(IEnumerable<>))) && realType != typeof(string)) {
modifiers |= (byte)PropertyType.List;
}
if (realType.IsGenericType) {
realType = realType.GenericTypeArguments.First();
}
var type = PropertyType.Text;
var realTypeCode = Type.GetTypeCode(realType);
if (realTypeCode == TypeCode.Boolean)
type = PropertyType.Boolean;
if (realTypeCode == TypeCode.DateTime)
type = PropertyType.DateTime;
if (realType == typeof(DateOnly))
type = PropertyType.DateOnly;
if (realType == typeof(TimeOnly))
type = PropertyType.TimeOnly;
if (realType.IsEnum)
type = PropertyType.Enum;
if (realType.IsNumeric())
type = PropertyType.Numeric;
if (info.CustomAttributes.Any(a => a.AttributeType == typeof(EmailAddressAttribute)))
type = PropertyType.Email;
return (PropertyType)((byte)type | modifiers);
}
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,23 @@
namespace HopFrame.Core.Helpers;
internal static class TypeExtensions {
public static bool IsNumeric(this Type o) {
if (o.IsEnum) return false;
switch (Type.GetTypeCode(o)) {
case TypeCode.Byte:
case TypeCode.SByte:
case TypeCode.UInt16:
case TypeCode.UInt32:
case TypeCode.UInt64:
case TypeCode.Int16:
case TypeCode.Int32:
case TypeCode.Int64:
case TypeCode.Decimal:
case TypeCode.Double:
case TypeCode.Single:
return true;
default:
return false;
}
}
}

View File

@@ -0,0 +1,25 @@
<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.EntityFrameworkCore" Version="10.0.3" />
<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,51 @@
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 : class {
/// <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 = default);
/// <inheritdoc cref="UpdateGenericAsync"/>
public abstract Task UpdateAsync(TModel entry, CancellationToken ct = default);
/// <inheritdoc cref="DeleteGenericAsync"/>
public abstract Task DeleteAsync(TModel entry, CancellationToken ct = default);
/// <inheritdoc/>
public async Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
return await LoadPageAsync(page, perPage, ct);
}
/// <inheritdoc/>
public async Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
return await SearchAsync(searchTerm, page, perPage, ct);
}
/// <inheritdoc/>
public Task CreateGenericAsync(object entry, CancellationToken ct) {
return CreateAsync((TModel)entry, ct);
}
/// <inheritdoc/>
public Task UpdateGenericAsync(object entry, CancellationToken ct) {
return UpdateAsync((TModel)entry, ct);
}
/// <inheritdoc/>
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
return DeleteAsync((TModel)entry, ct);
}
}

View File

@@ -0,0 +1,46 @@
#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<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct);
/// <summary>
/// Returns the total amount of entries in the dataset
/// </summary>
public Task<int> CountAsync(CancellationToken ct);
/// <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<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct);
/// <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>
/// Saves the changes made to the entry to the dataset
/// </summary>
/// <param name="entry">The modified entry</param>
public Task UpdateGenericAsync(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,24 @@
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 IServiceCollection AddHopFrameServices(this IServiceCollection services, Action<HopFrameConfigurator> configurator) {
var config = new HopFrameConfig();
services.AddSingleton(config);
services.AddTransient<IConfigAccessor, ConfigAccessor>();
services.AddTransient<IEntityAccessor, EntityAccessor>();
configurator.Invoke(new HopFrameConfigurator(config, services));
return 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,38 @@
using HopFrame.Core.Configuration;
namespace HopFrame.Core.Services;
/// A service used to modify the actual properties of a model
public interface IEntityAccessor {
/// <summary>
/// Returns the formatted content of the property, ready to be displayed
/// </summary>
/// <param name="model">The model to pull the property from</param>
/// <param name="property">The property that shall be extracted</param>
public string? GetValue(object model, PropertyConfig property);
/// <summary>
/// Formats the property to be displayed properly
/// </summary>
/// <param name="value">The value of the property</param>
/// <param name="property">The property that shall be extracted</param>
public string? FormatValue(object? value, PropertyConfig property);
/// <summary>
/// Properly formats and sets the new value of the property
/// </summary>
/// <param name="model">The model to save the property to</param>
/// <param name="property">The property that shall be modified</param>
/// <param name="value">The new value of the property</param>
public void SetValue(object model, PropertyConfig property, object value);
/// <summary>
/// Sorts the provided dataset by the specified property
/// </summary>
/// <param name="data">The dataset that needs to be sorted</param>
/// <param name="property">The property that defines the sort order</param>
/// <param name="descending">Determines if the resulting order should be flipped</param>
public IEnumerable<object> SortDataByProperty(IEnumerable<object> data, PropertyConfig property, bool descending = false);
}

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

View File

@@ -0,0 +1,91 @@
using System.Linq.Expressions;
using System.Reflection;
using HopFrame.Core.Configuration;
namespace HopFrame.Core.Services.Implementation;
internal class EntityAccessor(IConfigAccessor accessor) : IEntityAccessor {
public string? GetValue(object model, PropertyConfig property) {
var prop = model.GetType().GetProperty(property.Identifier);
if (prop is null)
return null;
var value = prop.GetValue(model);
return FormatValue(value, property);
}
public string? FormatValue(object? value, PropertyConfig property) {
if (value is null)
return null;
if ((property.PropertyType & PropertyType.List) != 0) {
return (value as IEnumerable<object>)!.Count().ToString();
}
if ((property.PropertyType & PropertyType.Relation) != 0) {
var table = accessor.GetTableByType(property.Type);
if (table?.PreferredProperty != null) {
var tableProp = table.Properties.First(p => p.Identifier == table.PreferredProperty);
return GetValue(value, tableProp);
}
}
return value.ToString();
}
public void SetValue(object model, PropertyConfig property, object value) {
var prop = model.GetType().GetProperty(property.Identifier);
if (prop is null)
return;
if (value.GetType() != property.Type)
value = Convert.ChangeType(value, property.Type);
prop.SetValue(model, value);
}
public IEnumerable<object> SortDataByProperty(IEnumerable<object> data, PropertyConfig property, bool descending = false) {
var prop = property.Table.TableType.GetProperty(property.Identifier);
if (prop is null)
return data;
var parameter = Expression.Parameter(property.Table.TableType);
Expression expression = Expression.Property(parameter, prop);
var targetType = prop.PropertyType;
if ((property.PropertyType & PropertyType.Relation) != 0) {
var relationTable = accessor.GetTableByType(property.Type);
PropertyInfo? relationPropInfo = null;
if (relationTable?.PreferredProperty != null) {
var relationProp = relationTable.Properties.First(p => p.Identifier == relationTable.PreferredProperty);
if ((relationProp.PropertyType & PropertyType.List) == 0)
relationPropInfo = relationProp.Type.GetProperty(relationProp.Identifier);
}
if (relationPropInfo == null) {
var formatMethod = GetType().GetMethod(nameof(FormatValue))!;
targetType = typeof(string);
expression = Expression.Call(Expression.Constant(this), formatMethod, expression, Expression.Constant(property));
}
else {
targetType = relationPropInfo.PropertyType;
expression = Expression.Property(expression, relationPropInfo);
}
}
var lambda = Expression.Lambda(expression, parameter);
var methodName = descending ? nameof(Enumerable.OrderByDescending) : nameof(Enumerable.OrderBy);
var method = typeof(Enumerable)
.GetMethods()
.Single(m => m.Name == methodName && m.GetParameters().Length == 2)
.MakeGenericMethod(property.Table.TableType, targetType);
var result = method.Invoke(null, [data, lambda.Compile()]);
return (IEnumerable<object>)result!;
}
}

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<ImportMap/>
<HeadOutlet/>
</head>
<body>
<Router AppAssembly="typeof(ServiceCollectionExtensions).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData"/>
</Found>
</Router>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Components;
namespace HopFrame.Web.Components;
public class CancellableComponent : ComponentBase, IDisposable {
protected CancellationTokenSource TokenSource { get; } = new();
public void Dispose() {
TokenSource.Dispose();
}
}

View File

@@ -0,0 +1,33 @@
<MudCard Style="width: 350px; height: 200px">
<MudCardHeader>
<CardHeaderAvatar>
<MudIcon Icon="@Icon" Style="margin: auto" />
</CardHeaderAvatar>
<CardHeaderContent>
<MudText Typo="Typo.h6">@Title</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText>@Description</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Href="@Href">Open</MudButton>
</MudCardActions>
</MudCard>
@code {
[Parameter]
public required string Title { get; set; }
[Parameter]
public string? Subtitle { get; set; }
[Parameter]
public string? Description { get; set; }
[Parameter]
public required string Href { get; set; }
[Parameter]
public required string Icon { get; set; }
}

View File

@@ -0,0 +1,40 @@
@using HopFrame.Core.Configuration
<MudDrawer Open="true" Fixed="true" ClipMode="DrawerClipMode.Docked" Width="200px">
<MudNavMenu>
<MudNavLink Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.SpaceDashboard" Href="admin">Dashboard</MudNavLink>
<br>
@foreach (var route in Routes) {
<MudNavLink Match="NavLinkMatch.All" Icon="@route.Icon" Href="@route.Route">@route.Name</MudNavLink>
}
</MudNavMenu>
</MudDrawer>
@inject HopFrameConfig Config
@code {
private readonly struct RouteDefinition {
public required string Route { get; init; }
public required string Icon { get; init; }
public required string Name { get; init; }
}
private RouteDefinition[] Routes { get; set; } = null!;
protected override void OnInitialized() {
base.OnInitialized();
Routes = Config.Tables
.OrderBy(t => t.OrderIndex)
.Select(table => new RouteDefinition {
Route = "admin/" + table.Route,
Icon = Icons.Material.Filled.TableChart,
Name = table.DisplayName
})
.ToArray();
}
}

View File

@@ -0,0 +1,61 @@
@rendermode InteractiveServer
<MudTable ServerData="Reload"
@ref="Manager"
Hover="true"
Breakpoint="Breakpoint.Sm"
LoadingProgressColor="Color.Info"
HorizontalScrollbar="true"
FixedHeader="true"
FixedFooter="true"
Height="calc(100vh - 164px)">
<ToolBarContent>
<MudText Typo="Typo.h6">@Config.DisplayName</MudText>
<MudSpacer />
<MudStack Row="true" Spacing="2" Style="min-width: 600px">
<MudButton OnClick="@(Manager.ReloadServerData)">Reload</MudButton>
<MudTextField
T="string"
Placeholder="Search"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
IconSize="Size.Medium"
Class="mt-0"
FullWidth="true"
Clearable="true"
DebounceInterval="200"
OnDebounceIntervalElapsed="@(s => OnSearch(s))"/>
<MudButton EndIcon="@Icons.Material.Filled.Add" Style="margin-right: 0.5rem">Add</MudButton>
</MudStack>
</ToolBarContent>
<HeaderContent>
@foreach (var prop in OrderedProperties) {
<MudTh>
<MudTableSortLabel
T="object"
@ref="SortDirections[prop.Identifier]"
SortDirectionChanged="@(dir => OnSort(prop, dir))"
Enabled="@prop.Sortable">
@prop.DisplayName
</MudTableSortLabel>
</MudTh>
}
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
@foreach (var prop in OrderedProperties) {
<MudTd DataLabel="@prop.DisplayName" Style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden; max-width: 500px">@context[prop.Identifier]</MudTd>
}
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" />
</MudStack>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>

View File

@@ -0,0 +1,102 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories;
using HopFrame.Core.Services;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace HopFrame.Web.Components.Components;
public partial class Table(IEntityAccessor accessor, IConfigAccessor configAccessor) : ComponentBase {
[Parameter]
public required TableConfig Config { get; set; }
private IHopFrameRepository Repository { get; set; } = null!;
private PropertyConfig[] OrderedProperties { get; set; } = null!;
private MudTable<Dictionary<string, string>> Manager { get; set; } = null!;
private Dictionary<string, MudTableSortLabel<object>> SortDirections { get; set; } = new();
private KeyValuePair<string, SortDirection>? _currentSort;
private string _searchText = string.Empty;
protected override void OnInitialized() {
base.OnInitialized();
Repository = configAccessor.LoadRepository(Config);
OrderedProperties = Config.Properties
.Where(p => p.Listable)
.OrderBy(p => p.OrderIndex)
.ToArray();
foreach (var property in OrderedProperties) {
SortDirections.Add(property.Identifier, null!);
}
}
private List<Dictionary<string, string>> PrepareData(object[] entries) {
var list = new List<Dictionary<string, string>>();
foreach (var entry in entries) {
var dict = new Dictionary<string, string>();
foreach (var prop in OrderedProperties) {
dict.Add(prop.Identifier, accessor.GetValue(entry, prop) ?? string.Empty);
}
list.Add(dict);
}
return list;
}
private async Task<TableData<Dictionary<string, string>>> Reload(TableState state, CancellationToken ct) {
IEnumerable<object> entries;
if (string.IsNullOrWhiteSpace(_searchText))
entries = await Repository.LoadPageGenericAsync(state.Page, state.PageSize, ct);
else
entries = await Repository.SearchGenericAsync(_searchText, state.Page, state.PageSize, ct);
if (_currentSort.HasValue) {
var sortProp = Config.Properties.First(p => p.Identifier == _currentSort.Value.Key);
entries = accessor.SortDataByProperty(entries, sortProp, _currentSort.Value.Value == SortDirection.Descending);
}
var data = PrepareData(entries.ToArray());
var total = await Repository.CountAsync(ct);
return new TableData<Dictionary<string, string>> {
TotalItems = total,
Items = data
};
}
private async Task OnSearch(string searchText) {
_searchText = searchText;
await Manager.ReloadServerData();
}
private async Task OnSort(PropertyConfig property, SortDirection direction) {
if (direction != SortDirection.None) {
foreach (var reference in SortDirections
.Where(d => d.Key != property.Identifier)) {
#pragma warning disable BL0005
reference.Value.SortDirection = SortDirection.None;
#pragma warning restore BL0005
}
}
if (direction == SortDirection.None) {
_currentSort = null;
}
else {
_currentSort = new(property.Identifier, direction);
}
await Manager.ReloadServerData();
}
}

View File

@@ -0,0 +1,3 @@
<MudAppBar Dense="true" Elevation="0">
HopFrame
</MudAppBar>

View File

@@ -0,0 +1,18 @@
@using HopFrame.Web.Components.Components
@inherits LayoutComponentBase
<link rel="stylesheet" href="@Assets["_content/HopFrame.Web/HopFrame.Web.bundle.scp.css"]"/>
<link rel="stylesheet" href="@Assets["_content/HopFrame.Web/hopframe.css"]"/>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]" defer></script>
<MudThemeProvider IsDarkMode="true" />
<MudLayout>
<Topbar />
<Sidebar />
<MudMainContent>
@Body
</MudMainContent>
</MudLayout>

View File

@@ -0,0 +1,51 @@
@page "/admin"
@using HopFrame.Core.Configuration
@using HopFrame.Web.Components.Components
@layout HopFrameLayout
<PageTitle>HopFrame</PageTitle>
<section style="padding: 1.5rem">
<MudText Typo="Typo.h5">Pages</MudText>
<br>
<MudStack Wrap="Wrap.Wrap" Row="true">
@foreach (var route in Routes) {
<Card
Title="@route.Name"
Href="@route.Route"
Icon="@route.Icon"
Description="@route.Description"/>
}
</MudStack>
</section>
@inject HopFrameConfig Config
@code {
private readonly struct RouteDefinition {
public required string Route { get; init; }
public required string Icon { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
}
private RouteDefinition[] Routes { get; set; } = null!;
protected override void OnInitialized() {
base.OnInitialized();
Routes = Config.Tables
.OrderBy(t => t.OrderIndex)
.Select(table => new RouteDefinition {
Route = "admin/" + table.Route,
Icon = Icons.Material.Filled.TableChart,
Name = table.DisplayName,
Description = table.Description,
})
.ToArray();
}
}

View File

@@ -0,0 +1,12 @@
@page "/admin/{TableRoute}"
@using HopFrame.Web.Components.Components
@rendermode InteractiveServer
@layout HopFrameLayout
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider/>
<PageTitle>HopFrame - @Table.DisplayName</PageTitle>
<Table Config="Table"></Table>

View File

@@ -0,0 +1,27 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Services;
using Microsoft.AspNetCore.Components;
namespace HopFrame.Web.Components.Pages;
public partial class TablePage(IConfigAccessor accessor, NavigationManager navigator) : ComponentBase {
private const int PerPage = 25;
[Parameter]
public string TableRoute { get; set; } = null!;
public TableConfig Table { get; set; } = null!;
protected override void OnInitialized() {
base.OnInitialized();
var table = accessor.GetTableByRoute(TableRoute);
if (table is null) {
navigator.NavigateTo("/admin", true);
return;
}
Table = table;
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>true</IsPackable>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageId>HopFrame.Web</PackageId>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser"/>
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0"/>
<PackageReference Include="MudBlazor" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HopFrame.Core\HopFrame.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
using HopFrame.Core;
using HopFrame.Core.Configurators;
using HopFrame.Web.Components;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace HopFrame.Web;
/// 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 IServiceCollection AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator) {
services.AddHopFrameServices(configurator);
services.AddMudServices();
return services;
}
/// <summary>
/// Adds the HopFrame admin ui endpoints
/// </summary>
public static RazorComponentsEndpointConventionBuilder AddHopFrame(this RazorComponentsEndpointConventionBuilder builder) {
builder
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(ServiceCollectionExtensions).Assembly);
return builder;
}
/// <summary>
/// Adds the HopFrame admin ui endpoints
/// </summary>
public static WebApplication MapHopFrame(this WebApplication app) {
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
return app;
}
}

View File

@@ -0,0 +1,8 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using MudBlazor

View File

@@ -0,0 +1,3 @@
.mud-card-header-avatar {
display: flex;
}

View File

@@ -0,0 +1,170 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Configurators;
using HopFrame.Core.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Tests.Core.Configurators;
public class HopFrameConfiguratorTests {
private class TestRepository : IHopFrameRepository {
public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
throw new NotImplementedException();
}
public Task<int> CountAsync(CancellationToken ct) {
throw new NotImplementedException();
}
public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
throw new NotImplementedException();
}
public Task CreateGenericAsync(object entry, CancellationToken ct) {
throw new NotImplementedException();
}
public Task UpdateGenericAsync(object entry, CancellationToken ct) {
throw new NotImplementedException();
}
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
throw new NotImplementedException();
}
}
private HopFrameConfig CreateConfig()
=> new HopFrameConfig { Tables = new List<TableConfig>() };
private TableConfig CreateValidTable<TModel>()
=> new TableConfig {
Identifier = typeof(TModel).Name,
TableType = typeof(TModel),
RepositoryType = typeof(TestRepository),
Route = typeof(TModel).Name.ToLower(),
DisplayName = typeof(TModel).Name,
OrderIndex = 0,
Properties = new List<PropertyConfig> {
new PropertyConfig {
Identifier = "Id",
DisplayName = "Id",
Type = typeof(int),
OrderIndex = 0,
PropertyType = PropertyType.Numeric
}
}
};
// -------------------------------------------------------------
// AddRepository<TRepository, TModel>
// -------------------------------------------------------------
[Fact]
public void AddRepository_AddsTableToConfig() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
configurator.AddRepository<TestRepository, TestModel>();
Assert.Single(config.Tables);
Assert.Equal(typeof(TestModel), config.Tables[0].TableType);
}
[Fact]
public void AddRepository_RegistersRepositoryInServices() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
configurator.AddRepository<TestRepository, TestModel>();
Assert.Contains(services, d => d.ServiceType == typeof(TestRepository));
}
[Fact]
public void AddRepository_InvokesConfiguratorAction() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
bool invoked = false;
configurator.AddRepository<TestRepository, TestModel>(_ => { invoked = true; });
Assert.True(invoked);
}
// -------------------------------------------------------------
// AddTable<TModel>
// -------------------------------------------------------------
[Fact]
public void AddTable_AddsValidTableToConfig() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
var table = CreateValidTable<TestModel>();
configurator.AddTable<TestModel>(table);
Assert.Single(config.Tables);
Assert.Equal(table, config.Tables[0]);
}
[Fact]
public void AddTable_RegistersRepositoryType() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
var table = CreateValidTable<TestModel>();
configurator.AddTable<TestModel>(table);
Assert.Contains(services, d => d.ServiceType == typeof(TestRepository));
}
[Fact]
public void AddTable_Throws_WhenTableTypeDoesNotMatch() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
var table = CreateValidTable<TestModel>();
table.TableType = typeof(string); // falscher Typ
var ex = Assert.Throws<ArgumentException>(() =>
configurator.AddTable<TestModel>(table));
Assert.Contains("does not mach requested type", ex.Message);
}
[Fact]
public void AddTable_Throws_WhenValidationFails() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
var table = CreateValidTable<TestModel>();
table.DisplayName = null!; // invalid
var ex = Assert.Throws<ArgumentException>(() =>
configurator.AddTable<TestModel>(table));
Assert.Contains("validation errors", ex.Message);
Assert.Contains("DisplayName cannot be null", ex.Message);
}
[Fact]
public void AddTable_InvokesConfiguratorAction() {
var config = CreateConfig();
var services = new ServiceCollection();
var configurator = new HopFrameConfigurator(config, services);
var table = CreateValidTable<TestModel>();
bool invoked = false;
configurator.AddTable<TestModel>(table, _ => { invoked = true; });
Assert.True(invoked);
}
}

View File

@@ -0,0 +1,85 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Configurators;
namespace HopFrame.Tests.Core.Configurators;
public class PropertyConfiguratorTests {
private PropertyConfig CreateConfig(PropertyType type)
=> new PropertyConfig {
Identifier = "Test",
DisplayName = "Test",
Type = typeof(string),
OrderIndex = 0,
PropertyType = type
};
[Fact]
public void SetType_ReplacesBaseType_AndPreservesModifiers() {
// Arrange: Nullable + List + Numeric
var original = PropertyType.Numeric | PropertyType.Nullable | PropertyType.List;
var config = CreateConfig(original);
var configurator = new PropertyConfigurator(config);
// Act: change base type to Text
configurator.SetType(PropertyType.Text);
// Assert: modifiers remain, base type replaced
Assert.Equal(
PropertyType.Text | PropertyType.Nullable | PropertyType.List,
config.PropertyType
);
}
[Fact]
public void SetType_DoesNotAffectModifiers_WhenSettingSameBaseType() {
var original = PropertyType.Boolean | PropertyType.Nullable;
var config = CreateConfig(original);
var configurator = new PropertyConfigurator(config);
configurator.SetType(PropertyType.Boolean);
Assert.Equal(original, config.PropertyType);
}
[Fact]
public void SetType_CanChangeEnumToNumeric_WhileKeepingModifiers() {
var original = PropertyType.Enum | PropertyType.List;
var config = CreateConfig(original);
var configurator = new PropertyConfigurator(config);
configurator.SetType(PropertyType.Numeric);
Assert.Equal(
PropertyType.Numeric | PropertyType.List,
config.PropertyType
);
}
[Fact]
public void SetType_CanChangeToEmail_AndPreserveNullable() {
var original = PropertyType.Text | PropertyType.Nullable;
var config = CreateConfig(original);
var configurator = new PropertyConfigurator(config);
configurator.SetType(PropertyType.Email);
Assert.Equal(
PropertyType.Email | PropertyType.Nullable,
config.PropertyType
);
}
[Fact]
public void SetType_ReturnsConfigurator_ForFluentApi() {
var config = CreateConfig(PropertyType.Text);
var configurator = new PropertyConfigurator(config);
var result = configurator.SetType(PropertyType.Numeric);
Assert.Same(configurator, result);
}
}

View File

@@ -0,0 +1,147 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.EFCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Tests.Core.EFCore;
public class DbConfigPopulatorTests {
private class TestContext { }
private class TestModel {
public int Id { get; set; }
}
private class OtherModel {
public int X { get; set; }
}
private HopFrameConfig CreateConfig(params TableConfig[] tables)
=> new HopFrameConfig { Tables = tables.ToList() };
private TableConfig CreateTable(Type modelType)
=> new TableConfig {
Identifier = modelType.Name,
TableType = modelType,
RepositoryType = typeof(object),
Route = modelType.Name.ToLower(),
DisplayName = modelType.Name,
OrderIndex = 0,
Properties = new List<PropertyConfig> {
new PropertyConfig {
Identifier = "Id",
DisplayName = "Id",
Type = typeof(int),
PropertyType = PropertyType.Numeric
}
}
};
// -------------------------------------------------------------
// ConfigureRepository
// -------------------------------------------------------------
[Fact]
public void ConfigureRepository_RegistersRepositoryType() {
var services = new ServiceCollection();
var global = CreateConfig();
var prop = typeof(TestDbContext).GetProperty(nameof(TestDbContext.TestModels))!;
var identifier = DbConfigPopulator.ConfigureRepository(global, services, typeof(TestDbContext), prop);
var repoType = typeof(EfCoreRepository<TestModel, TestDbContext>);
Assert.Contains(services, d => d.ServiceType == repoType);
Assert.Single(global.Tables);
Assert.Equal(identifier, global.Tables[0].Identifier);
}
[Fact]
public void ConfigureRepository_AddsTableToGlobalConfig() {
var services = new ServiceCollection();
var global = CreateConfig();
var prop = typeof(TestDbContext).GetProperty(nameof(TestDbContext.TestModels))!;
DbConfigPopulator.ConfigureRepository(global, services, typeof(TestDbContext), prop);
Assert.Single(global.Tables);
Assert.Equal(typeof(TestModel), global.Tables[0].TableType);
}
private class TestDbContext : DbContext {
public List<TestModel> TestModels { get; set; } = new();
}
// -------------------------------------------------------------
// CheckForRelations
// -------------------------------------------------------------
private class RelationModel {
public OtherModel Single { get; set; } = new();
public List<OtherModel> Many { get; set; } = new();
}
[Fact]
public void CheckForRelations_SetsRelationFlag_ForSingleReference() {
var otherTable = CreateTable(typeof(OtherModel));
var relationTable = CreateTable(typeof(RelationModel));
relationTable.Properties = new List<PropertyConfig> {
new PropertyConfig {
Identifier = "Single",
DisplayName = "Single",
Type = typeof(OtherModel),
PropertyType = PropertyType.Text
}
};
var global = CreateConfig(otherTable, relationTable);
DbConfigPopulator.CheckForRelations(global, relationTable);
Assert.True((relationTable.Properties[0].PropertyType & PropertyType.Relation) != 0);
}
[Fact]
public void CheckForRelations_SetsRelationFlag_ForListReference() {
var otherTable = CreateTable(typeof(OtherModel));
var relationTable = CreateTable(typeof(RelationModel));
relationTable.Properties = new List<PropertyConfig> {
new PropertyConfig {
Identifier = "Many",
DisplayName = "Many",
Type = typeof(List<OtherModel>),
PropertyType = PropertyType.List
}
};
var global = CreateConfig(otherTable, relationTable);
DbConfigPopulator.CheckForRelations(global, relationTable);
Assert.True((relationTable.Properties[0].PropertyType & PropertyType.Relation) != 0);
}
[Fact]
public void CheckForRelations_DoesNotSetRelationFlag_WhenNoMatchingTableExists() {
var relationTable = CreateTable(typeof(RelationModel));
relationTable.Properties = new List<PropertyConfig> {
new PropertyConfig {
Identifier = "Single",
DisplayName = "Single",
Type = typeof(OtherModel),
PropertyType = PropertyType.Text
}
};
var global = CreateConfig(relationTable);
DbConfigPopulator.CheckForRelations(global, relationTable);
Assert.False((relationTable.Properties[0].PropertyType & PropertyType.Relation) != 0);
}
}

View File

@@ -0,0 +1,128 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Configurators;
using HopFrame.Core.EFCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Tests.Core.EFCore;
public class HopFrameConfiguratorExtensionsTests {
private class TestModelA {
public int Id { get; set; }
}
private class TestModelB {
public int Id { get; set; }
}
private class TestDbContext : DbContext {
public DbSet<TestModelA> A { get; set; } = null!;
public DbSet<TestModelB> B { get; set; } = null!;
}
private HopFrameConfig CreateConfig()
=> new HopFrameConfig { Tables = new List<TableConfig>() };
// -------------------------------------------------------------
// AddDbContext - all tables
// -------------------------------------------------------------
[Fact]
public void AddDbContext_AddsAllDbSets_WhenNoFilterProvided() {
var services = new ServiceCollection();
var config = CreateConfig();
var configurator = new HopFrameConfigurator(config, services);
configurator.AddDbContext<TestDbContext>();
Assert.Equal(2, config.Tables.Count);
Assert.Contains(config.Tables, t => t.TableType == typeof(TestModelA));
Assert.Contains(config.Tables, t => t.TableType == typeof(TestModelB));
}
[Fact]
public void AddDbContext_RegistersRepositoriesForAllDbSets() {
var services = new ServiceCollection();
var config = CreateConfig();
var configurator = new HopFrameConfigurator(config, services);
configurator.AddDbContext<TestDbContext>();
Assert.Contains(services, d => d.ServiceType == typeof(EfCoreRepository<TestModelA, TestDbContext>));
Assert.Contains(services, d => d.ServiceType == typeof(EfCoreRepository<TestModelB, TestDbContext>));
}
// -------------------------------------------------------------
// AddDbContext - filtered tables
// -------------------------------------------------------------
[Fact]
public void AddDbContext_UsesOnlyIncludedTables_WhenFilterProvided() {
var services = new ServiceCollection();
var config = CreateConfig();
var configurator = new HopFrameConfigurator(config, services);
configurator.AddDbContext<TestDbContext>(ctx => ctx.A
);
Assert.Single(config.Tables);
Assert.Equal(typeof(TestModelA), config.Tables[0].TableType);
}
[Fact]
public void AddDbContext_RegistersOnlyFilteredRepositories() {
var services = new ServiceCollection();
var config = CreateConfig();
var configurator = new HopFrameConfigurator(config, services);
configurator.AddDbContext<TestDbContext>(ctx => ctx.A
);
Assert.Contains(services, d => d.ServiceType == typeof(EfCoreRepository<TestModelA, TestDbContext>));
Assert.DoesNotContain(services, d => d.ServiceType == typeof(EfCoreRepository<TestModelB, TestDbContext>));
}
// -------------------------------------------------------------
// Relation detection
// -------------------------------------------------------------
private class RelationModel {
public TestModelA Single { get; set; } = null!;
public List<TestModelB> Many { get; set; } = new();
}
private class RelationDbContext : DbContext {
public DbSet<TestModelA> A { get; set; } = null!;
public DbSet<TestModelB> B { get; set; } = null!;
public DbSet<RelationModel> R { get; set; } = null!;
}
[Fact]
public void AddDbContext_ChecksRelationsForCreatedTables() {
var services = new ServiceCollection();
var config = CreateConfig();
var configurator = new HopFrameConfigurator(config, services);
configurator.AddDbContext<RelationDbContext>();
var relationTable = config.Tables.Single(t => t.TableType == typeof(RelationModel));
// At least one property should have the Relation flag
Assert.Contains(relationTable.Properties, p => (p.PropertyType & PropertyType.Relation) != 0);
}
// -------------------------------------------------------------
// Fluent API
// -------------------------------------------------------------
[Fact]
public void AddDbContext_ReturnsConfigurator() {
var services = new ServiceCollection();
var config = CreateConfig();
var configurator = new HopFrameConfigurator(config, services);
var result = configurator.AddDbContext<TestDbContext>();
Assert.Same(configurator, result);
}
}

View File

@@ -0,0 +1,378 @@
using System.ComponentModel.DataAnnotations;
using HopFrame.Core.Configuration;
using HopFrame.Core.Helpers;
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
namespace HopFrame.Tests.Core.Helpers;
public class ConfigurationHelperTests {
private class PropertyTypeModel {
public int Number { get; set; }
public int? NullableNumber { get; set; }
public string Text { get; set; } = "";
public bool Flag { get; set; }
public DateTime Timestamp { get; set; }
public DateOnly Date { get; set; }
public TimeOnly Time { get; set; }
public TestEnum EnumValue { get; set; }
public List<int> Numbers { get; set; } = new();
public IEnumerable<string> Strings { get; set; } = new List<string>();
[EmailAddress]
public string Email { get; set; } = "";
}
private enum TestEnum {
A,
B
}
private HopFrameConfig CreateGlobal(params TableConfig[] tables)
=> new HopFrameConfig { Tables = tables.ToList() };
private TableConfig CreateValidTable()
=> new TableConfig {
Identifier = "Test",
TableType = typeof(string),
RepositoryType = typeof(string),
Route = "/test",
DisplayName = "Test Table",
Properties = new List<PropertyConfig> {
new PropertyConfig {
Identifier = "Prop1",
DisplayName = "Property 1",
Type = typeof(int),
PropertyType = PropertyType.Numeric
}
}
};
private TableConfig CreateDummyTable(string identifier)
=> new TableConfig {
Identifier = identifier,
TableType = typeof(object),
RepositoryType = typeof(object),
Route = identifier.ToLower(),
DisplayName = identifier,
OrderIndex = 0
};
[Fact]
public void InitializeTable_UsesModelNameAsIdentifier_WhenUnique() {
var global = CreateGlobal();
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
Assert.Equal("TestModel", config.Identifier);
}
[Fact]
public void InitializeTable_GeneratesGuid_WhenIdentifierAlreadyExists() {
var existing = CreateDummyTable("TestModel");
var global = CreateGlobal(existing);
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
Assert.NotEqual("TestModel", config.Identifier);
Assert.True(Guid.TryParse(config.Identifier, out _));
}
[Fact]
public void InitializeTable_SetsBasicFieldsCorrectly() {
var global = CreateGlobal();
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
Assert.Equal(typeof(string), config.RepositoryType);
Assert.Equal(typeof(TestModel), config.TableType);
Assert.Equal("testmodels", config.Route);
Assert.Equal("TestModels", config.DisplayName);
Assert.Equal(0, config.OrderIndex);
}
[Fact]
public void InitializeTable_SetsOrderIndex_ToCurrentTableCount() {
var global = CreateGlobal(
CreateDummyTable("A"),
CreateDummyTable("B")
);
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
Assert.Equal(2, config.OrderIndex);
}
[Fact]
public void InitializeTable_CreatesPropertyConfigs_ForAllModelProperties() {
var global = CreateGlobal();
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
Assert.Equal(2, config.Properties.Count);
Assert.Contains(config.Properties, p => p.Identifier == "Id");
Assert.Contains(config.Properties, p => p.Identifier == "Name");
}
[Fact]
public void InitializeTable_SetsPropertyTypes_ForAllProperties() {
var global = CreateGlobal();
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(PropertyTypeModel));
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.Numeric);
Assert.Contains(config.Properties, p => p.PropertyType == (PropertyType.Numeric | PropertyType.Nullable));
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.Boolean);
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.DateTime);
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.DateOnly);
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.TimeOnly);
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.Enum);
Assert.Contains(config.Properties, p => p.PropertyType == (PropertyType.Numeric | PropertyType.List));
Assert.Contains(config.Properties, p => p.PropertyType == (PropertyType.Text | PropertyType.List));
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.Email);
}
[Fact]
public void InitializeProperty_UsesPropertyNameAsIdentifier_WhenUnique() {
var table = CreateDummyTable("T");
var property = typeof(TestModel).GetProperty(nameof(TestModel.Id))!;
var config = ConfigurationHelper.InitializeProperty(table, property);
Assert.Equal("Id", config.Identifier);
}
[Fact]
public void InitializeProperty_GeneratesGuid_WhenIdentifierAlreadyExists() {
var table = CreateDummyTable("T");
table.Properties.Add(new PropertyConfig {
Identifier = "Id", Type = typeof(int), DisplayName = "Id", OrderIndex = 0,
PropertyType = PropertyType.Numeric
});
var property = typeof(TestModel).GetProperty(nameof(TestModel.Id))!;
var config = ConfigurationHelper.InitializeProperty(table, property);
Assert.NotEqual("Id", config.Identifier);
Assert.True(Guid.TryParse(config.Identifier, out _));
}
[Fact]
public void InitializeProperty_SetsBasicFieldsCorrectly() {
var table = CreateDummyTable("T");
var property = typeof(TestModel).GetProperty(nameof(TestModel.Name))!;
var config = ConfigurationHelper.InitializeProperty(table, property);
Assert.Equal("Name", config.DisplayName);
Assert.Equal(typeof(string), config.Type);
Assert.Equal(0, config.OrderIndex);
}
[Fact]
public void InitializeProperty_SetsOrderIndex_ToCurrentPropertyCount() {
var table = CreateDummyTable("T");
table.Properties.Add(new PropertyConfig {
Identifier = "X", Type = typeof(int), DisplayName = "X", OrderIndex = 0, PropertyType = PropertyType.Numeric
});
var property = typeof(TestModel).GetProperty(nameof(TestModel.Name))!;
var config = ConfigurationHelper.InitializeProperty(table, property);
Assert.Equal(1, config.OrderIndex);
}
[Fact]
public void InitializeProperty_SetsPropertyType_FromInferPropertyType() {
var table = CreateDummyTable("T");
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Number))!;
var config = ConfigurationHelper.InitializeProperty(table, prop);
Assert.Equal(PropertyType.Numeric, config.PropertyType);
}
[Fact]
public void ValidateTable_ReturnsError_WhenIdentifierNotUnique() {
var config = CreateValidTable();
var global = CreateGlobal(new TableConfig {
Identifier = "Test",
DisplayName = null,
TableType = null,
RepositoryType = null,
Route = null
});
var result = ConfigurationHelper.ValidateTable(global, config).ToList();
Assert.Contains("Table identifier 'Test' is not unique", result);
}
[Fact]
public void ValidateTable_ReturnsError_WhenTableTypeIsNull() {
var config = CreateValidTable();
config.TableType = null;
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
Assert.Contains("TableType cannot be null", result);
}
[Fact]
public void ValidateTable_ReturnsError_WhenRepositoryTypeIsNull() {
var config = CreateValidTable();
config.RepositoryType = null;
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
Assert.Contains("RepositoryType cannot be null", result);
}
[Fact]
public void ValidateTable_ReturnsError_WhenRouteIsNull() {
var config = CreateValidTable();
config.Route = null;
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
Assert.Contains("Route cannot be null", result);
}
[Fact]
public void ValidateTable_ReturnsError_WhenDisplayNameIsNull() {
var config = CreateValidTable();
config.DisplayName = null;
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
Assert.Contains("DisplayName cannot be null", result);
}
[Fact]
public void ValidateTable_ReturnsError_WhenPropertyIdentifierNotUnique() {
var config = CreateValidTable();
config.Properties.Add(new PropertyConfig {
Identifier = "Prop1",
DisplayName = "Duplicate",
Type = typeof(int),
PropertyType = PropertyType.Numeric
});
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
Assert.Contains("Property identifier 'Prop1' is not unique", result);
}
[Fact]
public void ValidateTable_ReturnsError_WhenPropertyDisplayNameIsNull() {
var config = CreateValidTable();
config.Properties[0].DisplayName = null;
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
Assert.Contains("Property 'Prop1': DisplayName cannot be null", result);
}
[Fact]
public void ValidateTable_ReturnsError_WhenPropertyTypeIsNull() {
var config = CreateValidTable();
config.Properties[0].Type = null;
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
Assert.Contains("Property 'Prop1': Type cannot be null", result);
}
[Fact]
public void ValidateTable_ReturnsNoErrors_WhenConfigIsValid() {
var config = CreateValidTable();
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
Assert.Empty(result);
}
[Fact]
public void InferPropertyType_RecognizesNumeric() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Number))!;
var result = ConfigurationHelper.InferPropertyType(typeof(int), prop);
Assert.Equal(PropertyType.Numeric, result);
}
[Fact]
public void InferPropertyType_RecognizesNullableNumeric() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.NullableNumber))!;
var result = ConfigurationHelper.InferPropertyType(typeof(int?), prop);
Assert.Equal(PropertyType.Numeric | PropertyType.Nullable, result);
}
[Fact]
public void InferPropertyType_RecognizesBoolean() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Flag))!;
var result = ConfigurationHelper.InferPropertyType(typeof(bool), prop);
Assert.Equal(PropertyType.Boolean, result);
}
[Fact]
public void InferPropertyType_RecognizesDateTime() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Timestamp))!;
var result = ConfigurationHelper.InferPropertyType(typeof(DateTime), prop);
Assert.Equal(PropertyType.DateTime, result);
}
[Fact]
public void InferPropertyType_RecognizesDateOnly() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Date))!;
var result = ConfigurationHelper.InferPropertyType(typeof(DateOnly), prop);
Assert.Equal(PropertyType.DateOnly, result);
}
[Fact]
public void InferPropertyType_RecognizesTimeOnly() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Time))!;
var result = ConfigurationHelper.InferPropertyType(typeof(TimeOnly), prop);
Assert.Equal(PropertyType.TimeOnly, result);
}
[Fact]
public void InferPropertyType_RecognizesEnum() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.EnumValue))!;
var result = ConfigurationHelper.InferPropertyType(typeof(TestEnum), prop);
Assert.Equal(PropertyType.Enum, result);
}
[Fact]
public void InferPropertyType_RecognizesList() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Numbers))!;
var result = ConfigurationHelper.InferPropertyType(typeof(List<int>), prop);
Assert.Equal(PropertyType.Numeric | PropertyType.List, result);
}
[Fact]
public void InferPropertyType_RecognizesEnumerable() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Strings))!;
var result = ConfigurationHelper.InferPropertyType(typeof(IEnumerable<string>), prop);
Assert.Equal(PropertyType.Text | PropertyType.List, result);
}
[Fact]
public void InferPropertyType_RecognizesEmail() {
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Email))!;
var result = ConfigurationHelper.InferPropertyType(typeof(string), prop);
Assert.Equal(PropertyType.Email, result);
}
}

View File

@@ -0,0 +1,44 @@
using System.Linq.Expressions;
using HopFrame.Core.Helpers;
namespace HopFrame.Tests.Core.Helpers;
public class ExpressionHelperTests {
[Fact]
public void GetPropertyInfo_ReturnsPropertyInfo_WhenExpressionIsValid() {
Expression<Func<TestModel, int>> expr = x => x.Id;
var prop = ExpressionHelper.GetPropertyInfo(expr);
Assert.Equal(nameof(TestModel.Id), prop.Name);
Assert.Equal(typeof(int), prop.PropertyType);
}
[Fact]
public void GetPropertyInfo_Throws_WhenExpressionRefersToMethod() {
Expression<Func<TestModel, int>> expr = x => x.Method();
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
Assert.Contains("refers to a method", ex.Message);
}
[Fact]
public void GetPropertyInfo_Throws_WhenExpressionRefersToField() {
Expression<Func<TestModel, int>> expr = x => x.FieldBacking;
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
Assert.Contains("refers to a field", ex.Message);
}
[Fact]
public void GetPropertyInfo_Throws_WhenExpressionBodyIsNotMemberExpression() {
Expression<Func<TestModel, int>> expr = x => 5;
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
Assert.Contains("refers to a method, not a property", ex.Message);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HopFrame.Core\HopFrame.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,115 @@
using HopFrame.Core.Repositories;
using Moq;
namespace HopFrame.Tests.Core.Repositories;
public class HopFrameRepositoryTests {
private Mock<HopFrameRepository<TestModel>> CreateMock()
=> new(MockBehavior.Strict);
// -------------------------------------------------------------
// LoadPageGenericAsync
// -------------------------------------------------------------
[Fact]
public async Task LoadPageGenericAsync_DelegatesToTypedMethod() {
var mock = CreateMock();
var expected = new List<TestModel> { new TestModel { Id = 1 } };
mock.Setup(r => r.LoadPageAsync(2, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var result = await mock.Object.LoadPageGenericAsync(2, 10, CancellationToken.None);
Assert.Equal(expected, result);
}
// -------------------------------------------------------------
// SearchGenericAsync
// -------------------------------------------------------------
[Fact]
public async Task SearchGenericAsync_DelegatesToTypedMethod() {
var mock = CreateMock();
var expected = new List<TestModel> { new TestModel { Id = 5 } };
mock.Setup(r => r.SearchAsync("abc", 1, 20, It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var result = await mock.Object.SearchGenericAsync("abc", 1, 20, CancellationToken.None);
Assert.Equal(expected, result);
}
// -------------------------------------------------------------
// CreateGenericAsync
// -------------------------------------------------------------
[Fact]
public async Task CreateGenericAsync_CastsAndDelegates() {
var mock = CreateMock();
var model = new TestModel { Id = 99 };
mock.Setup(r => r.CreateAsync(model, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
await mock.Object.CreateGenericAsync(model, CancellationToken.None);
mock.Verify(r => r.CreateAsync(model, It.IsAny<CancellationToken>()), Times.Once);
}
// -------------------------------------------------------------
// UpdateGenericAsync
// -------------------------------------------------------------
[Fact]
public async Task UpdateGenericAsync_CastsAndDelegates() {
var mock = CreateMock();
var model = new TestModel { Id = 77 };
mock.Setup(r => r.UpdateAsync(model, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
await mock.Object.UpdateGenericAsync(model, CancellationToken.None);
mock.Verify(r => r.UpdateAsync(model, It.IsAny<CancellationToken>()), Times.Once);
}
// -------------------------------------------------------------
// DeleteGenericAsync
// -------------------------------------------------------------
[Fact]
public async Task DeleteGenericAsync_CastsAndDelegates() {
var mock = CreateMock();
var model = new TestModel { Id = 42 };
mock.Setup(r => r.DeleteAsync(model, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
await mock.Object.DeleteGenericAsync(model, CancellationToken.None);
mock.Verify(r => r.DeleteAsync(model, It.IsAny<CancellationToken>()), Times.Once);
}
// -------------------------------------------------------------
// CountAsync (direct abstract method)
// -------------------------------------------------------------
[Fact]
public async Task CountAsync_CanBeMockedAndReturnsValue() {
var mock = CreateMock();
mock.Setup(r => r.CountAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(123);
var result = await mock.Object.CountAsync();
Assert.Equal(123, result);
}
}

View File

@@ -0,0 +1,165 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories;
using HopFrame.Core.Services.Implementation;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace HopFrame.Tests.Core.Services.Implementation;
public class ConfigAccessorTests {
private class TestRepository : IHopFrameRepository {
public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
throw new NotImplementedException();
}
public Task<int> CountAsync(CancellationToken ct) {
throw new NotImplementedException();
}
public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
throw new NotImplementedException();
}
public Task CreateGenericAsync(object entry, CancellationToken ct) {
throw new NotImplementedException();
}
public Task UpdateGenericAsync(object entry, CancellationToken ct) {
throw new NotImplementedException();
}
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
throw new NotImplementedException();
}
}
private TableConfig CreateTable(string id, string route, Type type)
=> new TableConfig {
Identifier = id,
Route = route,
TableType = type,
RepositoryType = typeof(TestRepository),
DisplayName = id,
OrderIndex = 0,
Properties = new List<PropertyConfig> {
new PropertyConfig {
Identifier = "Id",
DisplayName = "Id",
Type = typeof(int),
OrderIndex = 0,
PropertyType = PropertyType.Numeric
}
}
};
private HopFrameConfig CreateConfig(params TableConfig[] tables)
=> new HopFrameConfig { Tables = new List<TableConfig>(tables) };
// -------------------------------------------------------------
// GetTableByIdentifier
// -------------------------------------------------------------
[Fact]
public void GetTableByIdentifier_ReturnsCorrectTable() {
var table = CreateTable("A", "a", typeof(TestModel));
var config = CreateConfig(table);
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
var result = accessor.GetTableByIdentifier("A");
Assert.Equal(table, result);
}
[Fact]
public void GetTableByIdentifier_ReturnsNull_WhenNotFound() {
var config = CreateConfig();
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
var result = accessor.GetTableByIdentifier("missing");
Assert.Null(result);
}
// -------------------------------------------------------------
// GetTableByRoute
// -------------------------------------------------------------
[Fact]
public void GetTableByRoute_ReturnsCorrectTable() {
var table = CreateTable("A", "routeA", typeof(TestModel));
var config = CreateConfig(table);
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
var result = accessor.GetTableByRoute("routeA");
Assert.Equal(table, result);
}
[Fact]
public void GetTableByRoute_ReturnsNull_WhenNotFound() {
var config = CreateConfig();
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
var result = accessor.GetTableByRoute("missing");
Assert.Null(result);
}
// -------------------------------------------------------------
// GetTableByType
// -------------------------------------------------------------
[Fact]
public void GetTableByType_ReturnsCorrectTable() {
var table = CreateTable("A", "a", typeof(TestModel));
var config = CreateConfig(table);
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
var result = accessor.GetTableByType(typeof(TestModel));
Assert.Equal(table, result);
}
[Fact]
public void GetTableByType_ReturnsNull_WhenNotFound() {
var config = CreateConfig();
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
var result = accessor.GetTableByType(typeof(TestModel));
Assert.Null(result);
}
// -------------------------------------------------------------
// LoadRepository
// -------------------------------------------------------------
[Fact]
public void LoadRepository_ResolvesRepositoryFromServiceProvider() {
var table = CreateTable("A", "a", typeof(TestModel));
var repo = new TestRepository();
var providerMock = new Mock<IServiceProvider>();
providerMock
.Setup(p => p.GetService(typeof(TestRepository)))
.Returns(repo);
var accessor = new ConfigAccessor(CreateConfig(table), providerMock.Object);
var result = accessor.LoadRepository(table);
Assert.Equal(repo, result);
}
[Fact]
public void LoadRepository_Throws_WhenServiceNotRegistered() {
var table = CreateTable("A", "a", typeof(TestModel));
var provider = new ServiceCollection().BuildServiceProvider();
var accessor = new ConfigAccessor(CreateConfig(table), provider);
Assert.Throws<InvalidOperationException>(() => accessor.LoadRepository(table));
}
}

View File

@@ -0,0 +1,10 @@
namespace HopFrame.Tests.Core;
public class TestModel {
public int Id { get; set; }
public string Name { get; set; } = null!;
public int Method() => 42;
public int FieldBacking;
}