Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
626d6f0
Build upsert queries appropriately with insert/create policies
ArjunNarendra Jul 5, 2026
3c9b908
Change logic to determine how to process result sets so that appropri…
ArjunNarendra Jul 5, 2026
73d536c
Provide another parameter when executing the query to help with resul…
ArjunNarendra Jul 5, 2026
b5f3734
Add (unignore) relevant tests
ArjunNarendra Jul 5, 2026
000ec1b
Add create policy config and permissions for PostgreSQL tests
ArjunNarendra Jul 5, 2026
4e06b23
Modify policy updates when generating the config file to reflect crea…
ArjunNarendra Jul 5, 2026
2333d8e
Remove unnecessary using statements
ArjunNarendra Jul 5, 2026
e744cae
Update PostgreSQL verified snapshot policies
ArjunNarendra Jul 5, 2026
849b32f
Only pass in isFallbackToUpdate parameter for PostgreSQL
ArjunNarendra Jul 5, 2026
fa64d55
Update PostgreSQL insert query builder to account for create policy
ArjunNarendra Jul 5, 2026
ddde125
Update unit test to reflect support for create policy for PostgreSQL
ArjunNarendra Jul 5, 2026
b96b0c6
Merge branch 'main' into user/an/db-policy-for-put-patch-postgresql
ArjunNarendra Jul 5, 2026
af1f7b2
Remove dependency on IS_UPDATE_RESULT_SET flag when determining wheth…
ArjunNarendra Jul 14, 2026
a2a1de1
Add back the dependency on IS_UPDATE_RESULT_SET flag when determining…
ArjunNarendra Jul 14, 2026
fa30fcd
Merge branch 'main' into user/an/db-policy-for-put-patch-postgresql
ArjunNarendra Jul 14, 2026
56723d6
Merge branch 'main' into user/an/db-policy-for-put-patch-postgresql
ArjunNarendra Jul 14, 2026
02b8411
Remove isFallbackToUpdate flag from argument list and pass this flag …
ArjunNarendra Jul 20, 2026
3fddd8f
Simplify if-else structure when determining what exception to throw f…
ArjunNarendra Jul 21, 2026
74854a8
Remove seperate argument list for PostgreSQL when performing an upsert
ArjunNarendra Jul 22, 2026
23e4375
Merge branch 'main' into user/an/db-policy-for-put-patch-postgresql
RubenCerna2079 Jul 23, 2026
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
3 changes: 2 additions & 1 deletion config-generators/postgresql-commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ update Publisher --config "dab-config.PostgreSql.json" --permissions "database_p
update Publisher --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create"
update Publisher --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:update" --policy-database "@item.id ne 1234"
update Stock --config "dab-config.PostgreSql.json" --permissions "authenticated:create,read,update,delete" --rest commodities --graphql true --relationship stocks_price --target.entity stocks_price --cardinality one
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create,read"
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:update" --policy-database "@item.pieceid ne 1"
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create" --policy-database "@item.pieceid ne 6 and @item.piecesAvailable gt 0"
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:read"
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_noread:create,update,delete"
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_excluded_fields:create,update,delete"
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "categoryName"
Expand Down
3 changes: 2 additions & 1 deletion src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public class RuntimeConfigValidator : IConfigValidator
private static readonly HashSet<DatabaseType> _databaseTypesSupportingCreatePolicy =
[
DatabaseType.MSSQL,
DatabaseType.DWSQL
DatabaseType.DWSQL,
DatabaseType.PostgreSQL
];
Comment thread
ArjunNarendra marked this conversation as resolved.

// Error messages for user-delegated authentication configuration.
Expand Down
83 changes: 83 additions & 0 deletions src/Core/Resolvers/PostgreSqlExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
// Licensed under the MIT License.

using System.Data.Common;
using System.Net;
Comment thread
unattendedfaxmachine marked this conversation as resolved.
using Azure.Core;
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Models;
using Azure.DataApiBuilder.Service.Exceptions;
using Azure.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -146,6 +148,87 @@ private static bool ShouldManagedIdentityAccessBeAttempted(NpgsqlConnectionStrin
return string.IsNullOrEmpty(builder.Password);
}

