diff --git a/src/Core/Resolvers/SqlMutationEngine.cs b/src/Core/Resolvers/SqlMutationEngine.cs index 970a58bb51..204d71d44f 100644 --- a/src/Core/Resolvers/SqlMutationEngine.cs +++ b/src/Core/Resolvers/SqlMutationEngine.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Data; using System.Data.Common; using System.Net; using System.Text.Json; @@ -542,86 +541,59 @@ await queryExecutor.ExecuteQueryAsync( try { - if (context.OperationType is EntityActionOperation.Upsert || context.OperationType is EntityActionOperation.UpsertIncremental) + // When the URL path has no primary key route but the request body contains + // ALL PK columns, promote those values into PrimaryKeyValuePairs so the upsert + // path can build a proper UPDATE ... WHERE pk = value (with INSERT fallback) + // instead of blindly inserting and failing on a PK violation. + // Every PK column must be present — including auto-generated ones — because + // a partial composite key cannot uniquely identify a row for UPDATE. + if ((context.OperationType is EntityActionOperation.Upsert || context.OperationType is EntityActionOperation.UpsertIncremental) + && context.PrimaryKeyValuePairs.Count == 0) { - // When no primary key values are provided (empty PrimaryKeyValuePairs), - // there is no row to look up for update. The upsert degenerates to a - // pure INSERT - execute it via the insert path so the mutation engine - // generates a correct INSERT statement instead of an UPDATE with an - // empty WHERE clause (WHERE 1 = 1) that would match every row. - if (context.PrimaryKeyValuePairs.Count == 0) - { - DbResultSetRow? insertResultRow = null; + SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(context.EntityName); + bool allPKsInBody = true; + List pkExposedNames = new(); - try + foreach (string pk in sourceDefinition.PrimaryKey) + { + if (!sqlMetadataProvider.TryGetExposedColumnName(context.EntityName, pk, out string? exposedName)) { - using (TransactionScope transactionScope = ConstructTransactionScopeBasedOnDbType(sqlMetadataProvider)) - { - insertResultRow = - await PerformMutationOperation( - entityName: context.EntityName, - operationType: EntityActionOperation.Insert, - parameters: parameters, - sqlMetadataProvider: sqlMetadataProvider); - - if (insertResultRow is null) - { - throw new DataApiBuilderException( - message: "An unexpected error occurred while trying to execute the query.", - statusCode: HttpStatusCode.InternalServerError, - subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); - } - - if (insertResultRow.Columns.Count == 0) - { - throw new DataApiBuilderException( - message: "Could not insert row with given values.", - statusCode: HttpStatusCode.Forbidden, - subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure); - } - - if (isDatabasePolicyDefinedForReadAction) - { - FindRequestContext findRequestContext = ConstructFindRequestContext(context, insertResultRow, roleName, sqlMetadataProvider); - IQueryEngine queryEngine = _queryEngineFactory.GetQueryEngine(sqlMetadataProvider.GetDatabaseType()); - selectOperationResponse = await queryEngine.ExecuteAsync(findRequestContext); - } - - transactionScope.Complete(); - } + allPKsInBody = false; + break; } - catch (TransactionException) + + if (!context.FieldValuePairsInBody.ContainsKey(exposedName)) { - throw _dabExceptionWithTransactionErrorMessage; + allPKsInBody = false; + break; } - if (isReadPermissionConfiguredForRole && !isDatabasePolicyDefinedForReadAction) + pkExposedNames.Add(exposedName); + } + + if (allPKsInBody) + { + // Populate PrimaryKeyValuePairs from the body so the upsert path + // generates an UPDATE with the correct WHERE clause. + foreach (string exposedName in pkExposedNames) { - IEnumerable allowedExposedColumns = _authorizationResolver.GetAllowedExposedColumns(context.EntityName, roleName, EntityActionOperation.Read); - foreach (string columnInResponse in insertResultRow.Columns.Keys) + if (context.FieldValuePairsInBody.TryGetValue(exposedName, out object? value)) { - if (!allowedExposedColumns.Contains(columnInResponse)) - { - insertResultRow.Columns.Remove(columnInResponse); - } + context.PrimaryKeyValuePairs[exposedName] = value!; } } - - string pkRouteForLocationHeader = isReadPermissionConfiguredForRole - ? SqlResponseHelpers.ConstructPrimaryKeyRoute(context, insertResultRow.Columns, sqlMetadataProvider) - : string.Empty; - - return SqlResponseHelpers.ConstructCreatedResultResponse( - insertResultRow.Columns, - selectOperationResponse, - pkRouteForLocationHeader, - isReadPermissionConfiguredForRole, - isDatabasePolicyDefinedForReadAction, - context.OperationType, - GetBaseRouteFromConfig(_runtimeConfigProvider.GetConfig()), - GetHttpContext()); } + } + // When an upsert still has no primary key values after checking the body, + // it degenerates to a pure INSERT. Fall through to the shared insert/update + // handling so the mutation engine generates a correct INSERT statement instead + // of an UPDATE with an empty WHERE clause (WHERE 1 = 1) that would match every row. + bool isKeylessUpsert = (context.OperationType is EntityActionOperation.Upsert || context.OperationType is EntityActionOperation.UpsertIncremental) + && context.PrimaryKeyValuePairs.Count == 0; + + if (!isKeylessUpsert && (context.OperationType is EntityActionOperation.Upsert || context.OperationType is EntityActionOperation.UpsertIncremental)) + { DbResultSet? upsertOperationResult; DbResultSetRow upsertOperationResultSetRow; @@ -723,7 +695,12 @@ await PerformMutationOperation( } else { - // This code block gets executed when the operation type is one among Insert, Update or UpdateIncremental. + // This code block handles Insert, Update, UpdateIncremental, + // and keyless upsert (which degenerates to Insert). + EntityActionOperation effectiveOperationType = isKeylessUpsert + ? EntityActionOperation.Insert + : context.OperationType; + DbResultSetRow? mutationResultRow = null; try @@ -734,13 +711,13 @@ await PerformMutationOperation( mutationResultRow = await PerformMutationOperation( entityName: context.EntityName, - operationType: context.OperationType, + operationType: effectiveOperationType, parameters: parameters, sqlMetadataProvider: sqlMetadataProvider); if (mutationResultRow is null || mutationResultRow.Columns.Count == 0) { - if (context.OperationType is EntityActionOperation.Insert) + if (effectiveOperationType is EntityActionOperation.Insert) { if (mutationResultRow is null) { @@ -827,17 +804,32 @@ await PerformMutationOperation( string primaryKeyRouteForLocationHeader = isReadPermissionConfiguredForRole ? SqlResponseHelpers.ConstructPrimaryKeyRoute(context, mutationResultRow!.Columns, sqlMetadataProvider) : string.Empty; - if (context.OperationType is EntityActionOperation.Insert) + if (effectiveOperationType is EntityActionOperation.Insert) { // Location Header is made up of the Base URL of the request and the primary key of the item created. - // For POST requests, the primary key info would not be available in the URL and needs to be appended. So, the primary key of the newly created item - // which is stored in the primaryKeyRoute is used to construct the Location Header. - return SqlResponseHelpers.ConstructCreatedResultResponse(mutationResultRow!.Columns, selectOperationResponse, primaryKeyRouteForLocationHeader, isReadPermissionConfiguredForRole, isDatabasePolicyDefinedForReadAction, context.OperationType, GetBaseRouteFromConfig(_runtimeConfigProvider.GetConfig()), GetHttpContext()); + // For POST requests and keyless PUT/PATCH requests, the primary key info would not be available + // in the URL and needs to be appended. So, the primary key of the newly created item which is + // stored in the primaryKeyRoute is used to construct the Location Header. + // effectiveOperationType (Insert) is passed so that ConstructCreatedResultResponse populates + // the Location header for both true POST inserts and keyless upserts that result in an insert. + return SqlResponseHelpers.ConstructCreatedResultResponse( + mutationResultRow!.Columns, + selectOperationResponse, + primaryKeyRouteForLocationHeader, + isReadPermissionConfiguredForRole, + isDatabasePolicyDefinedForReadAction, + effectiveOperationType, + GetBaseRouteFromConfig(_runtimeConfigProvider.GetConfig()), + GetHttpContext()); } - if (context.OperationType is EntityActionOperation.Update || context.OperationType is EntityActionOperation.UpdateIncremental) + if (effectiveOperationType is EntityActionOperation.Update || effectiveOperationType is EntityActionOperation.UpdateIncremental) { - return SqlResponseHelpers.ConstructOkMutationResponse(mutationResultRow!.Columns, selectOperationResponse, isReadPermissionConfiguredForRole, isDatabasePolicyDefinedForReadAction); + return SqlResponseHelpers.ConstructOkMutationResponse( + mutationResultRow!.Columns, + selectOperationResponse, + isReadPermissionConfiguredForRole, + isDatabasePolicyDefinedForReadAction); } } diff --git a/src/Core/Resolvers/SqlResponseHelpers.cs b/src/Core/Resolvers/SqlResponseHelpers.cs index d955a7a36b..3736054d7f 100644 --- a/src/Core/Resolvers/SqlResponseHelpers.cs +++ b/src/Core/Resolvers/SqlResponseHelpers.cs @@ -369,16 +369,16 @@ HttpContext httpContext string locationHeaderURL = string.Empty; using JsonDocument emptyResponseJsonDocument = JsonDocument.Parse("[]"); - // For PUT and PATCH API requests, the users are aware of the Pks as it is required to be passed in the request URL. - // In case of tables with auto-gen PKs, PUT or PATCH will not result in an insert but error out. Seeing that Location Header does not provide users with - // any additional information, it is set as an empty string always. - // For POST API requests, the primary key route calculated will be an empty string in the following scenarions. + // For PUT/PATCH requests where PKs are in the URL, the caller passes operationType as Upsert + // and primaryKeyRoute as empty, so the Location header is not populated (the client already knows the URL). + // For keyless PUT/PATCH requests that result in an insert, the caller passes operationType as Insert + // with a non-empty primaryKeyRoute so the client can discover the newly created resource's location. + // For POST requests, the primary key route will be empty in the following scenarios: // 1. When read action is not configured for the role. // 2. When the read action for the role does not have access to one or more PKs. - // When the computed primaryKeyRoute is non-empty, the location header is calculated. - // Location is made up of three parts, the first being constructed from the Host property found in the HttpContext.Request. - // The second part being the base route configured in the config file. - // The third part is the computed primary key route. + // When the computed primaryKeyRoute is non-empty and operationType is Insert, the Location header is populated. + // Location is made up of three parts: the scheme/host from the request, the base route from config, + // and the computed primary key route. if (operationType is EntityActionOperation.Insert && !string.IsNullOrEmpty(primaryKeyRoute)) { // Use scheme/host from X-Forwarded-* headers if present, else fallback to request values diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs index e711b648dc..c169d9d90c 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs @@ -25,6 +25,19 @@ public class MsSqlPatchApiTests : PatchApiTestBase $"AND [publisher_id] = 1234 " + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" }, + { + "PatchOne_Update_KeylessWithPKInBody_ExistingRow_Test", + $"SELECT [id], [title], [issue_number] FROM [foo].{ _integration_NonAutoGenPK_TableName } " + + $"WHERE [id] = 1 AND [title] = 'Updated Vogue' " + + $"AND [issue_number] = 1234 " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + }, + { + "PatchOne_Insert_KeylessWithPKInBody_NewRow_Test", + $"SELECT [id], [title], [issue_number] FROM [foo].{ _integration_NonAutoGenPK_TableName } " + + $"WHERE [id] = { STARTING_ID_FOR_TEST_INSERTS } AND [title] = 'Brand New Magazine' " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + }, { "PatchOne_Insert_NonAutoGenPK_Test", $"SELECT [id], [title], [issue_number] FROM [foo].{ _integration_NonAutoGenPK_TableName } " + diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs index 65cf224e75..8751bf2183 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs @@ -24,6 +24,28 @@ public class MySqlPatchApiTests : PatchApiTestBase ) AS subq " }, + { + "PatchOne_Update_KeylessWithPKInBody_ExistingRow_Test", + @"SELECT JSON_OBJECT('id', id, 'title', title, 'issue_number', issue_number) AS data + FROM ( + SELECT id, title, issue_number + FROM " + _integration_NonAutoGenPK_TableName + @" + WHERE id = 1 + AND title = 'Updated Vogue' AND issue_number = 1234 + ) AS subq + " + }, + { + "PatchOne_Insert_KeylessWithPKInBody_NewRow_Test", + @"SELECT JSON_OBJECT('id', id, 'title', title, 'issue_number', issue_number) AS data + FROM ( + SELECT id, title, issue_number + FROM " + _integration_NonAutoGenPK_TableName + @" + WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @" + AND title = 'Brand New Magazine' + ) AS subq + " + }, { "PatchOne_Insert_NonAutoGenPK_Test", @"SELECT JSON_OBJECT('id', id, 'title', title, 'issue_number', issue_number ) AS data diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs index ba5971c02c..90e132a652 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/PatchApiTestBase.cs @@ -373,6 +373,65 @@ await SetupAndRunRestApiTest( ); } + /// + /// Tests the PatchOne functionality with a REST PATCH request + /// without a primary key route, where the request body contains + /// all PK columns that match an existing row. + /// The engine should detect the PK in the body, route through the + /// upsert path, find the existing row, and perform an UPDATE (200 OK). + /// This is a regression test: previously, a keyless upsert with body PKs + /// always executed an INSERT, which would fail on a PK violation. + /// + [TestMethod] + public virtual async Task PatchOne_Update_KeylessWithPKInBody_ExistingRow_Test() + { + // id=1 exists in the magazines table with title='Vogue'. + // Sending a PATCH with the PK in the body should UPDATE the existing row. + string requestBody = @" + { + ""id"": 1, + ""title"": ""Updated Vogue"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _integration_NonAutoGenPK_EntityName, + sqlQuery: GetQuery(nameof(PatchOne_Update_KeylessWithPKInBody_ExistingRow_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + } + + /// + /// Tests the PatchOne functionality with a REST PATCH request + /// without a primary key route, where the request body contains + /// all PK columns that do NOT match any existing row. + /// The engine should detect the PK in the body, route through the + /// upsert path, find no existing row, and perform an INSERT (201 Created). + /// + [TestMethod] + public virtual async Task PatchOne_Insert_KeylessWithPKInBody_NewRow_Test() + { + string requestBody = @" + { + ""id"": " + STARTING_ID_FOR_TEST_INSERTS + @", + ""title"": ""Brand New Magazine"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _integration_NonAutoGenPK_EntityName, + sqlQuery: GetQuery(nameof(PatchOne_Insert_KeylessWithPKInBody_NewRow_Test)), + operationType: EntityActionOperation.UpsertIncremental, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + /// /// Tests successful execution of PATCH update requests on views /// when requests try to modify fields belonging to one base table diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/PostgreSqlPatchApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/PostgreSqlPatchApiTests.cs index 0a808bae58..b80658df83 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/PostgreSqlPatchApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/PostgreSqlPatchApiTests.cs @@ -25,6 +25,30 @@ SELECT to_jsonb(subq) AS data ) AS subq " }, + { + "PatchOne_Update_KeylessWithPKInBody_ExistingRow_Test", + @" + SELECT to_jsonb(subq) AS data + FROM ( + SELECT id, title, issue_number + FROM " + "foo." + _integration_NonAutoGenPK_TableName + @" + WHERE id = 1 + AND title = 'Updated Vogue' AND issue_number = 1234 + ) AS subq + " + }, + { + "PatchOne_Insert_KeylessWithPKInBody_NewRow_Test", + @" + SELECT to_jsonb(subq) AS data + FROM ( + SELECT id, title, issue_number + FROM " + "foo." + _integration_NonAutoGenPK_TableName + @" + WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @" + AND title = 'Brand New Magazine' + ) AS subq + " + }, { "PatchOne_Insert_Mapping_Test", @" diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs index 7ae15bb510..e9ea04a971 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs @@ -25,6 +25,20 @@ public class MsSqlPutApiTests : PutApiTestBase $"AND [publisher_id] = 1234 " + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" }, + { + "PutOne_Update_KeylessWithPKInBody_ExistingRow_Test", + $"SELECT [id], [title], [issue_number] FROM [foo].{ _integration_NonAutoGenPK_TableName } " + + $"WHERE [id] = 1 AND [title] = 'Updated Vogue' " + + $"AND [issue_number] = 9999 " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + }, + { + "PutOne_Insert_KeylessWithPKInBody_NewRow_Test", + $"SELECT [id], [title], [issue_number] FROM [foo].{ _integration_NonAutoGenPK_TableName } " + + $"WHERE [id] = { STARTING_ID_FOR_TEST_INSERTS } AND [title] = 'Brand New Magazine' " + + $"AND [issue_number] = 42 " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + }, { "PutOne_Update_Test", $"SELECT [id], [title], [publisher_id] FROM { _integrationTableName } " + diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs index 89024053b4..9ba465f240 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs @@ -25,6 +25,30 @@ SELECT JSON_OBJECT('id', id, 'title', title, 'publisher_id', publisher_id) AS da ) AS subq " }, + { + "PutOne_Update_KeylessWithPKInBody_ExistingRow_Test", + @" + SELECT JSON_OBJECT('id', id, 'title', title, 'issue_number', issue_number) AS data + FROM ( + SELECT id, title, issue_number + FROM " + _integration_NonAutoGenPK_TableName + @" + WHERE id = 1 + AND title = 'Updated Vogue' AND issue_number = 9999 + ) AS subq + " + }, + { + "PutOne_Insert_KeylessWithPKInBody_NewRow_Test", + @" + SELECT JSON_OBJECT('id', id, 'title', title, 'issue_number', issue_number) AS data + FROM ( + SELECT id, title, issue_number + FROM " + _integration_NonAutoGenPK_TableName + @" + WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @" + AND title = 'Brand New Magazine' AND issue_number = 42 + ) AS subq + " + }, { "PutOne_Update_Test", @" diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/PostgreSqlPutApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/PostgreSqlPutApiTests.cs index e9f8bcaac1..1e2b028665 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/PostgreSqlPutApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/PostgreSqlPutApiTests.cs @@ -26,6 +26,30 @@ SELECT to_jsonb(subq) AS data ) AS subq " }, + { + "PutOne_Update_KeylessWithPKInBody_ExistingRow_Test", + @" + SELECT to_jsonb(subq) AS data + FROM ( + SELECT id, title, issue_number + FROM " + "foo." + _integration_NonAutoGenPK_TableName + @" + WHERE id = 1 + AND title = 'Updated Vogue' AND issue_number = 9999 + ) AS subq + " + }, + { + "PutOne_Insert_KeylessWithPKInBody_NewRow_Test", + @" + SELECT to_jsonb(subq) AS data + FROM ( + SELECT id, title, issue_number + FROM " + "foo." + _integration_NonAutoGenPK_TableName + @" + WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @" + AND title = 'Brand New Magazine' AND issue_number = 42 + ) AS subq + " + }, { "PutOne_Update_Test", @" diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs index 503a8388a4..5ee3654897 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/PutApiTestBase.cs @@ -255,6 +255,67 @@ await SetupAndRunRestApiTest( ); } + /// + /// Tests the PutOne functionality with a REST PUT request + /// without a primary key route, where the request body contains + /// all PK columns that match an existing row. + /// The engine should detect the PK in the body, route through the + /// upsert path, find the existing row, and perform an UPDATE (200 OK). + /// This is a regression test: previously, a keyless upsert with body PKs + /// always executed an INSERT, which would fail on a PK violation. + /// + [TestMethod] + public virtual async Task PutOne_Update_KeylessWithPKInBody_ExistingRow_Test() + { + // id=1 exists in the magazines table with title='Vogue'. + // Sending a PUT with the PK in the body should UPDATE the existing row. + string requestBody = @" + { + ""id"": 1, + ""title"": ""Updated Vogue"", + ""issue_number"": 9999 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _integration_NonAutoGenPK_EntityName, + sqlQuery: GetQuery(nameof(PutOne_Update_KeylessWithPKInBody_ExistingRow_Test)), + operationType: EntityActionOperation.Upsert, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + } + + /// + /// Tests the PutOne functionality with a REST PUT request + /// without a primary key route, where the request body contains + /// all PK columns that do NOT match any existing row. + /// The engine should detect the PK in the body, route through the + /// upsert path, find no existing row, and perform an INSERT (201 Created). + /// + [TestMethod] + public virtual async Task PutOne_Insert_KeylessWithPKInBody_NewRow_Test() + { + string requestBody = @" + { + ""id"": " + STARTING_ID_FOR_TEST_INSERTS + @", + ""title"": ""Brand New Magazine"", + ""issue_number"": 42 + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: null, + entityNameOrPath: _integration_NonAutoGenPK_EntityName, + sqlQuery: GetQuery(nameof(PutOne_Insert_KeylessWithPKInBody_NewRow_Test)), + operationType: EntityActionOperation.Upsert, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.Created, + expectedLocationHeader: string.Empty + ); + } + /// /// Tests the PutOne functionality with a REST PUT request using /// headers that include as a key "If-Match" with an item that does exist, @@ -1092,7 +1153,7 @@ await SetupAndRunRestApiTest( [TestMethod] public virtual async Task PutWithNoPrimaryKeyRouteAndPartialCompositeKeyInBodyTest() { - // Body only contains categoryid but not pieceid — both are required + // Body only contains categoryid but not pieceid - both are required // since neither is auto-generated. string requestBody = @" {