Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
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!);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it always asserted that rowsWithGivenPK will always return an object that is not null? I think it might be a good idea to add all of this under a try catch statement to make ensure we don't throw an unexpected exception.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, with the way the SQL is written I am pretty sure that rowsWithGivenPK will always return an object that is not null. In PostgresQueryBuilder.cs, the query is as follows:

SELECT COUNT(*) AS cnt_rows_to_update
FROM <table>
WHERE <pk predicates>

COUNT(*) without GROUP BY always returns exactly one row, and the count value is an integer count: 0, 1, or more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this if statement redundant? If isFallbackToUpdate true then it means args is is not null and its count is > 2.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right. I will make this change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the check and I am getting the below compiler/build error:

In externally visible method 'Task<DbResultSet> PostgreSqlQueryExecutor.GetMultipleResultSetsIfAnyAsync(DbDataReader dbDataReader, List<string>? args = null)', validate parameter 'args' is non-null before using it. If appropriate, throw an 'ArgumentNullException' when the argument is 'null'.[CA1062](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1062)

For some reason, even though we are guaranteed that both args is not null and count > 2 because isFallbackToUpdate is true, the compiler is not able to make this logical leap. I played around with it and, of the two predicates, only args is not null is required for the code to compile successfully.

I am going to leave the code as is to make the compiler happy, but let me know if you still have any concerns with this.

{
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}";
}
}

Expand Down
Loading
Loading