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
81 changes: 60 additions & 21 deletions src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,8 @@ public void ValidateDatabaseType(
/// <exception cref="DataApiBuilderException"></exception>
public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType databaseType, RuntimeEntities entityCollection)
{
HashSet<string> graphQLOperationNames = new();
// Maps each generated GraphQL operation name to the entity name that registered it.
Dictionary<string, string> graphQLOperationOwners = new();

foreach ((string entityName, Entity entity) in entityCollection)
{
Expand All @@ -916,16 +917,13 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType
continue;
}

bool containsDuplicateOperationNames = false;
List<string> 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
{
Expand All @@ -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<string> conflictingOperations = currentEntityOperations
.Where(op => graphQLOperationOwners.ContainsKey(op))
.ToList();

if (conflictingOperations.Count > 0)
{
// Identify which previously registered entities own the conflicting operations.
IEnumerable<string> 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;
}
}
}
}

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