Skip to content

Commit 53c70a7

Browse files
naxing123CopilotAniruddh25souvikghosh04ArjunNarendra
authored
Port PostgreSQL GraphQL groupby/aggregation + related features to release/2.0 (#3767)
Ports the PostgreSQL GraphQL groupby/aggregation and related feature commits from `main` (released in v2.1.0-rc) to `release/2.0` via cherry-pick. ## Commits ported (chronological) | PR | Title | |----|-------| | #3450 | Fix GraphQL aggregation features disabled when runtime.graphql config section is absent | | #3694 | Database policy support for PUT/PATCH operations - PostgreSQL | | #3728 | Add support for DateTime filters in PostgreSQL | | #3750 | Fix column mapping in GroupBy and aggregation queries | | #3741 | Add groupby/aggregation support for PostgreSQL in GraphQL | | #3753 | Enhance test coverage for GraphQL queries by adding orderBy clause | ## Notes - All six cherry-picks applied cleanly. - One manual adjustment in `SqlMutationEngine.cs` (part of #3694 port): the original referenced `effectiveOperationType` (a local introduced by the unrelated refactor #3287, which is not in `release/2.0`). Substituted `context.OperationType`, which is functionally equivalent in that non-upsert branch and matches the `release/2.0` convention. Folded into the #3694 commit. - Solution builds clean (0 warnings, 0 errors). - Integration tests (PostgreSql/MsSql) require live databases and were not run locally. --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com> Co-authored-by: Aniruddh Munde <anmunde@microsoft.com> Co-authored-by: Souvik Ghosh <souvikofficial04@gmail.com> Co-authored-by: Arjun Narendra <arjunnarendra1@gmail.com> Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com> Co-authored-by: Arpit Gupta <106474712+ar-guptaar@users.noreply.github.com> Co-authored-by: ARPIT GUPTA <guptaar@microsoft.com> Co-authored-by: Anusha Kolan <anushakolan10@gmail.com>
1 parent 17ae3aa commit 53c70a7

33 files changed

Lines changed: 1619 additions & 495 deletions

config-generators/postgresql-commands.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ update Publisher --config "dab-config.PostgreSql.json" --permissions "database_p
5858
update Publisher --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create"
5959
update Publisher --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:update" --policy-database "@item.id ne 1234"
6060
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
61-
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create,read"
6261
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:update" --policy-database "@item.pieceid ne 1"
62+
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:create" --policy-database "@item.pieceid ne 6 and @item.piecesAvailable gt 0"
63+
update Stock --config "dab-config.PostgreSql.json" --permissions "database_policy_tester:read"
6364
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_noread:create,update,delete"
6465
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_excluded_fields:create,update,delete"
6566
update Stock --config "dab-config.PostgreSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "categoryName"
@@ -175,3 +176,4 @@ add dbo_DimAccount --config "dab-config.PostgreSql.json" --source "dimaccount" -
175176
update dbo_DimAccount --config "dab-config.PostgreSql.json" --map "parentaccountkey:ParentAccountKey,accountkey:AccountKey"
176177
update dbo_DimAccount --config "dab-config.PostgreSql.json" --relationship parent_account --target.entity dbo_DimAccount --cardinality one --relationship.fields "parentaccountkey:accountkey"
177178
update dbo_DimAccount --config "dab-config.PostgreSql.json" --relationship child_accounts --target.entity dbo_DimAccount --cardinality many --relationship.fields "accountkey:parentaccountkey"
179+
add DateOnlyTable --config "dab-config.PostgreSql.json" --source "date_only_table" --permissions "anonymous:*" --rest true --graphql true --source.key-fields "event_date"

schemas/dab.draft.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@
246246
"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.",
247247
"default": null
248248
},
249+
"enable-aggregation": {
250+
"$ref": "#/$defs/boolean-or-string",
251+
"description": "Allow enabling/disabling aggregation (groupBy, sum, avg, min, max, count) for supported database types (MSSQL, DWSQL).",
252+
"default": true
253+
},
249254
"multiple-mutations": {
250255
"type": "object",
251256
"description": "Configuration properties for multiple mutation operations",

src/Config/ObjectModel/RuntimeConfig.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,14 @@ Runtime.GraphQL is null ||
222222
public string DefaultDataSourceName { get; set; }
223223

224224
/// <summary>
225-
/// Retrieves the value of runtime.graphql.aggregation.enabled property if present, default is true.
225+
/// Retrieves the value of runtime.graphql.enable-aggregation property if present, default is true.
226+
/// Returns true when runtime section is absent, when graphql section is absent,
227+
/// or when enable-aggregation is explicitly set to true.
226228
/// </summary>
227229
[JsonIgnore]
228230
public bool EnableAggregation =>
229-
Runtime is not null &&
230-
Runtime.GraphQL is not null &&
231+
Runtime is null ||
232+
Runtime.GraphQL is null ||
231233
Runtime.GraphQL.EnableAggregation;
232234

233235
[JsonIgnore]

src/Core/Configurations/RuntimeConfigValidator.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ public class RuntimeConfigValidator : IConfigValidator
4646
private static readonly HashSet<DatabaseType> _databaseTypesSupportingCreatePolicy =
4747
[
4848
DatabaseType.MSSQL,
49-
DatabaseType.DWSQL
49+
DatabaseType.DWSQL,
50+
DatabaseType.PostgreSQL
5051
];
5152

5253
// Error messages for user-delegated authentication configuration.

src/Core/Resolvers/BaseSqlQueryBuilder.cs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,92 @@ protected virtual string Build(AggregationColumn column, bool useAlias = false)
193193
return $"{column.Type.ToString()}({columnName}) {appendAlias}";
194194
}
195195

