Skip to content
Open
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
146 changes: 69 additions & 77 deletions src/Core/Resolvers/SqlMutationEngine.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string> 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<string> 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;

Expand Down Expand Up @@ -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
Expand All @@ -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)
{
Expand Down Expand Up @@ -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);
}
}

Expand Down
16 changes: 8 additions & 8 deletions src/Core/Resolvers/SqlResponseHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading