From a9a4ea0d19f95312bbaf5078761ab6ebeaccb450 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 22 Jan 2026 13:37:51 -0800 Subject: [PATCH 01/18] Changes for autoentities --- src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 1cadaf5838..f43553b8fb 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -60,6 +60,7 @@ public override Type SqlToCLRType(string sqlType) /// public override async Task PopulateTriggerMetadataForTable(string entityName, string schemaName, string tableName, SourceDefinition sourceDefinition) { + string enumerateEnabledTriggers = SqlQueryBuilder.BuildFetchEnabledTriggersQuery(); Dictionary parameters = new() { From 4acee5c09b1bdfbc4773054642ba62402e53c339 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Mon, 26 Jan 2026 15:28:51 -0800 Subject: [PATCH 02/18] Add query and its execution --- .../MetadataProviders/MsSqlMetadataProvider.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index f43553b8fb..2433049b86 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -295,10 +295,17 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType) /// protected override async Task GenerateAutoentitiesIntoEntities() { - await Task.CompletedTask; + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + if (runtimeConfig.Autoentities is not null) + { + foreach ((string name, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) + { + JsonArray? resultArray = await QueryAutoentitiesConfiguration(autoentity); + } + } } - public async Task QueryAutoentitiesAsync(Autoentity autoentity) + public async Task QueryAutoentitiesConfiguration(Autoentity autoentity) { string include = string.Join(",", autoentity.Patterns.Include); string exclude = string.Join(",", autoentity.Patterns.Exclude); From 67e10d77d7094090f549041c788a91eabddce329 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Mon, 26 Jan 2026 21:26:04 -0800 Subject: [PATCH 03/18] Add testing --- src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs index 30969918c0..3aa9f465e3 100644 --- a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs @@ -19,6 +19,7 @@ using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Tests.Configuration; using Azure.DataApiBuilder.Service.Tests.SqlTests; +using HotChocolate.Execution.Processing; using Microsoft.AspNetCore.Http; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; From 5453cbce10b86f4d13c6532911063d932c34514c Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Wed, 28 Jan 2026 16:49:13 -0800 Subject: [PATCH 04/18] Changes based on comments --- src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | 1 + src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 2433049b86..5002c2acb5 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -301,6 +301,7 @@ protected override async Task GenerateAutoentitiesIntoEntities() foreach ((string name, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) { JsonArray? resultArray = await QueryAutoentitiesConfiguration(autoentity); + // TODO: Finish implementation of autoentities generation in task #3052 } } } diff --git a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs index 3aa9f465e3..30969918c0 100644 --- a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs @@ -19,7 +19,6 @@ using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Tests.Configuration; using Azure.DataApiBuilder.Service.Tests.SqlTests; -using HotChocolate.Execution.Processing; using Microsoft.AspNetCore.Http; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; From 8fbea755c5152f89fff63ca074e99f9ff8971aea Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Wed, 28 Jan 2026 17:26:57 -0800 Subject: [PATCH 05/18] Changes based on comments --- .../Services/MetadataProviders/MsSqlMetadataProvider.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 5002c2acb5..b815216d39 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -300,13 +300,13 @@ protected override async Task GenerateAutoentitiesIntoEntities() { foreach ((string name, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) { - JsonArray? resultArray = await QueryAutoentitiesConfiguration(autoentity); + JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity); // TODO: Finish implementation of autoentities generation in task #3052 } } } - public async Task QueryAutoentitiesConfiguration(Autoentity autoentity) + public async Task QueryAutoentitiesAsync(Autoentity autoentity) { string include = string.Join(",", autoentity.Patterns.Include); string exclude = string.Join(",", autoentity.Patterns.Exclude); @@ -324,6 +324,11 @@ protected override async Task GenerateAutoentitiesIntoEntities() _logger.LogInformation($"Autoentities exclude pattern: {exclude}"); _logger.LogInformation($"Autoentities name pattern: {namePattern}"); + _logger.LogInformation("Query for Autoentities is being executed with the following parameters."); + _logger.LogInformation($"Autoentities include pattern: {include}"); + _logger.LogInformation($"Autoentities exclude pattern: {exclude}"); + _logger.LogInformation($"Autoentities name pattern: {namePattern}"); + JsonArray? resultArray = await QueryExecutor.ExecuteQueryAsync( sqltext: getAutoentitiesQuery, parameters: parameters, From cf4debf1013b96dec6b826ca59fea0da1f43a2f2 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 29 Jan 2026 17:19:58 -0800 Subject: [PATCH 06/18] Added changes based on comments --- src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index b815216d39..ebea0c0992 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -324,11 +324,6 @@ protected override async Task GenerateAutoentitiesIntoEntities() _logger.LogInformation($"Autoentities exclude pattern: {exclude}"); _logger.LogInformation($"Autoentities name pattern: {namePattern}"); - _logger.LogInformation("Query for Autoentities is being executed with the following parameters."); - _logger.LogInformation($"Autoentities include pattern: {include}"); - _logger.LogInformation($"Autoentities exclude pattern: {exclude}"); - _logger.LogInformation($"Autoentities name pattern: {namePattern}"); - JsonArray? resultArray = await QueryExecutor.ExecuteQueryAsync( sqltext: getAutoentitiesQuery, parameters: parameters, From 88b8c662df4ad0200aa2494216bc70920997ef45 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 29 Jan 2026 17:24:38 -0800 Subject: [PATCH 07/18] Comment out failing section --- .../Services/MetadataProviders/MsSqlMetadataProvider.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index ebea0c0992..fb49e0d33a 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -293,7 +293,8 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType) } /// - protected override async Task GenerateAutoentitiesIntoEntities() + // TODO: Finish implementation of autoentities generation in task #3052 + /*protected override async Task GenerateAutoentitiesIntoEntities() { RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); if (runtimeConfig.Autoentities is not null) @@ -301,10 +302,9 @@ protected override async Task GenerateAutoentitiesIntoEntities() foreach ((string name, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) { JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity); - // TODO: Finish implementation of autoentities generation in task #3052 } } - } + }*/ public async Task QueryAutoentitiesAsync(Autoentity autoentity) { From 612d4bdb97fe9a94aa0dc236c1ef46877ebe5288 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 29 Jan 2026 17:30:33 -0800 Subject: [PATCH 08/18] Fix test failures --- .../MetadataProviders/MsSqlMetadataProvider.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index fb49e0d33a..a22452b2f1 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -294,17 +294,19 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType) /// // TODO: Finish implementation of autoentities generation in task #3052 - /*protected override async Task GenerateAutoentitiesIntoEntities() + protected override async Task GenerateAutoentitiesIntoEntities() { - RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + await Task.CompletedTask; // Temporary await to suppress build errors. + + /*RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); if (runtimeConfig.Autoentities is not null) { foreach ((string name, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) { JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity); } - } - }*/ + }*/ + } public async Task QueryAutoentitiesAsync(Autoentity autoentity) { From a396fab79a60389e57807a93ae345280211ae736 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 29 Jan 2026 17:48:50 -0800 Subject: [PATCH 09/18] Fix formatting issues --- src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index a22452b2f1..4e3be0548a 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -60,7 +60,6 @@ public override Type SqlToCLRType(string sqlType) /// public override async Task PopulateTriggerMetadataForTable(string entityName, string schemaName, string tableName, SourceDefinition sourceDefinition) { - string enumerateEnabledTriggers = SqlQueryBuilder.BuildFetchEnabledTriggersQuery(); Dictionary parameters = new() { From c8ed0d5d1c3ba6a439633416b85a0796a0355eb8 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 5 Feb 2026 09:52:16 -0800 Subject: [PATCH 10/18] Generate in-memory entities --- src/Config/ObjectModel/RuntimeConfig.cs | 5 ++ .../MsSqlMetadataProvider.cs | 68 +++++++++++++++++-- .../MetadataProviders/SqlMetadataProvider.cs | 3 +- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 1e567da1cd..2f9a4a5e29 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -245,6 +245,11 @@ public bool TryGetEntityNameFromPath(string entityPathName, [NotNullWhen(true)] return _entityPathNameToEntityName.TryGetValue(entityPathName, out entityName); } + public bool TryAddEntityNameToDataSourceName(string entityName) + { + return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName); + } + /// /// Constructor for runtimeConfig. /// To be used when setting up from cli json scenario. diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 4e3be0548a..8c27c91650 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -292,19 +292,73 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType) } /// - // TODO: Finish implementation of autoentities generation in task #3052 protected override async Task GenerateAutoentitiesIntoEntities() { - await Task.CompletedTask; // Temporary await to suppress build errors. + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + Dictionary entities = (Dictionary)_entities; + if (runtimeConfig.Autoentities is null) + { + return; + } - /*RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); - if (runtimeConfig.Autoentities is not null) + foreach ((string autoentityName, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) { - foreach ((string name, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) + JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity); + if (resultArray is null) { - JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity); + continue; } - }*/ + + foreach (JsonObject resultObject in resultArray!) + { + // Extract the entity name, schema, and database object name from the query result. + // The SQL query returns these values with placeholders already replaced. + + // TODO: change it so that we don't need to verify if the names are null + string? entityName = resultObject["entity_name"]?.GetValue(); + string? schemaName = resultObject["schema"]?.GetValue(); + string? objectName = resultObject["object"]?.GetValue(); + + if (string.IsNullOrWhiteSpace(entityName) || string.IsNullOrWhiteSpace(objectName)) + { + _logger.LogError("Skipping autoentity generation: entity_name or object is null or empty for autoentity pattern '{AutoentityName}'.", autoentityName); + continue; + } + + // Create the entity using the template settings and permissions from the autoentity configuration. + // Currently the source type is always Table for auto-generated entities from database objects. + Entity generatedEntity = new( + Source: new EntitySource( + Object: objectName, + Type: EntitySourceType.Table, + Parameters: null, + KeyFields: null), + GraphQL: autoentity.Template.GraphQL, + Rest: autoentity.Template.Rest, + Mcp: autoentity.Template.Mcp, + Permissions: autoentity.Permissions, + Cache: autoentity.Template.Cache, + Health: autoentity.Template.Health, + Fields: null, + Relationships: null, + Mappings: new()); + + // Add the generated entity to the linking entities dictionary. + // This allows the entity to be processed later during metadata population. + // TODO: Add new log message that shows if the rest calls are enabled or disabled for each of this new entities + if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddEntityNameToDataSourceName(entityName)) + { + // TODO: need to make a better message that includes if the conflict is with a user-defined entity or another auto-generated entity. + throw new DataApiBuilderException( + message: $"Entity with name '{entityName}' already exists. Cannot create new entity from autoentity pattern '{autoentityName}'.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + } + } + + // TODO: include warning message that no autoentities were created if it is the same number between the _entities and the new entities. + _entities = entities; } public async Task QueryAutoentitiesAsync(Autoentity autoentity) diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index c9a62ca470..bf729a1d78 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -39,7 +39,7 @@ public abstract class SqlMetadataProvider : private readonly DatabaseType _databaseType; // Represents the entities exposed in the runtime config. - private IReadOnlyDictionary _entities; + protected IReadOnlyDictionary _entities; // Represents the linking entities created by DAB to support multiple mutations for entities having an M:N relationship between them. protected Dictionary _linkingEntities = new(); @@ -307,6 +307,7 @@ public string GetEntityName(string graphQLType) public async Task InitializeAsync() { System.Diagnostics.Stopwatch timer = System.Diagnostics.Stopwatch.StartNew(); + if (GetDatabaseType() == DatabaseType.MSSQL) { await GenerateAutoentitiesIntoEntities(); From 947fdecfe796607cfb2e30f739bd8ea1abb70cf0 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Tue, 10 Feb 2026 17:01:11 -0800 Subject: [PATCH 11/18] Changes to generate autoentities as entities --- src/Config/ObjectModel/RuntimeConfig.cs | 19 ++- src/Config/RuntimeConfigLoader.cs | 5 + .../Configurations/RuntimeConfigProvider.cs | 9 ++ .../Configurations/RuntimeConfigValidator.cs | 2 +- .../MsSqlMetadataProvider.cs | 34 ++-- .../Configuration/ConfigurationTests.cs | 151 ++++++++++++++++++ src/Service/Startup.cs | 2 + 7 files changed, 204 insertions(+), 18 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 2f9a4a5e29..55de26c99d 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -273,7 +273,7 @@ public RuntimeConfig( this.DataSource = DataSource; this.Runtime = Runtime; this.AzureKeyVault = AzureKeyVault; - this.Entities = Entities; + this.Entities = Entities ?? new RuntimeEntities(new Dictionary()); this.Autoentities = Autoentities; this.DefaultDataSourceName = Guid.NewGuid().ToString(); @@ -292,17 +292,20 @@ public RuntimeConfig( }; _entityNameToDataSourceName = new Dictionary(); - if (Entities is null) + if (Entities is null && Autoentities is null) { 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); + } } // Process data source and entities information for each database in multiple database scenario. @@ -310,7 +313,7 @@ public RuntimeConfig( if (DataSourceFiles is not null && DataSourceFiles.SourceFiles is not null) { - IEnumerable> allEntities = Entities.AsEnumerable(); + IEnumerable>? allEntities = Entities?.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. @@ -327,7 +330,7 @@ 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()); + allEntities = allEntities?.Concat(config.Entities.AsEnumerable()); } catch (Exception e) { @@ -341,7 +344,7 @@ 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()); } SetupDataSourcesUsed(); diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 8939f34e21..ae5c2dde95 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -499,4 +499,9 @@ public void InsertWantedChangesInProductionMode() RuntimeConfig = runtimeConfigCopy; } } + + public void EditRuntimeConfig(RuntimeConfig newRuntimeConfig) + { + RuntimeConfig = newRuntimeConfig; + } } diff --git a/src/Core/Configurations/RuntimeConfigProvider.cs b/src/Core/Configurations/RuntimeConfigProvider.cs index b46a716f48..79f717529e 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -411,4 +411,13 @@ private static RuntimeConfig HandleCosmosNoSqlConfiguration(string? schema, Runt return runtimeConfig; } + + public void AddNewEntitiesToConfig(Dictionary entities) + { + RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with + { + Entities = new(entities) + }; + _configLoader.EditRuntimeConfig(newRuntimeConfig); + } } diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 208c6fb331..009339bb5f 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -602,7 +602,7 @@ public void ValidateEntityConfiguration(RuntimeConfig runtimeConfig) // Stores the unique rest paths configured for different entities present in the config. HashSet restPathsForEntities = new(); - foreach ((string entityName, Entity entity) in runtimeConfig.Entities) + foreach ((string entityName, Entity entity) in runtimeConfig.Entities ?? Enumerable.Empty>()) { if (runtimeConfig.IsRestEnabled && entity.Rest is not null && entity.Rest.Enabled) { diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 8c27c91650..801d7bc493 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -6,6 +6,7 @@ using System.Net; using System.Text.Json; using System.Text.Json.Nodes; +using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; @@ -294,6 +295,7 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType) /// protected override async Task GenerateAutoentitiesIntoEntities() { + int addedEntities = 0; RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); Dictionary entities = (Dictionary)_entities; if (runtimeConfig.Autoentities is null) @@ -313,11 +315,9 @@ protected override async Task GenerateAutoentitiesIntoEntities() { // Extract the entity name, schema, and database object name from the query result. // The SQL query returns these values with placeholders already replaced. - - // TODO: change it so that we don't need to verify if the names are null - string? entityName = resultObject["entity_name"]?.GetValue(); - string? schemaName = resultObject["schema"]?.GetValue(); - string? objectName = resultObject["object"]?.GetValue(); + string entityName = resultObject["entity_name"]!.ToString(); + string schemaName = resultObject["schema"]!.ToString(); + string objectName = resultObject["object"]!.ToString(); if (string.IsNullOrWhiteSpace(entityName) || string.IsNullOrWhiteSpace(objectName)) { @@ -345,20 +345,36 @@ protected override async Task GenerateAutoentitiesIntoEntities() // Add the generated entity to the linking entities dictionary. // This allows the entity to be processed later during metadata population. - // TODO: Add new log message that shows if the rest calls are enabled or disabled for each of this new entities if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddEntityNameToDataSourceName(entityName)) { // TODO: need to make a better message that includes if the conflict is with a user-defined entity or another auto-generated entity. throw new DataApiBuilderException( - message: $"Entity with name '{entityName}' already exists. Cannot create new entity from autoentity pattern '{autoentityName}'.", + message: $"Entity with name '{entityName}' already exists. Cannot create new entity from autoentity pattern with definition-name '{autoentityName}'.", statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); } + + if (runtimeConfig.IsRestEnabled) + { + _logger.LogInformation("[{entity}] REST path: {globalRestPath}/{entityRestPath}", entityName, runtimeConfig.RestPath, entityName); + } + else + { + _logger.LogInformation(message: "REST calls are disabled for the entity: {entity}", entityName); + } + + addedEntities++; } } - // TODO: include warning message that no autoentities were created if it is the same number between the _entities and the new entities. - _entities = entities; + if (addedEntities == 0) + { + _logger.LogWarning("No new entities were generated from the autoentity patterns defined in the configuration."); + } + else + { + _runtimeConfigProvider.AddNewEntitiesToConfig(entities); + } } public async Task QueryAutoentitiesAsync(Autoentity autoentity) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 9df54be519..bc41f231e1 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5231,6 +5231,157 @@ public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit() } } + [TestCategory(TestCategory.MSSQL)] + [TestMethod] + public async Task TestAutoentitiesAreGeneratedIntoEntities() + { + // Arrange + Dictionary autoentityMap = new() + { + { + "PublisherAutoEntity", new Autoentity( + Patterns: new AutoentityPatterns( + Include: new[] { "%publishers%" }, + Exclude: null, + Name: null // Let DAB decide entity naming + ), + Template: new AutoentityTemplate( + Rest: new EntityRestOptions(Enabled: true), // Enable REST as requested + GraphQL: new EntityGraphQLOptions( + Singular: string.Empty, + Plural: string.Empty, + Enabled: true + ), + Health: null, + Cache: null + ), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) } + ) + } + }; + + // Create DataSource for MSSQL connection + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + // Build complete runtime configuration with autoentities + RuntimeConfig configuration = new( + Schema: "TestAutoentitiesSchema", + DataSource: dataSource, + Runtime: new( + Rest: new(Enabled: true), + GraphQL: new(Enabled: true), + Mcp: new(Enabled: false), + Host: new( + Cors: null, + Authentication: new Config.ObjectModel.AuthenticationOptions( + Provider: nameof(EasyAuthType.StaticWebApps), + Jwt: null + ) + ) + ), + Entities: new(new Dictionary()), // Start with empty entities + Autoentities: new RuntimeAutoentities(autoentityMap) + ); + + const string CUSTOM_CONFIG = "autoentities-test-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // Act + HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + + string graphqlQuery = @" + { + publishers { + items { + id + name + } + } + }"; + + object graphqlPayload = new { query = graphqlQuery }; + HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(graphqlPayload) + }; + HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest); + + // Assert + // Verify REST response + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, + "REST request to auto-generated entity should succeed"); + + // TODO: check this thing + string restResponseBody = await restResponse.Content.ReadAsStringAsync(); + Assert.IsFalse(string.IsNullOrEmpty(restResponseBody), + "REST response should contain data"); + + // Parse and validate REST response structure + JsonElement restJsonResponse = JsonSerializer.Deserialize(restResponseBody); + Assert.IsTrue(restJsonResponse.TryGetProperty("value", out JsonElement restDataArray), + "REST response should contain 'value' property with data array"); + Assert.AreEqual(JsonValueKind.Array, restDataArray.ValueKind, + "REST response data should be an array"); + + // TODO: Add specific field validation here based on your publishers table schema + // I think it can be done by sending a request to the database and get compare the values we get with this response + // Example: + // if (restDataArray.GetArrayLength() > 0) + // { + // JsonElement firstPublisher = restDataArray[0]; + // Assert.IsTrue(firstPublisher.TryGetProperty("id", out _), "Publisher should have id field"); + // Assert.IsTrue(firstPublisher.TryGetProperty("name", out _), "Publisher should have name field"); + // + // // Add more specific field and value assertions here + // } + + // Verify GraphQL response + Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, + "GraphQL request to auto-generated entity should succeed"); + + // TODO: Check this thing, I think we can just check if it is null without having to do too much stuff with readasstringasync + string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); + Assert.IsFalse(string.IsNullOrEmpty(graphqlResponseBody), + "GraphQL response should contain data"); + + // Parse and validate GraphQL response structure + JsonElement graphqlJsonResponse = JsonSerializer.Deserialize(graphqlResponseBody); + Assert.IsFalse(graphqlJsonResponse.TryGetProperty("errors", out _), + "GraphQL response should not contain errors"); + Assert.IsTrue(graphqlJsonResponse.TryGetProperty("data", out JsonElement graphqlData), + "GraphQL response should contain data"); + Assert.IsTrue(graphqlData.TryGetProperty("publishers", out JsonElement publishersData), + "GraphQL data should contain publishers"); + Assert.IsTrue(publishersData.TryGetProperty("items", out JsonElement publishersItems), + "GraphQL publishers should contain items array"); + Assert.AreEqual(JsonValueKind.Array, publishersItems.ValueKind, + "GraphQL publishers items should be an array"); + + // TODO: Add specific GraphQL field validation here based on your publishers table schema + // Example: + // if (publishersItems.GetArrayLength() > 0) + // { + // JsonElement firstPublisher = publishersItems[0]; + // Assert.IsTrue(firstPublisher.TryGetProperty("id", out JsonElement idValue), "Publisher should have id field"); + // Assert.IsTrue(firstPublisher.TryGetProperty("name", out JsonElement nameValue), "Publisher should have name field"); + // + // // Add specific value assertions here: + // // Assert.AreEqual("Expected Publisher Name", nameValue.GetString()); + // // Assert.AreEqual(123, idValue.GetInt32()); + // } + + // Success! The autoentity was successfully converted to a working entity + // that responds to both REST and GraphQL requests + } + } + /// /// Tests the behavior of GraphQL queries in non-hosted mode when the depth limit is explicitly set to -1 or null. /// Setting the depth limit to -1 is intended to disable the depth limit check, allowing queries of any depth. diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index e16673347c..47e4471a79 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -1140,11 +1140,13 @@ private async Task PerformOnConfigChangeAsync(IApplicationBuilder app) // Now that the configuration has been set, perform validation of the runtime config // itself. + // TODO: Add this check at the end of generating the new entities and skip this one only if it is runtimeConfigValidator.ValidateConfigProperties(); if (runtimeConfig.IsDevelopmentMode()) { // Running only in developer mode to ensure fast and smooth startup in production. + // TODO: Add this check at the end of generating the new entities and skip this one only if it is runtimeConfigValidator.ValidatePermissionsInConfig(runtimeConfig); } From e5ae16a2d68a59d562b83ac60a8b62c4d782c99a Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 12 Feb 2026 14:36:42 -0800 Subject: [PATCH 12/18] Add new testing --- .../Configuration/ConfigurationTests.cs | 126 ++++++++---------- 1 file changed, 59 insertions(+), 67 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index bc41f231e1..39e31d22cc 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.ComponentModel; using System.IdentityModel.Tokens.Jwt; using System.IO; using System.IO.Abstractions; @@ -5232,10 +5233,51 @@ public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit() } [TestCategory(TestCategory.MSSQL)] - [TestMethod] - public async Task TestAutoentitiesAreGeneratedIntoEntities() + [DataTestMethod] + [DataRow(true, DisplayName = "Test Autoentities with additional entities")] + [DataRow(false, DisplayName = "Test Autoentities without additional entities")] + public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities) { // Arrange + EntityRelationship bookRelationship = new(Cardinality: Cardinality.One, + TargetEntity: "BookPublisher", + SourceFields: new string[] { }, + TargetFields: new string[] { }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null); + + Entity bookEntity = new(Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: new Dictionary() { { "publishers", bookRelationship } }, + Mappings: null); + + EntityRelationship publisherRelationship = new(Cardinality: Cardinality.Many, + TargetEntity: "Book", + SourceFields: new string[] { }, + TargetFields: new string[] { }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null); + + Entity publisherEntity = new( + Source: new("publishers", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "bookpublisher", Plural: "bookpublishers"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: new Dictionary() { { "books", publisherRelationship } }, + Mappings: null); + + Dictionary entityMap = new() + { + { "Book", bookEntity }, + { "BookPublisher", publisherEntity } + }; + Dictionary autoentityMap = new() { { @@ -5243,10 +5285,10 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities() Patterns: new AutoentityPatterns( Include: new[] { "%publishers%" }, Exclude: null, - Name: null // Let DAB decide entity naming + Name: null ), Template: new AutoentityTemplate( - Rest: new EntityRestOptions(Enabled: true), // Enable REST as requested + Rest: new EntityRestOptions(Enabled: true), GraphQL: new EntityGraphQLOptions( Singular: string.Empty, Plural: string.Empty, @@ -5280,14 +5322,13 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities() ) ) ), - Entities: new(new Dictionary()), // Start with empty entities + Entities: new (useEntities ? entityMap : new Dictionary()), Autoentities: new RuntimeAutoentities(autoentityMap) ); - const string CUSTOM_CONFIG = "autoentities-test-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson()); - string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" }; + string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) @@ -5314,71 +5355,22 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities() HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest); // Assert + string expectedResponseFragment = @"{""id"":1156,""name"":""The First Publisher""}"; + // Verify REST response - Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, - "REST request to auto-generated entity should succeed"); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, "REST request to auto-generated entity should succeed"); - // TODO: check this thing string restResponseBody = await restResponse.Content.ReadAsStringAsync(); - Assert.IsFalse(string.IsNullOrEmpty(restResponseBody), - "REST response should contain data"); - - // Parse and validate REST response structure - JsonElement restJsonResponse = JsonSerializer.Deserialize(restResponseBody); - Assert.IsTrue(restJsonResponse.TryGetProperty("value", out JsonElement restDataArray), - "REST response should contain 'value' property with data array"); - Assert.AreEqual(JsonValueKind.Array, restDataArray.ValueKind, - "REST response data should be an array"); - - // TODO: Add specific field validation here based on your publishers table schema - // I think it can be done by sending a request to the database and get compare the values we get with this response - // Example: - // if (restDataArray.GetArrayLength() > 0) - // { - // JsonElement firstPublisher = restDataArray[0]; - // Assert.IsTrue(firstPublisher.TryGetProperty("id", out _), "Publisher should have id field"); - // Assert.IsTrue(firstPublisher.TryGetProperty("name", out _), "Publisher should have name field"); - // - // // Add more specific field and value assertions here - // } - + Assert.IsTrue(!string.IsNullOrEmpty(restResponseBody), "REST response should contain data"); + Assert.IsTrue(restResponseBody.Contains(expectedResponseFragment)); + // Verify GraphQL response - Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, - "GraphQL request to auto-generated entity should succeed"); + Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, "GraphQL request to auto-generated entity should succeed"); - // TODO: Check this thing, I think we can just check if it is null without having to do too much stuff with readasstringasync string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); - Assert.IsFalse(string.IsNullOrEmpty(graphqlResponseBody), - "GraphQL response should contain data"); - - // Parse and validate GraphQL response structure - JsonElement graphqlJsonResponse = JsonSerializer.Deserialize(graphqlResponseBody); - Assert.IsFalse(graphqlJsonResponse.TryGetProperty("errors", out _), - "GraphQL response should not contain errors"); - Assert.IsTrue(graphqlJsonResponse.TryGetProperty("data", out JsonElement graphqlData), - "GraphQL response should contain data"); - Assert.IsTrue(graphqlData.TryGetProperty("publishers", out JsonElement publishersData), - "GraphQL data should contain publishers"); - Assert.IsTrue(publishersData.TryGetProperty("items", out JsonElement publishersItems), - "GraphQL publishers should contain items array"); - Assert.AreEqual(JsonValueKind.Array, publishersItems.ValueKind, - "GraphQL publishers items should be an array"); - - // TODO: Add specific GraphQL field validation here based on your publishers table schema - // Example: - // if (publishersItems.GetArrayLength() > 0) - // { - // JsonElement firstPublisher = publishersItems[0]; - // Assert.IsTrue(firstPublisher.TryGetProperty("id", out JsonElement idValue), "Publisher should have id field"); - // Assert.IsTrue(firstPublisher.TryGetProperty("name", out JsonElement nameValue), "Publisher should have name field"); - // - // // Add specific value assertions here: - // // Assert.AreEqual("Expected Publisher Name", nameValue.GetString()); - // // Assert.AreEqual(123, idValue.GetInt32()); - // } - - // Success! The autoentity was successfully converted to a working entity - // that responds to both REST and GraphQL requests + Assert.IsTrue(!string.IsNullOrEmpty(graphqlResponseBody), "GraphQL response should contain data"); + Assert.IsFalse(graphqlResponseBody.Contains("errors"), "GraphQL response should not contain errors"); + Assert.IsTrue(graphqlResponseBody.Contains(expectedResponseFragment)); } } From 2f256e43c192f248339f420baf4b7c64dd8b1911 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 12 Feb 2026 14:55:07 -0800 Subject: [PATCH 13/18] Add check so that either entities or autoentities is required --- 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 55de26c99d..4833ce5d07 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -292,7 +292,7 @@ public RuntimeConfig( }; _entityNameToDataSourceName = new Dictionary(); - if (Entities is null && Autoentities is null) + if ((Entities is null || Entities.Entities.Count == 0) && Autoentities is null) { throw new DataApiBuilderException( message: "Configuration file should contain either at least the Entities or Autoentities property", From 6fca50d90d29584fc164aecb2c3a408d0854ef44 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Thu, 12 Feb 2026 17:21:42 -0800 Subject: [PATCH 14/18] Fix grammar errors --- src/Core/Configurations/RuntimeConfigValidator.cs | 2 +- .../MetadataProviders/MsSqlMetadataProvider.cs | 1 - .../Configuration/ConfigurationTests.cs | 15 +++++++-------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 009339bb5f..208c6fb331 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -602,7 +602,7 @@ public void ValidateEntityConfiguration(RuntimeConfig runtimeConfig) // Stores the unique rest paths configured for different entities present in the config. HashSet restPathsForEntities = new(); - foreach ((string entityName, Entity entity) in runtimeConfig.Entities ?? Enumerable.Empty>()) + foreach ((string entityName, Entity entity) in runtimeConfig.Entities) { if (runtimeConfig.IsRestEnabled && entity.Rest is not null && entity.Rest.Enabled) { diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 801d7bc493..330112ac6e 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -6,7 +6,6 @@ using System.Net; using System.Text.Json; using System.Text.Json.Nodes; -using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 39e31d22cc..2156413375 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; -using System.ComponentModel; using System.IdentityModel.Tokens.Jwt; using System.IO; using System.IO.Abstractions; @@ -5290,8 +5289,8 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities) Template: new AutoentityTemplate( Rest: new EntityRestOptions(Enabled: true), GraphQL: new EntityGraphQLOptions( - Singular: string.Empty, - Plural: string.Empty, + Singular: string.Empty, + Plural: string.Empty, Enabled: true ), Health: null, @@ -5315,14 +5314,14 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities) GraphQL: new(Enabled: true), Mcp: new(Enabled: false), Host: new( - Cors: null, + Cors: null, Authentication: new Config.ObjectModel.AuthenticationOptions( - Provider: nameof(EasyAuthType.StaticWebApps), + Provider: nameof(EasyAuthType.StaticWebApps), Jwt: null ) ) ), - Entities: new (useEntities ? entityMap : new Dictionary()), + Entities: new(useEntities ? entityMap : new Dictionary()), Autoentities: new RuntimeAutoentities(autoentityMap) ); @@ -5363,8 +5362,8 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities) string restResponseBody = await restResponse.Content.ReadAsStringAsync(); Assert.IsTrue(!string.IsNullOrEmpty(restResponseBody), "REST response should contain data"); Assert.IsTrue(restResponseBody.Contains(expectedResponseFragment)); - - // Verify GraphQL response + + // Verify GraphQL response Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, "GraphQL request to auto-generated entity should succeed"); string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); From ba070db5b7d67bde793215a2660cc4d16cbda5d0 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Fri, 13 Feb 2026 11:49:53 -0800 Subject: [PATCH 15/18] Changes to fix tests --- .../MetadataProviders/MsSqlMetadataProvider.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 330112ac6e..b7dfec8698 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -310,8 +310,16 @@ protected override async Task GenerateAutoentitiesIntoEntities() continue; } - foreach (JsonObject resultObject in resultArray!) + foreach (JsonObject? resultObject in resultArray) { + if (resultObject is null) + { + throw new DataApiBuilderException( + message: $"Cannot create new entity from autoentity pattern due to an internal error.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + // Extract the entity name, schema, and database object name from the query result. // The SQL query returns these values with placeholders already replaced. string entityName = resultObject["entity_name"]!.ToString(); From 5bd10a9b5b5df647d456203e2d61915ba205645a Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Tue, 17 Feb 2026 11:50:35 -0800 Subject: [PATCH 16/18] Changes based on comments --- src/Config/ObjectModel/RuntimeConfig.cs | 4 ++-- src/Core/Configurations/RuntimeConfigProvider.cs | 2 +- .../MetadataProviders/MsSqlMetadataProvider.cs | 10 +++++----- src/Service.Tests/Configuration/ConfigurationTests.cs | 10 +++++++--- src/Service/Startup.cs | 4 ++-- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 4833ce5d07..6615d24a1a 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -292,10 +292,10 @@ public RuntimeConfig( }; _entityNameToDataSourceName = new Dictionary(); - if ((Entities is null || Entities.Entities.Count == 0) && Autoentities is null) + if (this.Entities.Entities.Count == 0 && Autoentities is null) { throw new DataApiBuilderException( - message: "Configuration file should contain either at least the Entities or Autoentities property", + message: "Configuration file should contain either at least the entities or autoentities property", statusCode: HttpStatusCode.UnprocessableEntity, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } diff --git a/src/Core/Configurations/RuntimeConfigProvider.cs b/src/Core/Configurations/RuntimeConfigProvider.cs index 79f717529e..a588d884c7 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -412,7 +412,7 @@ private static RuntimeConfig HandleCosmosNoSqlConfiguration(string? schema, Runt return runtimeConfig; } - public void AddNewEntitiesToConfig(Dictionary entities) + public void AddMergedEntitiesToConfig(Dictionary entities) { RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with { diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index b7dfec8698..36bbe11cf6 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -322,11 +322,11 @@ protected override async Task GenerateAutoentitiesIntoEntities() // Extract the entity name, schema, and database object name from the query result. // The SQL query returns these values with placeholders already replaced. - string entityName = resultObject["entity_name"]!.ToString(); - string schemaName = resultObject["schema"]!.ToString(); - string objectName = resultObject["object"]!.ToString(); + string? entityName = resultObject["entity_name"]?.ToString(); + string? objectName = resultObject["object"]?.ToString(); + string? schemaName = resultObject["schema"]?.ToString(); - if (string.IsNullOrWhiteSpace(entityName) || string.IsNullOrWhiteSpace(objectName)) + if (string.IsNullOrWhiteSpace(entityName) || string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(schemaName)) { _logger.LogError("Skipping autoentity generation: entity_name or object is null or empty for autoentity pattern '{AutoentityName}'.", autoentityName); continue; @@ -380,7 +380,7 @@ protected override async Task GenerateAutoentitiesIntoEntities() } else { - _runtimeConfigProvider.AddNewEntitiesToConfig(entities); + _runtimeConfigProvider.AddMergedEntitiesToConfig(entities); } } diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 2156413375..d58022637c 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5233,9 +5233,9 @@ public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit() [TestCategory(TestCategory.MSSQL)] [DataTestMethod] - [DataRow(true, DisplayName = "Test Autoentities with additional entities")] - [DataRow(false, DisplayName = "Test Autoentities without additional entities")] - public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities) + [DataRow(true, 4, DisplayName = "Test Autoentities with additional entities")] + [DataRow(false, 2, DisplayName = "Test Autoentities without additional entities")] + public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities, int expectedEntityCount) { // Arrange EntityRelationship bookRelationship = new(Cardinality: Cardinality.One, @@ -5333,6 +5333,7 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities) using (HttpClient client = server.CreateClient()) { // Act + RuntimeConfigProvider configProvider = server.Services.GetService(); HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers"); HttpResponseMessage restResponse = await client.SendAsync(restRequest); @@ -5356,6 +5357,9 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities) // Assert string expectedResponseFragment = @"{""id"":1156,""name"":""The First Publisher""}"; + // Verify number of entities + Assert.AreEqual(expectedEntityCount, configProvider.GetConfig().Entities.Entities.Count, "Number of generated entities is not what is expected"); + // Verify REST response Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, "REST request to auto-generated entity should succeed"); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 47e4471a79..bbc4d062e1 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -1140,13 +1140,13 @@ private async Task PerformOnConfigChangeAsync(IApplicationBuilder app) // Now that the configuration has been set, perform validation of the runtime config // itself. - // TODO: Add this check at the end of generating the new entities and skip this one only if it is + // TODO: Task #3131. Need to change validation so that the validation of entities is done after the autoentities are generated and added with the regular entitites. runtimeConfigValidator.ValidateConfigProperties(); if (runtimeConfig.IsDevelopmentMode()) { // Running only in developer mode to ensure fast and smooth startup in production. - // TODO: Add this check at the end of generating the new entities and skip this one only if it is + // TODO: Task #3131. Need to change validation so that the validation of entities is done after the autoentities are generated and added with the regular entitites. runtimeConfigValidator.ValidatePermissionsInConfig(runtimeConfig); } From 83d706f5dcd2260736dc50055f91b43903e257e2 Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Tue, 17 Feb 2026 21:30:44 -0800 Subject: [PATCH 17/18] Fix bug --- 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 6615d24a1a..bd90357eab 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -292,7 +292,7 @@ public RuntimeConfig( }; _entityNameToDataSourceName = new Dictionary(); - if (this.Entities.Entities.Count == 0 && Autoentities is null) + if (Entities is null && this.Entities.Entities.Count == 0 && Autoentities is null) { throw new DataApiBuilderException( message: "Configuration file should contain either at least the entities or autoentities property", From 4a743c7c5618173ac90cb07f4219990f49deb95b Mon Sep 17 00:00:00 2001 From: Ruben Cerna Date: Fri, 20 Feb 2026 11:43:16 -0800 Subject: [PATCH 18/18] Changes to ensure autoentities work with multiple data sources --- .../RuntimeAutoentitiesConverter.cs | 2 +- src/Config/ObjectModel/RuntimeAutoentities.cs | 21 +++-- src/Config/ObjectModel/RuntimeConfig.cs | 57 +++++++++++++- .../Configurations/RuntimeConfigProvider.cs | 8 +- .../MsSqlMetadataProvider.cs | 27 +++---- .../MetadataProviders/SqlMetadataProvider.cs | 41 +++++----- .../Configuration/ConfigurationTests.cs | 4 +- .../Configuration/RuntimeConfigLoaderTests.cs | 30 ++++++++ src/Service.Tests/Multidab-config.MsSql.json | 76 +++++++++++++++++++ 9 files changed, 219 insertions(+), 47 deletions(-) diff --git a/src/Config/Converters/RuntimeAutoentitiesConverter.cs b/src/Config/Converters/RuntimeAutoentitiesConverter.cs index b65bcb9989..597ef18523 100644 --- a/src/Config/Converters/RuntimeAutoentitiesConverter.cs +++ b/src/Config/Converters/RuntimeAutoentitiesConverter.cs @@ -29,7 +29,7 @@ class RuntimeAutoentitiesConverter : JsonConverter public override void Write(Utf8JsonWriter writer, RuntimeAutoentities value, JsonSerializerOptions options) { writer.WriteStartObject(); - foreach ((string key, Autoentity autoEntity) in value.AutoEntities) + foreach ((string key, Autoentity autoEntity) in value.Autoentities) { writer.WritePropertyName(key); JsonSerializer.Serialize(writer, autoEntity, options); diff --git a/src/Config/ObjectModel/RuntimeAutoentities.cs b/src/Config/ObjectModel/RuntimeAutoentities.cs index 0fec45f5a1..4148e9ba02 100644 --- a/src/Config/ObjectModel/RuntimeAutoentities.cs +++ b/src/Config/ObjectModel/RuntimeAutoentities.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections; using System.Text.Json.Serialization; using Azure.DataApiBuilder.Config.Converters; @@ -10,19 +11,29 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// Represents a collection of available from the RuntimeConfig. /// [JsonConverter(typeof(RuntimeAutoentitiesConverter))] -public record RuntimeAutoentities +public record RuntimeAutoentities : IEnumerable> { /// /// The collection of available from the RuntimeConfig. /// - public IReadOnlyDictionary AutoEntities { get; init; } + public IReadOnlyDictionary Autoentities { get; init; } /// /// Creates a new instance of the class using a collection of entities. /// - /// The collection of auto-entities to map to RuntimeAutoentities. - public RuntimeAutoentities(IReadOnlyDictionary autoEntities) + /// The collection of auto-entities to map to RuntimeAutoentities. + public RuntimeAutoentities(IReadOnlyDictionary autoentities) { - AutoEntities = autoEntities; + Autoentities = autoentities; + } + + public IEnumerator> GetEnumerator() + { + return Autoentities.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); } } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index bd90357eab..3de9503725 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(); /// @@ -250,6 +252,16 @@ public bool TryAddEntityNameToDataSourceName(string entityName) return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName); } + public bool TryAddEntityNameToDataSourceName(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. @@ -274,7 +286,7 @@ public RuntimeConfig( this.Runtime = Runtime; this.AzureKeyVault = AzureKeyVault; this.Entities = Entities ?? new RuntimeEntities(new Dictionary()); - this.Autoentities = Autoentities; + this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); this.DefaultDataSourceName = Guid.NewGuid().ToString(); if (this.DataSource is null) @@ -292,7 +304,8 @@ public RuntimeConfig( }; _entityNameToDataSourceName = new Dictionary(); - if (Entities is null && this.Entities.Entities.Count == 0 && Autoentities is null) + if (Entities is null && this.Entities.Entities.Count == 0 && + Autoentities is null && this.Autoentities.Autoentities.Count == 0) { throw new DataApiBuilderException( message: "Configuration file should contain either at least the entities or autoentities property", @@ -308,12 +321,21 @@ public RuntimeConfig( } } + 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. this.DataSourceFiles = DataSourceFiles; if (DataSourceFiles is not null && DataSourceFiles.SourceFiles is not null) { 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. @@ -330,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); + _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) { @@ -345,6 +369,7 @@ public RuntimeConfig( } 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(); @@ -359,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; @@ -459,6 +486,17 @@ public DataSource GetDataSourceFromEntityName(string entityName) return _dataSourceNameToDataSource[_entityNameToDataSourceName[entityName]]; } + /// + /// Gets datasourceName from AutoentityNameToDatasourceName dictionary. + /// + /// autoentityName + /// DataSourceName + public string GetDataSourceNameFromAutoentityName(string autoentityName) + { + CheckAutoentityNamePresent(autoentityName); + return _autoentityNameToDataSourceName[autoentityName]; + } + /// /// Validates if datasource is present in runtimeConfig. /// @@ -596,6 +634,17 @@ private void CheckEntityNamePresent(string entityName) } } + private void CheckAutoentityNamePresent(string autoentityName) + { + if (!_autoentityNameToDataSourceName.ContainsKey(autoentityName)) + { + throw new DataApiBuilderException( + message: $"{autoentityName} is not a valid autoentity.", + statusCode: HttpStatusCode.NotFound, + subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); + } + } + private void SetupDataSourcesUsed() { SqlDataSourceUsed = _dataSourceNameToDataSource.Values.Any diff --git a/src/Core/Configurations/RuntimeConfigProvider.cs b/src/Core/Configurations/RuntimeConfigProvider.cs index a588d884c7..5aa9741733 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -412,8 +412,14 @@ private static RuntimeConfig HandleCosmosNoSqlConfiguration(string? schema, Runt return runtimeConfig; } - public void AddMergedEntitiesToConfig(Dictionary entities) + public void AddMergedEntitiesToConfig(Dictionary newEntities) { + Dictionary entities = new(_configLoader.RuntimeConfig!.Entities); + foreach((string name, Entity entity) in newEntities) + { + entities.Add(name, entity); + } + RuntimeConfig newRuntimeConfig = _configLoader.RuntimeConfig! with { Entities = new(entities) diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 36bbe11cf6..588992949c 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -292,18 +292,18 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType) } /// - protected override async Task GenerateAutoentitiesIntoEntities() + protected override async Task GenerateAutoentitiesIntoEntities(Dictionary? autoentities) { - int addedEntities = 0; RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); - Dictionary entities = (Dictionary)_entities; - if (runtimeConfig.Autoentities is null) + Dictionary entities = new(); + if (autoentities is null) { return; } - foreach ((string autoentityName, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) + foreach ((string autoentityName, Autoentity autoentity) in autoentities) { + int addedEntities = 0; JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity); if (resultArray is null) { @@ -352,9 +352,8 @@ protected override async Task GenerateAutoentitiesIntoEntities() // Add the generated entity to the linking entities dictionary. // This allows the entity to be processed later during metadata population. - if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddEntityNameToDataSourceName(entityName)) + if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddEntityNameToDataSourceName(entityName, autoentityName)) { - // TODO: need to make a better message that includes if the conflict is with a user-defined entity or another auto-generated entity. throw new DataApiBuilderException( message: $"Entity with name '{entityName}' already exists. Cannot create new entity from autoentity pattern with definition-name '{autoentityName}'.", statusCode: HttpStatusCode.BadRequest, @@ -372,16 +371,14 @@ protected override async Task GenerateAutoentitiesIntoEntities() addedEntities++; } - } - if (addedEntities == 0) - { - _logger.LogWarning("No new entities were generated from the autoentity patterns defined in the configuration."); - } - else - { - _runtimeConfigProvider.AddMergedEntitiesToConfig(entities); + if (addedEntities == 0) + { + _logger.LogWarning($"No new entities were generated from the autoentity {autoentityName} defined in the configuration."); + } } + + _runtimeConfigProvider.AddMergedEntitiesToConfig(entities); } public async Task QueryAutoentitiesAsync(Autoentity autoentity) diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index bf729a1d78..fa5ade5aeb 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.ObjectModel; using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; @@ -38,14 +39,17 @@ public abstract class SqlMetadataProvider : private readonly DatabaseType _databaseType; - // Represents the entities exposed in the runtime config. - protected IReadOnlyDictionary _entities; - // Represents the linking entities created by DAB to support multiple mutations for entities having an M:N relationship between them. protected Dictionary _linkingEntities = new(); protected readonly string _dataSourceName; + // Represents the entities exposed in the runtime config. + private IReadOnlyDictionary Entities => new ReadOnlyDictionary(_runtimeConfigProvider.GetConfig().Entities.Where(x => string.Equals(_runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(x.Key), _dataSourceName, StringComparison.OrdinalIgnoreCase)).ToDictionary(x => x.Key, x => x.Value)); + + // Represents the autoentities exposed in the runtime config. + private IReadOnlyDictionary Autoentities => new ReadOnlyDictionary(_runtimeConfigProvider.GetConfig().Autoentities.Where(x => string.Equals(_runtimeConfigProvider.GetConfig().GetDataSourceNameFromAutoentityName(x.Key), _dataSourceName, StringComparison.OrdinalIgnoreCase)).ToDictionary(x => x.Key, x => x.Value)); + // Dictionary containing mapping of graphQL stored procedure exposed query/mutation name // to their corresponding entity names defined in the config. public Dictionary GraphQLStoredProcedureExposedNameToEntityNameMap { get; set; } = new(); @@ -113,10 +117,9 @@ public SqlMetadataProvider( _runtimeConfigProvider = runtimeConfigProvider; _dataSourceName = dataSourceName; _databaseType = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; - _entities = runtimeConfig.Entities.Where(x => string.Equals(runtimeConfig.GetDataSourceNameFromEntityName(x.Key), _dataSourceName, StringComparison.OrdinalIgnoreCase)).ToDictionary(x => x.Key, x => x.Value); _logger = logger; _isValidateOnly = isValidateOnly; - foreach ((string entityName, Entity entityMetatdata) in _entities) + foreach ((string entityName, Entity entityMetatdata) in Entities) { if (runtimeConfig.IsRestEnabled) { @@ -227,7 +230,7 @@ public bool TryGetExposedColumnName(string entityName, string backingFieldName, return true; } - if (_entities.TryGetValue(entityName, out Entity? entityDefinition) && entityDefinition.Fields is not null) + if (Entities.TryGetValue(entityName, out Entity? entityDefinition) && entityDefinition.Fields is not null) { // Find the field by backing name and use its Alias if present. FieldMetadata? matched = entityDefinition @@ -260,7 +263,7 @@ public bool TryGetBackingColumn(string entityName, string field, [NotNullWhen(tr return true; } - if (_entities.TryGetValue(entityName, out Entity? entityDefinition) && entityDefinition.Fields is not null) + if (Entities.TryGetValue(entityName, out Entity? entityDefinition) && entityDefinition.Fields is not null) { FieldMetadata? matchedField = entityDefinition.Fields.FirstOrDefault(f => f.Alias != null && f.Alias.Equals(field, StringComparison.OrdinalIgnoreCase)); @@ -284,12 +287,12 @@ public IReadOnlyDictionary GetEntityNamesAndDbObjects() /// public string GetEntityName(string graphQLType) { - if (_entities.ContainsKey(graphQLType)) + if (Entities.ContainsKey(graphQLType)) { return graphQLType; } - foreach ((string entityName, Entity entity) in _entities) + foreach ((string entityName, Entity entity) in Entities) { if (entity.GraphQL.Singular == graphQLType) { @@ -310,7 +313,7 @@ public async Task InitializeAsync() if (GetDatabaseType() == DatabaseType.MSSQL) { - await GenerateAutoentitiesIntoEntities(); + await GenerateAutoentitiesIntoEntities(new Dictionary(Autoentities)); } GenerateDatabaseObjectForEntities(); @@ -390,7 +393,7 @@ public bool TryGetBackingFieldToExposedFieldMap(string entityName, [NotNullWhen( private void LogPrimaryKeys() { ColumnDefinition column; - foreach ((string entityName, Entity _) in _entities) + foreach ((string entityName, Entity _) in Entities) { try { @@ -554,7 +557,7 @@ private void GenerateRestPathToEntityMap() RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); string graphQLGlobalPath = runtimeConfig.GraphQLPath; - foreach ((string entityName, Entity entity) in _entities) + foreach ((string entityName, Entity entity) in Entities) { try { @@ -686,7 +689,7 @@ protected virtual Dictionary private void GenerateDatabaseObjectForEntities() { Dictionary sourceObjects = new(); - foreach ((string entityName, Entity entity) in _entities) + foreach ((string entityName, Entity entity) in Entities) { PopulateDatabaseObjectForEntity(entity, entityName, sourceObjects); } @@ -696,7 +699,7 @@ private void GenerateDatabaseObjectForEntities() /// Creates entities for each table that is found, based on the autoentity configuration. /// This method is only called for tables in MsSql. /// - protected virtual Task GenerateAutoentitiesIntoEntities() + protected virtual Task GenerateAutoentitiesIntoEntities(Dictionary? autoentities) { throw new NotSupportedException($"{GetType().Name} does not support Autoentities yet."); } @@ -825,7 +828,7 @@ private void ProcessRelationships( foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships!) { string targetEntityName = relationship.TargetEntity; - if (!_entities.TryGetValue(targetEntityName, out Entity? targetEntity)) + if (!Entities.TryGetValue(targetEntityName, out Entity? targetEntity)) { throw new InvalidOperationException($"Target Entity {targetEntityName} should be one of the exposed entities."); } @@ -1107,7 +1110,7 @@ public IReadOnlyDictionary GetLinkingEntities() /// private async Task PopulateObjectDefinitionForEntities() { - foreach ((string entityName, Entity entity) in _entities) + foreach ((string entityName, Entity entity) in Entities) { await PopulateObjectDefinitionForEntity(entityName, entity); } @@ -1306,7 +1309,7 @@ private async Task PopulateResultSetDefinitionsForStoredProcedureAsync( /// private void GenerateExposedToBackingColumnMapsForEntities() { - foreach ((string entityName, Entity _) in _entities) + foreach ((string entityName, Entity _) in Entities) { GenerateExposedToBackingColumnMapUtil(entityName); } @@ -1331,7 +1334,7 @@ private void GenerateExposedToBackingColumnMapUtil(string entityName) Dictionary exposedToBack = new(StringComparer.OrdinalIgnoreCase); // Pull definitions. - _entities.TryGetValue(entityName, out Entity? entity); + Entities.TryGetValue(entityName, out Entity? entity); SourceDefinition sourceDefinition = GetSourceDefinition(entityName); // 1) Prefer new-style fields (backing = f.Name, exposed = f.Alias ?? f.Name) @@ -1427,7 +1430,7 @@ private async Task PopulateSourceDefinitionAsync( subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); } - _entities.TryGetValue(entityName, out Entity? entity); + Entities.TryGetValue(entityName, out Entity? entity); if (GetDatabaseType() is DatabaseType.MSSQL && entity is not null && entity.Source.Type is EntitySourceType.Table) { await PopulateTriggerMetadataForTable(entityName, schemaName, tableName, sourceDefinition); diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index d58022637c..646cd7de4d 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5334,8 +5334,8 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities, int { // Act RuntimeConfigProvider configProvider = server.Services.GetService(); - HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers"); - HttpResponseMessage restResponse = await client.SendAsync(restRequest); + using HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers"); + using HttpResponseMessage restResponse = await client.SendAsync(restRequest); string graphqlQuery = @" { diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index 7dcf837d08..b938205780 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -101,4 +101,34 @@ public async Task FailLoadMultiDataSourceConfigDuplicateEntities(string configPa Assert.IsTrue(error.StartsWith("Deserialization of the configuration file failed during a post-processing step.")); Assert.IsTrue(error.Contains("An item with the same key has already been added.")); } + + /// + /// Test validates that when child files are present all autoentities are loaded correctly. + /// + [DataTestMethod] + [DataRow("Multidab-config.CosmosDb_NoSql.json", new string[] { "Multidab-config.MsSql.json", "Multidab-config.MySql.json", "Multidab-config.PostgreSql.json" }, 10)] + public async Task CanLoadValidMultiSourceConfigWithAutoentities(string configPath, IEnumerable dataSourceFiles, int expectedEntities) + { + string fileContents = await File.ReadAllTextAsync(configPath); + + // Parse the base JSON string + JObject baseJsonObject = JObject.Parse(fileContents); + + // Create a new JArray to hold the values to be appended + JArray valuesToAppend = new(dataSourceFiles); + + // Add or append the values to the base JSON + baseJsonObject.Add("data-source-files", valuesToAppend); + + // Convert the modified JSON object back to a JSON string + string resultJson = baseJsonObject.ToString(); + + IFileSystem fs = new MockFileSystem(new Dictionary() { { "dab-config.json", new MockFileData(resultJson) } }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + Assert.IsTrue(loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig), "Should successfully load config"); + Assert.IsTrue(runtimeConfig.SqlDataSourceUsed, "Should have Sql data source"); + Assert.AreEqual(expectedEntities, runtimeConfig.Entities.Entities.Count, "Default datasource should be of root file database type."); + } } diff --git a/src/Service.Tests/Multidab-config.MsSql.json b/src/Service.Tests/Multidab-config.MsSql.json index a5b22089f7..68a8f0df6d 100644 --- a/src/Service.Tests/Multidab-config.MsSql.json +++ b/src/Service.Tests/Multidab-config.MsSql.json @@ -1563,5 +1563,81 @@ } ] } + }, + "autoentities": { + "AutoPublisher": { + "patterns": { + "include": [ + "%publisher%" + ], + "exclude": [ + "%book%" + ], + "name": "auto{object}" + }, + "template": { + "rest": { + "enabled": true + }, + "graphql": { + "enabled": true + }, + "health": { + "enabled": false + }, + "cache": { + "enabled": false, + "ttl-seconds": 10, + "level": "l1l2" + } + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + }, + "NewBooks": { + "patterns": { + "include": [ + "%book%" + ], + "exclude": [ + "%publisher%" + ], + "name": "{schema}_auto_{object}" + }, + "template": { + "rest": { + "enabled": true + }, + "graphql": { + "enabled": true + }, + "health": { + "enabled": true + }, + "cache": { + "enabled": true, + "ttl-seconds": 5, + "level": "l1l2" + } + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "read" + } + ] + } + ] + } } }