Skip to content

Commit 7ecc365

Browse files
committed
Enhances cache key generation and validation
Introduces `CacheKeyAttribute` for explicit cache key strings on enum members, making them refactor-safe. Adds URL encoding for optional keys and optional length validation for distributed cache keys to improve compatibility and performance. Includes new unit tests for these features.
1 parent be9ccd8 commit 7ecc365

6 files changed

Lines changed: 491 additions & 2 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace Neolution.Extensions.Caching.Abstractions
2+
{
3+
using System;
4+
5+
/// <summary>
6+
/// Specifies the explicit cache key string to use for an enum value.
7+
/// This makes cache keys refactor-safe by decoupling them from the enum member name.
8+
/// </summary>
9+
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
10+
public sealed class CacheKeyAttribute : Attribute
11+
{
12+
/// <summary>
13+
/// Initializes a new instance of the <see cref="CacheKeyAttribute"/> class.
14+
/// </summary>
15+
/// <param name="key">The explicit cache key string to use.</param>
16+
/// <exception cref="ArgumentNullException">Thrown when key is null.</exception>
17+
/// <exception cref="ArgumentException">Thrown when key is empty or whitespace.</exception>
18+
public CacheKeyAttribute(string key)
19+
{
20+
if (key == null)
21+
{
22+
throw new ArgumentNullException(nameof(key));
23+
}
24+
25+
if (string.IsNullOrWhiteSpace(key))
26+
{
27+
throw new ArgumentException("Cache key cannot be empty or whitespace.", nameof(key));
28+
}
29+
30+
this.Key = key;
31+
}
32+
33+
/// <summary>
34+
/// Gets the explicit cache key string.
35+
/// </summary>
36+
public string Key { get; }
37+
}
38+
}

