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/EntityCacheLevel.cs b/src/Config/ObjectModel/EntityCacheLevel.cs
index cb4aa58c95..bcf227bd1c 100644
--- a/src/Config/ObjectModel/EntityCacheLevel.cs
+++ b/src/Config/ObjectModel/EntityCacheLevel.cs
@@ -1,10 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using System.Runtime.Serialization;
+
namespace Azure.DataApiBuilder.Config.ObjectModel;
public enum EntityCacheLevel
{
+ [EnumMember(Value = "L1")]
L1,
+ [EnumMember(Value = "L1L2")]
L1L2
}
diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs
index a947cd6d99..fffe722780 100644
--- a/src/Config/ObjectModel/EntityCacheOptions.cs
+++ b/src/Config/ObjectModel/EntityCacheOptions.cs
@@ -20,6 +20,10 @@ public record EntityCacheOptions
///
/// Default cache level for an entity.
+ /// Placeholder cache level value used when the entity does not explicitly set a level.
+ /// This value is stored on the EntityCacheOptions object but is NOT used at runtime
+ /// for resolution — GetEntityCacheEntryLevel() falls through to GlobalCacheEntryLevel()
+ /// (which infers the level from the runtime Level2 configuration) when UserProvidedLevelOptions is false.
///
public const EntityCacheLevel DEFAULT_LEVEL = EntityCacheLevel.L1L2;
@@ -30,26 +34,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..e17880d161 100644
--- a/src/Config/ObjectModel/RuntimeConfig.cs
+++ b/src/Config/ObjectModel/RuntimeConfig.cs
@@ -373,7 +373,9 @@ public RuntimeConfig(
}
SetupDataSourcesUsed();
-
+ // Resolve entity cache inheritance: if an entity's Cache.Enabled is null,
+ // inherit the global runtime cache enabled setting.
+ this.Entities = ResolveEntityCacheInheritance(this.Entities, this.Runtime);
}
///
@@ -404,6 +406,10 @@ public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtim
this.AzureKeyVault = AzureKeyVault;
SetupDataSourcesUsed();
+
+ // Resolve entity cache inheritance: if an entity's Cache.Enabled is null,
+ // inherit the global runtime cache enabled setting.
+ this.Entities = ResolveEntityCacheInheritance(this.Entities, this.Runtime);
}
///
@@ -565,7 +571,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,10 +599,35 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName)
{
return entityConfig.Cache.Level.Value;
}
- else
- {
- return EntityCacheLevel.L1L2;
- }
+
+ // GlobalCacheEntryLevel() returns null when runtime cache is not configured.
+ // Callers guard with IsCachingEnabled, so null is not expected here,
+ // but we default to L1 defensively.
+ return GlobalCacheEntryLevel() ?? EntityCacheLevel.L1;
+ }
+
+ ///
+ /// Returns the ttl-seconds value for the global cache entry.
+ /// If no value is explicitly set, returns the global default value.
+ ///
+ /// Number of seconds a cache entry should be valid before cache eviction.
+ 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.
+ /// Returns null when runtime cache is not configured.
+ ///
+ /// Cache level for a cache entry, or null if runtime cache is not configured.
+ public virtual EntityCacheLevel? GlobalCacheEntryLevel()
+ {
+ return Runtime?.Cache?.InferredLevel;
}
///
@@ -611,15 +643,41 @@ public virtual bool CanUseCache()
}
///
- /// Returns the ttl-seconds value for the global cache entry.
- /// If no value is explicitly set, returns the global default value.
+ /// Resolves entity cache inheritance at construction time.
+ /// For each entity whose Cache.Enabled is null (not explicitly set by the user),
+ /// inherits the global runtime cache enabled setting (Runtime.Cache.Enabled).
+ /// This ensures Entity.IsCachingEnabled is the single source of truth for whether
+ /// an entity has caching enabled, without callers needing to check the global setting.
///
- /// Number of seconds a cache entry should be valid before cache eviction.
- public int GlobalCacheEntryTtl()
+ /// A new RuntimeEntities with inheritance resolved, or the original if no changes needed.
+ private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities entities, RuntimeOptions? runtime)
{
- return Runtime is not null && Runtime.IsCachingEnabled && Runtime.Cache.UserProvidedTtlOptions
- ? Runtime.Cache.TtlSeconds.Value
- : EntityCacheOptions.DEFAULT_TTL_SECONDS;
+ bool globalCacheEnabled = runtime?.Cache?.Enabled is true;
+
+ Dictionary resolvedEntities = new();
+ bool anyResolved = false;
+
+ foreach (KeyValuePair kvp in entities)
+ {
+ Entity entity = kvp.Value;
+
+ // If entity has no cache config at all, and global is enabled, create one inheriting enabled.
+ // If entity has cache config but Enabled is null, inherit the global value.
+ if (entity.Cache is null && globalCacheEnabled)
+ {
+ entity = entity with { Cache = new EntityCacheOptions(Enabled: true) };
+ anyResolved = true;
+ }
+ else if (entity.Cache is not null && !entity.Cache.Enabled.HasValue)
+ {
+ entity = entity with { Cache = entity.Cache with { Enabled = globalCacheEnabled } };
+ anyResolved = true;
+ }
+
+ resolvedEntities.Add(kvp.Key, entity);
+ }
+
+ return anyResolved ? new RuntimeEntities(resolvedEntities) : entities;
}
private void CheckDataSourceNamePresent(string dataSourceName)
diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs
index 1294c009da..fa2d76c3b6 100644
--- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs
+++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs
@@ -346,6 +346,137 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig)
}
}
+ ///
+ /// Validates that Entity.IsCachingEnabled correctly reflects inheritance from the runtime cache enabled
+ /// setting when the entity does not explicitly set cache enabled.
+ /// Inheritance is resolved at RuntimeConfig construction time via ResolveEntityCacheInheritance().
+ /// Also validates that entity-level explicit enabled overrides the runtime setting.
+ ///
+ /// Global cache configuration JSON fragment.
+ /// Entity cache configuration JSON fragment.
+ /// Whether Entity.IsCachingEnabled 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"": 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 EntityIsCachingEnabled_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.");
+
+ Entity entity = config.Entities.First().Value;
+
+ // Act - Entity.IsCachingEnabled should reflect the inherited value resolved at construction time.
+ bool actualIsEntityCachingEnabled = entity.IsCachingEnabled;
+
+ // Assert
+ Assert.AreEqual(expected: expectedIsEntityCachingEnabled, actual: actualIsEntityCachingEnabled,
+ message: $"Entity.IsCachingEnabled 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.")]
+ [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.IsNotNull(actualLevel, message: "GlobalCacheEntryLevel should not be null when runtime cache is configured.");
+ Assert.AreEqual(expected: expectedLevel, actual: actualLevel.Value,
+ message: $"GlobalCacheEntryLevel should be {expectedLevel}.");
+ }
+
+ ///
+ /// Validates that GlobalCacheEntryLevel returns null when runtime cache is not configured,
+ /// since determining a cache level is meaningless when caching is disabled.
+ ///
+ [TestMethod]
+ public void GlobalCacheEntryLevel_ReturnsNullWhenRuntimeCacheIsNull()
+ {
+ // Arrange: no global cache config
+ string fullConfig = GetRawConfigJson(globalCacheConfig: string.Empty, 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.IsNull(actualLevel, "GlobalCacheEntryLevel should return null when runtime cache is not configured.");
+ }
+
+ ///
+ /// Validates that the entity cache level is serialized with the correct casing (e.g. "L1", "L1L2")
+ /// when writing the runtime config to JSON. This ensures the serialized config passes JSON schema
+ /// validation which expects uppercase enum values.
+ ///
+ /// The cache level value as written in the JSON config.
+ /// The expected string in the serialized JSON output.
+ [DataRow("L1", "L1", DisplayName = "L1 level serialized with correct casing.")]
+ [DataRow("L1L2", "L1L2", DisplayName = "L1L2 level serialized with correct casing.")]
+ [DataTestMethod]
+ public void EntityCacheLevelSerializedWithCorrectCasing(string levelValue, string expectedSerializedLevel)
+ {
+ // Arrange
+ string entityCacheConfig = @",""cache"": { ""enabled"": true, ""level"": """ + levelValue + @""" }";
+ string fullConfig = GetRawConfigJson(globalCacheConfig: @",""cache"": { ""enabled"": true }", 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.");
+
+ // Act
+ string serializedConfig = config.ToJson();
+
+ // Assert
+ using JsonDocument parsedConfig = JsonDocument.Parse(serializedConfig);
+ JsonElement entityElement = parsedConfig.RootElement
+ .GetProperty("entities")
+ .EnumerateObject().First().Value;
+ JsonElement cacheElement = entityElement.GetProperty("cache");
+ string? actualLevel = cacheElement.GetProperty("level").GetString();
+ Assert.AreEqual(expected: expectedSerializedLevel, actual: actualLevel,
+ message: $"Cache level should be serialized as '{expectedSerializedLevel}', not lowercase.");
+ }
+
///
/// Returns a JSON string of the runtime config with the test-provided
/// cache configuration.