196+
/// <summary>
197+
/// Build the Group By Clause needed to append to the main query
198+
/// </summary>
199+
/// <param name="structure">Sql query structure to build query on</param>
200+
/// <returns>SQL query with group-by clause</returns>
201+
protected virtual string BuildGroupBy(SqlQueryStructure structure)
202+
{
203+
// Add GROUP BY clause if there are any group by columns
204+
if (structure.GroupByMetadata.Fields.Any())
205+
{
206+
return $" GROUP BY {string.Join(", ", structure.GroupByMetadata.Fields.Values.Select(c => Build(c)))}";
207+
}
208+
209+
return string.Empty;
210+
}
211+
212+
/// <summary>
213+
/// Build the Having clause needed to append to the main query
214+
/// </summary>
215+
/// <param name="structure">Sql query structure to build query on</param>
216+
/// <returns>SQL query with having clause</returns>
217+
protected virtual string BuildHaving(SqlQueryStructure structure)
218+
{
219+
if (structure.GroupByMetadata.Aggregations.Count > 0)
220+
{
221+
List<Predicate>? havingPredicates = structure.GroupByMetadata.Aggregations
222+
.SelectMany(aggregation => aggregation.HavingPredicates ?? new List<Predicate>())
223+
.ToList();
224+
225+
if (havingPredicates.Any())
226+
{
227+
return $" HAVING {Build(havingPredicates)}";
228+
}
229+
}
230+
231+
return string.Empty;
232+
}
233+
234+
/// <summary>
235+
/// Build the aggregation columns needed to append to the main query
236+
/// </summary>
237+
/// <param name="structure">Sql query structure to build query on</param>
238+
/// <returns>SQL query with aggregation columns</returns>
239+
protected virtual string BuildAggregationColumns(SqlQueryStructure structure)
240+
{
241+
string aggregations = string.Empty;
242+
if (structure.GroupByMetadata.Aggregations.Count > 0)
243+
{
244+
if (structure.Columns.Any())
245+
{
246+
aggregations = $",{BuildAggregationColumns(structure.GroupByMetadata)}";
247+
}
248+
else
249+
{
250+
aggregations = $"{BuildAggregationColumns(structure.GroupByMetadata)}";
251+
}
252+
}
253+
254+
return aggregations;
255+
}
256+
257+
/// <summary>
258+
/// Build the aggregation columns needed to append to the main query
259+
/// </summary>
260+
/// <param name="metadata">GroupByMetadata</param>
261+
/// <returns>SQL query with aggregation columns</returns>
262+
protected virtual string BuildAggregationColumns(GroupByMetadata metadata)
263+
{
264+
return string.Join(", ", metadata.Aggregations.Select(aggregation => Build(aggregation.Column, useAlias: true)));
265+
}
266+
267+
/// <summary>
268+
/// Build the Order By clause needed to append to the main query
269+
/// </summary>
270+
/// <param name="structure">Sql query structure to build query on</param>
271+
/// <returns>SQL query with order-by clause</returns>
272+
protected virtual string BuildOrderBy(SqlQueryStructure structure)
273+
{
274+
if (structure.OrderByColumns.Any())
275+
{
276+
return $" ORDER BY {Build(structure.OrderByColumns)}";
277+
}
278+
279+
return string.Empty;
280+
}
281+
196282
/// <summary>
197283
/// Build orderby column as
198284
/// {SourceAlias}.{ColumnName} {direction}
@@ -447,7 +533,7 @@ public virtual string BuildForeignKeyInfoQuery(int numberOfParameters)
447533
// constraint columns - one inner join for the columns from the 'Referencing table'
448534
// and the other join for the columns from the 'Referenced Table'.
449535
string foreignKeyQuery = $@"
450-
SELECT
536+
SELECT
451537
ReferentialConstraints.CONSTRAINT_NAME {QuoteIdentifier(nameof(ForeignKeyDefinition))},
452538
ReferencingColumnUsage.TABLE_SCHEMA
453539
{QuoteIdentifier($"Referencing{nameof(DatabaseObject.SchemaName)}")},
@@ -457,9 +543,9 @@ public virtual string BuildForeignKeyInfoQuery(int numberOfParameters)
457543
{QuoteIdentifier($"Referenced{nameof(DatabaseObject.SchemaName)}")},
458544
ReferencedColumnUsage.TABLE_NAME {QuoteIdentifier($"Referenced{nameof(SourceDefinition)}")},
459545
ReferencedColumnUsage.COLUMN_NAME {QuoteIdentifier(nameof(ForeignKeyDefinition.ReferencedColumns))}
460-
FROM
546+
FROM
461547
INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS ReferentialConstraints
462-
INNER JOIN
548+
INNER JOIN
463549
INFORMATION_SCHEMA.KEY_COLUMN_USAGE ReferencingColumnUsage
464550
ON ReferentialConstraints.CONSTRAINT_CATALOG = ReferencingColumnUsage.CONSTRAINT_CATALOG
465551
AND ReferentialConstraints.CONSTRAINT_SCHEMA = ReferencingColumnUsage.CONSTRAINT_SCHEMA
Lines changed: 0 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Azure.DataApiBuilder.Config.ObjectModel;
2-
using Azure.DataApiBuilder.Core.Models;
32

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

