diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 2baf75a780..68fd93b8e3 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -67,6 +67,8 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedEnabledOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. + VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedEnabledOptions); // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt index b4b428038b..594b050f35 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt @@ -50,7 +50,7 @@ Cache: { Enabled: true, TtlSeconds: 1, - Level: L1L2, + Level: L1, UserProvidedLevelOptions: false } } diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt index a3df8fd6c4..75c48ade4c 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt @@ -44,7 +44,7 @@ Cache: { Enabled: true, TtlSeconds: 1, - Level: L1L2, + Level: L1, UserProvidedLevelOptions: false } } diff --git a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs index 641efd062f..30f1241131 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; @@ -119,16 +121,22 @@ public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? r } /// - /// When writing the EntityCacheOptions back to a JSON file, only write the ttl-seconds - /// and level properties and values when EntityCacheOptions.Enabled is true. - /// This avoids polluting the written JSON file with a property the user most likely - /// omitted when writing the original DAB runtime config file. - /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// When writing the EntityCacheOptions back to a JSON file, only write each sub-property + /// when its corresponding UserProvided* flag is true. This avoids polluting the written + /// JSON file with properties the user omitted (defaults or inherited values). + /// If the user provided a cache object (Entity.Cache is non-null), we always write the + /// object — even if it ends up empty ("cache": {}) — because the user explicitly included it. + /// Entity.Cache being null means the user never wrote a cache property, and the serializer's + /// DefaultIgnoreCondition.WhenWritingNull suppresses the "cache" key entirely. /// public override void Write(Utf8JsonWriter writer, EntityCacheOptions value, JsonSerializerOptions options) { writer.WriteStartObject(); - writer.WriteBoolean("enabled", value?.Enabled ?? false); + + if (value?.UserProvidedEnabledOptions is true) + { + writer.WriteBoolean("enabled", value.Enabled!.Value); + } if (value?.UserProvidedTtlOptions is true) { diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index 1e8c5a6dba..d67a1f9f28 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.HealthCheck; @@ -76,12 +75,13 @@ public Entity( } /// - /// Resolves the value of Entity.Cache property if present, default is false. - /// Caching is enabled only when explicitly set to true. + /// Resolves the value of Entity.Cache.Enabled property if present, default is false. + /// Caching is enabled only when explicitly set to true on the entity. + /// To resolve inheritance from the global runtime cache setting, use + /// RuntimeConfig.IsEntityCachingEnabled(entityName) instead. /// - /// Whether caching is enabled for the entity. + /// Whether caching is explicitly enabled for the entity. [JsonIgnore] - [MemberNotNullWhen(true, nameof(Cache))] public bool IsCachingEnabled => Cache?.Enabled is true; [JsonIgnore] 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..983dcbe812 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -20,8 +20,12 @@ 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; + public const EntityCacheLevel DEFAULT_LEVEL = EntityCacheLevel.L1; /// /// The L2 cache provider we support. @@ -30,27 +34,39 @@ public record EntityCacheOptions /// /// Whether the cache should be used for the entity. + /// When null after deserialization, indicates the user did not explicitly set this property, + /// and the entity should inherit the runtime-level cache enabled setting. + /// After ResolveEntityCacheInheritance runs, this will hold the resolved value + /// (inherited from runtime or explicitly set by user). Use UserProvidedEnabledOptions + /// to distinguish whether the value was user-provided or inherited. /// [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 (Enabled is not null) + { + this.Enabled = Enabled; + UserProvidedEnabledOptions = true; + } + else + { + this.Enabled = null; + } if (TtlSeconds is not null) { @@ -73,6 +89,18 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCa } } + /// + /// Flag which informs CLI and JSON serializer whether to write the enabled + /// property and value to the runtime config file. + /// When the user doesn't provide the enabled property/value, which signals DAB + /// to inherit from the runtime cache setting, the DAB CLI should not write the + /// inherited value to a serialized config. This preserves the user's intent to + /// inherit rather than explicitly set the value. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Enabled))] + public bool UserProvidedEnabledOptions { get; init; } = false; + /// /// Flag which informs CLI and JSON serializer whether to write ttl-seconds /// property and value to the runtime config file. 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 b5c241a0a4..46ff5a8dda 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -577,7 +577,33 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) return entityConfig.Cache.Level.Value; } - return EntityCacheOptions.DEFAULT_LEVEL; + // GlobalCacheEntryLevel() returns null when runtime cache is not configured. + // Default to L1 to match EntityCacheOptions.DEFAULT_LEVEL. + return GlobalCacheEntryLevel() ?? EntityCacheOptions.DEFAULT_LEVEL; + } + + /// + /// 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; } /// @@ -592,18 +618,6 @@ public virtual bool CanUseCache() return IsCachingEnabled && !setSessionContextEnabled; } - /// - /// 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 int GlobalCacheEntryTtl() - { - return Runtime is not null && Runtime.IsCachingEnabled && Runtime.Cache.UserProvidedTtlOptions - ? Runtime.Cache.TtlSeconds.Value - : EntityCacheOptions.DEFAULT_TTL_SECONDS; - } - private void CheckDataSourceNamePresent(string dataSourceName) { if (!_dataSourceNameToDataSource.ContainsKey(dataSourceName)) @@ -794,4 +808,46 @@ public LogLevel GetConfiguredLogLevel(string loggerFilter = "") /// [JsonIgnore] public DmlToolsConfig? McpDmlTools => Runtime?.Mcp?.DmlTools; + + /// + /// Determines whether caching is enabled for a given entity, resolving inheritance lazily. + /// If the entity explicitly sets cache enabled/disabled, that value wins. + /// If the entity has a cache object but did not explicitly set enabled (UserProvidedEnabledOptions is false), + /// the global runtime cache enabled setting is inherited. + /// If the entity has no cache config at all, the global runtime cache enabled setting is inherited. + /// + /// 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) + { + if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) + { + throw new DataApiBuilderException( + message: $"{entityName} is not a valid entity.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); + } + + return IsEntityCachingEnabled(entityConfig); + } + + /// + /// Determines whether caching is enabled for a given entity, resolving inheritance lazily. + /// If the entity explicitly sets cache enabled/disabled (UserProvidedEnabledOptions is true), that value wins. + /// Otherwise, inherits the global runtime cache enabled setting. + /// + /// The entity to check cache configuration. + /// Whether caching is enabled for the entity. + private bool IsEntityCachingEnabled(Entity entity) + { + // If entity has an explicit cache config with user-provided enabled value, use it. + if (entity.Cache is not null && entity.Cache.UserProvidedEnabledOptions) + { + return entity.IsCachingEnabled; + } + + // Otherwise, inherit from the global runtime cache setting. + return IsCachingEnabled; + } } diff --git a/src/Core/Resolvers/CosmosQueryEngine.cs b/src/Core/Resolvers/CosmosQueryEngine.cs index 7525318089..de879040b0 100644 --- a/src/Core/Resolvers/CosmosQueryEngine.cs +++ b/src/Core/Resolvers/CosmosQueryEngine.cs @@ -92,7 +92,7 @@ public async Task> ExecuteAsync( JObject executeQueryResult = null; - if (runtimeConfig.CanUseCache() && runtimeConfig.Entities[structure.EntityName].IsCachingEnabled) + if (runtimeConfig.CanUseCache() && runtimeConfig.IsEntityCachingEnabled(structure.EntityName)) { StringBuilder dataSourceKey = new(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 d8cd279d95..961b0ecbba 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -346,6 +346,138 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig) } } + /// + /// Validates that RuntimeConfig.IsEntityCachingEnabled correctly reflects inheritance from the runtime cache enabled + /// setting when the entity does not explicitly set cache enabled. + /// Inheritance is resolved lazily via RuntimeConfig.IsEntityCachingEnabled(). + /// 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"": {""enabled"": true, ""level"": ""L1"" }", true, DisplayName = "Global cache enabled, entity cache with level L1: 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."); + + string entityName = config.Entities.First().Key; + + // Act - RuntimeConfig.IsEntityCachingEnabled resolves inheritance lazily by + // checking the entity's explicit setting first, then falling back to the global setting. + 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.")] + [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. @@ -417,20 +549,128 @@ private static string GetRawConfigJson(string globalCacheConfig, string entityCa return expectedRuntimeConfigJson.ToString(); } + /// + /// Validates that when an entity has no cache config but inherits caching enabled from the + /// global runtime setting, the inherited cache object is NOT serialized back to the JSON + /// config file. This prevents config pollution where a "cache" property appears on entities + /// that never had one in the user's original config. + /// + [DataRow(@",""cache"": { ""enabled"": true }", @"", DisplayName = "Global cache enabled, entity cache omitted: inherited cache should not be serialized.")] + [DataRow(@",""cache"": { ""enabled"": false }", @"", DisplayName = "Global cache disabled, entity cache omitted: inherited cache should not be serialized.")] + [DataTestMethod] + public void InheritedEntityCacheNotWrittenToSerializedJsonConfigFile(string globalCacheConfig, string entityCacheConfig) + { + // 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."); + + // Act + string serializedConfig = config.ToJson(); + + // Assert: entity should NOT have a "cache" property since user never defined one. + using JsonDocument parsedConfig = JsonDocument.Parse(serializedConfig); + JsonElement entityElement = parsedConfig.RootElement + .GetProperty("entities") + .EnumerateObject().First().Value; + bool cachePropertyExists = entityElement.TryGetProperty("cache", out _); + Assert.IsFalse(cachePropertyExists, + message: "Entity cache property should not be serialized when it was inherited from the runtime setting, not user-defined."); + } + + /// + /// Validates that GetEntityCacheEntryLevel returns the correct inherited cache level + /// when the entity does not explicitly set a level. The entity should fall back to the + /// global inferred level (determined by Level2 configuration). + /// Also validates that an explicit entity-level setting overrides the global inferred level. + /// + /// Global cache configuration JSON fragment. + /// Entity cache configuration JSON fragment. + /// Expected cache level returned by GetEntityCacheEntryLevel. + [DataRow( + @",""cache"": { ""enabled"": true }", + @",""cache"": { ""enabled"": true }", + EntityCacheLevel.L1, + DisplayName = "Global L1 (no Level2), entity has no level: entity inherits L1 from global.")] + [DataRow( + @",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", + @",""cache"": { ""enabled"": true }", + EntityCacheLevel.L1L2, + DisplayName = "Global L1L2 (Level2 enabled), entity has no level: entity inherits L1L2 from global.")] + [DataRow( + @",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": false } }", + @",""cache"": { ""enabled"": true }", + EntityCacheLevel.L1, + DisplayName = "Global L1 (Level2 disabled), entity has no level: entity inherits L1 from global.")] + [DataRow( + @",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", + @",""cache"": { ""enabled"": true, ""level"": ""L1"" }", + EntityCacheLevel.L1, + DisplayName = "Global L1L2, entity explicitly sets L1: entity explicit value wins.")] + [DataRow( + @",""cache"": { ""enabled"": true }", + @",""cache"": { ""enabled"": true, ""level"": ""L1L2"" }", + EntityCacheLevel.L1L2, + DisplayName = "Global L1 (no Level2), entity explicitly sets L1L2: entity explicit value wins.")] + [DataRow( + @",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", + @"", + EntityCacheLevel.L1L2, + DisplayName = "Global L1L2, entity cache omitted (inherits enabled): entity inherits L1L2 from global.")] + [DataRow( + @",""cache"": { ""enabled"": true }", + @"", + EntityCacheLevel.L1, + DisplayName = "Global L1 (no Level2), entity cache omitted (inherits enabled): entity inherits L1 from global.")] + [DataTestMethod] + public void GetEntityCacheEntryLevel_InheritsFromGlobalLevel( + string globalCacheConfig, + string entityCacheConfig, + EntityCacheLevel expectedEntityLevel) + { + // 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; + + // Precondition: entity must have caching enabled (explicitly or inherited) for GetEntityCacheEntryLevel to succeed. + Assert.IsTrue(config.IsEntityCachingEnabled(entityName), + message: "Test precondition failed: entity must have caching enabled."); + + // Act + EntityCacheLevel actualLevel = config.GetEntityCacheEntryLevel(entityName); + + // Assert + Assert.AreEqual(expected: expectedEntityLevel, actual: actualLevel, + message: $"GetEntityCacheEntryLevel should return {expectedEntityLevel}."); + } + /// /// Regression test: Validates that when global runtime cache is enabled but entity cache is disabled, /// GetEntityCacheEntryTtl and GetEntityCacheEntryLevel do not throw and return sensible defaults. /// Previously, these methods threw a DataApiBuilderException (BadRequest/NotSupported) when the entity /// had caching disabled, which caused 400 errors for valid requests when the global cache was enabled. /// These methods are now pure accessors that always return a value regardless of cache enablement. + /// The level falls back to GlobalCacheEntryLevel() which infers from Level2 configuration; + /// when Level2 is absent, the inferred level is L1. /// /// Global cache configuration JSON fragment. /// Entity cache configuration JSON fragment. /// Expected TTL returned by GetEntityCacheEntryTtl. /// Expected cache level returned by GetEntityCacheEntryLevel. - [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity returns global TTL and default level.")] - [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", 5, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity returns default TTL and default level.")] - [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache omitted: entity returns global TTL and default level.")] + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", 10, EntityCacheLevel.L1, DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity returns global TTL and global inferred level.")] + [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", 5, EntityCacheLevel.L1, DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity returns default TTL and global inferred level.")] + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", 10, EntityCacheLevel.L1, DisplayName = "Global cache enabled with custom TTL, entity cache omitted: entity returns global TTL and global inferred level.")] + [DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true }, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with Level2 and custom TTL, entity cache disabled: entity returns global TTL and L1L2.")] [DataTestMethod] public void GetEntityCacheEntryTtlAndLevel_DoesNotThrow_WhenRuntimeCacheEnabledAndEntityCacheDisabled( string globalCacheConfig, @@ -458,6 +698,6 @@ public void GetEntityCacheEntryTtlAndLevel_DoesNotThrow_WhenRuntimeCacheEnabledA EntityCacheLevel actualLevel = config.GetEntityCacheEntryLevel(entityName); Assert.AreEqual(expected: expectedTtl, actual: actualTtl, message: "GetEntityCacheEntryTtl should return the global/default TTL when entity cache is disabled."); - Assert.AreEqual(expected: expectedLevel, actual: actualLevel, message: "GetEntityCacheEntryLevel should return the default level when entity cache is disabled."); + Assert.AreEqual(expected: expectedLevel, actual: actualLevel, message: "GetEntityCacheEntryLevel should return the global inferred level when entity cache is disabled."); } } diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs index 91c6ef28bd..3bf6e37012 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -788,6 +788,9 @@ private static Mock CreateMockRuntimeConfigProvider(strin mockRuntimeConfig .Setup(c => c.GetEntityCacheEntryLevel(It.IsAny())) .Returns(EntityCacheLevel.L1); + mockRuntimeConfig + .Setup(c => c.IsEntityCachingEnabled(It.IsAny())) + .Returns(true); Mock mockLoader = new(null, null); Mock mockRuntimeConfigProvider = new(mockLoader.Object); mockRuntimeConfigProvider diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index b2d713b804..64189544c1 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -71,6 +71,8 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedEnabledOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. + VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedEnabledOptions); // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory.