Skip to content

Commit 5436ebb

Browse files
authored
Merge pull request #33 from linkdotnet/feature/cached-repository
Feature/cached repository
2 parents 5a4bd16 + a5fef5d commit 5436ebb

File tree

7 files changed

+251
-9
lines changed

7 files changed

+251
-9
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System;
2+
using System.Linq.Expressions;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using LinkDotNet.Blog.Domain;
6+
using Microsoft.Extensions.Caching.Memory;
7+
using Microsoft.Extensions.Primitives;
8+
using X.PagedList;
9+
10+
namespace LinkDotNet.Blog.Infrastructure.Persistence
11+
{
12+
public class CachedRepository<T> : IRepository<T>
13+
where T : Entity
14+
{
15+
private static CancellationTokenSource resetToken = new();
16+
17+
private readonly IRepository<T> repository;
18+
19+
private readonly IMemoryCache memoryCache;
20+
21+
public CachedRepository(IRepository<T> repository, IMemoryCache memoryCache)
22+
{
23+
this.repository = repository;
24+
this.memoryCache = memoryCache;
25+
}
26+
27+
private static MemoryCacheEntryOptions Options => new()
28+
{
29+
ExpirationTokens = { new CancellationChangeToken(resetToken.Token) },
30+
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7),
31+
};
32+
33+
public async Task<T> GetByIdAsync(string id)
34+
{
35+
if (!memoryCache.TryGetValue(id, out T value))
36+
{
37+
value = await repository.GetByIdAsync(id);
38+
memoryCache.Set(id, value, Options);
39+
}
40+
41+
return value;
42+
}
43+
44+
public async Task<IPagedList<T>> GetAllAsync(
45+
Expression<Func<T, bool>> filter = null,
46+
Expression<Func<T, object>> orderBy = null,
47+
bool descending = true,
48+
int page = 1,
49+
int pageSize = int.MaxValue)
50+
{
51+
var key = $"{filter?.Body}-{orderBy?.Body}-{descending}-{page}-{pageSize}";
52+
return await memoryCache.GetOrCreate(key, async e =>
53+
{
54+
e.SetOptions(Options);
55+
return await repository.GetAllAsync(filter, orderBy, descending, page, pageSize);
56+
});
57+
}
58+
59+
public async Task StoreAsync(T entity)
60+
{
61+
ResetCache();
62+
memoryCache.Set(entity.Id, entity, Options);
63+
await repository.StoreAsync(entity);
64+
}
65+
66+
public async Task DeleteAsync(string id)
67+
{
68+
ResetCache();
69+
memoryCache.Remove(id);
70+
await repository.DeleteAsync(id);
71+
}
72+
73+
private static void ResetCache()
74+
{
75+
if (resetToken is { IsCancellationRequested: false, Token: { CanBeCanceled: true } })
76+
{
77+
resetToken.Cancel();
78+
resetToken.Dispose();
79+
}
80+
81+
resetToken = new CancellationTokenSource();
82+
}
83+
}
84+
}

