diff --git a/README.md b/README.md index 50745f6..cf6ee20 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,12 @@ public class MyService(IPersistedCache cache) var value = cache.GetOrSet("my-key", () => new RandomObject(), Expire.InMinutes(5)); } + // Query values from the cache using a pattern + public IEnumerable QuerySomething() + { + return cache.Query("my-*"); + } + // Check if a value exists in the cache public bool HasSomething() { @@ -164,26 +170,28 @@ The first cache registered will be the default cache, so you can use the `IPersi ### Methods -| Method | Description | -|--------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------| -| `Set(string key, T value, Expire expiry)` | Set a value in the cache with an expiry time | -| `SetAsync(string key, T value, Expire expiry, CancellationToken cancellationToken = default)` | Set a value in the cache with an expiry time asynchronously | -| `Get(string key)` | Get a value from the cache | -| `GetAsync(string key, CancellationToken cancellationToken = default)` | Get a value from the cache asynchronously | -| `GetOrSet(string key, Func valueFactory, Expire expiry)` | Get a value from the cache or set it if it doesn't exist | -| `GetOrSetAsync(string key, Func valueFactory, Expire expiry, CancellationToken cancellationToken = default)` | Get a value from the cache or set it if it doesn't exist asynchronously | -| `GetOrSetAsync(string key, Func> valueFactory, Expire expiry, CancellationToken cancellationToken = default)` | Get a value from the cache or set it if it doesn't exist asynchronously | -| `Has(string key)` | Check if a value exists in the cache | -| `HasAsync(string key, CancellationToken cancellationToken = default)` | Check if a value exists in the cache asynchronously | -| `Forget(string key)` | Forget a value from the cache | -| `ForgetAsync(string key, CancellationToken cancellationToken = default)` | Forget a value from the cache asynchronously | -| `Pull(string key)` | Get a value from the cache and remove it | -| `PullAsync(string key, CancellationToken cancellationToken = default)` | Get a value from the cache and remove it asynchronously | -| `Flush()` | Flush all values from the cache | -| `FlushAsync(CancellationToken cancellationToken = default)` | Flush all values from the cache asynchronously | -| `Flush(string pattern)` | Flush values from the cache by pattern | -| `FlushAsync(string pattern, CancellationToken cancellationToken = default)` | Flush values from the cache by pattern asynchronously | -| `Purge()` | Purge the cache of expired entries | +| Method | Description | +|--------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------| +| `Set(string key, T value, Expire expiry)` | Set a value in the cache with an expiry time | +| `SetAsync(string key, T value, Expire expiry, CancellationToken cancellationToken = default)` | Set a value in the cache with an expiry time asynchronously | +| `Get(string key)` | Get a value from the cache | +| `GetAsync(string key, CancellationToken cancellationToken = default)` | Get a value from the cache asynchronously | +| `GetOrSet(string key, Func valueFactory, Expire expiry)` | Get a value from the cache or set it if it doesn't exist | +| `GetOrSetAsync(string key, Func valueFactory, Expire expiry, CancellationToken cancellationToken = default)` | Get a value from the cache or set it if it doesn't exist asynchronously | +| `GetOrSetAsync(string key, Func> valueFactory, Expire expiry, CancellationToken cancellationToken = default)` | Get a value from the cache or set it if it doesn't exist asynchronously | +| `Query(string pattern)` | Query values from the cache by pattern (* and ? wildcards supported) | +| `QueryAsync(string pattern, CancellationToken cancellationToken = default)` | Query values from the cache by pattern asynchronously (* and ? wildcards supported) | +| `Has(string key)` | Check if a value exists in the cache | +| `HasAsync(string key, CancellationToken cancellationToken = default)` | Check if a value exists in the cache asynchronously | +| `Forget(string key)` | Forget a value from the cache | +| `ForgetAsync(string key, CancellationToken cancellationToken = default)` | Forget a value from the cache asynchronously | +| `Pull(string key)` | Get a value from the cache and remove it | +| `PullAsync(string key, CancellationToken cancellationToken = default)` | Get a value from the cache and remove it asynchronously | +| `Flush()` | Flush all values from the cache | +| `FlushAsync(CancellationToken cancellationToken = default)` | Flush all values from the cache asynchronously | +| `Flush(string pattern)` | Flush values from the cache by pattern | +| `FlushAsync(string pattern, CancellationToken cancellationToken = default)` | Flush values from the cache by pattern asynchronously | +| `Purge()` | Purge the cache of expired entries | ### Want to contribute? diff --git a/src/PersistedCache.FileSystem/FileSystemPersistedCache.cs b/src/PersistedCache.FileSystem/FileSystemPersistedCache.cs index 400c82e..b79ec72 100644 --- a/src/PersistedCache.FileSystem/FileSystemPersistedCache.cs +++ b/src/PersistedCache.FileSystem/FileSystemPersistedCache.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using PersistedCache.Helpers; namespace PersistedCache; @@ -161,9 +162,9 @@ public IEnumerable Query(string pattern) foreach (var file in directory.EnumerateFiles($"{pattern}.json")) { - var cacheEntry = ReadFromFile(file.FullName); + var cacheEntry = ReadFromFile(file.FullName); - if (cacheEntry == null || cacheEntry.IsExpired) + if (cacheEntry == null || cacheEntry.IsExpired || cacheEntry.Value == null) { continue; } diff --git a/src/PersistedCache.MongoDb/MongoDbPersistedCache.cs b/src/PersistedCache.MongoDb/MongoDbPersistedCache.cs index 7f15c4c..a09ec67 100644 --- a/src/PersistedCache.MongoDb/MongoDbPersistedCache.cs +++ b/src/PersistedCache.MongoDb/MongoDbPersistedCache.cs @@ -1,6 +1,7 @@ using System.Text.Json; using MongoDB.Bson.Serialization; using MongoDB.Driver; +using PersistedCache.Helpers; namespace PersistedCache; @@ -156,13 +157,26 @@ public IEnumerable Query(string pattern) Validators.ValidatePattern(pattern); var builder = Builders.Filter; - var filter = builder.Regex(entry => entry.Key, pattern) & - builder.Gt(entry => entry.Expiry, DateTimeOffset.UtcNow); + var filter = builder.Gt(entry => entry.Expiry, DateTimeOffset.UtcNow); + + if (pattern != "*") + { + filter &= builder.Regex(entry => entry.Key, pattern); + } var entries = Collection.Find(filter).ToList(); - return entries.Where(entry => !string.IsNullOrEmpty(entry.Value)).Select(entry => - JsonSerializer.Deserialize(entry.Value, _options.JsonOptions)!); + var deserialized = new List(); + + foreach (var entry in entries) + { + if (JsonHelper.TryDeserialize(entry.Value, out var value, _options.JsonOptions)) + { + deserialized.Add(value!); + } + } + + return deserialized; } /// @@ -171,13 +185,26 @@ public async Task> QueryAsync(string pattern, CancellationToke Validators.ValidatePattern(pattern); var builder = Builders.Filter; - var filter = builder.Regex(entry => entry.Key, pattern) & - builder.Gt(entry => entry.Expiry, DateTimeOffset.UtcNow); + var filter = builder.Gt(entry => entry.Expiry, DateTimeOffset.UtcNow); + + if (pattern != "*") + { + filter &= builder.Regex(entry => entry.Key, pattern); + } var entries = await Collection.Find(filter).ToListAsync(cancellationToken); - return entries.Where(entry => !string.IsNullOrEmpty(entry.Value)).Select(entry => - JsonSerializer.Deserialize(entry.Value, _options.JsonOptions)!); + var deserialized = new List(); + + foreach (var entry in entries) + { + if (JsonHelper.TryDeserialize(entry.Value, out var value, _options.JsonOptions)) + { + deserialized.Add(value!); + } + } + + return deserialized; } /// diff --git a/src/PersistedCache/Helpers/JsonHelper.cs b/src/PersistedCache/Helpers/JsonHelper.cs new file mode 100644 index 0000000..cec1adb --- /dev/null +++ b/src/PersistedCache/Helpers/JsonHelper.cs @@ -0,0 +1,26 @@ +using System.Text.Json; + +namespace PersistedCache.Helpers; + +public class JsonHelper +{ + public static bool TryDeserialize(string json, out T? result, JsonSerializerOptions? options = null) + { + try + { + if (string.IsNullOrEmpty(json)) + { + result = default; + return false; + } + + result = JsonSerializer.Deserialize(json, options); + return result != null; + } + catch + { + result = default; + return false; + } + } +} \ No newline at end of file diff --git a/src/PersistedCache/Validators.cs b/src/PersistedCache/Helpers/Validators.cs similarity index 96% rename from src/PersistedCache/Validators.cs rename to src/PersistedCache/Helpers/Validators.cs index 07cef9b..5573d23 100644 --- a/src/PersistedCache/Validators.cs +++ b/src/PersistedCache/Helpers/Validators.cs @@ -1,6 +1,4 @@ -using System.Text.RegularExpressions; - -namespace PersistedCache; +namespace PersistedCache.Helpers; internal class PatternValidatorOptions { diff --git a/src/PersistedCache/Sql/SqlPersistedCache.cs b/src/PersistedCache/Sql/SqlPersistedCache.cs index 27818d1..fc58c5e 100644 --- a/src/PersistedCache/Sql/SqlPersistedCache.cs +++ b/src/PersistedCache/Sql/SqlPersistedCache.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Dapper; +using PersistedCache.Helpers; namespace PersistedCache.Sql; @@ -271,18 +272,23 @@ public IEnumerable Query(string pattern) var values = connection.Query( new CommandDefinition( commandText: _driver.QueryScript, - parameters: new { Pattern = pattern }, + parameters: new { Pattern = pattern, Expiry = DateTimeOffset.UtcNow }, transaction: transaction ) ); - return values.Select(value => JsonSerializer.Deserialize(value, _options.JsonOptions)); - }); + var deserialized = new List(); + + foreach (var value in values) + { + if (JsonHelper.TryDeserialize(value, out var result, _options.JsonOptions)) + { + deserialized.Add(result!); + } + } - if (result == default) - { - return []; - } + return deserialized; + }); return result!; } @@ -298,20 +304,25 @@ public async Task> QueryAsync(string pattern, CancellationToke var values = await connection.QueryAsync( new CommandDefinition( commandText: _driver.QueryScript, - parameters: new { Pattern = pattern }, + parameters: new { Pattern = pattern, Expiry = DateTimeOffset.UtcNow }, transaction: transaction, cancellationToken: cancellationToken ) ); + + var deserialized = new List(); + + foreach (var value in values) + { + if (JsonHelper.TryDeserialize(value, out var result, _options.JsonOptions)) + { + deserialized.Add(result!); + } + } - return values.Select(value => JsonSerializer.Deserialize(value, _options.JsonOptions)); + return deserialized; }, cancellationToken); - if (result == default) - { - return []; - } - return result!; } diff --git a/tests/PersistedCache.Tests/Helpers/CompletelyDifferentObject.cs b/tests/PersistedCache.Tests/Helpers/CompletelyDifferentObject.cs new file mode 100644 index 0000000..72f43d2 --- /dev/null +++ b/tests/PersistedCache.Tests/Helpers/CompletelyDifferentObject.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace PersistedCache.Tests.Helpers; + +public class CompletelyDifferentObject +{ + public List TestValue { get; set; } = new List(); + public int TestNumber { get; set; } +} \ No newline at end of file diff --git a/tests/PersistedCache.Tests/QueryTests.cs b/tests/PersistedCache.Tests/QueryTests.cs new file mode 100644 index 0000000..2886727 --- /dev/null +++ b/tests/PersistedCache.Tests/QueryTests.cs @@ -0,0 +1,130 @@ +using System.Threading.Tasks; +using AutoFixture; +using FluentAssertions; +using PersistedCache.Tests.Common; +using PersistedCache.Tests.Fixtures; +using PersistedCache.Tests.Helpers; +using Xunit; + +namespace PersistedCache.Tests; + +public abstract class QueryTests : BaseTest +{ + private readonly IPersistedCache _cache; + private readonly Fixture _fixture = new Fixture(); + + public QueryTests(IPersistedCache cache) : base(cache) + { + _cache = cache; + } + + [Theory] + [InlineData("key*")] + [InlineData("key?")] + public void Query_WithValidPattern_ReturnsExpectedValues(string pattern) + { + // Arrange + _cache.Set("key1", _fixture.Create(), Expire.Never); + _cache.Set("key2", _fixture.Create(), Expire.Never); + _cache.Set("key3", _fixture.Create(), Expire.Never); + + // Act + var result = _cache.Query(pattern); + + // Assert + result.Should().HaveCount(3); + } + + [Fact] + public void Query_WithNoMatchingPattern_ReturnsEmptyCollection() + { + // Arrange + _cache.Set("key1", _fixture.Create(), Expire.Never); + _cache.Set("key2", _fixture.Create(), Expire.Never); + + // Act + var result = _cache.Query("keyyyy*"); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void Query_WithAstrixPattern_ReturnsAllValues() + { + // Arrange + _cache.Set("key1", _fixture.Create(), Expire.Never); + _cache.Set("key2", _fixture.Create(), Expire.Never); + _cache.Set("key3", _fixture.Create(), Expire.Never); + + // Act + var result = _cache.Query("*"); + + // Assert + result.Should().HaveCount(3); + } + + [Fact] + public async Task Query_WhenExpiredValues_ReturnsOnlyNonExpiredValues() + { + // Arrange + await _cache.SetAsync("key1", _fixture.Create(), Expire.InMilliseconds(1)); + await _cache.SetAsync("key2", _fixture.Create(), Expire.Never); + await _cache.SetAsync("key3", _fixture.Create(), Expire.InMilliseconds(1)); + + // Act + await Task.Delay(2); + var result = _cache.QueryAsync("*"); + + // Assert + (await result).Should().HaveCount(1); + } +} + +[Collection(nameof(MySqlFixture))] +public class MySqlQueryTestsExecutor : QueryTests +{ + public MySqlQueryTestsExecutor(MySqlFixture fixture) : base(fixture.PersistedCache) + { + } +} + +[Collection(nameof(PostgreSqlFixture))] +public class PostgreSqlQueryTestsExecutor : QueryTests +{ + public PostgreSqlQueryTestsExecutor(PostgreSqlFixture fixture) : base(fixture.PersistedCache) + { + } +} + +// [Collection(nameof(SqlServerFixture))] +// public class SqlServerQueryTestsExecutor : QueryTests +// { +// public SqlServerQueryTestsExecutor(SqlServerFixture fixture) : base(fixture.PersistedCache) +// { +// } +// } + +[Collection(nameof(FileSystemFixture))] +public class FileSystemQueryTestsExecutor : QueryTests +{ + public FileSystemQueryTestsExecutor(FileSystemFixture fixture) : base(fixture.PersistedCache) + { + } +} + +[Collection(nameof(SqliteFixture))] +public class SqliteQueryTestsExecutor : QueryTests +{ + public SqliteQueryTestsExecutor(SqliteFixture fixture) : base(fixture.PersistedCache) + { + } +} + +[Collection(nameof(MongoDbFixture))] +public class MongoDbQueryTestsExecutor : QueryTests +{ + public MongoDbQueryTestsExecutor(MongoDbFixture fixture) : base(fixture.PersistedCache) + { + } +} \ No newline at end of file