diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f16b2b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + branches: [master, main] + push: + branches: [master, main] + +jobs: + test: + name: Build & Test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 9.x + 10.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test CleverCache.Tests/CleverCache.Tests.csproj --no-build --configuration Release --logger "trx" --verbosity normal diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml new file mode 100644 index 0000000..8635792 --- /dev/null +++ b/.github/workflows/publish-wiki.yml @@ -0,0 +1,22 @@ +name: Publish Wiki + +on: + push: + branches: [main] + paths: ['wiki/**'] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Push wiki to GitHub Wiki + uses: Andrew-Chen-Wang/github-wiki-action@v4 + with: + path: wiki/ + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/update-about.yml b/.github/workflows/update-about.yml new file mode 100644 index 0000000..6287bf6 --- /dev/null +++ b/.github/workflows/update-about.yml @@ -0,0 +1,45 @@ +name: Update Repo About +# Requires a classic PAT with 'public_repo' scope stored as SYNC_META_TOKEN. + +on: + push: + branches: [main] + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract metadata from csproj + id: meta + run: | + DESCRIPTION=$(grep -oPm1 '(?<=)[^<]+' CleverCache.csproj) + TAGS=$(grep -oPm1 '(?<=)[^<]+' CleverCache.csproj) + + echo "description=$DESCRIPTION" >> $GITHUB_OUTPUT + echo "tags=$TAGS" >> $GITHUB_OUTPUT + + - name: Update description and homepage + run: | + gh repo edit "$GITHUB_REPOSITORY" \ + --description "${{ steps.meta.outputs.description }}" \ + --homepage "https://github.com/$GITHUB_REPOSITORY/wiki" + env: + GH_TOKEN: ${{ secrets.SYNC_META_TOKEN }} + + - name: Replace topics from PackageTags + run: | + # Convert comma-separated tags to a JSON array of lowercase, hyphenated topics + TOPICS_JSON=$(echo "${{ steps.meta.outputs.tags }}" \ + | tr ',' '\n' \ + | sed 's/^ *//;s/ *$//' \ + | tr '[:upper:]' '[:lower:]' \ + | tr ' ' '-' \ + | jq -R . \ + | jq -sc '{names: .}') + + echo "Topics: $TOPICS_JSON" + echo "$TOPICS_JSON" | gh api --method PUT "/repos/$GITHUB_REPOSITORY/topics" --input - + env: + GH_TOKEN: ${{ secrets.SYNC_META_TOKEN }} diff --git a/.gitignore b/.gitignore index 6845257..974bcb9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ /obj /bin + +/*/bin/* +/*/obj/* \ No newline at end of file diff --git a/Attributes/DependentCachesAttribute.cs b/Attributes/DependentCachesAttribute.cs index 672e6ab..67fba2f 100644 --- a/Attributes/DependentCachesAttribute.cs +++ b/Attributes/DependentCachesAttribute.cs @@ -1,13 +1,31 @@ namespace CleverCache.Attributes; +/// +/// Declares cache dependency relationships for an entity type. +/// Use builder.Services.AddCleverCache(o => o.ScanAssemblyContaining<T>()) to register +/// these at startup. When any entry for this type is invalidated, entries for all declared +/// dependent types are also invalidated. +/// +/// +/// +/// // Invalidating Order also invalidates OrderLine and OrderNote +/// [DependentCaches([typeof(OrderLine), typeof(OrderNote)])] +/// public class Order { } +/// +/// // reverse: true also invalidates Order when OrderLine changes +/// [DependentCaches([typeof(OrderLine)], reverse: true)] +/// public class Order { } +/// +/// [AttributeUsage(AttributeTargets.Class)] -public class DependantCachesAttribute( +public class DependentCachesAttribute( Type[] types, - DependentCacheNavigationScanMode navigationScanMode = DependentCacheNavigationScanMode.None, bool reverse = false ) : Attribute { - public Type[] DependantTypes { get; } = types ?? []; - public DependentCacheNavigationScanMode NavigationScanMode { get; set; } = navigationScanMode; + /// The entity types that should also be invalidated when this type's entries are evicted. + public Type[] DependentTypes { get; } = types ?? []; + + /// When true, also registers the inverse dependency so that invalidating any dependent type also invalidates this type. public bool Reverse { get; set; } = reverse; } \ No newline at end of file diff --git a/CacheKeyManager.cs b/CacheKeyManager.cs index 48454cc..c4a6deb 100644 --- a/CacheKeyManager.cs +++ b/CacheKeyManager.cs @@ -1,7 +1,7 @@ namespace CleverCache; using System.Collections.Concurrent; -public abstract class CacheEntryManager: ICacheEntryManager +internal abstract class CacheEntryManager { // type -> set of keys (thread-safe “set” via ConcurrentDictionary) private readonly ConcurrentDictionary> _keysByType = new(); @@ -31,12 +31,38 @@ public void AddKeyToTypes(Type[] types, object key) } + /// + /// Removes a key from every type's tracked key set. + /// Called after a key is removed from the store so the tracking set stays in sync. + /// + protected void RemoveKeyFromAllTypes(object key) + { + foreach (var typeSet in _keysByType.Values) + typeSet.TryRemove(key, out _); + } + /// /// Snapshot keys for a given type (safe to enumerate). /// protected object[] SnapshotKeysFor(Type type) => _keysByType.TryGetValue(type, out var set) ? set.Keys.ToArray() : []; + /// + /// Snapshot of the full dependency graph and tracked keys. + /// + protected CleverCacheDiagnostics SnapshotDiagnostics() + { + var dependants = _dependants.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value.Keys.OrderBy(t => t.Name).ToList()); + + var keysByType = _keysByType.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value.Keys.ToList()); + + return new CleverCacheDiagnostics(dependants, keysByType); + } + /// /// Transitive closure over dependents, cycle-safe /// diff --git a/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj b/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj new file mode 100644 index 0000000..d7f96b1 --- /dev/null +++ b/CleverCache.EntityFrameworkCore/CleverCache.EntityFrameworkCore.csproj @@ -0,0 +1,23 @@ + + + CleverCache.EntityFrameworkCore + 2.0.0 + https://github.com/chunty/CleverCache/wiki/Getting-Started + EF Core integration for CleverCache — automatic cache invalidation via SaveChangesInterceptor and DbSet navigation scanning. + MemoryCache,Cache Invalidation,EntityFrameworkCore,EF Core + NuGetReadMe.md + + + + + + + + + + + + <_Parameter1>CleverCache.Tests + + + diff --git a/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs b/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..b9145ea --- /dev/null +++ b/CleverCache.EntityFrameworkCore/DependencyInjection/ApplicationBuilderExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Builder; + +namespace CleverCache.EntityFrameworkCore.DependencyInjection; + +public static class ApplicationBuilderExtensions +{ + /// + /// Scans the navigation properties for + /// and registers the discovered cache dependency rules at startup. + /// Can be called multiple times for multiple types, each with their own scan options. + /// + /// + /// Use this overload in ASP.NET Core apps where is available. + /// For worker services or console apps, use the overload instead. + /// + /// + /// + /// app.ScanDbSetsForCacheDependencies<AppDbContext>(o => + /// o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); + /// + /// + public static IApplicationBuilder ScanDbSetsForCacheDependencies( + this IApplicationBuilder app, + Action? configure = null) + where TContext : DbContext + { + app.ApplicationServices.ScanDbSetsForCacheDependencies(configure); + return app; + } + + /// + /// Scans the navigation properties for + /// and registers the discovered cache dependency rules at startup. + /// Can be called multiple times for multiple types, each with their own scan options. + /// + /// + /// Use this overload in worker services or console apps where is not available. + /// + /// + /// + /// host.Services.ScanDbSetsForCacheDependencies<AppDbContext>(o => + /// o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); + /// await host.RunAsync(); + /// + /// + public static IServiceProvider ScanDbSetsForCacheDependencies( + this IServiceProvider services, + Action? configure = null) + where TContext : DbContext + { + var cache = services.GetRequiredService(); + var scanOptions = new CleverCacheScanOptions(); + configure?.Invoke(scanOptions); + + using var scope = services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + foreach (var dep in dbContext.DiscoverDependentCaches(scanOptions)) + cache.AddDependentCache(dep.Type, dep.DependentType); + + return services; + } +} diff --git a/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs b/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1e817a5 --- /dev/null +++ b/CleverCache.EntityFrameworkCore/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace CleverCache.EntityFrameworkCore.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers CleverCache services and the EF Core . + /// This is the single call needed when using EF Core — there is no need to also call AddCleverCache(). + /// + public static IServiceCollection AddCleverCacheEntityFramework(this IServiceCollection services, + Action? options = null) + { + services.AddCleverCache(options); + services.TryAddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/CleverCache.EntityFrameworkCore/Exceptions/MissingInterceptorException.cs b/CleverCache.EntityFrameworkCore/Exceptions/MissingInterceptorException.cs new file mode 100644 index 0000000..e2047e5 --- /dev/null +++ b/CleverCache.EntityFrameworkCore/Exceptions/MissingInterceptorException.cs @@ -0,0 +1,4 @@ +namespace CleverCache.EntityFrameworkCore.Exceptions; + +internal class MissingInterceptorException() : + Exception("CleverCache requires CleverCacheInterceptor to be registered. Call AddCleverCacheEntityFramework() on your IServiceCollection."); diff --git a/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs b/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs new file mode 100644 index 0000000..a678850 --- /dev/null +++ b/CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs @@ -0,0 +1,32 @@ +using CleverCache.EntityFrameworkCore.Exceptions; +using CleverCache.EntityFrameworkCore.Helpers; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CleverCache.EntityFrameworkCore.Extensions; + +internal static class DbContextExtensions +{ + public static void EnsureCleverCacheInterceptor(this DbContext dbContext) + { + var isRegistered = dbContext.GetService().Extensions + .OfType() + .Any(e => e.Interceptors != null + && e.Interceptors.Any(i => i.GetType() == typeof(CleverCacheInterceptor))); + + if (!isRegistered) throw new MissingInterceptorException(); + } + + public static List DiscoverDependentCaches(this DbContext dbContext, CleverCacheScanOptions scanOptions) + { + if (scanOptions.NavigationScanMode == DependentCacheNavigationScanMode.None) + return []; + + HashSet dependentCaches = []; + + foreach (var entityType in dbContext.Model.GetEntityTypes()) + NavigationScanningHelper.Scan(scanOptions, entityType, dependentCaches); + + return [.. dependentCaches]; + } +} diff --git a/Extensions/DbOptionsBuilderExtensions.cs b/CleverCache.EntityFrameworkCore/Extensions/DbOptionsBuilderExtensions.cs similarity index 55% rename from Extensions/DbOptionsBuilderExtensions.cs rename to CleverCache.EntityFrameworkCore/Extensions/DbOptionsBuilderExtensions.cs index 44088e2..264ca4f 100644 --- a/Extensions/DbOptionsBuilderExtensions.cs +++ b/CleverCache.EntityFrameworkCore/Extensions/DbOptionsBuilderExtensions.cs @@ -1,13 +1,16 @@ -namespace CleverCache.Extensions; +namespace CleverCache.EntityFrameworkCore.Extensions; public static class DbOptionsBuilderExtensions { + /// + /// Adds to the . + /// Use this when you prefer constructor injection over the DI-based interceptor registration. + /// public static DbContextOptionsBuilder AddCleverCache(this DbContextOptionsBuilder optionsBuilder, IServiceProvider serviceProvider) { var interceptor = serviceProvider.GetRequiredService(); - optionsBuilder.AddInterceptors(interceptor); return optionsBuilder; } -} \ No newline at end of file +} diff --git a/CleverCache.EntityFrameworkCore/GlobalUsings.cs b/CleverCache.EntityFrameworkCore/GlobalUsings.cs new file mode 100644 index 0000000..b102bbc --- /dev/null +++ b/CleverCache.EntityFrameworkCore/GlobalUsings.cs @@ -0,0 +1,11 @@ +// Global using directives + +global using Microsoft.AspNetCore.Builder; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.DependencyInjection; +global using CleverCache; +global using CleverCache.DependencyInjection; +global using CleverCache.Models; +global using CleverCache.EntityFrameworkCore.Models; +global using CleverCache.EntityFrameworkCore.Interceptors; +global using CleverCache.EntityFrameworkCore.Extensions; diff --git a/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs b/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs new file mode 100644 index 0000000..6f3a6ad --- /dev/null +++ b/CleverCache.EntityFrameworkCore/Helpers/NavigationScanningHelper.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CleverCache.EntityFrameworkCore.Helpers; + +internal static class NavigationScanningHelper +{ + public static void Scan(CleverCacheScanOptions scanOptions, IEntityType entityType, HashSet dependentCaches, HashSet? visited = null) + { + if (scanOptions.NavigationScanMode == DependentCacheNavigationScanMode.Recursive) + { + visited ??= []; + if (!visited.Add(entityType.ClrType)) + return; + } + + foreach (var navigation in entityType.GetNavigations()) + { + var sourceType = entityType.ClrType; + var dependentEntityType = navigation.TargetEntityType; + var dependentType = dependentEntityType.ClrType; + + dependentCaches.Add(new DependentCache(sourceType, dependentType)); + if (scanOptions.ReverseNavigationDependencies) + dependentCaches.Add(new DependentCache(dependentType, sourceType)); + + if (scanOptions.NavigationScanMode == DependentCacheNavigationScanMode.Recursive) + Scan(scanOptions, dependentEntityType, dependentCaches, visited); + } + } +} diff --git a/Interceptors/CleverCacheInterceptor.cs b/CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs similarity index 73% rename from Interceptors/CleverCacheInterceptor.cs rename to CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs index 3c15c9a..8d6466d 100644 --- a/Interceptors/CleverCacheInterceptor.cs +++ b/CleverCache.EntityFrameworkCore/Interceptors/CleverCacheInterceptor.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore.Diagnostics; using System.Collections.Concurrent; -namespace CleverCache.Interceptors; +namespace CleverCache.EntityFrameworkCore.Interceptors; /// /// Interceptor to clear smart memory cache after changes are saved to the database. @@ -26,13 +26,6 @@ public override ValueTask> SavingChangesAsync( return base.SavingChangesAsync(eventData, result, cancellationToken); } - - /// - /// Synchronously handles the event after changes are saved to the database. - /// - /// The event data. - /// The result of the save operation. - /// The result of the save operation. public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) { var savedChanges = base.SavedChanges(eventData, result); @@ -40,30 +33,28 @@ public override int SavedChanges(SaveChangesCompletedEventData eventData, int re return savedChanges; } - /// - /// Asynchronously handles the event after changes are saved to the database. - /// - /// The event data. - /// The result of the save operation. - /// The cancellation token. - /// The result of the save operation. public override ValueTask SavedChangesAsync( SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) { - var savedChanges = base.SavedChangesAsync(eventData, result, cancellationToken); - InvalidateAndClear(eventData); + return InvalidateAndClearAsync(eventData, result, cancellationToken); + } + + private async ValueTask InvalidateAndClearAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken) + { + var savedChanges = await base.SavedChangesAsync(eventData, result, cancellationToken); + if (eventData.Context is null) return savedChanges; + if (!_pendingTypes.TryRemove(eventData.Context.ContextId, out var types) || types is not { Count: > 0 }) return savedChanges; + foreach (var t in types) + await cache.RemoveByTypeAsync(t, cancellationToken).ConfigureAwait(false); return savedChanges; } public override void SaveChangesFailed(DbContextErrorEventData eventData) { - // Guard for null Context (defensive; eventData.Context is nullable) if (eventData.Context != null) - { _pendingTypes.TryRemove(eventData.Context.ContextId, out _); - } base.SaveChangesFailed(eventData); } @@ -72,9 +63,7 @@ public override Task SaveChangesFailedAsync( CancellationToken cancellationToken = default) { if (eventData.Context != null) - { _pendingTypes.TryRemove(eventData.Context.ContextId, out _); - } return base.SaveChangesFailedAsync(eventData, cancellationToken); } @@ -83,25 +72,19 @@ private void CaptureTypes(DbContextEventData eventData) if (eventData.Context is null) return; var contextId = eventData.Context.ContextId; - var set = _pendingTypes.GetOrAdd(contextId, _ => []); foreach (var entry in eventData.Context.ChangeTracker.Entries()) { if (entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) - { set.Add(entry.Metadata.ClrType); - } } } - + private void InvalidateAndClear(SaveChangesCompletedEventData eventData) { if (eventData.Context is null) return; - if (!_pendingTypes.TryRemove(eventData.Context.ContextId, out var types) || types is not { Count: > 0 }) return; foreach (var t in types.Distinct()) - { cache.RemoveByType(t); - } } -} \ No newline at end of file +} diff --git a/Models/CleverCacheScanOptions.cs b/CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs similarity index 79% rename from Models/CleverCacheScanOptions.cs rename to CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs index 90b4fcf..6df5642 100644 --- a/Models/CleverCacheScanOptions.cs +++ b/CleverCache.EntityFrameworkCore/Models/CleverCacheScanOptions.cs @@ -1,10 +1,10 @@ -namespace CleverCache.Models; +namespace CleverCache.EntityFrameworkCore.Models; public class CleverCacheScanOptions( - DependentCacheNavigationScanMode navigationScanMode = DependentCacheNavigationScanMode.None, + DependentCacheNavigationScanMode navigationScanMode = DependentCacheNavigationScanMode.Direct, bool reverseNavigationDependencies = false ) { public DependentCacheNavigationScanMode NavigationScanMode { get; set; } = navigationScanMode; public bool ReverseNavigationDependencies { get; set; } = reverseNavigationDependencies; -} \ No newline at end of file +} diff --git a/Models/DependentCacheNavigationScanMode.cs b/CleverCache.EntityFrameworkCore/Models/DependentCacheNavigationScanMode.cs similarity index 50% rename from Models/DependentCacheNavigationScanMode.cs rename to CleverCache.EntityFrameworkCore/Models/DependentCacheNavigationScanMode.cs index f527a36..7b6e0c0 100644 --- a/Models/DependentCacheNavigationScanMode.cs +++ b/CleverCache.EntityFrameworkCore/Models/DependentCacheNavigationScanMode.cs @@ -1,9 +1,8 @@ -namespace CleverCache.Models; +namespace CleverCache.EntityFrameworkCore.Models; public enum DependentCacheNavigationScanMode { None, Direct, - Recursive/*, - Attribute*/ -} \ No newline at end of file + Recursive +} diff --git a/CleverCache.EntityFrameworkCore/NuGetReadMe.md b/CleverCache.EntityFrameworkCore/NuGetReadMe.md new file mode 100644 index 0000000..d302efc --- /dev/null +++ b/CleverCache.EntityFrameworkCore/NuGetReadMe.md @@ -0,0 +1,19 @@ +# CleverCache.EntityFrameworkCore + +EF Core integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — automatic cache invalidation via `SaveChangesInterceptor` and optional DbSet navigation scanning. + +## Setup + +```csharp +// Register CleverCache core +builder.Services.AddCleverCache(); + +// Register EF Core integration (interceptor only) +builder.Services.AddCleverCacheEntityFramework(); + +// Or with automatic DbSet navigation scanning +builder.Services.AddCleverCacheEntityFramework(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); +``` + +See the [Getting Started](https://github.com/chunty/CleverCache/wiki/Getting-Started) wiki for full setup instructions. diff --git a/CleverCache.MediatR/AutoCacheAttribute.cs b/CleverCache.MediatR/AutoCacheAttribute.cs new file mode 100644 index 0000000..1d88710 --- /dev/null +++ b/CleverCache.MediatR/AutoCacheAttribute.cs @@ -0,0 +1,19 @@ +namespace CleverCache.Mediatr; + +/// +/// Marks a MediatR query so that its result is automatically cached by the CleverCache pipeline behaviour. +/// The MediatR request object is used as the cache key, so the same query with different parameters +/// gets its own cache entry. +/// Cache entries are evicted automatically when any of the specified entity types changes via EF Core. +/// +/// +/// +/// [AutoCache([typeof(Order)])] +/// public record GetOrdersQuery(int CustomerId) : IRequest<List<Order>>; +/// +/// +public class AutoCacheAttribute(params Type[] types) : Attribute +{ + /// The entity types this cache entry is associated with. The entry is evicted when any of these types is invalidated. + public Type[] Types { get; } = types; +} diff --git a/Mediatr/AutoCacheBehaviour.cs b/CleverCache.MediatR/AutoCacheBehaviour.cs similarity index 52% rename from Mediatr/AutoCacheBehaviour.cs rename to CleverCache.MediatR/AutoCacheBehaviour.cs index ab58870..38069d1 100644 --- a/Mediatr/AutoCacheBehaviour.cs +++ b/CleverCache.MediatR/AutoCacheBehaviour.cs @@ -1,7 +1,8 @@ -using System.Reflection; +using System.Reflection; using MediatR; namespace CleverCache.Mediatr; + internal class AutoCacheBehaviour(ICleverCache cache) : IPipelineBehavior where TRequest : class @@ -10,35 +11,23 @@ public async Task Handle(TRequest request, RequestHandlerDelegate(); - // No types to use if (attribute is null) { return await next(cancellationToken); } - // Try to get the result from cache var result = await cache.GetOrCreateAsync( attribute.Types, request, - async _ => - { - var result = await next(cancellationToken); - return result; - } - // Call next only once when the cache is not available + () => next(cancellationToken), + cancellationToken: cancellationToken ); - // If cache has the result, return it - if (result is not null) - { - return result; - } - - // If result is null, remove from cache and call next delegate - cache.Remove(request); - return await next(cancellationToken); // Only call next here if cache miss + // GetOrCreateAsync returns TResponse? to satisfy nullability constraints on the cache store, + // but the handler is responsible for its own return value — if it legitimately returns null, + // propagate that rather than re-executing the handler. + return result ?? default!; } } diff --git a/CleverCache.MediatR/CleverCache.MediatR.csproj b/CleverCache.MediatR/CleverCache.MediatR.csproj new file mode 100644 index 0000000..f6417f7 --- /dev/null +++ b/CleverCache.MediatR/CleverCache.MediatR.csproj @@ -0,0 +1,24 @@ + + + CleverCache.MediatR + 2.0.0 + https://github.com/chunty/CleverCache/wiki/MediatR-Integration + MediatR pipeline integration for CleverCache — automatic caching of MediatR queries via the [AutoCache] attribute. + MediatR,CleverCache,Cache,Automatic Cache + NuGetReadMe.md + + + + + + + + + + + + + <_Parameter1>CleverCache.Tests + + + diff --git a/CleverCache.MediatR/InvalidateCacheBehaviour.cs b/CleverCache.MediatR/InvalidateCacheBehaviour.cs new file mode 100644 index 0000000..0aab28c --- /dev/null +++ b/CleverCache.MediatR/InvalidateCacheBehaviour.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using MediatR; + +namespace CleverCache.Mediatr; + +internal class InvalidateCacheBehaviour(ICleverCache cache) + : IPipelineBehavior + where TRequest : class +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + var attribute = typeof(TRequest).GetCustomAttribute(); + + if (attribute is null) + return await next(cancellationToken); + + var result = await next(cancellationToken); + + foreach (var type in attribute.Types) + await cache.RemoveByTypeAsync(type, cancellationToken); + + return result; + } +} diff --git a/CleverCache.MediatR/InvalidatesCacheAttribute.cs b/CleverCache.MediatR/InvalidatesCacheAttribute.cs new file mode 100644 index 0000000..bcc974b --- /dev/null +++ b/CleverCache.MediatR/InvalidatesCacheAttribute.cs @@ -0,0 +1,19 @@ +namespace CleverCache.Mediatr; + +/// +/// Marks a MediatR command so that the specified cache types are automatically invalidated +/// after the command handler completes successfully. +/// Cache is only cleared on success — a handler that throws leaves the cache untouched. +/// +/// +/// +/// [InvalidatesCache(typeof(Order), typeof(OrderLine))] +/// public record DeleteOrderCommand(int OrderId) : IRequest; +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public class InvalidatesCacheAttribute(params Type[] types) : Attribute +{ + /// The entity types whose cache entries will be evicted after the command succeeds. + public Type[] Types { get; } = types; +} diff --git a/Mediatr/MediatRServiceConfigurationExt.cs b/CleverCache.MediatR/MediatRServiceConfigurationExt.cs similarity index 57% rename from Mediatr/MediatRServiceConfigurationExt.cs rename to CleverCache.MediatR/MediatRServiceConfigurationExt.cs index d4fd1ca..631263c 100644 --- a/Mediatr/MediatRServiceConfigurationExt.cs +++ b/CleverCache.MediatR/MediatRServiceConfigurationExt.cs @@ -1,8 +1,12 @@ -namespace CleverCache.Mediatr; +using Microsoft.Extensions.DependencyInjection; + +namespace CleverCache.Mediatr; + public static class MediatRServiceConfigurationExt { public static void AddCleverCache(this MediatRServiceConfiguration cfg) { + cfg.AddOpenBehavior(typeof(InvalidateCacheBehaviour<,>)); cfg.AddOpenBehavior(typeof(AutoCacheBehaviour<,>)); } } diff --git a/CleverCache.MediatR/NuGetReadMe.md b/CleverCache.MediatR/NuGetReadMe.md new file mode 100644 index 0000000..c52fa0b --- /dev/null +++ b/CleverCache.MediatR/NuGetReadMe.md @@ -0,0 +1,25 @@ +# CleverCache.MediatR + +MediatR pipeline integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — automatically caches MediatR query results and invalidates cache on commands, with zero changes to your handlers. + +For full documentation see the [MediatR Integration wiki](https://github.com/chunty/CleverCache/wiki/MediatR-Integration). + +## Quick start + +```csharp +// 1. Register the pipeline behaviours +services.AddMediatR(cfg => +{ + cfg.AddCleverCache(); +}); + +// 2. Cache query results — decorate any query with [AutoCache] +[AutoCache([typeof(MyEntity)])] +public record GetMyQuery(int Id) : IRequest; + +// 3. Invalidate on commands — decorate any command with [InvalidatesCache] +[InvalidatesCache(typeof(MyEntity))] +public record DeleteMyCommand(int Id) : IRequest; +``` + +Cache is cleared after the command handler completes successfully. A failed handler leaves the cache untouched. diff --git a/CleverCache.Redis/CleverCache.Redis.csproj b/CleverCache.Redis/CleverCache.Redis.csproj new file mode 100644 index 0000000..cbd1b58 --- /dev/null +++ b/CleverCache.Redis/CleverCache.Redis.csproj @@ -0,0 +1,19 @@ + + + CleverCache.Redis + 2.0.0 + https://github.com/chunty/CleverCache/wiki/Cache-Providers + Redis integration for CleverCache — adds UseRedisCache() convenience registration backed by StackExchange.Redis. + Redis,StackExchange.Redis,CleverCache,Cache,Automatic Cache + NuGetReadMe.md + + + + + + + + + + + diff --git a/CleverCache.Redis/CleverCacheOptionsRedisExtensions.cs b/CleverCache.Redis/CleverCacheOptionsRedisExtensions.cs new file mode 100644 index 0000000..ee80d9a --- /dev/null +++ b/CleverCache.Redis/CleverCacheOptionsRedisExtensions.cs @@ -0,0 +1,30 @@ +using CleverCache.Implementations; +using CleverCache.Models; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CleverCache.Redis; + +public static class CleverCacheOptionsRedisExtensions +{ + /// + /// Configures CleverCache to use Redis as the cache backend via StackExchange.Redis. + /// + /// The CleverCache options. + /// The Redis connection string (e.g. "localhost:6379"). + public static CleverCacheOptions UseRedisCache(this CleverCacheOptions options, string connectionString) => + options.UseRedisCache(redis => redis.Configuration = connectionString); + + /// + /// Configures CleverCache to use Redis as the cache backend via StackExchange.Redis. + /// + /// The CleverCache options. + /// A delegate to configure the Redis cache options. + public static CleverCacheOptions UseRedisCache(this CleverCacheOptions options, Action configure) => + options.UseCustomStoreRegistration(services => + { + services.AddStackExchangeRedisCache(configure); + services.TryAddSingleton(); + }); +} diff --git a/CleverCache.Redis/NuGetReadMe.md b/CleverCache.Redis/NuGetReadMe.md new file mode 100644 index 0000000..cd17e5e --- /dev/null +++ b/CleverCache.Redis/NuGetReadMe.md @@ -0,0 +1,19 @@ +# CleverCache.Redis + +Redis integration for [CleverCache](https://www.nuget.org/packages/CleverCache) — adds a `UseRedisCache()` convenience method backed by `StackExchange.Redis`. + +For full documentation see the [Cache Providers wiki](https://github.com/chunty/CleverCache/wiki/Cache-Providers). + +## Quick start + +```csharp +// Simple connection string +builder.Services.AddCleverCache(o => o.UseRedisCache("localhost:6379")); + +// Full Redis options +builder.Services.AddCleverCache(o => o.UseRedisCache(redis => +{ + redis.Configuration = "localhost:6379"; + redis.InstanceName = "MyApp:"; +})); +``` diff --git a/CleverCache.Tests/AssemblyScanningTests.cs b/CleverCache.Tests/AssemblyScanningTests.cs new file mode 100644 index 0000000..eb8d289 --- /dev/null +++ b/CleverCache.Tests/AssemblyScanningTests.cs @@ -0,0 +1,84 @@ +using CleverCache.Attributes; +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Memory; + +namespace CleverCache.Tests; + +// Test entity types with [DependentCaches] in this assembly +[DependentCaches([typeof(ScanDependent)])] +file class ScanPrimary; +file class ScanDependent; + +[DependentCaches([typeof(ScanReverseDependent)], reverse: true)] +file class ScanReverse; +file class ScanReverseDependent; + +file class ScanNoAttribute; + +public class AssemblyScanningTests +{ + private static ICleverCache BuildCache(CleverCacheOptions options) + { + var store = new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())); + return new CleverCacheService(store, options); + } + + [Fact] + public void ScanAssemblyContaining_RegistersDependentCachesFromAttribute() + { + var options = new CleverCacheOptions(); + options.ScanAssemblyContaining(); + + Assert.Contains(new DependentCache(typeof(ScanPrimary), typeof(ScanDependent)), options.DependentCaches); + } + + [Fact] + public void ScanAssemblyContaining_Reverse_RegistersBothDirections() + { + var options = new CleverCacheOptions(); + options.ScanAssemblyContaining(); + + Assert.Contains(new DependentCache(typeof(ScanReverse), typeof(ScanReverseDependent)), options.DependentCaches); + Assert.Contains(new DependentCache(typeof(ScanReverseDependent), typeof(ScanReverse)), options.DependentCaches); + } + + [Fact] + public void ScanAssemblyContaining_TypeWithNoAttribute_NotRegistered() + { + var options = new CleverCacheOptions(); + options.ScanAssemblyContaining(); + + Assert.DoesNotContain(options.DependentCaches, d => d.Type == typeof(ScanNoAttribute)); + } + + [Fact] + public void ScanAssemblies_MultipleAssemblies_RegistersAll() + { + var options = new CleverCacheOptions(); + options.ScanAssemblies(typeof(AssemblyScanningTests).Assembly); + + Assert.Contains(new DependentCache(typeof(ScanPrimary), typeof(ScanDependent)), options.DependentCaches); + } + + [Fact] + public void CleverCacheService_InitialisesFromOptions_CascadesWork() + { + var options = new CleverCacheOptions(); + options.ScanAssemblyContaining(); + + var store = new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())); + var cache = new TestCacheManager2(store, options); + + // Adding a key for ScanPrimary should cascade to ScanDependent (wired via [DependentCaches] attribute) + cache.AddKeyToTypes([typeof(ScanPrimary)], "cascade-key"); + + Assert.Contains("cascade-key", cache.KeysFor(typeof(ScanDependent))); + } +} + +// Helper subclass to expose SnapshotKeysFor for the cascade test +file class TestCacheManager2 : CleverCacheService +{ + public TestCacheManager2(ICleverCacheStore store, CleverCacheOptions options) : base(store, options) { } + public object[] KeysFor(Type type) => SnapshotKeysFor(type); +} diff --git a/CleverCache.Tests/AutoCacheBehaviourTests.cs b/CleverCache.Tests/AutoCacheBehaviourTests.cs new file mode 100644 index 0000000..3b733ff --- /dev/null +++ b/CleverCache.Tests/AutoCacheBehaviourTests.cs @@ -0,0 +1,90 @@ +using CleverCache.Mediatr; +using MediatR; +using Moq; + +namespace CleverCache.Tests; + +[AutoCache([typeof(CachedEntity)])] +file record CachedQuery(int Id) : IRequest; + +file record UncachedQuery(int Id) : IRequest; + +file class CachedEntity; + +public class AutoCacheBehaviourTests +{ + [Fact] + public async Task Handle_NoAttribute_AlwaysCallsNext() + { + var cacheMock = new Mock(); + var sut = new AutoCacheBehaviour(cacheMock.Object); + var callCount = 0; + RequestHandlerDelegate next = _ => { callCount++; return Task.FromResult("result"); }; + + await sut.Handle(new UncachedQuery(1), next, CancellationToken.None); + await sut.Handle(new UncachedQuery(1), next, CancellationToken.None); + + Assert.Equal(2, callCount); + cacheMock.Verify(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithAttribute_CacheMiss_CallsNextAndCaches() + { + var cacheMock = new Mock(); + cacheMock + .Setup(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny())) + .Returns>, CleverCacheEntryOptions?, CancellationToken>((_, _, factory, _, _) => factory()); + + var sut = new AutoCacheBehaviour(cacheMock.Object); + var callCount = 0; + RequestHandlerDelegate next = _ => { callCount++; return Task.FromResult("fresh"); }; + + var result = await sut.Handle(new CachedQuery(1), next, CancellationToken.None); + + Assert.Equal("fresh", result); + Assert.Equal(1, callCount); + cacheMock.Verify(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithAttribute_CacheHit_DoesNotCallNext() + { + var cacheMock = new Mock(); + cacheMock + .Setup(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny())) + .ReturnsAsync("cached-value"); // returns cached directly, never invokes factory + + var sut = new AutoCacheBehaviour(cacheMock.Object); + var callCount = 0; + RequestHandlerDelegate next = _ => { callCount++; return Task.FromResult("fresh"); }; + + var result = await sut.Handle(new CachedQuery(1), next, CancellationToken.None); + + Assert.Equal("cached-value", result); + Assert.Equal(0, callCount); + } + + [Fact] + public async Task Handle_WithAttribute_NullResult_DoesNotCallNextTwice() + { + var cacheMock = new Mock(); + cacheMock + .Setup(c => c.GetOrCreateAsync( + It.IsAny(), It.IsAny(), It.IsAny>>(), It.IsAny(), It.IsAny())) + .Returns>, CleverCacheEntryOptions?, CancellationToken>((_, _, factory, _, _) => factory()); + + var sut = new AutoCacheBehaviour(cacheMock.Object); + var callCount = 0; + RequestHandlerDelegate next = _ => { callCount++; return Task.FromResult(null!); }; + + var result = await sut.Handle(new CachedQuery(1), next, CancellationToken.None); + + Assert.Null(result); + Assert.Equal(1, callCount); // handler must only execute once even when result is null + } +} diff --git a/CleverCache.Tests/BulkOperationExtensionsTests.cs b/CleverCache.Tests/BulkOperationExtensionsTests.cs new file mode 100644 index 0000000..a287567 --- /dev/null +++ b/CleverCache.Tests/BulkOperationExtensionsTests.cs @@ -0,0 +1,72 @@ +using CleverCache.Extensions; +using Moq; + +namespace CleverCache.Tests; + +file class BulkEntity; +file class OtherBulkEntity; + +public class BulkOperationExtensionsTests +{ + [Fact] + public void InvalidateCaches_CallsRemoveByTypeForEachType() + { + var cacheMock = new Mock(); + + 42.InvalidateCaches(cacheMock.Object, typeof(BulkEntity), typeof(OtherBulkEntity)); + + cacheMock.Verify(c => c.RemoveByType(typeof(BulkEntity)), Times.Once); + cacheMock.Verify(c => c.RemoveByType(typeof(OtherBulkEntity)), Times.Once); + } + + [Fact] + public void InvalidateCaches_ReturnsRowCount() + { + var cacheMock = new Mock(); + + var result = 7.InvalidateCaches(cacheMock.Object, typeof(BulkEntity)); + + Assert.Equal(7, result); + } + + [Fact] + public void InvalidateCaches_Generic_CallsRemoveByTypeForT() + { + var cacheMock = new Mock(); + + 5.InvalidateCaches(cacheMock.Object); + + cacheMock.Verify(c => c.RemoveByType(typeof(BulkEntity)), Times.Once); + } + + [Fact] + public async Task InvalidateCaches_Async_CallsRemoveByTypeForEachType() + { + var cacheMock = new Mock(); + + await Task.FromResult(10).InvalidateCaches(cacheMock.Object, typeof(BulkEntity), typeof(OtherBulkEntity)); + + cacheMock.Verify(c => c.RemoveByType(typeof(BulkEntity)), Times.Once); + cacheMock.Verify(c => c.RemoveByType(typeof(OtherBulkEntity)), Times.Once); + } + + [Fact] + public async Task InvalidateCaches_Async_ReturnsRowCount() + { + var cacheMock = new Mock(); + + var result = await Task.FromResult(3).InvalidateCaches(cacheMock.Object, typeof(BulkEntity)); + + Assert.Equal(3, result); + } + + [Fact] + public async Task InvalidateCaches_Async_Generic_CallsRemoveByTypeForT() + { + var cacheMock = new Mock(); + + await Task.FromResult(1).InvalidateCaches(cacheMock.Object); + + cacheMock.Verify(c => c.RemoveByType(typeof(BulkEntity)), Times.Once); + } +} diff --git a/CleverCache.Tests/CacheEntryManagerTests.cs b/CleverCache.Tests/CacheEntryManagerTests.cs new file mode 100644 index 0000000..45cc2b6 --- /dev/null +++ b/CleverCache.Tests/CacheEntryManagerTests.cs @@ -0,0 +1,99 @@ +namespace CleverCache.Tests; + +// Concrete subclass so we can test the abstract CacheEntryManager via its public API +file class TestCacheManager : CacheEntryManager +{ + public object[] KeysFor(Type type) => SnapshotKeysFor(type); + public CleverCacheDiagnostics Diagnostics() => SnapshotDiagnostics(); +} + +public class CacheEntryManagerTests +{ + [Fact] + public void AddKeyToTypes_AssociatesKeyWithType() + { + var mgr = new TestCacheManager(); + + mgr.AddKeyToTypes([typeof(string)], "myKey"); + + Assert.Contains("myKey", mgr.KeysFor(typeof(string))); + } + + [Fact] + public void AddKeyToTypes_MultipleTypes_KeyAssociatedWithAll() + { + var mgr = new TestCacheManager(); + + mgr.AddKeyToTypes([typeof(string), typeof(int)], "sharedKey"); + + Assert.Contains("sharedKey", mgr.KeysFor(typeof(string))); + Assert.Contains("sharedKey", mgr.KeysFor(typeof(int))); + } + + [Fact] + public void AddDependentCache_KeyForPrimaryAlsoRegisteredUnderDependent() + { + var mgr = new TestCacheManager(); + mgr.AddDependentCache(typeof(string), typeof(int)); + + mgr.AddKeyToTypes([typeof(string)], "key1"); + + Assert.Contains("key1", mgr.KeysFor(typeof(string))); + Assert.Contains("key1", mgr.KeysFor(typeof(int))); + } + + [Fact] + public void AddKeyToTypes_TransitiveDependency_KeyRegisteredUnderAllTypes() + { + // A -> B -> C: key for A should appear under A, B, and C + var mgr = new TestCacheManager(); + mgr.AddDependentCache(typeof(string), typeof(int)); + mgr.AddDependentCache(typeof(int), typeof(bool)); + + mgr.AddKeyToTypes([typeof(string)], "transitiveKey"); + + Assert.Contains("transitiveKey", mgr.KeysFor(typeof(string))); + Assert.Contains("transitiveKey", mgr.KeysFor(typeof(int))); + Assert.Contains("transitiveKey", mgr.KeysFor(typeof(bool))); + } + + [Fact] + public void AddKeyToTypes_CyclicDependency_DoesNotInfiniteLoop() + { + // A -> B -> A: should terminate cleanly + var mgr = new TestCacheManager(); + mgr.AddDependentCache(typeof(string), typeof(int)); + mgr.AddDependentCache(typeof(int), typeof(string)); + + var ex = Record.Exception(() => mgr.AddKeyToTypes([typeof(string)], "cycleKey")); + + Assert.Null(ex); + Assert.Contains("cycleKey", mgr.KeysFor(typeof(string))); + Assert.Contains("cycleKey", mgr.KeysFor(typeof(int))); + } + + [Fact] + public void SnapshotKeysFor_UnknownType_ReturnsEmpty() + { + var mgr = new TestCacheManager(); + + Assert.Empty(mgr.KeysFor(typeof(double))); + } + + [Fact] + public void SnapshotDiagnostics_ReflectsDependantsAndKeys() + { + var mgr = new TestCacheManager(); + mgr.AddDependentCache(typeof(string), typeof(int)); + mgr.AddKeyToTypes([typeof(string)], "k1"); + + var d = mgr.Diagnostics(); + + Assert.True(d.Dependants.ContainsKey(typeof(string))); + Assert.Contains(typeof(int), d.Dependants[typeof(string)]); + Assert.True(d.KeysByType.ContainsKey(typeof(string))); + Assert.Contains("k1", d.KeysByType[typeof(string)]); + // int inherits the key via cascade expansion + Assert.Contains("k1", d.KeysByType[typeof(int)]); + } +} diff --git a/CleverCache.Tests/CleverCache.Tests.csproj b/CleverCache.Tests/CleverCache.Tests.csproj new file mode 100644 index 0000000..2c7b8ae --- /dev/null +++ b/CleverCache.Tests/CleverCache.Tests.csproj @@ -0,0 +1,23 @@ + + + CleverCache.Tests + false + true + Exe + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/CleverCache.Tests/CleverCacheInterceptorTests.cs b/CleverCache.Tests/CleverCacheInterceptorTests.cs new file mode 100644 index 0000000..9799358 --- /dev/null +++ b/CleverCache.Tests/CleverCacheInterceptorTests.cs @@ -0,0 +1,112 @@ +using CleverCache.EntityFrameworkCore.Interceptors; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace CleverCache.Tests; + +internal class IcOrder { public int Id { get; set; } public string Name { get; set; } = ""; } +internal class IcCustomer { public int Id { get; set; } } + +internal class IcDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + public DbSet Customers => Set(); +} + +public class CleverCacheInterceptorTests +{ + private static (IcDbContext context, Mock mockCache) CreateContext() + { + var mockCache = new Mock(); + var interceptor = new CleverCacheInterceptor(mockCache.Object); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options; + return (new IcDbContext(options), mockCache); + } + + [Fact] + public void SavedChanges_EntityAdded_CallsRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } + + [Fact] + public void SavedChanges_EntityModified_CallsRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + context.SaveChanges(); + mockCache.Invocations.Clear(); + + var order = context.Orders.Find(1)!; + order.Name = "Updated"; + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } + + [Fact] + public void SavedChanges_EntityDeleted_CallsRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + context.SaveChanges(); + mockCache.Invocations.Clear(); + + context.Orders.Remove(context.Orders.Find(1)!); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } + + [Fact] + public void SavedChanges_MultipleEntitiesOfSameType_CallsRemoveByTypeOnce() + { + var (context, mockCache) = CreateContext(); + context.Orders.AddRange( + new IcOrder { Id = 1, Name = "A" }, + new IcOrder { Id = 2, Name = "B" }); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + } + + [Fact] + public void SavedChanges_MultipleEntityTypes_CallsRemoveByTypeForEach() + { + var (context, mockCache) = CreateContext(); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + context.Customers.Add(new IcCustomer { Id = 1 }); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(typeof(IcOrder)), Times.Once); + mockCache.Verify(c => c.RemoveByType(typeof(IcCustomer)), Times.Once); + } + + [Fact] + public void SavedChanges_NoChanges_DoesNotCallRemoveByType() + { + var (context, mockCache) = CreateContext(); + context.SaveChanges(); + + mockCache.Verify(c => c.RemoveByType(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SavedChangesAsync_EntityAdded_CallsRemoveByTypeAsync() + { + var (context, mockCache) = CreateContext(); + mockCache.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + context.Orders.Add(new IcOrder { Id = 1, Name = "Test" }); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + + mockCache.Verify(c => c.RemoveByTypeAsync(typeof(IcOrder), It.IsAny()), Times.Once); + } +} diff --git a/CleverCache.Tests/CleverCacheServiceTests.cs b/CleverCache.Tests/CleverCacheServiceTests.cs new file mode 100644 index 0000000..4987716 --- /dev/null +++ b/CleverCache.Tests/CleverCacheServiceTests.cs @@ -0,0 +1,249 @@ +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Memory; +using Moq; + +namespace CleverCache.Tests; + +public class CleverCacheServiceTests +{ + private static CleverCacheService CreateService(ICleverCacheStore? store = null) + => new(store ?? new MemoryCacheStore(new MemoryCache(new MemoryCacheOptions())), new CleverCacheOptions()); + + [Fact] + public void GetOrCreate_CacheMiss_InvokesFactory() + { + var sut = CreateService(); + var callCount = 0; + + var result = sut.GetOrCreate([typeof(string)], "key1", () => { callCount++; return 42; }); + + Assert.Equal(42, result); + Assert.Equal(1, callCount); + } + + [Fact] + public void GetOrCreate_CacheHit_DoesNotInvokeFactoryAgain() + { + var sut = CreateService(); + var callCount = 0; + + sut.GetOrCreate([typeof(string)], "key1", () => { callCount++; return 42; }); + var result = sut.GetOrCreate([typeof(string)], "key1", () => { callCount++; return 99; }); + + Assert.Equal(42, result); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task GetOrCreateAsync_CacheMiss_InvokesFactory() + { + var sut = CreateService(); + var callCount = 0; + + var result = await sut.GetOrCreateAsync([typeof(string)], "key1", + async () => { callCount++; await Task.Yield(); return 42; }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(42, result); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task GetOrCreateAsync_CacheHit_DoesNotInvokeFactoryAgain() + { + var sut = CreateService(); + var callCount = 0; + + await sut.GetOrCreateAsync([typeof(string)], "key1", + async () => { callCount++; await Task.Yield(); return 42; }, + cancellationToken: TestContext.Current.CancellationToken); + var result = await sut.GetOrCreateAsync([typeof(string)], "key1", + async () => { callCount++; await Task.Yield(); return 99; }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(42, result); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task Remove_AllowsFactoryToBeCalledAgain() + { + var sut = CreateService(); + var callCount = 0; + + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 1; }, cancellationToken: TestContext.Current.CancellationToken); + sut.Remove("key1"); + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 2; }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, callCount); + } + + [Fact] + public async Task RemoveByType_RemovesAllKeysForType() + { + var sut = CreateService(); + var callCount = 0; + + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { await Task.Yield(); return 1; }, cancellationToken: TestContext.Current.CancellationToken); + await sut.GetOrCreateAsync([typeof(string)], "key2", async () => { await Task.Yield(); return 2; }, cancellationToken: TestContext.Current.CancellationToken); + + sut.RemoveByType(typeof(string)); + + await sut.GetOrCreateAsync([typeof(string)], "key1", async () => { callCount++; await Task.Yield(); return 99; }, cancellationToken: TestContext.Current.CancellationToken); + await sut.GetOrCreateAsync([typeof(string)], "key2", async () => { callCount++; await Task.Yield(); return 99; }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, callCount); + } + + /// + /// Regression test: with the old global SemaphoreSlim, a factory that triggered another + /// cached call (different key) would deadlock indefinitely. Per-key locking must not deadlock. + /// + [Fact] + public async Task GetOrCreateAsync_NestedCachedCallWithDifferentKey_DoesNotDeadlock() + { + var sut = CreateService(); + + var outerCompleted = false; + + var task = sut.GetOrCreateAsync([typeof(string)], "outer-key", async () => + { + // Simulate a nested cached call with a DIFFERENT key — the original deadlock scenario + var inner = await sut.GetOrCreateAsync([typeof(int)], "inner-key", + async () => { await Task.Yield(); return 99; }, + cancellationToken: TestContext.Current.CancellationToken); + + outerCompleted = true; + return $"result:{inner}"; + }, cancellationToken: TestContext.Current.CancellationToken); + + // If deadlocked, this will throw after the timeout rather than hang the test suite + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + + Assert.Same(task, completed); // task finished, not the timeout + Assert.True(outerCompleted); + Assert.Equal("result:99", await task); + } + + [Fact] + public async Task GetOrCreateAsync_ConcurrentRequestsSameKey_FactoryCalledOnce() + { + var sut = CreateService(); + var callCount = 0; + var tcs = new TaskCompletionSource(); + + // 20 concurrent calls all waiting on the same factory + var tasks = Enumerable.Range(0, 20).Select(_ => + sut.GetOrCreateAsync([typeof(string)], "stampede-key", async () => + { + await tcs.Task; // all wait here until released + Interlocked.Increment(ref callCount); + return 42; + })).ToArray(); + + tcs.SetResult(); // release all waiters simultaneously + var results = await Task.WhenAll(tasks); + + Assert.Equal(1, callCount); + Assert.All(results, r => Assert.Equal(42, r)); + } + + [Fact] + public void GetDiagnostics_ReturnsCascadesAndKeys() + { + var sut = CreateService(); + sut.AddDependentCache(typeof(string), typeof(int)); + sut.GetOrCreate([typeof(string)], "k1", () => 1); + + var d = sut.GetDiagnostics(); + + Assert.Contains(typeof(int), d.Dependants[typeof(string)]); + Assert.Contains("k1", d.KeysByType[typeof(string)]); + } + + [Fact] + public void RenderDependencyTree_ContainsTypeNamesAndKeys() + { + var sut = CreateService(); + sut.AddDependentCache(typeof(string), typeof(int)); + sut.GetOrCreate([typeof(string)], "my-key", () => 1); + + var output = sut.RenderDependencyTree(); + + Assert.Contains("String", output); + Assert.Contains("Int32", output); + Assert.Contains("my-key", output); + Assert.Contains("cascades to", output); + } + + [Fact] + public void RenderDependencyTree_NoTypes_ReturnsEmptyMessage() + { + var sut = CreateService(); + + var output = sut.RenderDependencyTree(); + + Assert.Contains("no types registered", output); + } + + [Fact] + public async Task RemoveByTypeAsync_RemovesAllKeysForType() + { + var storeMock = new Mock(); + storeMock.Setup(s => s.TryGet(It.IsAny(), out It.Ref.IsAny)).Returns(false); + storeMock.Setup(s => s.TryGetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((false, default(int))); + storeMock.Setup(s => s.RemoveAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var sut = new CleverCacheService(storeMock.Object, new CleverCacheOptions()); + + sut.GetOrCreate([typeof(string)], "k1", () => 1); + sut.GetOrCreate([typeof(string)], "k2", () => 2); + await sut.RemoveByTypeAsync(typeof(string), TestContext.Current.CancellationToken); + + storeMock.Verify(s => s.RemoveAsync("k1", It.IsAny()), Times.Once); + storeMock.Verify(s => s.RemoveAsync("k2", It.IsAny()), Times.Once); + } + + [Fact] + public void Remove_CleansUpKeyFromAllTypeSets() + { + var sut = CreateService(); + sut.AddDependentCache(typeof(string), typeof(int)); + sut.GetOrCreate([typeof(string)], "k1", () => 1); // k1 added to String and Int32 + + sut.Remove("k1"); + + var d = sut.GetDiagnostics(); + Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(string)) ?? []); + Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(int)) ?? []); + } + + [Fact] + public void RemoveByType_CleansUpKeysFromAllTypeSets() + { + var sut = CreateService(); + sut.AddDependentCache(typeof(string), typeof(int)); + sut.GetOrCreate([typeof(string)], "k1", () => 1); // k1 added to String and Int32 transitively + + sut.RemoveByType(typeof(string)); + + var d = sut.GetDiagnostics(); + Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(string)) ?? []); + Assert.DoesNotContain("k1", d.KeysByType.GetValueOrDefault(typeof(int)) ?? []); // must also clean up dependent type + } + + [Fact] + public void Eviction_WhenStoreSupportsNotification_CleansUpKeyFromTypeSets() + { + // MemoryCacheStore implements IEvictionNotifyingStore — eviction should clean up _keysByType + var sut = CreateService(); + sut.GetOrCreate([typeof(string)], "k1", () => 1); + + Assert.Contains("k1", sut.GetDiagnostics().KeysByType[typeof(string)]); + + sut.Remove("k1"); // triggers store eviction → PostEvictionCallback → RemoveKeyFromAllTypes + + Assert.DoesNotContain("k1", sut.GetDiagnostics().KeysByType.GetValueOrDefault(typeof(string)) ?? []); + } +} diff --git a/CleverCache.Tests/DbContextExtensionsTests.cs b/CleverCache.Tests/DbContextExtensionsTests.cs new file mode 100644 index 0000000..7040b46 --- /dev/null +++ b/CleverCache.Tests/DbContextExtensionsTests.cs @@ -0,0 +1,107 @@ +using CleverCache.EntityFrameworkCore.Exceptions; +using CleverCache.EntityFrameworkCore.Extensions; +using CleverCache.EntityFrameworkCore.Interceptors; +using CleverCache.EntityFrameworkCore.Models; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace CleverCache.Tests; + +// Navigation model: DbExt prefix avoids any collision with other test types +internal class DbExtOrder { public int Id { get; set; } public List Lines { get; set; } = []; } +internal class DbExtOrderLine { public int Id { get; set; } public int DbExtOrderId { get; set; } public DbExtOrder? Order { get; set; } } + +// Recursive model: A → B → C +internal class DbExtA { public int Id { get; set; } public DbExtB? B { get; set; } } +internal class DbExtB { public int Id { get; set; } public DbExtC? C { get; set; } } +internal class DbExtC { public int Id { get; set; } } + +// Circular model: P ↔ Q (bidirectional) — DbExtQ is the dependent (has FK) +internal class DbExtP { public int Id { get; set; } public DbExtQ? Q { get; set; } } +internal class DbExtQ { public int Id { get; set; } public int DbExtPId { get; set; } public DbExtP? P { get; set; } } + +internal class ScanTestDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + public DbSet OrderLines => Set(); + public DbSet As => Set(); + public DbSet Bs => Set(); + public DbSet Cs => Set(); + public DbSet Ps => Set(); + public DbSet Qs => Set(); +} + +public class DbContextExtensionsTests +{ + private static ScanTestDbContext CreateContext(bool withInterceptor = false) + { + var builder = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()); + if (withInterceptor) + builder.AddInterceptors(new CleverCacheInterceptor(new Mock().Object)); + return new ScanTestDbContext(builder.Options); + } + + [Fact] + public void EnsureCleverCacheInterceptor_WithInterceptor_DoesNotThrow() + { + using var context = CreateContext(withInterceptor: true); + context.EnsureCleverCacheInterceptor(); // should not throw + } + + [Fact] + public void EnsureCleverCacheInterceptor_WithoutInterceptor_ThrowsMissingInterceptorException() + { + using var context = CreateContext(withInterceptor: false); + Assert.Throws(() => context.EnsureCleverCacheInterceptor()); + } + + [Fact] + public void DiscoverDependentCaches_DirectNavigation_DiscoversRelationship() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.Direct)); + + Assert.Contains(result, d => d.Type == typeof(DbExtOrder) && d.DependentType == typeof(DbExtOrderLine)); + } + + [Fact] + public void DiscoverDependentCaches_NoneMode_ReturnsEmpty() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.None)); + + Assert.Empty(result); + } + + [Fact] + public void DiscoverDependentCaches_RecursiveNavigation_DiscoversTransitiveRelationships() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.Recursive)); + + Assert.Contains(result, d => d.Type == typeof(DbExtA) && d.DependentType == typeof(DbExtB)); + Assert.Contains(result, d => d.Type == typeof(DbExtB) && d.DependentType == typeof(DbExtC)); + } + + [Fact] + public void DiscoverDependentCaches_BidirectionalNavigation_RegistersBothDirections() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.Direct)); + + Assert.Contains(result, d => d.Type == typeof(DbExtOrder) && d.DependentType == typeof(DbExtOrderLine)); + Assert.Contains(result, d => d.Type == typeof(DbExtOrderLine) && d.DependentType == typeof(DbExtOrder)); + } + + [Fact] + public void DiscoverDependentCaches_CircularNavigation_DoesNotStackOverflow() + { + using var context = CreateContext(); + var result = context.DiscoverDependentCaches(new CleverCacheScanOptions(DependentCacheNavigationScanMode.Recursive)); + + Assert.Contains(result, d => d.Type == typeof(DbExtP) && d.DependentType == typeof(DbExtQ)); + Assert.Contains(result, d => d.Type == typeof(DbExtQ) && d.DependentType == typeof(DbExtP)); + } +} + diff --git a/CleverCache.Tests/DistributedCacheStoreTests.cs b/CleverCache.Tests/DistributedCacheStoreTests.cs new file mode 100644 index 0000000..6c7f037 --- /dev/null +++ b/CleverCache.Tests/DistributedCacheStoreTests.cs @@ -0,0 +1,73 @@ +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace CleverCache.Tests; + +public class DistributedCacheStoreTests +{ + private static DistributedCacheStore CreateStore() + { + var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + return new DistributedCacheStore(cache); + } + + [Fact] + public void TryGet_MissingKey_ReturnsFalse() + { + var store = CreateStore(); + + var found = store.TryGet("missing", out var value); + + Assert.False(found); + Assert.Null(value); + } + + [Fact] + public void Set_ThenTryGet_ReturnsDeserializedValue() + { + var store = CreateStore(); + + store.Set("key1", "distributed-hello"); + var found = store.TryGet("key1", out var value); + + Assert.True(found); + Assert.Equal("distributed-hello", value); + } + + [Fact] + public void Remove_AfterSet_TryGetReturnsFalse() + { + var store = CreateStore(); + store.Set("key1", 42); + + store.Remove("key1"); + + Assert.False(store.TryGet("key1", out _)); + } + + [Fact] + public async Task SetAsync_ThenTryGetAsync_ReturnsValue() + { + var store = CreateStore(); + + await store.SetAsync("key1", "async-value", cancellationToken: TestContext.Current.CancellationToken); + var (found, value) = await store.TryGetAsync("key1", TestContext.Current.CancellationToken); + + Assert.True(found); + Assert.Equal("async-value", value); + } + + [Fact] + public async Task RemoveAsync_AfterSetAsync_TryGetReturnsFalse() + { + var store = CreateStore(); + await store.SetAsync("key1", 99, cancellationToken: TestContext.Current.CancellationToken); + + await store.RemoveAsync("key1", TestContext.Current.CancellationToken); + + var (found, _) = await store.TryGetAsync("key1", TestContext.Current.CancellationToken); + Assert.False(found); + } +} diff --git a/CleverCache.Tests/FakeCacheTests.cs b/CleverCache.Tests/FakeCacheTests.cs new file mode 100644 index 0000000..a56cd20 --- /dev/null +++ b/CleverCache.Tests/FakeCacheTests.cs @@ -0,0 +1,60 @@ +namespace CleverCache.Tests; + +public class FakeCacheTests +{ + [Fact] + public void GetOrCreate_AlwaysCallsFactory() + { + var fake = new FakeCache(); + var callCount = 0; + + fake.GetOrCreate([typeof(string)], "key", () => { callCount++; return 1; }); + fake.GetOrCreate([typeof(string)], "key", () => { callCount++; return 2; }); + + Assert.Equal(2, callCount); + } + + [Fact] + public async Task GetOrCreateAsync_AlwaysCallsFactory() + { + var fake = new FakeCache(); + var callCount = 0; + + await fake.GetOrCreateAsync([typeof(string)], "key", async () => { callCount++; await Task.Yield(); return 1; }, cancellationToken: TestContext.Current.CancellationToken); + await fake.GetOrCreateAsync([typeof(string)], "key", async () => { callCount++; await Task.Yield(); return 2; }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, callCount); + } + + [Fact] + public void Remove_DoesNotThrow() + { + var fake = new FakeCache(); + var ex = Record.Exception(() => fake.Remove("any-key")); + Assert.Null(ex); + } + + [Fact] + public void RemoveByType_DoesNotThrow() + { + var fake = new FakeCache(); + var ex = Record.Exception(() => fake.RemoveByType(typeof(string))); + Assert.Null(ex); + } + + [Fact] + public async Task RemoveByTypeAsync_DoesNotThrow() + { + var fake = new FakeCache(); + var ex = await Record.ExceptionAsync(() => fake.RemoveByTypeAsync(typeof(string), TestContext.Current.CancellationToken)); + Assert.Null(ex); + } + + [Fact] + public void AddKeyToTypes_DoesNotThrow() + { + var fake = new FakeCache(); + var ex = Record.Exception(() => fake.AddKeyToTypes([typeof(string)], "key")); + Assert.Null(ex); + } +} diff --git a/CleverCache.Tests/GlobalUsings.cs b/CleverCache.Tests/GlobalUsings.cs new file mode 100644 index 0000000..b4e18b1 --- /dev/null +++ b/CleverCache.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Xunit; +global using CleverCache.Models; +global using CleverCache.Extensions; +global using CleverCache.EntityFrameworkCore.Models; diff --git a/CleverCache.Tests/InvalidateCacheBehaviourTests.cs b/CleverCache.Tests/InvalidateCacheBehaviourTests.cs new file mode 100644 index 0000000..034fe63 --- /dev/null +++ b/CleverCache.Tests/InvalidateCacheBehaviourTests.cs @@ -0,0 +1,92 @@ +using CleverCache.Mediatr; +using MediatR; +using Moq; + +namespace CleverCache.Tests; + +[InvalidatesCache(typeof(InvalidatedEntity), typeof(DependentEntity))] +file record DeleteCommand(int Id) : IRequest; + +file record NoInvalidationCommand(int Id) : IRequest; + +file class InvalidatedEntity; +file class DependentEntity; + +public class InvalidateCacheBehaviourTests +{ + [Fact] + public async Task Handle_NoAttribute_DoesNotInvalidate() + { + var cacheMock = new Mock(); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + RequestHandlerDelegate next = _ => Task.FromResult(true); + + await sut.Handle(new NoInvalidationCommand(1), next, CancellationToken.None); + + cacheMock.Verify(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithAttribute_InvalidatesAllDeclaredTypes() + { + var cacheMock = new Mock(); + cacheMock.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + RequestHandlerDelegate next = _ => Task.FromResult(true); + + await sut.Handle(new DeleteCommand(1), next, CancellationToken.None); + + cacheMock.Verify(c => c.RemoveByTypeAsync(typeof(InvalidatedEntity), It.IsAny()), Times.Once); + cacheMock.Verify(c => c.RemoveByTypeAsync(typeof(DependentEntity), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithAttribute_InvalidatesAfterNextCompletes() + { + var cacheMock = new Mock(); + var order = new List(); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + + cacheMock.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) + .Callback((_, _) => order.Add("invalidate")) + .Returns(Task.CompletedTask); + + RequestHandlerDelegate next = _ => + { + order.Add("handler"); + return Task.FromResult(true); + }; + + await sut.Handle(new DeleteCommand(1), next, CancellationToken.None); + + Assert.Equal(["handler", "invalidate", "invalidate"], order); + } + + [Fact] + public async Task Handle_WithAttribute_ReturnsHandlerResult() + { + var cacheMock = new Mock(); + cacheMock.Setup(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + RequestHandlerDelegate next = _ => Task.FromResult(true); + + var result = await sut.Handle(new DeleteCommand(1), next, CancellationToken.None); + + Assert.True(result); + } + + [Fact] + public async Task Handle_HandlerThrows_DoesNotInvalidate() + { + var cacheMock = new Mock(); + var sut = new InvalidateCacheBehaviour(cacheMock.Object); + RequestHandlerDelegate next = _ => Task.FromException(new InvalidOperationException("db error")); + + await Assert.ThrowsAsync(() => + sut.Handle(new DeleteCommand(1), next, CancellationToken.None)); + + cacheMock.Verify(c => c.RemoveByTypeAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/CleverCache.Tests/MemoryCacheStoreTests.cs b/CleverCache.Tests/MemoryCacheStoreTests.cs new file mode 100644 index 0000000..20ff02b --- /dev/null +++ b/CleverCache.Tests/MemoryCacheStoreTests.cs @@ -0,0 +1,97 @@ +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Memory; + +namespace CleverCache.Tests; + +public class MemoryCacheStoreTests +{ + private static MemoryCacheStore CreateStore() + => new(new MemoryCache(new MemoryCacheOptions())); + + [Fact] + public void TryGet_MissingKey_ReturnsFalse() + { + var store = CreateStore(); + + var found = store.TryGet("missing", out var value); + + Assert.False(found); + Assert.Null(value); + } + + [Fact] + public void Set_ThenTryGet_ReturnsValue() + { + var store = CreateStore(); + + store.Set("key1", "hello"); + var found = store.TryGet("key1", out var value); + + Assert.True(found); + Assert.Equal("hello", value); + } + + [Fact] + public void Remove_AfterSet_TryGetReturnsFalse() + { + var store = CreateStore(); + store.Set("key1", 42); + + store.Remove("key1"); + + Assert.False(store.TryGet("key1", out _)); + } + + [Fact] + public async Task TryGetAsync_MissingKey_ReturnsFalse() + { + var store = CreateStore(); + + var (found, value) = await store.TryGetAsync("missing", TestContext.Current.CancellationToken); + + Assert.False(found); + Assert.Null(value); + } + + [Fact] + public async Task SetAsync_ThenTryGetAsync_ReturnsValue() + { + var store = CreateStore(); + + await store.SetAsync("key1", "async-hello", cancellationToken: TestContext.Current.CancellationToken); + var (found, value) = await store.TryGetAsync("key1", TestContext.Current.CancellationToken); + + Assert.True(found); + Assert.Equal("async-hello", value); + } + + [Fact] + public void Set_WithOptions_StoresValue() + { + var store = CreateStore(); + var options = new CleverCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + }; + + store.Set("key1", 99, options); + + Assert.True(store.TryGet("key1", out var val)); + Assert.Equal(99, val); + } + + [Fact] + public async Task RegisterEvictionCallback_CalledWhenEntryEvicted() + { + var store = CreateStore(); + var tcs = new TaskCompletionSource(); + store.RegisterEvictionCallback(k => tcs.TrySetResult(k)); + + store.Set("key1", 42); + store.Remove("key1"); // triggers PostEvictionCallback (async) + + var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken)) == tcs.Task; + Assert.True(completed, "eviction callback was not fired"); + Assert.Equal("key1", await tcs.Task); + } +} diff --git a/CleverCache.csproj b/CleverCache.csproj index fc0dad3..19ce5ab 100644 --- a/CleverCache.csproj +++ b/CleverCache.csproj @@ -1,30 +1,25 @@  - net9.0 - enable - enable CleverCache - 1.0.13 - Chris Hunt - TappetyClick - Like MemoryCache but better! You never need to worry about clearing cache again. - MemoryCache,Automatic Cache - ReadMe.md - - MIT - - https://github.com/chunty/CleverCache - git - - https://github.com/chunty/CleverCache + 2.0.0 + https://github.com/chunty/CleverCache/wiki + Like MemoryCache but better! Automatically invalidates cache by entity type, supports memory cache, distributed cache, or a custom provider. + MemoryCache,DistributedCache,Automatic Cache,Cache Invalidation + NuGetReadMe.md + + $(DefaultItemExcludes);CleverCache.MediatR\**;CleverCache.Redis\**;CleverCache.Tests\**;CleverCache.EntityFrameworkCore\** - - - - + + + - + + + + + <_Parameter1>CleverCache.Tests + diff --git a/CleverCache.sln b/CleverCache.sln index 77712e8..c29a658 100644 --- a/CleverCache.sln +++ b/CleverCache.sln @@ -1,22 +1,105 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.12.35527.113 +# Visual Studio Version 18 +VisualStudioVersion = 18.5.11716.220 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache", "CleverCache.csproj", "{2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.MediatR", "CleverCache.MediatR\CleverCache.MediatR.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Redis", "CleverCache.Redis\CleverCache.Redis.csproj", "{C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.Tests", "CleverCache.Tests\CleverCache.Tests.csproj", "{261F2368-C237-4CF0-A4C0-50E650D64412}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleverCache.EntityFrameworkCore", "CleverCache.EntityFrameworkCore\CleverCache.EntityFrameworkCore.csproj", "{96A9C78A-506E-4691-2C0B-35E0C678B967}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{C6C9B89B-A02A-48AA-94E4-D181F5BDB289}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{8D5E3E15-A947-4EC4-8E73-A99B45C8BA82}" + ProjectSection(SolutionItems) = preProject + .github\workflows\ci.yml = .github\workflows\ci.yml + .github\workflows\publish-wiki.yml = .github\workflows\publish-wiki.yml + .github\workflows\update-about.yml = .github\workflows\update-about.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|x64.ActiveCfg = Debug|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|x64.Build.0 = Debug|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|x86.ActiveCfg = Debug|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Debug|x86.Build.0 = Debug|Any CPU {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|Any CPU.Build.0 = Release|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|x64.ActiveCfg = Release|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|x64.Build.0 = Release|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|x86.ActiveCfg = Release|Any CPU + {2D79F3CA-DFEC-479F-BCA5-06B1935B22F3}.Release|x86.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|x64.Build.0 = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Debug|x86.Build.0 = Debug|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|Any CPU.Build.0 = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x64.ActiveCfg = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x64.Build.0 = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x86.ActiveCfg = Release|Any CPU + {C6F2E0F4-2E5E-44DB-BF7D-E4861992FD72}.Release|x86.Build.0 = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|Any CPU.Build.0 = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|x64.ActiveCfg = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|x64.Build.0 = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|x86.ActiveCfg = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Debug|x86.Build.0 = Debug|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|Any CPU.ActiveCfg = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|Any CPU.Build.0 = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x64.ActiveCfg = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x64.Build.0 = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x86.ActiveCfg = Release|Any CPU + {261F2368-C237-4CF0-A4C0-50E650D64412}.Release|x86.Build.0 = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|x64.ActiveCfg = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|x64.Build.0 = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|x86.ActiveCfg = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Debug|x86.Build.0 = Debug|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|Any CPU.Build.0 = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|x64.ActiveCfg = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|x64.Build.0 = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|x86.ActiveCfg = Release|Any CPU + {96A9C78A-506E-4691-2C0B-35E0C678B967}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8D5E3E15-A947-4EC4-8E73-A99B45C8BA82} = {C6C9B89B-A02A-48AA-94E4-D181F5BDB289} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5D6FA936-EAF9-4BA3-8509-8BA10E6D096A} + EndGlobalSection EndGlobal diff --git a/DependencyInjection/ApplicationBuilderExtensions.cs b/DependencyInjection/ApplicationBuilderExtensions.cs deleted file mode 100644 index d1e54a7..0000000 --- a/DependencyInjection/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace CleverCache.DependencyInjection; - -public static class ApplicationBuilderExtensions -{ - public static IApplicationBuilder UseCleverCache(this IApplicationBuilder app) where TContext : DbContext - { - var cache = app.ApplicationServices.GetRequiredService(); - var smartCacheOptions = app.ApplicationServices.GetRequiredService(); - var dbContext = app.ApplicationServices.GetRequiredService(); - var dependentCaches = smartCacheOptions.DependentCaches.ToList(); - - dbContext.EnsureCleverCacheInterceptor(); - - if (!smartCacheOptions.DisableAllScanning) - { - dependentCaches.AddRange(dbContext.DiscoverDependentCaches(smartCacheOptions)); - } - - foreach (var dependentCache in dependentCaches.Distinct()) - { - cache.AddDependentCache(dependentCache.Type, dependentCache.DependentType); - } - - return app; - } -} \ No newline at end of file diff --git a/DependencyInjection/ServiceCollectionExtensions.cs b/DependencyInjection/ServiceCollectionExtensions.cs index 8a281ff..7021c06 100644 --- a/DependencyInjection/ServiceCollectionExtensions.cs +++ b/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using CleverCache.Implementations; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection.Extensions; // ReSharper disable IdentifierTypo @@ -11,21 +10,15 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddCleverCache(this IServiceCollection services, Action? options = null) { - // Register the Smart Cache Options var localOptions = new CleverCacheOptions(); options?.Invoke(localOptions); services.TryAddSingleton(localOptions); - services.AddMemoryCache(); + // Register chosen store (defaults to memory) + localOptions.StoreRegistration?.Invoke(services); - // Register ICleverCache - services.TryAddSingleton(); - - // Register the Smart Cache Interceptor as Service - services.TryAddScoped(); - - // Register the Smart Cache Interceptor as Interceptor - services.AddScoped(); + // Register ICleverCache backed by the store + services.TryAddSingleton(); return services; } diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..09d4167 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + net8.0;net9.0;net10.0 + enable + enable + Chris Hunt + TappetyClick + MIT + https://github.com/chunty/Simpository + git + https://github.com/chunty/Simpository + True + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..2166052 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,43 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Exceptions/MissingInterceptorException.cs b/Exceptions/MissingInterceptorException.cs deleted file mode 100644 index a8ba658..0000000 --- a/Exceptions/MissingInterceptorException.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace CleverCache.Exceptions; - -internal class MissingInterceptorException() : - ApplicationException("CleverCache requires the ClearSmartMemoryCacheInterceptor to be added to the database context."); \ No newline at end of file diff --git a/Extensions/BulkOperationExtensions.cs b/Extensions/BulkOperationExtensions.cs new file mode 100644 index 0000000..3f1deb7 --- /dev/null +++ b/Extensions/BulkOperationExtensions.cs @@ -0,0 +1,44 @@ +namespace CleverCache.Extensions; + +/// +/// Fluent cache invalidation helpers to use after EF Core bulk operations +/// (ExecuteDelete, ExecuteUpdate) which bypass the change tracker and therefore +/// do not trigger automatic CleverCache invalidation. +/// +public static class BulkOperationExtensions +{ + /// + /// Invalidates the cache for the specified types and returns the row count. + /// Use after ExecuteDelete / ExecuteUpdate to keep the cache consistent. + /// + public static int InvalidateCaches(this int rowCount, ICleverCache cache, params Type[] types) + { + foreach (var type in types) + cache.RemoveByType(type); + return rowCount; + } + + /// + /// Invalidates the cache for and returns the row count. + /// + public static int InvalidateCaches(this int rowCount, ICleverCache cache) + => rowCount.InvalidateCaches(cache, typeof(T)); + + /// + /// Awaits the bulk operation task, invalidates the cache for the specified types, and returns the row count. + /// Use after ExecuteDeleteAsync / ExecuteUpdateAsync to keep the cache consistent. + /// + public static async Task InvalidateCaches(this Task task, ICleverCache cache, params Type[] types) + { + var rowCount = await task.ConfigureAwait(false); + foreach (var type in types) + cache.RemoveByType(type); + return rowCount; + } + + /// + /// Awaits the bulk operation task, invalidates the cache for , and returns the row count. + /// + public static Task InvalidateCaches(this Task task, ICleverCache cache) + => task.InvalidateCaches(cache, typeof(T)); +} diff --git a/Extensions/CacheEntryManagerExtensions.cs b/Extensions/CacheEntryManagerExtensions.cs index 1313de6..03fba3f 100644 --- a/Extensions/CacheEntryManagerExtensions.cs +++ b/Extensions/CacheEntryManagerExtensions.cs @@ -1,31 +1,41 @@ namespace CleverCache.Extensions; /// -/// Provides extension methods for the interface. +/// Provides extension methods for . /// public static class CacheEntryManagerExtensions { /// - /// Adds a dependent cache type. + /// Registers a cascade rule: when entries of are invalidated, + /// entries of are also invalidated. /// - /// The type of the cache. + /// The trigger type — invalidating this type will also invalidate . /// The cache instance. - /// The dependent type of the cache. - public static void AddDependentCache(this ICacheEntryManager cache, Type dependentType) => cache.AddDependentCache(typeof(T), dependentType); + /// The type whose entries should also be evicted when is invalidated. + public static void AddDependentCache(this ICleverCache cache, Type dependentType) => cache.AddDependentCache(typeof(T), dependentType); /// - /// Adds the specified key to the cache entry type. + /// Registers a cache key under the specified entity type so that the key is automatically + /// evicted when data of type changes. /// - /// The type of the object the cache key belongs to. + /// The entity type to associate with this cache key. /// The cache instance. - /// The key of the cache entry to add. - public static void AddKeyToType(this ICacheEntryManager cache, object key) where T : class => cache.AddKeyToType(typeof(T), key); + /// The cache key to register. + public static void AddKeyToType(this ICleverCache cache, object key) where T : class => cache.AddKeyToType(typeof(T), key); /// - /// Adds the specified key to the cache entry type. + /// Registers a cache key under the specified entity type so that the key is automatically + /// evicted when data of that type changes. /// /// The cache instance. - /// The type of the object the cache key belongs to. - /// The key of the cache entry to add. - public static void AddKeyToType(this ICacheEntryManager cache, Type type, object key) => cache.AddKeyToTypes([type], key); + /// The entity type to associate with this cache key. + /// The cache key to register. + public static void AddKeyToType(this ICleverCache cache, Type type, object key) => cache.AddKeyToTypes([type], key); + + /// + /// Asynchronously removes all cache entries associated with , + /// including any dependent types. Prefer over RemoveByType when using a distributed cache backend. + /// + public static Task RemoveByTypeAsync(this ICleverCache cache, CancellationToken cancellationToken = default) => + cache.RemoveByTypeAsync(typeof(T), cancellationToken); } diff --git a/Extensions/CleverCacheExtensions.cs b/Extensions/CleverCacheExtensions.cs index 7421560..9f37977 100644 --- a/Extensions/CleverCacheExtensions.cs +++ b/Extensions/CleverCacheExtensions.cs @@ -12,11 +12,11 @@ public static class CleverCacheExtensions /// The cache instance. /// The key of the entry to look for or create. /// The factory that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The value associated with this key. public static TItem? GetOrCreate(this ICleverCache cache, object key, - Func factory, - MemoryCacheEntryOptions? createOptions = null) where T : class => + Func factory, + CleverCacheEntryOptions? createOptions = null) where T : class => cache.GetOrCreate(typeof(T), key, factory, createOptions); /// @@ -27,10 +27,10 @@ public static class CleverCacheExtensions /// The type of the object the cache key belongs to. /// The key of the entry to look for or create. /// The factory that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The value associated with this key. - public static TItem? GetOrCreate(this ICleverCache cache, Type type, object key, Func factory, - MemoryCacheEntryOptions? createOptions = null) => + public static TItem? GetOrCreate(this ICleverCache cache, Type type, object key, Func factory, + CleverCacheEntryOptions? createOptions = null) => cache.GetOrCreate([type], key, factory, createOptions); /// @@ -41,12 +41,13 @@ public static class CleverCacheExtensions /// The cache instance. /// The key of the entry to look for or create. /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The task object representing the asynchronous operation. public static async Task GetOrCreateAsync(this ICleverCache cache, object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null) where T : class => - await cache.GetOrCreateAsync(typeof(T), key, factory, createOptions); + Func> factory, + CleverCacheEntryOptions? createOptions = null, + CancellationToken cancellationToken = default) where T : class => + await cache.GetOrCreateAsync(typeof(T), key, factory, createOptions, cancellationToken); /// /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. @@ -56,26 +57,56 @@ public static class CleverCacheExtensions /// The type of the object the cache key belongs to. /// The key of the entry to look for or create. /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. + /// A token to cancel the async operation. /// The task object representing the asynchronous operation. public static async Task GetOrCreateAsync(this ICleverCache cache, Type type, object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null) => - await cache.GetOrCreateAsync([type], key, factory, createOptions); + Func> factory, + CleverCacheEntryOptions? createOptions = null, + CancellationToken cancellationToken = default) => + await cache.GetOrCreateAsync([type], key, factory, createOptions, cancellationToken); /// - /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. + /// Returns a human-readable representation of the dependency graph and tracked cache keys. + /// Useful for debugging — log it at startup or on-demand via a diagnostic endpoint. /// - /// The type of the object to get. - /// The cache instance. - /// An array of types the cache key belongs to. - /// The key of the entry to look for or create. - /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. - /// The task object representing the asynchronous operation. - public static Task GetOrCreateAsync(this ICleverCache cache, Type[] types, - object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null) => throw new NotImplementedException(); + /// + /// + /// logger.LogDebug(cache.RenderDependencyTree()); + /// + /// + public static string RenderDependencyTree(this ICleverCache cache) + { + var d = cache.GetDiagnostics(); + + // Collect all types that appear in either cascades or key tracking + var allTypes = new HashSet(d.Dependants.Keys); + foreach (var cascades in d.Dependants.Values) + foreach (var t in cascades) allTypes.Add(t); + foreach (var t in d.KeysByType.Keys) allTypes.Add(t); + + if (allTypes.Count == 0) + return "CleverCache: no types registered."; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("CleverCache Dependency Tree"); + sb.AppendLine(new string('─', 40)); + + foreach (var type in allTypes.OrderBy(t => t.Name)) + { + sb.AppendLine(type.Name); + + if (d.Dependants.TryGetValue(type, out var cascades) && cascades.Count > 0) + sb.AppendLine($" ↳ cascades to : {string.Join(", ", cascades.Select(t => t.Name))}"); + + if (d.KeysByType.TryGetValue(type, out var keys) && keys.Count > 0) + sb.AppendLine($" ↳ tracked keys : {string.Join(", ", keys)}"); + } + + var totalKeys = d.KeysByType.Values.SelectMany(k => k).Distinct().Count(); + sb.Append($"─ {allTypes.Count} type(s) | {d.Dependants.Count} cascade rule(s) | {totalKeys} tracked key(s) ─"); + return sb.ToString(); + } } + diff --git a/Extensions/DbContextExtensions.cs b/Extensions/DbContextExtensions.cs deleted file mode 100644 index 0a1412d..0000000 --- a/Extensions/DbContextExtensions.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Reflection; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using CleverCache.Attributes; -using CleverCache.Exceptions; -using CleverCache.Helpers; - -namespace CleverCache.Extensions; - -internal static class DbContextExtensions -{ - public static void EnsureCleverCacheInterceptor(this DbContext dbContext) - { - var isRegistered = dbContext.GetService().Extensions - .OfType() - .Any(e => e.Interceptors != null - && e.Interceptors.Any(i => i.GetType() == typeof(CleverCacheInterceptor))); - - if (!isRegistered) throw new MissingInterceptorException(); - } - - public static List DiscoverDependentCaches(this DbContext dbContext, CleverCacheOptions smartCacheOptions) - { - HashSet dependentCaches = []; - - foreach (var entityType in dbContext.Model.GetEntityTypes()) - { - if (smartCacheOptions.Scanning.NavigationScanMode != DependentCacheNavigationScanMode.None) - { - NavigationScanningHelper.Scan(smartCacheOptions.Scanning, entityType, dependentCaches); - } - - // Its pointless doing attribute based processing if we already did recursive scanning - if (smartCacheOptions.Scanning.NavigationScanMode != DependentCacheNavigationScanMode.Recursive) - { - ProcessAttribute(dbContext, entityType, dependentCaches); - } - } - - return [.. dependentCaches]; - } - - private static void ProcessAttribute(DbContext dbContext, IEntityType entityType, HashSet dependentCaches) - { - // Check if this has attribute - var type = entityType.ClrType; - var attribute = type.GetCustomAttribute(); - if (attribute is null) - { - return; - } - - // Do the mappings - foreach (var dependentType in attribute.DependantTypes) - { - dependentCaches.Add(new DependentCache(type, dependentType)); - if (attribute.Reverse) - { - dependentCaches.Add(new DependentCache(dependentType, type)); ; - } - - if (attribute.NavigationScanMode == DependentCacheNavigationScanMode.None) - { - continue; - } - - var dependentModelType = dbContext.Model.FindEntityType(dependentType); - - if (dependentModelType is null) - { - continue; - } - - NavigationScanningHelper.Scan( - new CleverCacheScanOptions(attribute.NavigationScanMode, attribute.Reverse), - entityType, - dependentCaches); - } - } -} \ No newline at end of file diff --git a/GlobalUsings.cs b/GlobalUsings.cs index b4951ce..59e706f 100644 --- a/GlobalUsings.cs +++ b/GlobalUsings.cs @@ -1,8 +1,5 @@ // Global using directives -global using Microsoft.EntityFrameworkCore; -global using Microsoft.Extensions.Caching.Memory; global using Microsoft.Extensions.DependencyInjection; global using CleverCache.Extensions; -global using CleverCache.Interceptors; global using CleverCache.Models; \ No newline at end of file diff --git a/Helpers/NavigationScanningHelper.cs b/Helpers/NavigationScanningHelper.cs deleted file mode 100644 index a2336ae..0000000 --- a/Helpers/NavigationScanningHelper.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.EntityFrameworkCore.Metadata; - -namespace CleverCache.Helpers; - -internal static class NavigationScanningHelper -{ - public static void Scan(CleverCacheScanOptions scanOptions, IEntityType entityType, HashSet dependentCaches) - { - foreach (var navigation in entityType.GetNavigations()) - { - var sourceType = entityType.ClrType; - var dependentEntityType = navigation.TargetEntityType; - var dependentType = dependentEntityType.ClrType; - - // If we already have this in the list, skip - if (dependentCaches.Any(x => x.Type == dependentType)) - { - continue; - } - - dependentCaches.Add(new DependentCache(sourceType, dependentType)); - if (scanOptions.ReverseNavigationDependencies) - { - dependentCaches.Add(new DependentCache(dependentType, sourceType)); - } - - if (!scanOptions.NavigationScanMode.Equals(DependentCacheNavigationScanMode.Recursive)) - { - continue; - } - - Scan(scanOptions, dependentEntityType, dependentCaches); - } - } -} \ No newline at end of file diff --git a/ICacheKeyManager.cs b/ICacheKeyManager.cs deleted file mode 100644 index 48e95de..0000000 --- a/ICacheKeyManager.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CleverCache; - -public interface ICacheEntryManager -{ - /// - /// Adds a dependent cache type. - /// - /// The type of the cache. - /// The dependent type of the cache. - void AddDependentCache(Type type, Type dependentType); - - /// - /// Adds the specified key to the cache entry type. - /// - /// An array types the cache key belongs to. - /// The key of the cache entry to add. - void AddKeyToTypes(Type[] types, object key); -} \ No newline at end of file diff --git a/ICleverCache.cs b/ICleverCache.cs index bfd4ad4..6a1b8fb 100644 --- a/ICleverCache.cs +++ b/ICleverCache.cs @@ -1,46 +1,89 @@ namespace CleverCache; -public interface ICleverCache : ICacheEntryManager +public interface ICleverCache { + /// + /// Registers a cascade rule: when all entries of are invalidated, + /// all entries of are also invalidated. + /// Cascades are transitive and cycle-safe. + /// + /// + /// This is normally configured automatically via the [DependentCaches] attribute and + /// app.UseCleverCache<TContext>(). Call this directly only when you need to set + /// up cache dependencies programmatically rather than via the attribute. + /// + void AddDependentCache(Type type, Type dependentType); + + /// + /// Registers a cache key under the specified entity types so that the key is automatically + /// evicted when data of any of those types changes (via ). + /// + /// + /// This is called automatically by and + /// . Call this directly only when you need to associate + /// a key with a type after the fact — for example, following a bulk operation that bypasses + /// the EF Core change tracker. + /// + void AddKeyToTypes(Type[] types, object key); + /// /// Gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. + /// The entry is automatically evicted when data of any type in changes. /// - /// An array types the cache key belongs to. + /// The entity types this cache entry is associated with. The entry is evicted when any of these types is invalidated. /// The key of the entry to look for or create. /// The factory that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The value associated with this key. TItem? GetOrCreate( Type[] types, object key, - Func factory, - MemoryCacheEntryOptions? createOptions = null); + Func factory, + CleverCacheEntryOptions? createOptions = null); /// /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. + /// The entry is automatically evicted when data of any type in changes. /// /// The type of the object to get. - /// An array types the cache key belongs to. + /// The entity types this cache entry is associated with. The entry is evicted when any of these types is invalidated. /// The key of the entry to look for or create. /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. + /// The options to be applied to the cache entry if the key does not exist in the cache. /// The task object representing the asynchronous operation. Task GetOrCreateAsync( Type[] types, object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null); + Func> factory, + CleverCacheEntryOptions? createOptions = null, + CancellationToken cancellationToken = default); /// - /// Removes the object associated with the given key. + /// Removes the cache entry with the specified key. /// - /// An object identifying the entry. + /// The key identifying the entry to remove. void Remove(object key); /// - /// Removes all cache entries of the specified type. + /// Returns a point-in-time snapshot of the dependency graph (cascade rules) and all + /// currently tracked cache keys, grouped by entity type. Useful for diagnostics and debugging. /// - /// The type of the objects to remove cache entries for. + CleverCacheDiagnostics GetDiagnostics(); + + /// + /// Removes all cache entries associated with the specified entity type, including entries + /// registered under any dependent types (see ). + /// + /// The entity type whose cache entries should be evicted. void RemoveByType(Type type); + /// + /// Asynchronously removes all cache entries associated with the specified entity type, + /// including entries registered under any dependent types (see ). + /// Prefer this over when using a distributed cache backend. + /// + /// The entity type whose cache entries should be evicted. + /// A token to cancel the async operation. + Task RemoveByTypeAsync(Type type, CancellationToken cancellationToken = default); + } \ No newline at end of file diff --git a/ICleverCacheStore.cs b/ICleverCacheStore.cs new file mode 100644 index 0000000..51fb5b6 --- /dev/null +++ b/ICleverCacheStore.cs @@ -0,0 +1,25 @@ +namespace CleverCache; + +/// +/// Abstraction over the underlying cache backend. Implement this interface to plug in a custom cache provider. +/// +public interface ICleverCacheStore +{ + /// Attempts to retrieve a cached value by key. + bool TryGet(object key, out TItem? value); + + /// Asynchronously attempts to retrieve a cached value by key. + Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default); + + /// Stores a value in the cache. + void Set(object key, TItem value, CleverCacheEntryOptions? options = null); + + /// Asynchronously stores a value in the cache. + Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + + /// Removes a cached entry by key. + void Remove(object key); + + /// Asynchronously removes a cached entry by key. + Task RemoveAsync(object key, CancellationToken cancellationToken = default); +} diff --git a/IEvictionNotifyingStore.cs b/IEvictionNotifyingStore.cs new file mode 100644 index 0000000..a3b1987 --- /dev/null +++ b/IEvictionNotifyingStore.cs @@ -0,0 +1,20 @@ +namespace CleverCache; + +/// +/// Optional interface for cache stores that can notify when entries are evicted. +/// Implement this alongside to enable automatic +/// cleanup of tracked keys when entries expire or are evicted by the store. +/// +/// implements this interface. +/// does not, as distributed caches +/// have no eviction notification mechanism. +/// +/// +public interface IEvictionNotifyingStore +{ + /// + /// Registers a callback to be invoked when a cache entry is evicted. + /// + /// Called with the evicted key. + void RegisterEvictionCallback(Action onEvicted); +} diff --git a/Implementations/CleverCacheService.cs b/Implementations/CleverCacheService.cs new file mode 100644 index 0000000..9c0ef4f --- /dev/null +++ b/Implementations/CleverCacheService.cs @@ -0,0 +1,79 @@ +using AsyncKeyedLock; + +namespace CleverCache.Implementations; + +/// +internal class CleverCacheService : CacheEntryManager, ICleverCache +{ + private readonly ICleverCacheStore _store; + private readonly AsyncKeyedLocker _locker = new(); + + public CleverCacheService(ICleverCacheStore store, CleverCacheOptions options) + { + _store = store; + foreach (var dep in options.DependentCaches) + AddDependentCache(dep.Type, dep.DependentType); + + if (store is IEvictionNotifyingStore evicting) + evicting.RegisterEvictionCallback(RemoveKeyFromAllTypes); + } + + public TItem? GetOrCreate(Type[] types, object key, Func factory, CleverCacheEntryOptions? options = null) + { + if (_store.TryGet(key, out var hit)) return hit; + + using var _ = _locker.Lock(key); + + // Double-check: another thread may have populated the cache while we waited for the lock + if (_store.TryGet(key, out hit)) return hit; + + AddKeyToTypes(types, key); + var value = factory(); + _store.Set(key, value, options); + return value; + } + + public async Task GetOrCreateAsync(Type[] types, object key, Func> factory, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + var (found, cached) = await _store.TryGetAsync(key, cancellationToken).ConfigureAwait(false); + if (found) return cached; + + using var _ = await _locker.LockAsync(key).ConfigureAwait(false); + + // Double-check: another thread may have populated the cache while we waited for the lock + (found, cached) = await _store.TryGetAsync(key, cancellationToken).ConfigureAwait(false); + if (found) return cached; + + AddKeyToTypes(types, key); + var value = await factory().ConfigureAwait(false); + await _store.SetAsync(key, value, options, cancellationToken).ConfigureAwait(false); + return value; + } + + public void RemoveByType(Type type) + { + foreach (var k in SnapshotKeysFor(type)) + { + _store.Remove(k); + RemoveKeyFromAllTypes(k); + } + } + + public async Task RemoveByTypeAsync(Type type, CancellationToken cancellationToken = default) + { + foreach (var k in SnapshotKeysFor(type)) + { + await _store.RemoveAsync(k, cancellationToken).ConfigureAwait(false); + RemoveKeyFromAllTypes(k); + } + } + + public void Remove(object key) + { + _store.Remove(key); + RemoveKeyFromAllTypes(key); + } + + public CleverCacheDiagnostics GetDiagnostics() => SnapshotDiagnostics(); +} + diff --git a/Implementations/DistributedCacheStore.cs b/Implementations/DistributedCacheStore.cs new file mode 100644 index 0000000..48f6e41 --- /dev/null +++ b/Implementations/DistributedCacheStore.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; + +namespace CleverCache.Implementations; + +public class DistributedCacheStore(IDistributedCache distributedCache) : ICleverCacheStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public bool TryGet(object key, out TItem? value) + { + var bytes = distributedCache.Get(ToStringKey(key)); + if (bytes is null) + { + value = default; + return false; + } + + value = JsonSerializer.Deserialize(bytes, JsonOptions); + return true; + } + + public async Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) + { + var bytes = await distributedCache.GetAsync(ToStringKey(key), cancellationToken).ConfigureAwait(false); + if (bytes is null) return (false, default); + + var value = JsonSerializer.Deserialize(bytes, JsonOptions); + return (true, value); + } + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); + distributedCache.Set(ToStringKey(key), bytes, ToDistributedOptions(options)); + } + + public async Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); + await distributedCache.SetAsync(ToStringKey(key), bytes, ToDistributedOptions(options), cancellationToken).ConfigureAwait(false); + } + + public void Remove(object key) => distributedCache.Remove(ToStringKey(key)); + + public async Task RemoveAsync(object key, CancellationToken cancellationToken = default) => + await distributedCache.RemoveAsync(ToStringKey(key), cancellationToken).ConfigureAwait(false); + + private static string ToStringKey(object key) => + JsonSerializer.Serialize(key, JsonOptions); + + private static DistributedCacheEntryOptions ToDistributedOptions(CleverCacheEntryOptions? options) => + options is null + ? new DistributedCacheEntryOptions() + : new DistributedCacheEntryOptions + { + AbsoluteExpiration = options.AbsoluteExpiration, + AbsoluteExpirationRelativeToNow = options.AbsoluteExpirationRelativeToNow, + SlidingExpiration = options.SlidingExpiration + }; +} diff --git a/Implementations/FakeCache.cs b/Implementations/FakeCache.cs index 2794ad5..e8ea881 100644 --- a/Implementations/FakeCache.cs +++ b/Implementations/FakeCache.cs @@ -1,70 +1,42 @@ -namespace CleverCache.Implementations; +namespace CleverCache; /// /// A fake implementation of the ICleverCache interface for testing purposes. /// public class FakeCache : ICleverCache { - /// - /// Adds a dependent cache type. - /// - /// The type of the cache. - /// The dependent type of the cache. + /// public void AddDependentCache(Type type, Type dependentType) { } - /// - /// Adds the specified key to the cache entry type. - /// - /// An array types the cache key belongs to. - /// The key of the cache entry to add. + /// public void AddKeyToTypes(Type[] types, object key) { } - /// - /// Removes all cache entries of the specified type. - /// - /// The type of the objects to remove cache entries for. + /// public void RemoveByType(Type type) { } - /// - /// Gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. - /// - /// The type of the object to get. - /// An array types the cache key belongs to. - /// The key of the entry to look for or create. - /// The factory that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. - /// The value associated with this key. + /// + public Task RemoveByTypeAsync(Type type, CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// public TItem? GetOrCreate( Type[] types, object key, - Func factory, - MemoryCacheEntryOptions? createOptions = null) - { - ICacheEntry cacheEntry = new FakeCacheEntry(); - return factory(cacheEntry); - } + Func factory, + CleverCacheEntryOptions? createOptions = null) => factory(); - /// - /// Asynchronously gets the value associated with this key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. - /// - /// The type of the object to get. - /// An array types the cache key belongs to. - /// The key of the entry to look for or create. - /// The factory task that creates the value associated with this key if the key does not exist in the cache. - /// The options to be applied to the if the key does not exist in the cache. - /// The task object representing the asynchronous operation. - public async Task GetOrCreateAsync(Type[] types, + /// + public async Task GetOrCreateAsync( + Type[] types, object key, - Func> factory, - MemoryCacheEntryOptions? createOptions = null) - { - ICacheEntry cacheEntry = new FakeCacheEntry(); - return await factory(cacheEntry); - } + Func> factory, + CleverCacheEntryOptions? createOptions = null, + CancellationToken cancellationToken = default) => await factory(); - /// - /// Removes the object associated with the given key. - /// - /// An object identifying the entry. + /// public void Remove(object key) { } + + /// + public CleverCacheDiagnostics GetDiagnostics() => + new(new Dictionary>(), new Dictionary>()); } + diff --git a/Implementations/MemoryCache.cs b/Implementations/MemoryCache.cs deleted file mode 100644 index 321dfa7..0000000 --- a/Implementations/MemoryCache.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace CleverCache.Implementations; - -/// -public class CleverMemoryCache(IMemoryCache memoryCache) : CacheEntryManager, ICleverCache -{ - public TItem? GetOrCreate(Type[] types, object key, Func factory, MemoryCacheEntryOptions? options = null) - { - if (memoryCache.TryGetValue(key, out var hit)) return (TItem?)hit; - - using var entry = GetCacheEntry(types, key, options); - return SetEntryValue(factory(entry), entry); - } - - public async Task GetOrCreateAsync(Type[] types, object key, Func> factory, MemoryCacheEntryOptions? options = null) - { - if (memoryCache.TryGetValue(key, out var hit)) return (TItem?)hit; - - using var entry = GetCacheEntry(types, key, options); - return SetEntryValue(await factory(entry).ConfigureAwait(false), entry); - } - - public void RemoveByType(Type type) - { - // snapshot avoids races - foreach (var k in SnapshotKeysFor(type)) - { - memoryCache.Remove(k); - } - } - - public void Remove(object key) => memoryCache.Remove(key); - - private static TItem? SetEntryValue(TItem value, ICacheEntry entry) - { - entry.Value = value; - return (TItem?)entry.Value; - } - - private ICacheEntry GetCacheEntry(Type[] types, object key, MemoryCacheEntryOptions? options) - { - ICacheEntry? entry = null; - try - { - AddKeyToTypes(types, key); - entry = memoryCache.CreateEntry(key); - if (options is not null) entry.SetOptions(options); - return entry; - } - catch - { - entry?.Dispose(); - throw; - } - } - -} diff --git a/Implementations/MemoryCacheStore.cs b/Implementations/MemoryCacheStore.cs new file mode 100644 index 0000000..ab38b98 --- /dev/null +++ b/Implementations/MemoryCacheStore.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace CleverCache.Implementations; + +public class MemoryCacheStore(IMemoryCache memoryCache) : ICleverCacheStore, IEvictionNotifyingStore +{ + private Action? _onEvicted; + + public void RegisterEvictionCallback(Action onEvicted) => _onEvicted = onEvicted; + + public bool TryGet(object key, out TItem? value) + { + if (memoryCache.TryGetValue(key, out var hit)) + { + value = (TItem?)hit; + return true; + } + + value = default; + return false; + } + + public Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) + { + var hit = TryGet(key, out var value); + return Task.FromResult((hit, value)); + } + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + { + using var entry = memoryCache.CreateEntry(key); + entry.Value = value; + + if (_onEvicted is not null) + entry.RegisterPostEvictionCallback((k, _, _, _) => _onEvicted(k)); + + if (options is null) return; + entry.AbsoluteExpiration = options.AbsoluteExpiration; + entry.AbsoluteExpirationRelativeToNow = options.AbsoluteExpirationRelativeToNow; + entry.SlidingExpiration = options.SlidingExpiration; + } + + public Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public void Remove(object key) => memoryCache.Remove(key); + + public Task RemoveAsync(object key, CancellationToken cancellationToken = default) + { + Remove(key); + return Task.CompletedTask; + } +} diff --git a/Mediatr/AutoCacheAttribute.cs b/Mediatr/AutoCacheAttribute.cs deleted file mode 100644 index 43190dc..0000000 --- a/Mediatr/AutoCacheAttribute.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace CleverCache.Mediatr; -public class AutoCacheAttribute(params Type[] types) : Attribute -{ - public Type[] Types { get; } = types; -} diff --git a/Models/CacheTypeMap.cs b/Models/CacheTypeMap.cs deleted file mode 100644 index d6b5032..0000000 --- a/Models/CacheTypeMap.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace CleverCache.Models; - -public record CacheTypeMap(Type Type, object Key); \ No newline at end of file diff --git a/Models/CleverCacheDiagnostics.cs b/Models/CleverCacheDiagnostics.cs new file mode 100644 index 0000000..9b02844 --- /dev/null +++ b/Models/CleverCacheDiagnostics.cs @@ -0,0 +1,11 @@ +namespace CleverCache.Models; + +/// +/// A point-in-time snapshot of the cache's dependency graph and tracked keys. +/// +/// Direct cascade edges: when a type is invalidated, all listed types are also invalidated. +/// Cache keys currently tracked per type (including keys inherited via cascade expansion). +public record CleverCacheDiagnostics( + IReadOnlyDictionary> Dependants, + IReadOnlyDictionary> KeysByType +); diff --git a/Models/CleverCacheEntryOptions.cs b/Models/CleverCacheEntryOptions.cs new file mode 100644 index 0000000..939ed4b --- /dev/null +++ b/Models/CleverCacheEntryOptions.cs @@ -0,0 +1,8 @@ +namespace CleverCache.Models; + +public class CleverCacheEntryOptions +{ + public DateTimeOffset? AbsoluteExpiration { get; set; } + public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; } + public TimeSpan? SlidingExpiration { get; set; } +} diff --git a/Models/CleverCacheOptions.cs b/Models/CleverCacheOptions.cs index fbba01e..6d18c8c 100644 --- a/Models/CleverCacheOptions.cs +++ b/Models/CleverCacheOptions.cs @@ -1,11 +1,105 @@ -namespace CleverCache.Models; +using System.Reflection; +using CleverCache.Attributes; +using CleverCache.Implementations; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; -public class CleverCacheOptions(CleverCacheScanOptions? scanOptions = null, - HashSet? dependentCaches = null, - bool disableAllScanning = false) +namespace CleverCache.Models; + +public class CleverCacheOptions(HashSet? dependentCaches = null) { - // ReSharper disable once IdentifierTypo - public CleverCacheScanOptions Scanning { get; set; } = scanOptions ?? new CleverCacheScanOptions(); public HashSet DependentCaches { get; set; } = dependentCaches ?? []; - public bool DisableAllScanning { get; set; } = disableAllScanning; // Don't do any scanning to set up -} \ No newline at end of file + + /// + /// Scans the assembly containing for + /// and registers the declared cache dependency relationships. + /// Use this instead of relying on UseCleverCache<TContext>() for attribute-based configuration. + /// + /// + /// + /// builder.Services.AddCleverCache(o => o.ScanAssemblyContaining<Order>()); + /// + /// + public CleverCacheOptions ScanAssemblyContaining() => ScanAssemblies(typeof(T).Assembly); + + /// + /// Scans the specified assemblies for and registers + /// the declared cache dependency relationships. + /// + public CleverCacheOptions ScanAssemblies(params Assembly[] assemblies) + { + foreach (var assembly in assemblies) + { + foreach (var type in assembly.GetTypes()) + { + var attr = type.GetCustomAttribute(); + if (attr is null) continue; + + foreach (var depType in attr.DependentTypes) + { + DependentCaches.Add(new DependentCache(type, depType)); + if (attr.Reverse) + DependentCaches.Add(new DependentCache(depType, type)); + } + } + } + return this; + } + + internal Action StoreRegistration { get; private set; } = services => + { + services.AddMemoryCache(); + services.TryAddSingleton(); + }; + + /// Uses the built-in backend (default). + public CleverCacheOptions UseMemoryCache() + { + StoreRegistration = services => + { + services.AddMemoryCache(); + services.TryAddSingleton(); + }; + return this; + } + + /// + /// Uses the built-in backend. + /// Requires IDistributedCache to be registered (e.g. via AddStackExchangeRedisCache or AddDistributedMemoryCache). + /// + public CleverCacheOptions UseDistributedCache() + { + StoreRegistration = services => + services.TryAddSingleton(); + return this; + } + + /// Registers a custom implementation. + public CleverCacheOptions UseCustomStore() where TStore : class, ICleverCacheStore + { + StoreRegistration = services => + services.TryAddSingleton(); + return this; + } + + /// Registers a custom via a factory. + public CleverCacheOptions UseCustomStore(Func factory) + { + StoreRegistration = services => + services.TryAddSingleton(factory); + return this; + } + + /// + /// Provides direct control over the service registrations used for the cache store. + /// Intended for use by extension packages (e.g. CleverCache.Redis) that need to register + /// additional services alongside the . + /// + public CleverCacheOptions UseCustomStoreRegistration(Action registration) + { + StoreRegistration = registration; + return this; + } +} diff --git a/Models/FakeCacheEntry.cs b/Models/FakeCacheEntry.cs deleted file mode 100644 index 15275d6..0000000 --- a/Models/FakeCacheEntry.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.Primitives; - -namespace CleverCache.Models; - -public class FakeCacheEntry : ICacheEntry -{ - public void Dispose() - { - GC.SuppressFinalize(this); - } - - public object Key { get; } = Guid.NewGuid(); - public object? Value { get; set; } - public DateTimeOffset? AbsoluteExpiration { get; set; } - public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; } - public TimeSpan? SlidingExpiration { get; set; } - public IList ExpirationTokens { get; } = []; - public IList PostEvictionCallbacks { get; } = []; - public CacheItemPriority Priority { get; set; } - public long? Size { get; set; } -} \ No newline at end of file diff --git a/NuGetReadMe.md b/NuGetReadMe.md new file mode 100644 index 0000000..62e0ea0 --- /dev/null +++ b/NuGetReadMe.md @@ -0,0 +1,21 @@ +# CleverCache + +Automatic cache invalidation for .NET — tracks entity changes via EF Core and clears related cache entries automatically. + +Supports **memory cache** (default), **distributed cache** (`IDistributedCache`), or a **custom provider**. + +For full documentation, examples, and configuration options see the [CleverCache wiki](https://github.com/chunty/CleverCache/wiki). + +## Quick start + +```csharp +// Memory cache (default) +builder.Services.AddCleverCache(); + +// Distributed cache (e.g. Redis) +builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost"); +builder.Services.AddCleverCache(o => o.UseDistributedCache()); + +// Custom provider +builder.Services.AddCleverCache(o => o.UseCustomStore()); +``` diff --git a/ReadMe.md b/ReadMe.md index dcf5b3b..d9dd38d 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,180 +1,75 @@ CleverCache ==================================================== +[![CI](https://github.com/chunty/CleverCache/actions/workflows/ci.yml/badge.svg)](https://github.com/chunty/CleverCache/actions/workflows/ci.yml) [![NuGet](https://img.shields.io/nuget/dt/clevercache.svg)](https://www.nuget.org/packages/clevercache) [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) +[![Publish Wiki](https://github.com/chunty/CleverCache/actions/workflows/publish-wiki.yml/badge.svg)](https://github.com/chunty/CleverCache/actions/workflows/publish-wiki.yml) -**CleverCache** was designed to try and solve the problem having to remember (or know when) to invalidate cache entries -when the data in them is out of date. This often particularly hard when cache entries contain data from multiple entities -a change in any of them effectively means the cached data is now wrong. Trying to do this manually often causes -cross-cutting concerns and invariably we forget something important which proves to be a right pain in the butt. +> ⚠️ **V2 contains breaking changes.** Read the **[V1 → V2 Migration Guide](https://github.com/chunty/CleverCache/wiki/Migrating-to-V2)** before upgrading. -With a small amount of configuration **CleverCache** will automatically track changes in your database context -and reset the cache for any entity if an entity of that type is create, updated or deleted, and - if required, -any related entity where data is also part of the same cache entry. +**CleverCache** solves the problem of remembering when to invalidate cache entries when underlying data changes — especially when a cache entry contains data from multiple entity types. ->_BONUS:_ If you're using Mediatr, CleverCache can automatically cache results but using a pipeline behaviour with minimal changes -to your existing code. +With a small amount of configuration, CleverCache automatically tracks entity changes via EF Core and clears related cache entries whenever data is created, updated, or deleted. -## Installing CleverCache -You should install CleverCache with NuGet: -``` -Install-Package CleverCache -``` -Or via the .NET Core command line interface: -``` -dotnet add package CleverCache -``` -Either commands, from Package Manager Console or .NET Core CLI, will download and install -CleverCache and all required dependencies. - -## Get Started - -1. Register the services: - ```csharp - builder.Services.AddCleverCache(); - ``` - -2. Ensure the interceptor is registered on your database context in any of the following ways: - ```csharp - // The interceptor interface if you have no other interceptors - public class AppDbContext(IInterceptor cleverCacheInterceptor) : DbContext() - { - private readonly IInterceptor _cleverCacheInterceptor = cleverCacheInterceptor; - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.AddInterceptors(new IInterceptor[] { cleverCacheInterceptor }); - } - } - ``` - or - - ```csharp - // The interceptor array if you are already using interceptors - public class AppDbContext(IInterceptor[] interceptors) : DbContext() - { - private readonly IInterceptor[] _interceptors= interceptors; - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.AddInterceptors(interceptors); - } - } - ``` - - or the concrete class - - ```csharp - // The interceptor interface if you have no other interceptors - public class AppDbContext(CleverCacheInterceptor cleverCacheInterceptor) : DbContext() - { - private readonly CleverCacheInterceptor _cleverCacheInterceptor = cleverCacheInterceptor; - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.AddInterceptors(new IInterceptor[] { cleverCacheInterceptor }); - } - } - ``` -3. Add the using to your app specifying the db context you are tracking: - - ```csharp - app.UseCleverCache(); - ``` - -## Usage -You create cache in the same way you would when using MemoryCache, but specify an additional type parameter as shown below -to associate a given type with a cache key: -```csharp - var myItem = await cache.GetOrCreateAsync( - typeof(MyEntityType), - cacheKey, - _ => - { - //return ; - } - ) ?? []; -``` +## 🚀 MediatR users -The interceptor tracks when any instance of `MyEntityType` is added, changed or deleted and will clear all -cache keys associated with that type. +Install [`CleverCache.MediatR`](https://www.nuget.org/packages/clevercache.mediatr) for **automatic query caching with zero changes to your handlers** — just add `[AutoCache]` to your query and CleverCache handles the rest, including automatic invalidation. -## Dependent Caches -Often you have information in a cache entry that contains data from multiple entity types -and the caches needs to be refreshed if ANY of the types changes not -just the primary object. +→ [MediatR Integration wiki](https://github.com/chunty/CleverCache/wiki/MediatR-Integration) -> tl;dr: See the `DependantCaches` attribute below +> **Automatic invalidation requires Entity Framework Core.** CleverCache hooks into EF Core as a `SaveChangesInterceptor` — entries are cleared automatically when `SaveChanges` or `SaveChangesAsync` completes. Writes that bypass the change tracker (raw SQL, stored procedures, external services) won't trigger automatic invalidation — see [Bulk Operations](https://github.com/chunty/CleverCache/wiki/Bulk-Operations). +> +> **No EF Core?** You can still use CleverCache for manual invalidation — `RemoveByType()` understands your full dependency tree and cascades automatically. + +## Install -You can create these associations manually on a type by type basis by calling: -```csharp -cache.AddKeyToType(type, key); ``` -or more succinctly: -```csharp -cache.AddKeyToType(key); +Install-Package CleverCache ``` -You can also do multiple types in one call by doing: -```csharp -cache.AddKeyToTypes(arrayOfTypes, key); -``` +Or via the .NET CLI: -You can also do it by specifying an array of types when calling any of the create methods. +``` +dotnet add package CleverCache +``` -However, this can be tiresome and result in repetitive code. If you know -you often need to do this for a given entity you can configure it globally via -an attribute on the entity class like this: +## Quick start ```csharp -[DependantCaches([typeof(ThingTwo),typeof(ThingThree)])] -public class ThingOne +// 1. Register services +builder.Services.AddCleverCache(); + +// 2. Add the interceptor to your DbContext +public class AppDbContext(IInterceptor cleverCacheInterceptor) : DbContext { - public ThingTwo Two {get; set;}; - public ThingThree Three {get; set;}; + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(cleverCacheInterceptor); } -public class ThingTwo; -public class ThingThree; -``` -This will automatically register any keys for `ThingOne` with `ThingTwo` and `ThingThree` -so changes to any object of these types will clear the cache key. You can also reverse these -mappings by using `reverse: true` in the attribute. This will register `ThingTwo` and `ThingThree` with `ThingOne` - -## Auto caching mediatr queries -This is a really powerful tool that enables you to quickly add caching to your mediatr queries without any changes -to your handlers. - -Add the following to your mediatr setup: +// 3. Register the middleware +app.UseCleverCache(); -```csharp -services.AddMediatR(cfg => +// 4. Use it +public class OrderService(ICleverCache cache, AppDbContext db) { - // Other config you may have - cfg.AddCleverCache(); // Registers the mediatr pipeline behaviour -}); -``` -Then simply add the following attribute to any query you want to cache, specifing the type(s) -you want the cache for: -```csharp -[AutoCache([typeof(MyEntityType)])] -public record MyQuery : IRequest; + public async Task> GetAllAsync() + => await cache.GetOrCreateAsync>( + "orders-all", + async () => await db.Orders.ToListAsync() + ) ?? []; +} ``` -This uses the mediatr request as the cache key so you can use the same query with different parameters -and it will cache each one separately. -## Unit testing -Unit testing methods that use cache is generally fiddly, to help with this **CleverCache** is shipped with a -`FakeCache` implementation which you can use in your test. The implementation never caches and always calls your -underlying method retrieve your data. For example when using `Moq.AutoMocker` you would do this: -```csharp -var mocker = new AutoMocker(); -mocker.Use(new FakeCache()); -var sut = mocker.CreateInstance(); +When any `Order` is saved via EF Core, the `"orders-all"` entry is automatically evicted. -// Run unit tests as normall -var result = sut.GetDoorCount(); -``` -Now can unit test the `GetDoorCount` method without the cache getting in the way. +## 📖 Full documentation -Note: If you're using the `Mediatr` automatic caching you don't need this. +| Topic | | +|---|---| +| [Getting Started](https://github.com/chunty/CleverCache/wiki/Getting-Started) | Setup, interceptor registration, EF Core requirement | +| [Caching Data](https://github.com/chunty/CleverCache/wiki/Caching-Data) | `GetOrCreate`, multi-type, entry options | +| [Cache Providers](https://github.com/chunty/CleverCache/wiki/Cache-Providers) | Memory, distributed, Redis, custom | +| [Dependent Caches](https://github.com/chunty/CleverCache/wiki/Dependent-Caches) | `AddKeyToType`, `AddDependentCache`, `[DependentCaches]` attribute | +| [MediatR Integration](https://github.com/chunty/CleverCache/wiki/MediatR-Integration) | `[AutoCache]`, `[InvalidatesCache]`, pipeline setup | +| [Bulk Operations](https://github.com/chunty/CleverCache/wiki/Bulk-Operations) | `ExecuteDelete`/`ExecuteUpdate` workarounds | +| [Unit Testing](https://github.com/chunty/CleverCache/wiki/Unit-Testing) | `FakeCache`, mocking `ICleverCache` | diff --git a/wiki/Bulk-Operations.md b/wiki/Bulk-Operations.md new file mode 100644 index 0000000..50f1fd0 --- /dev/null +++ b/wiki/Bulk-Operations.md @@ -0,0 +1,106 @@ +# Bulk Operations + +EF Core's `ExecuteDelete` and `ExecuteUpdate` — and any other writes that bypass the change tracker (raw SQL, stored procedures, external services) — do **not** trigger CleverCache's `SaveChangesInterceptor`. Cache entries won't be evicted automatically. + +Two workarounds are available. + +--- + +## Option 1 — Fluent `.InvalidateCaches()` extension + +Chain `.InvalidateCaches()` directly after any operation that returns `int` (rows affected). Works for both sync and async: + +```csharp +// Sync +context.Orders.Where(o => o.IsDeleted) + .ExecuteDelete() + .InvalidateCaches(cache, typeof(Order)); + +// Async +await context.Orders.Where(o => o.IsDeleted) + .ExecuteDeleteAsync() + .InvalidateCaches(cache, typeof(Order)); + +// Generic shorthand — no typeof needed +await context.Orders.Where(o => o.IsDeleted) + .ExecuteDeleteAsync() + .InvalidateCaches(cache); + +// Works after ExecuteUpdate too +await context.Orders.Where(o => o.Status == "pending") + .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "complete")) + .InvalidateCaches(cache); +``` + +The call passes the row count through as its return value, so existing code that checks how many rows were affected still compiles. + +### Multiple types + +```csharp +await context.Orders.Where(o => o.IsDeleted) + .ExecuteDeleteAsync() + .InvalidateCaches(cache, typeof(Order), typeof(OrderLine)); +``` + +--- + +## Option 2 — `[InvalidatesCache]` on MediatR commands + +If you're using CQRS with MediatR, decorate the command instead — no manual cache calls anywhere in the codebase: + +```csharp +[InvalidatesCache(typeof(Order), typeof(OrderLine))] +public record BulkDeleteOrdersCommand(int[] OrderIds) : IRequest; +``` + +Cache is cleared after the handler completes successfully. A failed handler leaves the cache untouched. + +See [MediatR Integration](MediatR-Integration) for setup. + +--- + +## Rolling your own invalidation + +If none of the above fit your situation — a message bus consumer, a background job, a third-party SDK that writes directly to the database — you can always call `RemoveByType` directly: + +```csharp +public class OrderSyncService(ICleverCache cache) +{ + public async Task SyncFromExternalSystem(IEnumerable orders) + { + // ... write logic ... + + // Manually evict after writes complete + cache.RemoveByType(); + } +} +``` + +`RemoveByType` understands the full dependency tree, so if `Order` is configured to cascade to `OrderLine` and `OrderNote`, a single call handles all three. You can also pass multiple types: + +```csharp +cache.RemoveByType(); +cache.RemoveByType(); +``` + +Or build a helper that mirrors what `[InvalidatesCache]` does — invalidate a set of types and let the cascades do the rest: + +```csharp +private void InvalidateOrderCaches() +{ + cache.RemoveByType(); // cascades to OrderLine, OrderNote automatically +} +``` + + +The same workarounds apply to any write that bypasses the change tracker — stored procedures, raw SQL via `DbConnection`, or writes from an external service: + +```csharp +// After a stored procedure call +await db.Database.ExecuteSqlRawAsync("EXEC sp_ArchiveOrders"); +cache.RemoveByType(); + +// Or using the extension on the returned task +await db.Database.ExecuteSqlRawAsync("EXEC sp_ArchiveOrders") + .InvalidateCaches(cache); +``` diff --git a/wiki/Cache-Providers.md b/wiki/Cache-Providers.md new file mode 100644 index 0000000..1fc24ba --- /dev/null +++ b/wiki/Cache-Providers.md @@ -0,0 +1,147 @@ +# Cache Providers + +CleverCache supports four cache backend options. + +## Memory cache (default) + +Uses `IMemoryCache`. No extra configuration needed: + +```csharp +builder.Services.AddCleverCache(); +``` + +Supports sliding expiration in addition to absolute expiration. Suitable for single-server deployments. + +## Distributed cache + +Uses any registered `IDistributedCache` backend (Redis, SQL Server, etc.): + +```csharp +// Register your IDistributedCache backend first +builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379"); +// or: builder.Services.AddDistributedMemoryCache(); + +// Then tell CleverCache to use it +builder.Services.AddCleverCache(o => o.UseDistributedCache()); +``` + +> **Note:** Distributed cache does not support sliding expiration. + +## Redis (dedicated package) + +Install `CleverCache.Redis` for a direct Redis connection independent of `IDistributedCache`: + +``` +Install-Package CleverCache.Redis +``` + +> **When to use this instead of the distributed cache option:** +> - You are not using `IDistributedCache` anywhere else in your app and don't want to pull it in just for CleverCache +> - You want CleverCache to use a *different* Redis instance than the one registered as your `IDistributedCache` +> +> If you already have `IDistributedCache` pointing at the right Redis instance, `UseDistributedCache()` is simpler. + +```csharp +// Simple connection string +builder.Services.AddCleverCache(o => o.UseRedisCache("localhost:6379")); + +// Full options +builder.Services.AddCleverCache(o => o.UseRedisCache(redis => +{ + redis.Configuration = "localhost:6379"; + redis.InstanceName = "MyApp:"; +})); +``` + +## Custom provider + +Implement `ICleverCacheStore` to plug in any backing store: + +```csharp +public interface ICleverCacheStore +{ + bool TryGet(object key, out TItem? value); + Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default); + void Set(object key, TItem value, CleverCacheEntryOptions? options = null); + Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default); + void Remove(object key); + Task RemoveAsync(object key, CancellationToken cancellationToken = default); +} +``` + +Register it with: + +```csharp +builder.Services.AddCleverCache(o => o.UseCustomStore()); + +// Or via a factory for stores with dependencies: +builder.Services.AddCleverCache(o => o.UseCustomStore(sp => new MyStore(sp.GetRequiredService()))); +``` + +### Eviction notifications (optional) + +CleverCache tracks which keys belong to which types in memory. To keep this tracking accurate when entries expire via TTL, your store can implement `IEvictionNotifyingStore` alongside `ICleverCacheStore`: + +```csharp +public class MyStore : ICleverCacheStore, IEvictionNotifyingStore +{ + private Action? _onEvicted; + + public void RegisterEvictionCallback(Action onEvicted) => _onEvicted = onEvicted; + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + { + // ... store the value ... + // Call _onEvicted(key) whenever this entry is evicted or expires + } + + // ... rest of ICleverCacheStore implementation +} +``` + +When `CleverCacheService` detects that your store implements `IEvictionNotifyingStore` at startup, it registers itself as the eviction listener automatically — no additional configuration needed. + +If your backing store has no eviction notification API (as is the case with `IDistributedCache`), simply omit `IEvictionNotifyingStore`. Tracked keys are still cleaned up on explicit `Remove`/`RemoveByType` calls; only naturally-expired entries may linger briefly in the in-memory tracking set. + +### Example — dictionary-backed store + +```csharp +public class DictionaryCacheStore : ICleverCacheStore +{ + private readonly Dictionary _store = new(); + + public bool TryGet(object key, out TItem? value) + { + if (_store.TryGetValue(key.ToString()!, out var hit)) + { + value = (TItem?)hit; + return true; + } + value = default; + return false; + } + + public Task<(bool Hit, TItem? Value)> TryGetAsync(object key, CancellationToken cancellationToken = default) + { + var found = TryGet(key, out var value); + return Task.FromResult((found, value)); + } + + public void Set(object key, TItem value, CleverCacheEntryOptions? options = null) + => _store[key.ToString()!] = value; + + public Task SetAsync(object key, TItem value, CleverCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public void Remove(object key) => _store.Remove(key.ToString()!); + + public Task RemoveAsync(object key, CancellationToken cancellationToken = default) + { + Remove(key); + return Task.CompletedTask; + } +} +``` diff --git a/wiki/Caching-Data.md b/wiki/Caching-Data.md new file mode 100644 index 0000000..66e962d --- /dev/null +++ b/wiki/Caching-Data.md @@ -0,0 +1,72 @@ +# Caching Data + +Inject `ICleverCache` into your service and use `GetOrCreate` or `GetOrCreateAsync` — same pattern as `IMemoryCache`, with an extra type parameter that tells CleverCache which entity type owns this cache entry. + +## Basic usage + +```csharp +public class OrderService(ICleverCache cache, AppDbContext db) +{ + public async Task> GetAllAsync() + => await cache.GetOrCreateAsync>( + "orders-all", + async () => await db.Orders.ToListAsync() + ) ?? []; +} +``` + +When any `Order` is saved (created, updated, or deleted) via EF Core, the `"orders-all"` entry is automatically evicted. The same applies if a type that `Order` is registered as a dependent of is saved — for example, if `Customer` is configured to cascade to `Order`, saving a `Customer` will also evict `"orders-all"`. + +## Multiple types + +If a cache entry contains data from more than one entity type, pass all relevant types — the entry will be evicted when *any* of them changes: + +```csharp +var summary = await cache.GetOrCreateAsync>( + new[] { typeof(Order), typeof(Customer) }, + "order-summary", + async () => await BuildSummaryAsync() +); +``` + +## Entry options + +All overloads accept an optional `CleverCacheEntryOptions`: + +```csharp +var options = new CleverCacheEntryOptions +{ + // Expire 10 minutes after creation + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10), + + // Or expire at a fixed point in time + AbsoluteExpiration = DateTimeOffset.UtcNow.AddHours(1), + + // Extend lifetime on each read (memory cache only) + SlidingExpiration = TimeSpan.FromMinutes(5), +}; + +var result = await cache.GetOrCreateAsync>( + "orders-all", + async () => await db.Orders.ToListAsync(), + options +); +``` + +> **Sliding expiration** is only honoured by the memory cache provider. Distributed and Redis providers ignore it. + +## Manual removal + +Remove a specific key: + +```csharp +cache.Remove("orders-all"); +``` + +Remove all entries for a type (also cascades to dependent types — see [Dependent Caches](Dependent-Caches)): + +```csharp +cache.RemoveByType(); +// or +cache.RemoveByType(typeof(Order)); +``` diff --git a/wiki/Dependent-Caches.md b/wiki/Dependent-Caches.md new file mode 100644 index 0000000..33ad071 --- /dev/null +++ b/wiki/Dependent-Caches.md @@ -0,0 +1,98 @@ +# Dependent Caches + +CleverCache tracks which entity types a cache entry is associated with and evicts it automatically when those types change. This page covers the scenarios for configuring cache dependency relationships. + +--- + +## Scenario 1 — A cache entry spans multiple entity types + +If a single cache entry contains data from more than one entity type, pass all relevant types when creating it. The entry is evicted when *any* of them changes: + +```csharp +var summary = await cache.GetOrCreateAsync>( + new[] { typeof(Order), typeof(Customer) }, + "order-summary", + async () => await BuildSummaryAsync() +); +``` + +For keys that weren't created through `GetOrCreate` — for example after a bulk operation — register them after the fact: + +```csharp +cache.AddKeyToType("orders-all"); +cache.AddKeyToTypes(new[] { typeof(Order), typeof(Customer) }, "order-summary"); +``` + +--- + +## Scenario 2 — Invalidating one type should always cascade to another + +If you always want invalidating `Order` to also invalidate `OrderLine` caches — regardless of how or where that invalidation is triggered — register a cascade rule. + +**Via the `[DependentCaches]` attribute** (recommended — configured once at startup): + +```csharp +[DependentCaches([typeof(OrderLine), typeof(OrderNote)])] +public class Order { } +``` + +Register the assembly containing your entities when configuring CleverCache — no EF Core required: + +```csharp +builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); +// or, when using EF Core: +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); +``` + +CleverCache scans the assembly at startup and registers the cascade rules. Now `cache.RemoveByType()` also clears all `OrderLine` and `OrderNote` entries automatically. + +**Programmatically** (for dynamic or test scenarios): + +```csharp +cache.AddDependentCache(typeof(OrderLine)); +``` + +### Cascades are transitive + +`A → B → C` means removing `A` also removes `B` and `C`. Cycles are handled safely. + +### Reverse mappings + +Use `reverse: true` to also register the inverse — so invalidating `OrderLine` cascades back to `Order`: + +```csharp +[DependentCaches([typeof(OrderLine)], reverse: true)] +public class Order { } +``` + +--- + +## Scenario 3 — Auto-wire the whole context via navigation scanning + +If you want cascade rules discovered automatically from your EF Core model without decorating any classes, call `ScanDbSetsForCacheDependencies` after building your app. This inspects every entity type in the context's model and registers cascades based on navigation properties: + +```csharp +app.ScanDbSetsForCacheDependencies(); + +// Control the depth of scanning: +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); + +// Also register reverse cascades (when OrderLine changes, also clear Order): +app.ScanDbSetsForCacheDependencies(o => +{ + o.NavigationScanMode = DependentCacheNavigationScanMode.Recursive; + o.ReverseNavigationDependencies = true; +}); +``` + +| Mode | Behaviour | +|---|---| +| `Direct` (default) | Scans the immediate navigation properties of each entity | +| `Recursive` | Scans the full navigation graph transitively from each entity | +| `None` | No scanning — returns nothing | + +> **⚠️ Consider carefully before using this in large projects.** Global navigation scanning wires up every entity in your context. In a large schema this can create a very wide dependency tree — a change to a central entity like `Customer` or `User` may cascade to dozens of cache keys, leading to excessive invalidation and high memory usage from tracking all those key associations. For most projects, Scenario 2 (`[DependentCaches]` attributes with `ScanAssemblyContaining`) gives you the same convenience with precise, opt-in control over which relationships matter. + +> **Navigation scanning and attributes are independent.** `ScanDbSetsForCacheDependencies` discovers relationships purely from EF navigation properties. `[DependentCaches]` attributes are registered separately via `ScanAssemblyContaining`. Use both together for full coverage. + diff --git a/wiki/Diagnostics.md b/wiki/Diagnostics.md new file mode 100644 index 0000000..e9fe391 --- /dev/null +++ b/wiki/Diagnostics.md @@ -0,0 +1,61 @@ +# Diagnostics + +CleverCache can produce a snapshot of its internal state at any point — useful for debugging misconfigured dependency trees or unexpectedly broad cache invalidation. + +--- + +## `GetDiagnostics()` + +Returns a `CleverCacheDiagnostics` record with two properties: + +| Property | Type | Description | +|---|---|---| +| `Dependants` | `IReadOnlyDictionary>` | Direct cascade edges — the types that will also be invalidated when a given type changes | +| `KeysByType` | `IReadOnlyDictionary>` | All cache keys currently tracked under each type, including keys inherited via cascade expansion | + +```csharp +var diagnostics = cache.GetDiagnostics(); + +// Which types does invalidating Order cascade to? +var cascades = diagnostics.Dependants[typeof(Order)]; + +// Which keys are currently tracked under Order? +var keys = diagnostics.KeysByType[typeof(Order)]; +``` + +--- + +## `RenderDependencyTree()` + +Formats the full dependency graph as a human-readable string. Useful for logging at startup or exposing via a diagnostic endpoint. + +```csharp +// Log at startup +logger.LogDebug(cache.RenderDependencyTree()); + +// Expose via a diagnostic endpoint +app.MapGet("/_cache/tree", (ICleverCache cache) => cache.RenderDependencyTree()); +``` + +Example output: + +``` +CleverCache Dependency Tree +──────────────────────────────────────── +Customer + ↳ cascades to : Order +Order + ↳ cascades to : OrderLine, OrderNote + ↳ tracked keys : orders-all, order:123, order:456 +OrderLine + ↳ tracked keys : orderlines-for-123 +OrderNote +─ 4 type(s) | 2 cascade rule(s) | 4 tracked key(s) ─ +``` + +> **Note:** `KeysByType` reflects the keys currently tracked by CleverCache. Keys are removed from the tracking set when: +> - `Remove(key)` is called explicitly +> - `RemoveByType(type)` is called (e.g. via the EF Core interceptor on `SaveChanges`) +> - The entry is evicted from the underlying store and the store implements `IEvictionNotifyingStore` (e.g. `MemoryCacheStore`) +> +> For distributed cache stores that don't support eviction notifications, keys may remain visible in `KeysByType` after their TTL expires in the remote store. This does not affect correctness — stale keys are simply no-ops when invalidation fires. diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md new file mode 100644 index 0000000..d39119d --- /dev/null +++ b/wiki/Getting-Started.md @@ -0,0 +1,121 @@ +# Getting Started + +## Install + +``` +Install-Package CleverCache +``` + +Or via the .NET CLI: + +``` +dotnet add package CleverCache +``` + +## Usage paths + +CleverCache can be used with or without EF Core: + +| Scenario | What you need | +|---|---| +| Manual or attribute-driven invalidation | `AddCleverCache` only | +| Automatic invalidation on `SaveChanges` | `AddCleverCacheEntityFramework` | +| Automatic cascade discovery from navigation properties | `AddCleverCacheEntityFramework` + `ScanDbSetsForCacheDependencies` | + +> **Without the interceptor** you only get dependency tree management — you can define cascade rules with `[DependentCaches]` or `AddDependentCache`, and call `RemoveByType()` yourself to invalidate. Cache entries are never evicted automatically; you are responsible for triggering invalidation. The interceptor is what makes CleverCache hands-off. + +## 1. Register services + +**Without EF Core** (manual or attribute-driven invalidation only): + +```csharp +builder.Services.AddCleverCache(); +// optionally scan assemblies for [DependentCaches] attributes: +builder.Services.AddCleverCache(o => o.ScanAssemblyContaining()); +``` + +**With EF Core** — `AddCleverCacheEntityFramework` registers the interceptor and calls `AddCleverCache` internally, so a single call is all you need: + +```csharp +builder.Services.AddCleverCacheEntityFramework(); +// with CleverCache options: +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); +``` + +See [Cache Providers](Cache-Providers) for memory, distributed, Redis, and custom provider options. + +## 2. Register the EF Core interceptor (optional) + +The interceptor is what makes CleverCache automatic — cache entries for changed entity types are evicted the moment `SaveChanges` or `SaveChangesAsync` completes. Without it, you can still use CleverCache by calling `RemoveByType()` yourself. + +Install the EF Core package: + +``` +dotnet add package CleverCache.EntityFrameworkCore +``` + +`AddCleverCacheEntityFramework()` registers both the core services and the interceptor, so you only need one call (see step 1 above). + +> **Important:** Automatic invalidation only fires for writes that go through EF Core's change tracker. Writes that bypass it — `ExecuteUpdate`, `ExecuteDelete`, raw SQL, stored procedures, or external services — will **not** trigger invalidation. See [Bulk Operations](Bulk-Operations) for workarounds. + +Inject the interceptor into your `DbContext`: + +```csharp +// Option A — single interceptor +public class AppDbContext(IInterceptor cleverCacheInterceptor) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(cleverCacheInterceptor); +} + +// Option B — alongside existing interceptors +public class AppDbContext(IInterceptor[] interceptors) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(interceptors); +} + +// Option C — concrete type +public class AppDbContext(CleverCacheInterceptor cleverCacheInterceptor) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(cleverCacheInterceptor); +} +``` + +## 3. Scan DbSet navigation properties (optional) + +Only needed if you want CleverCache to auto-discover cascade rules from EF Core navigation properties. This can be called multiple times for multiple DbContext types, each with their own scan options. + +**ASP.NET Core apps** — call on `IApplicationBuilder`: + +```csharp +app.ScanDbSetsForCacheDependencies(); + +// With navigation scanning options: +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); + +// Multiple contexts with different options: +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct); +app.ScanDbSetsForCacheDependencies(); +``` + +**Worker services and console apps** — `IApplicationBuilder` isn't available, call on `IServiceProvider` instead: + +```csharp +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(services => services.AddCleverCacheEntityFramework()) + .Build(); + +host.Services.ScanDbSetsForCacheDependencies(); + +await host.RunAsync(); +``` + +See [Dependent Caches](Dependent-Caches) for full details on navigation scanning modes. + +> **Prefer `[DependentCaches]` attributes for most projects.** Global DbSet scanning wires up every entity in your context — in large schemas this can cause excessive invalidation and high memory usage from tracking too many key associations. `ScanAssemblyContaining` (step 1) gives you the same automatic wiring with opt-in, per-entity control. + +> **Not using EF Core navigation scanning?** Skip this step entirely — `[DependentCaches]` attributes via `ScanAssemblyContaining` don't need it. diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..37b3029 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,30 @@ +# CleverCache + +**CleverCache** solves the problem of remembering when to invalidate cache entries when the underlying data changes — especially when a single cache entry contains data from multiple entity types. + +With a small amount of configuration, CleverCache automatically tracks changes via EF Core and clears related cache entries when any tracked entity is created, updated, or deleted. + +## Documentation + +| Topic | Description | +|---|---| +| [Getting Started](Getting-Started) | Install, configure EF Core interceptor, register services | +| [Caching Data](Caching-Data) | `GetOrCreate`, multi-type associations, entry options | +| [Cache Providers](Cache-Providers) | Memory, distributed, Redis, custom providers | +| [Dependent Caches](Dependent-Caches) | `AddKeyToType`, `AddDependentCache`, `[DependentCaches]` attribute | +| [MediatR Integration](MediatR-Integration) | `[AutoCache]`, `[InvalidatesCache]`, pipeline setup | +| [Bulk Operations](Bulk-Operations) | Handling `ExecuteDelete`/`ExecuteUpdate` and non-EF writes | +| [Diagnostics](Diagnostics) | Inspecting the dependency graph and tracked keys at runtime | +| [Unit Testing](Unit-Testing) | `FakeCache`, mocking `ICleverCache` | +| [Migrating to V2](Migrating-to-V2) | Breaking changes and before/after examples for V1 → V2 | + +## Packages + +| Package | NuGet | Purpose | +|---|---|---| +| `CleverCache` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.svg)](https://www.nuget.org/packages/clevercache) | Core package | +| `CleverCache.EntityFrameworkCore` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.entityframeworkcore.svg)](https://www.nuget.org/packages/clevercache.entityframeworkcore) | EF Core interceptor and DbSet scanning | +| `CleverCache.MediatR` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.mediatr.svg)](https://www.nuget.org/packages/clevercache.mediatr) | MediatR pipeline behaviours | +| `CleverCache.Redis` | [![NuGet](https://img.shields.io/nuget/vpre/clevercache.redis.svg)](https://www.nuget.org/packages/clevercache.redis) | Redis cache provider | + +> **EF Core.** Automatic invalidation via `SaveChanges` requires the `CleverCache.EntityFrameworkCore` package. Without EF Core you can still use CleverCache for manual invalidation — see [Bulk Operations](Bulk-Operations). diff --git a/wiki/MediatR-Integration.md b/wiki/MediatR-Integration.md new file mode 100644 index 0000000..918244c --- /dev/null +++ b/wiki/MediatR-Integration.md @@ -0,0 +1,98 @@ +# MediatR Integration + +Install the separate `CleverCache.MediatR` package to keep your main project free of the MediatR dependency: + +``` +Install-Package CleverCache.MediatR +``` + +## Setup + +Register the CleverCache pipeline behaviours inside your `AddMediatR` call: + +```csharp +services.AddMediatR(cfg => +{ + cfg.AddCleverCache(); +}); +``` + +This registers two pipeline behaviours automatically: +- **`InvalidateCacheBehaviour`** — clears cache after a command succeeds (see below) +- **`AutoCacheBehaviour`** — caches query results (see below) + +--- + +## Auto-caching queries with `[AutoCache]` + +Add `[AutoCache]` to any MediatR query to cache its result. The MediatR request object itself is used as the cache key, so the same query with different parameters gets its own cache entry. + +```csharp +[AutoCache([typeof(Order)])] +public record GetOrdersQuery(int CustomerId) : IRequest>; +``` + +No changes to the handler — caching is handled entirely by the pipeline behaviour. + +When any `Order` is saved via EF Core, all `GetOrdersQuery` cache entries are automatically evicted. + +### Multiple types + +```csharp +[AutoCache([typeof(Order), typeof(Customer)])] +public record GetOrderSummaryQuery(int OrderId) : IRequest; +``` + +The cache entry is evicted when *either* type changes. + +### Entry options + +```csharp +[AutoCache([typeof(Order)], AbsoluteExpirationSeconds = 300)] +public record GetOrdersQuery(int CustomerId) : IRequest>; +``` + +### Respects registered dependencies + +`[AutoCache]` and `[InvalidatesCache]` both work through the same dependency tree as the rest of CleverCache. If you have `[DependentCaches]` configured (or cascades registered via `ScanAssemblyContaining`), you only need to reference the root type — dependents are handled automatically. + +For example, if `Order` is configured to cascade to `OrderLine` and `OrderNote`, this is sufficient: + +```csharp +[AutoCache([typeof(Order)])] +public record GetOrdersQuery(int CustomerId) : IRequest>; + +[InvalidatesCache(typeof(Order))] +public record DeleteOrderCommand(int OrderId) : IRequest; +``` + +You do not need to list `OrderLine` or `OrderNote` — invalidating `Order` cascades to them automatically. + +--- + +Add `[InvalidatesCache]` to any command to automatically clear the specified cache types after the handler completes successfully: + +```csharp +[InvalidatesCache(typeof(Order), typeof(OrderLine))] +public record DeleteOrderCommand(int OrderId) : IRequest; +``` + +- Cache is only cleared if the handler **completes without throwing** — a failed command leaves the cache untouched. +- Works with any return type (`IRequest`, `IRequest`, etc.). +- Types are cleared in the order declared; each call to `RemoveByType` also cascades to dependent types (see [Dependent Caches](Dependent-Caches)). + +### Combined with `[AutoCache]` + +A command decorated with `[InvalidatesCache]` pairs naturally with queries decorated with `[AutoCache]`: + +```csharp +// Query — cache results +[AutoCache([typeof(Order)])] +public record GetOrdersQuery(int CustomerId) : IRequest>; + +// Command — clear cache after success +[InvalidatesCache(typeof(Order))] +public record CreateOrderCommand(int CustomerId, ...) : IRequest; +``` + +When `CreateOrderCommand` succeeds, all `GetOrdersQuery` entries for all customers are evicted — no manual cache calls needed anywhere. diff --git a/wiki/Migrating-to-V2.md b/wiki/Migrating-to-V2.md new file mode 100644 index 0000000..aed93c3 --- /dev/null +++ b/wiki/Migrating-to-V2.md @@ -0,0 +1,239 @@ +# Migrating from V1 to V2 + +V2 is a significant refactor. The core caching API is mostly the same but several breaking changes tighten the design and reduce coupling. This page documents every change with before/after examples. + +--- + +## Summary of breaking changes + +| Area | V1 | V2 | +|---|---|---| +| EF Core dependency | Bundled in `CleverCache` | Separate `CleverCache.EntityFrameworkCore` package | +| Startup (interceptor) | `AddCleverCache()` registered interceptor | `AddCleverCacheEntityFramework()` required | +| Startup (scanning) | `app.UseCleverCache()` | `app.ScanDbSetsForCacheDependencies()` | +| Attribute discovery | `UseCleverCache` picked up `[DependantCaches]` automatically | Must call `ScanAssemblyContaining()` explicitly in `AddCleverCacheEntityFramework` | +| Scan options | `CleverCacheOptions.Scanning` | Passed directly to `ScanDbSetsForCacheDependencies` | +| `GetOrCreate` factory | `Func` | `Func` | +| `GetOrCreateAsync` factory | `Func>` | `Func>` | +| Cache entry options type | `MemoryCacheEntryOptions` | `CleverCacheEntryOptions` | +| Dependent cache attribute | `[DependantCaches]` | `[DependentCaches]` | +| `FakeCache` namespace | `CleverCache.Implementations` | `CleverCache` | +| `DependentCacheNavigationScanMode` namespace | `CleverCache.Models` | `CleverCache.EntityFrameworkCore.Models` | + +--- + +## Step-by-step migration + +### 1. Install the new EF Core package + +V2 moves all EF Core concerns into a dedicated package. Your main project no longer depends on `Microsoft.EntityFrameworkCore`. + +**Before** — one package does everything: +``` +dotnet add package CleverCache +``` + +**After** — install both: +``` +dotnet add package CleverCache +dotnet add package CleverCache.EntityFrameworkCore +``` + +--- + +### 2. Update DI registration + +`AddCleverCache()` no longer registers the EF Core interceptor. Replace both calls with a single `AddCleverCacheEntityFramework()`, which registers the interceptor and calls `AddCleverCache()` internally. + +**Before:** +```csharp +builder.Services.AddCleverCache(); +``` + +**After:** +```csharp +builder.Services.AddCleverCacheEntityFramework(); +// with CleverCache options (e.g. assembly scanning): +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); +``` + +> If you are not using EF Core (manual invalidation only), keep using `AddCleverCache()` — `AddCleverCacheEntityFramework` is only needed if you have the EF package installed. + +--- + +### 3. Update `UseCleverCache` → `ScanDbSetsForCacheDependencies` + +> **Only needed if you were using navigation scanning.** If you used `UseCleverCache` purely to pick up `[DependantCaches]` attributes, you can simply **remove** the call — attribute registration is now handled separately in step 5. + +If you were using navigation scanning, replace `app.UseCleverCache()` with `app.ScanDbSetsForCacheDependencies()`. Scan options are now passed directly instead of being stored on `CleverCacheOptions`. + +**Before:** +```csharp +builder.Services.AddCleverCache(o => +{ + o.Scanning.NavigationScanMode = DependentCacheNavigationScanMode.Direct; + o.Scanning.ReverseNavigationDependencies = true; +}); +app.UseCleverCache(); +``` + +**After:** +```csharp +builder.Services.AddCleverCacheEntityFramework(); +// ... +app.ScanDbSetsForCacheDependencies(o => +{ + o.NavigationScanMode = DependentCacheNavigationScanMode.Direct; + o.ReverseNavigationDependencies = true; +}); +``` + +Multiple DbContexts each get their own call with independent options: + +```csharp +app.ScanDbSetsForCacheDependencies(); +app.ScanDbSetsForCacheDependencies(o => + o.NavigationScanMode = DependentCacheNavigationScanMode.Recursive); +``` + +> **Note:** `ScanDbSetsForCacheDependencies` is navigation scanning only — it no longer processes `[DependentCaches]` attributes. See step 5 below. + +--- + +### 4. Update the `[DependantCaches]` attribute + +The attribute class was renamed to fix a spelling mistake. The `navigationScanMode` parameter has been removed — navigation scanning is now exclusively handled by `ScanDbSetsForCacheDependencies`. + +**Before:** +```csharp +[DependantCaches([typeof(OrderLine), typeof(OrderNote)])] +public class Order { } +``` + +**After:** +```csharp +[DependentCaches([typeof(OrderLine), typeof(OrderNote)])] +public class Order { } +``` + +--- + +### 5. Register `[DependentCaches]` attributes at startup + +In V1, `UseCleverCache` processed `[DependantCaches]` attributes as a side effect of EF model scanning. In V2 these are separate concerns — `ScanDbSetsForCacheDependencies` handles navigation scanning only. Attributes must now be registered explicitly via `ScanAssemblyContaining`: + +```csharp +builder.Services.AddCleverCacheEntityFramework(o => o.ScanAssemblyContaining()); +``` + +You can combine both: use `ScanAssemblyContaining` for explicit attribute-based dependencies and `ScanDbSetsForCacheDependencies` for navigation-driven discovery. + +--- + +### 6. Update `GetOrCreate` / `GetOrCreateAsync` factory signatures + +The factory delegate no longer receives an `ICacheEntry`. Cache entry options are now a separate parameter of type `CleverCacheEntryOptions` instead of `MemoryCacheEntryOptions`. + +**Before:** +```csharp +var result = await cache.GetOrCreateAsync>( + [typeof(Order)], + "orders-all", + async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); + return await db.Orders.ToListAsync(); + }); +``` + +**After:** +```csharp +var result = await cache.GetOrCreateAsync>( + [typeof(Order)], + "orders-all", + async () => await db.Orders.ToListAsync(), + new CleverCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }); +``` + +The synchronous overload changes identically: + +**Before:** +```csharp +var result = cache.GetOrCreate( + [typeof(Order)], + "order-count", + entry => db.Orders.Count()); +``` + +**After:** +```csharp +var result = cache.GetOrCreate( + [typeof(Order)], + "order-count", + () => db.Orders.Count()); +``` + +--- + +### 7. Update `FakeCache` namespace + +`FakeCache` has moved from the `CleverCache.Implementations` namespace to the root `CleverCache` namespace. + +**Before:** +```csharp +using CleverCache.Implementations; + +mocker.Use(new FakeCache()); +``` + +**After:** +```csharp +// No extra using needed — FakeCache is in the CleverCache namespace +mocker.Use(new FakeCache()); +``` + +--- + +### 8. DbContext constructor injection (optional cleanup) + +V1 required you to inject `IInterceptor` or `CleverCacheInterceptor` into your DbContext constructor. This still works in V2 (the interceptor is still registered as `IInterceptor`), but the preferred V2 pattern is to wire it up in `AddDbContext` using the extension method: + +**V1 (still valid in V2 — no change required):** +```csharp +public class AppDbContext(IInterceptor cleverCacheInterceptor) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options.AddInterceptors(cleverCacheInterceptor); +} +``` + +**V2 preferred (no DbContext constructor changes):** +```csharp +// Program.cs +builder.Services.AddDbContext((sp, options) => +{ + options.UseSqlServer(connectionString); + options.AddCleverCache(sp); // extension from CleverCache.EntityFrameworkCore +}); + +// AppDbContext.cs — no interceptor injection needed +public class AppDbContext(DbContextOptions options) : DbContext(options) { } +``` + +--- + +## New features in V2 + +These aren't breaking changes but are worth knowing about: + +- **`ScanAssemblyContaining()`** — scan assemblies for `[DependentCaches]` attributes without EF Core. Replaces manually calling `AddDependentCache` for each type. + +- **`[InvalidatesCache]` for MediatR** — decorate any command with `[InvalidatesCache(typeof(Order))]` to evict cache entries automatically after the command succeeds. + +- **`InvalidateCaches` bulk extension** — chain onto `ExecuteDelete` / `ExecuteDeleteAsync` results for bulk operations that bypass the change tracker. + +- **Distributed cache and Redis** — `CleverCache.Redis` is now the only way to bring in StackExchange.Redis. The `AddCleverCache()` options no longer accept `UseRedisCache`. + +- **Custom cache provider** — implement `ICleverCacheStore` and register with `AddCleverCache(o => o.UseCustomStore())`. + +- **Stampede protection** — concurrent requests for the same key now only invoke the factory once. diff --git a/wiki/Unit-Testing.md b/wiki/Unit-Testing.md new file mode 100644 index 0000000..3fc0e60 --- /dev/null +++ b/wiki/Unit-Testing.md @@ -0,0 +1,46 @@ +# Unit Testing + +## Making cache transparent with `FakeCache` + +CleverCache ships with a `FakeCache` implementation that never caches — it always calls through to the underlying factory. This makes your service logic fully testable without the cache getting in the way. + +```csharp +using CleverCache; + +var mocker = new AutoMocker(); +mocker.Use(new FakeCache()); +var sut = mocker.CreateInstance(); + +// Tests run as normal — the factory is always called, cache is invisible +var result = await sut.GetAllOrdersAsync(); +``` + +`FakeCache` is in the root `CleverCache` namespace — no additional `using` directives needed beyond what you'd normally import. + +## Verifying cache interactions with `Mock` + +If you need to assert *how* the cache was used — for example, that the factory was called exactly once, or that a specific key was evicted — use a mock instead: + +```csharp +var cacheMock = new Mock(); +cacheMock + .Setup(c => c.GetOrCreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>>(), + null)) + .ReturnsAsync(new List()); + +var sut = new OrderService(cacheMock.Object, db); +await sut.GetAllOrdersAsync(); + +cacheMock.Verify(c => c.GetOrCreateAsync(...), Times.Once); +``` + +`ICleverCache` is a plain interface and works with any mocking library (Moq, NSubstitute, FakeItEasy, etc.). + +## MediatR and `[AutoCache]` + +If you are *only* using MediatR automatic caching via `[AutoCache]` and never injecting `ICleverCache` directly into your services, you don't need `FakeCache` at all — the `AutoCacheBehaviour` pipeline behaviour is never part of your unit test boundary. + +For integration tests that exercise the full MediatR pipeline, register `FakeCache` or an in-memory store in your test service collection.