diff --git a/.changeset/stupid-ducks-explain.md b/.changeset/stupid-ducks-explain.md new file mode 100644 index 00000000000..3889e9bbdf5 --- /dev/null +++ b/.changeset/stupid-ducks-explain.md @@ -0,0 +1,10 @@ +--- +'@keystonejs/adapter-knex': minor +'@keystonejs/adapter-mongoose': minor +'@keystonejs/keystone': minor +'@keystonejs/mongo-join-builder': minor +--- + +Added new `sortBy` query argument. + +Each list now has an additional `SortBy` enum type that represents the valid sorting options for all orderable fields in the list. `sortBy` takes one or more of these enum types, allowing for multi-field/column sorting. diff --git a/api-tests/queries/limits.test.js b/api-tests/queries/limits.test.js index e4bddfd6898..3dfe3953535 100644 --- a/api-tests/queries/limits.test.js +++ b/api-tests/queries/limits.test.js @@ -67,7 +67,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => query { allUsers( where: { name_contains: "J" }, - orderBy: "name_ASC", + sortBy: name_ASC, ) { name } @@ -232,10 +232,10 @@ multiAdapterRunners().map(({ runner, adapterName }) => { title: "Two authors" }, ] } - orderBy: "title_ASC", + sortBy: title_ASC, ) { title - author(orderBy: "name_ASC") { + author(sortBy: name_ASC) { name } } @@ -319,7 +319,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => title author { posts { - title + title } } } diff --git a/api-tests/queries/relationships.test.js b/api-tests/queries/relationships.test.js index 985c586629e..a1522b5134f 100644 --- a/api-tests/queries/relationships.test.js +++ b/api-tests/queries/relationships.test.js @@ -180,7 +180,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => feed_some: { title_contains: "J" } }) { id - feed(orderBy: "title_ASC") { + feed(sortBy: title_ASC) { title } } @@ -299,7 +299,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => }) { id name - feed(orderBy: "title_ASC") { + feed(sortBy: title_ASC) { id title } @@ -331,7 +331,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => allUsers(where: { feed_none: { title_contains: "J" } }, - orderBy: "id_ASC") { + sortBy: id_ASC) { id feed { title diff --git a/api-tests/relationships/filtering/nested.test.js b/api-tests/relationships/filtering/nested.test.js index 00988cf116f..dcbdea6ccb3 100644 --- a/api-tests/relationships/filtering/nested.test.js +++ b/api-tests/relationships/filtering/nested.test.js @@ -100,7 +100,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => query { allUsers { id - posts (first: 1, orderBy: "content_ASC") { + posts (first: 1, sortBy: content_ASC) { id content } diff --git a/api-tests/relationships/nested-mutations/create-many.test.js b/api-tests/relationships/nested-mutations/create-many.test.js index 87735f3c12b..7de1415040c 100644 --- a/api-tests/relationships/nested-mutations/create-many.test.js +++ b/api-tests/relationships/nested-mutations/create-many.test.js @@ -82,7 +82,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => notes: { create: [{ content: "${noteContent}" }] } }) { id - notes(orderBy: "content_ASC") { + notes(sortBy: content_ASC) { id content } @@ -118,7 +118,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => } }) { id - notes(orderBy: "content_ASC") { + notes(sortBy: content_ASC) { id content } @@ -245,7 +245,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => } ) { id - notes(orderBy: "content_ASC") { + notes(sortBy: content_ASC) { id content } diff --git a/api-tests/relationships/nested-mutations/reconnect-many-to-one.test.js b/api-tests/relationships/nested-mutations/reconnect-many-to-one.test.js index b1c0d32a1b0..67af3eb767c 100644 --- a/api-tests/relationships/nested-mutations/reconnect-many-to-one.test.js +++ b/api-tests/relationships/nested-mutations/reconnect-many-to-one.test.js @@ -46,7 +46,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => notes: { connect: [{ id: "${noteA.id}" }, { id: "${noteB.id}" }] } }) { id - notes(orderBy: "title_ASC") { id title } + notes(sortBy: title_ASC) { id title } } } `, @@ -60,7 +60,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => notes: { connect: [{ id: "${noteC.id}" }, { id: "${noteD.id}" }] } }) { id - notes(orderBy: "title_ASC") { id title } + notes(sortBy: title_ASC) { id title } } } `, @@ -82,7 +82,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => notes: { connect: [{ id: "${noteB.id}" }] } }) { id - notes(orderBy: "title_ASC") { id title } + notes(sortBy: title_ASC) { id title } } } `, @@ -119,7 +119,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => { User(where: { id: "${alice.createUser.id}"}) { id - notes(orderBy: "title_ASC") { id title } + notes(sortBy: title_ASC) { id title } } } `, diff --git a/api-tests/relationships/nested-mutations/two-way-backreference/to-many.test.js b/api-tests/relationships/nested-mutations/two-way-backreference/to-many.test.js index 6bc9ab784e4..d8b36ad6dd0 100644 --- a/api-tests/relationships/nested-mutations/two-way-backreference/to-many.test.js +++ b/api-tests/relationships/nested-mutations/two-way-backreference/to-many.test.js @@ -204,7 +204,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => } ) { id - teachers(orderBy: "id_ASC") { + teachers(sortBy: id_ASC) { id } } diff --git a/demo-projects/meetup/site/graphql/events.js b/demo-projects/meetup/site/graphql/events.js index c72913ce320..f08440a8670 100644 --- a/demo-projects/meetup/site/graphql/events.js +++ b/demo-projects/meetup/site/graphql/events.js @@ -30,13 +30,13 @@ export const GET_CURRENT_EVENTS = gql` query GetCurrentEvents($now: DateTime!) { upcomingEvents: allEvents( where: { startTime_not: null, status: active, startTime_gte: $now } - orderBy: "startTime_DESC" + sortBy: startTime_DESC ) { ...EventData } previousEvents: allEvents( where: { startTime_not: null, status: active, startTime_lte: $now } - orderBy: "startTime_ASC" + sortBy: startTime_ASC ) { ...EventData } @@ -46,7 +46,7 @@ export const GET_CURRENT_EVENTS = gql` export const GET_ALL_EVENTS = gql` { - allEvents(where: { startTime_not: null, status: active }, orderBy: "startTime_DESC") { + allEvents(where: { startTime_not: null, status: active }, sortBy: startTime_DESC) { ...EventData } } diff --git a/docs/guides/intro-to-graphql.md b/docs/guides/intro-to-graphql.md index 2bc531a7dbc..4aacd5e73d9 100644 --- a/docs/guides/intro-to-graphql.md +++ b/docs/guides/intro-to-graphql.md @@ -224,7 +224,8 @@ When executing queries and mutations there are a number of ways you can filter, - `search` - `skip` - `first` -- `orderby` +- `sortBy` +- `orderby` (deprecated) ### `where` @@ -320,8 +321,22 @@ query { } ``` +### `sortBy` + +Order results. Accepts one or more list sort enums in the format `_`. For example, to order by name descending (alphabetical order, A -> Z): + +```graphql +query { + allUsers(sortBy: name_DESC) { + id + } +} +``` + ### `orderBy` +> **Warning:** This argument is deprecated. Use `sortBy` instead. + Order results. The orderBy string should match the format `_`. For example, to order by name descending (alphabetical order, A -> Z): ```graphql diff --git a/packages/adapter-knex/lib/adapter-knex.js b/packages/adapter-knex/lib/adapter-knex.js index d02186e70d1..8c0bb28bf50 100644 --- a/packages/adapter-knex/lib/adapter-knex.js +++ b/packages/adapter-knex/lib/adapter-knex.js @@ -568,7 +568,7 @@ class KnexListAdapter extends BaseListAdapter { class QueryBuilder { constructor( listAdapter, - { where = {}, first, skip, orderBy, search }, + { where = {}, first, skip, sortBy, orderBy, search }, { meta = false, from = {} } ) { this._tableAliases = {}; @@ -649,6 +649,17 @@ class QueryBuilder { const sortKey = listAdapter.fieldAdaptersByPath[orderField].sortKey || orderField; this._query.orderBy(sortKey, orderDirection); } + if (sortBy !== undefined) { + // SELECT ... ORDER BY [, , ...] + this._query.orderBy( + sortBy.map(s => { + const [orderField, orderDirection] = s.split('_'); + const sortKey = listAdapter.fieldAdaptersByPath[orderField].sortKey || orderField; + + return { column: sortKey, order: orderDirection }; + }) + ); + } } } diff --git a/packages/adapter-mongoose/lib/adapter-mongoose.js b/packages/adapter-mongoose/lib/adapter-mongoose.js index 61683715849..85f3f6c9bc2 100644 --- a/packages/adapter-mongoose/lib/adapter-mongoose.js +++ b/packages/adapter-mongoose/lib/adapter-mongoose.js @@ -533,7 +533,10 @@ class MongooseListAdapter extends BaseListAdapter { ? graphQlQueryToMongoJoinQuery(whereElement) // Recursively traverse relationship fields : whereElement ), - ...mapKeyNames(pick(modifiers, ['search', 'orderBy', 'skip', 'first']), key => `$${key}`), + ...mapKeyNames( + pick(modifiers, ['search', 'sortBy', 'orderBy', 'skip', 'first']), + key => `$${key}` + ), }); let query; try { diff --git a/packages/fields/src/types/DateTime/test-fixtures.js b/packages/fields/src/types/DateTime/test-fixtures.js index e09ed5e7453..24ff4dc9246 100644 --- a/packages/fields/src/types/DateTime/test-fixtures.js +++ b/packages/fields/src/types/DateTime/test-fixtures.js @@ -199,11 +199,11 @@ export const filterTests = withKeystone => { ); test( - 'Sorting: orderBy: lastOnline_ASC', + 'Sorting: sortBy: lastOnline_ASC', withKeystone(({ keystone, adapterName }) => match( keystone, - 'orderBy: "lastOnline_ASC"', + 'sortBy: lastOnline_ASC', adapterName === 'mongoose' ? [ { name: 'person5', lastOnline: null }, @@ -225,11 +225,11 @@ export const filterTests = withKeystone => { ); test( - 'Sorting: orderBy: lastOnline_DESC', + 'Sorting: sortBy: lastOnline_DESC', withKeystone(({ keystone, adapterName }) => match( keystone, - 'orderBy: "lastOnline_DESC"', + 'sortBy: lastOnline_DESC', adapterName === 'mongoose' ? [ { name: 'person2', lastOnline: '2000-01-20T00:08:00.000+10:00' }, diff --git a/packages/keystone/lib/List/index.js b/packages/keystone/lib/List/index.js index 9a2b589a2e1..f7bcbabe8f6 100644 --- a/packages/keystone/lib/List/index.js +++ b/packages/keystone/lib/List/index.js @@ -188,6 +188,7 @@ module.exports = class List { listQueryName: `all${_listQueryName}`, listQueryMetaName: `_all${_listQueryName}Meta`, listMetaName: preventInvalidUnderscorePrefix(`_${_listQueryName}Meta`), + listSortName: `Sort${_listQueryName}By`, deleteMutationName: `delete${_itemQueryName}`, updateMutationName: `update${_itemQueryName}`, createMutationName: `create${_itemQueryName}`, @@ -370,8 +371,11 @@ module.exports = class List { getGqlTypes({ schemaName }) { const schemaAccess = this.access[schemaName]; - // https://github.com/opencrud/opencrud/blob/master/spec/2-relational/2-2-queries/2-2-3-filters.md#boolean-expressions const types = []; + + // We want to include `id` fields + // If read is globally set to false, makes sense to never show it + const readFields = this.getAllFieldsWithAccess({ schemaName, access: 'read' }); if ( schemaAccess.read || schemaAccess.create || @@ -393,26 +397,21 @@ module.exports = class List { """ _label_: String ${flatten( - this.fields - .filter(field => field.access[schemaName].read) // If it's globally set to false, makes sense to never show it - .map(field => - field.schemaDoc - ? `""" ${field.schemaDoc} """ ${field.gqlOutputFields({ schemaName })}` - : field.gqlOutputFields({ schemaName }) - ) + readFields.map(field => + field.schemaDoc + ? `""" ${field.schemaDoc} """ ${field.gqlOutputFields({ schemaName })}` + : field.gqlOutputFields({ schemaName }) + ) ).join('\n')} - } - `, + }`, + + // https://github.com/opencrud/opencrud/blob/master/spec/2-relational/2-2-queries/2-2-3-filters.md#boolean-expressions ` input ${this.gqlNames.whereInputName} { AND: [${this.gqlNames.whereInputName}] OR: [${this.gqlNames.whereInputName}] - ${flatten( - this.fields - .filter(field => field.access[schemaName].read) // If it's globally set to false, makes sense to never show it - .map(field => field.gqlQueryInputFields({ schemaName })) - ).join('\n')} + ${flatten(readFields.map(field => field.gqlQueryInputFields({ schemaName }))).join('\n')} }`, // TODO: Include other `unique` fields and allow filtering by them ` @@ -420,6 +419,21 @@ module.exports = class List { id: ID! }` ); + + const sortOptions = flatten( + readFields.map(({ path, isOrderable }) => + // Explicitly allow sorting by id + isOrderable || path === 'id' ? [`${path}_ASC`, `${path}_DESC`] : [] + ) + ); + + if (sortOptions.length) { + types.push(` + enum ${this.gqlNames.listSortName} { + ${sortOptions.join('\n')} + } + `); + } } const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); @@ -458,6 +472,7 @@ module.exports = class List { return [ `where: ${this.gqlNames.whereInputName}`, `search: String`, + `sortBy: [${this.gqlNames.listSortName}!]`, `orderBy: String`, `first: Int`, `skip: Int`, diff --git a/packages/keystone/tests/List.test.js b/packages/keystone/tests/List.test.js index 6d96448f602..27f73a9eaaa 100644 --- a/packages/keystone/tests/List.test.js +++ b/packages/keystone/tests/List.test.js @@ -250,6 +250,7 @@ describe('new List()', () => { listQueryName: 'allTests', listQueryMetaName: '_allTestsMeta', listMetaName: '_TestsMeta', + listSortName: 'SortTestsBy', deleteMutationName: 'deleteTest', deleteManyMutationName: 'deleteTests', updateMutationName: 'updateTest', @@ -438,6 +439,7 @@ describe('getAdminMeta()', () => { itemQueryName: 'Test', listQueryName: 'allTests', listQueryMetaName: '_allTestsMeta', + listSortName: 'SortTestsBy', listMetaName: '_TestsMeta', deleteMutationName: 'deleteTest', deleteManyMutationName: 'deleteTests', @@ -577,7 +579,16 @@ describe(`getGqlTypes() `, () => { const createManyInput = `input TestsCreateInput { data: TestCreateInput }`; - + const sortTestsBy = `enum SortTestsBy { + name_ASC + name_DESC + email_ASC + email_DESC + other_ASC + other_DESC + writeOnce_ASC + writeOnce_DESC + }`; const schemaName = 'public'; test('access: true', () => { expect( @@ -589,6 +600,7 @@ describe(`getGqlTypes() `, () => { type, whereInput, whereUniqueInput, + sortTestsBy, updateInput, updateManyInput, createInput, @@ -608,7 +620,7 @@ describe(`getGqlTypes() `, () => { setup({ access: { read: true, create: false, update: false, delete: false } }) .getGqlTypes({ schemaName }) .map(s => print(gql(s))) - ).toEqual([type, whereInput, whereUniqueInput].map(s => print(gql(s)))); + ).toEqual([type, whereInput, whereUniqueInput, sortTestsBy].map(s => print(gql(s)))); }); test('create: true', () => { expect( @@ -616,7 +628,9 @@ describe(`getGqlTypes() `, () => { .getGqlTypes({ schemaName }) .map(s => print(gql(s))) ).toEqual( - [type, whereInput, whereUniqueInput, createInput, createManyInput].map(s => print(gql(s))) + [type, whereInput, whereUniqueInput, sortTestsBy, createInput, createManyInput].map(s => + print(gql(s)) + ) ); }); test('update: true', () => { @@ -625,7 +639,9 @@ describe(`getGqlTypes() `, () => { .getGqlTypes({ schemaName }) .map(s => print(gql(s))) ).toEqual( - [type, whereInput, whereUniqueInput, updateInput, updateManyInput].map(s => print(gql(s))) + [type, whereInput, whereUniqueInput, sortTestsBy, updateInput, updateManyInput].map(s => + print(gql(s)) + ) ); }); test('delete: true', () => { @@ -633,7 +649,7 @@ describe(`getGqlTypes() `, () => { setup({ access: { read: false, create: false, update: false, delete: true } }) .getGqlTypes({ schemaName }) .map(s => print(gql(s))) - ).toEqual([type, whereInput, whereUniqueInput].map(s => print(gql(s)))); + ).toEqual([type, whereInput, whereUniqueInput, sortTestsBy].map(s => print(gql(s)))); }); }); @@ -642,6 +658,7 @@ test('getGraphqlFilterFragment', () => { expect(list.getGraphqlFilterFragment()).toEqual([ 'where: TestWhereInput', 'search: String', + 'sortBy: [SortTestsBy!]', 'orderBy: String', 'first: Int', 'skip: Int', @@ -661,6 +678,7 @@ describe(`getGqlQueries()`, () => { allTests( where: TestWhereInput search: String + sortBy: [SortTestsBy!] orderBy: String first: Int skip: Int @@ -673,6 +691,7 @@ describe(`getGqlQueries()`, () => { _allTestsMeta( where: TestWhereInput search: String + sortBy: [SortTestsBy!] orderBy: String first: Int skip: Int diff --git a/packages/mongo-join-builder/lib/query-parser.js b/packages/mongo-join-builder/lib/query-parser.js index 41725183ef3..fa8b2b17525 100644 --- a/packages/mongo-join-builder/lib/query-parser.js +++ b/packages/mongo-join-builder/lib/query-parser.js @@ -33,7 +33,7 @@ function queryParser({ listAdapter, getUID }, query, pathSoFar = [], include) { ), { AND: '$and', OR: '$or' }[key] ); - } else if (['$search', '$orderBy', '$skip', '$first', '$count'].includes(key)) { + } else if (['$search', '$sortBy', '$orderBy', '$skip', '$first', '$count'].includes(key)) { return { postJoinPipeline: [modifierTokenizer(listAdapter, query, key, path)] }; } else if (key === 'id') { if (getType(value) === 'Object') { diff --git a/packages/mongo-join-builder/lib/tokenizers.js b/packages/mongo-join-builder/lib/tokenizers.js index 78b4a03a31a..227c7541159 100644 --- a/packages/mongo-join-builder/lib/tokenizers.js +++ b/packages/mongo-join-builder/lib/tokenizers.js @@ -121,6 +121,17 @@ const modifierTokenizer = (listAdapter, query, queryKey, path) => { return { $sort: { [mongoField]: orderDirection === 'DESC' ? -1 : 1 } }; }, + $sortBy: (value, _, listAdapter) => { + const res = {}; + value.map(s => { + const [orderField, orderDirection] = s.split('_'); + const mongoField = listAdapter.graphQlQueryPathToMongoField(orderField); + + res[mongoField] = orderDirection === 'DESC' ? -1 : 1; + }); + + return { $sort: res }; + }, $skip: value => { if (value < Infinity && value > 0) { return { $skip: value }; diff --git a/packages/mongo-join-builder/tests/join-builder.test.js b/packages/mongo-join-builder/tests/join-builder.test.js index 295929fc2d5..4f050144e4c 100644 --- a/packages/mongo-join-builder/tests/join-builder.test.js +++ b/packages/mongo-join-builder/tests/join-builder.test.js @@ -180,7 +180,7 @@ describe('join builder', () => { rel: { cardinality: '1:N', columnName: 'author' }, filterType: 'every', }, - postJoinPipeline: [{ $orderBy: 'title' }], + postJoinPipeline: [{ $sortBy: 'title' }], relationships: [], excludeFields: [], }, @@ -205,7 +205,7 @@ describe('join builder', () => { pipeline: [ { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $addFields: { id: '$_id' } }, - { $orderBy: 'title' }, + { $sortBy: 'title' }, ], }, }, diff --git a/packages/mongo-join-builder/tests/query-parser.test.js b/packages/mongo-join-builder/tests/query-parser.test.js index f24bccc7c02..f3ea58f6357 100644 --- a/packages/mongo-join-builder/tests/query-parser.test.js +++ b/packages/mongo-join-builder/tests/query-parser.test.js @@ -74,7 +74,7 @@ describe('query parser', () => { test('builds a query tree with to-many relationship and other postjoin filters', () => { const queryTree = queryParser( { listAdapter, getUID: jest.fn(key => key) }, - { name: 'foobar', age: 23, $first: 1, posts: { title: 'hello', $orderBy: 'title_ASC' } } + { name: 'foobar', age: 23, $first: 1, posts: { title: 'hello', $sortBy: ['title_ASC'] } } ); expect(queryTree).toMatchObject({