Skip to content
Merged
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
4 changes: 3 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 Expand Up @@ -175,3 +176,4 @@ add dbo_DimAccount --config "dab-config.PostgreSql.json" --source "dimaccount" -
update dbo_DimAccount --config "dab-config.PostgreSql.json" --map "parentaccountkey:ParentAccountKey,accountkey:AccountKey"
update dbo_DimAccount --config "dab-config.PostgreSql.json" --relationship parent_account --target.entity dbo_DimAccount --cardinality one --relationship.fields "parentaccountkey:accountkey"
update dbo_DimAccount --config "dab-config.PostgreSql.json" --relationship child_accounts --target.entity dbo_DimAccount --cardinality many --relationship.fields "accountkey:parentaccountkey"
add DateOnlyTable --config "dab-config.PostgreSql.json" --source "date_only_table" --permissions "anonymous:*" --rest true --graphql true --source.key-fields "event_date"
5 changes: 5 additions & 0 deletions schemas/dab.draft.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@
"description": "Maximum allowed depth of a GraphQL query. Only positive integers are enforced. Default: null (no limit). Use -1 to explicitly remove a previously set limit.",
"default": null
},
"enable-aggregation": {
"$ref": "#/$defs/boolean-or-string",
"description": "Allow enabling/disabling aggregation (groupBy, sum, avg, min, max, count) for supported database types (MSSQL, DWSQL).",
"default": true
Comment thread
naxing123 marked this conversation as resolved.
},
"multiple-mutations": {
"type": "object",
"description": "Configuration properties for multiple mutation operations",
Expand Down
8 changes: 5 additions & 3 deletions src/Config/ObjectModel/RuntimeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,14 @@ Runtime.GraphQL is null ||
public string DefaultDataSourceName { get; set; }

/// <summary>
/// Retrieves the value of runtime.graphql.aggregation.enabled property if present, default is true.
/// Retrieves the value of runtime.graphql.enable-aggregation property if present, default is true.
/// Returns true when runtime section is absent, when graphql section is absent,
/// or when enable-aggregation is explicitly set to true.
/// </summary>
[JsonIgnore]
public bool EnableAggregation =>
Runtime is not null &&
Runtime.GraphQL is not null &&
Runtime is null ||
Runtime.GraphQL is null ||
Runtime.GraphQL.EnableAggregation;

[JsonIgnore]
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 @@ -46,7 +46,8 @@ public class RuntimeConfigValidator : IConfigValidator
private static readonly HashSet<DatabaseType> _databaseTypesSupportingCreatePolicy =
[
DatabaseType.MSSQL,
DatabaseType.DWSQL
DatabaseType.DWSQL,
DatabaseType.PostgreSQL
];

// Error messages for user-delegated authentication configuration.
Expand Down
92 changes: 89 additions & 3 deletions src/Core/Resolvers/BaseSqlQueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,92 @@ protected virtual string Build(AggregationColumn column, bool useAlias = false)
return $"{column.Type.ToString()}({columnName}) {appendAlias}";
}

/// <summary>
/// Build the Group By Clause needed to append to the main query
/// </summary>
/// <param name="structure">Sql query structure to build query on</param>
/// <returns>SQL query with group-by clause</returns>
protected virtual string BuildGroupBy(SqlQueryStructure structure)
{
// Add GROUP BY clause if there are any group by columns
if (structure.GroupByMetadata.Fields.Any())
{
return $" GROUP BY {string.Join(", ", structure.GroupByMetadata.Fields.Values.Select(c => Build(c)))}";
}

return string.Empty;
}

/// <summary>
/// Build the Having clause needed to append to the main query
/// </summary>
/// <param name="structure">Sql query structure to build query on</param>
/// <returns>SQL query with having clause</returns>
protected virtual string BuildHaving(SqlQueryStructure structure)
{
if (structure.GroupByMetadata.Aggregations.Count > 0)
{
List<Predicate>? havingPredicates = structure.GroupByMetadata.Aggregations
.SelectMany(aggregation => aggregation.HavingPredicates ?? new List<Predicate>())
.ToList();

if (havingPredicates.Any())
{
return $" HAVING {Build(havingPredicates)}";
}
}

return string.Empty;
}

/// <summary>
/// Build the aggregation columns needed to append to the main query
/// </summary>
/// <param name="structure">Sql query structure to build query on</param>
/// <returns>SQL query with aggregation columns</returns>
protected virtual string BuildAggregationColumns(SqlQueryStructure structure)
{
string aggregations = string.Empty;
if (structure.GroupByMetadata.Aggregations.Count > 0)
{
if (structure.Columns.Any())
{
aggregations = $",{BuildAggregationColumns(structure.GroupByMetadata)}";
}
else
{
aggregations = $"{BuildAggregationColumns(structure.GroupByMetadata)}";
}
}

return aggregations;
}

/// <summary>
/// Build the aggregation columns needed to append to the main query
/// </summary>
/// <param name="metadata">GroupByMetadata</param>
/// <returns>SQL query with aggregation columns</returns>
protected virtual string BuildAggregationColumns(GroupByMetadata metadata)
{
return string.Join(", ", metadata.Aggregations.Select(aggregation => Build(aggregation.Column, useAlias: true)));
}

