Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/Avolutions.Baf.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Nullable>enable</Nullable>

<PackageId>Avolutions.Baf.Core</PackageId>
<Version>0.16.0</Version>
<Version>0.17.0</Version>

<Title>Avolutions BAF Core</Title>
<Company>Avolutions</Company>
Expand Down Expand Up @@ -34,6 +34,7 @@

<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" />
<PackageReference Include="FluentValidation" Version="12.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
Expand All @@ -47,6 +48,7 @@
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NJsonSchema" Version="11.5.1" />
<PackageReference Include="PDFsharp" Version="6.2.3" />
</ItemGroup>

<ItemGroup>
Expand Down
12 changes: 12 additions & 0 deletions src/Caching/Abstractions/ICache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Avolutions.Baf.Core.Caching.Abstractions;

public interface ICache
{
Task RefreshAsync(CancellationToken cancellationToken = default);
}

public interface ICache<T> : ICache
{
Task<IReadOnlyList<T>> GetAllAsync(CancellationToken cancellationToken = default);
Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
}
49 changes: 49 additions & 0 deletions src/Caching/CacheBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Collections.Concurrent;
using Avolutions.Baf.Core.Caching.Abstractions;
using Microsoft.Extensions.DependencyInjection;

namespace Avolutions.Baf.Core.Caching;

public abstract class CacheBase<T> : ICache<T>
{
private readonly SemaphoreSlim _loadLock = new(1, 1);
private IReadOnlyList<T> _items = [];
private ConcurrentDictionary<Guid, T> _itemsById = new();

protected readonly IServiceScopeFactory ScopeFactory;

protected CacheBase(IServiceScopeFactory scopeFactory)
{
ScopeFactory = scopeFactory;
}

public Task<IReadOnlyList<T>> GetAllAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(_items);
}

public Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
_itemsById.TryGetValue(id, out var item);
return Task.FromResult(item);
}

public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
await _loadLock.WaitAsync(cancellationToken);
try
{
var items = await LoadAsync(cancellationToken);
_items = items;
_itemsById = new ConcurrentDictionary<Guid, T>(items.ToDictionary(GetId));
}
finally
{
_loadLock.Release();
}
}

protected abstract Task<IReadOnlyList<T>> LoadAsync(CancellationToken cancellationToken);

protected abstract Guid GetId(T item);
}
41 changes: 41 additions & 0 deletions src/Caching/CacheInitializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Avolutions.Baf.Core.Caching.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Avolutions.Baf.Core.Caching;

public class CacheInitializer : IHostedService
{
private readonly IEnumerable<ICache> _caches;
private readonly ILogger<CacheInitializer> _logger;

public CacheInitializer(IEnumerable<ICache> caches, ILogger<CacheInitializer> logger)
{
_caches = caches;
_logger = logger;
}

public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Initializing {Count} caches...", _caches.Count());

foreach (var cache in _caches)
{
var cacheName = cache.GetType().Name;
try
{
_logger.LogDebug("Loading cache: {CacheName}", cacheName);
await cache.RefreshAsync(cancellationToken);
_logger.LogDebug("Cache loaded: {CacheName}", cacheName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load cache: {CacheName}", cacheName);
}
}

_logger.LogInformation("Cache initialization completed");
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
12 changes: 12 additions & 0 deletions src/Caching/CachingModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Avolutions.Baf.Core.Module.Abstractions;
using Microsoft.Extensions.DependencyInjection;

namespace Avolutions.Baf.Core.Caching;

public class CachingModule : IFeatureModule
{
public void Register(IServiceCollection services)
{
services.AddHostedService<CacheInitializer>();
}
}
14 changes: 14 additions & 0 deletions src/Entity/Abstractions/IEntityNavigationCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Avolutions.Baf.Core.Caching.Abstractions;
using Avolutions.Baf.Core.Entity.Models;

namespace Avolutions.Baf.Core.Entity.Abstractions;

public interface IEntityNavigationCache : ICache
{
Task<EntityNavigationResult> GetNavigationAsync(Guid currentId, CancellationToken cancellationToken = default);
}

public interface IEntityNavigationCache<TEntity> : IEntityNavigationCache
where TEntity : class, IEntity
{
}
9 changes: 9 additions & 0 deletions src/Entity/Abstractions/IEntityRouteProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Avolutions.Baf.Core.Entity.Abstractions;

public interface IEntityRouteProvider<TEntity> where TEntity : class, IEntity
{
string Index { get; }
string Create { get; }
string Details(Guid id);
string Edit(Guid id);
}
5 changes: 5 additions & 0 deletions src/Entity/Abstractions/INavigable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Avolutions.Baf.Core.Entity.Abstractions;

public interface INavigable
{
}
13 changes: 0 additions & 13 deletions src/Entity/Abstractions/ITranslatable.cs

This file was deleted.

6 changes: 6 additions & 0 deletions src/Entity/Attributes/EntityNavigationKeyAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Avolutions.Baf.Core.Entity.Attributes;

[AttributeUsage(AttributeTargets.Property)]
public class EntityNavigationKeyAttribute : Attribute
{
}
12 changes: 12 additions & 0 deletions src/Entity/Attributes/EntityRouteAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Avolutions.Baf.Core.Entity.Attributes;

[AttributeUsage(AttributeTargets.Class)]
public class EntityRouteAttribute : Attribute
{
public string BaseUrl { get; }

public EntityRouteAttribute(string baseUrl)
{
BaseUrl = baseUrl.TrimEnd('/');
}
}
96 changes: 96 additions & 0 deletions src/Entity/Cache/EntityNavigationCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Linq.Expressions;
using System.Reflection;
using Avolutions.Baf.Core.Entity.Abstractions;
using Avolutions.Baf.Core.Entity.Attributes;
using Avolutions.Baf.Core.Entity.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Avolutions.Baf.Core.Entity.Cache;

public class EntityNavigationCache<TEntity> : IEntityNavigationCache<TEntity>
where TEntity : class, IEntity, INavigable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly Expression<Func<TEntity, object>> _orderByExpression;

private IReadOnlyList<Guid> _orderedIds = Array.Empty<Guid>();
private Dictionary<Guid, int> _idToIndex = new();

public EntityNavigationCache(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
_orderByExpression = BuildOrderByExpression();
}

private static Expression<Func<TEntity, object>> BuildOrderByExpression()
{
var property = typeof(TEntity)
.GetProperties()
.FirstOrDefault(p => p.GetCustomAttribute<EntityNavigationKeyAttribute>() != null);

if (property == null)
{
throw new InvalidOperationException($"Entity {typeof(TEntity).Name} must have a property with [NavigationKey] attribute.");
}

var parameter = Expression.Parameter(typeof(TEntity), "e");
var propertyAccess = Expression.Property(parameter, property);
var converted = Expression.Convert(propertyAccess, typeof(object));

return Expression.Lambda<Func<TEntity, object>>(converted, parameter);
}

public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
await _lock.WaitAsync(cancellationToken);
try
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<DbContext>();

var ordered = await context.Set<TEntity>()
.AsNoTracking()
.OrderBy(_orderByExpression)
.Select(e => e.Id)
.ToListAsync(cancellationToken);

var index = new Dictionary<Guid, int>(ordered.Count);
for (var i = 0; i < ordered.Count; i++)
{
index[ordered[i]] = i;
}

_orderedIds = ordered;
_idToIndex = index;
}
finally
{
_lock.Release();
}
}

