diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs index daee3bfb1b..51bbb5a5b3 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs @@ -567,6 +567,22 @@ await SetupAndRunRestApiTest( ); } + /// + /// Tests the REST Api for Find operation for all records. + /// Uses entity with mapped columns, and order by title in ascending order. + /// + [TestMethod] + public async Task FindTestWithQueryStringAllFieldsMappedEntityOrderByAsc() + { + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: "?$orderby=fancyName", + entity: _integrationMappingDifferentEntity, + sqlQuery: GetQuery(nameof(FindTestWithQueryStringAllFieldsMappedEntityOrderByAsc)), + controller: _restController + ); + } + /// /// Tests the REST Api for Find operation for all records /// when there is a space in the column name. diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs index 8e6c4e6527..ada887e31d 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs @@ -261,6 +261,12 @@ public class MsSqlFindApiTests : FindApiTestBase $"ORDER BY title, id " + $"FOR JSON PATH, INCLUDE_NULL_VALUES" }, + { + "FindTestWithQueryStringAllFieldsMappedEntityOrderByAsc", + $"SELECT [treeId], [species] AS [fancyName], [region], [height] FROM { _integrationMappingTable } " + + $"ORDER BY species " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES" + }, { "FindTestWithQueryStringSpaceInNamesOrderByAsc", $"SELECT * FROM { _integrationTableHasColumnWithSpace } " + diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs index 5460b6764b..34430806fc 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs @@ -490,6 +490,17 @@ SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'title', title, 'publisher_id', publi LIMIT 100 ) AS subq" }, + { + "FindTestWithQueryStringAllFieldsMappedEntityOrderByAsc", + @" + SELECT JSON_ARRAYAGG(JSON_OBJECT('treeId', treeId, 'fancyName', species, 'region', region, 'height', height)) AS data + FROM ( + SELECT * + FROM " + _integrationMappingTable + @" + ORDER BY species + LIMIT 100 + ) AS subq" + }, { "FindTestWithQueryStringSpaceInNamesOrderByAsc", @" diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs index dfedd8ca59..cdbd7976c1 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs @@ -383,6 +383,16 @@ SELECT json_agg(to_jsonb(subq)) AS data ORDER BY title, id ) AS subq" }, + { + "FindTestWithQueryStringAllFieldsMappedEntityOrderByAsc", + @" + SELECT json_agg(to_jsonb(subq)) AS data + FROM ( + SELECT ""treeId"", ""species"" AS ""fancyName"", ""region"", ""height"" + FROM " + _integrationMappingTable + @" + ORDER BY ""species"" + ) AS subq" + }, { "FindTestWithFirstAndSpacedColumnOrderBy", @" diff --git a/src/Service/Models/RestRequestContexts/RestRequestContext.cs b/src/Service/Models/RestRequestContexts/RestRequestContext.cs index d98ba35dfc..b4de92e9b7 100644 --- a/src/Service/Models/RestRequestContexts/RestRequestContext.cs +++ b/src/Service/Models/RestRequestContexts/RestRequestContext.cs @@ -62,6 +62,12 @@ protected RestRequestContext(string entityName, DatabaseObject dbo) /// public virtual List? OrderByClauseInUrl { get; set; } + /// + /// List of OrderBy Columns which represent the OrderByClause using backing columns. + /// Based on the operation type, this property may or may not be populated. + /// + public virtual List? OrderByClauseOfBackingColumns { get; set; } + /// /// Dictionary of field names and their values given in the request body. /// Based on the operation type, this property may or may not be populated. diff --git a/src/Service/Parsers/RequestParser.cs b/src/Service/Parsers/RequestParser.cs index 9f53899b7d..d02ca1f6d3 100644 --- a/src/Service/Parsers/RequestParser.cs +++ b/src/Service/Parsers/RequestParser.cs @@ -109,7 +109,7 @@ public static void ParseQueryString(RestRequestContext context, ISqlMetadataProv break; case SORT_URL: string sortQueryString = $"?{SORT_URL}={context.ParsedQueryString[key]}"; - context.OrderByClauseInUrl = GenerateOrderByList(context, sqlMetadataProvider, sortQueryString); + (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = GenerateOrderByLists(context, sqlMetadataProvider, sortQueryString); break; case AFTER_URL: context.After = context.ParsedQueryString[key]; @@ -135,9 +135,9 @@ public static void ParseQueryString(RestRequestContext context, ISqlMetadataProv /// associated with the sort param. /// A List /// - private static List? GenerateOrderByList(RestRequestContext context, - ISqlMetadataProvider sqlMetadataProvider, - string sortQueryString) + private static (List?, List?) GenerateOrderByLists(RestRequestContext context, + ISqlMetadataProvider sqlMetadataProvider, + string sortQueryString) { string schemaName = context.DatabaseObject.SchemaName; string tableName = context.DatabaseObject.Name; @@ -148,7 +148,9 @@ public static void ParseQueryString(RestRequestContext context, ISqlMetadataProv // used for performant Remove operations HashSet remainingKeys = new(primaryKeys); - List orderByList = new(); + List orderByListUrl = new(); + List orderByListBackingColumn = new(); + // OrderBy AST is in the form of a linked list // so we traverse by calling node.ThenBy until // node is null @@ -159,43 +161,49 @@ public static void ParseQueryString(RestRequestContext context, ISqlMetadataProv // column name of null. ie: $orderby='hello world', or $orderby=null // note: null support is not currently implemented. QueryNode? expression = node.Expression is not null ? node.Expression : - throw new DataApiBuilderException(message: "OrderBy property is not supported.", - HttpStatusCode.BadRequest, - DataApiBuilderException.SubStatusCodes.BadRequest); + throw new DataApiBuilderException( + message: "OrderBy property is not supported.", + HttpStatusCode.BadRequest, + DataApiBuilderException.SubStatusCodes.BadRequest); - string backingColumnName; + string? backingColumnName; + string exposedName; if (expression.Kind is QueryNodeKind.SingleValuePropertyAccess) { + exposedName = ((SingleValuePropertyAccessNode)expression).Property.Name; + sqlMetadataProvider.TryGetBackingColumn(context.EntityName, exposedName, out backingColumnName); // if name is in SingleValuePropertyAccess node it matches our model and we will // always be able to get backing column successfully - sqlMetadataProvider.TryGetBackingColumn(context.EntityName, ((SingleValuePropertyAccessNode)expression).Property.Name, out backingColumnName!); } else if (expression.Kind is QueryNodeKind.Constant && ((ConstantNode)expression).Value is not null) { // since this comes from constant node, it was not checked against our model // so this may return false in which case we throw for a bad request - if (!sqlMetadataProvider.TryGetBackingColumn(context.EntityName, ((ConstantNode)expression).Value.ToString()!, out backingColumnName!)) + exposedName = ((ConstantNode)expression).Value.ToString()!; + if (!sqlMetadataProvider.TryGetBackingColumn(context.EntityName, exposedName, out backingColumnName)) { throw new DataApiBuilderException( - message: $"Invalid orderby column requested: {((ConstantNode)expression).Value.ToString()!}.", + message: $"Invalid orderby column requested: {exposedName}.", statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } } else { - throw new DataApiBuilderException(message: "OrderBy property is not supported.", - HttpStatusCode.BadRequest, - DataApiBuilderException.SubStatusCodes.BadRequest); + throw new DataApiBuilderException( + message: "OrderBy property is not supported.", + HttpStatusCode.BadRequest, + DataApiBuilderException.SubStatusCodes.BadRequest); } // Sorting order is stored in node.Direction as OrderByDirection Enum // We convert to an Enum of our own that matches the SQL text we want OrderBy direction = GetDirection(node.Direction); // Add OrderByColumn and remove any matching columns from our primary key set - orderByList.Add(new OrderByColumn(schemaName, tableName, backingColumnName, direction: direction)); - remainingKeys.Remove(backingColumnName); + orderByListUrl.Add(new OrderByColumn(schemaName, tableName, exposedName, direction: direction)); + orderByListBackingColumn.Add(new OrderByColumn(schemaName, tableName, backingColumnName!, direction: direction)); + remainingKeys.Remove(backingColumnName!); node = node.ThenBy; } @@ -206,11 +214,13 @@ public static void ParseQueryString(RestRequestContext context, ISqlMetadataProv { if (remainingKeys.Contains(column)) { - orderByList.Add(new OrderByColumn(schemaName, tableName, column)); + sqlMetadataProvider.TryGetExposedColumnName(context.EntityName, column, out string? exposedName); + orderByListUrl.Add(new OrderByColumn(schemaName, tableName, exposedName!)); + orderByListBackingColumn.Add(new OrderByColumn(schemaName, tableName, column)); } } - return orderByList; + return (orderByListUrl, orderByListBackingColumn); } /// diff --git a/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs index d57cd9c0e4..7776d62d0b 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs @@ -171,10 +171,12 @@ public SqlQueryStructure( value: predicate.Value); } - // context.OrderByColumnsInUrl will lack TableAlias because it is created in RequestParser - // which may be called for any type of operation. To avoid coupling the OrderByClauseInUrl + // context.OrderByClauseOfBackingColumns will lack TableAlias because it is created in RequestParser + // which may be called for any type of operation. To avoid coupling the OrderByClauseOfBackingColumns // to only Find, we populate the TableAlias in this constructor where we know we have a Find operation. - OrderByColumns = context.OrderByClauseInUrl is not null ? context.OrderByClauseInUrl : PrimaryKeyAsOrderByColumns(); + OrderByColumns = context.OrderByClauseOfBackingColumns is not null ? + context.OrderByClauseOfBackingColumns : PrimaryKeyAsOrderByColumns(); + foreach (OrderByColumn column in OrderByColumns) { if (string.IsNullOrEmpty(column.TableAlias)) @@ -247,6 +249,28 @@ private void AddFields(RestRequestContext context, ISqlMetadataProvider sqlMetad } } + /// + /// Exposes the primary key of the underlying table of the structure + /// as a list of OrderByColumn + /// + private List PrimaryKeyAsOrderByColumns() + { + if (_primaryKeyAsOrderByColumns is null) + { + _primaryKeyAsOrderByColumns = new(); + + foreach (string column in PrimaryKey()) + { + _primaryKeyAsOrderByColumns.Add(new OrderByColumn(tableSchema: DatabaseObject.SchemaName, + tableName: DatabaseObject.Name, + columnName: column, + tableAlias: TableAlias)); + } + } + + return _primaryKeyAsOrderByColumns; + } + /// /// Private constructor that is used for recursive query generation, /// for each subquery that's necessary to resolve a nested GraphQL @@ -851,28 +875,6 @@ private List ProcessGqlOrderByArg(List orderByFi return orderByColumnsList; } - /// - /// Exposes the primary key of the underlying table of the structure - /// as a list of OrderByColumn - /// - public List PrimaryKeyAsOrderByColumns() - { - if (_primaryKeyAsOrderByColumns == null) - { - _primaryKeyAsOrderByColumns = new(); - - foreach (string column in PrimaryKey()) - { - _primaryKeyAsOrderByColumns.Add(new OrderByColumn(tableSchema: DatabaseObject.SchemaName, - tableName: DatabaseObject.Name, - columnName: column, - tableAlias: TableAlias)); - } - } - - return _primaryKeyAsOrderByColumns; - } - /// /// Adds a labelled column to this query's columns, where /// the column name is all that is provided, and we add diff --git a/src/Service/Resolvers/SqlQueryEngine.cs b/src/Service/Resolvers/SqlQueryEngine.cs index d9e07cc160..f17ad47eb7 100644 --- a/src/Service/Resolvers/SqlQueryEngine.cs +++ b/src/Service/Resolvers/SqlQueryEngine.cs @@ -172,7 +172,7 @@ private OkObjectResult FormatFindResult(JsonDocument jsonDoc, FindRequestContext rootEnumerated = rootEnumerated.Take(rootEnumerated.Count() - 1); string after = SqlPaginationUtil.MakeCursorFromJsonElement( element: rootEnumerated.Last(), - orderByColumns: context.OrderByClauseInUrl, + orderByColumns: context.OrderByClauseOfBackingColumns, primaryKey: _sqlMetadataProvider.GetTableDefinition(context.EntityName).PrimaryKey, entityName: context.EntityName, schemaName: context.DatabaseObject.SchemaName,