Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
ea0c54e
v2: cache provider abstraction, MediatR + Redis sub-packages, multi-T…
chrisqescom May 2, 2026
119cd11
Add unit tests and fix DistributedCacheStore key encoding
chrisqescom May 2, 2026
0b1b7a7
Remove ICacheEntryManager - inline members onto ICleverCache
chrisqescom May 2, 2026
f22b82f
Cleanup: remove dead code, fix typo, fix base exception class
chrisqescom May 2, 2026
2a95ba0
Move FakeCache to root CleverCache namespace, improve testing docs
chrisqescom May 3, 2026
9e8ccee
README: clarify EF Core dependency and manual invalidation fallback
chrisqescom May 3, 2026
213167d
README: give MediatR callout proper prominence
chrisqescom May 3, 2026
0ccbaed
README: promote MediatR to its own section heading
chrisqescom May 3, 2026
9f69568
Add InvalidatesCache attribute and bulk operation InvalidateCaches ex…
chrisqescom May 3, 2026
32f396f
docs, assembly scanning, and bug fixes
chrisqescom May 3, 2026
2f108bf
feat: split EF Core concerns into CleverCache.EntityFrameworkCore pac…
chrisqescom May 3, 2026
08a5b20
chore: update solution file for Visual Studio 2022
chrisqescom May 3, 2026
853f753
test: add CleverCacheInterceptor and DbContextExtensions tests
chrisqescom May 3, 2026
ca7093d
docs: add V1 to V2 migration guide
chrisqescom May 3, 2026
157c9c7
feat: AddCleverCacheEntityFramework calls AddCleverCache internally
chrisqescom May 3, 2026
ca66a91
docs: clarify attribute scanning requirement when not using ScanDbSet…
chrisqescom May 3, 2026
87804f3
Clean up DependentCachesAttribute and wiki docs
chrisqescom May 3, 2026
eb4eca6
docs: add attribute discovery breaking change to migration guide summary
chrisqescom May 3, 2026
ff8a047
docs: move attribute steps adjacent to UseCleverCache step in migrati…
chrisqescom May 3, 2026
73b5ec8
fix: rename DependantTypes to DependentTypes on DependentCachesAttribute
chrisqescom May 3, 2026
a3a65cd
docs: add V2 breaking changes warning to README
chrisqescom May 3, 2026
e238890
docs: simplify V2 warning in README
chrisqescom May 3, 2026
9462011
docs: use emoji warning icon for NuGet compatibility
chrisqescom May 3, 2026
b6e047a
feat: add GetDiagnostics and RenderDependencyTree
chrisqescom May 3, 2026
dd45076
docs: add Diagnostics wiki page for GetDiagnostics and RenderDependen…
chrisqescom May 3, 2026
ef991d5
ci: add workflow to auto-publish wiki/ to GitHub Wiki on push to main
chrisqescom May 3, 2026
3293f2c
docs: clarify step 3 is only needed when using navigation scanning
chrisqescom May 3, 2026
d721025
feat: propagate CancellationToken and add RemoveByTypeAsync
chrisqescom May 3, 2026
a2ceda1
ci: update repo About and add wiki badge on push to main
chrisqescom May 3, 2026
1dbe451
ci: auto-update repo About from csproj metadata on push to main
chrisqescom May 3, 2026
a1a8f10
fix: NavigationScanningHelper dedup logic drops valid bidirectional r…
chrisqescom May 5, 2026
924a02b
refactor: move DependentCacheNavigationScanMode to EF Core package
chrisqescom May 5, 2026
e0f5f58
fix: AutoCacheBehaviour null result causes handler to execute twice
chrisqescom May 5, 2026
dc9d4cf
feat: add IServiceProvider overload for ScanDbSetsForCacheDependencies
chrisqescom May 5, 2026
cb6f6f1
fix: clean up _keysByType on explicit Remove and RemoveByType
chrisqescom May 5, 2026
3c457e8
feat: add IEvictionNotifyingStore for TTL-based key cleanup
chrisqescom May 5, 2026
b35633a
docs: document IEvictionNotifyingStore and update key tracking notes
chrisqescom May 5, 2026
1d6d8ed
chore: bump .NET 9 packages to 9.0.15 and xunit runner to 3.1.5
chrisqescom May 6, 2026
453752d
ci: fix update-about workflow to use PAT and atomic topic replacement
chrisqescom May 7, 2026
a42b909
ci: rename PAT secret to SYNC_META_TOKEN
chrisqescom May 7, 2026
9d1088d
ci: document required token scope (public_repo)
chrisqescom May 7, 2026
310f0b7
ci: add CI workflow to run tests on PRs and pushes to main
chrisqescom May 7, 2026
51ab80a
feat: add .NET 8 (LTS) support
chrisqescom May 7, 2026
77ad6e0
test: migrate to xunit v3
chrisqescom May 7, 2026
e3298ff
package update
chrisqescom May 7, 2026
44368c6
Treat warning as errors
chrisqescom May 7, 2026
3bb5c60
clean up
chrisqescom May 26, 2026
758eb17
update packages
chrisqescom May 26, 2026
a513823
ci: run Build & Test on master and main
chrisqescom May 26, 2026
7959826
ci: use trx logger in test step
chrisqescom May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions .github/workflows/publish-wiki.yml
Original file line number Diff line number Diff line change
@@ -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 }}

