Skip to content
Open
42 changes: 41 additions & 1 deletion src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,9 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType
{
HashSet<string> graphQLOperationNames = new();

// Tracks which entity registered each operation name, used only for building conflict error messages.
Dictionary<string, string> operationOwner = new();

foreach ((string entityName, Entity entity) in entityCollection)
{
if (!entity.GraphQL.Enabled)
Expand All @@ -918,6 +921,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.
Expand All @@ -926,6 +930,11 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType
if (!graphQLOperationNames.Add(storedProcedureQueryName))
{
containsDuplicateOperationNames = true;
conflictingEntityName = operationOwner.GetValueOrDefault(storedProcedureQueryName, string.Empty);
}
else
{
operationOwner[storedProcedureQueryName] = entityName;
}
}
else
Expand All @@ -952,13 +961,44 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType
|| ((databaseType is DatabaseType.CosmosDB_NoSQL) && !graphQLOperationNames.Add(patchMutationName)))
{
containsDuplicateOperationNames = true;
conflictingEntityName =
Comment thread
RubenCerna2079 marked this conversation as resolved.
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;
}
}
}
Comment thread
RubenCerna2079 marked this conversation as resolved.

if (containsDuplicateOperationNames)
{
string entitiesStr = string.IsNullOrEmpty(conflictingEntityName)
Comment thread
RubenCerna2079 marked this conversation as resolved.
? $" {entityName}"
: $" {conflictingEntityName}{Environment.NewLine} {entityName}";

string entityNamesStr = $" {GraphQLNaming.GetDefinedSingularName(entityName, entity)}{Environment.NewLine} {GraphQLNaming.GetDefinedPluralName(entityName, entity)}";

string message = $"{Environment.NewLine}GraphQL naming conflict detected."
+ $"{Environment.NewLine}{Environment.NewLine}Entities:{Environment.NewLine}{entitiesStr}"
+ $"{Environment.NewLine}{Environment.NewLine}Both entities generate the following GraphQL names:{Environment.NewLine}{entityNamesStr}"
+ $"{Environment.NewLine}{Environment.NewLine}Configure distinct GraphQL singular and plural names for one of the entities to resolve this conflict.";
Comment thread
RubenCerna2079 marked this conversation as resolved.

HandleOrRecordException(new DataApiBuilderException(
message: $"Entity {entityName} generates queries/mutation that already exist",
message: message,
statusCode: HttpStatusCode.ServiceUnavailable,
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
}
Expand Down
8 changes: 6 additions & 2 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6292,8 +6292,12 @@ public async Task TestAutoentitiesGeneratedWithSpacesInObjectName(string tableNa
[TestCategory(TestCategory.MSSQL)]
[DataTestMethod]
[DataRow("dbo_publishers", "uniqueSingularPublisher", "uniquePluralPublishers", "/unique/publisher", "Entity 'dbo_publishers' conflicts in autoentity pattern 'PublisherAutoEntity'. Use --patterns.exclude to skip it.", DisplayName = "Autoentities fail due to entity name")]
[DataRow("UniquePublisher", "dbo_publishers", "uniquePluralPublishers", "/unique/publisher", "Entity dbo_publishers generates queries/mutation that already exist", DisplayName = "Autoentities fail due to graphql singular type")]
[DataRow("UniquePublisher", "uniqueSingularPublisher", "dbo_publishers", "/unique/publisher", "Entity dbo_publishers generates queries/mutation that already exist", DisplayName = "Autoentities fail due to graphql plural type")]
[DataRow("UniquePublisher", "dbo_publishers", "uniquePluralPublishers", "/unique/publisher",
"\r\nGraphQL naming conflict detected.\r\n\r\nEntities:\r\n UniquePublisher\r\n dbo_publishers\r\n\r\nBoth entities generate the following GraphQL names:\r\n dbo_publishers\r\n dbo_publishers\r\n\r\nConfigure distinct GraphQL singular and plural names for one of the entities to resolve this conflict.",
DisplayName = "Autoentities fail due to graphql singular type")]
[DataRow("UniquePublisher", "uniqueSingularPublisher", "dbo_publishers", "/unique/publisher",
"\r\nGraphQL naming conflict detected.\r\n\r\nEntities:\r\n UniquePublisher\r\n dbo_publishers\r\n\r\nBoth entities generate the following GraphQL names:\r\n dbo_publishers\r\n dbo_publishers\r\n\r\nConfigure distinct GraphQL singular and plural names for one of the entities to resolve this conflict.",
DisplayName = "Autoentities fail due to graphql plural type")]
[DataRow("UniquePublisher", "uniqueSingularPublisher", "uniquePluralPublishers", "/dbo_publishers", "The rest path: dbo_publishers specified for entity: dbo_publishers is already used by another entity.", DisplayName = "Autoentities fail due to rest path")]
public async Task ValidateAutoentityGenerationConflicts(string entityName, string singular, string plural, string path, string exceptionMessage)
{
Expand Down
64 changes: 55 additions & 9 deletions src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1259,7 +1259,7 @@ public void ValidateEntitiesWithGraphQLExposedGenerateDuplicateQueries(DatabaseT
{ "book", book },
{ "Book", bookWithUpperCase }
};
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "Book", databaseType);
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "Book", databaseType, "book");
}

/// <summary>
Expand Down Expand Up @@ -1302,7 +1302,7 @@ public void ValidateStoredProcedureAndTableGeneratedDuplicateQueries(DatabaseTyp
{ "executeBook", bookTable },
{ "Book_by_pk", bookByPkStoredProcedure }
};
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "executeBook", databaseType);
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "executeBook", databaseType, "Book_by_pk");
}

