Skip to content

Commit 0bcdaf0

Browse files
authored
Merge pull request #329 from mixmaxhq/jordi/COMM-185-paginate-null-values
fix: properly page through undefs and nulls
2 parents 649c710 + 0eb28e7 commit 0bcdaf0

9 files changed

+361
-74
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Call `find()` with the following parameters:
5454
exist, the results will be secondarily ordered by the _id.
5555
2. Indexed. For large collections, this should be indexed for query performance.
5656
3. Immutable. If the value changes between paged queries, it could appear twice.
57-
4. Complete. A value must exist for all documents.
57+
4. Consistent. All values (except undefined and null values) must be of the same type.
5858
The default is to use the Mongo built-in '_id' field, which satisfies the above criteria.
5959
The only reason to NOT use the Mongo _id field is if you chose to implement your own ids.
6060
-sortAscending {Boolean} True to sort using paginatedField ascending (default is false - descending).

package-lock.json

+17-29
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"homepage": "https://github.com/mixmaxhq/mongo-cursor-pagination#readme",
4040
"dependencies": {
4141
"base64-url": "^2.2.0",
42-
"bson": "^4.1.0",
42+
"bson": "^4.7.0",
4343
"object-path": "^0.11.5",
4444
"projection-utils": "^1.1.0",
4545
"semver": "^5.4.1",
@@ -61,7 +61,7 @@
6161
"mockgoose": "^8.0.4",
6262
"mongodb": "^2.2.11",
6363
"mongodb-memory-server": "^5.2.11",
64-
"mongoist": "2.3.0",
64+
"mongoist": "^2.5.5",
6565
"mongoose": "5.11.10",
6666
"prettier": "^1.19.1",
6767
"semantic-release": "^17.2.3"

src/aggregate.js

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const config = require('./config');
2828
* 2. Immutable. If the value changes between paged queries, it could appear twice.
2929
* 3. Accessible. The field must be present in the aggregation's end result so the
3030
* aggregation steps added at the end of the pipeline to implement the paging can access it.
31+
4. Consistent. All values (except undefined and null values) must be of the same type.
3132
* The default is to use the Mongo built-in '_id' field, which satisfies the above criteria.
3233
* The only reason to NOT use the Mongo _id field is if you chose to implement your own ids.
3334
* -sortAscending {boolean} Whether to sort in ascending order by the `paginatedField`.

src/find.js

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const config = require('./config');
2020
* exist, the results will be secondarily ordered by the _id.
2121
* 2. Indexed. For large collections, this should be indexed for query performance.
2222
* 3. Immutable. If the value changes between paged queries, it could appear twice.
23+
4. Consistent. All values (except undefined and null values) must be of the same type.
2324
* The default is to use the Mongo built-in '_id' field, which satisfies the above criteria.
2425
* The only reason to NOT use the Mongo _id field is if you chose to implement your own ids.
2526
* -sortAscending {boolean} Whether to sort in ascending order by the `paginatedField`.

src/utils/bsonUrlEncoding.js

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
const { EJSON } = require('bson');
22
const base64url = require('base64-url');
33

4+
// BSON can't encode undefined values, so we will use this value instead:
5+
const BSON_UNDEFINED = '__mixmax__undefined__';
6+
47
/**
5-
* These will take a BSON object (an database result returned by the MongoDB library) and
6-
* encode/decode as a URL-safe string.
8+
* These will take a paging handle (`next` or `previous`) and encode/decode it
9+
* as a string which can be passed in a URL.
710
*/
811

912
module.exports.encode = function(obj) {
13+
if (Array.isArray(obj) && obj[0] === undefined) obj[0] = BSON_UNDEFINED;
1014
return base64url.encode(EJSON.stringify(obj));
1115
};
1216

1317
module.exports.decode = function(str) {
14-
return EJSON.parse(base64url.decode(str));
18+
const obj = EJSON.parse(base64url.decode(str));
19+
if (Array.isArray(obj) && obj[0] === BSON_UNDEFINED) obj[0] = undefined;
20+
return obj;
1521
};

src/utils/query.js

+83-25
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ function encodePaginationTokens(params, response) {
2121

2222
if (response.previous) {
2323
let previousPaginatedField = objectPath.get(response.previous, params.paginatedField);
24-
if (params.sortCaseInsensitive) previousPaginatedField = previousPaginatedField.toLowerCase();
24+
if (params.sortCaseInsensitive) {
25+
previousPaginatedField = previousPaginatedField?.toLowerCase?.() ?? '';
26+
}
2527
if (shouldSecondarySortOnId) {
2628
response.previous = bsonUrlEncoding.encode([previousPaginatedField, response.previous._id]);
2729
} else {
@@ -30,7 +32,9 @@ function encodePaginationTokens(params, response) {
3032
}
3133
if (response.next) {
3234
let nextPaginatedField = objectPath.get(response.next, params.paginatedField);
33-
if (params.sortCaseInsensitive) nextPaginatedField = nextPaginatedField.toLowerCase();
35+
if (params.sortCaseInsensitive) {
36+
nextPaginatedField = nextPaginatedField?.toLowerCase?.() ?? '';
37+
}
3438
if (shouldSecondarySortOnId) {
3539
response.next = bsonUrlEncoding.encode([nextPaginatedField, response.next._id]);
3640
} else {
@@ -112,36 +116,90 @@ module.exports = {
112116

113117
const sortAsc =
114118
(!params.sortAscending && params.previous) || (params.sortAscending && !params.previous);
115-
const comparisonOp = sortAsc ? '$gt' : '$lt';
116119

117120
// a `next` cursor will have precedence over a `previous` cursor.
118121
const op = params.next || params.previous;
119122

120123
if (params.paginatedField == '_id') {
121-
return {
122-
_id: {
123-
[comparisonOp]: op,
124-
},
125-
};
124+
if (sortAsc) {
125+
return { _id: { $gt: op } };
126+
} else {
127+
return { _id: { $lt: op } };
128+
}
126129
} else {
127130
const field = params.sortCaseInsensitive ? '__lc' : params.paginatedField;
128-
return {
129-
$or: [
130-
{
131-
[field]: {
132-
[comparisonOp]: op[0],
133-
},
134-
},
135-
{
136-
[field]: {
137-
$eq: op[0],
138-
},
139-
_id: {
140-
[comparisonOp]: op[1],
141-
},
142-
},
143-
],
144-
};
131+
132+
const notUndefined = { [field]: { $exists: true } };
133+
const onlyUndefs = { [field]: { $exists: false } };
134+
const notNullNorUndefined = { [field]: { $ne: null } };
135+
const nullOrUndefined = { [field]: null };
136+
const onlyNulls = { $and: [{ [field]: { $exists: true } }, { [field]: null }] };
137+
138+
const [paginatedFieldValue, idValue] = op;
139+
switch (paginatedFieldValue) {
140+
case null:
141+
if (sortAsc) {
142+
return {
143+
$or: [
144+
notNullNorUndefined,
145+
{
146+
...onlyNulls,
147+
_id: { $gt: idValue },
148+
},
149+
],
150+
};
151+
} else {
152+
return {
153+
$or: [
154+
onlyUndefs,
155+
{
156+
...onlyNulls,
157+
_id: { $lt: idValue },
158+
},
159+
],
160+
};
161+
}
162+
case undefined:
163+
if (sortAsc) {
164+
return {
165+
$or: [
166+
notUndefined,
167+
{
168+
...onlyUndefs,
169+
_id: { $gt: idValue },
170+
},
171+
],
172+
};
173+
} else {
174+
return {
175+
...onlyUndefs,
176+
_id: { $lt: idValue },
177+
};
178+
}
179+
default:
180+
if (sortAsc) {
181+
return {
182+
$or: [
183+
{ [field]: { $gt: paginatedFieldValue } },
184+
{
185+
[field]: { $eq: paginatedFieldValue },
186+
_id: { $gt: idValue },
187+
},
188+
],
189+
};
190+
} else {
191+
return {
192+
$or: [
193+
{ [field]: { $lt: paginatedFieldValue } },
194+
nullOrUndefined,
195+
{
196+
[field]: { $eq: paginatedFieldValue },
197+
_id: { $lt: idValue },
198+
},
199+
],
200+
};
201+
}
202+
}
145203
}
146204
},
147205
};

0 commit comments

Comments
 (0)