Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions samples/PepperX.QueryForge.Sample.WebApi/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -124,6 +125,19 @@ 4. The `usp_GetUserReport` Stored Procedure.
.WithSummary("Fetch page 5 of a large dataset.")
.Accepts<Query>("application/json").Produces<QueryResult<TestUser>>();

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<TestUser>(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<Query>("application/json").Produces<QueryResult<TestUser>>();

// ==========================================
// GROUP 2: ADVANCED FILTERING
// ==========================================
Expand Down Expand Up @@ -319,6 +333,16 @@ await db.Users.AsNoTracking().Where(u => u.IsActive).ToQueryResultAsync<TestUser
.WithDescription("Useful for seeing that values become parameters rather than inlined literals.")
.Accepts<Query>("application/json").Produces<string>();

efApi.MapPost("/cached", async (Query q, SampleDbContext db) =>
{
q.WithCache(q.Cache?.Expiration ?? TimeSpan.FromMinutes(2), tags: ["users"]);
return await db.Users.AsNoTracking().ToQueryResultAsync<TestUser>(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<Query>("application/json").Produces<QueryResult<TestUser>>();

// ==========================================
// GROUP 7: IN-MEMORY PROVIDER
// ==========================================
Expand Down Expand Up @@ -363,6 +387,27 @@ await db.Users.AsNoTracking().Where(u => u.IsActive).ToQueryResultAsync<TestUser
.Produces<QueryResult<TestUser>>(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();
}
}
Expand Down
60 changes: 59 additions & 1 deletion samples/PepperX.QueryForge.Sample.WebApi/Requests.http
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

36 changes: 33 additions & 3 deletions src/PepperX.QueryForge.Dapper/Builders/DapperQueryBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using PepperX.QueryForge.Dapper;
using PepperX.QueryForge.Dapper;

/// <summary>
/// A static entry point for constructing Dapper <see cref="DapperQuery"/> instances.
Expand All @@ -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.
/// </summary>
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 });

/// <summary>Starts a new chain by specifying the target SQL object.</summary>
public static DapperQueryFluent ForObject(string name, string schema = "", DapperObjectType type = DapperObjectType.Auto, IReadOnlyDictionary<string, object?>? parameters = null)
Expand Down Expand Up @@ -76,6 +76,35 @@ public DapperQueryFluent ForObject(string name, string schema = "", DapperObject
/// <inheritdoc/>
public new DapperQueryFluent Page(int size, int number = 1) { base.Page(size, number); return this; }

/// <summary>Enables caching on the query with the specified expiration, optional custom key, and tags.</summary>
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<string>(),
SlidingExpiration = slidingExpiration
};
return this;
}

/// <summary>Enables caching on the query with the specified cache options.</summary>
public DapperQueryFluent WithCacheOptions(PepperX.QueryForge.Caching.QueryCacheOptions options)
{
_dapperQuery.Cache = options;
return this;
}

/// <summary>Disables caching on the query.</summary>
public DapperQueryFluent WithoutCache()
{
if (_dapperQuery.Cache is not null)
_dapperQuery.Cache.Enabled = false;
return this;
}

/// <summary>
/// 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.
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ public class DapperQueryForgeOptions
/// connection. Not required when every call supplies its own <see cref="IDbConnection"/>.
/// </summary>
public Func<IServiceProvider, IDbConnection>? ConnectionFactory { get; set; }

/// <summary>
/// The query cache instance used for storing query results.
/// If null, <see cref="PepperX.QueryForge.Caching.QueryForgeCache.Default"/> is used.
/// </summary>
public PepperX.QueryForge.Caching.IQueryCache? Cache { get; set; }
}
2 changes: 1 addition & 1 deletion src/PepperX.QueryForge.Dapper/Internals/DapperRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>Resolves the executor for an explicitly named engine.</summary>
Expand Down
31 changes: 30 additions & 1 deletion src/PepperX.QueryForge.Dapper/Internals/QueryExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Data;
using Dapper;
using PepperX.QueryForge.Caching;
using PepperX.QueryForge.Dapper.Compiler;
using PepperX.QueryForge.Querying;

Expand All @@ -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.
/// </remarks>
internal sealed class QueryExecutor(SqlQueryCompiler compiler, SchemaCache schemaCache)
internal sealed class QueryExecutor(SqlQueryCompiler compiler, SchemaCache schemaCache, DapperQueryForgeOptions? options = null)
{
public async Task<QueryResult<TModel>> QueryAsync<TModel>(
IDbConnection connection,
Expand All @@ -24,6 +25,34 @@ public async Task<QueryResult<TModel>> QueryAsync<TModel>(
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<TModel>(
query,
targetSource: query.Object?.Name,
customPrefix: "dapper");

var cached = await cache.GetAsync<TModel>(cacheKey);
if (cached is not null)
return cached;

var executed = await ExecuteCoreAsync<TModel>(connection, query, commandTimeout, transaction);
await cache.SetAsync(cacheKey, executed, cacheOptions);
return executed;
}

return await ExecuteCoreAsync<TModel>(connection, query, commandTimeout, transaction);
}

private async Task<QueryResult<TModel>> ExecuteCoreAsync<TModel>(
IDbConnection connection,
DapperQuery query,
int? commandTimeout,
IDbTransaction? transaction)
{
if (query.Object?.Type == DapperObjectType.SP)
return await QueryStoredProcedureAsync<TModel>(connection, query, commandTimeout, transaction);

Expand Down
62 changes: 61 additions & 1 deletion src/PepperX.QueryForge.EFCore/QueryForgeQueryableExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -171,22 +172,81 @@ private static bool HasIncludes(Expression expression)
/// <summary>Applies filtering, sorting and paging, leaving the query unexecuted.</summary>
/// <remarks>
/// Grouping is not applied here, because a hierarchy is a shape rather than a queryable. Use
/// <see cref="ToQueryResultAsync{TModel}"/> for grouped queries.
/// <see cref="ToQueryResultAsync{TModel}(IQueryable{TModel}, Query, IQueryCache?, CancellationToken)"/> for grouped queries.
/// </remarks>
public static IQueryable<TModel> ApplyQuery<TModel>(this IQueryable<TModel> source, Query query)
=> source.ApplyFilter(query).ApplySort(query).ApplyPaging(query).ApplyProjection(query);

/// <summary>
/// Executes the query and returns the standard QueryForge result, flat or grouped.
/// Supports second-level query caching when <see cref="Query.Cache"/> is configured or when a cache is provided.
/// </summary>
public static async Task<QueryResult<TModel>> ToQueryResultAsync<TModel>(
this IQueryable<TModel> 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<TModel>(
query,
targetSource: typeof(TModel).Name,
customPrefix: "ef");

var cached = await cacheInstance.GetAsync<TModel>(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);
}

/// <summary>
/// Executes the query with the specified caching options.
/// </summary>
public static Task<QueryResult<TModel>> ToQueryResultAsync<TModel>(
this IQueryable<TModel> source,
Query query,
QueryCacheOptions cacheOptions,
IQueryCache? cache = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
query.Cache = cacheOptions;
return source.ToQueryResultAsync(query, cache, cancellationToken);
}

/// <summary>
/// Executes the query with caching enabled for the specified expiration duration.
/// </summary>
public static Task<QueryResult<TModel>> ToQueryResultCachedAsync<TModel>(
this IQueryable<TModel> 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<QueryResult<TModel>> ExecuteCoreAsync<TModel>(
IQueryable<TModel> 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
Expand Down
Loading
Loading