diff --git a/src/Avolutions.Baf.Core.csproj b/src/Avolutions.Baf.Core.csproj index 53f898c..d1622ae 100644 --- a/src/Avolutions.Baf.Core.csproj +++ b/src/Avolutions.Baf.Core.csproj @@ -6,7 +6,7 @@ enable Avolutions.Baf.Core - 0.17.0 + 0.18.0 Avolutions BAF Core Avolutions diff --git a/src/Loading/Abstractions/IBlockingLoadingService.cs b/src/Loading/Abstractions/IBlockingLoadingService.cs new file mode 100644 index 0000000..61b25ae --- /dev/null +++ b/src/Loading/Abstractions/IBlockingLoadingService.cs @@ -0,0 +1,8 @@ +namespace Avolutions.Baf.Core.Loading.Abstractions; + +public interface IBlockingLoadingService : ILoadingService +{ + string LoadingText { get; } + void StartLoading(string text); + void UpdateText(string text); +} \ No newline at end of file diff --git a/src/Loading/Abstractions/ILoadingService.cs b/src/Loading/Abstractions/ILoadingService.cs new file mode 100644 index 0000000..eb6a626 --- /dev/null +++ b/src/Loading/Abstractions/ILoadingService.cs @@ -0,0 +1,9 @@ +namespace Avolutions.Baf.Core.Loading.Abstractions; + +public interface ILoadingService +{ + bool IsLoading { get; } + event Action? OnLoadingChanged; + void StartLoading(); + void StopLoading(); +} \ No newline at end of file diff --git a/src/Loading/LoadingModule.cs b/src/Loading/LoadingModule.cs index cf51347..ff941bd 100644 --- a/src/Loading/LoadingModule.cs +++ b/src/Loading/LoadingModule.cs @@ -1,4 +1,5 @@ -using Avolutions.Baf.Core.Loading.Services; +using Avolutions.Baf.Core.Loading.Abstractions; +using Avolutions.Baf.Core.Loading.Services; using Avolutions.Baf.Core.Module.Abstractions; using Microsoft.Extensions.DependencyInjection; @@ -8,6 +9,7 @@ public class LoadingModule : IFeatureModule { public void Register(IServiceCollection services) { - services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); } } \ No newline at end of file diff --git a/src/Loading/Services/BlockingLoadingService.cs b/src/Loading/Services/BlockingLoadingService.cs new file mode 100644 index 0000000..3375df0 --- /dev/null +++ b/src/Loading/Services/BlockingLoadingService.cs @@ -0,0 +1,32 @@ +using Avolutions.Baf.Core.Loading.Abstractions; + +namespace Avolutions.Baf.Core.Loading.Services; + +public class BlockingLoadingService : LoadingService, IBlockingLoadingService +{ + private string _loadingText = string.Empty; + public string LoadingText => _loadingText; + + public void StartLoading(string text) + { + _loadingText = text; + base.StartLoading(); + } + + public void UpdateText(string text) + { + if (!IsLoading) + { + return; + } + + _loadingText = text; + NotifyStateChanged(); + } + + public override void StopLoading() + { + base.StopLoading(); + _loadingText = string.Empty; + } +} diff --git a/src/Loading/Services/LoadingService.cs b/src/Loading/Services/LoadingService.cs index 854d6ea..ab9371b 100644 --- a/src/Loading/Services/LoadingService.cs +++ b/src/Loading/Services/LoadingService.cs @@ -1,6 +1,8 @@ -namespace Avolutions.Baf.Core.Loading.Services; +using Avolutions.Baf.Core.Loading.Abstractions; -public class LoadingService +namespace Avolutions.Baf.Core.Loading.Services; + +public class LoadingService : ILoadingService { private bool _isLoading; @@ -8,19 +10,30 @@ public class LoadingService public event Action? OnLoadingChanged; - public void StartLoading() + public virtual void StartLoading() { - if (_isLoading) return; + if (_isLoading) + { + return; + } _isLoading = true; OnLoadingChanged?.Invoke(); } - public void StopLoading() + public virtual void StopLoading() { - if (!_isLoading) return; + if (!_isLoading) + { + return; + } _isLoading = false; OnLoadingChanged?.Invoke(); } -} \ No newline at end of file + + protected void NotifyStateChanged() + { + OnLoadingChanged?.Invoke(); + } +} diff --git a/src/Lookups/Abstractions/ILookupHydrationCache.cs b/src/Lookups/Abstractions/ILookupHydrationCache.cs new file mode 100644 index 0000000..54f2241 --- /dev/null +++ b/src/Lookups/Abstractions/ILookupHydrationCache.cs @@ -0,0 +1,9 @@ +using Avolutions.Baf.Core.Caching.Abstractions; +using Avolutions.Baf.Core.Lookups.Models; + +namespace Avolutions.Baf.Core.Lookups.Abstractions; + +public interface ILookupHydrationCache : ICache +{ + LookupPropertyMetadata[]? GetMetadata(Type entityType); +} \ No newline at end of file diff --git a/src/Lookups/Abstractions/ILookupHydrator.cs b/src/Lookups/Abstractions/ILookupHydrator.cs new file mode 100644 index 0000000..a01cf63 --- /dev/null +++ b/src/Lookups/Abstractions/ILookupHydrator.cs @@ -0,0 +1,8 @@ +using Avolutions.Baf.Core.Entity.Abstractions; + +namespace Avolutions.Baf.Core.Lookups.Abstractions; + +public interface ILookupHydrator +{ + void Hydrate(IEntity entity); +} \ No newline at end of file diff --git a/src/Lookups/Attributes/LookupAttribute.cs b/src/Lookups/Attributes/LookupAttribute.cs new file mode 100644 index 0000000..63344f1 --- /dev/null +++ b/src/Lookups/Attributes/LookupAttribute.cs @@ -0,0 +1,7 @@ +namespace Avolutions.Baf.Core.Lookups.Attributes; + +[AttributeUsage(AttributeTargets.Property)] +public class LookupAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/Lookups/Cache/LookupHydrationCache.cs b/src/Lookups/Cache/LookupHydrationCache.cs new file mode 100644 index 0000000..7972601 --- /dev/null +++ b/src/Lookups/Cache/LookupHydrationCache.cs @@ -0,0 +1,156 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using Avolutions.Baf.Core.Caching.Abstractions; +using Avolutions.Baf.Core.Lookups.Abstractions; +using Avolutions.Baf.Core.Lookups.Attributes; +using Avolutions.Baf.Core.Lookups.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Avolutions.Baf.Core.Lookups.Cache; + +public class LookupHydrationCache : ILookupHydrationCache +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IServiceProvider _serviceProvider; + private ConcurrentDictionary _cache = new(); + + public LookupHydrationCache(IServiceScopeFactory scopeFactory, IServiceProvider serviceProvider) + { + _scopeFactory = scopeFactory; + _serviceProvider = serviceProvider; + } + + public LookupPropertyMetadata[]? GetMetadata(Type entityType) + { + return _cache.TryGetValue(entityType, out var metadata) ? metadata : null; + } + + public Task RefreshAsync(CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var newCache = new ConcurrentDictionary(); + + foreach (var entityType in context.Model.GetEntityTypes()) + { + var clrType = entityType.ClrType; + var metadata = BuildMetadata(clrType); + + if (metadata.Length > 0) + { + newCache[clrType] = metadata; + } + } + + _cache = newCache; + return Task.CompletedTask; + } + + private LookupPropertyMetadata[] BuildMetadata(Type entityType) + { + var result = new List(); + var properties = entityType.GetProperties(); + + foreach (var property in properties) + { + if (property.GetCustomAttribute() == null) + { + continue; + } + + var idPropertyName = $"{property.Name}Id"; + var idProperty = entityType.GetProperty(idPropertyName); + + if (idProperty == null || (idProperty.PropertyType != typeof(Guid) && idProperty.PropertyType != typeof(Guid?))) + { + continue; + } + + var cacheType = typeof(ILookupCache<>).MakeGenericType(property.PropertyType); + var cache = _serviceProvider.GetService(cacheType); + + if (cache == null) + { + continue; + } + + var baseCacheType = typeof(ICache<>).MakeGenericType(property.PropertyType); + var getByIdMethod = baseCacheType.GetMethod("GetByIdAsync", [typeof(Guid), typeof(CancellationToken)]); + + if (getByIdMethod == null) + { + continue; + } + + result.Add(new LookupPropertyMetadata + { + GetId = BuildGetIdDelegate(entityType, idProperty), + SetLookup = BuildSetLookupDelegate(entityType, property), + GetFromCache = BuildCacheResolver(cache, getByIdMethod) + }); + } + + return result.ToArray(); + } + + /// + /// Builds a compiled delegate to get the Id property value from an entity. + /// For an Article with QuantityUnitId, this compiles to: + /// (object entity) => ((Article)entity).QuantityUnitId + /// + private static Func BuildGetIdDelegate(Type entityType, PropertyInfo idProperty) + { + var parameter = Expression.Parameter(typeof(object), "entity"); + var cast = Expression.Convert(parameter, entityType); + var propertyAccess = Expression.Property(cast, idProperty); + + Expression result; + if (idProperty.PropertyType == typeof(Guid?)) + { + var emptyGuid = Expression.Constant(Guid.Empty); + result = Expression.Coalesce(propertyAccess, emptyGuid); + } + else + { + result = propertyAccess; + } + + var lambda = Expression.Lambda>(result, parameter); + return lambda.Compile(); + } + + /// + /// Builds a compiled delegate to set the lookup property on an entity. + /// For an Article with QuantityUnit, this compiles to: + /// (object entity, object? value) => ((Article)entity).QuantityUnit = (QuantityUnit)value + /// + private static Action BuildSetLookupDelegate(Type entityType, PropertyInfo lookupProperty) + { + var entityParam = Expression.Parameter(typeof(object), "entity"); + var valueParam = Expression.Parameter(typeof(object), "value"); + var castEntity = Expression.Convert(entityParam, entityType); + var castValue = Expression.Convert(valueParam, lookupProperty.PropertyType); + var propertyAccess = Expression.Property(castEntity, lookupProperty); + var assign = Expression.Assign(propertyAccess, castValue); + var lambda = Expression.Lambda>(assign, entityParam, valueParam); + return lambda.Compile(); + } + + /// + /// Builds a delegate to resolve a lookup from cache by Id. + /// This compiles to: + /// (Guid id) => cache.GetByIdAsync(id, CancellationToken.None).Result + /// + private static Func BuildCacheResolver(object cache, MethodInfo getByIdMethod) + { + return id => + { + var task = (Task)getByIdMethod.Invoke(cache, [id, CancellationToken.None])!; + task.GetAwaiter().GetResult(); + return task.GetType().GetProperty("Result")!.GetValue(task); + }; + } +} \ No newline at end of file diff --git a/src/Lookups/Interceptors/LookupHydrationInterceptor.cs b/src/Lookups/Interceptors/LookupHydrationInterceptor.cs new file mode 100644 index 0000000..1bdb28d --- /dev/null +++ b/src/Lookups/Interceptors/LookupHydrationInterceptor.cs @@ -0,0 +1,25 @@ +using Avolutions.Baf.Core.Entity.Abstractions; +using Avolutions.Baf.Core.Lookups.Abstractions; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Avolutions.Baf.Core.Lookups.Interceptors; + +public class LookupHydrationInterceptor : IMaterializationInterceptor +{ + private readonly ILookupHydrator _hydrator; + + public LookupHydrationInterceptor(ILookupHydrator hydrator) + { + _hydrator = hydrator; + } + + public object InitializedInstance(MaterializationInterceptionData materializationData, object entity) + { + if (entity is IEntity entityBase) + { + _hydrator.Hydrate(entityBase); + } + + return entity; + } +} \ No newline at end of file diff --git a/src/Lookups/Interceptors/LookupSaveChangesInterceptor.cs b/src/Lookups/Interceptors/LookupSaveChangesInterceptor.cs new file mode 100644 index 0000000..8d73485 --- /dev/null +++ b/src/Lookups/Interceptors/LookupSaveChangesInterceptor.cs @@ -0,0 +1,49 @@ +using Avolutions.Baf.Core.Entity.Abstractions; +using Avolutions.Baf.Core.Lookups.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Avolutions.Baf.Core.Lookups.Interceptors; + +public class LookupSaveChangesInterceptor : SaveChangesInterceptor +{ + private readonly ILookupHydrator _hydrator; + + public LookupSaveChangesInterceptor(ILookupHydrator hydrator) + { + _hydrator = hydrator; + } + + public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) + { + HydrateEntities(eventData.Context); + return base.SavedChanges(eventData, result); + } + + public override ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, + int result, + CancellationToken cancellationToken = default) + { + HydrateEntities(eventData.Context); + return base.SavedChangesAsync(eventData, result, cancellationToken); + } + + private void HydrateEntities(DbContext? context) + { + if (context == null) + { + return; + } + + var entries = context.ChangeTracker.Entries() + .Where(e => e.Entity is IEntity) + .Where(e => e.Entity is not ILookup) + .Where(e => e.State is EntityState.Added or EntityState.Modified); + + foreach (var entry in entries) + { + _hydrator.Hydrate((IEntity)entry.Entity); + } + } +} \ No newline at end of file diff --git a/src/Lookups/LookupsModule.cs b/src/Lookups/LookupsModule.cs new file mode 100644 index 0000000..d7574a4 --- /dev/null +++ b/src/Lookups/LookupsModule.cs @@ -0,0 +1,21 @@ +using Avolutions.Baf.Core.Caching.Abstractions; +using Avolutions.Baf.Core.Lookups.Abstractions; +using Avolutions.Baf.Core.Lookups.Cache; +using Avolutions.Baf.Core.Lookups.Interceptors; +using Avolutions.Baf.Core.Lookups.Services; +using Avolutions.Baf.Core.Module.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Avolutions.Baf.Core.Lookups; + +public class LookupsModule : IFeatureModule +{ + public void Register(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Lookups/Models/LookupPropertyMetadata.cs b/src/Lookups/Models/LookupPropertyMetadata.cs new file mode 100644 index 0000000..a302396 --- /dev/null +++ b/src/Lookups/Models/LookupPropertyMetadata.cs @@ -0,0 +1,8 @@ +namespace Avolutions.Baf.Core.Lookups.Models; + +public class LookupPropertyMetadata +{ + public required Func GetId { get; init; } + public required Action SetLookup { get; init; } + public required Func GetFromCache { get; init; } +} \ No newline at end of file diff --git a/src/Lookups/Services/LookupHydrator.cs b/src/Lookups/Services/LookupHydrator.cs new file mode 100644 index 0000000..d525493 --- /dev/null +++ b/src/Lookups/Services/LookupHydrator.cs @@ -0,0 +1,38 @@ +using Avolutions.Baf.Core.Entity.Abstractions; +using Avolutions.Baf.Core.Lookups.Abstractions; + +namespace Avolutions.Baf.Core.Lookups.Services; + +public class LookupHydrator : ILookupHydrator +{ + private readonly ILookupHydrationCache _hydrationCache; + + public LookupHydrator(ILookupHydrationCache hydrationCache) + { + _hydrationCache = hydrationCache; + } + + public void Hydrate(IEntity entity) + { + var metadata = _hydrationCache.GetMetadata(entity.GetType()); + if (metadata == null) + { + return; + } + + foreach (var prop in metadata) + { + var id = prop.GetId(entity); + if (id == Guid.Empty) + { + continue; + } + + var lookup = prop.GetFromCache(id); + if (lookup != null) + { + prop.SetLookup(entity, lookup); + } + } + } +} \ No newline at end of file diff --git a/src/Module/Extensions/ServiceCollectionExtensions.cs b/src/Module/Extensions/ServiceCollectionExtensions.cs index fcd57e2..10236b5 100644 --- a/src/Module/Extensions/ServiceCollectionExtensions.cs +++ b/src/Module/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using System.Reflection; using Avolutions.Baf.Core.Audit.Interceptors; using Avolutions.Baf.Core.Entity.Interceptors; +using Avolutions.Baf.Core.Lookups.Interceptors; using Avolutions.Baf.Core.Module.Abstractions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -51,7 +52,9 @@ public static IServiceCollection AddBafCore(this IServiceCollection se { options.AddInterceptors( sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() ); }); diff --git a/src/Template/Abstractions/ITemplateService.cs b/src/Template/Abstractions/ITemplateService.cs index 80276ba..80a6b09 100644 --- a/src/Template/Abstractions/ITemplateService.cs +++ b/src/Template/Abstractions/ITemplateService.cs @@ -3,4 +3,5 @@ public interface ITemplateService { Task ApplyModelToTemplateAsync(TTemplate template, object model, CancellationToken ct); + IReadOnlyList ExtractFieldNames(Stream template); } \ No newline at end of file diff --git a/src/Template/Services/HandlebarsTemplateService.cs b/src/Template/Services/HandlebarsTemplateService.cs index e313821..9cfcfd4 100644 --- a/src/Template/Services/HandlebarsTemplateService.cs +++ b/src/Template/Services/HandlebarsTemplateService.cs @@ -4,7 +4,12 @@ namespace Avolutions.Baf.Core.Template.Services; public class HandlebarsTemplateService : TemplateService { - protected override Task ApplyValuesToTemplateAsync(string template, IDictionary values, CancellationToken ct) + public override IReadOnlyList ExtractFieldNames(Stream template) + { + throw new NotImplementedException(); + } + + public override Task ApplyValuesToTemplateAsync(string template, IDictionary values, CancellationToken ct) { var compiledTemplate = Handlebars.Compile(template); var result = compiledTemplate(values); diff --git a/src/Template/Services/PdfTemplateService.cs b/src/Template/Services/PdfTemplateService.cs index 0e03127..4a5a897 100644 --- a/src/Template/Services/PdfTemplateService.cs +++ b/src/Template/Services/PdfTemplateService.cs @@ -18,8 +18,13 @@ public PdfTemplateService() _fontsConfigured = true; } } - - protected override Task ApplyValuesToTemplateAsync(Stream template, IDictionary values, CancellationToken ct) + + public override IReadOnlyList ExtractFieldNames(Stream template) + { + throw new NotImplementedException(); + } + + public override Task ApplyValuesToTemplateAsync(Stream template, IDictionary values, CancellationToken ct) { using var templateBuffer = new MemoryStream(); template.CopyTo(templateBuffer); diff --git a/src/Template/Services/TemplateService.cs b/src/Template/Services/TemplateService.cs index 8c94104..ae6740d 100644 --- a/src/Template/Services/TemplateService.cs +++ b/src/Template/Services/TemplateService.cs @@ -15,18 +15,21 @@ public Task ApplyModelToTemplateAsync(TTemplate template, object model, return ApplyValuesToTemplateAsync(template, values, ct); } - - protected abstract Task ApplyValuesToTemplateAsync( + + public abstract IReadOnlyList ExtractFieldNames(Stream template); + + public abstract Task ApplyValuesToTemplateAsync( TTemplate template, IDictionary values, CancellationToken ct); - protected Dictionary BuildValueDictionary(object model) + protected virtual Dictionary BuildValueDictionary(object model) { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); var type = model.GetType(); + var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + var result = new Dictionary(properties.Length, StringComparer.OrdinalIgnoreCase); - foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + foreach (var property in properties) { if (!property.CanRead) { diff --git a/src/Template/Services/WordTemplateService.cs b/src/Template/Services/WordTemplateService.cs index 8fbb727..705d0f3 100644 --- a/src/Template/Services/WordTemplateService.cs +++ b/src/Template/Services/WordTemplateService.cs @@ -6,10 +6,48 @@ namespace Avolutions.Baf.Core.Template.Services; public class WordTemplateService : TemplateService { - protected override Task ApplyValuesToTemplateAsync(Stream template, IDictionary values, CancellationToken ct) + public override IReadOnlyList ExtractFieldNames(Stream template) + { + var fieldNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + using var document = WordprocessingDocument.Open(template, false); + + var body = document.MainDocumentPart?.Document.Body; + if (body != null) + { + ExtractMergeFieldNames(body, fieldNames); + } + + return fieldNames.ToList(); + } + + private static void ExtractMergeFieldNames(OpenXmlElement root, HashSet fieldNames) + { + foreach (var fieldCode in root.Descendants()) + { + var instruction = fieldCode.Text; + if (string.IsNullOrWhiteSpace(instruction)) + { + continue; + } + + if (!instruction.Contains("MERGEFIELD", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var fieldName = ExtractMergeFieldName(instruction); + if (fieldName != null) + { + fieldNames.Add(fieldName); + } + } + } + + public override Task ApplyValuesToTemplateAsync(Stream template, IDictionary values, CancellationToken ct) { // Copy to a writable, seekable stream - var output = new MemoryStream(); + using var output = new MemoryStream(); template.CopyTo(output); output.Position = 0; @@ -21,8 +59,6 @@ protected override Task ApplyValuesToTemplateAsync(Stream template, IDic ReplaceMergeFields(body, values); } } - - output.Position = 0; return Task.FromResult(output.ToArray()); }