diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs index 853d52711b..00f313cd1d 100644 --- a/src/Core/Resolvers/SqlQueryEngine.cs +++ b/src/Core/Resolvers/SqlQueryEngine.cs @@ -353,11 +353,54 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta // private async Task 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, + dataSource: dataSourceName, + queryParameters: structure.Parameters); + + JsonArray? result = await _cache.GetOrSetAsync( + 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, diff --git a/src/Core/Services/Cache/DabCacheService.cs b/src/Core/Services/Cache/DabCacheService.cs index 3499a36612..20ab2905d4 100644 --- a/src/Core/Services/Cache/DabCacheService.cs +++ b/src/Core/Services/Cache/DabCacheService.cs @@ -53,7 +53,10 @@ public DabCacheService(IFusionCache cache, ILogger? logger, IHt /// Number of seconds the cache entry should be valid before eviction. /// JSON Response /// Throws when the cache-miss factory method execution fails. - public async ValueTask GetOrSetAsync(IQueryExecutor queryExecutor, DatabaseQueryMetadata queryMetadata, int cacheEntryTtl) + public async ValueTask GetOrSetAsync( + IQueryExecutor queryExecutor, + DatabaseQueryMetadata queryMetadata, + int cacheEntryTtl) { string cacheKey = CreateCacheKey(queryMetadata); JsonElement? result = await _cache.GetOrSetAsync( @@ -87,7 +90,10 @@ public DabCacheService(IFusionCache cache, ILogger? logger, IHt /// Number of seconds the cache entry should be valid before eviction. /// JSON Response /// Throws when the cache-miss factory method execution fails. - public async ValueTask GetOrSetAsync(Func> executeQueryAsync, DatabaseQueryMetadata queryMetadata, int cacheEntryTtl) + public async ValueTask GetOrSetAsync( + Func> executeQueryAsync, + DatabaseQueryMetadata queryMetadata, + int cacheEntryTtl) { string cacheKey = CreateCacheKey(queryMetadata); diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs index 785c8a572b..1185726764 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -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; @@ -61,8 +62,8 @@ public async Task FirstCacheServiceInvocationCallsFactory() DabCacheService dabCache = CreateDabCacheService(cache); // Act - int cacheEntryTtl = 1; - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + int cacheEntryTtlInSeconds = 1; + JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); // Assert Assert.AreEqual(expected: true, actual: mockQueryExecutor.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS); @@ -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(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + int cacheEntryTtlInSeconds = 1; + _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); // Act - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + JsonElement? result = await dabCache.GetOrSetAsync(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."); @@ -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(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); - _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + int cacheEntryTtlInSeconds = 1; + _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + _ = await dabCache.GetOrSetAsync(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(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + JsonElement? result = await dabCache.GetOrSetAsync(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."); @@ -173,11 +174,11 @@ public async Task LargeCacheKey_BadBehavior() DabCacheService dabCache = CreateDabCacheService(cache); // Prime the cache. - int cacheEntryTtl = 1; - _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + int cacheEntryTtlInSeconds = 1; + _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); // Act - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + JsonElement? result = await dabCache.GetOrSetAsync(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."); @@ -207,8 +208,8 @@ public async Task CacheServiceFactoryInvocationReturnsNull() DabCacheService dabCache = CreateDabCacheService(cache); // Act - int cacheEntryTtl = 1; - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + int cacheEntryTtlInSeconds = 1; + JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); // Assert Assert.AreEqual(expected: true, actual: mockQueryExecutor.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS); @@ -246,12 +247,95 @@ public async Task CacheServiceFactoryInvocationThrowsException() DabCacheService dabCache = CreateDabCacheService(cache); // Act and Assert - int cacheEntryTtl = 1; + int cacheEntryTtlInSeconds = 1; await Assert.ThrowsExceptionAsync( - async () => await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl), + async () => await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds), message: "Expected an exception to be thrown."); } + /// + /// 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. + /// + [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>> mockExecuteQuery = new(); + mockExecuteQuery.Setup(e => e.Invoke()).Returns(Task.FromResult(expectedDatabaseResponse)); + + Dictionary 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(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); + } + + /// + /// 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. + /// + [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>> mockExecuteQuery = new(); + mockExecuteQuery.Setup(e => e.Invoke()).Returns(Task.FromResult(expectedDatabaseResponse)); + + Dictionary 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(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + + // Act + JsonArray? result = await dabCache.GetOrSetAsync(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); + Assert.AreEqual(expected: expectedDatabaseResponse, actual: result, message: ERROR_UNEXPECTED_RESULT); + } + /// /// Validates that the first invocation of the cache service results in a cache miss because /// the cache is expected to be empty. @@ -277,8 +361,8 @@ public async Task FirstCacheServiceInvocationCallsFuncAndReturnResult() DabCacheService dabCache = CreateDabCacheService(cache); // Act - int cacheEntryTtl = 1; - JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + int cacheEntryTtlInSeconds = 1; + JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); // Assert Assert.AreEqual(expected: true, actual: mockExecuteQuery.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS); @@ -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(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); // Act - JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + JObject? result = await dabCache.GetOrSetAsync(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); @@ -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(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); - _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + _ = await dabCache.GetOrSetAsync(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(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtl); + JObject? result = await dabCache.GetOrSetAsync(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.");