Skip to content

Commit 1bf6ef1

Browse files
fix(NODE-4609): allow mapping to falsey non-null values in cursors (#3452)
1 parent 289300d commit 1bf6ef1

File tree

2 files changed

+158
-8
lines changed

2 files changed

+158
-8
lines changed

src/cursor/abstract_cursor.ts

+44-8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { promisify } from 'util';
44
import { BSONSerializeOptions, Document, Long, pluckBSONSerializeOptions } from '../bson';
55
import {
66
AnyError,
7+
MongoAPIError,
78
MongoCursorExhaustedError,
89
MongoCursorInUseError,
910
MongoInvalidArgumentError,
@@ -305,7 +306,18 @@ export abstract class AbstractCursor<
305306
while (true) {
306307
const document = await this.next();
307308

308-
if (document == null) {
309+
// Intentional strict null check, because users can map cursors to falsey values.
310+
// We allow mapping to all values except for null.
311+
// eslint-disable-next-line no-restricted-syntax
312+
if (document === null) {
313+
if (!this.closed) {
314+
const message =
315+
'Cursor returned a `null` document, but the cursor is not exhausted. Mapping documents to `null` is not supported in the cursor transform.';
316+
317+
await cleanupCursorAsync(this, { needsToEmitClosed: true }).catch(() => null);
318+
319+
throw new MongoAPIError(message);
320+
}
309321
break;
310322
}
311323

@@ -504,6 +516,29 @@ export abstract class AbstractCursor<
504516
* this function's transform.
505517
*
506518
* @remarks
519+
*
520+
* **Note** Cursors use `null` internally to indicate that there are no more documents in the cursor. Providing a mapping
521+
* function that maps values to `null` will result in the cursor closing itself before it has finished iterating
522+
* all documents. This will **not** result in a memory leak, just surprising behavior. For example:
523+
*
524+
* ```typescript
525+
* const cursor = collection.find({});
526+
* cursor.map(() => null);
527+
*
528+
* const documents = await cursor.toArray();
529+
* // documents is always [], regardless of how many documents are in the collection.
530+
* ```
531+
*
532+
* Other falsey values are allowed:
533+
*
534+
* ```typescript
535+
* const cursor = collection.find({});
536+
* cursor.map(() => '');
537+
*
538+
* const documents = await cursor.toArray();
539+
* // documents is now an array of empty strings
540+
* ```
541+
*
507542
* **Note for Typescript Users:** adding a transform changes the return type of the iteration of this cursor,
508543
* it **does not** return a new instance of a cursor. This means when calling map,
509544
* you should always assign the result to a new variable in order to get a correctly typed cursor variable.
@@ -657,7 +692,7 @@ export abstract class AbstractCursor<
657692
* a significant refactor.
658693
*/
659694
[kInit](callback: Callback<TSchema | null>): void {
660-
this._initialize(this[kSession], (err, state) => {
695+
this._initialize(this[kSession], (error, state) => {
661696
if (state) {
662697
const response = state.response;
663698
this[kServer] = state.server;
@@ -689,8 +724,12 @@ export abstract class AbstractCursor<
689724
// the cursor is now initialized, even if an error occurred or it is dead
690725
this[kInitialized] = true;
691726

692-
if (err || cursorIsDead(this)) {
693-
return cleanupCursor(this, { error: err }, () => callback(err, nextDocument(this)));
727+
if (error) {
728+
return cleanupCursor(this, { error }, () => callback(error, undefined));
729+
}
730+
731+
if (cursorIsDead(this)) {
732+
return cleanupCursor(this, undefined, () => callback());
694733
}
695734

696735
callback();
@@ -743,11 +782,8 @@ export function next<T>(
743782

744783
if (cursorId == null) {
745784
// All cursors must operate within a session, one must be made implicitly if not explicitly provided
746-
cursor[kInit]((err, value) => {
785+
cursor[kInit](err => {
747786
if (err) return callback(err);
748-
if (value) {
749-
return callback(undefined, value);
750-
}
751787
return next(cursor, blocking, callback);
752788
});
753789

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect } from 'chai';
2+
import { inspect } from 'util';
3+
4+
import { Collection, MongoAPIError, MongoClient } from '../../../src';
5+
6+
const falseyValues = [0, 0n, NaN, '', false, undefined];
7+
8+
describe('class AbstractCursor', function () {
9+
let client: MongoClient;
10+
11+
let collection: Collection;
12+
beforeEach(async function () {
13+
client = this.configuration.newClient();
14+
15+
collection = client.db('abstract_cursor_integration').collection('test');
16+
17+
await collection.insertMany(Array.from({ length: 5 }, (_, index) => ({ index })));
18+
});
19+
20+
afterEach(async function () {
21+
await collection.deleteMany({});
22+
await client.close();
23+
});
24+
25+
context('toArray() with custom transforms', function () {
26+
for (const value of falseyValues) {
27+
it(`supports mapping to falsey value '${inspect(value)}'`, async function () {
28+
const cursor = collection.find();
29+
cursor.map(() => value);
30+
31+
const result = await cursor.toArray();
32+
33+
const expected = Array.from({ length: 5 }, () => value);
34+
expect(result).to.deep.equal(expected);
35+
});
36+
}
37+
38+
it('throws when mapping to `null` and cleans up cursor', async function () {
39+
const cursor = collection.find();
40+
cursor.map(() => null);
41+
42+
const error = await cursor.toArray().catch(e => e);
43+
44+
expect(error).be.instanceOf(MongoAPIError);
45+
expect(cursor.closed).to.be.true;
46+
});
47+
});
48+
49+
context('Symbol.asyncIterator() with custom transforms', function () {
50+
for (const value of falseyValues) {
51+
it(`supports mapping to falsey value '${inspect(value)}'`, async function () {
52+
const cursor = collection.find();
53+
cursor.map(() => value);
54+
55+
let count = 0;
56+
57+
for await (const document of cursor) {
58+
expect(document).to.deep.equal(value);
59+
count++;
60+
}
61+
62+
expect(count).to.equal(5);
63+
});
64+
}
65+
66+
it('throws when mapping to `null` and cleans up cursor', async function () {
67+
const cursor = collection.find();
68+
cursor.map(() => null);
69+
70+
try {
71+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
72+
for await (const document of cursor) {
73+
expect.fail('Expected error to be thrown');
74+
}
75+
} catch (error) {
76+
expect(error).to.be.instanceOf(MongoAPIError);
77+
expect(cursor.closed).to.be.true;
78+
}
79+
});
80+
});
81+
82+
context('forEach() with custom transforms', function () {
83+
for (const value of falseyValues) {
84+
it(`supports mapping to falsey value '${inspect(value)}'`, async function () {
85+
const cursor = collection.find();
86+
cursor.map(() => value);
87+
88+
let count = 0;
89+
90+
function transform(value) {
91+
expect(value).to.deep.equal(value);
92+
count++;
93+
}
94+
95+
await cursor.forEach(transform);
96+
97+
expect(count).to.equal(5);
98+
});
99+
}
100+
101+
it('throws when mapping to `null` and cleans up cursor', async function () {
102+
const cursor = collection.find();
103+
cursor.map(() => null);
104+
105+
function iterator() {
106+
expect.fail('Expected no documents from cursor, received at least one.');
107+
}
108+
109+
const error = await cursor.forEach(iterator).catch(e => e);
110+
expect(error).to.be.instanceOf(MongoAPIError);
111+
expect(cursor.closed).to.be.true;
112+
});
113+
});
114+
});

0 commit comments

Comments
 (0)