LinkDotNet.Blog.Infrastructure/Persistence/Sql/Repository.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,4 @@ public async Task DeleteAsync(string id)
7474
}
7575
}
7676
}
77-
}
77+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using System;
2+
using System.Linq.Expressions;
3+
using System.Threading.Tasks;
4+
using FluentAssertions;
5+
using LinkDotNet.Blog.Domain;
6+
using LinkDotNet.Blog.Infrastructure.Persistence;
7+
using LinkDotNet.Blog.TestUtilities;
8+
using Microsoft.Extensions.Caching.Memory;
9+
using Moq;
10+
using X.PagedList;
11+
using Xunit;
12+
13+
namespace LinkDotNet.Blog.UnitTests.Infrastructure.Persistence
14+
{
15+
public class CachedRepositoryTests
16+
{
17+
private readonly Mock<IRepository<BlogPost>> repositoryMock;
18+
private readonly CachedRepository<BlogPost> sut;
19+
20+
public CachedRepositoryTests()
21+
{
22+
repositoryMock = new Mock<IRepository<BlogPost>>();
23+
sut = new CachedRepository<BlogPost>(repositoryMock.Object, new MemoryCache(new MemoryCacheOptions()));
24+
}
25+
26+
[Fact]
27+
public async Task ShouldGetFromCacheWhenLoaded()
28+
{
29+
var blogPost = new BlogPostBuilder().Build();
30+
repositoryMock.Setup(r => r.GetByIdAsync("id")).ReturnsAsync(blogPost);
31+
var firstCall = await sut.GetByIdAsync("id");
32+
33+
var secondCall = await sut.GetByIdAsync("id");
34+
35+
firstCall.Should().Be(secondCall);
36+
firstCall.Should().Be(blogPost);
37+
repositoryMock.Verify(r => r.GetByIdAsync("id"), Times.Once);
38+
}
39+
40+
[Fact]
41+
public async Task ShouldGetAllFromCacheWhenLoaded()
42+
{
43+
var blogPost = new BlogPostBuilder().Build();
44+
repositoryMock.Setup(r => r.GetAllAsync(
45+
It.IsAny<Expression<Func<BlogPost, bool>>>(),
46+
It.IsAny<Expression<Func<BlogPost, object>>>(),
47+
It.IsAny<bool>(),
48+
It.IsAny<int>(),
49+
It.IsAny<int>()))
50+
.ReturnsAsync(new PagedList<BlogPost>(new[] { blogPost }, 1, 1));
51+
var firstCall = await sut.GetAllAsync();
52+
53+
var secondCall = await sut.GetAllAsync();
54+
55+
firstCall.Count.Should().Be(1);
56+
secondCall.Count.Should().Be(1);
57+
repositoryMock.Verify(
58+
r => r.GetAllAsync(
59+
It.IsAny<Expression<Func<BlogPost, bool>>>(),
60+
It.IsAny<Expression<Func<BlogPost, object>>>(),
61+
It.IsAny<bool>(),
62+
It.IsAny<int>(),
63+
It.IsAny<int>()),
64+
Times.Once);
65+
}
66+
67+
[Fact]
68+
public async Task ShouldNotCacheWhenParameterDifferent()
69+
{
70+
SetupRepository();
71+
await sut.GetAllAsync();
72+
await sut.GetAllAsync(p => p.IsPublished);
73+
await sut.GetAllAsync(p => p.IsPublished, p => p.Likes);
74+
await sut.GetAllAsync(
75+
p => p.IsPublished,
76+
p => p.Likes,
77+
false);
78+
await sut.GetAllAsync(
79+
p => p.IsPublished,
80+
p => p.Likes,
81+
false,
82+
2);
83+
await sut.GetAllAsync(
84+
p => p.IsPublished,
85+
p => p.Likes,
86+
false,
87+
2,
88+
30);
89+
90+
repositoryMock.Verify(
91+
r => r.GetAllAsync(
92+
It.IsAny<Expression<Func<BlogPost, bool>>>(),
93+
It.IsAny<Expression<Func<BlogPost, object>>>(),
94+
It.IsAny<bool>(),
95+
It.IsAny<int>(),
96+
It.IsAny<int>()),
97+
Times.Exactly(6));
98+
}
99+
100+
[Fact]
101+
public async Task ShouldUpdateCacheOnStore()
102+
{
103+
var blogPost = new BlogPostBuilder().Build();
104+
blogPost.Id = "id";
105+
repositoryMock.Setup(r => r.GetByIdAsync("id")).ReturnsAsync(blogPost);
106+
await sut.GetByIdAsync("id");
107+
var update = new BlogPostBuilder().WithTitle("new").Build();
108+
blogPost.Update(update);
109+
await sut.StoreAsync(blogPost);
110+
111+
var latest = await sut.GetByIdAsync("id");
112+
113+
latest.Title.Should().Be("new");
114+
}
115+
116+
[Fact]
117+
public async Task ShouldDelete()
118+
{
119+
await sut.DeleteAsync("id");
120+
121+
repositoryMock.Verify(r => r.DeleteAsync("id"), Times.Once);
122+
}
123+
124+
[Fact]
125+
public async Task ShouldGetFreshDataAfterDelete()
126+
{
127+
SetupRepository();
128+
await sut.GetAllAsync();
129+
await sut.DeleteAsync("some_id");
130+
131+
await sut.GetAllAsync();
132+
133+
repositoryMock.Verify(
134+
r => r.GetAllAsync(
135+
It.IsAny<Expression<Func<BlogPost,bool>>>(),
136+
It.IsAny<Expression<Func<BlogPost,object>>>(),
137+
It.IsAny<bool>(),
138+
It.IsAny<int>(),
139+
It.IsAny<int>()),
140+
Times.Exactly(2));
141+
}
142+
143+
private void SetupRepository()
144+
{
145+
var blogPost = new BlogPostBuilder().Build();
146+
repositoryMock.Setup(r => r.GetAllAsync(
147+
It.IsAny<Expression<Func<BlogPost, bool>>>(),
148+
It.IsAny<Expression<Func<BlogPost, object>>>(),
149+
It.IsAny<bool>(),
150+
It.IsAny<int>(),
151+
It.IsAny<int>()))
152+
.ReturnsAsync(new PagedList<BlogPost>(new[] { blogPost }, 1, 1));
153+
}
154+
}
155+
}

