Skip to content
Merged
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
43 changes: 43 additions & 0 deletions src/Core/Resolvers/SqlQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,11 +353,54 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta
// </summary>
private async Task<JsonDocument?> ExecuteAsync(SqlExecuteStructure structure, string dataSourceName)
{
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
DatabaseType databaseType = _runtimeConfigProvider.GetConfig().GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
IQueryBuilder queryBuilder = _queryFactory.GetQueryBuilder(databaseType);
IQueryExecutor queryExecutor = _queryFactory.GetQueryExecutor(databaseType);
string queryString = queryBuilder.Build(structure);

// Only proceed to use caching code when
// RuntimeConfig.Cache.Enabled is true
// RuntimeConfig.DataSource.Options.SetSessionContext is false
if (runtimeConfig.CanUseCache())
{
// Entity level cache behavior checks
bool entityCacheEnabled = runtimeConfig.Entities[structure.EntityName].IsCachingEnabled;

// Stored procedures do not support nor honor runtime config defined
// authorization policies. Here, DAB only checks that the entity has
// caching enabled and doesn't check for database policies. This explicitly
// differs from how the cache works for non-stored procedure requests.
if (entityCacheEnabled)
{
DatabaseQueryMetadata queryMetadata = new(
queryText: queryString,
Comment thread
seantleonard marked this conversation as resolved.
dataSource: dataSourceName,
queryParameters: structure.Parameters);

JsonArray? result = await _cache.GetOrSetAsync<JsonArray?>(
async () => await queryExecutor.ExecuteQueryAsync(
sqltext: queryString,
parameters: structure.Parameters,
dataReaderHandler: queryExecutor.GetJsonArrayAsync,
httpContext: _httpContextAccessor.HttpContext!,
args: null,
dataSourceName: dataSourceName),
queryMetadata,
runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName));

JsonDocument? cacheServiceResponse = null;

if (result is not null)
{
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(result);
cacheServiceResponse = JsonDocument.Parse(jsonBytes);
}

return cacheServiceResponse;
}
}

