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 1e567da1cd..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(); /// @@ -245,6 +247,21 @@ public bool TryGetEntityNameFromPath(string entityPathName, [NotNullWhen(true)] return _entityPathNameToEntityName.TryGetValue(entityPathName, out entityName); } + public bool TryAddEntityNameToDataSourceName(string entityName) + { + return _entityNameToDataSourceName.TryAdd(entityName, this.DefaultDataSourceName); + } + + public bool 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. @@ -268,8 +285,8 @@ public RuntimeConfig( this.DataSource = DataSource; this.Runtime = Runtime; this.AzureKeyVault = AzureKeyVault; - this.Entities = Entities; - this.Autoentities = Autoentities; + this.Entities = Entities ?? new RuntimeEntities(new Dictionary()); + this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); this.DefaultDataSourceName = Guid.NewGuid().ToString(); if (this.DataSource is null) @@ -287,17 +304,29 @@ public RuntimeConfig( }; _entityNameToDataSourceName = new Dictionary(); - if (Entities is null) + if (Entities is null && this.Entities.Entities.Count == 0 && + Autoentities is null && this.Autoentities.Autoentities.Count == 0) { throw new DataApiBuilderException( - message: "entities is a mandatory property in DAB Config", + message: "Configuration file should contain either at least the entities or autoentities property", statusCode: HttpStatusCode.UnprocessableEntity, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } - foreach (KeyValuePair entity in Entities) + if (Entities is not null) + { + foreach (KeyValuePair entity in Entities) + { + _entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName); + } + } + + if (Autoentities is not null) { - _entityNameToDataSourceName.TryAdd(entity.Key, this.DefaultDataSourceName); + foreach (KeyValuePair autoentity in Autoentities) + { + _autoentityNameToDataSourceName.TryAdd(autoentity.Key, this.DefaultDataSourceName); + } } // Process data source and entities information for each database in multiple database scenario. @@ -305,7 +334,8 @@ public RuntimeConfig( if (DataSourceFiles is not null && DataSourceFiles.SourceFiles is not null) { - IEnumerable> allEntities = Entities.AsEnumerable(); + IEnumerable>? allEntities = Entities?.AsEnumerable(); + IEnumerable>? allAutoentities = Autoentities?.AsEnumerable(); // Iterate through all the datasource files and load the config. IFileSystem fileSystem = new FileSystem(); // This loader is not used as a part of hot reload and therefore does not need a handler. @@ -322,7 +352,9 @@ public RuntimeConfig( { _dataSourceNameToDataSource = _dataSourceNameToDataSource.Concat(config._dataSourceNameToDataSource).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); _entityNameToDataSourceName = _entityNameToDataSourceName.Concat(config._entityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - allEntities = allEntities.Concat(config.Entities.AsEnumerable()); + _autoentityNameToDataSourceName = _autoentityNameToDataSourceName.Concat(config._autoentityNameToDataSourceName).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + allEntities = allEntities?.Concat(config.Entities.AsEnumerable()); + allAutoentities = allAutoentities?.Concat(config.Autoentities.AsEnumerable()); } catch (Exception e) { @@ -336,7 +368,8 @@ public RuntimeConfig( } } - this.Entities = new RuntimeEntities(allEntities.ToDictionary(x => x.Key, x => x.Value)); + this.Entities = new RuntimeEntities(allEntities != null ? allEntities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary()); + this.Autoentities = new RuntimeAutoentities(allAutoentities != null ? allAutoentities.ToDictionary(x => x.Key, x => x.Value) : new Dictionary()); } SetupDataSourcesUsed(); @@ -351,17 +384,19 @@ public RuntimeConfig( /// Default datasource. /// Runtime settings. /// Entities + /// Autoentities /// List of datasource files for multiple db scenario.Null for single db scenario. /// DefaultDataSourceName to maintain backward compatibility. /// Dictionary mapping datasourceName to datasource object. /// Dictionary mapping entityName to datasourceName. /// Datasource files which represent list of child runtimeconfigs for multi-db scenario. - public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtime, RuntimeEntities Entities, string DefaultDataSourceName, Dictionary DataSourceNameToDataSource, Dictionary EntityNameToDataSourceName, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null) + public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtime, RuntimeEntities Entities, string DefaultDataSourceName, Dictionary DataSourceNameToDataSource, Dictionary EntityNameToDataSourceName, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null, RuntimeAutoentities? Autoentities = null) { this.Schema = Schema; this.DataSource = DataSource; this.Runtime = Runtime; this.Entities = Entities; + this.Autoentities = Autoentities ?? new RuntimeAutoentities(new Dictionary()); this.DefaultDataSourceName = DefaultDataSourceName; _dataSourceNameToDataSource = DataSourceNameToDataSource; _entityNameToDataSourceName = EntityNameToDataSourceName; @@ -451,6 +486,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. /// @@ -588,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/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..5aa9741733 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -411,4 +411,19 @@ private static RuntimeConfig HandleCosmosNoSqlConfiguration(string? schema, Runt return runtimeConfig; } + + 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) + }; + _configLoader.EditRuntimeConfig(newRuntimeConfig); + } } diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 1cadaf5838..588992949c 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -292,9 +292,93 @@ private bool TryResolveDbType(string sqlDbTypeName, out DbType dbType) } /// - protected override async Task GenerateAutoentitiesIntoEntities() + protected override async Task GenerateAutoentitiesIntoEntities(Dictionary? autoentities) { - await Task.CompletedTask; + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + Dictionary entities = new(); + if (autoentities is null) + { + return; + } + + foreach ((string autoentityName, Autoentity autoentity) in autoentities) + { + int addedEntities = 0; + JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity); + if (resultArray is null) + { + continue; + } + + 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(); + string? objectName = resultObject["object"]?.ToString(); + string? schemaName = resultObject["schema"]?.ToString(); + + 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; + } + + // 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, autoentityName)) + { + 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 {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 c9a62ca470..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. - private 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) { @@ -307,9 +310,10 @@ public string GetEntityName(string graphQLType) public async Task InitializeAsync() { System.Diagnostics.Stopwatch timer = System.Diagnostics.Stopwatch.StartNew(); + if (GetDatabaseType() == DatabaseType.MSSQL) { - await GenerateAutoentitiesIntoEntities(); + await GenerateAutoentitiesIntoEntities(new Dictionary(Autoentities)); } GenerateDatabaseObjectForEntities(); @@ -389,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 { @@ -553,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 { @@ -685,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); } @@ -695,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."); } @@ -824,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."); } @@ -1106,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); } @@ -1305,7 +1309,7 @@ private async Task PopulateResultSetDefinitionsForStoredProcedureAsync( /// private void GenerateExposedToBackingColumnMapsForEntities() { - foreach ((string entityName, Entity _) in _entities) + foreach ((string entityName, Entity _) in Entities) { GenerateExposedToBackingColumnMapUtil(entityName); } @@ -1330,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) @@ -1426,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 a704f22e4d..6781c81675 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5288,6 +5288,152 @@ public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit() } } + [TestCategory(TestCategory.MSSQL)] + [DataTestMethod] + [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, + 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 + RuntimeConfigProvider configProvider = server.Services.GetService(); + using HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers"); + using 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 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"); + + 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.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" + } + ] + } + ] + } } } diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index e16673347c..bbc4d062e1 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: 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: 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); }