diff --git a/src/Cli.Tests/TestHelper.cs b/src/Cli.Tests/TestHelper.cs index 1b706cda47..0b3fbe8a16 100644 --- a/src/Cli.Tests/TestHelper.cs +++ b/src/Cli.Tests/TestHelper.cs @@ -1065,6 +1065,104 @@ public static Process ExecuteDabCommand(string command, string flags) } }"; + public const string COMPLETE_CONFIG_WITH_RELATIONSHIPS_NON_WORKING_CONN_STRING = @" + { + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""options"": { + ""set-session-context"": false + }, + ""connection-string"": ""Server=XXXXX;Persist Security Info=False;User ID=;Password= ;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=5;"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""allow-introspection"": true, + ""enabled"": true, + ""path"": ""/graphql"" + }, + ""host"": { + ""mode"": ""development"", + ""cors"": { + ""origins"": [], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""StaticWebApps"" + } + } + }, + ""entities"": { + ""Publisher"": { + ""source"": { + ""object"": ""publishers"", + ""type"": ""table"", + ""key-fields"": [ ""id"" ] + }, + ""graphql"": { + ""enabled"": true, + ""type"": { + ""singular"": ""Publisher"", + ""plural"": ""Publishers"" + } + }, + ""rest"": { + ""enabled"": true + }, + ""permissions"": [ + ], + ""relationships"": { + ""books"": { + ""cardinality"": ""many"", + ""target.entity"": ""Book"", + ""source.fields"": [ ""id"" ], + ""target.fields"": [ ""publisher_id"" ], + ""linking.source.fields"": [], + ""linking.target.fields"": [] + } + } + }, + ""Book"": { + ""source"": { + ""object"": ""books"", + ""type"": ""table"", + ""key-fields"": [ ""id"" ] + }, + ""graphql"": { + ""enabled"": true, + ""type"": { + ""singular"": ""book"", + ""plural"": ""books"" + } + }, + ""rest"": { + ""enabled"": true + }, + ""permissions"": [ + ], + ""mappings"": { + ""id"": ""id"", + ""title"": ""title"" + }, + ""relationships"": { + ""publishers"": { + ""cardinality"": ""one"", + ""target.entity"": ""Publisher"", + ""source.fields"": [ ""publisher_id"" ], + ""target.fields"": [ ""id"" ], + ""linking.source.fields"": [], + ""linking.target.fields"": [] + } + } + } + } +} +"; + /// /// Creates basic initialization options for MS SQL config. /// diff --git a/src/Cli.Tests/ValidateConfigTests.cs b/src/Cli.Tests/ValidateConfigTests.cs index 9c85ce611e..54e19a78a7 100644 --- a/src/Cli.Tests/ValidateConfigTests.cs +++ b/src/Cli.Tests/ValidateConfigTests.cs @@ -46,6 +46,31 @@ public void TestConfigWithCustomPropertyAsInvalid() Assert.IsFalse(isConfigValid); } + /// + /// This method verifies that the relationship validation does not cause unhandled + /// exceptions, and that the errors generated include the expected messaging. + /// This case is a regression test due to the metadata needed not always being + /// populated in the SqlMetadataProvider if for example a bad connection string + /// is given. + /// + [TestMethod] + public void TestErrorHandlingForRelationshipValidationWithNonWorkingConnectionString() + { + // Arrange + ((MockFileSystem)_fileSystem!).AddFile(TEST_RUNTIME_CONFIG_FILE, COMPLETE_CONFIG_WITH_RELATIONSHIPS_NON_WORKING_CONN_STRING); + ValidateOptions validateOptions = new(TEST_RUNTIME_CONFIG_FILE); + StringWriter writer = new(); + // Capture console output to get error messaging. + Console.SetOut(writer); + + // Act + ConfigGenerator.IsConfigValid(validateOptions, _runtimeConfigLoader!, _fileSystem!); + string errorMessage = writer.ToString(); + + // Assert + Assert.IsTrue(errorMessage.Contains(DataApiBuilderException.CONNECTION_STRING_ERROR_MESSAGE)); + } + /// /// Validates that the IsConfigValid method returns false when a config is passed with /// both rest and graphQL disabled globally. diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 17ca831312..7ffb0bd7f7 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -162,12 +162,9 @@ public async Task TryValidateConfig( ValidateConfigProperties(); ValidatePermissionsInConfig(runtimeConfig); - // If the ConfigValidationExceptions list doesn't contain a DataApiBuilderException with connection string error message, - // then only we run the metadata validation. - if (!ConfigValidationExceptions.Any(x => x.Message.Equals(DataApiBuilderException.CONNECTION_STRING_ERROR_MESSAGE))) - { - await ValidateEntitiesMetadata(runtimeConfig, loggerFactory); - } + _logger.LogInformation("Validating entity relationships."); + ValidateRelationshipConfigCorrectness(runtimeConfig); + await ValidateEntitiesMetadata(runtimeConfig, loggerFactory); if (validationResult.IsValid && !ConfigValidationExceptions.Any()) { @@ -209,7 +206,115 @@ public async Task ValidateConfigSchema(RuntimeConfig } /// - /// This method runs validates the entities metadata against the database objects. + /// Validates the semantic correctness of an Entity's relationships in the + /// runtime configuration without cross referencing DB metadata. + /// Validating Cases: + /// 1. Entities not defined in the config cannot be used in a relationship. + /// 2. Entities with graphQL disabled cannot be used in a relationship with another entity. + /// + /// Throws exception whenever some validation fails. + public void ValidateRelationshipConfigCorrectness(RuntimeConfig runtimeConfig) + { + // Loop through each entity in the config and verify its relationship. + foreach ((string entityName, Entity entity) in runtimeConfig.Entities) + { + // Skipping relationship validation if entity has no relationship + // or if graphQL is disabled. + if (entity.Relationships is null || !entity.GraphQL.Enabled) + { + continue; + } + + if (entity.Source.Type is not EntitySourceType.Table && entity.Relationships is not null + && entity.Relationships.Count > 0) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Cannot define relationship for entity: {entityName}", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + + string databaseName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); + + foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships!) + { + // Validate if entity referenced in relationship is defined in the config. + if (!runtimeConfig.Entities.TryGetValue(relationship.TargetEntity, out Entity? targetEntity)) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity: {relationship.TargetEntity} used for relationship is not defined in the config.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + + // Validation to ensure that an entity with graphQL disabled cannot be referenced in a relationship by other entities + EntityGraphQLOptions? targetEntityGraphQLDetails = targetEntity is not null ? targetEntity.GraphQL : null; + if (targetEntityGraphQLDetails is not null && !targetEntityGraphQLDetails.Enabled) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity: {relationship.TargetEntity} is disabled for GraphQL.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + + // Linking object is null and therefore we have a many-one or a one-many relationship. These relationships + // must be validated separately from many-many relationships. In one-many and many-one relationships, the count + // of source and target fields need to match, or if one is null the other must be as well. + // If both of these sets of fields are null, foreign key information, inferred from the database metadata, + // will be used to define the relationship. + // see: https://learn.microsoft.com/en-us/azure/data-api-builder/relationships + if (string.IsNullOrWhiteSpace(relationship.LinkingObject)) + { + // Validation to ensure that source and target fields are both null or both not null. + if (relationship.SourceFields is null ^ relationship.TargetFields is null) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity: {entityName} has a relationship: {relationshipName}, which has source and target fields " + + $"where one is null and the other is not.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + + // In one-one, one-many, or many-one relationships when both source and target are non null their size must match. + if ((relationship.SourceFields is not null && relationship.TargetFields is not null) && + relationship.SourceFields.Length != relationship.TargetFields.Length) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity: {entityName} has a relationship: {relationshipName}, which has {relationship.SourceFields.Length} source fields defined, " + + $"but {relationship.TargetFields.Length} target fields defined.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + } + + // Linking object exists and we therefore have a many-many relationship. Validation here differs from one-many and many-one in that + // the source and target fields are now only indirectly related through the linking object. Therefore, it is the source and linkingSource + // along with the target and linkingTarget fields that must match, respectively. Source and linkingSource fields provide the relationship + // from the source entity to the linkingObject while target and linkingTarget fields provide the relationship from the target entity to the + // linkingObject. + // see: https://learn.microsoft.com/en-us/azure/data-api-builder/relationships#many-to-many-relationship + if (!string.IsNullOrWhiteSpace(relationship.LinkingObject)) + { + ValidateFieldsAndAssociatedLinkingFields( + fields: relationship.SourceFields, + linkingFields: relationship.LinkingSourceFields, + fieldType: "source", + entityName: entityName, + relationshipName: relationshipName); + ValidateFieldsAndAssociatedLinkingFields( + fields: relationship.TargetFields, + linkingFields: relationship.LinkingTargetFields, + fieldType: "target", + entityName: entityName, + relationshipName: relationshipName); + } + } + } + } + + /// + /// This method validates the entities relationships against the database objects using + /// metadata from the backend DB generated by this function. /// public async Task ValidateEntitiesMetadata(RuntimeConfig runtimeConfig, ILoggerFactory loggerFactory) { @@ -225,11 +330,15 @@ public async Task ValidateEntitiesMetadata(RuntimeConfig runtimeConfig, ILoggerF logger: loggerFactory.CreateLogger(), fileSystem: _fileSystem, isValidateOnly: _isValidateOnly); - await metadataProviderFactory.InitializeAsync(); + ConfigValidationExceptions.AddRange(metadataProviderFactory.GetAllMetadataExceptions()); - ValidateRelationshipsInConfig(runtimeConfig, metadataProviderFactory); + // Validation below relies on metadata being populated from backend, only execute when there were no connection errors + if (!ConfigValidationExceptions.Any(x => x.Message.StartsWith(DataApiBuilderException.CONNECTION_STRING_ERROR_MESSAGE))) + { + ValidateRelationships(runtimeConfig, metadataProviderFactory); + } } /// @@ -780,22 +889,19 @@ public static bool IsValidDatabasePolicyForAction(EntityAction permission) } /// - /// Validates the semantic correctness of an Entity's relationship metadata - /// in the runtime configuration. - /// Validating Cases: - /// 1. Entities not defined in the config cannot be used in a relationship. - /// 2. Entities with graphQL disabled cannot be used in a relationship with another entity. - /// 3. If the LinkingSourceFields or sourceFields and LinkingTargetFields or targetFields are not + /// Validates the semantic correctness of an Entity's relationships in the + /// runtime configuration against the metadata collected from the backend DB + /// that is provided to this function via use of the MetadataProviderFactory. + /// Validating Cases using DB metadata: + /// 1. If the LinkingSourceFields or sourceFields and LinkingTargetFields or targetFields are not /// specified in the config for the given linkingObject, then the underlying database should /// contain a foreign key relationship between the source and target entity. - /// 4. If linkingObject is null, and either of SourceFields or targetFields is null, then foreignKey pair + /// 2. If linkingObject is null, and either of SourceFields or targetFields is null, then foreignKey pair /// between source and target entity must be defined in the DB. /// /// Throws exception whenever some validation fails. - public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, IMetadataProviderFactory sqlMetadataProviderFactory) + public void ValidateRelationships(RuntimeConfig runtimeConfig, IMetadataProviderFactory sqlMetadataProviderFactory) { - _logger.LogInformation("Validating entity relationships."); - // To avoid creating many lists of invalid columns we instantiate before looping through entities. // List.Clear() is O(1) so clearing the list, for re-use, inside of the loops is fine. List invalidColumns = new(); @@ -810,128 +916,17 @@ public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, IMetadata continue; } - if (entity.Source.Type is not EntitySourceType.Table && entity.Relationships is not null - && entity.Relationships.Count > 0) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Cannot define relationship for entity: {entityName}", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - } - string databaseName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); ISqlMetadataProvider sqlMetadataProvider = sqlMetadataProviderFactory.GetMetadataProvider(databaseName); 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)) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Entity: {relationship.TargetEntity} used for relationship is not defined in the config.", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - } - - // Validation to ensure that an entity with graphQL disabled cannot be referenced in a relationship by other entities - EntityGraphQLOptions targetEntityGraphQLDetails = runtimeConfig.Entities[relationship.TargetEntity].GraphQL; - if (!targetEntityGraphQLDetails.Enabled) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Entity: {relationship.TargetEntity} is disabled for GraphQL.", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - } - - // Linking object is null and therefore we have a many-one or a one-many relationship. These relationships - // must be validated separately from many-many relationships. In one-many and many-one relationships, the count - // of source and target fields need to match, or if one is null the other must be as well. - // If both of these sets of fields are null, foreign key information, inferred from the database metadata, - // will be used to define the relationship. - // see: https://learn.microsoft.com/en-us/azure/data-api-builder/relationships - if (string.IsNullOrWhiteSpace(relationship.LinkingObject)) - { - // Validation to ensure that source and target fields are both null or both not null. - if (relationship.SourceFields is null ^ relationship.TargetFields is null) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Entity: {entityName} has a relationship: {relationshipName}, which has source and target fields " + - $"where one is null and the other is not.", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - } - - // In one-one, one-many, or many-one relationships when both source and target are non null their size must match. - if ((relationship.SourceFields is not null && relationship.TargetFields is not null) && - relationship.SourceFields.Length != relationship.TargetFields.Length) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Entity: {entityName} has a relationship: {relationshipName}, which has {relationship.SourceFields.Length} source fields defined, " + - $"but {relationship.TargetFields.Length} target fields defined.", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - } - } - - // In all kinds of relationships, if sourceFields are included they must be valid columns in the backend. - if (relationship.SourceFields is not null) - { - GetFieldsNotBackedByColumnsInDB( - fields: relationship.SourceFields, - invalidColumns: invalidColumns, - entityName: entityName, - sqlMetadataProvider: sqlMetadataProvider); - - if (invalidColumns.Count > 0) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Entity: {entityName} has a relationship: {relationshipName} with source fields: {string.Join(",", invalidColumns)} that " + - $"do not exist as columns in entity: {entityName}.", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - } - } - - // In all kinds of relationships, if targetFields are included they must be valid columns in the backend. - if (relationship.TargetFields is not null) - { - GetFieldsNotBackedByColumnsInDB( - fields: relationship.TargetFields, - invalidColumns: invalidColumns, - entityName: relationship.TargetEntity, - sqlMetadataProvider: sqlMetadataProvider); - - if (invalidColumns.Count > 0) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Entity: {entityName} has a relationship: {relationshipName} with target fields: {string.Join(",", invalidColumns)} that " + - $"do not exist as columns in entity: {relationship.TargetEntity}.", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - } - } - - // Linking object exists and we therefore have a many-many relationship. Validation here differs from one-many and many-one in that - // the source and target fields are now only indirectly related through the linking object. Therefore, it is the source and linkingSource - // along with the target and linkingTarget fields that must match, respectively. Source and linkingSource fields provide the relationship - // from the source entity to the linkingObject while target and linkingTarget fields provide the relationship from the target entity to the - // linkingObject. - // see: https://learn.microsoft.com/en-us/azure/data-api-builder/relationships#many-to-many-relationship - if (!string.IsNullOrWhiteSpace(relationship.LinkingObject)) - { - ValidateFieldsAndAssociatedLinkingFields( - fields: relationship.SourceFields, - linkingFields: relationship.LinkingSourceFields, - fieldType: "source", - entityName: entityName, - relationshipName: relationshipName); - ValidateFieldsAndAssociatedLinkingFields( - fields: relationship.TargetFields, - linkingFields: relationship.LinkingTargetFields, - fieldType: "target", - entityName: entityName, - relationshipName: relationshipName); - } + ValidateSourceAndTargetFieldsAsBackingColumns( + entityName: entityName, + relationshipName: relationshipName, + relationship: relationship, + sqlMetadataProvider: sqlMetadataProvider, + invalidColumns: invalidColumns); // Validation to ensure DatabaseObject is correctly inferred from the entity name. DatabaseObject? sourceObject, targetObject; @@ -1075,6 +1070,60 @@ public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, IMetadata } } + /// + /// Handles the validation of source and target fields as valid backing columns in the DB. + /// + /// Name of the relationship. + /// The name of the entity that we check for backing columns. + /// The relationship that holds the fields to validate. + /// The sqlMetadataProvider used to lookup if the backing fields are valid columns in DB. + /// List in which invalid fields are aggregated, this list may be modified by this function. + private void ValidateSourceAndTargetFieldsAsBackingColumns( + string entityName, + string relationshipName, + EntityRelationship relationship, + ISqlMetadataProvider sqlMetadataProvider, + List invalidColumns) + { + // In all kinds of relationships, if sourceFields are included they must be valid columns in the backend. + if (relationship.SourceFields is not null) + { + GetFieldsNotBackedByColumnsInDB( + invalidColumns: invalidColumns, + fields: relationship.SourceFields, + entityName: entityName, + sqlMetadataProvider: sqlMetadataProvider); + + if (invalidColumns.Count > 0) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity: {entityName} has a relationship: {relationshipName} with source fields: {string.Join(",", invalidColumns)} that " + + $"do not exist as columns in entity: {entityName}.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + } + + // In all kinds of relationships, if targetFields are included they must be valid columns in the backend. + if (relationship.TargetFields is not null) + { + GetFieldsNotBackedByColumnsInDB( + invalidColumns: invalidColumns, + fields: relationship.TargetFields, + entityName: relationship.TargetEntity, + sqlMetadataProvider: sqlMetadataProvider); + + if (invalidColumns.Count > 0) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity: {entityName} has a relationship: {relationshipName} with target fields: {string.Join(",", invalidColumns)} that " + + $"do not exist as columns in entity: {relationship.TargetEntity}.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + } + } + /// /// This helper function checks if the elements of fields exist as valid backing columns /// in the DB associated with the provided entity name. Those fields that do not exist diff --git a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs index baf94a7fb2..5050cd5068 100644 --- a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs @@ -92,11 +92,15 @@ bool VerifyForeignKeyExistsInDB( /// try to get the exposed name associated /// with the provided field, if it exists, save in out /// parameter, and return true, otherwise return false. + /// If an entity name is provided that does not exist + /// as metadata in this metadata provider, a KeyNotFoundException + /// is thrown. /// /// The entity whose mapping we lookup. /// The field used for the lookup in the mapping. /// Out parameter in which we will save exposed name. /// True if exists, false otherwise. + /// KeyNotFoundException if entity name not found. bool TryGetExposedColumnName(string entityName, string backingFieldName, [NotNullWhen(true)] out string? name); /// @@ -104,11 +108,15 @@ bool VerifyForeignKeyExistsInDB( /// try to get the underlying backing column name associated /// with the provided field, if it exists, save in out /// parameter, and return true, otherwise return false. + /// If an entity name is provided that does not exist + /// as metadata in this metadata provider, a KeyNotFoundException + /// is thrown. /// /// The entity whose mapping we lookup. /// The field used for the lookup in the mapping. /// Out parameter in which we will save backing column name. /// True if exists, false otherwise. + /// KeyNotFoundException if entity name not found. bool TryGetBackingColumn(string entityName, string field, [NotNullWhen(true)] out string? name); /// diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 03b4e042ed..6e1234d6d3 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -209,13 +209,25 @@ public StoredProcedureDefinition GetStoredProcedureDefinition(string entityName) /// public bool TryGetExposedColumnName(string entityName, string backingFieldName, [NotNullWhen(true)] out string? name) { - return EntityBackingColumnsToExposedNames[entityName].TryGetValue(backingFieldName, out name); + Dictionary? backingColumnsToExposedNamesMap; + if (!EntityBackingColumnsToExposedNames.TryGetValue(entityName, out backingColumnsToExposedNamesMap)) + { + throw new KeyNotFoundException($"Initialization of metadata incomplete for entity: {entityName}"); + } + + return backingColumnsToExposedNamesMap.TryGetValue(backingFieldName, out name); } /// public bool TryGetBackingColumn(string entityName, string field, [NotNullWhen(true)] out string? name) { - return EntityExposedNamesToBackingColumnNames[entityName].TryGetValue(field, out name); + Dictionary? exposedNamesToBackingColumnsMap; + if (!EntityExposedNamesToBackingColumnNames.TryGetValue(entityName, out exposedNamesToBackingColumnsMap)) + { + throw new KeyNotFoundException($"Initialization of metadata incomplete for entity: {entityName}"); + } + + return exposedNamesToBackingColumnsMap.TryGetValue(field, out name); } /// diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index d0c3a85ff3..c6bf8a4a46 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1271,6 +1271,7 @@ public async Task TestSqlMetadataForValidConfigEntities() configValidatorLogger.Object, isValidateOnly: true); + configValidator.ValidateRelationshipConfigCorrectness(configProvider.GetConfig()); await configValidator.ValidateEntitiesMetadata(configProvider.GetConfig(), mockLoggerFactory); Assert.IsTrue(configValidator.ConfigValidationExceptions.IsNullOrEmpty()); } @@ -1337,6 +1338,7 @@ public async Task TestSqlMetadataForInvalidConfigEntities() ILoggerFactory mockLoggerFactory = TestHelper.ProvisionLoggerFactory(); + configValidator.ValidateRelationshipConfigCorrectness(configProvider.GetConfig()); await configValidator.ValidateEntitiesMetadata(configProvider.GetConfig(), mockLoggerFactory); Assert.IsTrue(configValidator.ConfigValidationExceptions.Any()); @@ -1418,6 +1420,7 @@ public async Task TestSqlMetadataValidationForEntitiesWithInvalidSource() try { + configValidator.ValidateRelationshipConfigCorrectness(configProvider.GetConfig()); await configValidator.ValidateEntitiesMetadata(configProvider.GetConfig(), mockLoggerFactory); } catch diff --git a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs index f18139072d..941b6ee8d8 100644 --- a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs @@ -262,13 +262,10 @@ public void TestAddingRelationshipWithInvalidTargetEntity() ); RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(); - Mock _sqlMetadataProvider = new(); - Mock _metadataProviderFactory = new(); - _metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(_sqlMetadataProvider.Object); // Assert that expected exception is thrown. Entity used in relationship is Invalid DataApiBuilderException ex = Assert.ThrowsException(() => - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); + configValidator.ValidateRelationshipConfigCorrectness(runtimeConfig)); Assert.AreEqual($"Entity: {sampleRelationship.TargetEntity} used for relationship is not defined in the config.", ex.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); } @@ -325,13 +322,10 @@ public void TestAddingRelationshipWithDisabledGraphQL() ); RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(); - Mock _sqlMetadataProvider = new(); - Mock _metadataProviderFactory = new(); - _metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(_sqlMetadataProvider.Object); // Exception should be thrown as we cannot use an entity (with graphQL disabled) in a relationship. DataApiBuilderException ex = Assert.ThrowsException(() => - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); + configValidator.ValidateRelationshipConfigCorrectness(runtimeConfig)); Assert.AreEqual($"Entity: {sampleRelationship.TargetEntity} is disabled for GraphQL.", ex.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); } @@ -417,7 +411,7 @@ string relationshipEntity // Exception thrown as foreignKeyPair not found in the DB. DataApiBuilderException ex = Assert.ThrowsException(() => - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); + configValidator.ValidateRelationships(runtimeConfig, _metadataProviderFactory.Object)); Assert.AreEqual($"Could not find relationship between Linking Object: TEST_SOURCE_LINK" + $" and entity: {relationshipEntity}.", ex.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); @@ -435,7 +429,7 @@ string relationshipEntity // Since, we have defined the relationship in Database, // the engine was able to find foreign key relation and validation will pass. - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object); + configValidator.ValidateRelationships(runtimeConfig, _metadataProviderFactory.Object); } /// @@ -498,7 +492,7 @@ public void TestRelationshipWithNoLinkingObjectAndEitherSourceOrTargetFieldIsNul // Exception is thrown as foreignKey pair is not specified in the config, nor defined // in the database. DataApiBuilderException ex = Assert.ThrowsException(() => - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); + configValidator.ValidateRelationships(runtimeConfig, _metadataProviderFactory.Object)); Assert.AreEqual($"Could not find relationship between entities:" + $" SampleEntity1 and SampleEntity2.", ex.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); @@ -518,7 +512,7 @@ public void TestRelationshipWithNoLinkingObjectAndEitherSourceOrTargetFieldIsNul // No Exception is thrown as foreignKey Pair was found in the DB between // source and target entity. - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object); + configValidator.ValidateRelationships(runtimeConfig, _metadataProviderFactory.Object); } /// @@ -566,7 +560,6 @@ public void TestRelationshipWithoutSourceAndTargetFieldsMatching( FileSystemRuntimeConfigLoader loader = new(fileSystem); RuntimeConfigProvider provider = new(loader) { IsLateConfigured = true }; RuntimeConfigValidator configValidator = new(provider, fileSystem, new Mock>().Object); - Mock _sqlMetadataProvider = new(); Dictionary mockDictionaryForEntityDatabaseObject = new() { @@ -581,16 +574,10 @@ public void TestRelationshipWithoutSourceAndTargetFieldsMatching( } }; - _sqlMetadataProvider.Setup>(x => - x.EntityToDatabaseObject).Returns(mockDictionaryForEntityDatabaseObject); - - Mock _metadataProviderFactory = new(); - _metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(_sqlMetadataProvider.Object); - // Exception is thrown since sourceFields and targetFields do not match in either their existence, // or their length. DataApiBuilderException ex = Assert.ThrowsException(() => - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); + configValidator.ValidateRelationshipConfigCorrectness(runtimeConfig)); Assert.AreEqual(expectedExceptionMessage, ex.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); @@ -672,7 +659,7 @@ public void TestRelationshipWithoutSourceAndTargetFieldsAsValidBackingColumns( // Exception is thrown since either source or target field does not exist as a valid backing column in their respective entity. DataApiBuilderException ex = Assert.ThrowsException(() => - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); + configValidator.ValidateRelationships(runtimeConfig, _metadataProviderFactory.Object)); Assert.AreEqual(expectedExceptionMessage, ex.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); @@ -776,7 +763,6 @@ public void TestRelationshipWithoutLinkingSourceAndTargetFieldsMatching( FileSystemRuntimeConfigLoader loader = new(fileSystem); RuntimeConfigProvider provider = new(loader) { IsLateConfigured = true }; RuntimeConfigValidator configValidator = new(provider, fileSystem, new Mock>().Object); - Mock _sqlMetadataProvider = new(); Dictionary mockDictionaryForEntityDatabaseObject = new() { @@ -791,19 +777,10 @@ public void TestRelationshipWithoutLinkingSourceAndTargetFieldsMatching( } }; - _sqlMetadataProvider.Setup>(x => - x.EntityToDatabaseObject).Returns(mockDictionaryForEntityDatabaseObject); - - string discard; - _sqlMetadataProvider.Setup(x => x.TryGetExposedColumnName(It.IsAny(), It.IsAny(), out discard)).Returns(true); - - Mock _metadataProviderFactory = new(); - _metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(_sqlMetadataProvider.Object); - // Exception is thrown since linkingSourceFields and linkingTargetFields do not match in either their existence, // or their length. DataApiBuilderException ex = Assert.ThrowsException(() => - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); + configValidator.ValidateRelationshipConfigCorrectness(runtimeConfig)); Assert.AreEqual(expectedExceptionMessage, ex.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index eb89200d81..c5490529f4 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -635,7 +635,8 @@ private async Task PerformOnConfigChangeAsync(IApplicationBuilder app) if (runtimeConfig.IsDevelopmentMode()) { // Running only in developer mode to ensure fast and smooth startup in production. - runtimeConfigValidator.ValidateRelationshipsInConfig(runtimeConfig, sqlMetadataProviderFactory!); + runtimeConfigValidator.ValidateRelationshipConfigCorrectness(runtimeConfig); + runtimeConfigValidator.ValidateRelationships(runtimeConfig, sqlMetadataProviderFactory!); } // OpenAPI document creation is only attempted for REST supporting database types.