public Task<EntityNavigationResult> GetNavigationAsync(Guid currentId, CancellationToken cancellationToken = default)
{
var ids = _orderedIds;

if (ids.Count == 0)
{
return Task.FromResult(new EntityNavigationResult(null, null, null, null, null, 0));
}

if (!_idToIndex.TryGetValue(currentId, out var currentIndex))
{
return Task.FromResult(new EntityNavigationResult(ids[0], null, null, ids[^1], null, ids.Count));
}

return Task.FromResult(new EntityNavigationResult(
ids[0],
currentIndex > 0 ? ids[currentIndex - 1] : null,
currentIndex < ids.Count - 1 ? ids[currentIndex + 1] : null,
ids[^1],
currentIndex,
ids.Count));
}
}
2 changes: 1 addition & 1 deletion src/Entity/EntityModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class EntityModule : IFeatureModule
public void Register(IServiceCollection services)
{
services.AddScoped(typeof(IEntityService<>), typeof(EntityService<>));
services.AddScoped(typeof(ITranslatableEntityService<,>), typeof(TranslatableEntityService<,>));
services.AddScoped<TrackableSaveChangesInterceptor>();
services.AddSingleton(typeof(IEntityRouteProvider<>), typeof(EntityRouteProvider<>));
}
}
22 changes: 22 additions & 0 deletions src/Entity/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Avolutions.Baf.Core.Caching.Abstractions;
using Avolutions.Baf.Core.Entity.Abstractions;
using Avolutions.Baf.Core.Entity.Cache;
using Microsoft.Extensions.DependencyInjection;

namespace Avolutions.Baf.Core.Entity.Extensions;

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddEntityNavigationCache<TEntity>(this IServiceCollection services)
where TEntity : class, IEntity, INavigable
{
var entityType = typeof(TEntity);
var cacheType = typeof(EntityNavigationCache<>).MakeGenericType(entityType);
var interfaceType = typeof(IEntityNavigationCache<>).MakeGenericType(entityType);

services.AddSingleton(interfaceType, cacheType);
services.AddSingleton(typeof(ICache), sp => sp.GetRequiredService(interfaceType));

return services;
}
}
18 changes: 0 additions & 18 deletions src/Entity/Extensions/TranslationExtensions.cs

This file was deleted.

15 changes: 15 additions & 0 deletions src/Entity/Models/EntityNavigationResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Avolutions.Baf.Core.Entity.Models;

public record EntityNavigationResult(
Guid? FirstId,
Guid? PreviousId,
Guid? NextId,
Guid? LastId,
int? CurrentIndex,
int TotalCount)
{
public bool HasFirst => CurrentIndex > 0;
public bool HasPrevious => PreviousId.HasValue;
public bool HasNext => NextId.HasValue;
public bool HasLast => CurrentIndex < TotalCount - 1;
}
Loading
Loading