diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 09dc413525..7bb3aa2f7e 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -806,7 +806,7 @@ public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, IMetadata && entity.Relationships.Count > 0) { HandleOrRecordException(new DataApiBuilderException( - message: $"Cannot define relationship for entity: {entity}", + message: $"Cannot define relationship for entity: {entityName}", statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); } @@ -814,7 +814,7 @@ public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, IMetadata string databaseName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); ISqlMetadataProvider sqlMetadataProvider = sqlMetadataProviderFactory.GetMetadataProvider(databaseName); - foreach (EntityRelationship relationship in entity.Relationships!.Values) + foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships!) { // Validate if entity referenced in relationship is defined in the config. if (!runtimeConfig.Entities.ContainsKey(relationship.TargetEntity)) @@ -835,8 +835,35 @@ public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, IMetadata subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); } - DatabaseTable sourceDatabaseObject = (DatabaseTable)sqlMetadataProvider.EntityToDatabaseObject[entityName]; - DatabaseTable targetDatabaseObject = (DatabaseTable)sqlMetadataProvider.EntityToDatabaseObject[relationship.TargetEntity]; + // Validation to ensure DatabaseObject is correctly inferred from the entity name. + DatabaseObject? sourceObject, targetObject; + if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out sourceObject)) + { + sourceObject = null; + HandleOrRecordException(new DataApiBuilderException( + message: $"Could not infer database object for source entity: {entityName} in relationship: {relationshipName}." + + $" Check if the entity: {entityName} is correctly defined in the config.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + + if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(relationship.TargetEntity, out targetObject)) + { + targetObject = null; + HandleOrRecordException(new DataApiBuilderException( + message: $"Could not infer database object for target entity: {relationship.TargetEntity} in relationship: {relationshipName}." + + $" Check if the entity: {relationship.TargetEntity} is correctly defined in the config.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + + if (sourceObject is null || targetObject is null) + { + continue; + } + + DatabaseTable sourceDatabaseObject = (DatabaseTable)sourceObject; + DatabaseTable targetDatabaseObject = (DatabaseTable)targetObject; if (relationship.LinkingObject is not null) { (string linkingTableSchema, string linkingTableName) = sqlMetadataProvider.ParseSchemaAndDbTableName(relationship.LinkingObject)!; diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index d498fb8921..0fd11a5974 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -333,26 +333,37 @@ private void LogPrimaryKeys() ColumnDefinition column; foreach ((string entityName, Entity _) in _entities) { - SourceDefinition sourceDefinition = GetSourceDefinition(entityName); - _logger.LogDebug("Logging primary key information for entity: {entityName}.", entityName); - foreach (string pK in sourceDefinition.PrimaryKey) + try { - column = sourceDefinition.Columns[pK]; - if (TryGetExposedColumnName(entityName, pK, out string? exposedPKeyName)) + SourceDefinition sourceDefinition = GetSourceDefinition(entityName); + _logger.LogDebug("Logging primary key information for entity: {entityName}.", entityName); + foreach (string pK in sourceDefinition.PrimaryKey) { - _logger.LogDebug( - message: "Primary key column name: {pK}\n" + - " Primary key mapped name: {exposedPKeyName}\n" + - " Type: {column.SystemType.Name}\n" + - " IsNullable: {column.IsNullable}\n" + - " IsAutoGenerated: {column.IsAutoGenerated}", - pK, - exposedPKeyName, - column.SystemType.Name, - column.IsNullable, - column.IsAutoGenerated); + column = sourceDefinition.Columns[pK]; + if (TryGetExposedColumnName(entityName, pK, out string? exposedPKeyName)) + { + _logger.LogDebug( + message: "Primary key column name: {pK}\n" + + " Primary key mapped name: {exposedPKeyName}\n" + + " Type: {column.SystemType.Name}\n" + + " IsNullable: {column.IsNullable}\n" + + " IsAutoGenerated: {column.IsAutoGenerated}", + pK, + exposedPKeyName, + column.SystemType.Name, + column.IsNullable, + column.IsAutoGenerated); + } } } + catch (Exception ex) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Failed to log primary key information for entity: {entityName} due to: {ex.Message}", + innerException: ex, + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization)); + } } } @@ -619,6 +630,14 @@ private void GenerateDatabaseObjectForEntities() if (!EntityToDatabaseObject.ContainsKey(entityName)) { + if (entity.Source.Object is null) + { + throw new DataApiBuilderException( + message: $"The entity {entityName} does not have a valid source object.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } + // Reuse the same Database object for multiple entities if they share the same source. if (!sourceObjects.TryGetValue(entity.Source.Object, out DatabaseObject? sourceObject)) { @@ -730,6 +749,14 @@ private void AddForeignKeysForRelationships( throw new InvalidOperationException($"Target Entity {targetEntityName} should be one of the exposed entities."); } + if (targetEntity.Source.Object is null) + { + throw new DataApiBuilderException( + message: $"Target entity {entityName} does not have a valid source object.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } + (targetSchemaName, targetDbTableName) = ParseSchemaAndDbTableName(targetEntity.Source.Object)!; DatabaseTable targetDbTable = new(targetSchemaName, targetDbTableName); // If a linking object is specified, @@ -963,7 +990,14 @@ await PopulateSourceDefinitionAsync( } } - await PopulateForeignKeyDefinitionAsync(); + try + { + await PopulateForeignKeyDefinitionAsync(); + } + catch (Exception e) + { + HandleOrRecordException(e); + } } /// diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 7a76ce10fa..3cfb150287 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1201,6 +1201,94 @@ public async Task TestSqlMetadataForInvalidConfigEntities() Assert.AreEqual("No stored procedure definition found for the given database object publishers", exceptionsList[1].Message); } + /// + /// This Test validates that when the entities in the runtime config have source object as null, + /// the validation exception handler collects the message and exits gracefully. + /// + [TestMethod("Validate Exception handling for Entities with Source object as null."), TestCategory(TestCategory.MSSQL)] + public async Task TestSqlMetadataValidationForEntitiesWithInvalidSource() + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), + Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new()); + + // creating an entity with invalid table name + Entity entityWithInvalidSource = new( + Source: new(null, EntitySourceType.Table, null, null), + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); + + // creating an entity with invalid source object and adding relationship with an entity with invalid source + Entity entityWithInvalidSourceAndRelationship = new( + Source: new(null, EntitySourceType.Table, null, null), + Rest: null, + GraphQL: new(Singular: "publisher", Plural: "publishers"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: new Dictionary() { {"books", new ( + Cardinality: Cardinality.Many, + TargetEntity: "Book", + SourceFields: null, + TargetFields: null, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null + )}}, + Mappings: null + ); + + configuration = configuration with + { + Entities = new RuntimeEntities(new Dictionary() + { + { "Book", entityWithInvalidSource }, + { "Publisher", entityWithInvalidSourceAndRelationship} + }) + }; + + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + FileSystemRuntimeConfigLoader configLoader = TestHelper.GetRuntimeConfigLoader(); + configLoader.UpdateConfigFilePath(CUSTOM_CONFIG); + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configLoader); + + Mock> configValidatorLogger = new(); + RuntimeConfigValidator configValidator = + new( + configProvider, + new MockFileSystem(), + configValidatorLogger.Object, + isValidateOnly: true); + + ILoggerFactory mockLoggerFactory = TestHelper.ProvisionLoggerFactory(); + + try + { + await configValidator.ValidateEntitiesMetadata(configProvider.GetConfig(), mockLoggerFactory); + } + catch + { + Assert.Fail("Execution of dab validate should not result in unhandled exceptions."); + } + + Assert.IsTrue(configValidator.ConfigValidationExceptions.Any()); + List exceptionMessagesList = configValidator.ConfigValidationExceptions.Select(x => x.Message).ToList(); + Assert.IsTrue(exceptionMessagesList.Contains("The entity Book does not have a valid source object.")); + Assert.IsTrue(exceptionMessagesList.Contains("The entity Publisher does not have a valid source object.")); + Assert.IsTrue(exceptionMessagesList.Contains("Table Definition for Book has not been inferred.")); + Assert.IsTrue(exceptionMessagesList.Contains("Table Definition for Publisher has not been inferred.")); + Assert.IsTrue(exceptionMessagesList.Contains("Could not infer database object for source entity: Publisher in relationship: books. Check if the entity: Publisher is correctly defined in the config.")); + Assert.IsTrue(exceptionMessagesList.Contains("Could not infer database object for target entity: Book in relationship: books. Check if the entity: Book is correctly defined in the config.")); + } + /// /// This test method validates a sample DAB runtime config file against DAB's JSON schema definition. /// It asserts that the validation is successful and there are no validation failures.