/// <summary>
Expand Down Expand Up @@ -1346,7 +1346,7 @@ public void ValidateStoredProcedureAndTableGeneratedDuplicateMutation(DatabaseTy
{ "ExecuteBooks", bookTable },
{ "AddBook", addBookStoredProcedure }
};
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "ExecuteBooks", databaseType);
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "ExecuteBooks", databaseType, "AddBook");
}

/// <summary>
Expand Down Expand Up @@ -1384,7 +1384,7 @@ public void ValidateEntitiesWithNameCollisionInGraphQLTypeGenerateDuplicateQueri
{ "book", book },
{ "book_alt", book_alt }
};
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType);
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType, "book");
}

/// <summary>
Expand Down Expand Up @@ -1427,7 +1427,7 @@ public void ValidateEntitiesWithCollisionsInSingularPluralNamesGenerateDuplicate
{ "book", book },
{ "book_alt", book_alt }
};
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType);
ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_alt", databaseType, "book");
}

/// <summary>
Expand Down Expand Up @@ -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, "book");
}

/// <summary>
/// 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.
/// </summary>
[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<string, Entity> entityCollection = new()
{
{ "dbo_Categories", categoriesEntity },
{ "dbo_Category", categoryEntity }
};

ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(
entityCollection,
"dbo_Category",
databaseType,
conflictingEntityName: "dbo_Categories");
}

/// <summary>
Expand Down Expand Up @@ -1612,14 +1650,22 @@ public void TestGlobalRouteValidation(string graphQLConfiguredPath, string restC
/// queries with the same name.
/// </summary>
/// <param name="entityCollection">Entity definitions</param>
/// <param name="entityName">Entity name to construct the expected exception message</param>
private static void ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(SortedDictionary<string, Entity> entityCollection, string entityName, DatabaseType databaseType)
/// <param name="entityName">The entity name expected to appear in the conflict message as the conflicting entity.</param>
/// <param name="databaseType">Database type used during validation.</param>
/// <param name="conflictingEntityName">The other entity name expected to appear in the conflict message.</param>
private static void ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(
SortedDictionary<string, Entity> entityCollection,
string entityName,
DatabaseType databaseType,
string conflictingEntityName)
{
RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator();
DataApiBuilderException dabException = Assert.ThrowsException<DataApiBuilderException>(
action: () => configValidator.ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(databaseType, new(entityCollection)));

Assert.AreEqual(expected: $"Entity {entityName} generates queries/mutation that already exist", actual: dabException.Message);
StringAssert.Contains(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);
}
Expand Down