/// <summary>
/// Build the Order By clause needed to append to the main query
/// </summary>
/// <param name="structure">Sql query structure to build query on</param>
/// <returns>SQL query with order-by clause</returns>
protected virtual string BuildOrderBy(SqlQueryStructure structure)
{
if (structure.OrderByColumns.Any())
{
return $" ORDER BY {Build(structure.OrderByColumns)}";
}

return string.Empty;
}

/// <summary>
/// Build orderby column as
/// {SourceAlias}.{ColumnName} {direction}
Expand Down Expand Up @@ -447,7 +533,7 @@ public virtual string BuildForeignKeyInfoQuery(int numberOfParameters)
// constraint columns - one inner join for the columns from the 'Referencing table'
// and the other join for the columns from the 'Referenced Table'.
string foreignKeyQuery = $@"
SELECT
SELECT
ReferentialConstraints.CONSTRAINT_NAME {QuoteIdentifier(nameof(ForeignKeyDefinition))},
ReferencingColumnUsage.TABLE_SCHEMA
{QuoteIdentifier($"Referencing{nameof(DatabaseObject.SchemaName)}")},
Expand All @@ -457,9 +543,9 @@ public virtual string BuildForeignKeyInfoQuery(int numberOfParameters)
{QuoteIdentifier($"Referenced{nameof(DatabaseObject.SchemaName)}")},
ReferencedColumnUsage.TABLE_NAME {QuoteIdentifier($"Referenced{nameof(SourceDefinition)}")},
ReferencedColumnUsage.COLUMN_NAME {QuoteIdentifier(nameof(ForeignKeyDefinition.ReferencedColumns))}
FROM
FROM
INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS ReferentialConstraints
INNER JOIN
INNER JOIN
INFORMATION_SCHEMA.KEY_COLUMN_USAGE ReferencingColumnUsage
ON ReferentialConstraints.CONSTRAINT_CATALOG = ReferencingColumnUsage.CONSTRAINT_CATALOG
AND ReferentialConstraints.CONSTRAINT_SCHEMA = ReferencingColumnUsage.CONSTRAINT_SCHEMA
Expand Down
86 changes: 0 additions & 86 deletions src/Core/Resolvers/BaseTSqlQueryBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Models;

namespace Azure.DataApiBuilder.Core.Resolvers
{
Expand Down Expand Up @@ -43,90 +42,5 @@ protected virtual string BuildPredicates(SqlQueryStructure structure)
Build(structure.PaginationMetadata.PaginationPredicate));
}

/// <summary>
/// Build the Group By Clause needed to append to the main query
/// </summary>
/// <param name="structure">Sql query structure to build query on</param>
/// <returns>SQL query with group-by clause</returns>
protected virtual string BuildGroupBy(SqlQueryStructure structure)
{
// Add GROUP BY clause if there are any group by columns
if (structure.GroupByMetadata.Fields.Any())
{
return $" GROUP BY {string.Join(", ", structure.GroupByMetadata.Fields.Values.Select(c => Build(c)))}";
}

return string.Empty;
}

/// <summary>
/// Build the Having clause needed to append to the main query
/// </summary>
/// <param name="structure">Sql query structure to build query on</param>
/// <returns>SQL query with having clause</returns>
protected virtual string BuildHaving(SqlQueryStructure structure)
{
if (structure.GroupByMetadata.Aggregations.Count > 0)
{
List<Predicate>? havingPredicates = structure.GroupByMetadata.Aggregations
.SelectMany(aggregation => aggregation.HavingPredicates ?? new List<Predicate>())
.ToList();

if (havingPredicates.Any())
{
return $" HAVING {Build(havingPredicates)}";
}
}

return string.Empty;
}

/// <summary>
/// Build the Order By clause needed to append to the main query
/// </summary>
/// <param name="structure">Sql query structure to build query on</param>
/// <returns>SQL query with order-by clause</returns>
protected virtual string BuildOrderBy(SqlQueryStructure structure)
{
if (structure.OrderByColumns.Any())
{
return $" ORDER BY {Build(structure.OrderByColumns)}";
}

return string.Empty;
}

/// <summary>
/// Build the aggregation columns needed to append to the main query
/// </summary>
/// <param name="structure">Sql query structure to build query on</param>
/// <returns>SQL query with aggregation columns</returns>
protected virtual string BuildAggregationColumns(SqlQueryStructure structure)
{
string aggregations = string.Empty;
if (structure.GroupByMetadata.Aggregations.Count > 0)
{
if (structure.Columns.Any())
{
aggregations = $",{BuildAggregationColumns(structure.GroupByMetadata)}";
}
else
{
aggregations = $"{BuildAggregationColumns(structure.GroupByMetadata)}";
}
}

return aggregations;
}

/// <summary>
/// Build the aggregation columns needed to append to the main query
/// </summary>
/// <param name="metadata">GroupByMetadata</param>
/// <returns>SQL query with aggregation columns</returns>
protected virtual string BuildAggregationColumns(GroupByMetadata metadata)
{
return string.Join(", ", metadata.Aggregations.Select(aggregation => Build(aggregation.Column, useAlias: true)));
}
}
}
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;
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!);
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)
{
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
Loading