diff --git a/.changeset/chatty-pens-compare.md b/.changeset/chatty-pens-compare.md new file mode 100644 index 000000000..dbed8f174 --- /dev/null +++ b/.changeset/chatty-pens-compare.md @@ -0,0 +1,8 @@ +--- +'contexture-elasticsearch': minor +'contexture-mongo': minor +'contexture-client': minor +'contexture': minor +--- + +Support sorting on multiple fields diff --git a/packages/client/src/exampleTypes.js b/packages/client/src/exampleTypes.js index 255abb64e..907a96f13 100644 --- a/packages/client/src/exampleTypes.js +++ b/packages/client/src/exampleTypes.js @@ -147,6 +147,7 @@ export default F.stampKey('type', { reactors: { page: 'self', pageSize: 'self', + sort: 'self', sortField: 'self', sortDir: 'self', include: 'self', diff --git a/packages/export/README.md b/packages/export/README.md index 72a31ccaa..e407dfbfc 100644 --- a/packages/export/README.md +++ b/packages/export/README.md @@ -80,12 +80,10 @@ await csv( - `include`: An array with the list of fields that will be included on each retrieved record. This is relevant to the `results` type. It's undefined by default (which is valid). - - `sortField`: Specifies what field will be used to sort the data. - This is relevant to the `results` type. It's undefined by default - (which is valid). - - `sortDir`: Specifies in which direction the data will be sorted - (`asc` or `desc`). This is relevant to the `results` type. It's - undefined by default (which is valid). + - `sort`: Specifies which fields will be used to sort the data. + This is relevant to the `results` type. It's undefined by default. + - `sortField`: Deprecated, use `sort`. + - `sortDir`: Deprecated, use `sort`. - `pageSize`: It allows you to specify how many records per page (per call of `getNext`) are returned. It defaults to 100. - `page`: Indicates the starting page of the specified search. diff --git a/packages/provider-elasticsearch/src/example-types/results/index.js b/packages/provider-elasticsearch/src/example-types/results/index.js index e3eccc68b..44807c870 100644 --- a/packages/provider-elasticsearch/src/example-types/results/index.js +++ b/packages/provider-elasticsearch/src/example-types/results/index.js @@ -1,14 +1,29 @@ import F from 'futil' +import _ from 'lodash/fp.js' import { getField } from '../../utils/fields.js' import { searchWithHighlights } from './highlighting/search.js' +export let getSortParameter = ({ sort, sortField, sortDir }, schema) => { + if (!_.isEmpty(sort)) { + return Object.fromEntries( + sort.map(({ field, desc }) => [ + getField(schema, field), + desc ? 'desc' : 'asc', + ]) + ) + } + if (sortField) { + return { [getField(schema, sortField)]: sortDir || 'asc' } + } + return { _score: 'desc' } +} + export default { validContext: () => true, async result(node, search, schema) { let page = (node.page || 1) - 1 let pageSize = node.pageSize || 10 let startRecord = page * pageSize - let sortField = node.sortField ? getField(schema, node.sortField) : '_score' search = node.highlight?.disable ? search @@ -18,7 +33,7 @@ export default { F.omitBlank({ from: startRecord, size: pageSize, - sort: { [sortField]: node.sortDir || 'desc' }, + sort: getSortParameter(node, schema), explain: node.explain, // Without this, ES7+ stops counting at 10k instead of returning the actual count track_total_hits: true, diff --git a/packages/provider-elasticsearch/src/example-types/results/index.test.js b/packages/provider-elasticsearch/src/example-types/results/index.test.js new file mode 100644 index 000000000..2b46e8b7b --- /dev/null +++ b/packages/provider-elasticsearch/src/example-types/results/index.test.js @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { getSortParameter } from './index.js' + +describe(getSortParameter, () => { + it('defaults to sorting on score', () => { + const actual = getSortParameter({}) + const expected = { _score: 'desc' } + expect(actual).toEqual(expected) + }) + + it('sort on multiple fields', () => { + const actual = getSortParameter({ + sort: [{ field: 'name' }, { field: 'age', desc: true }], + sortField: 'city', + sortDir: 'asc', + }) + const expected = { name: 'asc', age: 'desc' } + expect(actual).toEqual(expected) + }) + + it('sort on multiple subfields', () => { + const actual = getSortParameter( + { + sort: [{ field: 'name' }, { field: 'age', desc: true }], + sortField: 'city', + sortDir: 'asc', + }, + { + fields: { + name: { elasticsearch: { notAnalyzedField: 'keyword' } }, + age: { elasticsearch: { notAnalyzedField: 'keyword' } }, + }, + } + ) + const expected = { 'name.keyword': 'asc', 'age.keyword': 'desc' } + expect(actual).toEqual(expected) + }) + + it('legacy sort on single field', () => { + const actual = getSortParameter({ + sortField: 'name', + sortDir: 'asc', + }) + const expected = { name: 'asc' } + expect(actual).toEqual(expected) + }) + + it('legacy sort on single subfield', () => { + const actual = getSortParameter( + { + sortField: 'name', + sortDir: 'asc', + }, + { + fields: { + name: { elasticsearch: { notAnalyzedField: 'keyword' } }, + }, + } + ) + const expected = { 'name.keyword': 'asc' } + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/provider-mongo/src/example-types/results.js b/packages/provider-mongo/src/example-types/results.js index 1fcc3d0ab..d770766f7 100644 --- a/packages/provider-mongo/src/example-types/results.js +++ b/packages/provider-mongo/src/example-types/results.js @@ -112,26 +112,40 @@ let projectFromInclude = (include) => _.countBy(_.identity) )(include) +export let getSortStage = ({ sort, sortField, sortDir }) => { + if (!_.isEmpty(sort)) { + return [ + { + $sort: Object.fromEntries( + sort.map(({ field, desc }) => [field, desc ? -1 : 1]) + ), + }, + ] + } + if (sortField) { + return [{ $sort: { [sortField]: sortDir === 'asc' ? 1 : -1 } }] + } + return [] +} + let getResultsQuery = (node, getSchema, startRecord) => { - let { pageSize, sortField, sortDir, populate, include, skipCount } = node + let { pageSize, sortField, sort, populate, include, skipCount } = node // $sort, $skip, $limit - let $sort = { - $sort: { - [sortField]: sortDir === 'asc' ? 1 : -1, - }, - } + let $sort = getSortStage(node) let $limit = { $limit: F.when(skipCount, _.add(1), pageSize) } - let sort = _.compact([sortField && $sort]) let skipLimit = _.compact([{ $skip: startRecord }, pageSize > 0 && $limit]) - let sortSkipLimit = _.compact([...sort, ...skipLimit]) - // If sort field is a join field move $sort, $skip, and $limit to after $lookup. - // Otherwise, place those stages first to take advantage of any indexes on that field. + let sortSkipLimit = _.compact([...$sort, ...skipLimit]) + // If any sort fields is a join field move $sort, $skip, and $limit to after + // $lookup. Otherwise, place those stages first to take advantage of any + // indexes on the sort fields. let sortOnJoinField = _.some((x) => { let lookupField = _.getOr(x, `${x}.as`, populate) - return ( - _.startsWith(`${lookupField}.`, sortField) || sortField === lookupField + return _.some( + ({ field: sortField }) => + _.startsWith(`${lookupField}.`, sortField) || sortField === lookupField, + sort ?? [{ field: sortField }] ) }, _.keys(populate)) // check if any of the "populate" fields are indicating they can have more than one record @@ -143,8 +157,9 @@ let getResultsQuery = (node, getSchema, startRecord) => { return [ ...(!sortOnJoinField && !hasMany ? sortSkipLimit : []), - // if "hasMany" is set on a "populate" field but we are not sorting on a "populate" field, sort as early as possible - ...(hasMany && !sortOnJoinField ? sort : []), + // if "hasMany" is set on a "populate" field but we are not sorting on a + // "populate" field, sort as early as possible + ...(hasMany && !sortOnJoinField ? $sort : []), ...convertPopulate(getSchema)(populate), ...(sortOnJoinField ? sortSkipLimit : []), ...(hasMany && !sortOnJoinField ? skipLimit : []), diff --git a/packages/provider-mongo/src/example-types/results.test.js b/packages/provider-mongo/src/example-types/results.test.js index ad0178379..5535ad1b4 100644 --- a/packages/provider-mongo/src/example-types/results.test.js +++ b/packages/provider-mongo/src/example-types/results.test.js @@ -1,5 +1,5 @@ import F from 'futil' -import result from './results.js' +import result, { getSortStage } from './results.js' import { describe, expect, it } from 'vitest' let { @@ -549,3 +549,24 @@ describe('results', () => { }) }) }) + +describe(getSortStage, () => { + it('sort on multiple fields', () => { + const actual = getSortStage({ + sort: [{ field: 'name' }, { field: 'age', desc: true }], + sortField: 'city', + sortDir: 'asc', + }) + const expected = [{ $sort: { name: 1, age: -1 } }] + expect(actual).toEqual(expected) + }) + + it('legacy sort on single field', () => { + const actual = getSortStage({ + sortField: 'name', + sortDir: 'asc', + }) + const expected = [{ $sort: { name: 1 } }] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/server/src/provider-memory/results.js b/packages/server/src/provider-memory/results.js index 8add9a3fd..3697ffd3c 100644 --- a/packages/server/src/provider-memory/results.js +++ b/packages/server/src/provider-memory/results.js @@ -2,13 +2,18 @@ import _ from 'lodash/fp.js' export default { result: ( - { pageSize = 10, page = 1, sortField, sortDir = 'desc' }, + { pageSize = 10, page = 1, sort, sortField, sortDir = 'desc' }, search ) => ({ totalRecords: search(_.size), results: search( _.flow( - _.orderBy(sortField, sortDir), + sort + ? _.orderBy( + sort.map('field'), + sort.map(({ desc }) => (desc ? 'desc' : 'asc')) + ) + : _.orderBy(sortField, sortDir), pageSize > 0 ? _.slice((page - 1) * pageSize, page * pageSize) : _.identity