Neolution.Extensions.Caching.Abstractions/DistributedCache.cs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace Neolution.Extensions.Caching.Abstractions
22
{
33
using System;
4+
using System.Text;
45
using System.Threading;
56
using System.Threading.Tasks;
67

@@ -11,6 +12,12 @@
1112
public abstract class DistributedCache<TCacheId> : IDistributedCache<TCacheId>
1213
where TCacheId : struct, Enum
1314
{
15+
/// <summary>
16+
/// Maximum allowed cache key length in bytes (UTF-8 encoded).
17+
/// This ensures performance and compatibility with limits of certain cache backends.
18+
/// </summary>
19+
private const int MaxCacheKeyBytes = 250;
20+
1421
/// <summary>
1522
/// Gets the name of the cache.
1623
/// </summary>
@@ -19,6 +26,20 @@ public abstract class DistributedCache<TCacheId> : IDistributedCache<TCacheId>
1926
/// </value>
2027
private static string CacheIdName => typeof(TCacheId).Name;
2128

29+
/// <summary>
30+
/// Gets a value indicating whether optional cache keys should be URL-encoded.
31+
/// Default is true for safe handling of special characters.
32+
/// Set to false to maintain backwards compatibility with existing cache keys.
33+
/// </summary>
34+
protected virtual bool EnableKeyEncoding => true;
35+
36+
/// <summary>
37+
/// Gets a value indicating whether cache key length should be validated.
38+
/// Default is true to ensure performance and compatibility with many cache backends.
39+
/// Set to false to disable length validation if your cache backend supports longer keys.
40+
/// </summary>
41+
protected virtual bool EnableKeyLengthValidation => true;
42+
2243
/// <summary>
2344
/// Gets the cache key version for invalidation purposes.
2445
/// If null, version is not included in the cache key.
@@ -212,6 +233,30 @@ protected abstract Task SetCacheObjectAsync<T>(string key, T value, CacheEntryOp
212233
/// <returns>The <see cref="Task"/>.</returns>
213234
protected abstract Task RemoveCacheObjectAsync(string key, CancellationToken token);
214235

236+
/// <summary>
237+
/// Gets the cache key string for an enum value.
238+
/// If the enum value has a <see cref="CacheKeyAttribute"/>, uses the explicit key.
239+
/// Otherwise, uses the enum member name (ToString()).
240+
/// </summary>
241+
/// <param name="id">The cache identifier enum value.</param>
242+
/// <returns>The cache key string to use.</returns>
243+
private static string GetCacheKeyString(TCacheId id)
244+
{
245+
var enumType = typeof(TCacheId);
246+
var memberName = id.ToString();
247+
var memberInfo = enumType.GetField(memberName);
248+
249+
if (memberInfo == null)
250+
{
251+
return memberName;
252+
}
253+
254+
// Check for CacheKeyAttribute
255+
var attribute = (CacheKeyAttribute?)Attribute.GetCustomAttribute(memberInfo, typeof(CacheKeyAttribute));
256+
257+
return attribute?.Key ?? memberName;
258+
}
259+
215260
/// <summary>
216261
/// Creates the full key to use for the underlying cache implementation.
217262
/// </summary>
@@ -220,10 +265,14 @@ protected abstract Task SetCacheObjectAsync<T>(string key, T value, CacheEntryOp
220265
/// <returns>The caching key.</returns>
221266
private string CreateCacheKey(TCacheId id, string? key = null)
222267
{
223-
var cacheKey = id.ToString();
268+
// Use attribute value if present, otherwise enum name
269+
var cacheKey = GetCacheKeyString(id);
270+
224271
if (!string.IsNullOrWhiteSpace(key))
225272
{
226-
cacheKey = $"{cacheKey}_{key}";
273+
// URL-encode the key if enabled
274+
var processedKey = this.EnableKeyEncoding ? Uri.EscapeDataString(key) : key;
275+
cacheKey = $"{cacheKey}_{processedKey}";
227276
}
228277

229278
var fullKey = CacheIdName;
@@ -242,6 +291,21 @@ private string CreateCacheKey(TCacheId id, string? key = null)
242291
fullKey = $"{this.EnvironmentPrefix}:{fullKey}";
243292
}
244293

294+
// Optional validation - check total key length if enabled
295+
if (this.EnableKeyLengthValidation)
296+
{
297+
var keyBytes = Encoding.UTF8.GetByteCount(fullKey);
298+
299+
if (keyBytes > MaxCacheKeyBytes)
300+
{
301+
throw new ArgumentException(
302+
$"Generated cache key exceeds maximum length of {MaxCacheKeyBytes} bytes. " +
303+
$"Current key is {keyBytes} bytes: '{fullKey}'. " +
304+
$"Consider using a shorter optional key or shorter enum names.",
305+
nameof(key));
306+
}
307+
}
308+
245309
return fullKey;
246310
}
247311
}

Neolution.Extensions.Caching.Abstractions/MemoryCache.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public void Remove(TCacheId id, string key)
102102
private static string CreateCacheKey(TCacheId container, string? key = null)
103103
{
104104
var containerName = container.ToString();
105+
105106
if (!string.IsNullOrWhiteSpace(key))
106107
{
107108
containerName = $"{containerName}_{key}";
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
namespace Neolution.Extensions.Caching.UnitTests
2+
{
3+
using System;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Neolution.Extensions.Caching.Abstractions;
6+
using Neolution.Extensions.Caching.UnitTests.Models;
7+
using Shouldly;
8+
using Xunit;
9+
10+
/// <summary>
11+
/// Tests for cache key improvements (explicit string values for in-memory cache)
12+
/// </summary>
13+
public class CacheKeyImprovementsTests
14+
{
15+
/// <summary>
16+
/// Tests that in-memory cache handles special characters without encoding
17+
/// </summary>
18+
[Fact]
19+
public void MemoryCacheHandlesSpecialCharactersWithoutEncoding()
20+
{
21+
// Arrange
22+
using var serviceProvider = CreateServiceCollection().BuildServiceProvider();
23+
var cache = GetCache(serviceProvider);
24+
var key = "user:123 test@example.com";
25+
const string value = "test-value";
26+
27+
// Act
28+
cache.Set(TestCacheId.Foobar, key, value);
29+
var result = cache.Get<string>(TestCacheId.Foobar, key);
30+
31+
// Assert
32+
result.ShouldBe(value);
33+
}
34+
35+
/// <summary>
36+
/// Tests that in-memory cache handles unicode characters
37+
/// </summary>
38+
[Fact]
39+
public void MemoryCacheHandlesUnicodeCharacters()
40+
{
41+
// Arrange
42+
using var serviceProvider = CreateServiceCollection().BuildServiceProvider();
43+
var cache = GetCache(serviceProvider);
44+
var key = "用户-123"; // Chinese characters
45+
const string value = "test-value";
46+
47+
// Act
48+
cache.Set(TestCacheId.Foobar, key, value);
49+
var result = cache.Get<string>(TestCacheId.Foobar, key);
50+
51+
// Assert
52+
result.ShouldBe(value);
53+
}
54+
55+
/// <summary>
56+
/// Tests that in-memory cache handles URL-unsafe characters
57+
/// </summary>
58+
[Fact]
59+
public void MemoryCacheHandlesUrlUnsafeCharacters()
60+
{
61+
// Arrange
62+
using var serviceProvider = CreateServiceCollection().BuildServiceProvider();
63+
var cache = GetCache(serviceProvider);
64+
var key = "key/with%special&chars?param=value";
65+
const string value = "test-value";
66+
67+
// Act
68+
cache.Set(TestCacheId.Foobar, key, value);
69+
var result = cache.Get<string>(TestCacheId.Foobar, key);
70+
71+
// Assert
72+
result.ShouldBe(value);
73+
}
74+
75+
/// <summary>
76+
/// Tests that in-memory cache accepts very long keys (no length restriction)
77+
/// </summary>
78+
[Fact]
79+
public void MemoryCacheAcceptsVeryLongKeys()
80+
{
81+
// Arrange
82+
using var serviceProvider = CreateServiceCollection().BuildServiceProvider();
83+
var cache = GetCache(serviceProvider);
84+
// In-memory cache has no length restriction
85+
var longKey = new string('x', 500);
86+
const string value = "test-value";
87+
88+
// Act & Assert - Should not throw
89+
Should.NotThrow(() =>
90+
{
91+
cache.Set(TestCacheId.Foobar, longKey, value);
92+
var result = cache.Get<string>(TestCacheId.Foobar, longKey);
93+
result.ShouldBe(value);
94+
});
95+
}
96+
97+
/// <summary>
98+
/// Gets the cache.
99+
/// </summary>
100+
/// <param name="serviceProvider">The service provider.</param>
101+
/// <returns>Get the cache service from the specified service provider.</returns>
102+
private static IMemoryCache<TestCacheId> GetCache(IServiceProvider serviceProvider)
103+
{
104+
return serviceProvider.GetRequiredService<IMemoryCache<TestCacheId>>();
105+
}
106+
107+
/// <summary>
108+
/// Creates the service collection needed for these tests.
109+
/// </summary>
110+
/// <returns>The service collection.</returns>
111+
private static ServiceCollection CreateServiceCollection()
112+
{
113+
var services = new ServiceCollection();
114+
services.AddInMemoryCache();
115+
return services;
116+
}
117+
}
118+
}

0 commit comments

Comments
 (0)