diff --git a/samples/PepperX.QueryForge.Sample.WebApi/Program.cs b/samples/PepperX.QueryForge.Sample.WebApi/Program.cs index 445c2e7..613fb51 100644 --- a/samples/PepperX.QueryForge.Sample.WebApi/Program.cs +++ b/samples/PepperX.QueryForge.Sample.WebApi/Program.cs @@ -1,5 +1,6 @@ using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; +using PepperX.QueryForge.Caching; using PepperX.QueryForge.Dapper; using PepperX.QueryForge.EFCore; using PepperX.QueryForge.InMemory; @@ -124,6 +125,19 @@ 4. The `usp_GetUserReport` Stored Procedure. .WithSummary("Fetch page 5 of a large dataset.") .Accepts("application/json").Produces>(); + coreApi.MapPost("/cached", async (Query q, IDapperQueryService svc) => + { + var dq = DapperQueryBuilder.FromBase(q) + .ForObject("TestUsers", "dbo", DapperObjectType.Table) + .WithCache(q.Cache?.Expiration ?? TimeSpan.FromMinutes(2), tags: ["users"]) + .Build(); + return await svc.QueryAsync(dq); + }) + .WithName("DapperCachedQuery") + .WithSummary("Cached query via Dapper (returns immediately from memory on repeat calls).") + .WithDescription("Results are cached using QueryForge second-level memory cache to avoid repeat SQL queries.") + .Accepts("application/json").Produces>(); + // ========================================== // GROUP 2: ADVANCED FILTERING // ========================================== @@ -319,6 +333,16 @@ await db.Users.AsNoTracking().Where(u => u.IsActive).ToQueryResultAsync("application/json").Produces(); + efApi.MapPost("/cached", async (Query q, SampleDbContext db) => + { + q.WithCache(q.Cache?.Expiration ?? TimeSpan.FromMinutes(2), tags: ["users"]); + return await db.Users.AsNoTracking().ToQueryResultAsync(q); + }) + .WithName("EfCoreCachedQuery") + .WithSummary("Cached query via EF Core (returns from memory cache on repeat executions).") + .WithDescription("Uses QueryForge second-level memory cache to avoid repeat SQL queries.") + .Accepts("application/json").Produces>(); + // ========================================== // GROUP 7: IN-MEMORY PROVIDER // ========================================== @@ -363,6 +387,27 @@ await db.Users.AsNoTracking().Where(u => u.IsActive).ToQueryResultAsync>(StatusCodes.Status200OK) .ProducesValidationProblem(); + // ========================================== + // GROUP 8: CACHE MANAGEMENT + // ========================================== + var cacheApi = app.MapGroup("/api/cache").WithTags("8. Cache Management"); + + cacheApi.MapPost("/invalidate", async (string tag) => + { + await QueryForgeCache.Default.InvalidateTagAsync(tag); + return Results.Ok(new { message = $"Cache entries tagged with '{tag}' invalidated." }); + }) + .WithName("InvalidateCacheByTag") + .WithSummary("Invalidates cached queries by tag (e.g. 'users')."); + + cacheApi.MapPost("/clear", async () => + { + await QueryForgeCache.Default.ClearAsync(); + return Results.Ok(new { message = "Entire query cache cleared." }); + }) + .WithName("ClearQueryCache") + .WithSummary("Clears all cached queries."); + app.Run(); } } diff --git a/samples/PepperX.QueryForge.Sample.WebApi/Requests.http b/samples/PepperX.QueryForge.Sample.WebApi/Requests.http index 86fdba8..fa773ff 100644 --- a/samples/PepperX.QueryForge.Sample.WebApi/Requests.http +++ b/samples/PepperX.QueryForge.Sample.WebApi/Requests.http @@ -94,6 +94,28 @@ Content-Type: application/json ### +# 4b. Cached query — repeat calls return immediately from memory cache without SQL. +POST {{baseUrl}}/api/users/cached +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "Country", "operator": 0, "value": "Iran" } + ] + } + ] + }, + "paging": { "size": 10, "number": 1 }, + "sortColumns": [ { "columnName": "UserId", "sortOrder": 0 } ] +} + +### + # 5. No inputs at all — the defaults apply: page 1, size 12, no filter. POST {{baseUrl}}/api/users/query Content-Type: application/json @@ -648,6 +670,28 @@ Content-Type: application/json "sortColumns": [ { "columnName": "Score", "sortOrder": 1 } ] } +### + +# 30b. Cached query via EF Core — repeat queries return directly from memory cache. +POST {{baseUrl}}/api/efcore/users/cached +Content-Type: application/json + +{ + "criteria": { + "logic": 0, + "groups": [ + { + "logic": 0, + "conditions": [ + { "columnName": "Country", "operator": 0, "value": "Germany" } + ] + } + ] + }, + "paging": { "size": 10, "number": 1 }, + "sortColumns": [ { "columnName": "UserId", "sortOrder": 0 } ] +} + ### ========================================================================== ### GROUP 7: IN-MEMORY PROVIDER (no database, no configuration) @@ -906,11 +950,25 @@ Content-Type: application/json } +### ========================================================================== +### GROUP 8: CACHE MANAGEMENT +### ========================================================================== + +# 44. Invalidate cache entries by tag (e.g. 'users'). +POST {{baseUrl}}/api/cache/invalidate?tag=users + +### + +# 45. Clear all cached queries from memory cache. +POST {{baseUrl}}/api/cache/clear + + ### ========================================================================== ### The OpenAPI document (development environment only) ### ========================================================================== -# 44. The generated OpenAPI document — for Swagger UI, Scalar, Postman import, +# 46. The generated OpenAPI document — for Swagger UI, Scalar, Postman import, # or frontend client generation. GET {{baseUrl}}/openapi/v1.json Accept: application/json + diff --git a/src/PepperX.QueryForge.Dapper/Builders/DapperQueryBuilder.cs b/src/PepperX.QueryForge.Dapper/Builders/DapperQueryBuilder.cs index 10e46e2..98be103 100644 --- a/src/PepperX.QueryForge.Dapper/Builders/DapperQueryBuilder.cs +++ b/src/PepperX.QueryForge.Dapper/Builders/DapperQueryBuilder.cs @@ -1,4 +1,4 @@ -using PepperX.QueryForge.Dapper; +using PepperX.QueryForge.Dapper; /// /// A static entry point for constructing Dapper instances. @@ -15,7 +15,7 @@ public static class DapperQueryBuilder /// Upgrades a base, provider-agnostic Query into a Dapper-specific query builder. /// This allows the frontend to send the base Query, and the backend to securely append the DapperQueryObject. /// - public static DapperQueryFluent FromBase(PepperX.QueryForge.Query baseQuery) => New(new DapperQuery { Criteria = baseQuery.Criteria, Paging = baseQuery.Paging, SelectColumns = baseQuery.SelectColumns, SortColumns = baseQuery.SortColumns, GroupByColumns = baseQuery.GroupByColumns }); + public static DapperQueryFluent FromBase(PepperX.QueryForge.Query baseQuery) => New(new DapperQuery { Criteria = baseQuery.Criteria, Paging = baseQuery.Paging, SelectColumns = baseQuery.SelectColumns, SortColumns = baseQuery.SortColumns, GroupByColumns = baseQuery.GroupByColumns, Cache = baseQuery.Cache }); /// Starts a new chain by specifying the target SQL object. public static DapperQueryFluent ForObject(string name, string schema = "", DapperObjectType type = DapperObjectType.Auto, IReadOnlyDictionary? parameters = null) @@ -76,6 +76,35 @@ public DapperQueryFluent ForObject(string name, string schema = "", DapperObject /// public new DapperQueryFluent Page(int size, int number = 1) { base.Page(size, number); return this; } + /// Enables caching on the query with the specified expiration, optional custom key, and tags. + public DapperQueryFluent WithCache(TimeSpan? expiration = null, string? key = null, string[]? tags = null, TimeSpan? slidingExpiration = null) + { + _dapperQuery.Cache = new PepperX.QueryForge.Caching.QueryCacheOptions + { + Enabled = true, + Expiration = expiration, + Key = key, + Tags = tags ?? Array.Empty(), + SlidingExpiration = slidingExpiration + }; + return this; + } + + /// Enables caching on the query with the specified cache options. + public DapperQueryFluent WithCacheOptions(PepperX.QueryForge.Caching.QueryCacheOptions options) + { + _dapperQuery.Cache = options; + return this; + } + + /// Disables caching on the query. + public DapperQueryFluent WithoutCache() + { + if (_dapperQuery.Cache is not null) + _dapperQuery.Cache.Enabled = false; + return this; + } + /// /// Upgrades a base, provider-agnostic Query into a Dapper-specific query builder. /// This allows the frontend to send the base Query, and the backend to securely append the DapperQueryObject. @@ -88,7 +117,8 @@ public static DapperQueryFluent FromBase(PepperX.QueryForge.Query baseQuery) Paging = baseQuery.Paging, SelectColumns = baseQuery.SelectColumns, SortColumns = baseQuery.SortColumns, - GroupByColumns = baseQuery.GroupByColumns + GroupByColumns = baseQuery.GroupByColumns, + Cache = baseQuery.Cache }; return new DapperQueryFluent(dapperQuery); diff --git a/src/PepperX.QueryForge.Dapper/Configuration/DapperQueryForgeOptions.cs b/src/PepperX.QueryForge.Dapper/Configuration/DapperQueryForgeOptions.cs index e88391d..c3108d3 100644 --- a/src/PepperX.QueryForge.Dapper/Configuration/DapperQueryForgeOptions.cs +++ b/src/PepperX.QueryForge.Dapper/Configuration/DapperQueryForgeOptions.cs @@ -12,4 +12,10 @@ public class DapperQueryForgeOptions /// connection. Not required when every call supplies its own . /// public Func? ConnectionFactory { get; set; } + + /// + /// The query cache instance used for storing query results. + /// If null, is used. + /// + public PepperX.QueryForge.Caching.IQueryCache? Cache { get; set; } } diff --git a/src/PepperX.QueryForge.Dapper/Internals/DapperRegistry.cs b/src/PepperX.QueryForge.Dapper/Internals/DapperRegistry.cs index 11c05dc..bc027d6 100644 --- a/src/PepperX.QueryForge.Dapper/Internals/DapperRegistry.cs +++ b/src/PepperX.QueryForge.Dapper/Internals/DapperRegistry.cs @@ -23,7 +23,7 @@ public void Register(ISqlDialect dialect) ArgumentNullException.ThrowIfNull(dialect); var compiler = new SqlQueryCompiler(dialect); - _executors[dialect.ProviderType] = new QueryExecutor(compiler, new SchemaCache(compiler)); + _executors[dialect.ProviderType] = new QueryExecutor(compiler, new SchemaCache(compiler), Options); } /// Resolves the executor for an explicitly named engine. diff --git a/src/PepperX.QueryForge.Dapper/Internals/QueryExecutor.cs b/src/PepperX.QueryForge.Dapper/Internals/QueryExecutor.cs index c6640bf..c0d8a44 100644 --- a/src/PepperX.QueryForge.Dapper/Internals/QueryExecutor.cs +++ b/src/PepperX.QueryForge.Dapper/Internals/QueryExecutor.cs @@ -1,5 +1,6 @@ using System.Data; using Dapper; +using PepperX.QueryForge.Caching; using PepperX.QueryForge.Dapper.Compiler; using PepperX.QueryForge.Querying; @@ -13,7 +14,7 @@ namespace PepperX.QueryForge.Dapper.Internals; /// three, because paging applies to the outermost grouping level rather than to rows — the counts /// and the key page describe groups, and only then are the rows for those groups fetched. /// -internal sealed class QueryExecutor(SqlQueryCompiler compiler, SchemaCache schemaCache) +internal sealed class QueryExecutor(SqlQueryCompiler compiler, SchemaCache schemaCache, DapperQueryForgeOptions? options = null) { public async Task> QueryAsync( IDbConnection connection, @@ -24,6 +25,34 @@ public async Task> QueryAsync( ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(query); + var cache = options?.Cache ?? QueryForgeCache.Default; + var cacheOptions = query.Cache; + + if (cacheOptions is { Enabled: true }) + { + var cacheKey = QueryCacheKeyGenerator.GenerateKey( + query, + targetSource: query.Object?.Name, + customPrefix: "dapper"); + + var cached = await cache.GetAsync(cacheKey); + if (cached is not null) + return cached; + + var executed = await ExecuteCoreAsync(connection, query, commandTimeout, transaction); + await cache.SetAsync(cacheKey, executed, cacheOptions); + return executed; + } + + return await ExecuteCoreAsync(connection, query, commandTimeout, transaction); + } + + private async Task> ExecuteCoreAsync( + IDbConnection connection, + DapperQuery query, + int? commandTimeout, + IDbTransaction? transaction) + { if (query.Object?.Type == DapperObjectType.SP) return await QueryStoredProcedureAsync(connection, query, commandTimeout, transaction); diff --git a/src/PepperX.QueryForge.EFCore/QueryForgeQueryableExtensions.cs b/src/PepperX.QueryForge.EFCore/QueryForgeQueryableExtensions.cs index 459e123..1e03359 100644 --- a/src/PepperX.QueryForge.EFCore/QueryForgeQueryableExtensions.cs +++ b/src/PepperX.QueryForge.EFCore/QueryForgeQueryableExtensions.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Reflection; using Microsoft.EntityFrameworkCore; +using PepperX.QueryForge.Caching; using PepperX.QueryForge.EFCore.Translation; using PepperX.QueryForge.Querying; @@ -171,22 +172,81 @@ private static bool HasIncludes(Expression expression) /// Applies filtering, sorting and paging, leaving the query unexecuted. /// /// Grouping is not applied here, because a hierarchy is a shape rather than a queryable. Use - /// for grouped queries. + /// for grouped queries. /// public static IQueryable ApplyQuery(this IQueryable source, Query query) => source.ApplyFilter(query).ApplySort(query).ApplyPaging(query).ApplyProjection(query); /// /// Executes the query and returns the standard QueryForge result, flat or grouped. + /// Supports second-level query caching when is configured or when a cache is provided. /// public static async Task> ToQueryResultAsync( this IQueryable source, Query query, + IQueryCache? cache = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(query); + var cacheInstance = cache ?? QueryForgeCache.Default; + var cacheOptions = query.Cache; + + if (cacheOptions is { Enabled: true }) + { + var cacheKey = QueryCacheKeyGenerator.GenerateKey( + query, + targetSource: typeof(TModel).Name, + customPrefix: "ef"); + + var cached = await cacheInstance.GetAsync(cacheKey, cancellationToken); + if (cached is not null) + return cached; + + var result = await ExecuteCoreAsync(source, query, cancellationToken); + await cacheInstance.SetAsync(cacheKey, result, cacheOptions, cancellationToken); + return result; + } + + return await ExecuteCoreAsync(source, query, cancellationToken); + } + + /// + /// Executes the query with the specified caching options. + /// + public static Task> ToQueryResultAsync( + this IQueryable source, + Query query, + QueryCacheOptions cacheOptions, + IQueryCache? cache = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + query.Cache = cacheOptions; + return source.ToQueryResultAsync(query, cache, cancellationToken); + } + + /// + /// Executes the query with caching enabled for the specified expiration duration. + /// + public static Task> ToQueryResultCachedAsync( + this IQueryable source, + Query query, + TimeSpan? expiration = null, + IQueryCache? cache = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + query.WithCache(expiration); + return source.ToQueryResultAsync(query, cache, cancellationToken); + } + + private static async Task> ExecuteCoreAsync( + IQueryable source, + Query query, + CancellationToken cancellationToken) + { var filtered = source.ApplyFilter(query); // A grouping level has to be orderable, not merely present: its keys are ordered and paged diff --git a/src/PepperX.QueryForge/Caching/IQueryCache.cs b/src/PepperX.QueryForge/Caching/IQueryCache.cs new file mode 100644 index 0000000..aaa235f --- /dev/null +++ b/src/PepperX.QueryForge/Caching/IQueryCache.cs @@ -0,0 +1,39 @@ +namespace PepperX.QueryForge.Caching; + +/// +/// Abstraction for second-level and in-memory query result caching. +/// Supports both simple key lookups and tag-based invalidation. +/// +public interface IQueryCache +{ + /// + /// Attempts to retrieve a cached query result by key. + /// + ValueTask?> GetAsync( + string key, + CancellationToken cancellationToken = default); + + /// + /// Stores a query result into the cache with the given policy. + /// + ValueTask SetAsync( + string key, + QueryResult result, + QueryCacheOptions options, + CancellationToken cancellationToken = default); + + /// + /// Removes an entry from the cache by its key. + /// + ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Invalidates all cached query results associated with the specified tag. + /// + ValueTask InvalidateTagAsync(string tag, CancellationToken cancellationToken = default); + + /// + /// Clears all entries from the cache. + /// + ValueTask ClearAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PepperX.QueryForge/Caching/MemoryQueryCache.cs b/src/PepperX.QueryForge/Caching/MemoryQueryCache.cs new file mode 100644 index 0000000..6f07867 --- /dev/null +++ b/src/PepperX.QueryForge/Caching/MemoryQueryCache.cs @@ -0,0 +1,199 @@ +using System.Collections.Concurrent; + +namespace PepperX.QueryForge.Caching; + +/// +/// A high-performance, thread-safe in-memory cache implementation of +/// with absolute/sliding expiration, tag indexing, and automated expiration eviction. +/// Zero external package dependencies. +/// +public sealed class MemoryQueryCache : IQueryCache, IDisposable +{ + private sealed class CacheEntry + { + public required object Result { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? AbsoluteExpiresAt { get; set; } + public TimeSpan? SlidingExpiration { get; init; } + public DateTimeOffset LastAccessedAt { get; set; } + public IReadOnlyList Tags { get; init; } = Array.Empty(); + + public bool IsExpired(DateTimeOffset now) + { + if (AbsoluteExpiresAt.HasValue && now >= AbsoluteExpiresAt.Value) + return true; + + if (SlidingExpiration.HasValue && now - LastAccessedAt >= SlidingExpiration.Value) + return true; + + return false; + } + + public void Refresh(DateTimeOffset now) + { + LastAccessedAt = now; + if (SlidingExpiration.HasValue) + { + var newSlidingExpiry = now + SlidingExpiration.Value; + if (!AbsoluteExpiresAt.HasValue || newSlidingExpiry < AbsoluteExpiresAt.Value) + AbsoluteExpiresAt = newSlidingExpiry; + } + } + } + + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _tagIndex = new(StringComparer.OrdinalIgnoreCase); + private readonly Timer _cleanupTimer; + private bool _disposed; + + /// + /// Default expiration duration applied when an entry specifies none. + /// Defaults to 5 minutes. + /// + public TimeSpan DefaultExpiration { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets the current count of cached query results. + /// + public int Count => _entries.Count; + + /// + /// Initializes a new instance of . + /// + /// How often to scan and remove expired items. Defaults to 1 minute. + public MemoryQueryCache(TimeSpan? cleanupInterval = null) + { + var interval = cleanupInterval ?? TimeSpan.FromMinutes(1); + _cleanupTimer = new Timer(_ => CleanExpired(), null, interval, interval); + } + + /// + public ValueTask?> GetAsync( + string key, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (_entries.TryGetValue(key, out var entry)) + { + var now = DateTimeOffset.UtcNow; + if (entry.IsExpired(now)) + { + RemoveInternal(key); + return ValueTask.FromResult?>(null); + } + + entry.Refresh(now); + if (entry.Result is QueryResult typedResult) + return ValueTask.FromResult?>(typedResult); + } + + return ValueTask.FromResult?>(null); + } + + /// + public ValueTask SetAsync( + string key, + QueryResult result, + QueryCacheOptions options, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(options); + + var now = DateTimeOffset.UtcNow; + var expiration = options.Expiration ?? DefaultExpiration; + DateTimeOffset? absExpiry = expiration > TimeSpan.Zero ? now + expiration : null; + + var entry = new CacheEntry + { + Result = result, + CreatedAt = now, + LastAccessedAt = now, + AbsoluteExpiresAt = absExpiry, + SlidingExpiration = options.SlidingExpiration, + Tags = options.Tags + }; + + _entries[key] = entry; + + // Index tags + if (options.Tags.Count > 0) + { + foreach (var tag in options.Tags) + { + var keysForTag = _tagIndex.GetOrAdd(tag, _ => new ConcurrentDictionary(StringComparer.Ordinal)); + keysForTag[key] = 0; + } + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + RemoveInternal(key); + return ValueTask.CompletedTask; + } + + /// + public ValueTask InvalidateTagAsync(string tag, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tag); + + if (_tagIndex.TryRemove(tag, out var keys)) + { + foreach (var key in keys.Keys) + { + RemoveInternal(key); + } + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask ClearAsync(CancellationToken cancellationToken = default) + { + _entries.Clear(); + _tagIndex.Clear(); + return ValueTask.CompletedTask; + } + + private void RemoveInternal(string key) + { + if (_entries.TryRemove(key, out var entry)) + { + foreach (var tag in entry.Tags) + { + if (_tagIndex.TryGetValue(tag, out var keys)) + keys.TryRemove(key, out _); + } + } + } + + private void CleanExpired() + { + var now = DateTimeOffset.UtcNow; + foreach (var kvp in _entries) + { + if (kvp.Value.IsExpired(now)) + RemoveInternal(kvp.Key); + } + } + + /// + public void Dispose() + { + if (!_disposed) + { + _cleanupTimer.Dispose(); + _entries.Clear(); + _tagIndex.Clear(); + _disposed = true; + } + } +} diff --git a/src/PepperX.QueryForge/Caching/QueryCacheExtensions.cs b/src/PepperX.QueryForge/Caching/QueryCacheExtensions.cs new file mode 100644 index 0000000..2d1a5bf --- /dev/null +++ b/src/PepperX.QueryForge/Caching/QueryCacheExtensions.cs @@ -0,0 +1,58 @@ +namespace PepperX.QueryForge.Caching; + +/// +/// Fluent extension methods for configuring query caching policies. +/// +public static class QueryCacheExtensions +{ + /// + /// Enables caching for the query with the specified expiration, optional custom key, and tags. + /// + public static TQuery WithCache( + this TQuery query, + TimeSpan? expiration = null, + string? key = null, + string[]? tags = null, + TimeSpan? slidingExpiration = null) where TQuery : Query + { + ArgumentNullException.ThrowIfNull(query); + + query.Cache = new QueryCacheOptions + { + Enabled = true, + Expiration = expiration, + Key = key, + Tags = tags ?? Array.Empty(), + SlidingExpiration = slidingExpiration + }; + + return query; + } + + /// + /// Enables caching for the query using the specified options. + /// + public static TQuery WithCacheOptions( + this TQuery query, + QueryCacheOptions options) where TQuery : Query + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(options); + + query.Cache = options; + return query; + } + + /// + /// Disables caching for the query. + /// + public static TQuery WithoutCache(this TQuery query) where TQuery : Query + { + ArgumentNullException.ThrowIfNull(query); + + if (query.Cache is not null) + query.Cache.Enabled = false; + + return query; + } +} diff --git a/src/PepperX.QueryForge/Caching/QueryCacheKeyGenerator.cs b/src/PepperX.QueryForge/Caching/QueryCacheKeyGenerator.cs new file mode 100644 index 0000000..9cba198 --- /dev/null +++ b/src/PepperX.QueryForge/Caching/QueryCacheKeyGenerator.cs @@ -0,0 +1,145 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using PepperX.QueryForge.Querying; + +namespace PepperX.QueryForge.Caching; + +/// +/// Generates deterministic, collision-resistant cache keys for executions. +/// +public static class QueryCacheKeyGenerator +{ + private const string DefaultPrefix = "qf"; + + /// + /// Computes a unique, deterministic cache key for the given query and model type. + /// + public static string GenerateKey( + Query query, + string? targetSource = null, + string? customPrefix = null) + { + ArgumentNullException.ThrowIfNull(query); + + if (!string.IsNullOrWhiteSpace(query.Cache?.Key)) + return query.Cache.Key; + + var prefix = customPrefix ?? query.Cache?.KeyPrefix ?? DefaultPrefix; + var modelName = typeof(TModel).FullName ?? typeof(TModel).Name; + + var builder = new StringBuilder(); + builder.Append("m:").Append(modelName).Append('|'); + + if (!string.IsNullOrWhiteSpace(targetSource)) + builder.Append("src:").Append(targetSource).Append('|'); + + // Criteria + AppendCriteria(builder, query.Criteria); + + // Paging + builder.Append("|p:").Append(query.Paging.Number).Append(':').Append(query.Paging.Size); + + // SelectColumns + if (query.SelectColumns.Count > 0) + { + builder.Append("|sel:"); + for (var i = 0; i < query.SelectColumns.Count; i++) + { + if (i > 0) builder.Append(','); + builder.Append(query.SelectColumns[i]); + } + } + + // SortColumns + if (query.SortColumns.Count > 0) + { + builder.Append("|sort:"); + for (var i = 0; i < query.SortColumns.Count; i++) + { + if (i > 0) builder.Append(','); + builder.Append(query.SortColumns[i].ColumnName) + .Append(':') + .Append((int)query.SortColumns[i].SortOrder); + } + } + + // GroupByColumns + if (query.GroupByColumns.Count > 0) + { + builder.Append("|grp:"); + for (var i = 0; i < query.GroupByColumns.Count; i++) + { + if (i > 0) builder.Append(','); + builder.Append(query.GroupByColumns[i].ColumnName) + .Append(':') + .Append((int)query.GroupByColumns[i].SortOrder); + } + } + + var rawPayload = builder.ToString(); + var hash = ComputeHash(rawPayload); + + return $"{prefix}:{modelName}:{hash}"; + } + + private static void AppendCriteria(StringBuilder builder, QueryCriteria criteria) + { + builder.Append("c:logic=").Append((int)criteria.Logic); + + for (var g = 0; g < criteria.Groups.Count; g++) + { + var group = criteria.Groups[g]; + builder.Append(";g").Append(g).Append(":logic=").Append((int)group.Logic); + + for (var c = 0; c < group.Conditions.Count; c++) + { + var cond = group.Conditions[c]; + builder.Append('[') + .Append(cond.ColumnName) + .Append(':') + .Append((int)cond.Operator) + .Append('=') + .Append(ValueToString(ConditionSemantics.Unwrap(cond.Value))); + + if (cond.ValueTo is not null) + { + builder.Append("..") + .Append(ValueToString(ConditionSemantics.Unwrap(cond.ValueTo))); + } + + builder.Append(']'); + } + } + } + + private static string ValueToString(object? value) + { + if (value is null) return "null"; + if (value is string s) return s; + if (value is bool b) return b ? "true" : "false"; + if (value is IFormattable f) return f.ToString(null, System.Globalization.CultureInfo.InvariantCulture); + if (value is System.Collections.IEnumerable enumerable) + { + var sb = new StringBuilder("["); + var first = true; + foreach (var item in enumerable) + { + if (!first) sb.Append(','); + sb.Append(ValueToString(item)); + first = false; + } + sb.Append(']'); + return sb.ToString(); + } + + return value.ToString() ?? ""; + } + + private static string ComputeHash(string input) + { + var bytes = Encoding.UTF8.GetBytes(input); + var hashBytes = SHA256.HashData(bytes); + return Convert.ToHexString(hashBytes)[..24].ToLowerInvariant(); + } +} diff --git a/src/PepperX.QueryForge/Caching/QueryCacheOptions.cs b/src/PepperX.QueryForge/Caching/QueryCacheOptions.cs new file mode 100644 index 0000000..5ee05ad --- /dev/null +++ b/src/PepperX.QueryForge/Caching/QueryCacheOptions.cs @@ -0,0 +1,52 @@ +namespace PepperX.QueryForge.Caching; + +/// +/// Defines caching behavior and lifetime policies for a query execution. +/// +public sealed class QueryCacheOptions +{ + /// + /// Gets or sets whether caching is enabled for this query. + /// Defaults to . + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets an explicit cache key. If null or empty, a deterministic key is automatically + /// calculated from the query criteria, paging, sorting, grouping, projections, and model type. + /// + public string? Key { get; set; } + + /// + /// Gets or sets an optional prefix prepended to the generated cache key. + /// + public string? KeyPrefix { get; set; } + + /// + /// Gets or sets the absolute expiration duration relative to when the item is cached. + /// If null, a default of 5 minutes is applied. + /// + public TimeSpan? Expiration { get; set; } + + /// + /// Gets or sets the sliding expiration duration. If accessed within this window, the entry's + /// expiration is extended. + /// + public TimeSpan? SlidingExpiration { get; set; } + + /// + /// Gets or sets the cache tags associated with this query entry for group or second-level invalidation + /// (e.g. invalidate all queries tagged with "users" or "reports"). + /// + public IReadOnlyList Tags { get; set; } = Array.Empty(); + + /// + /// Creates a default caching policy with the specified absolute expiration. + /// + public static QueryCacheOptions WithExpiration(TimeSpan expiration, params string[] tags) => new() + { + Enabled = true, + Expiration = expiration, + Tags = tags + }; +} diff --git a/src/PepperX.QueryForge/Caching/QueryForgeCache.cs b/src/PepperX.QueryForge/Caching/QueryForgeCache.cs new file mode 100644 index 0000000..ec5d546 --- /dev/null +++ b/src/PepperX.QueryForge/Caching/QueryForgeCache.cs @@ -0,0 +1,24 @@ +namespace PepperX.QueryForge.Caching; + +/// +/// Provides access to the ambient/default query cache instance across the QueryForge ecosystem. +/// +public static class QueryForgeCache +{ + private static IQueryCache _default = new MemoryQueryCache(); + + /// + /// Gets the ambient default used when no specific cache is provided. + /// Defaults to an in-memory thread-safe . + /// + public static IQueryCache Default => _default; + + /// + /// Configures the default ambient used by QueryForge. + /// + public static void ConfigureDefault(IQueryCache cache) + { + ArgumentNullException.ThrowIfNull(cache); + _default = cache; + } +} diff --git a/src/PepperX.QueryForge/Models/Query.cs b/src/PepperX.QueryForge/Models/Query.cs index 31929e5..fffaf95 100644 --- a/src/PepperX.QueryForge/Models/Query.cs +++ b/src/PepperX.QueryForge/Models/Query.cs @@ -1,4 +1,4 @@ -namespace PepperX.QueryForge; +namespace PepperX.QueryForge; /// /// The master, provider-agnostic query intent object. @@ -22,4 +22,7 @@ public class Query /// The columns to group the results by, creating a nested hierarchy. public IReadOnlyList GroupByColumns { get; set; } = Array.Empty(); + + /// Optional caching policy and configuration for the query result. + public Caching.QueryCacheOptions? Cache { get; set; } } \ No newline at end of file diff --git a/tests/PepperX.QueryForge.Dapper.Tests/Builders/DapperQueryBuilderTests.cs b/tests/PepperX.QueryForge.Dapper.Tests/Builders/DapperQueryBuilderTests.cs index 87edbc9..d923bda 100644 --- a/tests/PepperX.QueryForge.Dapper.Tests/Builders/DapperQueryBuilderTests.cs +++ b/tests/PepperX.QueryForge.Dapper.Tests/Builders/DapperQueryBuilderTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using PepperX.QueryForge; using PepperX.QueryForge.Dapper; using Xunit; @@ -63,4 +63,40 @@ public void FluentChaining_ShouldReturnDapperQueryFluent() fluent.Should().BeOfType(); } + + [Fact] + public void WithCache_ShouldConfigureCacheOptionsOnDapperQuery() + { + var query = DapperQueryBuilder.New() + .ForObject("Users") + .WithCache(TimeSpan.FromMinutes(10), tags: ["users"]) + .Build(); + + query.Cache.Should().NotBeNull(); + query.Cache!.Enabled.Should().BeTrue(); + query.Cache.Expiration.Should().Be(TimeSpan.FromMinutes(10)); + query.Cache.Tags.Should().Contain("users"); + } + + [Fact] + public void FromBase_ShouldPreserveCacheOptions() + { + var baseQuery = QueryBuilder.New() + .Select("Id") + .Build(); + baseQuery.Cache = new PepperX.QueryForge.Caching.QueryCacheOptions + { + Enabled = true, + Expiration = TimeSpan.FromMinutes(5) + }; + + var dapperQuery = DapperQueryBuilder + .FromBase(baseQuery) + .ForObject("Users") + .Build(); + + dapperQuery.Cache.Should().NotBeNull(); + dapperQuery.Cache!.Enabled.Should().BeTrue(); + dapperQuery.Cache.Expiration.Should().Be(TimeSpan.FromMinutes(5)); + } } \ No newline at end of file diff --git a/tests/PepperX.QueryForge.Dapper.Tests/Services/DapperQueryServiceTests.cs b/tests/PepperX.QueryForge.Dapper.Tests/Services/DapperQueryServiceTests.cs index 2549507..cbf9846 100644 --- a/tests/PepperX.QueryForge.Dapper.Tests/Services/DapperQueryServiceTests.cs +++ b/tests/PepperX.QueryForge.Dapper.Tests/Services/DapperQueryServiceTests.cs @@ -40,6 +40,58 @@ public async Task QueryAsync_WithAnUnsupportedConnection_ShouldThrow() await act.Should().ThrowAsync(); } + private sealed record UserDto + { + public int Id { get; init; } + public string Name { get; init; } = ""; + } + + [Fact] + public async Task QueryAsync_WithCacheHit_ReturnsCachedResultWithoutExecutingQuery() + { + var cache = new PepperX.QueryForge.Caching.MemoryQueryCache(); + var options = new DapperQueryForgeOptions { Cache = cache }; + var registry = new DapperRegistry(options); + registry.Register(new PepperX.QueryForge.Dapper.Dialects.SqlServerDialect()); + var service = new DapperQueryService(registry, new FakeServiceProvider()); + + var query = DapperQueryBuilder.ForObject("Users") + .WithCache(TimeSpan.FromMinutes(5)) + .Build(); + + var expectedResult = new QueryResult + { + Models = [new UserDto { Id = 1, Name = "CachedUser" }], + Meta = new QueryResultMeta(new QueryResultMetaTotal(1, 1), QueryResultType.Flat) + }; + + var cacheKey = PepperX.QueryForge.Caching.QueryCacheKeyGenerator.GenerateKey(query, "Users", "dapper"); + await cache.SetAsync(cacheKey, expectedResult, query.Cache!); + + // Using a stub connection named SqlConnection — returns cached result without running query! + var result = await service.QueryAsync(new SqlConnection(), query); + + result.Should().NotBeNull(); + result.Models.Should().HaveCount(1); + result.Models[0].Name.Should().Be("CachedUser"); + } + + private sealed class SqlConnection : IDbConnection + { + [AllowNull] + public string ConnectionString { get; set; } = ""; + public int ConnectionTimeout => 0; + public string Database => ""; + public ConnectionState State => ConnectionState.Open; + public IDbTransaction BeginTransaction() => throw new NotSupportedException(); + public IDbTransaction BeginTransaction(IsolationLevel il) => throw new NotSupportedException(); + public void ChangeDatabase(string databaseName) { } + public void Close() { } + public IDbCommand CreateCommand() => throw new NotSupportedException(); + public void Dispose() { } + public void Open() { } + } + private sealed class FirebirdConnection : IDbConnection { // IDbConnection.ConnectionString allows null on the way in; the stub keeps a non-null value. diff --git a/tests/PepperX.QueryForge.EFCore.Tests/EfCoreProviderTests.cs b/tests/PepperX.QueryForge.EFCore.Tests/EfCoreProviderTests.cs index 3fd4c78..05e21c9 100644 --- a/tests/PepperX.QueryForge.EFCore.Tests/EfCoreProviderTests.cs +++ b/tests/PepperX.QueryForge.EFCore.Tests/EfCoreProviderTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; +using PepperX.QueryForge.Caching; using PepperX.QueryForge.EFCore; namespace PepperX.QueryForge.EFCore.Tests; @@ -441,4 +442,76 @@ public void ApplyFilter_ShouldEmitSqlParametersRatherThanInlinedLiterals() } #endregion + + #region Caching + + [Fact] + public async Task ToQueryResultAsync_WithCacheEnabled_ServesFromCacheOnSecondCall() + { + using var cache = new MemoryQueryCache(); + var query = new Query + { + Criteria = Criteria(Logic.And, new Condition("Country", ConditionOperator.Equals, "Iran")), + Paging = new QueryPaging { Number = 1, Size = 10 } + }.WithCache(TimeSpan.FromMinutes(5)); + + // First execution — queries SQLite DB and populates cache + var result1 = await _context.Users.ToQueryResultAsync(query, cache); + result1.Models.Should().HaveCount(3); + + // Mutate underlying database + _context.Users.Add(new User { Id = 99, Name = "NewIranUser", Country = "Iran", Age = 20, IsActive = true, JoinedOn = DateTime.UtcNow }); + await _context.SaveChangesAsync(); + + // Second execution with cache — should return cached result (count 3, not 4) + var result2 = await _context.Users.ToQueryResultAsync(query, cache); + result2.Models.Should().HaveCount(3); + result2.Models.Should().NotContain(u => u.Id == 99); + } + + [Fact] + public async Task ToQueryResultAsync_WithTagInvalidation_EvictsCacheAndRequeriesDatabase() + { + using var cache = new MemoryQueryCache(); + var query = new Query + { + Criteria = Criteria(Logic.And, new Condition("Country", ConditionOperator.Equals, "Canada")), + Paging = new QueryPaging { Number = 1, Size = 10 } + }.WithCache(TimeSpan.FromMinutes(5), tags: ["users"]); + + // First call + var result1 = await _context.Users.ToQueryResultAsync(query, cache); + result1.Models.Should().HaveCount(2); + + // Add user + _context.Users.Add(new User { Id = 100, Name = "NewCanadaUser", Country = "Canada", Age = 28, IsActive = true, JoinedOn = DateTime.UtcNow }); + await _context.SaveChangesAsync(); + + // Invalidate "users" cache tag + await cache.InvalidateTagAsync("users"); + + // Next call hits database again and sees the new user + var result2 = await _context.Users.ToQueryResultAsync(query, cache); + result2.Models.Should().HaveCount(3); + result2.Models.Should().Contain(u => u.Id == 100); + } + + [Fact] + public async Task ToQueryResultCachedAsync_Helper_EnablesCachingAutomatically() + { + using var cache = new MemoryQueryCache(); + var query = new Query + { + Paging = new QueryPaging { Number = 1, Size = 5 } + }; + + var result = await _context.Users.ToQueryResultCachedAsync(query, TimeSpan.FromMinutes(2), cache); + result.Models.Should().HaveCount(5); + + var key = QueryCacheKeyGenerator.GenerateKey(query, targetSource: nameof(User), customPrefix: "ef"); + var fromCache = await cache.GetAsync(key); + fromCache.Should().NotBeNull(); + } + + #endregion } diff --git a/tests/PepperX.QueryForge.Tests/Caching/QueryCacheTests.cs b/tests/PepperX.QueryForge.Tests/Caching/QueryCacheTests.cs new file mode 100644 index 0000000..a077182 --- /dev/null +++ b/tests/PepperX.QueryForge.Tests/Caching/QueryCacheTests.cs @@ -0,0 +1,192 @@ +using PepperX.QueryForge.Caching; +using Xunit; + +namespace PepperX.QueryForge.Tests.Caching; + +public class QueryCacheTests +{ + private sealed record TestModel(int Id, string Name); + + [Fact] + public async Task MemoryQueryCache_SetAndGet_ReturnsCachedResult() + { + using var cache = new MemoryQueryCache(); + var key = "test:query:1"; + var result = new QueryResult + { + Models = [new TestModel(1, "Alice"), new TestModel(2, "Bob")], + Meta = new QueryResultMeta(new QueryResultMetaTotal(2, 1), QueryResultType.Flat) + }; + + var options = new QueryCacheOptions { Enabled = true, Expiration = TimeSpan.FromMinutes(10) }; + await cache.SetAsync(key, result, options); + + var cached = await cache.GetAsync(key); + + Assert.NotNull(cached); + Assert.Equal(2, cached.Models.Count); + Assert.Equal("Alice", cached.Models[0].Name); + } + + [Fact] + public async Task MemoryQueryCache_Expiration_EvictsEntry() + { + using var cache = new MemoryQueryCache(); + var key = "test:query:expired"; + var result = new QueryResult + { + Models = [new TestModel(1, "Alice")], + Meta = new QueryResultMeta(new QueryResultMetaTotal(1, 1), QueryResultType.Flat) + }; + + // Expire immediately + var options = new QueryCacheOptions { Enabled = true, Expiration = TimeSpan.FromMilliseconds(1) }; + await cache.SetAsync(key, result, options); + + await Task.Delay(20); + + var cached = await cache.GetAsync(key); + Assert.Null(cached); + } + + [Fact] + public async Task MemoryQueryCache_InvalidateTag_EvictsTaggedEntries() + { + using var cache = new MemoryQueryCache(); + var key1 = "test:query:users:1"; + var key2 = "test:query:users:2"; + var key3 = "test:query:orders:1"; + + var dummy = new QueryResult { Models = [new TestModel(1, "Test")] }; + + await cache.SetAsync(key1, dummy, new QueryCacheOptions { Tags = ["users", "admin"] }); + await cache.SetAsync(key2, dummy, new QueryCacheOptions { Tags = ["users"] }); + await cache.SetAsync(key3, dummy, new QueryCacheOptions { Tags = ["orders"] }); + + // Invalidate "users" tag + await cache.InvalidateTagAsync("users"); + + Assert.Null(await cache.GetAsync(key1)); + Assert.Null(await cache.GetAsync(key2)); + Assert.NotNull(await cache.GetAsync(key3)); + } + + [Fact] + public async Task MemoryQueryCache_Remove_RemovesEntry() + { + using var cache = new MemoryQueryCache(); + var key = "test:query:remove"; + var dummy = new QueryResult { Models = [new TestModel(1, "Test")] }; + + await cache.SetAsync(key, dummy, new QueryCacheOptions()); + Assert.NotNull(await cache.GetAsync(key)); + + await cache.RemoveAsync(key); + Assert.Null(await cache.GetAsync(key)); + } + + [Fact] + public void QueryCacheKeyGenerator_IdenticalQueries_ProduceIdenticalKeys() + { + var q1 = new Query + { + Criteria = new QueryCriteria + { + Groups = + [ + new ConditionGroup + { + Conditions = [new Condition("Name", ConditionOperator.Equals, "Test")] + } + ] + }, + Paging = new QueryPaging { Number = 1, Size = 20 }, + SortColumns = [new SortDescriptor("Id", SortOrder.Ascending)] + }; + + var q2 = new Query + { + Criteria = new QueryCriteria + { + Groups = + [ + new ConditionGroup + { + Conditions = [new Condition("Name", ConditionOperator.Equals, "Test")] + } + ] + }, + Paging = new QueryPaging { Number = 1, Size = 20 }, + SortColumns = [new SortDescriptor("Id", SortOrder.Ascending)] + }; + + var key1 = QueryCacheKeyGenerator.GenerateKey(q1); + var key2 = QueryCacheKeyGenerator.GenerateKey(q2); + + Assert.Equal(key1, key2); + } + + [Fact] + public void QueryCacheKeyGenerator_DifferentCriteria_ProduceDifferentKeys() + { + var q1 = new Query + { + Criteria = new QueryCriteria + { + Groups = [new ConditionGroup { Conditions = [new Condition("Name", ConditionOperator.Equals, "A")] }] + } + }; + + var q2 = new Query + { + Criteria = new QueryCriteria + { + Groups = [new ConditionGroup { Conditions = [new Condition("Name", ConditionOperator.Equals, "B")] }] + } + }; + + var key1 = QueryCacheKeyGenerator.GenerateKey(q1); + var key2 = QueryCacheKeyGenerator.GenerateKey(q2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void QueryCacheKeyGenerator_DifferentPaging_ProduceDifferentKeys() + { + var q1 = new Query { Paging = new QueryPaging { Number = 1, Size = 10 } }; + var q2 = new Query { Paging = new QueryPaging { Number = 2, Size = 10 } }; + + var key1 = QueryCacheKeyGenerator.GenerateKey(q1); + var key2 = QueryCacheKeyGenerator.GenerateKey(q2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void QueryCacheKeyGenerator_ExplicitKey_IsHonored() + { + var q = new Query + { + Cache = new QueryCacheOptions { Key = "my-custom-key" } + }; + + var key = QueryCacheKeyGenerator.GenerateKey(q); + Assert.Equal("my-custom-key", key); + } + + [Fact] + public void Query_WithCache_FluentExtension_SetsOptionsCorrectly() + { + var q = new Query().WithCache(TimeSpan.FromMinutes(15), tags: ["users", "test"]); + + Assert.NotNull(q.Cache); + Assert.True(q.Cache.Enabled); + Assert.Equal(TimeSpan.FromMinutes(15), q.Cache.Expiration); + Assert.Contains("users", q.Cache.Tags); + Assert.Contains("test", q.Cache.Tags); + + q.WithoutCache(); + Assert.False(q.Cache.Enabled); + } +}