46-
/// <summary>
47-
/// Build the Group By Clause needed to append to the main query
48-
/// </summary>
49-
/// <param name="structure">Sql query structure to build query on</param>
50-
/// <returns>SQL query with group-by clause</returns>
51-
protected virtual string BuildGroupBy(SqlQueryStructure structure)
52-
{
53-
// Add GROUP BY clause if there are any group by columns
54-
if (structure.GroupByMetadata.Fields.Any())
55-
{
56-
return $" GROUP BY {string.Join(", ", structure.GroupByMetadata.Fields.Values.Select(c => Build(c)))}";
57-
}
58-
59-
return string.Empty;
60-
}
61-
62-
/// <summary>
63-
/// Build the Having clause needed to append to the main query
64-
/// </summary>
65-
/// <param name="structure">Sql query structure to build query on</param>
66-
/// <returns>SQL query with having clause</returns>
67-
protected virtual string BuildHaving(SqlQueryStructure structure)
68-
{
69-
if (structure.GroupByMetadata.Aggregations.Count > 0)
70-
{
71-
List<Predicate>? havingPredicates = structure.GroupByMetadata.Aggregations
72-
.SelectMany(aggregation => aggregation.HavingPredicates ?? new List<Predicate>())
73-
.ToList();
74-
75-
if (havingPredicates.Any())
76-
{
77-
return $" HAVING {Build(havingPredicates)}";
78-
}
79-
}
80-
81-
return string.Empty;
82-
}
83-
84-
/// <summary>
85-
/// Build the Order By clause needed to append to the main query
86-
/// </summary>
87-
/// <param name="structure">Sql query structure to build query on</param>
88-
/// <returns>SQL query with order-by clause</returns>
89-
protected virtual string BuildOrderBy(SqlQueryStructure structure)
90-
{
91-
if (structure.OrderByColumns.Any())
92-
{
93-
return $" ORDER BY {Build(structure.OrderByColumns)}";
94-
}
95-
96-
return string.Empty;
97-
}
98-
99-
/// <summary>
100-
/// Build the aggregation columns needed to append to the main query
101-
/// </summary>
102-
/// <param name="structure">Sql query structure to build query on</param>
103-
/// <returns>SQL query with aggregation columns</returns>
104-
protected virtual string BuildAggregationColumns(SqlQueryStructure structure)
105-
{
106-
string aggregations = string.Empty;
107-
if (structure.GroupByMetadata.Aggregations.Count > 0)
108-
{
109-
if (structure.Columns.Any())
110-
{
111-
aggregations = $",{BuildAggregationColumns(structure.GroupByMetadata)}";
112-
}
113-
else
114-
{
115-
aggregations = $"{BuildAggregationColumns(structure.GroupByMetadata)}";
116-
}
117-
}
118-
119-
return aggregations;
120-
}
121-
122-
/// <summary>
123-
/// Build the aggregation columns needed to append to the main query
124-
/// </summary>
125-
/// <param name="metadata">GroupByMetadata</param>
126-
/// <returns>SQL query with aggregation columns</returns>
127-
protected virtual string BuildAggregationColumns(GroupByMetadata metadata)
128-
{
129-
return string.Join(", ", metadata.Aggregations.Select(aggregation => Build(aggregation.Column, useAlias: true)));
130-
}
13145
}
13246
}

src/Core/Resolvers/PostgreSqlExecutor.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
// Licensed under the MIT License.
33

