Skip to content

Commit 29c46c2

Browse files
Fix $select clause behavior for Find Requests (#1697)
## Why make this change? - Closes #1505 - Responses for Find requests with `$select` query string fields in addition to those requested in `$select` For the below GET request, ```http GET https://localhost:5001/api/Book?$select=title ``` the response contains the `id` field as well. If a table contains composite PK, all the Pks are returned in the response. Response: ```json { "value": [ { "title": "New title #1", "id": 1 }, { "title": "Also Awesome book", "id": 2 }, { "title": "Great wall of china explained", "id": 3 }, ... ] } ``` When both $select and $orderby are used together, the response contains $orderby fields in addition to the ones in $select ```http GET https://localhost:5001/api/Book?$select=id,publisher_id&$orderby=name ``` the response contains the `name` field as well. Response: ```json { "value": [ { "id": 2, "publisher_id": 1234, "name": "Also Awesome book" }, ... ] } ``` Similar behavior was observed with views, where the configured key-fields were returned in the response. If multiple key-fields are configured, all of them were returned. ![image](https://github.com/Azure/data-api-builder/assets/11196553/dd6ad537-0ea5-43b8-bd51-6e81a77edff4) ## What is this change? - To support `pagination` and `$first` with Find requests, it is necessary to provide the `nextLink` field in the response. For the calculation of `nextLink`, the fields such as primary keys, fields in `$orderby` clause are retrieved from the database in addition to the fields requested in the $select clause. The `nextLink` calculation is done at DAB layer after executing the database query because it is impossible to know beforehand whether a table/view would contain more than 100 records (or more entries than requested in the `$first` clause if that is applicable). - Seeing that these additional fields needs to be retrieved from the database, the database queries cannot be modified to select only the fields present in `$select` clause. So, the additional fields are removed at the DAB layer before returning the response. ## Why can't the database queries by modifed to select only the fields present in `$select` query string? When the table contains more than 100 records, the nextLink URL field is returned in the response. The nextLink URL contains $after which is a base 64 encoded value. $after is calculated using all the primary key and $orderby column values of the 100th record (or nth when $first=n is used). The row values from the 100th (or nth when $first=n is used) record is necessary because of two reasons a) The last record's values are necessary to correctly fetch the subsequent set of records. Let's say the first GET request fetches 100 records. To be able to fetch the next set of rows: 101-200 in the right ordering, the database query will include the condition ~ `WHERE ( ( [orderby_column] > [orderby_value_of_row100] ) OR ( [orderby_column] == [orderby_value_of_row100] && [pk_column] > [pk_value_of_row100])`. Here `orderby_value_of_row100` and `pk_value_of_row100` refers to the values of orderby column of the 100th row and pk column of the 100th row respectively. b) $orderby can be performed in ASC or DESC order. The 100th record will be different in both the ordering and we rely on the value of the 100th row to be able to select the next set of rows (101-200, etc.) Logic for $after field calculation: [SqlPaginationUtil.MakeCursorFromJsonElement()](https://github.com/Azure/data-api-builder/blob/7b4c8b5606012d6c9e8093a1ed9b63e5116faf75/src/Core/Resolvers/SqlPaginationUtil.cs#L123C2-L123C2) Granted, all of this is applicable only when nextLink field is necessary in the response, it is not possible to know beforehand whether a table contains more than 100 records ( or n records when first n records is selected using $first clause). We select 101 records (or n+1 records for $first=n) and then determine at DAB layer if nextLink field is necessary. If the database query returns 101 (or n+1 records), then we conclude that a nextLink field is necessary. Essentailly, not being able to know beforehand the number of records in the table forces to select the additional fields. ## How was this tested? - [x] Integration Tests - [x] Manual Tests ## Sample Request(s) - Primary Key fields are not returned in the response when absent in $select query string ![image](https://github.com/Azure/data-api-builder/assets/11196553/d44a8a3b-aeb1-4d1f-929d-cdded8fd3d2b) - Orderby fields are not returned in the response ![image](https://github.com/Azure/data-api-builder/assets/11196553/230e5db7-14d2-4dc1-b0cd-62daa9003834)
1 parent 4fbe9f8 commit 29c46c2

File tree

5 files changed

+749
-43
lines changed

5 files changed

+749
-43
lines changed

src/Core/Resolvers/SqlQueryEngine.cs

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Azure.DataApiBuilder.Core.Configurations;
99
using Azure.DataApiBuilder.Core.Models;
1010
using Azure.DataApiBuilder.Core.Services;
11+
using Azure.DataApiBuilder.Service.Exceptions;
1112
using HotChocolate.Resolvers;
1213
using Microsoft.AspNetCore.Http;
1314
using Microsoft.AspNetCore.Http.Extensions;
@@ -186,22 +187,45 @@ private OkObjectResult FormatFindResult(JsonDocument jsonDoc, FindRequestContext
186187
{
187188
JsonElement jsonElement = jsonDoc.RootElement.Clone();
188189

190+
// When there are no rows returned from the database, the jsonElement will be an empty array.
191+
// In that case, the response is returned as is.
192+
if (jsonElement.ValueKind is JsonValueKind.Array && jsonElement.GetArrayLength() == 0)
193+
{
194+
return OkResponse(jsonElement);
195+
}
196+
197+
HashSet<string> extraFieldsInResponse = (jsonElement.ValueKind is not JsonValueKind.Array)
198+
? DetermineExtraFieldsInResponse(jsonElement, context)
199+
: DetermineExtraFieldsInResponse(jsonElement.EnumerateArray().First(), context);
200+
189201
// If the results are not a collection or if the query does not have a next page
190-
// no nextLink is needed, return JsonDocument as is
202+
// no nextLink is needed. So, the response is returned after removing the extra fields.
191203
if (jsonElement.ValueKind is not JsonValueKind.Array || !SqlPaginationUtil.HasNext(jsonElement, context.First))
192204
{
193-
// Clones the root element to a new JsonElement that can be
194-
// safely stored beyond the lifetime of the original JsonDocument.
195-
return OkResponse(jsonElement);
205+
// If there are no additional fields present, the response is returned directly. When there
206+
// are extra fields, they are removed before returning the response.
207+
if (extraFieldsInResponse.Count == 0)
208+
{
209+
return OkResponse(jsonElement);
210+
}
211+
else
212+
{
213+
return jsonElement.ValueKind is JsonValueKind.Array ? OkResponse(JsonSerializer.SerializeToElement(RemoveExtraFieldsInResponseWithMultipleItems(jsonElement.EnumerateArray().ToList(), extraFieldsInResponse)))
214+
: OkResponse(RemoveExtraFieldsInResponseWithSingleItem(jsonElement, extraFieldsInResponse));
215+
}
196216
}
197217

218+
List<JsonElement> rootEnumerated = jsonElement.EnumerateArray().ToList();
219+
198220
// More records exist than requested, we know this by requesting 1 extra record,
199221
// that extra record is removed here.
200-
IEnumerable<JsonElement> rootEnumerated = jsonElement.EnumerateArray();
222+
rootEnumerated.RemoveAt(rootEnumerated.Count - 1);
201223

202-
rootEnumerated = rootEnumerated.Take(rootEnumerated.Count() - 1);
224+
// The fields such as primary keys, fields in $orderby clause that are retrieved in addition to the
225+
// fields requested in the $select clause are required for calculating the $after element which is part of nextLink.
226+
// So, the extra fields are removed post the calculation of $after element.
203227
string after = SqlPaginationUtil.MakeCursorFromJsonElement(
204-
element: rootEnumerated.Last(),
228+
element: rootEnumerated[rootEnumerated.Count - 1],
205229
orderByColumns: context.OrderByClauseOfBackingColumns,
206230
primaryKey: _sqlMetadataProvider.GetSourceDefinition(context.EntityName).PrimaryKey,
207231
entityName: context.EntityName,
@@ -233,10 +257,86 @@ private OkObjectResult FormatFindResult(JsonDocument jsonDoc, FindRequestContext
233257
path,
234258
nvc: context!.ParsedQueryString,
235259
after);
236-
rootEnumerated = rootEnumerated.Append(nextLink);
260+
261+
// When there are extra fields present, they are removed before returning the response.
262+
if (extraFieldsInResponse.Count > 0)
263+
{
264+
rootEnumerated = RemoveExtraFieldsInResponseWithMultipleItems(rootEnumerated, extraFieldsInResponse);
265+
}
266+
267+
rootEnumerated.Add(nextLink);
237268
return OkResponse(JsonSerializer.SerializeToElement(rootEnumerated));
238269
}
239270

271+
/// <summary>
272+
/// To support pagination and $first clause with Find requests, it is necessary to provide the nextLink
273+
/// field in the response. For the calculation of nextLink, the fields such as primary keys, fields in $orderby clause
274+
/// are retrieved from the database in addition to the fields requested in the $select clause.
275+
/// However, these fields are not required in the response.
276+
/// This function helps to determine those additional fields that are present in the response.
277+
/// </summary>
278+
/// <param name="response">Response json retrieved from the database</param>
279+
/// <param name="context">FindRequestContext for the GET request.</param>
280+
/// <returns>Additional fields that are present in the response</returns>
281+
private static HashSet<string> DetermineExtraFieldsInResponse(JsonElement response, FindRequestContext context)
282+
{
283+
HashSet<string> fieldsPresentInResponse = new();
284+
285+
foreach (JsonProperty property in response.EnumerateObject())
286+
{
287+
fieldsPresentInResponse.Add(property.Name);
288+
}
289+
290+
// context.FieldsToBeReturned will contain the fields requested in the $select clause.
291+
// If $select clause is absent, it will contain the list of columns that can be returned in the
292+
// response taking into account the include and exclude fields configured for the entity.
293+
// So, the other fields in the response apart from the fields in context.FieldsToBeReturned
294+
// are not required.
295+
return fieldsPresentInResponse.Except(context.FieldsToBeReturned).ToHashSet();
296+
}
297+
298+
/// <summary>
299+
/// Helper function that removes the extra fields from each item of a list of json elements.
300+
/// </summary>
301+
/// <param name="jsonElementList">List of Json Elements with extra fields</param>
302+
/// <param name="extraFields">Additional fields that needs to be removed from the list of Json elements</param>
303+
/// <returns>List of Json Elements after removing the additional fields</returns>
304+
private static List<JsonElement> RemoveExtraFieldsInResponseWithMultipleItems(List<JsonElement> jsonElementList, IEnumerable<string> extraFields)
305+
{
306+
for (int i = 0; i < jsonElementList.Count; i++)
307+
{
308+
jsonElementList[i] = RemoveExtraFieldsInResponseWithSingleItem(jsonElementList[i], extraFields);
309+
}
310+
311+
return jsonElementList;
312+
}
313+
314+
/// <summary>
315+
/// Helper function that removes the extra fields from a single json element.
316+
/// </summary>
317+
/// <param name="jsonElement"> Json Element with extra fields</param>
318+
/// <param name="extraFields">Additional fields that needs to be removed from the Json element</param>
319+
/// <returns>Json Element after removing the additional fields</returns>
320+
private static JsonElement RemoveExtraFieldsInResponseWithSingleItem(JsonElement jsonElement, IEnumerable<string> extraFields)
321+
{
322+
JsonObject? jsonObject = JsonObject.Create(jsonElement);
323+
324+
if (jsonObject is null)
325+
{
326+
throw new DataApiBuilderException(
327+
message: "While processing your request the server ran into an unexpected error",
328+
statusCode: System.Net.HttpStatusCode.InternalServerError,
329+
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
330+
}
331+
332+
foreach (string extraField in extraFields)
333+
{
334+
jsonObject.Remove(extraField);
335+
}
336+
337+
return JsonSerializer.SerializeToElement(jsonObject);
338+
}
339+
240340
/// <summary>
241341
/// Helper function returns an OkObjectResult with provided arguments in a
242342
/// form that complies with vNext Api guidelines.

0 commit comments

Comments
 (0)