diff --git a/config-generators/mysql-commands.txt b/config-generators/mysql-commands.txt index d322c8722a..cd2f1c58d8 100644 --- a/config-generators/mysql-commands.txt +++ b/config-generators/mysql-commands.txt @@ -2,6 +2,7 @@ init --config "dab-config.MySql.json" --database-type mysql --connection-string add Publisher --config "dab-config.MySql.json" --source publishers --permissions "anonymous:read" add Stock --config "dab-config.MySql.json" --source stocks --permissions "anonymous:create,read,update,delete" add Book --config "dab-config.MySql.json" --source books --permissions "anonymous:create,read,update,delete" --graphql "book:books" +add BookNF --config "dab-config.MySql.json" --source books --permissions "anonymous:*" --rest true --graphql "bookNF:booksNF" add BookWebsitePlacement --config "dab-config.MySql.json" --source book_website_placements --permissions "anonymous:read" add Author --config "dab-config.MySql.json" --source authors --permissions "anonymous:read" add Review --config "dab-config.MySql.json" --source reviews --permissions "anonymous:create,read,update" --rest false --graphql "review:reviews" diff --git a/src/Core/Resolvers/MultipleCreateOrderHelper.cs b/src/Core/Resolvers/MultipleCreateOrderHelper.cs index 2e20181d10..83c2ebd003 100644 --- a/src/Core/Resolvers/MultipleCreateOrderHelper.cs +++ b/src/Core/Resolvers/MultipleCreateOrderHelper.cs @@ -24,13 +24,14 @@ public class MultipleCreateOrderHelper /// returns the referencing entity's name for the pair of (source, target) entities. /// /// When visualized as a graphQL mutation request, - /// Source entity refers to the top level entity + /// Source entity refers to the top level entity in whose configuration the relationship is defined. /// Target entity refers to the related entity. /// /// This method handles the logic to determine the referencing entity for relationships from (source, target) with cardinalities: /// 1. 1:N - Target entity is the referencing entity /// 2. N:1 - Source entity is the referencing entity /// 3. 1:1 - Determined based on foreign key constraint/request input data. + /// 4. M:N - None of the source/target entity is the referencing entity. Instead, linking table acts as the referencing entity. /// /// GraphQL request context. /// Configured relationship name in the config file b/w source and target entity. @@ -40,6 +41,7 @@ public class MultipleCreateOrderHelper /// Column name/value for backing columns present in the request input for the source entity. /// Input GraphQL value for target node (could be an object or array). /// Nesting level of the entity in the mutation request. + /// true if the relationship is a Many-Many relationship. public static string GetReferencingEntityName( IMiddlewareContext context, string relationshipName, @@ -48,7 +50,8 @@ public static string GetReferencingEntityName( ISqlMetadataProvider metadataProvider, Dictionary columnDataInSourceBody, IValueNode? targetNodeValue, - int nestingLevel) + int nestingLevel, + bool isMNRelationship = false) { if (!metadataProvider.GetEntityNamesAndDbObjects().TryGetValue(sourceEntityName, out DatabaseObject? sourceDbObject) || !metadataProvider.GetEntityNamesAndDbObjects().TryGetValue(targetEntityName, out DatabaseObject? targetDbObject)) @@ -61,10 +64,39 @@ public static string GetReferencingEntityName( subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } + if (sourceDbObject.GetType() != typeof(DatabaseTable) || targetDbObject.GetType() != typeof(DatabaseTable)) + { + throw new DataApiBuilderException( + message: $"Cannot execute multiple-create for relationship: {relationshipName} at level: {nestingLevel} because currently DAB supports" + + $"multiple-create only for entities backed by tables.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); + } + + DatabaseTable sourceDbTable = (DatabaseTable)sourceDbObject; + DatabaseTable targetDbTable = (DatabaseTable)targetDbObject; + if (sourceDbTable.Equals(targetDbTable)) + { + // DAB does not yet support multiple-create for self referencing relationships where both the source and + // target entities are backed by same database table. + throw new DataApiBuilderException( + message: $"Multiple-create for relationship: {relationshipName} at level: {nestingLevel} is not supported because the " + + $"source entity: {sourceEntityName} and the target entity: {targetEntityName} are backed by the same database table.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); + } + + if (isMNRelationship) + { + // For M:N relationships, neither the source nor the target entity act as the referencing entity. + // Instead, the linking table act as the referencing entity. + return string.Empty; + } + if (TryDetermineReferencingEntityBasedOnEntityRelationshipMetadata( sourceEntityName: sourceEntityName, targetEntityName: targetEntityName, - sourceDbObject: sourceDbObject, + sourceDbTable: sourceDbTable, referencingEntityName: out string? referencingEntityNameBasedOnEntityMetadata)) { return referencingEntityNameBasedOnEntityMetadata; @@ -100,18 +132,17 @@ public static string GetReferencingEntityName( /// /// Source entity name. /// Target entity name. - /// Database object for source entity. + /// Database table for source entity. /// Stores the determined referencing entity name to be returned to the caller. /// True when the referencing entity name can be determined based on the foreign key constraint defined in the database; /// else false. private static bool TryDetermineReferencingEntityBasedOnEntityRelationshipMetadata( string sourceEntityName, string targetEntityName, - DatabaseObject sourceDbObject, + DatabaseTable sourceDbTable, [NotNullWhen(true)] out string? referencingEntityName) { - DatabaseTable sourceDbTable = (DatabaseTable)sourceDbObject; - SourceDefinition sourceDefinition = sourceDbObject.SourceDefinition; + SourceDefinition sourceDefinition = sourceDbTable.SourceDefinition; List targetEntityForeignKeys = sourceDefinition.SourceEntityRelationshipMap[sourceEntityName].TargetEntityToFkDefinitionMap[targetEntityName]; HashSet referencingEntityNames = new(); diff --git a/src/Core/Services/MultipleMutationInputValidator.cs b/src/Core/Services/MultipleMutationInputValidator.cs index 1811fa39bf..5723795a30 100644 --- a/src/Core/Services/MultipleMutationInputValidator.cs +++ b/src/Core/Services/MultipleMutationInputValidator.cs @@ -345,15 +345,7 @@ private void ProcessRelationshipField( const string relationshipSourceIdentifier = "$"; string targetEntityName = runtimeConfig.Entities![entityName].Relationships![relationshipName].TargetEntity; string? linkingObject = runtimeConfig.Entities![entityName].Relationships![relationshipName].LinkingObject; - if (!string.IsNullOrWhiteSpace(linkingObject)) - { - // The presence of a linking object indicates that an M:N relationship exists between the current entity and the target/child entity. - // The linking table acts as a referencing table for both the source/target entities which act as - // referenced entities. Consequently: - // - Column values for the target entity can't be derived from insertion in the current entity. - // - Column values for the current entity can't be derived from the insertion in the target/child entity. - return; - } + bool isMNRelationship = !string.IsNullOrWhiteSpace(linkingObject); // Determine the referencing entity for the current relationship field input. string referencingEntityName = MultipleCreateOrderHelper.GetReferencingEntityName( @@ -364,7 +356,18 @@ private void ProcessRelationshipField( metadataProvider: metadataProvider, columnDataInSourceBody: backingColumnData, targetNodeValue: relationshipFieldValue, - nestingLevel: nestingLevel); + nestingLevel: nestingLevel, + isMNRelationship: isMNRelationship); + + if (isMNRelationship) + { + // The presence of a linking object indicates that an M:N relationship exists between the current entity and the target/child entity. + // The linking table acts as a referencing table for both the source/target entities which act as + // referenced entities. Consequently: + // - Column values for the target entity can't be derived from insertion in the current entity. + // - Column values for the current entity can't be derived from the insertion in the target/child entity. + return; + } // Determine the referenced entity. string referencedEntityName = referencingEntityName.Equals(entityName) ? targetEntityName : entityName; diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt index e5e46779cb..678d4fb224 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt @@ -766,6 +766,32 @@ } } }, + { + BookNF: { + Source: { + Object: books, + Type: Table + }, + GraphQL: { + Singular: bookNF, + Plural: booksNF, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ] + } + }, { BookWebsitePlacement: { Source: { diff --git a/src/Service.Tests/Unittests/MultipleCreateUnitTests/MultipleCreateOrderHelperUnitTests.cs b/src/Service.Tests/Unittests/MultipleCreateUnitTests/MultipleCreateOrderHelperUnitTests.cs index a8ae61db00..87e3882eed 100644 --- a/src/Service.Tests/Unittests/MultipleCreateUnitTests/MultipleCreateOrderHelperUnitTests.cs +++ b/src/Service.Tests/Unittests/MultipleCreateUnitTests/MultipleCreateOrderHelperUnitTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Net; using Azure.DataApiBuilder.Core.Resolvers; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Tests.SqlTests; @@ -374,6 +375,23 @@ public void ValidateReferencingEntityBasedOnEntityMetadata() #endregion + #region Order determination test for relationships having source/target entities backed by same database table + + /// + /// Test to validate that when multiple-create is executed for a relationship for which source and target entities are backed by the + /// same database table, we throw an appropriate exception because DAB currently does not support multiple-create for such relationships. + /// + [TestMethod] + public void TestExceptionForSelfReferencingRelationships() + { + // Identical source and target entities backed by the same database table 'books'. + ValidateExceptionForSelfReferencingRelationship(sourceEntityName: "Book", targetEntityName: "Book"); + + // Different source and target entities backed by the same database table 'books'. + ValidateExceptionForSelfReferencingRelationship(sourceEntityName: "Book", targetEntityName: "BookNF"); + } + #endregion + #region Helpers private static void ValidateReferencingEntityForRelationship( string sourceEntityName, @@ -395,6 +413,35 @@ private static void ValidateReferencingEntityForRelationship( nestingLevel: 1); Assert.AreEqual(expectedReferencingEntityName, actualReferencingEntityName); } + + /// + /// Helper method to validate the exception when multiple-create is executed for a self-referencing relationship where source and target + /// entities are backed by the same database table. + /// + /// Name of the source entity. + /// NAme of the target entity. + private static void ValidateExceptionForSelfReferencingRelationship( + string sourceEntityName, + string targetEntityName) + { + // Setup mock IMiddlewareContext. + IMiddlewareContext context = SetupMiddlewareContext(); + DataApiBuilderException ex = Assert.ThrowsException(() => MultipleCreateOrderHelper.GetReferencingEntityName( + relationshipName: "testRelationship", // Don't need relationship name while testing determination of referencing entity using metadata. + context: context, + sourceEntityName: sourceEntityName, + targetEntityName: targetEntityName, + metadataProvider: _sqlMetadataProvider, + columnDataInSourceBody: new(), + targetNodeValue: null, + nestingLevel: 1)); + + // Assert that the exception is as expected. + Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.NotSupported, ex.SubStatusCode); + Assert.AreEqual($"Multiple-create for relationship: testRelationship at level: 1 is not supported because the source entity: {sourceEntityName} and" + + $" the target entity: {targetEntityName} are backed by the same database table.", ex.Message); + } #endregion #region Setup diff --git a/src/Service.Tests/dab-config.MySql.json b/src/Service.Tests/dab-config.MySql.json index e8401fcdcb..bfa60f263c 100644 --- a/src/Service.Tests/dab-config.MySql.json +++ b/src/Service.Tests/dab-config.MySql.json @@ -821,6 +821,32 @@ } } }, + "BookNF": { + "source": { + "object": "books", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "bookNF", + "plural": "booksNF" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + }, "BookWebsitePlacement": { "source": { "object": "book_website_placements",