diff --git a/src/QueryCache.Core/PublicAPI/PublicAPI.Shipped.txt b/src/QueryCache.Core/PublicAPI/PublicAPI.Shipped.txt index 7dc5c58..5c6173d 100644 --- a/src/QueryCache.Core/PublicAPI/PublicAPI.Shipped.txt +++ b/src/QueryCache.Core/PublicAPI/PublicAPI.Shipped.txt @@ -1 +1,18 @@ #nullable enable +QueryCache.QueryCacheStore +QueryCache.QueryKey +QueryCache.QueryKey.Equals(QueryCache.QueryKey? other) -> bool +QueryCache.QueryKey.QueryKey(string! text, params System.Collections.Generic.IEnumerable! values) -> void +const QueryCache.QueryCacheStore.MeterName = "QueryCache" -> string! +override QueryCache.QueryKey.Equals(object? obj) -> bool +override QueryCache.QueryKey.GetHashCode() -> int +override QueryCache.QueryKey.ToString() -> string! +static QueryCache.QueryCacheStore.Capacity.get -> int +static QueryCache.QueryCacheStore.Capacity.set -> void +static QueryCache.QueryCacheStore.GetOrAddAsync(QueryCache.QueryKey! key, System.TimeSpan expiration, System.Func!>! factory, System.Collections.Generic.IReadOnlyCollection! tags, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.QueryCacheStore.GetOrAddAsync(QueryCache.QueryKey! key, System.TimeSpan expiration, System.Func!>! factory, System.Func!>! tags, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.QueryCacheStore.GetOrAddAsync(QueryCache.QueryKey! key, System.TimeSpan expiration, System.Func!>! factory, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.QueryCacheStore.HybridCache.get -> Microsoft.Extensions.Caching.Hybrid.HybridCache? +static QueryCache.QueryCacheStore.HybridCache.set -> void +static QueryCache.QueryCacheStore.InvalidateTagsAsync(System.Collections.Generic.IEnumerable! tags, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.QueryCacheStore.RemoveAsync(QueryCache.QueryKey! key, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask diff --git a/src/QueryCache.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/QueryCache.Core/PublicAPI/PublicAPI.Unshipped.txt index 5c6173d..7dc5c58 100644 --- a/src/QueryCache.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/QueryCache.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -1,18 +1 @@ #nullable enable -QueryCache.QueryCacheStore -QueryCache.QueryKey -QueryCache.QueryKey.Equals(QueryCache.QueryKey? other) -> bool -QueryCache.QueryKey.QueryKey(string! text, params System.Collections.Generic.IEnumerable! values) -> void -const QueryCache.QueryCacheStore.MeterName = "QueryCache" -> string! -override QueryCache.QueryKey.Equals(object? obj) -> bool -override QueryCache.QueryKey.GetHashCode() -> int -override QueryCache.QueryKey.ToString() -> string! -static QueryCache.QueryCacheStore.Capacity.get -> int -static QueryCache.QueryCacheStore.Capacity.set -> void -static QueryCache.QueryCacheStore.GetOrAddAsync(QueryCache.QueryKey! key, System.TimeSpan expiration, System.Func!>! factory, System.Collections.Generic.IReadOnlyCollection! tags, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.QueryCacheStore.GetOrAddAsync(QueryCache.QueryKey! key, System.TimeSpan expiration, System.Func!>! factory, System.Func!>! tags, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.QueryCacheStore.GetOrAddAsync(QueryCache.QueryKey! key, System.TimeSpan expiration, System.Func!>! factory, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.QueryCacheStore.HybridCache.get -> Microsoft.Extensions.Caching.Hybrid.HybridCache? -static QueryCache.QueryCacheStore.HybridCache.set -> void -static QueryCache.QueryCacheStore.InvalidateTagsAsync(System.Collections.Generic.IEnumerable! tags, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.QueryCacheStore.RemoveAsync(QueryCache.QueryKey! key, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask diff --git a/src/QueryCache.Dapper/PublicAPI/PublicAPI.Shipped.txt b/src/QueryCache.Dapper/PublicAPI/PublicAPI.Shipped.txt index 7dc5c58..2a31cbe 100644 --- a/src/QueryCache.Dapper/PublicAPI/PublicAPI.Shipped.txt +++ b/src/QueryCache.Dapper/PublicAPI/PublicAPI.Shipped.txt @@ -1 +1,15 @@ #nullable enable +QueryCache.Dapper.DapperCacheQuery +QueryCache.Dapper.DapperCacheQuery.DapperCacheQuery(System.Data.Common.DbConnection! connection, Dapper.CommandDefinition command) -> void +QueryCache.Dapper.DapperCacheQuery.Equals(QueryCache.Dapper.DapperCacheQuery? other) -> bool +QueryCache.Dapper.DapperCacheQuery.ExecuteScalarAsync(System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +QueryCache.Dapper.DapperCacheQuery.InvalidateCacheAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +QueryCache.Dapper.DapperCacheQuery.QueryAsync(System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +QueryCache.Dapper.DapperCacheQuery.QueryFirstOrDefaultAsync(System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +QueryCache.Dapper.DapperCacheQueryExtensions +override QueryCache.Dapper.DapperCacheQuery.Equals(object? obj) -> bool +override QueryCache.Dapper.DapperCacheQuery.GetHashCode() -> int +override QueryCache.Dapper.DapperCacheQuery.ToString() -> string! +static QueryCache.Dapper.DapperCacheQuery.operator !=(QueryCache.Dapper.DapperCacheQuery? left, QueryCache.Dapper.DapperCacheQuery? right) -> bool +static QueryCache.Dapper.DapperCacheQuery.operator ==(QueryCache.Dapper.DapperCacheQuery? left, QueryCache.Dapper.DapperCacheQuery? right) -> bool +static QueryCache.Dapper.DapperCacheQueryExtensions.ToCacheQuery(this System.Data.Common.DbConnection! connection, Dapper.CommandDefinition command) -> QueryCache.Dapper.DapperCacheQuery! diff --git a/src/QueryCache.Dapper/PublicAPI/PublicAPI.Unshipped.txt b/src/QueryCache.Dapper/PublicAPI/PublicAPI.Unshipped.txt index 2a31cbe..7dc5c58 100644 --- a/src/QueryCache.Dapper/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/QueryCache.Dapper/PublicAPI/PublicAPI.Unshipped.txt @@ -1,15 +1 @@ #nullable enable -QueryCache.Dapper.DapperCacheQuery -QueryCache.Dapper.DapperCacheQuery.DapperCacheQuery(System.Data.Common.DbConnection! connection, Dapper.CommandDefinition command) -> void -QueryCache.Dapper.DapperCacheQuery.Equals(QueryCache.Dapper.DapperCacheQuery? other) -> bool -QueryCache.Dapper.DapperCacheQuery.ExecuteScalarAsync(System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -QueryCache.Dapper.DapperCacheQuery.InvalidateCacheAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -QueryCache.Dapper.DapperCacheQuery.QueryAsync(System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> -QueryCache.Dapper.DapperCacheQuery.QueryFirstOrDefaultAsync(System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -QueryCache.Dapper.DapperCacheQueryExtensions -override QueryCache.Dapper.DapperCacheQuery.Equals(object? obj) -> bool -override QueryCache.Dapper.DapperCacheQuery.GetHashCode() -> int -override QueryCache.Dapper.DapperCacheQuery.ToString() -> string! -static QueryCache.Dapper.DapperCacheQuery.operator !=(QueryCache.Dapper.DapperCacheQuery? left, QueryCache.Dapper.DapperCacheQuery? right) -> bool -static QueryCache.Dapper.DapperCacheQuery.operator ==(QueryCache.Dapper.DapperCacheQuery? left, QueryCache.Dapper.DapperCacheQuery? right) -> bool -static QueryCache.Dapper.DapperCacheQueryExtensions.ToCacheQuery(this System.Data.Common.DbConnection! connection, Dapper.CommandDefinition command) -> QueryCache.Dapper.DapperCacheQuery! diff --git a/src/QueryCache.EFCore/PublicAPI/PublicAPI.Shipped.txt b/src/QueryCache.EFCore/PublicAPI/PublicAPI.Shipped.txt index 7dc5c58..815063e 100644 --- a/src/QueryCache.EFCore/PublicAPI/PublicAPI.Shipped.txt +++ b/src/QueryCache.EFCore/PublicAPI/PublicAPI.Shipped.txt @@ -1 +1,17 @@ #nullable enable +QueryCache.EFCore.QueryCacheInvalidation +QueryCache.EFCore.QueryableCacheExtensions +static QueryCache.EFCore.QueryCacheInvalidation.UseQueryCacheInvalidation(this Microsoft.EntityFrameworkCore.DbContextOptionsBuilder! builder) -> Microsoft.EntityFrameworkCore.DbContextOptionsBuilder! +static QueryCache.EFCore.QueryCacheInvalidation.UseQueryCacheInvalidation(this Microsoft.EntityFrameworkCore.DbContextOptionsBuilder! builder) -> Microsoft.EntityFrameworkCore.DbContextOptionsBuilder! +static QueryCache.EFCore.QueryableCacheExtensions.AnyCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.EFCore.QueryableCacheExtensions.CountCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.EFCore.QueryableCacheExtensions.FirstCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.EFCore.QueryableCacheExtensions.FirstOrDefaultCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.EFCore.QueryableCacheExtensions.InvalidateCacheAsync(this System.Linq.IQueryable! query, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.EFCore.QueryableCacheExtensions.MaxCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.EFCore.QueryableCacheExtensions.SingleOrDefaultCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.EFCore.QueryableCacheExtensions.SumCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static QueryCache.EFCore.QueryableCacheExtensions.ToDictionaryCachedAsync(this System.Linq.IQueryable! query, System.Func! keySelector, System.Func! valueSelector, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static QueryCache.EFCore.QueryableCacheExtensions.ToDictionaryCachedAsync(this System.Linq.IQueryable! query, System.Func! keySelector, System.Collections.Generic.IEqualityComparer! comparer, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static QueryCache.EFCore.QueryableCacheExtensions.ToDictionaryCachedAsync(this System.Linq.IQueryable! query, System.Func! keySelector, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> +static QueryCache.EFCore.QueryableCacheExtensions.ToListCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> diff --git a/src/QueryCache.EFCore/PublicAPI/PublicAPI.Unshipped.txt b/src/QueryCache.EFCore/PublicAPI/PublicAPI.Unshipped.txt index 815063e..7dc5c58 100644 --- a/src/QueryCache.EFCore/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/QueryCache.EFCore/PublicAPI/PublicAPI.Unshipped.txt @@ -1,17 +1 @@ #nullable enable -QueryCache.EFCore.QueryCacheInvalidation -QueryCache.EFCore.QueryableCacheExtensions -static QueryCache.EFCore.QueryCacheInvalidation.UseQueryCacheInvalidation(this Microsoft.EntityFrameworkCore.DbContextOptionsBuilder! builder) -> Microsoft.EntityFrameworkCore.DbContextOptionsBuilder! -static QueryCache.EFCore.QueryCacheInvalidation.UseQueryCacheInvalidation(this Microsoft.EntityFrameworkCore.DbContextOptionsBuilder! builder) -> Microsoft.EntityFrameworkCore.DbContextOptionsBuilder! -static QueryCache.EFCore.QueryableCacheExtensions.AnyCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.EFCore.QueryableCacheExtensions.CountCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.EFCore.QueryableCacheExtensions.FirstCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.EFCore.QueryableCacheExtensions.FirstOrDefaultCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.EFCore.QueryableCacheExtensions.InvalidateCacheAsync(this System.Linq.IQueryable! query, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.EFCore.QueryableCacheExtensions.MaxCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.EFCore.QueryableCacheExtensions.SingleOrDefaultCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.EFCore.QueryableCacheExtensions.SumCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -static QueryCache.EFCore.QueryableCacheExtensions.ToDictionaryCachedAsync(this System.Linq.IQueryable! query, System.Func! keySelector, System.Func! valueSelector, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> -static QueryCache.EFCore.QueryableCacheExtensions.ToDictionaryCachedAsync(this System.Linq.IQueryable! query, System.Func! keySelector, System.Collections.Generic.IEqualityComparer! comparer, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> -static QueryCache.EFCore.QueryableCacheExtensions.ToDictionaryCachedAsync(this System.Linq.IQueryable! query, System.Func! keySelector, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> -static QueryCache.EFCore.QueryableCacheExtensions.ToListCachedAsync(this System.Linq.IQueryable! query, System.TimeSpan expiration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask!> diff --git a/src/QueryCache.EFCore/QueryCacheInvalidation.cs b/src/QueryCache.EFCore/QueryCacheInvalidation.cs index 0894925..6e4a414 100644 --- a/src/QueryCache.EFCore/QueryCacheInvalidation.cs +++ b/src/QueryCache.EFCore/QueryCacheInvalidation.cs @@ -71,14 +71,6 @@ private static ValueTask Saved(DbContext? context) return QueryCacheStore.InvalidateTagsAsync(tags, CancellationToken.None); } - private static void Failed(DbContext? context) - { - if (context is not null && States.TryGetValue(context, out var state)) - { - state.Saving = []; - } - } - private static ValueTask Ended(DbContext? context, bool committed) { if (context is null || !States.TryGetValue(context, out var state)) @@ -125,17 +117,6 @@ public override async ValueTask SavedChangesAsync(SaveChangesCompletedEvent await Saved(eventData.Context).ConfigureAwait(false); return result; } - - public override void SaveChangesFailed(DbContextErrorEventData eventData) - { - Failed(eventData.Context); - } - - public override Task SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken = default) - { - Failed(eventData.Context); - return Task.CompletedTask; - } } private sealed class TransactionInvalidator : DbTransactionInterceptor diff --git a/tests/QueryCache.IntegrationTests/CacheIntegrationTests.cs b/tests/QueryCache.IntegrationTests/CacheIntegrationTests.cs index 0ad487e..e1103f2 100644 --- a/tests/QueryCache.IntegrationTests/CacheIntegrationTests.cs +++ b/tests/QueryCache.IntegrationTests/CacheIntegrationTests.cs @@ -1,6 +1,7 @@ using System.Data.Common; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Transactions; using Dapper; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -244,6 +245,27 @@ public async Task Commit_invalidates_what_other_connections_cached_while_the_tra Assert.Equal(2, (await reader.Items.ToListCachedAsync(Minute, CancellationToken.None)).Count); } + + [Fact] + public async Task Completing_a_TransactionScope_invalidates_what_other_connections_cached_meanwhile() + { + var connectionString = await NewDatabaseAsync(new Item { Id = 1 }); + await using var writer = NewDb(connectionString); + await using var reader = NewDb(connectionString); + + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + writer.Items.Add(new Item { Id = 2 }); + await writer.SaveChangesAsync(); + using (new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled)) + { + Assert.Single(await reader.Items.ToListCachedAsync(Minute, CancellationToken.None)); + } + scope.Complete(); + } + + Assert.Equal(2, (await reader.Items.ToListCachedAsync(Minute, CancellationToken.None)).Count); + } } public sealed class SqlServerTests(SqlServerServer server) : CacheIntegrationTests(server); diff --git a/tests/QueryCache.Tests/DapperTests.cs b/tests/QueryCache.Tests/DapperTests.cs index 6dfcac1..7b51756 100644 --- a/tests/QueryCache.Tests/DapperTests.cs +++ b/tests/QueryCache.Tests/DapperTests.cs @@ -71,6 +71,35 @@ public void DynamicParameters_with_different_template_values_are_keyed_different Assert.NotEqual(Key(new DynamicParameters(new { Id = 1 })), Key(new DynamicParameters(new { Id = 2 }))); } + [Fact] + public void DynamicParameters_added_by_name_are_keyed_by_value() + { + static DynamicParameters Params(int id) + { + var parameters = new DynamicParameters(); + parameters.Add("Id", id); + return parameters; + } + + Assert.Equal(Key(Params(1)), Key(Params(1))); + Assert.NotEqual(Key(Params(1)), Key(Params(2))); + } + + [Fact] + public async Task Queries_are_equal_by_connection_sql_and_parameter_values() + { + await using var connection = await NewConnection("create table t(id int);"); + await using var other = await NewConnection("create table t(id int);"); + var query = Query(connection, new CommandDefinition("select id from t where id = @Id", new { Id = 1 })); + + Assert.Equal(query, (object)Query(connection, new CommandDefinition("select id from t where id = @Id", new { Id = 1 }))); + Assert.Equal(query.GetHashCode(), Query(connection, new CommandDefinition("select id from t where id = @Id", new { Id = 1 })).GetHashCode()); + Assert.True(query != Query(connection, new CommandDefinition("select id from t where id = @Id", new { Id = 2 }))); + Assert.True(query != Query(other, new CommandDefinition("select id from t where id = @Id", new { Id = 1 }))); + Assert.False(query.Equals((object?)null)); + Assert.Contains("select id from t where id = @Id", query.ToString(), StringComparison.Ordinal); + } + [Fact] public void Dictionary_parameters_are_keyed_by_value() { diff --git a/tests/QueryCache.Tests/EfCoreTests.cs b/tests/QueryCache.Tests/EfCoreTests.cs index 4c8fc2c..2da8743 100644 --- a/tests/QueryCache.Tests/EfCoreTests.cs +++ b/tests/QueryCache.Tests/EfCoreTests.cs @@ -30,6 +30,12 @@ internal sealed class CommandCounter : DbCommandInterceptor { public int Count; + public override InterceptionResult ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Interlocked.Increment(ref Count); + return base.ReaderExecuting(command, eventData, result); + } + public override ValueTask> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { Interlocked.Increment(ref Count); @@ -169,6 +175,14 @@ public async Task FirstOrDefaultCachedAsync_is_served_from_cache_until_invalidat Assert.Null(await query.FirstOrDefaultCachedAsync(Minute, CancellationToken.None)); } + [Fact] + public async Task FirstCachedAsync_returns_the_first_row() + { + await using var db = await NewDb(new Item { Id = 1 }); + + Assert.Equal(1, (await db.Items.FirstCachedAsync(Minute, CancellationToken.None)).Id); + } + [Fact] public async Task FirstCachedAsync_throws_on_empty() { @@ -321,8 +335,10 @@ public async Task SaveChanges_keeps_cached_queries_on_other_tables() Assert.Single(await db.Items.ToListCachedAsync(Minute, CancellationToken.None)); } - [Fact] - public async Task Commit_invalidates_what_other_connections_cached_while_the_transaction_was_open() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Commit_invalidates_what_other_connections_cached_while_the_transaction_was_open(bool sync) { var file = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.db"); try @@ -331,10 +347,24 @@ public async Task Commit_invalidates_what_other_connections_cached_while_the_tra await using var reader = await OpenDb(file); await using var transaction = await writer.Database.BeginTransactionAsync(); writer.Items.Add(new Item { Id = 2 }); - await writer.SaveChangesAsync(); + if (sync) + { + writer.SaveChanges(); + } + else + { + await writer.SaveChangesAsync(); + } Assert.Single(await reader.Items.ToListCachedAsync(Minute, CancellationToken.None)); - await transaction.CommitAsync(); + if (sync) + { + transaction.Commit(); + } + else + { + await transaction.CommitAsync(); + } Assert.Equal(2, (await reader.Items.ToListCachedAsync(Minute, CancellationToken.None)).Count); } @@ -344,4 +374,83 @@ public async Task Commit_invalidates_what_other_connections_cached_while_the_tra File.Delete(file); } } + + [Fact] + public async Task Synchronous_SaveChanges_invalidates_cached_queries() + { + await using var db = await NewDb(new Item { Id = 1, Name = "a" }); + var named = db.Items.Where(i => i.Name == "a"); + Assert.Single(await named.ToListCachedAsync(Minute, CancellationToken.None)); + + db.Items.Add(new Item { Id = 2, Name = "a" }); + db.SaveChanges(); + + Assert.Equal(2, (await named.ToListCachedAsync(Minute, CancellationToken.None)).Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Rolled_back_saves_leave_the_cache_matching_the_database(bool sync) + { + await using var db = await NewDb(new Item { Id = 1 }); + Assert.Single(await db.Items.ToListCachedAsync(Minute, CancellationToken.None)); + var transaction = await db.Database.BeginTransactionAsync(); + db.Items.Add(new Item { Id = 2 }); + await db.SaveChangesAsync(); + + if (sync) + { + transaction.Rollback(); + } + else + { + await transaction.RollbackAsync(); + } + await transaction.DisposeAsync(); + + Assert.Single(await db.Items.ToListCachedAsync(Minute, CancellationToken.None)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failed_saves_keep_cached_entries(bool sync) + { + await using var db = await NewDb(new Item { Id = 1 }); + Assert.Single(await db.Items.ToListCachedAsync(Minute, CancellationToken.None)); + db.Items.Add(new Item { Id = 1 }); + + await Assert.ThrowsAsync(async () => + { + if (sync) + { + db.SaveChanges(); + } + else + { + await db.SaveChangesAsync(); + } + }); + + var before = db.Commands.Count; + Assert.Single(await db.Items.ToListCachedAsync(Minute, CancellationToken.None)); + Assert.Equal(before, db.Commands.Count); + } + + [Fact] + public async Task ToDictionaryCachedAsync_uses_the_key_comparer() + { + await using var db = await NewDb(new Item { Id = 1, Name = "a" }); + + var byName = await db.Items.ToDictionaryCachedAsync(i => i.Name, StringComparer.OrdinalIgnoreCase, Minute, CancellationToken.None); + + Assert.Equal(1, byName["A"].Id); + } + + [Fact] + public void Expressions_without_an_entity_query_root_have_no_tags() + { + Assert.Empty(TableTags.ForQuery(System.Linq.Expressions.Expression.Constant(1), "scope")); + } } diff --git a/tests/QueryCache.Tests/HybridCacheTests.cs b/tests/QueryCache.Tests/HybridCacheTests.cs index 8b22c8f..ed8c820 100644 --- a/tests/QueryCache.Tests/HybridCacheTests.cs +++ b/tests/QueryCache.Tests/HybridCacheTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid; @@ -19,10 +20,20 @@ public sealed class HybridCacheTests : IDisposable public sealed record Row(int Id, string Name); - /// Stands in for Redis. HybridCache ignores a bare as a distributed layer. + /// + /// Stands in for Redis. HybridCache ignores a bare as a distributed layer. + /// Writes complete asynchronously, like a network cache, and are recorded. + /// private sealed class SharedCache : IDistributedCache { private readonly MemoryDistributedCache _inner = new(Options.Create(new MemoryDistributedCacheOptions())); + private int _writing; + + /// Writes started and not finished yet. + public int Writing => Volatile.Read(ref _writing); + + /// Every key written, in order. + public ConcurrentQueue Written { get; } = new(); public byte[]? Get(string key) { @@ -39,9 +50,19 @@ public void Set(string key, byte[] value, DistributedCacheEntryOptions options) _inner.Set(key, value, options); } - public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) { - return _inner.SetAsync(key, value, options, token); + Interlocked.Increment(ref _writing); + try + { + await Task.Delay(20, token); + await _inner.SetAsync(key, value, options, token); + Written.Enqueue(key); + } + finally + { + Interlocked.Decrement(ref _writing); + } } public void Refresh(string key) @@ -65,9 +86,11 @@ public Task RemoveAsync(string key, CancellationToken token = default) } } + private readonly SharedCache _distributed = new(); + public HybridCacheTests() { - QueryCacheStore.HybridCache = NewCache(new SharedCache()); + QueryCacheStore.HybridCache = NewCache(_distributed); } public void Dispose() @@ -143,6 +166,41 @@ public async Task Another_instance_reads_the_entry_from_the_distributed_cache() Assert.Equal([new Row(1, "a")], rows); } + [Fact] + public async Task Tag_invalidated_during_fill_keeps_the_stale_value_out_of_the_hybrid_cache() + { + var key = Key(nameof(Tag_invalidated_during_fill_keeps_the_stale_value_out_of_the_hybrid_cache)); + var release = new TaskCompletionSource(); + var started = new TaskCompletionSource(); + var fill = QueryCacheStore.GetOrAddAsync(key, Minute, async _ => + { + started.SetResult(); + await release.Task; + return "stale"; + }, ["tests.hybrid-during-fill"], CancellationToken.None).AsTask(); + + await started.Task; + await QueryCacheStore.InvalidateTagsAsync(["tests.hybrid-during-fill"], CancellationToken.None); + release.SetResult(); + + Assert.Equal("stale", await fill); + Assert.DoesNotContain(_distributed.Written, written => written.StartsWith("querycache:", StringComparison.Ordinal)); + Assert.Equal("fresh", await QueryCacheStore.GetOrAddAsync(key, Minute, _ => Task.FromResult("fresh"), CancellationToken.None)); + } + + [Fact] + public async Task Synchronous_SaveChanges_waits_for_the_hybrid_invalidation() + { + await using var db = await EfCoreTests.NewDb(new EfCoreTests.Item { Id = 1 }); + Assert.Single(await db.Items.ToListCachedAsync(Minute, CancellationToken.None)); + + db.Items.Add(new EfCoreTests.Item { Id = 2 }); + db.SaveChanges(); + + Assert.Equal(0, _distributed.Writing); + Assert.Equal(2, (await db.Items.ToListCachedAsync(Minute, CancellationToken.None)).Count); + } + [Fact] public async Task SaveChanges_invalidates_ef_entries() { diff --git a/tests/QueryCache.Tests/QueryCache.Tests.csproj b/tests/QueryCache.Tests/QueryCache.Tests.csproj index a5f8857..42a7e73 100644 --- a/tests/QueryCache.Tests/QueryCache.Tests.csproj +++ b/tests/QueryCache.Tests/QueryCache.Tests.csproj @@ -8,8 +8,9 @@ true true + - $(NoWarn);RS0016;RS0026;RS0037;CA2007;CA1707;CA1812;CA1034;CA2000;MA0004;MA0048;CA1002;CA1861;xUnit1051 + $(NoWarn);RS0016;RS0026;RS0037;CA1849;CA2007;CA1707;CA1812;CA1034;CA2000;MA0004;MA0048;CA1002;CA1861;xUnit1051 diff --git a/tests/QueryCache.Tests/QueryCacheStoreTests.cs b/tests/QueryCache.Tests/QueryCacheStoreTests.cs index 5201baa..fb60dd8 100644 --- a/tests/QueryCache.Tests/QueryCacheStoreTests.cs +++ b/tests/QueryCache.Tests/QueryCacheStoreTests.cs @@ -46,6 +46,30 @@ public void Keys_compare_text_and_values_including_sequences() Assert.NotEqual(Key("q", 1), Key("r", 1)); Assert.NotEqual(Key("q", new[] { 1, 2 }, new[] { 3 }), Key("q", new[] { 1 }, new[] { 2, 3 })); Assert.NotEqual(Key("q", "a", "b"), Key("q", (object)new[] { "a", "b" })); + Assert.True(Key("q", 1).Equals((object)Key("q", 1))); + Assert.False(Key("q", 1).Equals("q")); + Assert.Equal("q", Key("q", 1).ToString()); + } + + private static IEnumerable Lazy(int count) + { + for (var i = 0; i < count; i++) + { + yield return i; + } + } + + [Fact] + public async Task Lazy_enumerables_are_cached_only_when_they_have_items() + { + var empty = Key(nameof(Lazy_enumerables_are_cached_only_when_they_have_items), 0); + var full = Key(nameof(Lazy_enumerables_are_cached_only_when_they_have_items), 1); + + await QueryCacheStore.GetOrAddAsync(empty, Minute, _ => Task.FromResult(Lazy(0)), CancellationToken.None); + await QueryCacheStore.GetOrAddAsync(full, Minute, _ => Task.FromResult(Lazy(2)), CancellationToken.None); + + Assert.False(QueryCacheStore.TryGet>(empty, out _)); + Assert.True(QueryCacheStore.TryGet>(full, out _)); } [Fact]