JsonArray? resultArray =
await queryExecutor.ExecuteQueryAsync(
sqltext: queryString,
Expand Down
10 changes: 8 additions & 2 deletions src/Core/Services/Cache/DabCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ public DabCacheService(IFusionCache cache, ILogger<DabCacheService>? logger, IHt
/// <param name="cacheEntryTtl">Number of seconds the cache entry should be valid before eviction.</param>
/// <returns>JSON Response</returns>
/// <exception cref="Exception">Throws when the cache-miss factory method execution fails.</exception>
public async ValueTask<JsonElement?> GetOrSetAsync<JsonElement>(IQueryExecutor queryExecutor, DatabaseQueryMetadata queryMetadata, int cacheEntryTtl)
public async ValueTask<JsonElement?> GetOrSetAsync<JsonElement>(
IQueryExecutor queryExecutor,
DatabaseQueryMetadata queryMetadata,
int cacheEntryTtl)
{
string cacheKey = CreateCacheKey(queryMetadata);
JsonElement? result = await _cache.GetOrSetAsync(
Expand Down Expand Up @@ -87,7 +90,10 @@ public DabCacheService(IFusionCache cache, ILogger<DabCacheService>? logger, IHt
/// <param name="cacheEntryTtl">Number of seconds the cache entry should be valid before eviction.</param>
/// <returns>JSON Response</returns>
/// <exception cref="Exception">Throws when the cache-miss factory method execution fails.</exception>
public async ValueTask<TResult?> GetOrSetAsync<TResult>(Func<Task<TResult>> executeQueryAsync, DatabaseQueryMetadata queryMetadata, int cacheEntryTtl)
public async ValueTask<TResult?> GetOrSetAsync<TResult>(
Func<Task<TResult>> executeQueryAsync,
DatabaseQueryMetadata queryMetadata,
int cacheEntryTtl)
{

string cacheKey = CreateCacheKey(queryMetadata);
Expand Down
139 changes: 111 additions & 28 deletions src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Data.Common;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Core.Models;
Expand Down Expand Up @@ -61,8 +62,8 @@ public async Task FirstCacheServiceInvocationCallsFactory()
DabCacheService dabCache = CreateDabCacheService(cache);

// Act
int cacheEntryTtl = 1;
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
int cacheEntryTtlInSeconds = 1;
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.AreEqual(expected: true, actual: mockQueryExecutor.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS);
Expand Down Expand Up @@ -98,11 +99,11 @@ public async Task SecondCacheServiceInvocation_CacheHit_NoSecondFactoryCall()
DabCacheService dabCache = CreateDabCacheService(cache);

// Prime the cache with a single entry
int cacheEntryTtl = 1;
_ = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
int cacheEntryTtlInSeconds = 1;
_ = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Act
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.IsFalse(mockQueryExecutor.Invocations.Count is 2, message: "Expected a cache hit, but observed two cache misses.");
Expand Down Expand Up @@ -133,15 +134,15 @@ public async Task ThirdCacheServiceInvocation_CacheHit_NoSecondFactoryCall()
DabCacheService dabCache = CreateDabCacheService(cache);

// Prime the cache with a single entry
int cacheEntryTtl = 1;
_ = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
_ = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
int cacheEntryTtlInSeconds = 1;
_ = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);
_ = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Sleep for the amount of time the cache entry is valid to trigger eviction.
Thread.Sleep(millisecondsTimeout: cacheEntryTtl * 1000);
Thread.Sleep(millisecondsTimeout: cacheEntryTtlInSeconds * 1000);

// Act
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.IsFalse(mockQueryExecutor.Invocations.Count is 1, message: "QueryExecutor invocation count too low. A cache hit shouldn't have occurred since the entry should have expired.");
Expand Down Expand Up @@ -173,11 +174,11 @@ public async Task LargeCacheKey_BadBehavior()
DabCacheService dabCache = CreateDabCacheService(cache);

// Prime the cache.
int cacheEntryTtl = 1;
_ = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
int cacheEntryTtlInSeconds = 1;
_ = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Act
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.IsFalse(mockQueryExecutor.Invocations.Count is 1, message: "Unexpected cache hit when cache entry size exceeded cache capacity.");
Expand Down Expand Up @@ -207,8 +208,8 @@ public async Task CacheServiceFactoryInvocationReturnsNull()
DabCacheService dabCache = CreateDabCacheService(cache);

// Act
int cacheEntryTtl = 1;
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
int cacheEntryTtlInSeconds = 1;
JsonElement? result = await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.AreEqual(expected: true, actual: mockQueryExecutor.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS);
Expand Down Expand Up @@ -246,12 +247,95 @@ public async Task CacheServiceFactoryInvocationThrowsException()
DabCacheService dabCache = CreateDabCacheService(cache);

// Act and Assert
int cacheEntryTtl = 1;
int cacheEntryTtlInSeconds = 1;
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(
async () => await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl),
async () => await dabCache.GetOrSetAsync<JsonElement?>(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds),
message: "Expected an exception to be thrown.");
}

/// <summary>
/// Tests DAB's cache service invocation when the type is JsonArray.
/// JsonArray aligns with the type used for executing stored procedures against
/// MSSQL databases.
/// This test validates that the cache service returns the expected database response
/// because the cache is empty and the factory method is expected to be called.
/// </summary>
[TestMethod]
public async Task JsonArray_CacheServiceInvocation_CacheEmpty_ReturnsFactoryResult()
{
// Arrange
using FusionCache cache = CreateFusionCache(sizeLimit: 1000, defaultEntryTtlSeconds: 1);
JsonArray? expectedDatabaseResponse = new()
{
JsonNode.Parse(@"{""key"": ""value""}"),
JsonNode.Parse(@"{""key"": ""value2""}")
};

Mock<Func<Task<JsonArray>>> mockExecuteQuery = new();
mockExecuteQuery.Setup(e => e.Invoke()).Returns(Task.FromResult(expectedDatabaseResponse));

Dictionary<string, DbConnectionParam> parameters = new()
{
{"param1", new DbConnectionParam(value: "param1Value") }
};

DatabaseQueryMetadata queryMetadata = new(queryText: "select c.name from c", dataSource: "dataSource1", queryParameters: parameters);
DabCacheService dabCache = CreateDabCacheService(cache);

// Act
int cacheEntryTtlInSeconds = 1;
JsonArray? result = await dabCache.GetOrSetAsync<JsonArray>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.AreEqual(expected: true, actual: mockExecuteQuery.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS);

// Validates that the expected database response is returned by the cache service.
Assert.AreEqual(expected: expectedDatabaseResponse, actual: result, message: ERROR_UNEXPECTED_RESULT);
}

/// <summary>
/// Tests DAB's cache service invocation when the type is JsonArray.
/// JsonArray aligns with the type used for executing stored procedures against
/// MSSQL databases.
/// This test validates that a cache hit occurs when the same request
/// is submitted before the cache entry expires. Validates that
/// DabCacheService.CreateCacheKey(..) outputs the same key given constant input.
/// </summary>
[TestMethod]
public async Task JsonArray_CacheServiceInvocation_CacheHit_NoFactoryInvocation()
{
// Arrange
using FusionCache cache = CreateFusionCache(sizeLimit: 1000, defaultEntryTtlSeconds: 1);
JsonArray? expectedDatabaseResponse = new()
{
JsonNode.Parse(@"{""key"": ""value""}"),
JsonNode.Parse(@"{""key"": ""value2""}")
};

Mock<Func<Task<JsonArray>>> mockExecuteQuery = new();
mockExecuteQuery.Setup(e => e.Invoke()).Returns(Task.FromResult(expectedDatabaseResponse));

Dictionary<string, DbConnectionParam> parameters = new()
{
{"param1", new DbConnectionParam(value: "param1Value") }
};

DatabaseQueryMetadata queryMetadata = new(queryText: "select c.name from c", dataSource: "dataSource1", queryParameters: parameters);
DabCacheService dabCache = CreateDabCacheService(cache);

int cacheEntryTtlInSeconds = 1;
// First call. Cache miss
_ = await dabCache.GetOrSetAsync<JsonArray>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Act
JsonArray? result = await dabCache.GetOrSetAsync<JsonArray>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.IsFalse(mockExecuteQuery.Invocations.Count > 1, message: "Expected a cache hit, but observed cache misses.");
Assert.AreEqual(expected: true, actual: mockExecuteQuery.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS);
Comment thread
seantleonard marked this conversation as resolved.
Assert.AreEqual(expected: expectedDatabaseResponse, actual: result, message: ERROR_UNEXPECTED_RESULT);
}

/// <summary>
/// Validates that the first invocation of the cache service results in a cache miss because
/// the cache is expected to be empty.
Expand All @@ -277,8 +361,8 @@ public async Task FirstCacheServiceInvocationCallsFuncAndReturnResult()
DabCacheService dabCache = CreateDabCacheService(cache);

// Act
int cacheEntryTtl = 1;
JObject? result = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
int cacheEntryTtlInSeconds = 1;
JObject? result = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.AreEqual(expected: true, actual: mockExecuteQuery.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS);
Expand Down Expand Up @@ -309,15 +393,14 @@ public async Task SecondCacheServiceInvocation_CacheHit_NoFuncInvocation()
DatabaseQueryMetadata queryMetadata = new(queryText: "select c.name from c", dataSource: "dataSource1", queryParameters: parameters);
DabCacheService dabCache = CreateDabCacheService(cache);

int cacheEntryTtl = 1;
int cacheEntryTtlInSeconds = 1;
// First call. Cache miss
_ = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
_ = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Act
JObject? result = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
JObject? result = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.AreEqual(expected: true, actual: mockExecuteQuery.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS);
Assert.IsFalse(mockExecuteQuery.Invocations.Count > 1, message: "Expected a cache hit, but observed cache misses.");
Assert.AreEqual(expected: true, actual: mockExecuteQuery.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS);
Assert.AreEqual(expected: expectedDatabaseResponse, actual: result, message: ERROR_UNEXPECTED_RESULT);
Expand Down Expand Up @@ -347,17 +430,17 @@ public async Task ThirdCacheServiceInvocation_CacheHit_NoFuncInvocation()
DatabaseQueryMetadata queryMetadata = new(queryText: "select c.name from c", dataSource: "dataSource1", queryParameters: parameters);
DabCacheService dabCache = CreateDabCacheService(cache);

int cacheEntryTtl = 1;
int cacheEntryTtlInSeconds = 1;

// First call. Cache miss
_ = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
_ = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
_ = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);
_ = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Sleep for the amount of time the cache entry is valid to trigger eviction.
Thread.Sleep(millisecondsTimeout: cacheEntryTtl * 1000);
Thread.Sleep(millisecondsTimeout: cacheEntryTtlInSeconds * 1000);

// Act
JObject? result = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl);
JObject? result = await dabCache.GetOrSetAsync<JObject>(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds);

// Assert
Assert.IsFalse(mockExecuteQuery.Invocations.Count < 2, message: "QueryExecutor invocation count too low. A cache hit shouldn't have occurred since the entry should have expired.");
Expand Down