/// <inheritdoc/>
public override async Task<DbResultSet> GetMultipleResultSetsIfAnyAsync(
DbDataReader dbDataReader, List<string>? args = null)
{
// RS1: COUNT of rows matching PK (no policy) — used to distinguish
// "row doesn't exist" from "row exists but policy blocked".
DbResultSet resultSetWithCountOfRowsWithGivenPk = await ExtractResultSetFromDbDataReaderAsync(dbDataReader);
DbResultSetRow? resultSetRowWithCountOfRowsWithGivenPk = resultSetWithCountOfRowsWithGivenPk.Rows.FirstOrDefault();
int numOfRecordsWithGivenPK;
bool isFallbackToUpdate;

if (resultSetRowWithCountOfRowsWithGivenPk is not null &&
resultSetRowWithCountOfRowsWithGivenPk.Columns.TryGetValue(PostgresQueryBuilder.COUNT_ROWS_WITH_GIVEN_PK, out object? rowsWithGivenPK) &&
resultSetRowWithCountOfRowsWithGivenPk.Columns.TryGetValue(PostgresQueryBuilder.IS_FALLBACK_TO_UPDATE, out object? fallbackToUpdate))
{
// PostgreSQL COUNT(*) returns Int64; convert to int.
numOfRecordsWithGivenPK = Convert.ToInt32(rowsWithGivenPK!);
Comment thread
RubenCerna2079 marked this conversation as resolved.
isFallbackToUpdate = Convert.ToBoolean(fallbackToUpdate!);
}
else
{
throw new DataApiBuilderException(
message: $"Neither insert nor update could be performed.",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}

// RS2: UPDATE result, or UPDATE+INSERT CTE result.
DbResultSet dbResultSet = await dbDataReader.NextResultAsync()
? await ExtractResultSetFromDbDataReaderAsync(dbDataReader)
: throw new DataApiBuilderException(
message: $"Neither insert nor update could be performed.",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);

if (numOfRecordsWithGivenPK == 1) // Row existed — we attempted an UPDATE.
{
if (dbResultSet.Rows.Count == 0)
{
// Row exists but UPDATE returned no rows — update policy blocked it.
throw new DataApiBuilderException(
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
}
}
else if (dbResultSet.Rows.Count == 0)
{
// If true, the row simply didn't exist — return 404 (same as MsSql's null-RS2 path).
// If false, the INSERT ran but create policy blocked it — return 403.

if (isFallbackToUpdate)
{
if (args is not null && args.Count > 1)
Comment thread
RubenCerna2079 marked this conversation as resolved.
{
string prettyPrintPk = args[0];
string entityName = args[1];

throw new DataApiBuilderException(
message: $"Cannot perform INSERT and could not find {entityName} " +
$"with primary key {prettyPrintPk} to perform UPDATE on.",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.ItemNotFound);
}

throw new DataApiBuilderException(
message: $"Neither insert nor update could be performed.",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}

// Row didn't exist but INSERT returned no rows — create policy blocked it.
throw new DataApiBuilderException(
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
}

return dbResultSet;
}

/// <summary>
/// Determines if the saved default azure credential's access token is valid and not expired.
/// </summary>
Expand Down
58 changes: 47 additions & 11 deletions src/Core/Resolvers/PostgresQueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public class PostgresQueryBuilder : BaseSqlQueryBuilder, IQueryBuilder
private const string UPSERT_IDENTIFIER_COLUMN_NAME = "___upsert_op___";
private const string INSERT_UPSERT = "inserted";
private const string UPDATE_UPSERT = "updated";
public const string COUNT_ROWS_WITH_GIVEN_PK = "cnt_rows_to_update";
public const string IS_FALLBACK_TO_UPDATE = "is_fallback_to_update";

private static DbCommandBuilder _builder = new NpgsqlCommandBuilder();

Expand Down Expand Up @@ -67,11 +69,17 @@ public string Build(SqlQueryStructure structure)
/// <inheritdoc />
public string Build(SqlInsertStructure structure)
{
string insertQuery = $"INSERT INTO {QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)} ";
string tableName = $"{QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)}";
string dbPolicyPredicates = JoinPredicateStrings(structure.GetDbPolicyForOperation(EntityActionOperation.Create));
string insertQuery = $"INSERT INTO {tableName} ";

if (structure.InsertColumns.Any())
{
insertQuery += $"({Build(structure.InsertColumns)}) " +
$"VALUES ({string.Join(", ", (structure.Values))}) ";
string insertColumns = Build(structure.InsertColumns);
string insertValues = dbPolicyPredicates.Equals(BASE_PREDICATE)
? $"({insertColumns}) VALUES ({string.Join(", ", structure.Values)})"
: $"({insertColumns}) SELECT {insertColumns} FROM (SELECT {string.Join(", ", structure.InsertColumns.Zip(structure.Values, (col, val) => $"{val} AS {QuoteIdentifier(col)}"))}) AS T WHERE {dbPolicyPredicates}";
insertQuery += insertValues;
}
else
{
Expand Down Expand Up @@ -117,25 +125,53 @@ public string Build(SqlUpsertQueryStructure structure)
{
// https://stackoverflow.com/questions/42668720/check-if-postgres-query-inserted-or-updated-via-upsert
// relying on xmax to detect insert vs update breaks for views
string updatePredicates = JoinPredicateStrings(Build(structure.Predicates), structure.GetDbPolicyForOperation(EntityActionOperation.Update));
string updateQuery = $"UPDATE {QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)} " +
string tableName = $"{QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)}";
string pkPredicates = Build(structure.Predicates);
string isFallbackToUpdateSqlLiteral = structure.IsFallbackToUpdate ? "TRUE" : "FALSE";

// RS1: COUNT of rows matching PK (no policy) — used to distinguish
// "row doesn't exist" from "row exists but policy blocked" in the executor.
string countQuery = $"SELECT COUNT(*) AS {COUNT_ROWS_WITH_GIVEN_PK}, " +
$"{isFallbackToUpdateSqlLiteral} AS {IS_FALLBACK_TO_UPDATE} " +
$"FROM {tableName} WHERE {pkPredicates}";

string updatePredicates = JoinPredicateStrings(pkPredicates, structure.GetDbPolicyForOperation(EntityActionOperation.Update));
string updateQuery = $"UPDATE {tableName} " +
$"SET {Build(structure.UpdateOperations, ", ")} " +
$"WHERE {updatePredicates} " +
$"RETURNING {Build(structure.OutputColumns)}, '{UPDATE_UPSERT}' AS {UPSERT_IDENTIFIER_COLUMN_NAME}";

if (structure.IsFallbackToUpdate)
{
return updateQuery + ";";
// RS2: UPDATE only — no INSERT branch for autogen PK or missing required columns.
return $"{countQuery}; {updateQuery};";
}
else
{
return $"WITH update_cte AS ( {updateQuery} ), insert_cte AS ( " +
$"INSERT INTO {QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)} ({Build(structure.InsertColumns)}) " +
$"SELECT {string.Join(", ", (structure.Values))} " +
$"WHERE NOT EXISTS (SELECT 1 FROM update_cte) " +
// INSERT only runs when row doesn't exist (pkPredicates match nothing)
// AND the create policy (if any) is satisfied.
string insertPredicates = JoinPredicateStrings(
$"NOT EXISTS (SELECT 1 FROM {tableName} WHERE {pkPredicates})",
structure.GetDbPolicyForOperation(EntityActionOperation.Create));

// Alias each value with its column name so that policy predicates referencing
// column names (e.g. "pieceid" != @param) can be resolved in the WHERE clause.
// Using SELECT ... FROM (SELECT @p1 AS col1, ...) AS T avoids both the VALUES(NULL)
// type inference issue and the unnamed-column resolution issue.
string namedValues = string.Join(", ",
structure.InsertColumns.Zip(structure.Values,
(col, val) => $"{val} AS {QuoteIdentifier(col)}"));

// RS2: CTE that attempts UPDATE first; falls through to INSERT only when row is absent.
string cteQuery = $"WITH update_cte AS ( {updateQuery} ), insert_cte AS ( " +
$"INSERT INTO {tableName} ({Build(structure.InsertColumns)}) " +
$"SELECT {Build(structure.InsertColumns)} FROM (SELECT {namedValues}) AS T " +
$"WHERE {insertPredicates} " +
$"RETURNING {Build(structure.OutputColumns)}, '{INSERT_UPSERT}' AS {UPSERT_IDENTIFIER_COLUMN_NAME} ) " +
$"SELECT {BuildListOfLabels(structure.OutputColumns)}, {UPSERT_IDENTIFIER_COLUMN_NAME} FROM update_cte UNION " +
$"SELECT {BuildListOfLabels(structure.OutputColumns)}, {UPSERT_IDENTIFIER_COLUMN_NAME} FROM update_cte UNION ALL " +
$"SELECT {BuildListOfLabels(structure.OutputColumns)}, {UPSERT_IDENTIFIER_COLUMN_NAME} FROM insert_cte;";

return $"{countQuery}; {cteQuery}";
Comment thread
RubenCerna2079 marked this conversation as resolved.
}
}

Expand Down
69 changes: 27 additions & 42 deletions src/Core/Resolvers/SqlMutationEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -715,52 +715,37 @@ await PerformMutationOperation(
parameters: parameters,
sqlMetadataProvider: sqlMetadataProvider);

if (mutationResultRow is null || mutationResultRow.Columns.Count == 0)
if (mutationResultRow is null)
{
HttpStatusCode statusCode = effectiveOperationType is EntityActionOperation.Insert
? HttpStatusCode.InternalServerError
: HttpStatusCode.NotFound;

// Ideally this case should not happen, however may occur due to unexpected reasons,
// like the DbDataReader being null. We throw an exception
// which will be returned as an UnexpectedError.
throw new DataApiBuilderException(
message: "An unexpected error occurred while trying to execute the query.",
statusCode: statusCode,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}

if (mutationResultRow.Columns.Count == 0)
{
if (effectiveOperationType is EntityActionOperation.Insert)
{
if (mutationResultRow is null)
{
// Ideally this case should not happen, however may occur due to unexpected reasons,
// like the DbDataReader being null. We throw an exception
// which will be returned as an UnexpectedError.
throw new DataApiBuilderException(
message: "An unexpected error occurred while trying to execute the query.",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}

if (mutationResultRow.Columns.Count == 0)
{
throw new DataApiBuilderException(
message: "Could not insert row with given values.",
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure
);
}
throw new DataApiBuilderException(
message: "Could not insert row with given values.",
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure
);
}
else
{
if (mutationResultRow is null)
{
// Ideally this case should not happen, however may occur due to unexpected reasons,
// like the DbDataReader being null. We throw an exception
// which will be returned as an UnexpectedError
throw new DataApiBuilderException(message: "An unexpected error occurred while trying to execute the query.",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}

if (mutationResultRow.Columns.Count == 0)
{
// This code block is reached when Update or UpdateIncremental operation does not successfully find the record to
// update. An exception is thrown which will be returned as a 404 NotFound response.
throw new DataApiBuilderException(message: "No Update could be performed, record not found",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
}

}
// This code block is reached when Update or UpdateIncremental operation does not successfully find the record to
// update. An exception is thrown which will be returned as a 404 NotFound response.
throw new DataApiBuilderException(message: "No Update could be performed, record not found",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
}

// The role with which the REST request is executed can have database policies defined for the read action.
Expand Down Expand Up @@ -2264,7 +2249,7 @@ private void AuthorizeEntityAndFieldsForMutation(
/// }
/// }
/// </code>
///
///
/// Example 2 - Multiple items creation:
/// <code>
/// mutation {
Expand Down
Loading
Loading