LinkDotNet.Blog.UnitTests/LinkDotNet.Blog.UnitTests.csproj

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,6 @@
4444
<AdditionalFiles Include="..\stylecop.json" Link="stylecop.json" />
4545
</ItemGroup>
4646

47-
<ItemGroup>
48-
<Folder Include="Infrastructure\Persistence" />
49-
</ItemGroup>
50-
5147
<ItemGroup>
5248
<Compile Remove="Web\Shared\Services\LocalStorageServiceTests.cs" />
5349
</ItemGroup>

LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0-rc.1.21452.15" />
1212
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.0-rc.1.21452.10" />
1313
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.0-rc.1.21452.10" />
14+
<PackageReference Include="Scrutor.AspNetCore" Version="3.3.0" />
1415
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.29.0.36737">
1516
<PrivateAssets>all</PrivateAssets>
1617
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using LinkDotNet.Blog.Infrastructure.Persistence;
1+
using LinkDotNet.Blog.Domain;
2+
using LinkDotNet.Blog.Infrastructure.Persistence;
3+
using LinkDotNet.Blog.Infrastructure.Persistence.Sql;
24
using Microsoft.Extensions.Configuration;
35
using Microsoft.Extensions.DependencyInjection;
46

@@ -26,6 +28,10 @@ public static void AddStorageProvider(this IServiceCollection services, IConfigu
2628
{
2729
services.UseSqlAsStorageProvider();
2830
}
31+
32+
services.AddScoped<IRepository<BlogPost>, Repository<BlogPost>>();
33+
services.AddMemoryCache();
34+
services.Decorate(typeof(IRepository<>), typeof(CachedRepository<>));
2935
}
3036
}
3137
}

LinkDotNet.Blog.sln

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinkDotNet.Blog.Infrastruct
1111
EndProject
1212
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinkDotNet.Blog.UnitTests", "LinkDotNet.Blog.UnitTests\LinkDotNet.Blog.UnitTests.csproj", "{5B868911-7C93-4190-AEE4-3A6694F2FFCE}"
1313
EndProject
14-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LinkDotNet.Blog.IntegrationTests", "LinkDotNet.Blog.IntegrationTests\LinkDotNet.Blog.IntegrationTests.csproj", "{DEFDA17A-9586-4E50-83FB-8F75AC29D39A}"
14+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinkDotNet.Blog.IntegrationTests", "LinkDotNet.Blog.IntegrationTests\LinkDotNet.Blog.IntegrationTests.csproj", "{DEFDA17A-9586-4E50-83FB-8F75AC29D39A}"
1515
EndProject
16-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LinkDotNet.Blog.TestUtilities", "LinkDotNet.Blog.TestUtilities\LinkDotNet.Blog.TestUtilities.csproj", "{310ABEE1-C131-43E6-A759-F2DB75A483DD}"
16+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinkDotNet.Blog.TestUtilities", "LinkDotNet.Blog.TestUtilities\LinkDotNet.Blog.TestUtilities.csproj", "{310ABEE1-C131-43E6-A759-F2DB75A483DD}"
1717
EndProject
1818
Global
1919
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -52,4 +52,4 @@ Global
5252
GlobalSection(ExtensibilityGlobals) = postSolution
5353
SolutionGuid = {FB9B0642-F1F0-4BD8-9EDD-15C95F082180}
5454
EndGlobalSection
55-
EndGlobal
55+
EndGlobal

0 commit comments

Comments
 (0)