Skip to content

Commit

Permalink
feat(query): Support querying cache entries using patterns with * and…
Browse files Browse the repository at this point in the history
… ? as wildcards

BREAKING CHANGE: MongoDB no longer supports regex queries for Flush that does not contian a wildcard pattern (? or *)
  • Loading branch information
mnbuhl committed Sep 21, 2024
1 parent b1a44ab commit f83ea1d
Show file tree
Hide file tree
Showing 8 changed files with 257 additions and 47 deletions.
48 changes: 28 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<RandomObject> QuerySomething()
{
return cache.Query<RandomObject>("my-*");
}

// Check if a value exists in the cache
public bool HasSomething()
{
Expand Down Expand Up @@ -164,26 +170,28 @@ The first cache registered will be the default cache, so you can use the `IPersi

### Methods

| Method | Description |
|--------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| `Set<T>(string key, T value, Expire expiry)` | Set a value in the cache with an expiry time |
| `SetAsync<T>(string key, T value, Expire expiry, CancellationToken cancellationToken = default)` | Set a value in the cache with an expiry time asynchronously |
| `Get<T>(string key)` | Get a value from the cache |
| `GetAsync<T>(string key, CancellationToken cancellationToken = default)` | Get a value from the cache asynchronously |
| `GetOrSet<T>(string key, Func<T> valueFactory, Expire expiry)` | Get a value from the cache or set it if it doesn't exist |
| `GetOrSetAsync<T>(string key, Func<T> valueFactory, Expire expiry, CancellationToken cancellationToken = default)` | Get a value from the cache or set it if it doesn't exist asynchronously |
| `GetOrSetAsync<T>(string key, Func<Task<T>> 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<T>(string key)` | Get a value from the cache and remove it |
| `PullAsync<T>(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<T>(string key, T value, Expire expiry)` | Set a value in the cache with an expiry time |
| `SetAsync<T>(string key, T value, Expire expiry, CancellationToken cancellationToken = default)` | Set a value in the cache with an expiry time asynchronously |
| `Get<T>(string key)` | Get a value from the cache |
| `GetAsync<T>(string key, CancellationToken cancellationToken = default)` | Get a value from the cache asynchronously |
| `GetOrSet<T>(string key, Func<T> valueFactory, Expire expiry)` | Get a value from the cache or set it if it doesn't exist |
| `GetOrSetAsync<T>(string key, Func<T> valueFactory, Expire expiry, CancellationToken cancellationToken = default)` | Get a value from the cache or set it if it doesn't exist asynchronously |
| `GetOrSetAsync<T>(string key, Func<Task<T>> valueFactory, Expire expiry, CancellationToken cancellationToken = default)` | Get a value from the cache or set it if it doesn't exist asynchronously |
| `Query<T>(string pattern)` | Query values from the cache by pattern (* and ? wildcards supported) |
| `QueryAsync<T>(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<T>(string key)` | Get a value from the cache and remove it |
| `PullAsync<T>(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?
Expand Down
5 changes: 3 additions & 2 deletions src/PersistedCache.FileSystem/FileSystemPersistedCache.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using PersistedCache.Helpers;

namespace PersistedCache;

Expand Down Expand Up @@ -161,9 +162,9 @@ public IEnumerable<T> Query<T>(string pattern)

foreach (var file in directory.EnumerateFiles($"{pattern}.json"))
{
var cacheEntry = ReadFromFile<T>(file.FullName);
var cacheEntry = ReadFromFile<T?>(file.FullName);

if (cacheEntry == null || cacheEntry.IsExpired)
if (cacheEntry == null || cacheEntry.IsExpired || cacheEntry.Value == null)
{
continue;
}
Expand Down
43 changes: 35 additions & 8 deletions src/PersistedCache.MongoDb/MongoDbPersistedCache.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using PersistedCache.Helpers;

namespace PersistedCache;

Expand Down Expand Up @@ -156,13 +157,26 @@ public IEnumerable<T> Query<T>(string pattern)
Validators.ValidatePattern(pattern);

var builder = Builders<PersistedCacheEntry>.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<T>(entry.Value, _options.JsonOptions)!);
var deserialized = new List<T>();

foreach (var entry in entries)
{
if (JsonHelper.TryDeserialize<T>(entry.Value, out var value, _options.JsonOptions))
{
deserialized.Add(value!);
}
}

return deserialized;
}

/// <inheritdoc />
Expand All @@ -171,13 +185,26 @@ public async Task<IEnumerable<T>> QueryAsync<T>(string pattern, CancellationToke
Validators.ValidatePattern(pattern);

var builder = Builders<PersistedCacheEntry>.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<T>(entry.Value, _options.JsonOptions)!);
var deserialized = new List<T>();

foreach (var entry in entries)
{
if (JsonHelper.TryDeserialize<T?>(entry.Value, out var value, _options.JsonOptions))
{
deserialized.Add(value!);
}
}

return deserialized;
}

/// <inheritdoc />
Expand Down
26 changes: 26 additions & 0 deletions src/PersistedCache/Helpers/JsonHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Text.Json;

namespace PersistedCache.Helpers;

public class JsonHelper
{
public static bool TryDeserialize<T>(string json, out T? result, JsonSerializerOptions? options = null)
{
try
{
if (string.IsNullOrEmpty(json))
{
result = default;
return false;
}

result = JsonSerializer.Deserialize<T?>(json, options);
return result != null;
}
catch
{
result = default;
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Text.RegularExpressions;

namespace PersistedCache;
namespace PersistedCache.Helpers;

internal class PatternValidatorOptions
{
Expand Down
39 changes: 25 additions & 14 deletions src/PersistedCache/Sql/SqlPersistedCache.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using Dapper;
using PersistedCache.Helpers;

namespace PersistedCache.Sql;

Expand Down Expand Up @@ -271,18 +272,23 @@ public IEnumerable<T> Query<T>(string pattern)
var values = connection.Query<string>(
new CommandDefinition(
commandText: _driver.QueryScript,
parameters: new { Pattern = pattern },
parameters: new { Pattern = pattern, Expiry = DateTimeOffset.UtcNow },
transaction: transaction
)
);
return values.Select(value => JsonSerializer.Deserialize<T>(value, _options.JsonOptions));
});
var deserialized = new List<T>();
foreach (var value in values)
{
if (JsonHelper.TryDeserialize<T?>(value, out var result, _options.JsonOptions))
{
deserialized.Add(result!);
}
}
if (result == default)
{
return [];
}
return deserialized;
});

return result!;
}
Expand All @@ -298,20 +304,25 @@ public async Task<IEnumerable<T>> QueryAsync<T>(string pattern, CancellationToke
var values = await connection.QueryAsync<string>(
new CommandDefinition(
commandText: _driver.QueryScript,
parameters: new { Pattern = pattern },
parameters: new { Pattern = pattern, Expiry = DateTimeOffset.UtcNow },
transaction: transaction,
cancellationToken: cancellationToken
)
);
var deserialized = new List<T>();
foreach (var value in values)
{
if (JsonHelper.TryDeserialize<T>(value, out var result, _options.JsonOptions))
{
deserialized.Add(result!);
}
}
return values.Select(value => JsonSerializer.Deserialize<T>(value, _options.JsonOptions));
return deserialized;
}, cancellationToken);

if (result == default)
{
return [];
}

return result!;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Collections.Generic;

namespace PersistedCache.Tests.Helpers;

public class CompletelyDifferentObject
{
public List<string> TestValue { get; set; } = new List<string>();
public int TestNumber { get; set; }
}
130 changes: 130 additions & 0 deletions tests/PersistedCache.Tests/QueryTests.cs
Original file line number Diff line number Diff line change
@@ -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<RandomObject>(), Expire.Never);
_cache.Set("key2", _fixture.Create<RandomObject>(), Expire.Never);
_cache.Set("key3", _fixture.Create<RandomObject>(), Expire.Never);