45 changes: 45 additions & 0 deletions .github/workflows/update-about.yml
Original file line number Diff line number Diff line change
@@ -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 '(?<=<Description>)[^<]+' CleverCache.csproj)
TAGS=$(grep -oPm1 '(?<=<PackageTags>)[^<]+' 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 }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@
/obj
/bin


/*/bin/*
/*/obj/*
26 changes: 22 additions & 4 deletions Attributes/DependentCachesAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
namespace CleverCache.Attributes;

/// <summary>
/// Declares cache dependency relationships for an entity type.
/// Use <c>builder.Services.AddCleverCache(o => o.ScanAssemblyContaining&lt;T&gt;())</c> to register
/// these at startup. When any entry for this type is invalidated, entries for all declared
/// dependent types are also invalidated.
/// </summary>
/// <example>
/// <code>
/// // 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 { }
/// </code>
/// </example>
[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;
/// <summary>The entity types that should also be invalidated when this type's entries are evicted.</summary>
public Type[] DependentTypes { get; } = types ?? [];

/// <summary>When <c>true</c>, also registers the inverse dependency so that invalidating any dependent type also invalidates this type.</summary>
public bool Reverse { get; set; } = reverse;
}
28 changes: 27 additions & 1 deletion CacheKeyManager.cs
Original file line number Diff line number Diff line change
@@ -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<TKey, byte>)
private readonly ConcurrentDictionary<Type, ConcurrentDictionary<object, byte>> _keysByType = new();
Expand Down Expand Up @@ -31,12 +31,38 @@ public void AddKeyToTypes(Type[] types, object key)

}

/// <summary>
/// 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.
/// </summary>
protected void RemoveKeyFromAllTypes(object key)
{
foreach (var typeSet in _keysByType.Values)
typeSet.TryRemove(key, out _);
}

/// <summary>
/// Snapshot keys for a given type (safe to enumerate).
/// </summary>
protected object[] SnapshotKeysFor(Type type) =>
_keysByType.TryGetValue(type, out var set) ? set.Keys.ToArray() : [];

/// <summary>
/// Snapshot of the full dependency graph and tracked keys.
/// </summary>
protected CleverCacheDiagnostics SnapshotDiagnostics()
{
var dependants = _dependants.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<Type>)kvp.Value.Keys.OrderBy(t => t.Name).ToList());

var keysByType = _keysByType.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<object>)kvp.Value.Keys.ToList());

return new CleverCacheDiagnostics(dependants, keysByType);
}

/// <summary>
/// Transitive closure over dependents, cycle-safe
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>CleverCache.EntityFrameworkCore</PackageId>
<Version>2.0.0</Version>
<PackageProjectUrl>https://github.com/chunty/CleverCache/wiki/Getting-Started</PackageProjectUrl>
<Description>EF Core integration for CleverCache — automatic cache invalidation via SaveChangesInterceptor and DbSet navigation scanning.</Description>
<PackageTags>MemoryCache,Cache Invalidation,EntityFrameworkCore,EF Core</PackageTags>
<PackageReadmeFile>NuGetReadMe.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CleverCache.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
</ItemGroup>
<ItemGroup>
<None Include="NuGetReadMe.md" Pack="true" PackagePath="NuGetReadMe.md" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>CleverCache.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Builder;

namespace CleverCache.EntityFrameworkCore.DependencyInjection;

public static class ApplicationBuilderExtensions
{
/// <summary>
/// Scans the <see cref="DbContext"/> navigation properties for <typeparamref name="TContext"/>
/// and registers the discovered cache dependency rules at startup.
/// Can be called multiple times for multiple <see cref="DbContext"/> types, each with their own scan options.
/// </summary>
/// <remarks>
/// Use this overload in ASP.NET Core apps where <see cref="IApplicationBuilder"/> is available.
/// For worker services or console apps, use the <see cref="IServiceProvider"/> overload instead.
/// </remarks>
/// <example>
/// <code>
/// app.ScanDbSetsForCacheDependencies&lt;AppDbContext&gt;(o =>
/// o.NavigationScanMode = DependentCacheNavigationScanMode.Direct);
/// </code>
/// </example>
public static IApplicationBuilder ScanDbSetsForCacheDependencies<TContext>(
this IApplicationBuilder app,
Action<CleverCacheScanOptions>? configure = null)
where TContext : DbContext
{
app.ApplicationServices.ScanDbSetsForCacheDependencies<TContext>(configure);
return app;
}

/// <summary>
/// Scans the <see cref="DbContext"/> navigation properties for <typeparamref name="TContext"/>
/// and registers the discovered cache dependency rules at startup.
/// Can be called multiple times for multiple <see cref="DbContext"/> types, each with their own scan options.
/// </summary>
/// <remarks>
/// Use this overload in worker services or console apps where <see cref="IApplicationBuilder"/> is not available.
/// </remarks>
/// <example>
/// <code>
/// host.Services.ScanDbSetsForCacheDependencies&lt;AppDbContext&gt;(o =>
/// o.NavigationScanMode = DependentCacheNavigationScanMode.Direct);
/// await host.RunAsync();
/// </code>
/// </example>
public static IServiceProvider ScanDbSetsForCacheDependencies<TContext>(
this IServiceProvider services,
Action<CleverCacheScanOptions>? configure = null)
where TContext : DbContext
{
var cache = services.GetRequiredService<ICleverCache>();
var scanOptions = new CleverCacheScanOptions();
configure?.Invoke(scanOptions);

using var scope = services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TContext>();

foreach (var dep in dbContext.DiscoverDependentCaches(scanOptions))
cache.AddDependentCache(dep.Type, dep.DependentType);

return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace CleverCache.EntityFrameworkCore.DependencyInjection;

public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers CleverCache services and the EF Core <see cref="CleverCacheInterceptor"/>.
/// This is the single call needed when using EF Core — there is no need to also call <c>AddCleverCache()</c>.
/// </summary>
public static IServiceCollection AddCleverCacheEntityFramework(this IServiceCollection services,
Action<CleverCacheOptions>? options = null)
{
services.AddCleverCache(options);
services.TryAddScoped<CleverCacheInterceptor>();
services.AddScoped<IInterceptor, CleverCacheInterceptor>();
return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace CleverCache.EntityFrameworkCore.Exceptions;

internal class MissingInterceptorException() :
Exception("CleverCache requires CleverCacheInterceptor to be registered. Call AddCleverCacheEntityFramework() on your IServiceCollection.");
32 changes: 32 additions & 0 deletions CleverCache.EntityFrameworkCore/Extensions/DbContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<IDbContextOptions>().Extensions
.OfType<CoreOptionsExtension>()
.Any(e => e.Interceptors != null
&& e.Interceptors.Any(i => i.GetType() == typeof(CleverCacheInterceptor)));

if (!isRegistered) throw new MissingInterceptorException();
}

public static List<DependentCache> DiscoverDependentCaches(this DbContext dbContext, CleverCacheScanOptions scanOptions)
{
if (scanOptions.NavigationScanMode == DependentCacheNavigationScanMode.None)
return [];

HashSet<DependentCache> dependentCaches = [];

foreach (var entityType in dbContext.Model.GetEntityTypes())
NavigationScanningHelper.Scan(scanOptions, entityType, dependentCaches);

return [.. dependentCaches];
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
namespace CleverCache.Extensions;
namespace CleverCache.EntityFrameworkCore.Extensions;

public static class DbOptionsBuilderExtensions
{
/// <summary>
/// Adds <see cref="CleverCacheInterceptor"/> to the <see cref="DbContextOptionsBuilder"/>.
/// Use this when you prefer constructor injection over the DI-based interceptor registration.
/// </summary>
public static DbContextOptionsBuilder AddCleverCache(this DbContextOptionsBuilder optionsBuilder,
IServiceProvider serviceProvider)
{
var interceptor = serviceProvider.GetRequiredService<CleverCacheInterceptor>();

optionsBuilder.AddInterceptors(interceptor);
return optionsBuilder;
}
}
}
11 changes: 11 additions & 0 deletions CleverCache.EntityFrameworkCore/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading