Skip to content

Commit f4543f6

Browse files
authored
feat: add a sortCaseInsensitive option to find and aggregate (#323)
1 parent 26995ab commit f4543f6

File tree

6 files changed

+210
-82
lines changed

6 files changed

+210
-82
lines changed

src/aggregate.js

+22-20
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@ const config = require('./config');
2525
* -paginatedField {String} The field name to query the range for. The field must be:
2626
* 1. Orderable. We must sort by this value. If duplicate values for paginatedField field
2727
* exist, the results will be secondarily ordered by the _id.
28-
* 2. Indexed. For large collections, this should be indexed for query performance.
28+
* 2. Indexed. If the aggregation pipieline can return a large number of documents, this should
29+
* be indexed for query performance.
2930
* 3. Immutable. If the value changes between paged queries, it could appear twice.
3031
* The default is to use the Mongo built-in '_id' field, which satisfies the above criteria.
3132
* The only reason to NOT use the Mongo _id field is if you chose to implement your own ids.
33+
* -sortAscending {boolean} Whether to sort in ascending order by the `paginatedField`.
34+
* -sortCaseInsensitive {boolean} Whether to ignore case when sorting, in which case `paginatedField`
35+
* must be a string property.
3236
* -next {String} The value to start querying the page.
3337
* -previous {String} The value to start querying previous page.
3438
* -after {String} The _id to start querying the page.
@@ -38,31 +42,29 @@ const config = require('./config');
3842
*/
3943
module.exports = async function aggregate(collection, params) {
4044
params = _.defaults(await sanitizeParams(collection, params), { aggregation: [] });
41-
const cursorQuery = generateCursorQuery(params);
42-
const $sort = generateSort(params);
43-
44-
let index = _.findIndex(params.aggregation, (step) => !_.isEmpty(step.$match));
4545

46-
if (index < 0) {
47-
params.aggregation.unshift({ $match: cursorQuery });
48-
index = 0;
49-
} else {
50-
const matchStep = params.aggregation[index];
46+
const $match = generateCursorQuery(params);
47+
const $sort = generateSort(params);
48+
const $limit = params.limit + 1;
5149

52-
params.aggregation[index] = {
53-
$match: {
54-
$and: [cursorQuery, matchStep.$match],
55-
},
56-
};
50+
let addFields = [];
51+
let cleanUp = [];
52+
if (params.sortCaseInsensitive) {
53+
addFields = [{ $addFields: { __lc: { $toLower: '$' + params.paginatedField } } }];
54+
cleanUp = [{ $project: { __lc: 0 } }];
5755
}
58-
59-
params.aggregation.splice(index + 1, 0, { $sort });
60-
params.aggregation.splice(index + 2, 0, { $limit: params.limit + 1 });
56+
const aggregation = params.aggregation.concat([
57+
...addFields,
58+
{ $match },
59+
{ $sort },
60+
{ $limit },
61+
...cleanUp,
62+
]);
6163

6264
// Aggregation options:
6365
// https://mongodb.github.io/node-mongodb-native/3.6/api/Collection.html#aggregate
6466
// https://mongodb.github.io/node-mongodb-native/4.0/interfaces/aggregateoptions.html
65-
const options = params.options || {};
67+
const options = { ...params.options };
6668
/**
6769
* IMPORTANT
6870
*
@@ -77,7 +79,7 @@ module.exports = async function aggregate(collection, params) {
7779
// https://www.npmjs.com/package/mongoist#cursor-operations
7880
const aggregateMethod = collection.aggregateAsCursor ? 'aggregateAsCursor' : 'aggregate';
7981

80-
const results = await collection[aggregateMethod](params.aggregation, options).toArray();
82+
const results = await collection[aggregateMethod](aggregation, options).toArray();
8183

8284
return prepareResponse(results, params);
8385
};

src/find.js

+36-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const _ = require('underscore');
22
const sanitizeParams = require('./utils/sanitizeParams');
33
const { prepareResponse, generateSort, generateCursorQuery } = require('./utils/query');
4+
const aggregate = require('./aggregate');
45
const config = require('./config');
56

67
/**
@@ -21,6 +22,9 @@ const config = require('./config');
2122
* 3. Immutable. If the value changes between paged queries, it could appear twice.
2223
* The default is to use the Mongo built-in '_id' field, which satisfies the above criteria.
2324
* The only reason to NOT use the Mongo _id field is if you chose to implement your own ids.
25+
* -sortAscending {boolean} Whether to sort in ascending order by the `paginatedField`.
26+
* -sortCaseInsensitive {boolean} Whether to ignore case when sorting, in which case `paginatedField`
27+
* must be a string property.
2428
* -next {String} The value to start querying the page.
2529
* -previous {String} The value to start querying previous page.
2630
* -after {String} The _id to start querying the page.
@@ -29,35 +33,45 @@ const config = require('./config');
2933
* -collation {Object} An optional collation to provide to the mongo query. E.g. { locale: 'en', strength: 2 }. When null, disables the global collation.
3034
*/
3135
module.exports = async function(collection, params) {
32-
// Need to repeat `params.paginatedField` default value ('_id') since it's set in 'sanitizeParams()'
3336
const removePaginatedFieldInResponse =
3437
params.fields && !params.fields[params.paginatedField || '_id'];
3538

36-
params = _.defaults(await sanitizeParams(collection, params), { query: {} });
37-
const cursorQuery = generateCursorQuery(params);
38-
const $sort = generateSort(params);
39+
let response;
40+
if (params.sortCaseInsensitive) {
41+
// For case-insensitive sorting, we need to work with an aggregation:
42+
response = aggregate(collection, {
43+
...params,
44+
aggregation: params.query ? [{ $match: params.query }] : [],
45+
});
46+
} else {
47+
// Need to repeat `params.paginatedField` default value ('_id') since it's set in 'sanitizeParams()'
48+
params = _.defaults(await sanitizeParams(collection, params), { query: {} });
3949

40-
// Support both the native 'mongodb' driver and 'mongoist'. See:
41-
// https://www.npmjs.com/package/mongoist#cursor-operations
42-
const findMethod = collection.findAsCursor ? 'findAsCursor' : 'find';
50+
const cursorQuery = generateCursorQuery(params);
51+
const $sort = generateSort(params);
4352

44-
const query = collection[findMethod]({ $and: [cursorQuery, params.query] }, params.fields);
53+
// Support both the native 'mongodb' driver and 'mongoist'. See:
54+
// https://www.npmjs.com/package/mongoist#cursor-operations
55+
const findMethod = collection.findAsCursor ? 'findAsCursor' : 'find';
4556

46-
/**
47-
* IMPORTANT
48-
*
49-
* If using collation, check the README:
50-
* https://github.com/mixmaxhq/mongo-cursor-pagination#important-note-regarding-collation
51-
*/
52-
const isCollationNull = params.collation === null;
53-
const collation = params.collation || config.COLLATION;
54-
const collatedQuery = collation && !isCollationNull ? query.collation(collation) : query;
55-
// Query one more element to see if there's another page.
56-
const cursor = collatedQuery.sort($sort).limit(params.limit + 1);
57-
if (params.hint) cursor.hint(params.hint);
58-
const results = await cursor.toArray();
57+
const query = collection[findMethod]({ $and: [cursorQuery, params.query] }, params.fields);
5958

60-
const response = prepareResponse(results, params);
59+
/**
60+
* IMPORTANT
61+
*
62+
* If using collation, check the README:
63+
* https://github.com/mixmaxhq/mongo-cursor-pagination#important-note-regarding-collation
64+
*/
65+
const isCollationNull = params.collation === null;
66+
const collation = params.collation || config.COLLATION;
67+
const collatedQuery = collation && !isCollationNull ? query.collation(collation) : query;
68+
// Query one more element to see if there's another page.
69+
const cursor = collatedQuery.sort($sort).limit(params.limit + 1);
70+
if (params.hint) cursor.hint(params.hint);
71+
const results = await cursor.toArray();
72+
73+
response = prepareResponse(results, params);
74+
}
6175

6276
// Remove fields that we added to the query (such as paginatedField and _id) that the user didn't ask for.
6377
if (removePaginatedFieldInResponse) {

src/utils/query.js

+24-20
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ const objectPath = require('object-path');
77
* NOTE: this function modifies the passed-in `response` argument directly.
88
*
99
* @param {Object} params
10-
* * @param {String} paginatedField
10+
* @param {String} paginatedField
11+
* @param {boolean} sortCaseInsensitive
12+
*
1113
* @param {Object} response The response
1214
* @param {String?} previous
1315
* @param {String?} next
@@ -18,15 +20,17 @@ function encodePaginationTokens(params, response) {
1820
const shouldSecondarySortOnId = params.paginatedField !== '_id';
1921

2022
if (response.previous) {
21-
const previousPaginatedField = objectPath.get(response.previous, params.paginatedField);
23+
let previousPaginatedField = objectPath.get(response.previous, params.paginatedField);
24+
if (params.sortCaseInsensitive) previousPaginatedField = previousPaginatedField.toLowerCase();
2225
if (shouldSecondarySortOnId) {
2326
response.previous = bsonUrlEncoding.encode([previousPaginatedField, response.previous._id]);
2427
} else {
2528
response.previous = bsonUrlEncoding.encode(previousPaginatedField);
2629
}
2730
}
2831
if (response.next) {
29-
const nextPaginatedField = objectPath.get(response.next, params.paginatedField);
32+
let nextPaginatedField = objectPath.get(response.next, params.paginatedField);
33+
if (params.sortCaseInsensitive) nextPaginatedField = nextPaginatedField.toLowerCase();
3034
if (shouldSecondarySortOnId) {
3135
response.next = bsonUrlEncoding.encode([nextPaginatedField, response.next._id]);
3236
} else {
@@ -82,18 +86,18 @@ module.exports = {
8286
const sortAsc =
8387
(!params.sortAscending && params.previous) || (params.sortAscending && !params.previous);
8488
const sortDir = sortAsc ? 1 : -1;
85-
const shouldSecondarySortOnId = params.paginatedField !== '_id';
8689

87-
if (shouldSecondarySortOnId) {
90+
if (params.paginatedField == '_id') {
8891
return {
89-
[params.paginatedField]: sortDir,
92+
_id: sortDir,
93+
};
94+
} else {
95+
const field = params.sortCaseInsensitive ? '__lc' : params.paginatedField;
96+
return {
97+
[field]: sortDir,
9098
_id: sortDir,
9199
};
92100
}
93-
94-
return {
95-
[params.paginatedField]: sortDir,
96-
};
97101
},
98102

99103
/**
@@ -109,21 +113,27 @@ module.exports = {
109113
const sortAsc =
110114
(!params.sortAscending && params.previous) || (params.sortAscending && !params.previous);
111115
const comparisonOp = sortAsc ? '$gt' : '$lt';
112-
const shouldSecondarySortOnId = params.paginatedField !== '_id';
113116

114117
// a `next` cursor will have precedence over a `previous` cursor.
115118
const op = params.next || params.previous;
116119

117-
if (shouldSecondarySortOnId) {
120+
if (params.paginatedField == '_id') {
121+
return {
122+
_id: {
123+
[comparisonOp]: op,
124+
},
125+
};
126+
} else {
127+
const field = params.sortCaseInsensitive ? '__lc' : params.paginatedField;
118128
return {
119129
$or: [
120130
{
121-
[params.paginatedField]: {
131+
[field]: {
122132
[comparisonOp]: op[0],
123133
},
124134
},
125135
{
126-
[params.paginatedField]: {
136+
[field]: {
127137
$eq: op[0],
128138
},
129139
_id: {
@@ -133,11 +143,5 @@ module.exports = {
133143
],
134144
};
135145
}
136-
137-
return {
138-
[params.paginatedField]: {
139-
[comparisonOp]: op,
140-
},
141-
};
142146
},
143147
};

src/utils/sanitizeParams.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ module.exports = async function sanitizeParams(collection, params) {
3636
);
3737
if (doc) {
3838
// Handle usage of dot notation in paginatedField
39-
const prop = getPropertyViaDotNotation(params.paginatedField, doc);
39+
let prop = getPropertyViaDotNotation(params.paginatedField, doc);
40+
if (params.sortCaseInsensitive) prop = prop.toLowerCase();
4041
params.next = [prop, params.after];
4142
}
4243
} else {
@@ -60,7 +61,8 @@ module.exports = async function sanitizeParams(collection, params) {
6061
);
6162
if (doc) {
6263
// Handle usage of dot notation in paginatedField
63-
const prop = getPropertyViaDotNotation(params.paginatedField, doc);
64+
let prop = getPropertyViaDotNotation(params.paginatedField, doc);
65+
if (params.sortCaseInsensitive) prop = prop.toLowerCase();
6466
params.previous = [prop, params.before];
6567
}
6668
} else {

0 commit comments

Comments
 (0)