diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 1c9f8b9ecd..fd926e60a3 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -909,6 +909,9 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType { HashSet graphQLOperationNames = new(); + // Tracks which entity registered each operation name, used only for building conflict error messages. + Dictionary operationOwner = new(); + foreach ((string entityName, Entity entity) in entityCollection) { if (!entity.GraphQL.Enabled) @@ -917,6 +920,7 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType } bool containsDuplicateOperationNames = false; + string conflictingEntityName = string.Empty; if (entity.Source.Type is EntitySourceType.StoredProcedure) { // For Stored Procedures a single query/mutation is generated. @@ -925,6 +929,11 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType if (!graphQLOperationNames.Add(storedProcedureQueryName)) { containsDuplicateOperationNames = true; + conflictingEntityName = operationOwner.GetValueOrDefault(storedProcedureQueryName, string.Empty); + } + else + { + operationOwner[storedProcedureQueryName] = entityName; } } else @@ -951,13 +960,41 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType || ((databaseType is DatabaseType.CosmosDB_NoSQL) && !graphQLOperationNames.Add(patchMutationName))) { containsDuplicateOperationNames = true; + conflictingEntityName = + operationOwner.GetValueOrDefault(pkQueryName) ?? + operationOwner.GetValueOrDefault(listQueryName) ?? + operationOwner.GetValueOrDefault(createMutationName) ?? + operationOwner.GetValueOrDefault(updateMutationName) ?? + operationOwner.GetValueOrDefault(deleteMutationName) ?? + operationOwner.GetValueOrDefault(patchMutationName) ?? + string.Empty; + } + else + { + operationOwner[pkQueryName] = entityName; + operationOwner[listQueryName] = entityName; + operationOwner[createMutationName] = entityName; + operationOwner[updateMutationName] = entityName; + operationOwner[deleteMutationName] = entityName; + if (databaseType is DatabaseType.CosmosDB_NoSQL) + { + operationOwner[patchMutationName] = entityName; + } } } if (containsDuplicateOperationNames) { + string entitiesStr = string.IsNullOrEmpty(conflictingEntityName) + ? $" {entityName}" + : $" {conflictingEntityName}{Environment.NewLine} {entityName}"; + + string message = $"GraphQL naming conflict detected.{Environment.NewLine}" + + $"{Environment.NewLine}Entities:{Environment.NewLine}{entitiesStr}" + + $"{Environment.NewLine}{Environment.NewLine}Configure distinct GraphQL singular and plural names for one of the entities to resolve this conflict."; + HandleOrRecordException(new DataApiBuilderException( - message: $"Entity {entityName} generates queries/mutation that already exist", + message: message, statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); } diff --git a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs index 178115c86c..6096dd7835 100644 --- a/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs @@ -1259,7 +1259,7 @@ public void ValidateEntitiesWithGraphQLExposedGenerateDuplicateQueries(DatabaseT { "book", book }, { "Book", bookWithUpperCase } }; - ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "Book", databaseType); + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "Book", databaseType, conflictingEntityName: "book"); } /// @@ -1302,7 +1302,7 @@ public void ValidateStoredProcedureAndTableGeneratedDuplicateQueries(DatabaseTyp { "executeBook", bookTable }, { "Book_by_pk", bookByPkStoredProcedure } }; - ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "executeBook", databaseType); + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "executeBook", databaseType, conflictingEntityName: "Book_by_pk"); } /// @@ -1346,7 +1346,7 @@ public void ValidateStoredProcedureAndTableGeneratedDuplicateMutation(DatabaseTy { "ExecuteBooks", bookTable }, { "AddBook", addBookStoredProcedure } }; - ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "ExecuteBooks", databaseType); + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "ExecuteBooks", databaseType, conflictingEntityName: "AddBook"); } /// @@ -1384,7 +1384,7 @@ public void ValidateEntitiesWithNameCollisionInGraphQLTypeGenerateDuplicateQueri { "book", book }, { "book_alt", book_alt } }; - ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType); + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType, conflictingEntityName: "book"); } /// @@ -1427,7 +1427,7 @@ public void ValidateEntitiesWithCollisionsInSingularPluralNamesGenerateDuplicate { "book", book }, { "book_alt", book_alt } }; - ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType); + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType, conflictingEntityName: "book"); } /// @@ -1465,7 +1465,45 @@ public void ValidateEntitiesWithNameCollisionInSingularPluralTypeGeneratesDuplic entityCollection.Add("book_alt", book_alt); entityCollection.Add("book", book); - ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType); + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType, conflictingEntityName: "book"); + } + + /// + /// Validates that a detailed error is thrown when autoentities includes objects whose names + /// differ only by singular/plural form (e.g. dbo.Category and dbo.Categories), causing + /// DAB to generate conflicting GraphQL type and operation names. + /// + /// "dbo_Category" entity → singular: Category, plural: Categories + /// "dbo_Categories" entity → singular: Category, plural: Categories (after pluralization) + /// + /// Both entities generate the same pk query, list query, and mutation names. + /// + [TestMethod] + [DataRow(DatabaseType.MSSQL)] // Relational Database + [DataRow(DatabaseType.CosmosDB_NoSQL)] // Non Relational Database + public void ValidateAutoEntitiesWithSingularPluralNameCollisionGenerateDuplicateQueries(DatabaseType databaseType) + { + // Entity Name: dbo_Category + // Singular: Category (from entity name processed by autoentities) + // Plural: Categories (pluralized from singular) + Entity categoryEntity = GraphQLTestHelpers.GenerateEntityWithSingularPlural("Category", "Categories"); + + // Entity Name: dbo_Categories + // Singular: Category (after singularization by autoentities) + // Plural: Categories + Entity categoriesEntity = GraphQLTestHelpers.GenerateEntityWithSingularPlural("Category", "Categories"); + + SortedDictionary entityCollection = new() + { + { "dbo_Categories", categoriesEntity }, + { "dbo_Category", categoryEntity } + }; + + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions( + entityCollection, + "dbo_Category", + databaseType, + conflictingEntityName: "dbo_Categories"); } /// @@ -1612,14 +1650,22 @@ public void TestGlobalRouteValidation(string graphQLConfiguredPath, string restC /// queries with the same name. /// /// Entity definitions - /// Entity name to construct the expected exception message - private static void ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(SortedDictionary entityCollection, string entityName, DatabaseType databaseType) + /// The entity name expected to appear in the conflict message as the conflicting entity. + /// Database type used during validation. + /// The other entity name expected to appear in the conflict message. + private static void ValidateExceptionForDuplicateQueriesDueToEntityDefinitions( + SortedDictionary entityCollection, + string entityName, + DatabaseType databaseType, + string conflictingEntityName) { RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(); DataApiBuilderException dabException = Assert.ThrowsException( action: () => configValidator.ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(databaseType, new(entityCollection))); - Assert.AreEqual(expected: $"Entity {entityName} generates queries/mutation that already exist", actual: dabException.Message); + StringAssert.StartsWith(dabException.Message, "GraphQL naming conflict detected."); + StringAssert.Contains(dabException.Message, entityName); + StringAssert.Contains(dabException.Message, conflictingEntityName); Assert.AreEqual(expected: HttpStatusCode.ServiceUnavailable, actual: dabException.StatusCode); Assert.AreEqual(expected: DataApiBuilderException.SubStatusCodes.ConfigValidationError, actual: dabException.SubStatusCode); }