diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 1c9f8b9ecd..334c46be00 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -907,7 +907,8 @@ public void ValidateDatabaseType( /// public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType databaseType, RuntimeEntities entityCollection) { - HashSet graphQLOperationNames = new(); + // Maps each generated GraphQL operation name to the entity name that registered it. + Dictionary graphQLOperationOwners = new(); foreach ((string entityName, Entity entity) in entityCollection) { @@ -916,16 +917,13 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType continue; } - bool containsDuplicateOperationNames = false; + List currentEntityOperations = new(); + if (entity.Source.Type is EntitySourceType.StoredProcedure) { // For Stored Procedures a single query/mutation is generated. string storedProcedureQueryName = GraphQLNaming.GenerateStoredProcedureGraphQLFieldName(entityName, entity); - - if (!graphQLOperationNames.Add(storedProcedureQueryName)) - { - containsDuplicateOperationNames = true; - } + currentEntityOperations.Add(storedProcedureQueryName); } else { @@ -938,29 +936,70 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType string listQueryName = GraphQLNaming.GenerateListQueryName(entityName, entity); // Mutations names for the exposed entities are determined. - string createMutationName = $"create{GraphQLNaming.GetDefinedSingularName(entityName, entity)}"; - string updateMutationName = $"update{GraphQLNaming.GetDefinedSingularName(entityName, entity)}"; - string deleteMutationName = $"delete{GraphQLNaming.GetDefinedSingularName(entityName, entity)}"; - string patchMutationName = $"patch{GraphQLNaming.GetDefinedSingularName(entityName, entity)}"; - - if (!graphQLOperationNames.Add(pkQueryName) - || !graphQLOperationNames.Add(listQueryName) - || !graphQLOperationNames.Add(createMutationName) - || !graphQLOperationNames.Add(updateMutationName) - || !graphQLOperationNames.Add(deleteMutationName) - || ((databaseType is DatabaseType.CosmosDB_NoSQL) && !graphQLOperationNames.Add(patchMutationName))) + string singularName = GraphQLNaming.GetDefinedSingularName(entityName, entity); + string createMutationName = $"create{singularName}"; + string updateMutationName = $"update{singularName}"; + string deleteMutationName = $"delete{singularName}"; + string patchMutationName = $"patch{singularName}"; + + currentEntityOperations.Add(pkQueryName); + currentEntityOperations.Add(listQueryName); + currentEntityOperations.Add(createMutationName); + currentEntityOperations.Add(updateMutationName); + currentEntityOperations.Add(deleteMutationName); + + if (databaseType is DatabaseType.CosmosDB_NoSQL) { - containsDuplicateOperationNames = true; + currentEntityOperations.Add(patchMutationName); } } - if (containsDuplicateOperationNames) + // Identify all operations that are already registered by a previously processed entity. + List conflictingOperations = currentEntityOperations + .Where(op => graphQLOperationOwners.ContainsKey(op)) + .ToList(); + + if (conflictingOperations.Count > 0) { + // Identify which previously registered entities own the conflicting operations. + IEnumerable conflictingEntityNames = conflictingOperations + .Select(op => graphQLOperationOwners[op]) + .Distinct(); + + string conflictingEntitiesStr = string.Join( + $"{Environment.NewLine} ", + conflictingEntityNames.Append(entityName)); + + string conflictingOpsStr = string.Join( + $"{Environment.NewLine} ", + conflictingOperations); + + string message = $"GraphQL naming conflict detected.{Environment.NewLine}" + + $"{Environment.NewLine}Entities:{Environment.NewLine} {conflictingEntitiesStr}" + + $"{Environment.NewLine}{Environment.NewLine}The above entities generate conflicting GraphQL operation names:{Environment.NewLine} {conflictingOpsStr}"; + + if (entity.Source.Type is not EntitySourceType.StoredProcedure) + { + string singularName = GraphQLNaming.GetDefinedSingularName(entityName, entity); + string pluralName = GraphQLNaming.GetDefinedPluralName(entityName, entity); + message += $"{Environment.NewLine}{Environment.NewLine}GraphQL type names in conflict:{Environment.NewLine} Singular type: {singularName}{Environment.NewLine} Plural type: {pluralName}"; + } + + message += $"{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)); } + else + { + // No conflicts; register all operations for this entity. + foreach (string op in currentEntityOperations) + { + graphQLOperationOwners[op] = entityName; + } + } } } 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); }