From 340ef73ea806a641a21b70971bec37da5c855dcc Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 24 Feb 2026 11:23:33 -0800 Subject: [PATCH 01/23] take default for entity cache enabled and level from runtime cache setting --- src/Config/ObjectModel/EntityCacheOptions.cs | 20 ++++++- src/Config/ObjectModel/RuntimeCacheOptions.cs | 8 +++ src/Config/ObjectModel/RuntimeConfig.cs | 60 ++++++++++++++++--- src/Core/Resolvers/SqlQueryEngine.cs | 4 +- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index a947cd6d99..1fe1ee995a 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -49,8 +49,15 @@ public record EntityCacheOptions [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 +80,15 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCa } } + /// + /// Flag which informs the runtime whether the user explicitly set the Enabled property. + /// When the user doesn't provide the enabled property, the entity cache enabled state + /// will inherit from the runtime cache enabled setting. + /// + [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 1e567da1cd..5b87aad9be 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -492,7 +492,7 @@ public virtual int GetEntityCacheEntryTtl(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!entityConfig.IsCachingEnabled) + if (!IsEntityCachingEnabled(entityName)) { throw new DataApiBuilderException( message: $"{entityName} does not have caching enabled.", @@ -500,9 +500,9 @@ public virtual int GetEntityCacheEntryTtl(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); } - if (entityConfig.Cache.UserProvidedTtlOptions) + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedTtlOptions) { - return entityConfig.Cache.TtlSeconds.Value; + return entityConfig.Cache.TtlSeconds!.Value; } else { @@ -512,7 +512,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 property is not set, returns the global default value set in the runtime config. + /// If the global default value is not set, the default value (L1L2) is used. /// /// Name of the entity to check cache configuration. /// Cache level that a cache entry should be stored in. @@ -527,7 +528,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!entityConfig.IsCachingEnabled) + if (!IsEntityCachingEnabled(entityName)) { throw new DataApiBuilderException( message: $"{entityName} does not have caching enabled.", @@ -535,14 +536,43 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); } - if (entityConfig.Cache.UserProvidedLevelOptions) + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedLevelOptions) { - return entityConfig.Cache.Level.Value; + return entityConfig.Cache.Level!.Value; } else { - return EntityCacheLevel.L1L2; + return GlobalCacheEntryLevel(); + } + } + + /// + /// Determines whether caching is enabled for a given entity, taking into account + /// inheritance from the runtime cache settings. + /// If the entity explicitly sets enabled, that value is used. + /// If the entity does not set enabled, the runtime cache enabled value 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 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); + } + + // If the entity explicitly set enabled, use that value. + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedEnabledOptions) + { + return entityConfig.Cache.Enabled!.Value; } + + // Otherwise, inherit from the runtime cache enabled setting. + return Runtime?.Cache?.Enabled is true; } /// @@ -569,6 +599,20 @@ public int GlobalCacheEntryTtl() : 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 EntityCacheLevel GlobalCacheEntryLevel() + { + return Runtime?.Cache is not null + ? Runtime.Cache.InferredLevel + : EntityCacheOptions.DEFAULT_LEVEL; + } + 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 From 44c83350c19cd616c62df1806afd999a08983177 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 24 Feb 2026 13:42:11 -0800 Subject: [PATCH 02/23] include test for new behavior --- src/Config/ObjectModel/RuntimeConfig.cs | 40 ++----- .../Caching/CachingConfigProcessingTests.cs | 109 ++++++++++++++++++ 2 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 5b87aad9be..1358f3f415 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -481,7 +481,7 @@ Runtime is not null && Runtime.Host is not null /// /// Name of the entity to check cache configuration. /// Number of seconds (ttl) that a cache entry should be valid before cache eviction. - /// Raised when an invalid entity name is provided or if the entity has caching disabled. + /// Raised when an invalid entity name is provided. public virtual int GetEntityCacheEntryTtl(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) @@ -492,32 +492,22 @@ public virtual int GetEntityCacheEntryTtl(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!IsEntityCachingEnabled(entityName)) - { - throw new DataApiBuilderException( - message: $"{entityName} does not have caching enabled.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); - } - if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedTtlOptions) { - return entityConfig.Cache.TtlSeconds!.Value; - } - else - { - return GlobalCacheEntryTtl(); + return entityConfig.Cache.TtlSeconds.Value; } + + return GlobalCacheEntryTtl(); } /// /// Returns the cache level value for a given entity. - /// If the property is not set, returns the global default value set in the runtime config. - /// If the global default value is not set, the default value (L1L2) is used. + /// 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. - /// Raised when an invalid entity name is provided or if the entity has caching disabled. + /// Raised when an invalid entity name is provided. public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) @@ -528,22 +518,12 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!IsEntityCachingEnabled(entityName)) - { - throw new DataApiBuilderException( - message: $"{entityName} does not have caching enabled.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); - } - if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedLevelOptions) { - return entityConfig.Cache.Level!.Value; - } - else - { - return GlobalCacheEntryLevel(); + return entityConfig.Cache.Level.Value; } + + return GlobalCacheEntryLevel(); } /// 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. From eea30444b997e790f252fb582583d2d98f9a805e Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 12:32:35 -0800 Subject: [PATCH 03/23] addressing comments --- src/Config/ObjectModel/EntityCacheOptions.cs | 25 +--- src/Config/ObjectModel/RuntimeConfig.cs | 129 ++++++++++++++----- 2 files changed, 101 insertions(+), 53 deletions(-) diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index 1fe1ee995a..52b29cbd98 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -30,9 +30,13 @@ 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; } = null; /// /// The number of seconds a cache entry is valid before eligible for cache eviction. @@ -49,15 +53,7 @@ public record EntityCacheOptions [JsonConstructor] public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCacheLevel? Level = null) { - if (Enabled is not null) - { - this.Enabled = Enabled; - UserProvidedEnabledOptions = true; - } - else - { - this.Enabled = null; - } + this.Enabled = Enabled; if (TtlSeconds is not null) { @@ -80,15 +76,6 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCa } } - /// - /// Flag which informs the runtime whether the user explicitly set the Enabled property. - /// When the user doesn't provide the enabled property, the entity cache enabled state - /// will inherit from the runtime cache enabled setting. - /// - [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/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 1358f3f415..7d229f9a38 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -25,7 +25,7 @@ public record RuntimeConfig [JsonPropertyName("azure-key-vault")] public AzureKeyVaultOptions? AzureKeyVault { get; init; } - public RuntimeAutoentities? Autoentities { get; init; } + public RuntimeAutoentities Autoentities { get; init; } public virtual RuntimeEntities Entities { get; init; } @@ -216,6 +216,8 @@ Runtime.GraphQL.FeatureFlags is not null && private Dictionary _entityNameToDataSourceName = new(); + private Dictionary _autoentityNameToDataSourceName = new(); + private Dictionary _entityPathNameToEntityName = new(); /// @@ -245,6 +247,21 @@ public bool TryGetEntityNameFromPath(string entityPathName, [NotNullWhen(true)] return _entityPathNameToEntityName.TryGetValue(entityPathName, out entityName); } + public bool TryAddEntityNameToDataSourceName(string entityName) + { + return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName); + } + + public bool TryAddGeneratedAutoentityNameToDataSourceName(string entityName, string autoEntityDefinition) + { + if (_autoentityNameToDataSourceName.TryGetValue(autoEntityDefinition, out string? dataSourceName)) + { + return _entityNameToDataSourceName.TryAdd(entityName, dataSourceName); + } + + return false; + } + /// /// Constructor for runtimeConfig. /// To be used when setting up from cli json scenario. @@ -268,8 +285,8 @@ public RuntimeConfig( this.DataSource = DataSource; this.Runtime = Runtime; this.AzureKeyVault = AzureKeyVault; - this.Entities = Entities; - this.Autoentities = Autoentities; + this.Entities = Entities ?? new RuntimeEntities(new Dictionary()); + this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); this.DefaultDataSourceName = Guid.NewGuid().ToString(); if (this.DataSource is null) @@ -287,17 +304,29 @@ public RuntimeConfig( }; _entityNameToDataSourceName = new Dictionary(); - if (Entities is null) + if (Entities is null && this.Entities.Entities.Count == 0 && + Autoentities is null && this.Autoentities.Autoentities.Count == 0) { throw new DataApiBuilderException( - message: "entities is a mandatory property in DAB Config", + message: "Configuration file should contain either at least the entities or autoentities property", statusCode: HttpStatusCode.UnprocessableEntity, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } - foreach (KeyValuePair entity in Entities) + if (Entities is not null) { - _entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName); + foreach (KeyValuePair entity in Entities) + { + _entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName); + } + } + + if (Autoentities is not null) + { + foreach (KeyValuePair autoentity in Autoentities) + { + _autoentityNameToDataSourceName.TryAdd(autoentity.Key, this.DefaultDataSourceName); + } } // Process data source and entities information for each database in multiple database scenario. @@ -305,7 +334,8 @@ public RuntimeConfig( if (DataSourceFiles is not null && DataSourceFiles.SourceFiles is not null) { - IEnumerable> allEntities = Entities.AsEnumerable(); + IEnumerable>? allEntities = Entities?.AsEnumerable(); + IEnumerable>? allAutoentities = Autoentities?.AsEnumerable(); // Iterate through all the datasource files and load the config. IFileSystem fileSystem = new FileSystem(); // This loader is not used as a part of hot reload and therefore does not need a handler. @@ -322,7 +352,9 @@ public RuntimeConfig( { _dataSourceNameToDataSource = _dataSourceNameToDataSource.Concat(config._dataSourceNameToDataSource).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); _entityNameToDataSourceName = _entityNameToDataSourceName.Concat(config._entityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - allEntities = allEntities.Concat(config.Entities.AsEnumerable()); + _autoentityNameToDataSourceName = _autoentityNameToDataSourceName.Concat(config._autoentityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + allEntities = allEntities?.Concat(config.Entities.AsEnumerable()); + allAutoentities = allAutoentities?.Concat(config.Autoentities.AsEnumerable()); } catch (Exception e) { @@ -336,7 +368,8 @@ public RuntimeConfig( } } - this.Entities = new RuntimeEntities(allEntities.ToDictionary(x => x.Key, x => x.Value)); + this.Entities = new RuntimeEntities(allEntities != null ? allEntities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary()); + this.Autoentities = new RuntimeAutoentities(allAutoentities != null ? allAutoentities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary()); } SetupDataSourcesUsed(); @@ -351,17 +384,19 @@ public RuntimeConfig( /// Default datasource. /// Runtime settings. /// Entities + /// Autoentities /// List of datasource files for multiple db scenario.Null for single db scenario. /// DefaultDataSourceName to maintain backward compatibility. /// Dictionary mapping datasourceName to datasource object. /// Dictionary mapping entityName to datasourceName. /// Datasource files which represent list of child runtimeconfigs for multi-db scenario. - public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtime, RuntimeEntities Entities, string DefaultDataSourceName, Dictionary DataSourceNameToDataSource, Dictionary EntityNameToDataSourceName, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null) + public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtime, RuntimeEntities Entities, string DefaultDataSourceName, Dictionary DataSourceNameToDataSource, Dictionary EntityNameToDataSourceName, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null, RuntimeAutoentities? Autoentities = null) { this.Schema = Schema; this.DataSource = DataSource; this.Runtime = Runtime; this.Entities = Entities; + this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); this.DefaultDataSourceName = DefaultDataSourceName; _dataSourceNameToDataSource = DataSourceNameToDataSource; _entityNameToDataSourceName = EntityNameToDataSourceName; @@ -451,6 +486,24 @@ public DataSource GetDataSourceFromEntityName(string entityName) return _dataSourceNameToDataSource[_entityNameToDataSourceName[entityName]]; } + /// + /// Gets datasourceName from AutoentityNameToDatasourceName dictionary. + /// + /// autoentityName + /// DataSourceName + public string GetDataSourceNameFromAutoentityName(string autoentityName) + { + if (!_autoentityNameToDataSourceName.TryGetValue(autoentityName, out string? autoentityDataSource)) + { + throw new DataApiBuilderException( + message: $"{autoentityName} is not a valid autoentity.", + statusCode: HttpStatusCode.NotFound, + subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); + } + + return autoentityDataSource; + } + /// /// Validates if datasource is present in runtimeConfig. /// @@ -481,7 +534,7 @@ Runtime is not null && Runtime.Host is not null /// /// Name of the entity to check cache configuration. /// Number of seconds (ttl) that a cache entry should be valid before cache eviction. - /// Raised when an invalid entity name is provided. + /// Raised when an invalid entity name is provided or if the entity has caching disabled. public virtual int GetEntityCacheEntryTtl(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) @@ -492,12 +545,22 @@ public virtual int GetEntityCacheEntryTtl(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedTtlOptions) + if (!entityConfig.IsCachingEnabled) { - return entityConfig.Cache.TtlSeconds.Value; + throw new DataApiBuilderException( + message: $"{entityName} does not have caching enabled.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); } - return GlobalCacheEntryTtl(); + if (entityConfig.Cache.UserProvidedTtlOptions) + { + return entityConfig.Cache.TtlSeconds.Value; + } + else + { + return GlobalCacheEntryTtl(); + } } /// @@ -507,7 +570,7 @@ public virtual int GetEntityCacheEntryTtl(string entityName) /// /// Name of the entity to check cache configuration. /// Cache level that a cache entry should be stored in. - /// Raised when an invalid entity name is provided. + /// Raised when an invalid entity name is provided or if the entity has caching disabled. public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) @@ -518,7 +581,15 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedLevelOptions) + if (!entityConfig.IsCachingEnabled) + { + throw new DataApiBuilderException( + message: $"{entityName} does not have caching enabled.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); + } + + if (entityConfig.Cache.UserProvidedLevelOptions) { return entityConfig.Cache.Level.Value; } @@ -529,8 +600,10 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) /// /// Determines whether caching is enabled for a given entity, taking into account /// inheritance from the runtime cache settings. - /// If the entity explicitly sets enabled, that value is used. - /// If the entity does not set enabled, the runtime cache enabled value is inherited. + /// 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. /// /// Name of the entity to check cache configuration. /// Whether caching is enabled for the entity. @@ -545,28 +618,16 @@ public bool IsEntityCachingEnabled(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - // If the entity explicitly set enabled, use that value. - if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedEnabledOptions) + // If the entity explicitly set Enabled, use that value. + if (entityConfig.Cache is not null && entityConfig.Cache.Enabled.HasValue) { - return entityConfig.Cache.Enabled!.Value; + return entityConfig.Cache.Enabled.Value; } // Otherwise, inherit from the runtime cache enabled setting. return Runtime?.Cache?.Enabled is true; } - /// - /// 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; - } - /// /// Returns the ttl-seconds value for the global cache entry. /// If no value is explicitly set, returns the global default value. From 07a2bd958cf09c435de28831f7a2f7804ab7b38b Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 12:38:35 -0800 Subject: [PATCH 04/23] revert accidental change --- src/Config/ObjectModel/RuntimeConfig.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 7d229f9a38..4d7318d6f8 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -654,6 +654,18 @@ public EntityCacheLevel GlobalCacheEntryLevel() : 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)) From 6039a1ed0206278bcf4403faa3f44a6cd349bdea Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 12:50:16 -0800 Subject: [PATCH 05/23] addressing comments --- src/Config/Converters/EntityCacheOptionsConverterFactory.cs | 4 +++- src/Config/ObjectModel/RuntimeConfig.cs | 6 +++--- .../Caching/DabCacheServiceIntegrationTests.cs | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) 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/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 4d7318d6f8..217a91eeda 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -608,7 +608,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) /// Name of the entity to check cache configuration. /// Whether caching is enabled for the entity. /// Raised when an invalid entity name is provided. - public bool IsEntityCachingEnabled(string entityName) + public virtual bool IsEntityCachingEnabled(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) { @@ -633,7 +633,7 @@ public bool IsEntityCachingEnabled(string entityName) /// 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 @@ -647,7 +647,7 @@ public int GlobalCacheEntryTtl() /// If runtime cache is not configured, the default cache level is used. /// /// Cache level that a cache entry should be stored in. - public EntityCacheLevel GlobalCacheEntryLevel() + public virtual EntityCacheLevel GlobalCacheEntryLevel() { return Runtime?.Cache is not null ? Runtime.Cache.InferredLevel 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); From d808b30b7e3f49f274f8f136ca8771a8740c0e9c Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 12:59:51 -0800 Subject: [PATCH 06/23] cleanup --- src/Config/ObjectModel/EntityCacheOptions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index 52b29cbd98..5a748cad5f 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -36,19 +36,19 @@ public record EntityCacheOptions /// behavior regardless of whether the object was created via JsonConstructor or with-expression. /// [JsonPropertyName("enabled")] - public bool? Enabled { get; init; } = null; + 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) From 2fdb41be7563fd9295a56baa518163aeb4a3931e Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 06:50:16 -0800 Subject: [PATCH 07/23] return null for calarity when no cache --- src/Config/ObjectModel/EntityCacheOptions.cs | 4 +++ src/Config/ObjectModel/RuntimeConfig.cs | 15 ++++---- .../Caching/CachingConfigProcessingTests.cs | 35 ++++++------------- 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index 5a748cad5f..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; diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 217a91eeda..b6e46a31a0 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -594,7 +594,10 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) return entityConfig.Cache.Level.Value; } - return GlobalCacheEntryLevel(); + // 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; } /// @@ -644,14 +647,12 @@ public virtual int GlobalCacheEntryTtl() /// 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. + /// Returns null when runtime cache is not configured. /// - /// Cache level that a cache entry should be stored in. - public virtual EntityCacheLevel GlobalCacheEntryLevel() + /// Cache level for a cache entry, or null if runtime cache is not configured. + public virtual EntityCacheLevel? GlobalCacheEntryLevel() { - return Runtime?.Cache is not null - ? Runtime.Cache.InferredLevel - : EntityCacheOptions.DEFAULT_LEVEL; + return Runtime?.Cache?.InferredLevel; } /// diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index abcb08ccac..3607e3f7cf 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -396,7 +396,6 @@ public void IsEntityCachingEnabled_InheritsFromRuntimeCache( [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, @@ -412,32 +411,23 @@ public void GlobalCacheEntryLevel_InfersFromLevel2Config( Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); // Act - EntityCacheLevel actualLevel = config.GlobalCacheEntryLevel(); + EntityCacheLevel? actualLevel = config.GlobalCacheEntryLevel(); // Assert - Assert.AreEqual(expected: expectedLevel, actual: actualLevel, + 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 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. + /// Validates that GlobalCacheEntryLevel returns null when runtime cache is not configured, + /// since determining a cache level is meaningless when caching is disabled. /// - /// 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) + [TestMethod] + public void GlobalCacheEntryLevel_ReturnsNullWhenRuntimeCacheIsNull() { - // Arrange - string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: entityCacheConfig); + // Arrange: no global cache config + string fullConfig = GetRawConfigJson(globalCacheConfig: string.Empty, entityCacheConfig: string.Empty); RuntimeConfigLoader.TryParseConfig( json: fullConfig, out RuntimeConfig? config, @@ -445,14 +435,11 @@ public void GetEntityCacheEntryLevel_InheritsFromRuntimeLevel2( 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); + EntityCacheLevel? actualLevel = config.GlobalCacheEntryLevel(); // Assert - Assert.AreEqual(expected: expectedLevel, actual: actualLevel, - message: $"GetEntityCacheEntryLevel should be {expectedLevel}."); + Assert.IsNull(actualLevel, "GlobalCacheEntryLevel should return null when runtime cache is not configured."); } /// From 08047f15d410595bc589b203dd3bff5d0f68bed0 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 07:42:55 -0800 Subject: [PATCH 08/23] remove duplicated function and refactor around --- src/Config/ObjectModel/RuntimeConfig.cs | 77 +++++++++++-------- src/Core/Resolvers/SqlQueryEngine.cs | 4 +- .../Caching/CachingConfigProcessingTests.cs | 15 ++-- .../DabCacheServiceIntegrationTests.cs | 3 - 4 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index b6e46a31a0..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); } /// @@ -600,37 +606,6 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) return GlobalCacheEntryLevel() ?? EntityCacheLevel.L1; } - /// - /// 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. - /// - /// 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); - } - - // 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; - } - /// /// Returns the ttl-seconds value for the global cache entry. /// If no value is explicitly set, returns the global default value. @@ -667,6 +642,44 @@ public virtual bool CanUseCache() return IsCachingEnabled && !setSessionContextEnabled; } + /// + /// 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. + /// + /// A new RuntimeEntities with inheritance resolved, or the original if no changes needed. + private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities entities, RuntimeOptions? runtime) + { + 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) { if (!_dataSourceNameToDataSource.ContainsKey(dataSourceName)) diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs index f567251771..6523589532 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.IsEntityCachingEnabled(structure.EntityName); + bool entityCacheEnabled = runtimeConfig.Entities[structure.EntityName].IsCachingEnabled; // 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.IsEntityCachingEnabled(structure.EntityName); + bool entityCacheEnabled = runtimeConfig.Entities[structure.EntityName].IsCachingEnabled; // 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 3607e3f7cf..d0129f8cb5 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -347,13 +347,14 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig) } /// - /// Validates that IsEntityCachingEnabled correctly inherits from the runtime cache enabled + /// 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 IsEntityCachingEnabled should return true. + /// 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.")] @@ -363,7 +364,7 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig) [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( + public void EntityIsCachingEnabled_InheritsFromRuntimeCache( string globalCacheConfig, string entityCacheConfig, bool expectedIsEntityCachingEnabled) @@ -377,14 +378,14 @@ public void IsEntityCachingEnabled_InheritsFromRuntimeCache( Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); - string entityName = config.Entities.First().Key; + Entity entity = config.Entities.First().Value; - // Act - bool actualIsEntityCachingEnabled = config.IsEntityCachingEnabled(entityName); + // Act - Entity.IsCachingEnabled should reflect the inherited value resolved at construction time. + bool actualIsEntityCachingEnabled = entity.IsCachingEnabled; // Assert Assert.AreEqual(expected: expectedIsEntityCachingEnabled, actual: actualIsEntityCachingEnabled, - message: $"IsEntityCachingEnabled should be {expectedIsEntityCachingEnabled}."); + message: $"Entity.IsCachingEnabled should be {expectedIsEntityCachingEnabled}."); } /// diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs index 8572673c45..68c9225b96 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -777,9 +777,6 @@ 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); From 3baf002c428bd77c89730927a5d88d4f8f31539f Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 10:17:17 -0800 Subject: [PATCH 09/23] remove redundant row in test --- src/Service.Tests/Caching/CachingConfigProcessingTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index d0129f8cb5..cb76ed0456 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -358,7 +358,6 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig) [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.")] From bc9cc4b2286aa1376fe34e6a714636b1584b30ee Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 12:15:42 -0800 Subject: [PATCH 10/23] fix casing for level --- src/Config/ObjectModel/EntityCacheLevel.cs | 4 +++ .../Caching/CachingConfigProcessingTests.cs | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) 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/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index cb76ed0456..fa2d76c3b6 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -442,6 +442,41 @@ public void GlobalCacheEntryLevel_ReturnsNullWhenRuntimeCacheIsNull() 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. From 88bc81e91b37b7620f2c74a0fc3b1d402f1ce195 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Fri, 6 Mar 2026 11:19:00 -0800 Subject: [PATCH 11/23] dont write back non user provided --- .../EntityCacheOptionsConverterFactory.cs | 17 +++++++--- src/Config/ObjectModel/EntityCacheOptions.cs | 12 ++++++- .../Caching/CachingConfigProcessingTests.cs | 32 +++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs index 1f4c8ff2dd..15340b91e0 100644 --- a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs @@ -68,7 +68,7 @@ public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? r { if (reader.TokenType is JsonTokenType.EndObject) { - return new EntityCacheOptions(enabled, ttlSeconds, level); + return new EntityCacheOptions(enabled, ttlSeconds, level) { UserProvidedCacheOptions = true }; } string? property = reader.GetString(); @@ -129,16 +129,25 @@ public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? r /// public override void Write(Utf8JsonWriter writer, EntityCacheOptions value, JsonSerializerOptions options) { + // If the cache object was not provided by the user (e.g., synthesized by + // ResolveEntityCacheInheritance), write null so the serializer's + // DefaultIgnoreCondition.WhenWritingNull suppresses the "cache" property entirely. + if (value is null || !value.UserProvidedCacheOptions) + { + writer.WriteNullValue(); + return; + } + writer.WriteStartObject(); - writer.WriteBoolean("enabled", value?.Enabled ?? false); + writer.WriteBoolean("enabled", value.Enabled ?? false); - if (value?.UserProvidedTtlOptions is true) + if (value.UserProvidedTtlOptions is true) { writer.WritePropertyName("ttl-seconds"); JsonSerializer.Serialize(writer, value.TtlSeconds, options); } - if (value?.UserProvidedLevelOptions is true) + if (value.UserProvidedLevelOptions is true) { writer.WritePropertyName("level"); JsonSerializer.Serialize(writer, value.Level, options); diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index fffe722780..933b14067e 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -22,7 +22,7 @@ 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() + /// 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; @@ -107,4 +107,14 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCa [JsonIgnore(Condition = JsonIgnoreCondition.Always)] [MemberNotNullWhen(true, nameof(Level))] public bool UserProvidedLevelOptions { get; init; } = false; + + /// + /// Flag which informs the JSON serializer whether the user originally provided + /// a cache object in the config file. When false, the cache object was synthesized + /// by ResolveEntityCacheInheritance to support runtime inheritance and should NOT + /// be written back to the serialized config file. + /// This follows the same pattern as UserProvidedTtlOptions and UserProvidedLevelOptions. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedCacheOptions { get; init; } = false; } diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index fa2d76c3b6..85c5018353 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -547,4 +547,36 @@ 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."); + } } From 1ac1899012845faf6e21d5a47d67676384b0457c Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Fri, 6 Mar 2026 12:20:36 -0800 Subject: [PATCH 12/23] fixing tests --- ...ityTests.AddEntityWithCachingEnabled.verified.txt | 3 ++- ...eEntityTests.TestUpdateEntityCaching.verified.txt | 3 ++- src/Config/ObjectModel/EntityCacheOptions.cs | 12 +++++++----- src/Config/ObjectModel/RuntimeConfig.cs | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt index b4b428038b..90eb7b94b8 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt @@ -51,7 +51,8 @@ Enabled: true, TtlSeconds: 1, Level: L1L2, - UserProvidedLevelOptions: false + UserProvidedLevelOptions: false, + UserProvidedCacheOptions: true } } } diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt index a3df8fd6c4..555a0c8530 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt @@ -45,7 +45,8 @@ Enabled: true, TtlSeconds: 1, Level: L1L2, - UserProvidedLevelOptions: false + UserProvidedLevelOptions: false, + UserProvidedCacheOptions: true } } } diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index 933b14067e..e8aceb9448 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -110,11 +110,13 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCa /// /// Flag which informs the JSON serializer whether the user originally provided - /// a cache object in the config file. When false, the cache object was synthesized - /// by ResolveEntityCacheInheritance to support runtime inheritance and should NOT - /// be written back to the serialized config file. - /// This follows the same pattern as UserProvidedTtlOptions and UserProvidedLevelOptions. + /// a cache object in the config file or through the CLI. When false, the cache object + /// was synthesized by ResolveEntityCacheInheritance to support runtime inheritance + /// and should NOT be written back to the serialized config file. + /// Defaults to true because EntityCacheOptions created via constructor (JSON deserialization + /// or CLI) represent user intent. Only ResolveEntityCacheInheritance sets this to false + /// for cache objects synthesized to support inheritance. /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedCacheOptions { get; init; } = false; + public bool UserProvidedCacheOptions { get; init; } = true; } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index e17880d161..dcb9266b69 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -665,7 +665,7 @@ private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities ent // 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) }; + entity = entity with { Cache = new EntityCacheOptions(Enabled: true) { UserProvidedCacheOptions = false } }; anyResolved = true; } else if (entity.Cache is not null && !entity.Cache.Enabled.HasValue) From 2a80f14aabc4e50dae3b7a5d8d7d1b208fadba4c Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Fri, 6 Mar 2026 13:23:12 -0800 Subject: [PATCH 13/23] fix null write back behaviors --- src/Cli.Tests/ModuleInitializer.cs | 4 ++ ...s.AddEntityWithCachingEnabled.verified.txt | 5 +-- ...Tests.TestUpdateEntityCaching.verified.txt | 5 +-- .../EntityCacheOptionsConverterFactory.cs | 31 ++++++------- src/Config/ObjectModel/Entity.cs | 17 ++++++-- src/Config/ObjectModel/EntityCacheOptions.cs | 43 +++++++++++-------- src/Config/ObjectModel/RuntimeConfig.cs | 32 +++++++++++--- src/Service.Tests/ModuleInitializer.cs | 4 ++ 8 files changed, 90 insertions(+), 51 deletions(-) diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 2baf75a780..24e32316e0 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -65,8 +65,12 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.EntityFirst); // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); + // Ignore InheritedCachingEnabled as that's not serialized in our config file. + VerifierSettings.IgnoreMember(entity => entity.InheritedCachingEnabled); // 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 90eb7b94b8..e0639f8a3f 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt @@ -51,10 +51,9 @@ Enabled: true, TtlSeconds: 1, Level: L1L2, - UserProvidedLevelOptions: false, - UserProvidedCacheOptions: true + UserProvidedLevelOptions: false } } } ] -} \ No newline at end of file +}} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt index 555a0c8530..0379bd1175 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt @@ -45,10 +45,9 @@ Enabled: true, TtlSeconds: 1, Level: L1L2, - UserProvidedLevelOptions: false, - UserProvidedCacheOptions: true + UserProvidedLevelOptions: false } } } ] -} \ No newline at end of file +}} \ No newline at end of file diff --git a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs index 15340b91e0..30f1241131 100644 --- a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs @@ -68,7 +68,7 @@ public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? r { if (reader.TokenType is JsonTokenType.EndObject) { - return new EntityCacheOptions(enabled, ttlSeconds, level) { UserProvidedCacheOptions = true }; + return new EntityCacheOptions(enabled, ttlSeconds, level); } string? property = reader.GetString(); @@ -121,33 +121,30 @@ 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) { - // If the cache object was not provided by the user (e.g., synthesized by - // ResolveEntityCacheInheritance), write null so the serializer's - // DefaultIgnoreCondition.WhenWritingNull suppresses the "cache" property entirely. - if (value is null || !value.UserProvidedCacheOptions) + writer.WriteStartObject(); + + if (value?.UserProvidedEnabledOptions is true) { - writer.WriteNullValue(); - return; + writer.WriteBoolean("enabled", value.Enabled!.Value); } - writer.WriteStartObject(); - writer.WriteBoolean("enabled", value.Enabled ?? false); - - if (value.UserProvidedTtlOptions is true) + if (value?.UserProvidedTtlOptions is true) { writer.WritePropertyName("ttl-seconds"); JsonSerializer.Serialize(writer, value.TtlSeconds, options); } - if (value.UserProvidedLevelOptions is true) + if (value?.UserProvidedLevelOptions is true) { writer.WritePropertyName("level"); JsonSerializer.Serialize(writer, value.Level, options); diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index 1e8c5a6dba..04111c4c56 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; @@ -46,6 +45,16 @@ public record Entity [JsonIgnore] public bool IsLinkingEntity { get; init; } + /// + /// Tracks whether caching was inherited from the global runtime cache setting + /// for entities that did not explicitly define a cache configuration. + /// Set by ResolveEntityCacheInheritance at RuntimeConfig construction time. + /// This avoids synthesizing a fake EntityCacheOptions object that would + /// pollute the serialized config file. + /// + [JsonIgnore] + public bool InheritedCachingEnabled { get; init; } + [JsonConstructor] public Entity( EntitySource Source, @@ -77,12 +86,12 @@ public Entity( /// /// Resolves the value of Entity.Cache property if present, default is false. - /// Caching is enabled only when explicitly set to true. + /// Caching is enabled only when explicitly set to true, or inherited from the + /// global runtime cache setting via InheritedCachingEnabled. /// /// Whether caching is enabled for the entity. [JsonIgnore] - [MemberNotNullWhen(true, nameof(Cache))] - public bool IsCachingEnabled => Cache?.Enabled is true; + public bool IsCachingEnabled => Cache?.Enabled is true || InheritedCachingEnabled; [JsonIgnore] public bool IsEntityHealthEnabled => diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index e8aceb9448..1580482672 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -34,10 +34,11 @@ 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. + /// 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; } @@ -57,7 +58,15 @@ public record EntityCacheOptions [JsonConstructor] public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCacheLevel? Level = null) { - this.Enabled = Enabled; + if (Enabled is not null) + { + this.Enabled = Enabled; + UserProvidedEnabledOptions = true; + } + else + { + this.Enabled = null; + } if (TtlSeconds is not null) { @@ -80,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. @@ -107,16 +128,4 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCa [JsonIgnore(Condition = JsonIgnoreCondition.Always)] [MemberNotNullWhen(true, nameof(Level))] public bool UserProvidedLevelOptions { get; init; } = false; - - /// - /// Flag which informs the JSON serializer whether the user originally provided - /// a cache object in the config file or through the CLI. When false, the cache object - /// was synthesized by ResolveEntityCacheInheritance to support runtime inheritance - /// and should NOT be written back to the serialized config file. - /// Defaults to true because EntityCacheOptions created via constructor (JSON deserialization - /// or CLI) represent user intent. Only ResolveEntityCacheInheritance sets this to false - /// for cache objects synthesized to support inheritance. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public bool UserProvidedCacheOptions { get; init; } = true; } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index dcb9266b69..fe2f2db690 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -559,7 +559,9 @@ public virtual int GetEntityCacheEntryTtl(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); } - if (entityConfig.Cache.UserProvidedTtlOptions) + // If entity has explicit cache config with user-provided TTL, use it. + // Otherwise fall through to the global default. + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedTtlOptions) { return entityConfig.Cache.TtlSeconds.Value; } @@ -595,7 +597,9 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); } - if (entityConfig.Cache.UserProvidedLevelOptions) + // If entity has explicit cache config with user-provided level, use it. + // Otherwise fall through to the global default. + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedLevelOptions) { return entityConfig.Cache.Level.Value; } @@ -648,6 +652,8 @@ public virtual bool CanUseCache() /// 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. + /// The UserProvidedEnabledOptions flag is NOT set on inherited values, so the + /// serializer will not write the inherited enabled value back to the config file. /// /// A new RuntimeEntities with inheritance resolved, or the original if no changes needed. private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities entities, RuntimeOptions? runtime) @@ -661,16 +667,28 @@ private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities ent { 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 has no cache config at all, and global is enabled, + // set InheritedCachingEnabled so IsCachingEnabled returns true + // without synthesizing a fake EntityCacheOptions that would pollute serialized config. if (entity.Cache is null && globalCacheEnabled) { - entity = entity with { Cache = new EntityCacheOptions(Enabled: true) { UserProvidedCacheOptions = false } }; + entity = entity with { InheritedCachingEnabled = true }; anyResolved = true; } - else if (entity.Cache is not null && !entity.Cache.Enabled.HasValue) + else if (entity.Cache is not null && !entity.Cache.UserProvidedEnabledOptions) { - entity = entity with { Cache = entity.Cache with { Enabled = globalCacheEnabled } }; + // Entity has a cache object but Enabled was not explicitly set by the user + // (e.g., "cache": {} or "cache": { "ttl-seconds": 1 }"). + // Inherit the global value for runtime use but preserve the flag as false + // so the serializer won't write the inherited enabled value back. + entity = entity with + { + Cache = entity.Cache with + { + Enabled = globalCacheEnabled, + UserProvidedEnabledOptions = false + } + }; anyResolved = true; } diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index b2d713b804..4019e29467 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -69,8 +69,12 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.EntityFirst); // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); + // Ignore InheritedCachingEnabled as that's not serialized in our config file. + VerifierSettings.IgnoreMember(entity => entity.InheritedCachingEnabled); // 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. From f46687d88cd01e8432b80324f729563b8bc10ae6 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Fri, 6 Mar 2026 13:28:58 -0800 Subject: [PATCH 14/23] fix bool name --- src/Config/ObjectModel/RuntimeConfig.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index fe2f2db690..ec061f5c36 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -658,7 +658,7 @@ public virtual bool CanUseCache() /// A new RuntimeEntities with inheritance resolved, or the original if no changes needed. private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities entities, RuntimeOptions? runtime) { - bool globalCacheEnabled = runtime?.Cache?.Enabled is true; + bool isGlobalCacheEnabled = runtime?.Cache?.Enabled is true; Dictionary resolvedEntities = new(); bool anyResolved = false; @@ -670,7 +670,7 @@ private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities ent // If entity has no cache config at all, and global is enabled, // set InheritedCachingEnabled so IsCachingEnabled returns true // without synthesizing a fake EntityCacheOptions that would pollute serialized config. - if (entity.Cache is null && globalCacheEnabled) + if (entity.Cache is null && isGlobalCacheEnabled) { entity = entity with { InheritedCachingEnabled = true }; anyResolved = true; @@ -685,7 +685,7 @@ private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities ent { Cache = entity.Cache with { - Enabled = globalCacheEnabled, + Enabled = isGlobalCacheEnabled, UserProvidedEnabledOptions = false } }; From 6e219c37f6e0f4768a6e145991c9f73527039ab8 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Fri, 6 Mar 2026 16:39:45 -0800 Subject: [PATCH 15/23] typo in snapshot files --- .../AddEntityTests.AddEntityWithCachingEnabled.verified.txt | 2 +- .../UpdateEntityTests.TestUpdateEntityCaching.verified.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt index e0639f8a3f..b4b428038b 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt @@ -56,4 +56,4 @@ } } ] -}} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt index 0379bd1175..a3df8fd6c4 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt @@ -50,4 +50,4 @@ } } ] -}} \ No newline at end of file +} \ No newline at end of file From f455f14051cf6b013be8c64cc71bf2e7fb16eb30 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Mon, 9 Mar 2026 13:37:48 -0700 Subject: [PATCH 16/23] access time, L1L2 default, test added --- src/Cli.Tests/ModuleInitializer.cs | 2 - src/Config/ObjectModel/Entity.cs | 21 +-- src/Config/ObjectModel/RuntimeConfig.cs | 109 +++++++--------- src/Core/Resolvers/CosmosQueryEngine.cs | 2 +- src/Core/Resolvers/SqlQueryEngine.cs | 4 +- .../Caching/CachingConfigProcessingTests.cs | 121 ++++++++++++++++-- src/Service.Tests/ModuleInitializer.cs | 2 - 7 files changed, 167 insertions(+), 94 deletions(-) diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 24e32316e0..68fd93b8e3 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -65,8 +65,6 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.EntityFirst); // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); - // Ignore InheritedCachingEnabled as that's not serialized in our config file. - VerifierSettings.IgnoreMember(entity => entity.InheritedCachingEnabled); // 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. diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index 04111c4c56..d67a1f9f28 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -45,16 +45,6 @@ public record Entity [JsonIgnore] public bool IsLinkingEntity { get; init; } - /// - /// Tracks whether caching was inherited from the global runtime cache setting - /// for entities that did not explicitly define a cache configuration. - /// Set by ResolveEntityCacheInheritance at RuntimeConfig construction time. - /// This avoids synthesizing a fake EntityCacheOptions object that would - /// pollute the serialized config file. - /// - [JsonIgnore] - public bool InheritedCachingEnabled { get; init; } - [JsonConstructor] public Entity( EntitySource Source, @@ -85,13 +75,14 @@ public Entity( } /// - /// Resolves the value of Entity.Cache property if present, default is false. - /// Caching is enabled only when explicitly set to true, or inherited from the - /// global runtime cache setting via InheritedCachingEnabled. + /// 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] - public bool IsCachingEnabled => Cache?.Enabled is true || InheritedCachingEnabled; + public bool IsCachingEnabled => Cache?.Enabled is true; [JsonIgnore] public bool IsEntityHealthEnabled => diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index ec061f5c36..b6c5e3118a 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -373,9 +373,6 @@ 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); } /// @@ -406,10 +403,6 @@ 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); } /// @@ -551,7 +544,7 @@ public virtual int GetEntityCacheEntryTtl(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!entityConfig.IsCachingEnabled) + if (!IsEntityCachingEnabled(entityConfig)) { throw new DataApiBuilderException( message: $"{entityName} does not have caching enabled.", @@ -589,7 +582,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!entityConfig.IsCachingEnabled) + if (!IsEntityCachingEnabled(entityConfig)) { throw new DataApiBuilderException( message: $"{entityName} does not have caching enabled.", @@ -606,8 +599,8 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) // 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; + // but we default to L1L2 defensively to match EntityCacheOptions.DEFAULT_LEVEL. + return GlobalCacheEntryLevel() ?? EntityCacheLevel.L1L2; } /// @@ -646,58 +639,6 @@ public virtual bool CanUseCache() return IsCachingEnabled && !setSessionContextEnabled; } - /// - /// 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. - /// The UserProvidedEnabledOptions flag is NOT set on inherited values, so the - /// serializer will not write the inherited enabled value back to the config file. - /// - /// A new RuntimeEntities with inheritance resolved, or the original if no changes needed. - private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities entities, RuntimeOptions? runtime) - { - bool isGlobalCacheEnabled = 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, - // set InheritedCachingEnabled so IsCachingEnabled returns true - // without synthesizing a fake EntityCacheOptions that would pollute serialized config. - if (entity.Cache is null && isGlobalCacheEnabled) - { - entity = entity with { InheritedCachingEnabled = true }; - anyResolved = true; - } - else if (entity.Cache is not null && !entity.Cache.UserProvidedEnabledOptions) - { - // Entity has a cache object but Enabled was not explicitly set by the user - // (e.g., "cache": {} or "cache": { "ttl-seconds": 1 }"). - // Inherit the global value for runtime use but preserve the flag as false - // so the serializer won't write the inherited enabled value back. - entity = entity with - { - Cache = entity.Cache with - { - Enabled = isGlobalCacheEnabled, - UserProvidedEnabledOptions = false - } - }; - anyResolved = true; - } - - resolvedEntities.Add(kvp.Key, entity); - } - - return anyResolved ? new RuntimeEntities(resolvedEntities) : entities; - } - private void CheckDataSourceNamePresent(string dataSourceName) { if (!_dataSourceNameToDataSource.ContainsKey(dataSourceName)) @@ -888,4 +829,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.Cache.Enabled!.Value; + } + + // 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 85c5018353..7a460d50cb 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Azure.DataApiBuilder.Service.Tests.Caching; @@ -347,17 +348,17 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig) } /// - /// Validates that Entity.IsCachingEnabled correctly reflects inheritance from the runtime cache enabled + /// 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 at RuntimeConfig construction time via ResolveEntityCacheInheritance(). + /// 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 Entity.IsCachingEnabled should return true. + /// 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, ""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.")] @@ -377,14 +378,15 @@ public void EntityIsCachingEnabled_InheritsFromRuntimeCache( Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); - Entity entity = config.Entities.First().Value; + string entityName = config.Entities.First().Key; - // Act - Entity.IsCachingEnabled should reflect the inherited value resolved at construction time. - bool actualIsEntityCachingEnabled = entity.IsCachingEnabled; + // 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: $"Entity.IsCachingEnabled should be {expectedIsEntityCachingEnabled}."); + message: $"IsEntityCachingEnabled should be {expectedIsEntityCachingEnabled}."); } /// @@ -579,4 +581,105 @@ public void InheritedEntityCacheNotWrittenToSerializedJsonConfigFile(string glob 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}."); + } + + /// + /// Validates that GetEntityCacheEntryLevel throws a DataApiBuilderException + /// when caching is not enabled for the entity. + /// + [TestMethod] + public void GetEntityCacheEntryLevel_ThrowsWhenCachingDisabled() + { + // Arrange: global cache disabled, entity cache omitted -> caching is disabled for entity. + string fullConfig = GetRawConfigJson( + globalCacheConfig: @",""cache"": { ""enabled"": false }", + 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."); + + string entityName = config.Entities.First().Key; + Assert.IsFalse(config.IsEntityCachingEnabled(entityName), + message: "Test precondition: entity caching should be disabled."); + + // Act & Assert + Assert.ThrowsException( + () => config.GetEntityCacheEntryLevel(entityName), + message: "GetEntityCacheEntryLevel should throw when caching is disabled for the entity."); + } } diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index 4019e29467..64189544c1 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -69,8 +69,6 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.EntityFirst); // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); - // Ignore InheritedCachingEnabled as that's not serialized in our config file. - VerifierSettings.IgnoreMember(entity => entity.InheritedCachingEnabled); // 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. From 99fc1ee3d971004e25e46ba22f00926e4d825fdd Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Mon, 9 Mar 2026 16:26:27 -0700 Subject: [PATCH 17/23] fix mocked class --- src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs | 3 +++ 1 file changed, 3 insertions(+) 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 From bebdbe81e14438353b1dab2af0b435a9c82e501a Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 11:22:10 -0700 Subject: [PATCH 18/23] try to fix nuget job --- .pipelines/mssql-pipelines.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.pipelines/mssql-pipelines.yml b/.pipelines/mssql-pipelines.yml index f5e330b073..4f5947d629 100644 --- a/.pipelines/mssql-pipelines.yml +++ b/.pipelines/mssql-pipelines.yml @@ -186,10 +186,11 @@ jobs: - task: NuGetToolInstaller@1 - - task: NuGetCommand@2 + - task: DotNetCoreCLI@2 displayName: Restore NuGet packages inputs: - restoreSolution: '$(solution)' + command: restore + projects: '$(solution)' feedsToUse: config nugetConfigPath: Nuget.config @@ -277,4 +278,4 @@ jobs: inputs: PathtoPublish: '$(Build.ArtifactStagingDirectory)\Verify' ArtifactName: 'Verify' - publishLocation: 'Container' + publishLocation: 'Container' \ No newline at end of file From d727dbcf6f8565b468b38d1a6fcbb93ef5b5a5a1 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 13:45:41 -0700 Subject: [PATCH 19/23] pipeline fix ported int --- .pipelines/cosmos-pipelines.yml | 1 + .pipelines/dwsql-pipelines.yml | 7 +++++-- .pipelines/mssql-pipelines.yml | 4 +++- .pipelines/mysql-pipelines.yml | 1 + .pipelines/pg-pipelines.yml | 1 + 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.pipelines/cosmos-pipelines.yml b/.pipelines/cosmos-pipelines.yml index 61d066aac7..c664879288 100644 --- a/.pipelines/cosmos-pipelines.yml +++ b/.pipelines/cosmos-pipelines.yml @@ -69,6 +69,7 @@ steps: projects: '$(solution)' feedsToUse: config nugetConfigPath: Nuget.config + restoreArguments: '/p:RuntimeIdentifiers=""' - task: DotNetCoreCLI@2 displayName: Build diff --git a/.pipelines/dwsql-pipelines.yml b/.pipelines/dwsql-pipelines.yml index ab79529030..bc1e36dd6d 100644 --- a/.pipelines/dwsql-pipelines.yml +++ b/.pipelines/dwsql-pipelines.yml @@ -61,6 +61,7 @@ jobs: projects: '$(solution)' feedsToUse: config nugetConfigPath: Nuget.config + restoreArguments: '/p:RuntimeIdentifiers=""' - task: Bash@3 displayName: 'Generate password' @@ -182,12 +183,14 @@ jobs: - task: NuGetToolInstaller@1 - - task: NuGetCommand@2 + - task: DotNetCoreCLI@2 displayName: Restore NuGet packages inputs: - restoreSolution: '$(solution)' + command: restore + projects: '$(solution)' feedsToUse: config nugetConfigPath: Nuget.config + restoreArguments: '/p:RuntimeIdentifiers=""' - task: PowerShell@2 displayName: Install SQL LocalDB # Update when clarity on how to setup diff --git a/.pipelines/mssql-pipelines.yml b/.pipelines/mssql-pipelines.yml index 4f5947d629..d2d2d8c7fa 100644 --- a/.pipelines/mssql-pipelines.yml +++ b/.pipelines/mssql-pipelines.yml @@ -62,6 +62,7 @@ jobs: projects: '$(solution)' feedsToUse: config nugetConfigPath: Nuget.config + restoreArguments: '/p:RuntimeIdentifiers=""' - task: Bash@3 displayName: 'Generate password' @@ -193,6 +194,7 @@ jobs: projects: '$(solution)' feedsToUse: config nugetConfigPath: Nuget.config + restoreArguments: '/p:RuntimeIdentifiers=""' - task: PowerShell@2 displayName: Install SQL LocalDB @@ -278,4 +280,4 @@ jobs: inputs: PathtoPublish: '$(Build.ArtifactStagingDirectory)\Verify' ArtifactName: 'Verify' - publishLocation: 'Container' \ No newline at end of file + publishLocation: 'Container' diff --git a/.pipelines/mysql-pipelines.yml b/.pipelines/mysql-pipelines.yml index 35d1fb1ac4..e6fe59d6c0 100644 --- a/.pipelines/mysql-pipelines.yml +++ b/.pipelines/mysql-pipelines.yml @@ -60,6 +60,7 @@ jobs: projects: '$(solution)' feedsToUse: config nugetConfigPath: Nuget.config + restoreArguments: '/p:RuntimeIdentifiers=""' - task: Bash@3 displayName: 'Generate password' diff --git a/.pipelines/pg-pipelines.yml b/.pipelines/pg-pipelines.yml index c98430a23f..6cd223aca6 100644 --- a/.pipelines/pg-pipelines.yml +++ b/.pipelines/pg-pipelines.yml @@ -55,6 +55,7 @@ jobs: projects: '$(solution)' feedsToUse: config nugetConfigPath: Nuget.config + restoreArguments: '/p:RuntimeIdentifiers=""' - task: Bash@3 displayName: 'Generate password' From 893268c41d31e6f618708ab668e7d79e4340fc4a Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 18:37:20 -0700 Subject: [PATCH 20/23] use IsCachingEnabled --- src/Config/ObjectModel/RuntimeConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index e7440fe0c8..52ba7f103d 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -844,7 +844,7 @@ 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.Cache.Enabled!.Value; + return entity.IsCachingEnabled; } // Otherwise, inherit from the global runtime cache setting. From f031b8b79e37a4f0683d4de926a7c5a6726fefe8 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 19:38:05 -0700 Subject: [PATCH 21/23] align default level to infferred/L1 --- src/Config/ObjectModel/EntityCacheOptions.cs | 2 +- src/Config/ObjectModel/RuntimeConfig.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index 1580482672..983dcbe812 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -25,7 +25,7 @@ public record EntityCacheOptions /// 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. diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 52ba7f103d..b16eac248e 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -579,7 +579,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) // GlobalCacheEntryLevel() returns null when runtime cache is not configured. // Default to L1L2 to match EntityCacheOptions.DEFAULT_LEVEL. - return GlobalCacheEntryLevel() ?? EntityCacheLevel.L1L2; + return GlobalCacheEntryLevel() ?? EntityCacheOptions.DEFAULT_LEVEL; } /// From cbffd20e5d0c3b54f5ac7cc494ab8ae825349c2a Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 19:43:06 -0700 Subject: [PATCH 22/23] update comment --- src/Config/ObjectModel/RuntimeConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index b16eac248e..46ff5a8dda 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -578,7 +578,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) } // GlobalCacheEntryLevel() returns null when runtime cache is not configured. - // Default to L1L2 to match EntityCacheOptions.DEFAULT_LEVEL. + // Default to L1 to match EntityCacheOptions.DEFAULT_LEVEL. return GlobalCacheEntryLevel() ?? EntityCacheOptions.DEFAULT_LEVEL; } From 3a3fe092125a5c40772b7bbdd94c448248308563 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Wed, 11 Mar 2026 11:15:00 -0700 Subject: [PATCH 23/23] fix snapshots --- .../AddEntityTests.AddEntityWithCachingEnabled.verified.txt | 2 +- .../UpdateEntityTests.TestUpdateEntityCaching.verified.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 } }