// Act
var result = _cache.Query<RandomObject>(pattern);

// Assert
result.Should().HaveCount(3);
}

[Fact]
public void Query_WithNoMatchingPattern_ReturnsEmptyCollection()
{
// Arrange
_cache.Set("key1", _fixture.Create<RandomObject>(), Expire.Never);
_cache.Set("key2", _fixture.Create<RandomObject>(), Expire.Never);

// Act
var result = _cache.Query<RandomObject>("keyyyy*");

// Assert
result.Should().BeEmpty();
}

[Fact]
public void Query_WithAstrixPattern_ReturnsAllValues()
{
// Arrange
_cache.Set("key1", _fixture.Create<RandomObject>(), Expire.Never);
_cache.Set("key2", _fixture.Create<RandomObject>(), Expire.Never);
_cache.Set("key3", _fixture.Create<RandomObject>(), Expire.Never);

// Act
var result = _cache.Query<RandomObject>("*");

// Assert
result.Should().HaveCount(3);
}

[Fact]
public async Task Query_WhenExpiredValues_ReturnsOnlyNonExpiredValues()
{
// Arrange
await _cache.SetAsync("key1", _fixture.Create<RandomObject>(), Expire.InMilliseconds(1));
await _cache.SetAsync("key2", _fixture.Create<RandomObject>(), Expire.Never);
await _cache.SetAsync("key3", _fixture.Create<RandomObject>(), Expire.InMilliseconds(1));

// Act
await Task.Delay(2);
var result = _cache.QueryAsync<RandomObject>("*");

// 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)
{
}
}

0 comments on commit f83ea1d

Please sign in to comment.