diff --git a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs
index 641efd062f..1f4c8ff2dd 100644
--- a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs
+++ b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs
@@ -55,7 +55,9 @@ public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? r
{
if (reader.TokenType is JsonTokenType.StartObject)
{
- bool? enabled = false;
+ // Default to null (unset) so that an empty cache object ("cache": {})
+ // is treated as "not explicitly configured" and inherits from the runtime setting.
+ bool? enabled = null;
// Defer to EntityCacheOptions record definition to define default ttl value.
int? ttlSeconds = null;
diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs
index a947cd6d99..5a748cad5f 100644
--- a/src/Config/ObjectModel/EntityCacheOptions.cs
+++ b/src/Config/ObjectModel/EntityCacheOptions.cs
@@ -30,26 +30,29 @@ public record EntityCacheOptions
///
/// Whether the cache should be used for the entity.
+ /// When null, indicates the user did not explicitly set this property, and the entity
+ /// should inherit the runtime-level cache enabled setting.
+ /// Using Enabled.HasValue (rather than a separate UserProvided flag) ensures correct
+ /// behavior regardless of whether the object was created via JsonConstructor or with-expression.
///
[JsonPropertyName("enabled")]
- public bool? Enabled { get; init; } = false;
+ public bool? Enabled { get; init; }
///
/// The number of seconds a cache entry is valid before eligible for cache eviction.
///
[JsonPropertyName("ttl-seconds")]
- public int? TtlSeconds { get; init; } = null;
+ public int? TtlSeconds { get; init; }
///
/// The cache levels to use for a cache entry.
///
[JsonPropertyName("level")]
- public EntityCacheLevel? Level { get; init; } = null;
+ public EntityCacheLevel? Level { get; init; }
[JsonConstructor]
public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCacheLevel? Level = null)
{
- // TODO: shouldn't we apply the same "UserProvidedXyz" logic to Enabled, too?
this.Enabled = Enabled;
if (TtlSeconds is not null)
diff --git a/src/Config/ObjectModel/RuntimeCacheOptions.cs b/src/Config/ObjectModel/RuntimeCacheOptions.cs
index b507ba6fb3..b744769db4 100644
--- a/src/Config/ObjectModel/RuntimeCacheOptions.cs
+++ b/src/Config/ObjectModel/RuntimeCacheOptions.cs
@@ -65,4 +65,12 @@ public RuntimeCacheOptions(bool? Enabled = null, int? TtlSeconds = null)
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
[MemberNotNullWhen(true, nameof(TtlSeconds))]
public bool UserProvidedTtlOptions { get; init; } = false;
+
+ ///
+ /// Infers the cache level from the Level2 configuration.
+ /// If Level2 is enabled, the cache level is L1L2, otherwise L1.
+ ///
+ [JsonIgnore]
+ public EntityCacheLevel InferredLevel =>
+ Level2?.Enabled is true ? EntityCacheLevel.L1L2 : EntityCacheLevel.L1;
}
diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs
index 0684040f85..217a91eeda 100644
--- a/src/Config/ObjectModel/RuntimeConfig.cs
+++ b/src/Config/ObjectModel/RuntimeConfig.cs
@@ -565,7 +565,8 @@ public virtual int GetEntityCacheEntryTtl(string entityName)
///
/// Returns the cache level value for a given entity.
- /// If the property is not set, returns the default (L1L2) for a given entity.
+ /// If the entity explicitly sets level, that value is used.
+ /// Otherwise, the level is inferred from the runtime cache Level2 configuration.
///
/// Name of the entity to check cache configuration.
/// Cache level that a cache entry should be stored in.
@@ -592,22 +593,39 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName)
{
return entityConfig.Cache.Level.Value;
}
- else
- {
- return EntityCacheLevel.L1L2;
- }
+
+ return GlobalCacheEntryLevel();
}
///
- /// Whether the caching service should be used for a given operation. This is determined by
- /// - whether caching is enabled globally
- /// - whether the datasource is SQL and session context is disabled.
+ /// Determines whether caching is enabled for a given entity, taking into account
+ /// inheritance from the runtime cache settings.
+ /// If the entity explicitly sets Enabled (Enabled.HasValue is true), that value is used.
+ /// If the entity does not set Enabled (Enabled is null), the runtime cache enabled value is inherited.
+ /// Using Enabled.HasValue instead of a separate UserProvided flag ensures correctness
+ /// regardless of whether the object was created via JsonConstructor or with-expression.
///
- /// Whether cache operations should proceed.
- public virtual bool CanUseCache()
+ /// Name of the entity to check cache configuration.
+ /// Whether caching is enabled for the entity.
+ /// Raised when an invalid entity name is provided.
+ public virtual bool IsEntityCachingEnabled(string entityName)
{
- bool setSessionContextEnabled = DataSource.GetTypedOptions()?.SetSessionContext ?? true;
- return IsCachingEnabled && !setSessionContextEnabled;
+ if (!Entities.TryGetValue(entityName, out Entity? entityConfig))
+ {
+ throw new DataApiBuilderException(
+ message: $"{entityName} is not a valid entity.",
+ statusCode: HttpStatusCode.BadRequest,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
+ }
+
+ // If the entity explicitly set Enabled, use that value.
+ if (entityConfig.Cache is not null && entityConfig.Cache.Enabled.HasValue)
+ {
+ return entityConfig.Cache.Enabled.Value;
+ }
+
+ // Otherwise, inherit from the runtime cache enabled setting.
+ return Runtime?.Cache?.Enabled is true;
}
///
@@ -615,13 +633,39 @@ public virtual bool CanUseCache()
/// If no value is explicitly set, returns the global default value.
///
/// Number of seconds a cache entry should be valid before cache eviction.
- public int GlobalCacheEntryTtl()
+ public virtual int GlobalCacheEntryTtl()
{
return Runtime is not null && Runtime.IsCachingEnabled && Runtime.Cache.UserProvidedTtlOptions
? Runtime.Cache.TtlSeconds.Value
: EntityCacheOptions.DEFAULT_TTL_SECONDS;
}
+ ///
+ /// Returns the cache level value for the global cache entry.
+ /// The level is inferred from the runtime cache Level2 configuration:
+ /// if Level2 is enabled, the level is L1L2; otherwise L1.
+ /// If runtime cache is not configured, the default cache level is used.
+ ///
+ /// Cache level that a cache entry should be stored in.
+ public virtual EntityCacheLevel GlobalCacheEntryLevel()
+ {
+ return Runtime?.Cache is not null
+ ? Runtime.Cache.InferredLevel
+ : EntityCacheOptions.DEFAULT_LEVEL;
+ }
+
+ ///
+ /// Whether the caching service should be used for a given operation. This is determined by
+ /// - whether caching is enabled globally
+ /// - whether the datasource is SQL and session context is disabled.
+ ///
+ /// Whether cache operations should proceed.
+ public virtual bool CanUseCache()
+ {
+ bool setSessionContextEnabled = DataSource.GetTypedOptions()?.SetSessionContext ?? true;
+ return IsCachingEnabled && !setSessionContextEnabled;
+ }
+
private void CheckDataSourceNamePresent(string dataSourceName)
{
if (!_dataSourceNameToDataSource.ContainsKey(dataSourceName))
diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs
index 6523589532..f567251771 100644
--- a/src/Core/Resolvers/SqlQueryEngine.cs
+++ b/src/Core/Resolvers/SqlQueryEngine.cs
@@ -330,7 +330,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad
{
// Entity level cache behavior checks
bool dbPolicyConfigured = !string.IsNullOrEmpty(structure.DbPolicyPredicatesForOperations[EntityActionOperation.Read]);
- bool entityCacheEnabled = runtimeConfig.Entities[structure.EntityName].IsCachingEnabled;
+ bool entityCacheEnabled = runtimeConfig.IsEntityCachingEnabled(structure.EntityName);
// If a db policy is configured for the read operation in the context of the executing role, skip the cache.
// We want to avoid caching token metadata because token metadata can change frequently and we want to avoid caching it.
@@ -466,7 +466,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad
if (runtimeConfig.CanUseCache())
{
// Entity level cache behavior checks
- bool entityCacheEnabled = runtimeConfig.Entities[structure.EntityName].IsCachingEnabled;
+ bool entityCacheEnabled = runtimeConfig.IsEntityCachingEnabled(structure.EntityName);
// Stored procedures do not support nor honor runtime config defined
// authorization policies. Here, DAB only checks that the entity has
diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs
index 1294c009da..abcb08ccac 100644
--- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs
+++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs
@@ -346,6 +346,115 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig)
}
}
+ ///
+ /// Validates that IsEntityCachingEnabled correctly inherits from the runtime cache enabled
+ /// setting when the entity does not explicitly set cache enabled.
+ /// Also validates that entity-level explicit enabled overrides the runtime setting.
+ ///
+ /// Global cache configuration JSON fragment.
+ /// Entity cache configuration JSON fragment.
+ /// Whether IsEntityCachingEnabled should return true.
+ [DataRow(@",""cache"": { ""enabled"": true }", @"", true, DisplayName = "Global cache enabled, entity cache omitted: entity inherits enabled from runtime.")]
+ [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": {}", true, DisplayName = "Global cache enabled, entity cache empty: entity inherits enabled from runtime.")]
+ [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", false, DisplayName = "Global cache enabled, entity cache explicitly disabled: entity explicit value wins.")]
+ [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": true }", true, DisplayName = "Global cache enabled, entity cache explicitly enabled: entity explicit value wins.")]
+ [DataRow(@",""cache"": { ""enabled"": false }", @"", false, DisplayName = "Global cache disabled, entity cache omitted: entity inherits disabled from runtime.")]
+ [DataRow(@",""cache"": { ""enabled"": false }", @",""cache"": { ""enabled"": true }", true, DisplayName = "Global cache disabled, entity cache explicitly enabled: entity explicit value wins.")]
+ [DataRow(@"", @"", false, DisplayName = "No global cache, no entity cache: defaults to disabled.")]
+ [DataRow(@"", @",""cache"": { ""enabled"": true }", true, DisplayName = "No global cache, entity cache explicitly enabled: entity explicit value wins.")]
+ [DataTestMethod]
+ public void IsEntityCachingEnabled_InheritsFromRuntimeCache(
+ string globalCacheConfig,
+ string entityCacheConfig,
+ bool expectedIsEntityCachingEnabled)
+ {
+ // Arrange
+ string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: entityCacheConfig);
+ RuntimeConfigLoader.TryParseConfig(
+ json: fullConfig,
+ out RuntimeConfig? config,
+ replacementSettings: null);
+
+ Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed.");
+
+ string entityName = config.Entities.First().Key;
+
+ // Act
+ bool actualIsEntityCachingEnabled = config.IsEntityCachingEnabled(entityName);
+
+ // Assert
+ Assert.AreEqual(expected: expectedIsEntityCachingEnabled, actual: actualIsEntityCachingEnabled,
+ message: $"IsEntityCachingEnabled should be {expectedIsEntityCachingEnabled}.");
+ }
+
+ ///
+ /// Validates that GlobalCacheEntryLevel infers the cache level from the runtime cache Level2 configuration.
+ /// When Level2 is enabled, the global level is L1L2; when Level2 is absent or disabled, the global level is L1.
+ ///
+ /// Global cache configuration JSON fragment.
+ /// Expected inferred cache level.
+ [DataRow(@",""cache"": { ""enabled"": true }", EntityCacheLevel.L1, DisplayName = "Global cache enabled, no Level2: inferred level is L1.")]
+ [DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", EntityCacheLevel.L1L2, DisplayName = "Global cache enabled, Level2 enabled: inferred level is L1L2.")]
+ [DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": false } }", EntityCacheLevel.L1, DisplayName = "Global cache enabled, Level2 disabled: inferred level is L1.")]
+ [DataRow(@"", EntityCacheLevel.L1L2, DisplayName = "No global cache: default level is L1L2.")]
+ [DataTestMethod]
+ public void GlobalCacheEntryLevel_InfersFromLevel2Config(
+ string globalCacheConfig,
+ EntityCacheLevel expectedLevel)
+ {
+ // Arrange
+ string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: string.Empty);
+ RuntimeConfigLoader.TryParseConfig(
+ json: fullConfig,
+ out RuntimeConfig? config,
+ replacementSettings: null);
+
+ Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed.");
+
+ // Act
+ EntityCacheLevel actualLevel = config.GlobalCacheEntryLevel();
+
+ // Assert
+ Assert.AreEqual(expected: expectedLevel, actual: actualLevel,
+ message: $"GlobalCacheEntryLevel should be {expectedLevel}.");
+ }
+
+ ///
+ /// Validates that GetEntityCacheEntryLevel returns the entity-level explicit value when set,
+ /// and falls back to the runtime-inferred level (from Level2) when the entity doesn't set it.
+ ///
+ /// Global cache configuration JSON fragment.
+ /// Entity cache configuration JSON fragment.
+ /// Expected cache level returned by GetEntityCacheEntryLevel.
+ [DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", @",""cache"": { ""enabled"": true }", EntityCacheLevel.L1L2, DisplayName = "Level2 enabled, entity has no level: inherits L1L2 from runtime.")]
+ [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": true }", EntityCacheLevel.L1, DisplayName = "No Level2, entity has no level: inherits L1 from runtime.")]
+ [DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", @",""cache"": { ""enabled"": true, ""level"": ""L1"" }", EntityCacheLevel.L1, DisplayName = "Level2 enabled, entity explicitly sets L1: entity value wins.")]
+ [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": true, ""level"": ""L1L2"" }", EntityCacheLevel.L1L2, DisplayName = "No Level2, entity explicitly sets L1L2: entity value wins.")]
+ [DataTestMethod]
+ public void GetEntityCacheEntryLevel_InheritsFromRuntimeLevel2(
+ string globalCacheConfig,
+ string entityCacheConfig,
+ EntityCacheLevel expectedLevel)
+ {
+ // Arrange
+ string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: entityCacheConfig);
+ RuntimeConfigLoader.TryParseConfig(
+ json: fullConfig,
+ out RuntimeConfig? config,
+ replacementSettings: null);
+
+ Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed.");
+
+ string entityName = config.Entities.First().Key;
+
+ // Act
+ EntityCacheLevel actualLevel = config.GetEntityCacheEntryLevel(entityName);
+
+ // Assert
+ Assert.AreEqual(expected: expectedLevel, actual: actualLevel,
+ message: $"GetEntityCacheEntryLevel should be {expectedLevel}.");
+ }
+
///
/// Returns a JSON string of the runtime config with the test-provided
/// cache configuration.
diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs
index 68c9225b96..8572673c45 100644
--- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs
+++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs
@@ -777,6 +777,9 @@ private static Mock CreateMockRuntimeConfigProvider(strin
mockRuntimeConfig
.Setup(c => c.CanUseCache())
.Returns(true);
+ mockRuntimeConfig
+ .Setup(c => c.IsEntityCachingEnabled(It.IsAny()))
+ .Returns(true);
mockRuntimeConfig
.Setup(c => c.GetEntityCacheEntryTtl(It.IsAny()))
.Returns(60);