Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,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 @@ -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.
Expand All @@ -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
Expand All @@ -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));
}
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, conflictingEntityName: "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, conflictingEntityName: "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, conflictingEntityName: "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, conflictingEntityName: "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, conflictingEntityName: "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, conflictingEntityName: "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.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);
}
Expand Down