Skip to content

Commit 48d6da9

Browse files
authored
fix(NODE-3454): projection types are too narrow (#2924)
1 parent 4ecaa37 commit 48d6da9

File tree

7 files changed

+150
-46
lines changed

7 files changed

+150
-46
lines changed

src/collection.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -676,27 +676,27 @@ export class Collection<TSchema extends Document = Document> {
676676
findOne(callback: Callback<TSchema | undefined>): void;
677677
findOne(filter: Filter<TSchema>): Promise<TSchema | undefined>;
678678
findOne(filter: Filter<TSchema>, callback: Callback<TSchema | undefined>): void;
679-
findOne(filter: Filter<TSchema>, options: FindOptions<TSchema>): Promise<TSchema | undefined>;
679+
findOne(filter: Filter<TSchema>, options: FindOptions): Promise<TSchema | undefined>;
680680
findOne(
681681
filter: Filter<TSchema>,
682-
options: FindOptions<TSchema>,
682+
options: FindOptions,
683683
callback: Callback<TSchema | undefined>
684684
): void;
685685

686686
// allow an override of the schema.
687687
findOne<T = TSchema>(): Promise<T | undefined>;
688688
findOne<T = TSchema>(callback: Callback<T | undefined>): void;
689689
findOne<T = TSchema>(filter: Filter<T>): Promise<T | undefined>;
690-
findOne<T = TSchema>(filter: Filter<T>, options?: FindOptions<T>): Promise<T | undefined>;
690+
findOne<T = TSchema>(filter: Filter<T>, options?: FindOptions): Promise<T | undefined>;
691691
findOne<T = TSchema>(
692692
filter: Filter<T>,
693-
options?: FindOptions<T>,
693+
options?: FindOptions,
694694
callback?: Callback<T | undefined>
695695
): void;
696696

697697
findOne(
698698
filter?: Filter<TSchema> | Callback<TSchema | undefined>,
699-
options?: FindOptions<TSchema> | Callback<TSchema | undefined>,
699+
options?: FindOptions | Callback<TSchema | undefined>,
700700
callback?: Callback<TSchema>
701701
): Promise<TSchema | undefined> | void {
702702
if (callback != null && typeof callback !== 'function') {
@@ -728,9 +728,9 @@ export class Collection<TSchema extends Document = Document> {
728728
* @param filter - The filter predicate. If unspecified, then all documents in the collection will match the predicate
729729
*/
730730
find(): FindCursor<TSchema>;
731-
find(filter: Filter<TSchema>, options?: FindOptions<TSchema>): FindCursor<TSchema>;
732-
find<T = TSchema>(filter: Filter<T>, options?: FindOptions<T>): FindCursor<T>;
733-
find(filter?: Filter<TSchema>, options?: FindOptions<TSchema>): FindCursor<TSchema> {
731+
find(filter: Filter<TSchema>, options?: FindOptions): FindCursor<TSchema>;
732+
find<T = TSchema>(filter: Filter<T>, options?: FindOptions): FindCursor<T>;
733+
find(filter?: Filter<TSchema>, options?: FindOptions): FindCursor<TSchema> {
734734
if (arguments.length > 2) {
735735
throw new MongoInvalidArgumentError(
736736
'Method "collection.find()" accepts at most two arguments'

src/cursor/aggregation_cursor.ts

+26-6
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import type { Callback, MongoDBNamespace } from '../utils';
99
import type { ClientSession } from '../sessions';
1010
import type { AbstractCursorOptions } from './abstract_cursor';
1111
import type { ExplainVerbosityLike } from '../explain';
12-
import type { Projection } from '../mongo_types';
1312

1413
/** @public */
1514
export interface AggregationCursorOptions extends AbstractCursorOptions, AggregateOptions {}
@@ -135,22 +134,43 @@ export class AggregationCursor<TSchema = Document> extends AbstractCursor<TSchem
135134
* In order to strictly type this function you must provide an interface
136135
* that represents the effect of your projection on the result documents.
137136
*
138-
* **NOTE:** adding a projection changes the return type of the iteration of this cursor,
137+
* By default chaining a projection to your cursor changes the returned type to the generic {@link Document} type.
138+
* You should specify a parameterized type to have assertions on your final results.
139+
*
140+
* @example
141+
* ```typescript
142+
* // Best way
143+
* const docs: AggregationCursor<{ a: number }> = cursor.project<{ a: number }>({ _id: 0, a: true });
144+
* // Flexible way
145+
* const docs: AggregationCursor<Document> = cursor.project({ _id: 0, a: true });
146+
* ```
147+
*
148+
* @remarks
149+
* In order to strictly type this function you must provide an interface
150+
* that represents the effect of your projection on the result documents.
151+
*
152+
* Adding a projection changes the return type of the iteration of this cursor,
139153
* it **does not** return a new instance of a cursor. This means when calling project,
140154
* you should always assign the result to a new variable. Take note of the following example:
141155
*
142156
* @example
143157
* ```typescript
144158
* const cursor: AggregationCursor<{ a: number; b: string }> = coll.aggregate([]);
145-
* const projectCursor = cursor.project<{ a: number }>({ a: true });
159+
* const projectCursor = cursor.project<{ a: number }>({ _id: 0, a: true });
146160
* const aPropOnlyArray: {a: number}[] = await projectCursor.toArray();
161+
*
162+
* // or always use chaining and save the final cursor
163+
*
164+
* const cursor = coll.aggregate().project<{ a: string }>({
165+
* _id: 0,
166+
* a: { $convert: { input: '$a', to: 'string' }
167+
* }});
147168
* ```
148169
*/
149-
project<T = TSchema>($project: Projection<T>): AggregationCursor<T>;
150-
project($project: Document): this {
170+
project<T extends Document = Document>($project: Document): AggregationCursor<T> {
151171
assertUninitialized(this);
152172
this[kPipeline].push({ $project });
153-
return this;
173+
return (this as unknown) as AggregationCursor<T>;
154174
}
155175

156176
/** Add a lookup stage to the aggregation pipeline */

src/cursor/find_cursor.ts

+25-6
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type { ClientSession } from '../sessions';
1212
import { formatSort, Sort, SortDirection } from '../sort';
1313
import type { Callback, MongoDBNamespace } from '../utils';
1414
import { AbstractCursor, assertUninitialized } from './abstract_cursor';
15-
import type { Projection } from '../mongo_types';
1615

1716
/** @internal */
1817
const kFilter = Symbol('filter');
@@ -344,22 +343,42 @@ export class FindCursor<TSchema = Document> extends AbstractCursor<TSchema> {
344343
* In order to strictly type this function you must provide an interface
345344
* that represents the effect of your projection on the result documents.
346345
*
347-
* **NOTE:** adding a projection changes the return type of the iteration of this cursor,
346+
* By default chaining a projection to your cursor changes the returned type to the generic
347+
* {@link Document} type.
348+
* You should specify a parameterized type to have assertions on your final results.
349+
*
350+
* @example
351+
* ```typescript
352+
* // Best way
353+
* const docs: FindCursor<{ a: number }> = cursor.project<{ a: number }>({ _id: 0, a: true });
354+
* // Flexible way
355+
* const docs: FindCursor<Document> = cursor.project({ _id: 0, a: true });
356+
* ```
357+
*
358+
* @remarks
359+
*
360+
* Adding a projection changes the return type of the iteration of this cursor,
348361
* it **does not** return a new instance of a cursor. This means when calling project,
349362
* you should always assign the result to a new variable. Take note of the following example:
350363
*
351364
* @example
352365
* ```typescript
353366
* const cursor: FindCursor<{ a: number; b: string }> = coll.find();
354-
* const projectCursor = cursor.project<{ a: number }>({ a: true });
367+
* const projectCursor = cursor.project<{ a: number }>({ _id: 0, a: true });
355368
* const aPropOnlyArray: {a: number}[] = await projectCursor.toArray();
369+
*
370+
* // or always use chaining and save the final cursor
371+
*
372+
* const cursor = coll.find().project<{ a: string }>({
373+
* _id: 0,
374+
* a: { $convert: { input: '$a', to: 'string' }
375+
* }});
356376
* ```
357377
*/
358-
project<T = TSchema>(value: Projection<T>): FindCursor<T>;
359-
project(value: Projection<TSchema>): this {
378+
project<T extends Document = Document>(value: Document): FindCursor<T> {
360379
assertUninitialized(this);
361380
this[kBuiltOptions].projection = value;
362-
return this;
381+
return (this as unknown) as FindCursor<T>;
363382
}
364383

365384
/**

src/mongo_types.ts

+12-13
Original file line numberDiff line numberDiff line change
@@ -169,20 +169,19 @@ export type BSONType = typeof BSONType[keyof typeof BSONType];
169169
/** @public */
170170
export type BSONTypeAlias = keyof typeof BSONType;
171171

172-
/** @public */
173-
export interface ProjectionOperators extends Document {
174-
$elemMatch?: Document;
175-
$slice?: number | [number, number];
176-
$meta?: string;
177-
/** @deprecated Since MongoDB 3.2, Use FindCursor#max */
178-
$max?: any;
179-
}
172+
/**
173+
* @public
174+
* Projection is flexible to permit the wide array of aggregation operators
175+
* @deprecated since v4.1.0: Since projections support all aggregation operations we have no plans to narrow this type further
176+
*/
177+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
178+
export type Projection<TSchema extends Document = Document> = Document;
180179

181-
/** @public */
182-
export type Projection<TSchema> = {
183-
[Key in keyof TSchema]?: ProjectionOperators | 0 | 1 | boolean;
184-
} &
185-
Partial<Record<string, ProjectionOperators | 0 | 1 | boolean>>;
180+
/**
181+
* @public
182+
* @deprecated since v4.1.0: Since projections support all aggregation operations we have no plans to narrow this type further
183+
*/
184+
export type ProjectionOperators = Document;
186185

187186
/** @public */
188187
export type IsAny<Type, ResultIfAny, ResultIfNotAny> = true extends false & Type

src/operations/find.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@ import { Sort, formatSort } from '../sort';
1515
import { isSharded } from '../cmap/wire_protocol/shared';
1616
import { ReadConcern } from '../read_concern';
1717
import type { ClientSession } from '../sessions';
18-
import type { Projection } from '../mongo_types';
1918

20-
/** @public */
21-
export interface FindOptions<TSchema = Document> extends CommandOperationOptions {
19+
/**
20+
* @public
21+
* @typeParam TSchema - Unused schema definition, deprecated usage, only specify `FindOptions` with no generic
22+
*/
23+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
24+
export interface FindOptions<TSchema extends Document = Document> extends CommandOperationOptions {
2225
/** Sets the limit of documents returned in the query. */
2326
limit?: number;
2427
/** Set to sort the documents coming back from the query. Array of indexes, `[['a', 1]]` etc. */
2528
sort?: Sort;
2629
/** The fields to return in the query. Object of fields to either include or exclude (one of, not both), `{'a':1, 'b': 1}` **or** `{'a': 0, 'b': 0}` */
27-
projection?: Projection<TSchema>;
30+
projection?: Document;
2831
/** Set to skip N documents ahead in your query (useful for pagination). */
2932
skip?: number;
3033
/** Tell the query to use specific indexes in the query. Object of indexes to use, `{'_id':1}` */

test/types/community/collection/findX.test-d.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { expectNotType, expectType } from 'tsd';
1+
import { expectAssignable, expectNotType, expectType } from 'tsd';
22
import { FindCursor, FindOptions, MongoClient, Document } from '../../../../src';
3+
import type { Projection, ProjectionOperators } from '../../../../src';
34
import type { PropExists } from '../../utility_types';
45

56
// collection.findX tests
@@ -35,7 +36,7 @@ await collectionT.findOne(
3536
}
3637
);
3738

38-
const optionsWithComplexProjection: FindOptions<TestModel> = {
39+
const optionsWithComplexProjection: FindOptions = {
3940
projection: {
4041
stringField: { $meta: 'textScore' },
4142
fruitTags: { $min: 'fruitTags' },
@@ -120,14 +121,15 @@ function printCar(car: Car | undefined) {
120121
console.log(car ? `A car of ${car.make} make` : 'No car');
121122
}
122123

123-
const options: FindOptions<Car> = {};
124-
const optionsWithProjection: FindOptions<Car> = {
124+
const options: FindOptions = {};
125+
const optionsWithProjection: FindOptions = {
125126
projection: {
126127
make: 1
127128
}
128129
};
129130

130-
expectNotType<FindOptions<Car>>({
131+
// this is changed in NODE-3454 to be the opposite test since Projection is flexible now
132+
expectAssignable<FindOptions>({
131133
projection: {
132134
make: 'invalid'
133135
}
@@ -190,3 +192,9 @@ expectType<FindCursor<{ color: { $in: number } }>>(colorCollection.find({ color:
190192
// When you use the override, $in doesn't permit readonly
191193
colorCollection.find<{ color: string }>({ color: { $in: colorsFreeze } });
192194
colorCollection.find<{ color: string }>({ color: { $in: ['regularArray'] } });
195+
// This is a regression test that we don't remove the unused generic in FindOptions
196+
const findOptions: FindOptions<{ a: number }> = {};
197+
expectType<FindOptions>(findOptions);
198+
// This is just to check that we still export these type symbols
199+
expectAssignable<Projection>({});
200+
expectAssignable<ProjectionOperators>({});

test/types/community/cursor.test-d.ts

+59-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Readable } from 'stream';
22
import { expectNotType, expectType } from 'tsd';
3-
import { FindCursor, MongoClient } from '../../../src/index';
3+
import { FindCursor, MongoClient, Db, Document } from '../../../src/index';
44

55
// TODO(NODE-3346): Improve these tests to use expect assertions more
66

@@ -22,7 +22,7 @@ const cursor = collection
2222
.min({ age: 18 })
2323
.maxAwaitTimeMS(1)
2424
.maxTimeMS(1)
25-
.project({})
25+
// .project({}) -> projections removes the types from the returned documents
2626
.returnKey(true)
2727
.showRecordId(true)
2828
.skip(1)
@@ -31,6 +31,7 @@ const cursor = collection
3131

3232
expectType<FindCursor<{ foo: number }>>(cursor);
3333
expectType<Readable>(cursor.stream());
34+
expectType<FindCursor<Document>>(cursor.project({}));
3435

3536
collection.find().project({});
3637
collection.find().project({ notExistingField: 1 });
@@ -74,13 +75,13 @@ expectNotType<{ age: number }[]>(await typedCollection.find().project({ name: 1
7475
expectType<{ notExistingField: unknown }[]>(
7576
await typedCollection.find().project({ notExistingField: 1 }).toArray()
7677
);
77-
expectNotType<TypedDoc[]>(await typedCollection.find().project({ notExistingField: 1 }).toArray());
78+
expectType<TypedDoc[]>(await typedCollection.find().project({ notExistingField: 1 }).toArray());
7879

7980
// Projection operator
8081
expectType<{ listOfNumbers: number[] }[]>(
8182
await typedCollection
8283
.find()
83-
.project({ listOfNumbers: { $slice: [0, 4] } })
84+
.project<{ listOfNumbers: number[] }>({ listOfNumbers: { $slice: [0, 4] } })
8485
.toArray()
8586
);
8687

@@ -98,3 +99,57 @@ void async function () {
9899
expectType<number>(item.foo);
99100
}
100101
};
102+
103+
interface InternalMeme {
104+
_id: string;
105+
owner: string;
106+
receiver: string;
107+
createdAt: Date;
108+
expiredAt: Date;
109+
description: string;
110+
likes: string;
111+
private: string;
112+
replyTo: string;
113+
imageUrl: string;
114+
}
115+
116+
interface PublicMeme {
117+
myId: string;
118+
owner: string;
119+
likes: number;
120+
someRandomProp: boolean; // Projection makes no enforcement on anything
121+
// the convenience parameter project<X>() allows you to define a return type,
122+
// otherwise projections returns generic document
123+
}
124+
125+
const publicMemeProjection = {
126+
myId: { $toString: '$_id' },
127+
owner: { $toString: '$owner' },
128+
receiver: { $toString: '$receiver' },
129+
likes: '$totalLikes' // <== (NODE-3454) cause of TS2345 error: Argument of type T is not assignable to parameter of type U
130+
};
131+
const memeCollection = new Db(new MongoClient(''), '').collection<InternalMeme>('memes');
132+
133+
expectType<PublicMeme[]>(
134+
await memeCollection
135+
.find({ _id: { $in: [] } })
136+
.project<PublicMeme>(publicMemeProjection) // <==
137+
.toArray()
138+
);
139+
140+
// Returns generic document when no override given
141+
expectNotType<InternalMeme[]>(
142+
await memeCollection
143+
.find({ _id: { $in: [] } })
144+
.project(publicMemeProjection)
145+
.toArray()
146+
);
147+
148+
// Returns generic document when there is no schema
149+
expectType<Document[]>(
150+
await new Db(new MongoClient(''), '')
151+
.collection('memes')
152+
.find({ _id: { $in: [] } })
153+
.project(publicMemeProjection)
154+
.toArray()
155+
);

0 commit comments

Comments
 (0)