diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 1e567da1cd..4833ce5d07 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. @@ -268,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(); @@ -287,17 +292,20 @@ public RuntimeConfig( }; _entityNameToDataSourceName = new Dictionary(); - if (Entities is null) + if ((Entities is null || Entities.Entities.Count == 0) && 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. @@ -305,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. @@ -322,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) { @@ -336,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/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 1cadaf5838..330112ac6e 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -294,7 +294,86 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType) /// protected override async Task GenerateAutoentitiesIntoEntities() { - await Task.CompletedTask; + int addedEntities = 0; + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + Dictionary entities = (Dictionary)_entities; + if (runtimeConfig.Autoentities is null) + { + return; + } + + foreach ((string autoentityName, Autoentity autoentity) in runtimeConfig.Autoentities.AutoEntities) + { + JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity); + if (resultArray is null) + { + 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. + string entityName = resultObject["entity_name"]!.ToString(); + string schemaName = resultObject["schema"]!.ToString(); + string objectName = resultObject["object"]!.ToString(); + + 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. + 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 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++; + } + } + + 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/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(); diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 9df54be519..2156413375 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5231,6 +5231,148 @@ 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) + { + // 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() + { + { + "PublisherAutoEntity", new Autoentity( + Patterns: new AutoentityPatterns( + Include: new[] { "%publishers%" }, + Exclude: null, + Name: null + ), + Template: new AutoentityTemplate( + Rest: new EntityRestOptions(Enabled: true), + 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(useEntities ? entityMap : new Dictionary()), + Autoentities: new RuntimeAutoentities(autoentityMap) + ); + + File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson()); + + string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; + + 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 + 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"); + + string restResponseBody = await restResponse.Content.ReadAsStringAsync(); + 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"); + + string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); + 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)); + } + } + /// /// 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); }