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);