From 340ef73ea806a641a21b70971bec37da5c855dcc Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 24 Feb 2026 11:23:33 -0800 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)