44
using System.Data.Common;
5+
using System.Net;
56
using Azure.Core;
67
using Azure.DataApiBuilder.Config;
78
using Azure.DataApiBuilder.Config.ObjectModel;
89
using Azure.DataApiBuilder.Core.Configurations;
910
using Azure.DataApiBuilder.Core.Models;
11+
using Azure.DataApiBuilder.Service.Exceptions;
1012
using Azure.Identity;
1113
using Microsoft.AspNetCore.Http;
1214
using Microsoft.Extensions.Logging;
@@ -146,6 +148,87 @@ private static bool ShouldManagedIdentityAccessBeAttempted(NpgsqlConnectionStrin
146148
return string.IsNullOrEmpty(builder.Password);
147149
}
148150

151+
/// <inheritdoc/>
152+
public override async Task<DbResultSet> GetMultipleResultSetsIfAnyAsync(
153+
DbDataReader dbDataReader, List<string>? args = null)
154+
{
155+
// RS1: COUNT of rows matching PK (no policy) — used to distinguish
156+
// "row doesn't exist" from "row exists but policy blocked".
157+
DbResultSet resultSetWithCountOfRowsWithGivenPk = await ExtractResultSetFromDbDataReaderAsync(dbDataReader);
158+
DbResultSetRow? resultSetRowWithCountOfRowsWithGivenPk = resultSetWithCountOfRowsWithGivenPk.Rows.FirstOrDefault();
159+
int numOfRecordsWithGivenPK;
160+
bool isFallbackToUpdate;
161+
162+
if (resultSetRowWithCountOfRowsWithGivenPk is not null &&
163+
resultSetRowWithCountOfRowsWithGivenPk.Columns.TryGetValue(PostgresQueryBuilder.COUNT_ROWS_WITH_GIVEN_PK, out object? rowsWithGivenPK) &&
164+
resultSetRowWithCountOfRowsWithGivenPk.Columns.TryGetValue(PostgresQueryBuilder.IS_FALLBACK_TO_UPDATE, out object? fallbackToUpdate))
165+
{
166+
// PostgreSQL COUNT(*) returns Int64; convert to int.
167+
numOfRecordsWithGivenPK = Convert.ToInt32(rowsWithGivenPK!);
168+
isFallbackToUpdate = Convert.ToBoolean(fallbackToUpdate!);
169+
}
170+
else
171+
{
172+
throw new DataApiBuilderException(
173+
message: $"Neither insert nor update could be performed.",
174+
statusCode: HttpStatusCode.InternalServerError,
175+
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
176+
}
177+
178+
// RS2: UPDATE result, or UPDATE+INSERT CTE result.
179+
DbResultSet dbResultSet = await dbDataReader.NextResultAsync()
180+
? await ExtractResultSetFromDbDataReaderAsync(dbDataReader)
181+
: throw new DataApiBuilderException(
182+
message: $"Neither insert nor update could be performed.",
183+
statusCode: HttpStatusCode.InternalServerError,
184+
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
185+
186+
if (numOfRecordsWithGivenPK == 1) // Row existed — we attempted an UPDATE.
187+
{
188+
if (dbResultSet.Rows.Count == 0)
189+
{
190+
// Row exists but UPDATE returned no rows — update policy blocked it.
191+
throw new DataApiBuilderException(
192+
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
193+
statusCode: HttpStatusCode.Forbidden,
194+
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
195+
}
196+
}
197+
else if (dbResultSet.Rows.Count == 0)
198+
{
199+
// If true, the row simply didn't exist — return 404 (same as MsSql's null-RS2 path).
200+
// If false, the INSERT ran but create policy blocked it — return 403.
201+
202+
if (isFallbackToUpdate)
203+
{
204+
if (args is not null && args.Count > 1)
205+
{
206+
string prettyPrintPk = args[0];
207+
string entityName = args[1];
208+
209+
throw new DataApiBuilderException(
210+
message: $"Cannot perform INSERT and could not find {entityName} " +
211+
$"with primary key {prettyPrintPk} to perform UPDATE on.",
212+
statusCode: HttpStatusCode.NotFound,
213+
subStatusCode: DataApiBuilderException.SubStatusCodes.ItemNotFound);
214+
}
215+
216+
throw new DataApiBuilderException(
217+
message: $"Neither insert nor update could be performed.",
218+
statusCode: HttpStatusCode.InternalServerError,
219+
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
220+
}
221+
222+
// Row didn't exist but INSERT returned no rows — create policy blocked it.
223+
throw new DataApiBuilderException(
224+
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
225+
statusCode: HttpStatusCode.Forbidden,
226+
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
227+
}
228+
229+
return dbResultSet;
230+
}
231+
149232
/// <summary>
150233
/// Determines if the saved default azure credential's access token is valid and not expired.
151234
/// </summary>

0 commit comments

Comments
 (0)