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.