Skip to content

Commit 4d44c37

Browse files
feat: sort by multiple fields (#8799)
This change adds support for sort with multiple fields in local API and REST API. Related discussion #2089 Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
1 parent 6e919cc commit 4d44c37

File tree

34 files changed

+1033
-115
lines changed

34 files changed

+1033
-115
lines changed

docs/configuration/collections.mdx

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,26 @@ export const Posts: CollectionConfig = {
5757

5858
The following options are available:
5959

60-
| Option | Description |
61-
|------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
62-
| **`admin`** | The configuration options for the Admin Panel. [More details](../admin/collections). |
63-
| **`access`** | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
64-
| **`auth`** | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
65-
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
66-
| **`disableDuplicate`** | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
67-
| **`defaultSort`** | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. |
68-
| **`dbName`** | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
69-
| **`endpoints`** | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
70-
| **`fields`** \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
71-
| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. Set to `false` to disable GraphQL. |
72-
| **`hooks`** | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
73-
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
74-
| **`lockDocuments`** | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
75-
| **`slug`** \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
76-
| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
77-
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
78-
| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
79-
| **`versions`** | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
60+
| Option | Description |
61+
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
62+
| **`admin`** | The configuration options for the Admin Panel. [More details](../admin/collections). |
63+
| **`access`** | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
64+
| **`auth`** | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
65+
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
66+
| **`disableDuplicate`** | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
67+
| **`defaultSort`** | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
68+
| **`dbName`** | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
69+
| **`endpoints`** | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
70+
| **`fields`** \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
71+
| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. Set to `false` to disable GraphQL. |
72+
| **`hooks`** | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
73+
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
74+
| **`lockDocuments`** | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
75+
| **`slug`** \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
76+
| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
77+
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
78+
| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
79+
| **`versions`** | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
8080

8181
_\* An asterisk denotes that a property is required._
8282

docs/queries/sort.mdx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ desc: Payload sort allows you to order your documents by a field in ascending or
66
keywords: query, documents, pagination, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
77
---
88

9-
Documents in Payload can be easily sorted by a specific [Field](../fields/overview). When querying Documents, you can pass the name of any top-level field, and the response will sort the Documents by that field in _ascending_ order. If prefixed with a minus symbol ("-"), they will be sorted in _descending_ order.
9+
Documents in Payload can be easily sorted by a specific [Field](../fields/overview). When querying Documents, you can pass the name of any top-level field, and the response will sort the Documents by that field in _ascending_ order. If prefixed with a minus symbol ("-"), they will be sorted in _descending_ order. In Local API multiple fields can be specificed by using an array of strings. In REST API multiple fields can be specified by separating fields with comma. The minus symbol can be in front of individual fields.
1010

1111
Because sorting is handled by the database, the field cannot be a [Virtual Field](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges). It must be stored in the database to be searchable.
1212

@@ -30,6 +30,19 @@ const getPosts = async () => {
3030
}
3131
```
3232

33+
To sort by multiple fields, you can use the `sort` option with fields in an array:
34+
35+
```ts
36+
const getPosts = async () => {
37+
const posts = await payload.find({
38+
collection: 'posts',
39+
sort: ['priority', '-createdAt'], // highlight-line
40+
})
41+
42+
return posts
43+
}
44+
```
45+
3346
## REST API
3447

3548
To sort in the [REST API](../rest-api/overview), you can use the `sort` parameter in your query:
@@ -40,6 +53,14 @@ fetch('https://localhost:3000/api/posts?sort=-createdAt') // highlight-line
4053
.then((data) => console.log(data))
4154
```
4255

56+
To sort by multiple fields, you can use the `sort` parameter with fields separated by comma:
57+
58+
```ts
59+
fetch('https://localhost:3000/api/posts?sort=priority,-createdAt') // highlight-line
60+
.then((response) => response.json())
61+
.then((data) => console.log(data))
62+
```
63+
4364
## GraphQL API
4465

4566
To sort in the [GraphQL API](../graphql/overview), you can use the `sort` parameter in your query:
Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { PaginateOptions } from 'mongoose'
2-
import type { Field, SanitizedConfig } from 'payload'
2+
import type { Field, SanitizedConfig, Sort } from 'payload'
33

44
import { getLocalizedSortProperty } from './getLocalizedSortProperty.js'
55

66
type Args = {
77
config: SanitizedConfig
88
fields: Field[]
99
locale: string
10-
sort: string
10+
sort: Sort
1111
timestamps: boolean
1212
}
1313

@@ -25,32 +25,41 @@ export const buildSortParam = ({
2525
sort,
2626
timestamps,
2727
}: Args): PaginateOptions['sort'] => {
28-
let sortProperty: string
29-
let sortDirection: SortDirection = 'desc'
30-
3128
if (!sort) {
3229
if (timestamps) {
33-
sortProperty = 'createdAt'
30+
sort = '-createdAt'
3431
} else {
35-
sortProperty = '_id'
32+
sort = '-id'
3633
}
37-
} else if (sort.indexOf('-') === 0) {
38-
sortProperty = sort.substring(1)
39-
} else {
40-
sortProperty = sort
41-
sortDirection = 'asc'
4234
}
4335

44-
if (sortProperty === 'id') {
45-
sortProperty = '_id'
46-
} else {
47-
sortProperty = getLocalizedSortProperty({
36+
if (typeof sort === 'string') {
37+
sort = [sort]
38+
}
39+
40+
const sorting = sort.reduce<PaginateOptions['sort']>((acc, item) => {
41+
let sortProperty: string
42+
let sortDirection: SortDirection
43+
if (item.indexOf('-') === 0) {
44+
sortProperty = item.substring(1)
45+
sortDirection = 'desc'
46+
} else {
47+
sortProperty = item
48+
sortDirection = 'asc'
49+
}
50+
if (sortProperty === 'id') {
51+
acc['_id'] = sortDirection
52+
return acc
53+
}
54+
const localizedProperty = getLocalizedSortProperty({
4855
config,
4956
fields,
5057
locale,
5158
segments: sortProperty.split('.'),
5259
})
53-
}
60+
acc[localizedProperty] = sortDirection
61+
return acc
62+
}, {})
5463

55-
return { [sortProperty]: sortDirection }
64+
return sorting
5665
}

packages/drizzle/src/find.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const find: Find = async function find(
2121
},
2222
) {
2323
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
24-
const sort = typeof sortArg === 'string' ? sortArg : collectionConfig.defaultSort
24+
const sort = sortArg !== undefined && sortArg !== null ? sortArg : collectionConfig.defaultSort
2525

2626
const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug))
2727

packages/drizzle/src/find/findMany.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ export const findMany = async function find({
5959

6060
const selectDistinctMethods: ChainedMethods = []
6161

62-
if (orderBy?.order && orderBy?.column) {
62+
if (orderBy) {
6363
selectDistinctMethods.push({
64-
args: [orderBy.order(orderBy.column)],
64+
args: [() => orderBy.map(({ column, order }) => order(column))],
6565
method: 'orderBy',
6666
})
6767
}
@@ -114,7 +114,7 @@ export const findMany = async function find({
114114
} else {
115115
findManyArgs.limit = limit
116116
findManyArgs.offset = offset
117-
findManyArgs.orderBy = orderBy.order(orderBy.column)
117+
findManyArgs.orderBy = () => orderBy.map(({ column, order }) => order(column))
118118

119119
if (where) {
120120
findManyArgs.where = where

packages/drizzle/src/find/traverseFields.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ export const traverseFields = ({
373373
})
374374
.from(adapter.tables[joinCollectionTableName])
375375
.where(subQueryWhere)
376-
.orderBy(orderBy.order(orderBy.column)),
376+
.orderBy(() => orderBy.map(({ column, order }) => order(column))),
377377
})
378378

379379
const columnName = `${path.replaceAll('.', '_')}${field.name}`

packages/drizzle/src/findGlobalVersions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
2424
const globalConfig: SanitizedGlobalConfig = this.payload.globals.config.find(
2525
({ slug }) => slug === global,
2626
)
27-
const sort = typeof sortArg === 'string' ? sortArg : '-createdAt'
27+
const sort = sortArg !== undefined && sortArg !== null ? sortArg : '-createdAt'
2828

2929
const tableName = this.tableNameMap.get(
3030
`_${toSnakeCase(globalConfig.slug)}${this.versionsSuffix}`,

packages/drizzle/src/findVersions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const findVersions: FindVersions = async function findVersions(
2222
},
2323
) {
2424
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
25-
const sort = typeof sortArg === 'string' ? sortArg : collectionConfig.defaultSort
25+
const sort = sortArg !== undefined && sortArg !== null ? sortArg : collectionConfig.defaultSort
2626

2727
const tableName = this.tableNameMap.get(
2828
`_${toSnakeCase(collectionConfig.slug)}${this.versionsSuffix}`,
Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Field } from 'payload'
1+
import type { Field, Sort } from 'payload'
22

33
import { asc, desc } from 'drizzle-orm'
44

@@ -13,7 +13,7 @@ type Args = {
1313
joins: BuildQueryJoinAliases
1414
locale?: string
1515
selectFields: Record<string, GenericColumn>
16-
sort?: string
16+
sort?: Sort
1717
tableName: string
1818
}
1919

@@ -29,54 +29,55 @@ export const buildOrderBy = ({
2929
sort,
3030
tableName,
3131
}: Args): BuildQueryResult['orderBy'] => {
32-
const orderBy: BuildQueryResult['orderBy'] = {
33-
column: null,
34-
order: null,
32+
const orderBy: BuildQueryResult['orderBy'] = []
33+
34+
if (!sort) {
35+
const createdAt = adapter.tables[tableName]?.createdAt
36+
if (createdAt) {
37+
sort = '-createdAt'
38+
} else {
39+
sort = '-id'
40+
}
3541
}
3642

37-
if (sort) {
38-
let sortPath
43+
if (typeof sort === 'string') {
44+
sort = [sort]
45+
}
3946

40-
if (sort[0] === '-') {
41-
sortPath = sort.substring(1)
42-
orderBy.order = desc
47+
for (const sortItem of sort) {
48+
let sortProperty: string
49+
let sortDirection: 'asc' | 'desc'
50+
if (sortItem[0] === '-') {
51+
sortProperty = sortItem.substring(1)
52+
sortDirection = 'desc'
4353
} else {
44-
sortPath = sort
45-
orderBy.order = asc
54+
sortProperty = sortItem
55+
sortDirection = 'asc'
4656
}
47-
4857
try {
4958
const { columnName: sortTableColumnName, table: sortTable } = getTableColumnFromPath({
5059
adapter,
51-
collectionPath: sortPath,
60+
collectionPath: sortProperty,
5261
fields,
5362
joins,
5463
locale,
55-
pathSegments: sortPath.replace(/__/g, '.').split('.'),
64+
pathSegments: sortProperty.replace(/__/g, '.').split('.'),
5665
selectFields,
5766
tableName,
58-
value: sortPath,
67+
value: sortProperty,
5968
})
60-
orderBy.column = sortTable?.[sortTableColumnName]
69+
if (sortTable?.[sortTableColumnName]) {
70+
orderBy.push({
71+
column: sortTable[sortTableColumnName],
72+
order: sortDirection === 'asc' ? asc : desc,
73+
})
74+
75+
selectFields[sortTableColumnName] = sortTable[sortTableColumnName]
76+
}
6177
} catch (err) {
6278
// continue
6379
}
6480
}
6581

66-
if (!orderBy?.column) {
67-
orderBy.order = desc
68-
const createdAt = adapter.tables[tableName]?.createdAt
69-
70-
if (createdAt) {
71-
orderBy.column = createdAt
72-
} else {
73-
orderBy.column = adapter.tables[tableName].id
74-
}
75-
}
76-
77-
if (orderBy.column) {
78-
selectFields.sort = orderBy.column
79-
}
80-
8182
return orderBy
8283
}

packages/drizzle/src/queries/buildQuery.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { asc, desc, SQL } from 'drizzle-orm'
22
import type { PgTableWithColumns } from 'drizzle-orm/pg-core'
3-
import type { Field, Where } from 'payload'
3+
import type { Field, Sort, Where } from 'payload'
44

55
import type { DrizzleAdapter, GenericColumn, GenericTable } from '../types.js'
66

@@ -18,7 +18,7 @@ type BuildQueryArgs = {
1818
fields: Field[]
1919
joins?: BuildQueryJoinAliases
2020
locale?: string
21-
sort?: string
21+
sort?: Sort
2222
tableName: string
2323
where: Where
2424
}
@@ -28,7 +28,7 @@ export type BuildQueryResult = {
2828
orderBy: {
2929
column: GenericColumn
3030
order: typeof asc | typeof desc
31-
}
31+
}[]
3232
selectFields: Record<string, GenericColumn>
3333
where: SQL
3434
}

0 commit comments

Comments
 (0)