Skip to content

Commit f1979db

Browse files
fix(NODE-3803): Fix _id typing on collection create operations (#3077)
1 parent 3e83a6a commit f1979db

8 files changed

+152
-39
lines changed

src/collection.ts

+21-12
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { PkFactory } from './mongo_client';
1212
import type {
1313
Filter,
1414
Flatten,
15-
OptionalId,
15+
OptionalUnlessRequiredId,
1616
TODO_NODE_3286,
1717
UpdateFilter,
1818
WithId,
@@ -264,16 +264,22 @@ export class Collection<TSchema extends Document = Document> {
264264
* @param options - Optional settings for the command
265265
* @param callback - An optional callback, a Promise will be returned if none is provided
266266
*/
267-
insertOne(doc: OptionalId<TSchema>): Promise<InsertOneResult<TSchema>>;
268-
insertOne(doc: OptionalId<TSchema>, callback: Callback<InsertOneResult<TSchema>>): void;
269-
insertOne(doc: OptionalId<TSchema>, options: InsertOneOptions): Promise<InsertOneResult<TSchema>>;
267+
insertOne(doc: OptionalUnlessRequiredId<TSchema>): Promise<InsertOneResult<TSchema>>;
270268
insertOne(
271-
doc: OptionalId<TSchema>,
269+
doc: OptionalUnlessRequiredId<TSchema>,
270+
callback: Callback<InsertOneResult<TSchema>>
271+
): void;
272+
insertOne(
273+
doc: OptionalUnlessRequiredId<TSchema>,
274+
options: InsertOneOptions
275+
): Promise<InsertOneResult<TSchema>>;
276+
insertOne(
277+
doc: OptionalUnlessRequiredId<TSchema>,
272278
options: InsertOneOptions,
273279
callback: Callback<InsertOneResult<TSchema>>
274280
): void;
275281
insertOne(
276-
doc: OptionalId<TSchema>,
282+
doc: OptionalUnlessRequiredId<TSchema>,
277283
options?: InsertOneOptions | Callback<InsertOneResult<TSchema>>,
278284
callback?: Callback<InsertOneResult<TSchema>>
279285
): Promise<InsertOneResult<TSchema>> | void {
@@ -308,19 +314,22 @@ export class Collection<TSchema extends Document = Document> {
308314
* @param options - Optional settings for the command
309315
* @param callback - An optional callback, a Promise will be returned if none is provided
310316
*/
311-
insertMany(docs: OptionalId<TSchema>[]): Promise<InsertManyResult<TSchema>>;
312-
insertMany(docs: OptionalId<TSchema>[], callback: Callback<InsertManyResult<TSchema>>): void;
317+
insertMany(docs: OptionalUnlessRequiredId<TSchema>[]): Promise<InsertManyResult<TSchema>>;
318+
insertMany(
319+
docs: OptionalUnlessRequiredId<TSchema>[],
320+
callback: Callback<InsertManyResult<TSchema>>
321+
): void;
313322
insertMany(
314-
docs: OptionalId<TSchema>[],
323+
docs: OptionalUnlessRequiredId<TSchema>[],
315324
options: BulkWriteOptions
316325
): Promise<InsertManyResult<TSchema>>;
317326
insertMany(
318-
docs: OptionalId<TSchema>[],
327+
docs: OptionalUnlessRequiredId<TSchema>[],
319328
options: BulkWriteOptions,
320329
callback: Callback<InsertManyResult<TSchema>>
321330
): void;
322331
insertMany(
323-
docs: OptionalId<TSchema>[],
332+
docs: OptionalUnlessRequiredId<TSchema>[],
324333
options?: BulkWriteOptions | Callback<InsertManyResult<TSchema>>,
325334
callback?: Callback<InsertManyResult<TSchema>>
326335
): Promise<InsertManyResult<TSchema>> | void {
@@ -1526,7 +1535,7 @@ export class Collection<TSchema extends Document = Document> {
15261535
* @param callback - An optional callback, a Promise will be returned if none is provided
15271536
*/
15281537
insert(
1529-
docs: OptionalId<TSchema>[],
1538+
docs: OptionalUnlessRequiredId<TSchema>[],
15301539
options: BulkWriteOptions,
15311540
callback: Callback<InsertManyResult<TSchema>>
15321541
): Promise<InsertManyResult<TSchema>> | void {

src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -286,11 +286,13 @@ export type {
286286
KeysOfAType,
287287
KeysOfOtherType,
288288
MatchKeysAndValues,
289+
NonObjectIdLikeDocument,
289290
NotAcceptedFields,
290291
NumericType,
291292
OneOrMore,
292293
OnlyFieldsOfType,
293294
OptionalId,
295+
OptionalUnlessRequiredId,
294296
Projection,
295297
ProjectionOperators,
296298
PullAllOperator,

src/mongo_types.ts

+30-13
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ObjectIdLike } from 'bson';
12
import { EventEmitter } from 'events';
23

34
import type {
@@ -17,13 +18,15 @@ import type { Sort } from './sort';
1718
export type TODO_NODE_3286 = any;
1819

1920
/** Given an object shaped type, return the type of the _id field or default to ObjectId @public */
20-
export type InferIdType<TSchema> = TSchema extends { _id: infer IdType } // user has defined a type for _id
21-
? // eslint-disable-next-line @typescript-eslint/ban-types
22-
{} extends IdType // TODO(NODE-3285): Improve type readability
23-
? // eslint-disable-next-line @typescript-eslint/ban-types
24-
Exclude<IdType, {}>
25-
: unknown extends IdType
26-
? ObjectId
21+
export type InferIdType<TSchema> = TSchema extends { _id: infer IdType }
22+
? // user has defined a type for _id
23+
Record<any, never> extends IdType
24+
? never // explicitly forbid empty objects as the type of _id
25+
: IdType
26+
: TSchema extends { _id?: infer IdType }
27+
? // optional _id defined - return ObjectId | IdType
28+
unknown extends IdType
29+
? ObjectId // infer the _id type as ObjectId if the type of _id is unknown
2730
: IdType
2831
: ObjectId; // user has not defined _id on schema
2932

@@ -33,17 +36,23 @@ export type WithId<TSchema> = EnhancedOmit<TSchema, '_id'> & { _id: InferIdType<
3336
/**
3437
* Add an optional _id field to an object shaped type
3538
* @public
39+
*/
40+
export type OptionalId<TSchema> = EnhancedOmit<TSchema, '_id'> & { _id?: InferIdType<TSchema> };
41+
42+
/**
43+
* Adds an optional _id field to an object shaped type, unless the _id field is requried on that type.
44+
* In the case _id is required, this method continues to require_id.
45+
*
46+
* @public
3647
*
3748
* @privateRemarks
3849
* `ObjectId extends TSchema['_id']` is a confusing ordering at first glance. Rather than ask
3950
* `TSchema['_id'] extends ObjectId` which translated to "Is the _id property ObjectId?"
4051
* we instead ask "Does ObjectId look like (have the same shape) as the _id?"
4152
*/
42-
export type OptionalId<TSchema> = TSchema extends { _id?: any }
43-
? ObjectId extends TSchema['_id'] // a Schema with ObjectId _id type or "any" or "indexed type" provided
44-
? EnhancedOmit<TSchema, '_id'> & { _id?: InferIdType<TSchema> } // a Schema provided but _id type is not ObjectId
45-
: WithId<TSchema>
46-
: EnhancedOmit<TSchema, '_id'> & { _id?: InferIdType<TSchema> }; // TODO(NODE-3285): Improve type readability
53+
export type OptionalUnlessRequiredId<TSchema> = TSchema extends { _id: any }
54+
? TSchema
55+
: OptionalId<TSchema>;
4756

4857
/** TypeScript Omit (Exclude to be specific) does not work for objects with an "any" indexed type, and breaks discriminated unions @public */
4958
export type EnhancedOmit<TRecordOrUnion, KeyUnion> = string extends keyof TRecordOrUnion
@@ -91,8 +100,16 @@ export interface RootFilterOperators<TSchema> extends Document {
91100
$comment?: string | Document;
92101
}
93102

103+
/**
104+
* @public
105+
* A type that extends Document but forbids anything that "looks like" an object id.
106+
*/
107+
export type NonObjectIdLikeDocument = {
108+
[key in keyof ObjectIdLike]?: never;
109+
} & Document;
110+
94111
/** @public */
95-
export interface FilterOperators<TValue> extends Document {
112+
export interface FilterOperators<TValue> extends NonObjectIdLikeDocument {
96113
// Comparison
97114
$eq?: TValue;
98115
$gt?: TValue;

test/types/basic_schema.test-d.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expectAssignable, expectNotType, expectType } from 'tsd';
1+
import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd';
22

33
import { ObjectId } from '../../src/bson';
44
import { Collection } from '../../src/collection';
@@ -31,7 +31,7 @@ expectNotType<InsertOneArgOf<ACounter>>({ a: 2, b: 34 });
3131
// With _id
3232
expectAssignable<InsertOneArgOf<ACounterWithId>>({ _id: new ObjectId(), a: 3 });
3333
// Without _id
34-
expectAssignable<InsertOneArgOf<ACounterWithId>>({ a: 3 });
34+
expectNotAssignable<InsertOneArgOf<ACounterWithId>>({ a: 3 });
3535
// Does not permit extra keys
3636
expectNotType<InsertOneArgOf<ACounterWithId>>({ a: 2, b: 34 });
3737

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

+50-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BSONRegExp, Decimal128, ObjectId } from 'bson';
2-
import { expectAssignable, expectNotType, expectType } from 'tsd';
2+
import { expectAssignable, expectError, expectNotType, expectType } from 'tsd';
33

4-
import { Filter, MongoClient, WithId } from '../../../../src';
4+
import { Collection, Filter, MongoClient, WithId } from '../../../../src';
55

66
/**
77
* test the Filter type using collection.find<T>() method
@@ -236,3 +236,51 @@ expectNotType<Filter<PetModel>>({ type: { $size: 2 } });
236236
// dot key case that shows it is assignable even when the referenced key is the wrong type
237237
expectAssignable<Filter<PetModel>>({ 'bestFriend.name': 23 }); // using dot notation permits any type for the key
238238
expectNotType<Filter<PetModel>>({ bestFriend: { name: 23 } });
239+
240+
// ObjectId are not allowed to be used as a query predicate (issue described here: NODE-3758)
241+
// this only applies to schemas where the _id is not of type ObjectId.
242+
declare const nonObjectIdCollection: Collection<{ _id: number; otherField: string }>;
243+
expectError(
244+
nonObjectIdCollection.find({
245+
_id: new ObjectId()
246+
})
247+
);
248+
expectError(
249+
nonObjectIdCollection.find({
250+
otherField: new ObjectId()
251+
})
252+
);
253+
nonObjectIdCollection.find({
254+
fieldThatDoesNotExistOnSchema: new ObjectId()
255+
});
256+
257+
// we only forbid objects that "look like" object ids, so other random objects are permitted
258+
nonObjectIdCollection.find({
259+
_id: {
260+
hello: 'world'
261+
}
262+
});
263+
nonObjectIdCollection.find({
264+
otherField: {
265+
hello: 'world'
266+
}
267+
});
268+
269+
declare const nonSpecifiedCollection: Collection;
270+
nonSpecifiedCollection.find({
271+
_id: new ObjectId()
272+
});
273+
nonSpecifiedCollection.find({
274+
otherField: new ObjectId()
275+
});
276+
277+
nonSpecifiedCollection.find({
278+
_id: {
279+
hello: 'world'
280+
}
281+
});
282+
nonSpecifiedCollection.find({
283+
otherField: {
284+
hello: 'world'
285+
}
286+
});

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

+29-6
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,35 @@ await collectionWithObjectId.insertOne({
123123
numberField: 23,
124124
fruitTags: ['hi']
125125
});
126-
// should not demand _id if it is ObjectId
127-
await collectionWithObjectId.insertOne({
128-
stringField: 'hola',
129-
numberField: 23,
130-
fruitTags: ['hi']
131-
});
126+
// if _id is defined on the schema, it must be passed to insert operations
127+
expectError(
128+
collectionWithObjectId.insertOne({
129+
stringField: 'hola',
130+
numberField: 23,
131+
fruitTags: ['hi']
132+
})
133+
);
134+
135+
// defined _id's that are not of type ObjectId cannot be cast to ObjectId
136+
const collectionWithRequiredNumericId =
137+
db.collection<{ _id: number; otherField: string }>('testCollection');
138+
139+
expectError(
140+
collectionWithRequiredNumericId.insertOne({
141+
_id: new ObjectId(),
142+
otherField: 'hola'
143+
})
144+
);
145+
146+
const collectionWithOptionalNumericId =
147+
db.collection<{ _id?: number; otherField: string }>('testCollection');
148+
149+
expectError(
150+
collectionWithOptionalNumericId.insertOne({
151+
_id: new ObjectId(),
152+
otherField: 'hola'
153+
})
154+
);
132155

133156
/**
134157
* test indexed types

test/types/schema_helpers.test-d.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Document, ObjectId } from 'bson';
2-
import { expectAssignable, expectNotType, expectType } from 'tsd';
2+
import { expectAssignable, expectError, expectNotType, expectType } from 'tsd';
33

44
import type {
55
EnhancedOmit,
66
InferIdType,
77
OptionalId,
8+
OptionalUnlessRequiredId,
89
WithId,
910
WithoutId
1011
} from '../../src/mongo_types';
@@ -13,6 +14,11 @@ import type {
1314
expectType<InferIdType<Document>>(new ObjectId());
1415
expectType<InferIdType<{ _id: number }>>(1 + 1);
1516
expectType<InferIdType<{ a: number } | { b: string }>>(new ObjectId());
17+
expectType<InferIdType<{ _id?: number }>>(1 + 1);
18+
expectType<InferIdType<{ _id?: unknown }>>(new ObjectId());
19+
expectError<InferIdType<{ _id: Record<string, any> }>>({});
20+
21+
// union types could have an id of either type
1622
expectAssignable<InferIdType<{ _id: number } | { b: string }>>(new ObjectId());
1723
expectAssignable<InferIdType<{ _id: number } | { b: string }>>(1 + 1);
1824

@@ -38,6 +44,14 @@ class MyId {}
3844
expectNotType<OptionalId<{ _id: MyId; a: number }>>({ a: 3 });
3945
expectNotType<OptionalId<{ _id: MyId; a: number }>>({ _id: new ObjectId(), a: 3 });
4046

47+
declare function functionReturningOptionalId(): OptionalId<{
48+
_id?: ObjectId | undefined;
49+
a: number;
50+
}>;
51+
// OptionalUnlessRequiredId
52+
expectType<OptionalUnlessRequiredId<{ _id: ObjectId; a: number }>>({ a: 3, _id: new ObjectId() });
53+
expectType<OptionalUnlessRequiredId<{ _id?: ObjectId; a: number }>>(functionReturningOptionalId());
54+
4155
// WithoutId removes _id whether defined in the schema or not
4256
expectType<WithoutId<{ _id: number; a: number }>>({ a: 2 });
4357
expectNotType<WithoutId<{ _id: number; a: number }>>({ _id: 3, a: 2 });

test/types/union_schema.test-d.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ interface Rectangle {
1818
type Shape = Circle | Rectangle;
1919

2020
type ShapeInsert = InsertOneFirstParam<Shape>;
21-
expectAssignable<ShapeInsert>({ height: 2, width: 2, radius: 2 }); // This is permitted...
21+
expectNotAssignable<ShapeInsert>({ height: 2, width: 2, radius: 2 }); // This is not permitted...
2222
// error cases, should not insert a portion of a type
2323
expectNotAssignable<ShapeInsert>({ height: 2 });
2424
expectError<ShapeInsert>({
@@ -27,8 +27,8 @@ expectError<ShapeInsert>({
2727
_id: new ObjectId()
2828
});
2929
// valid cases
30-
expectAssignable<ShapeInsert>({ height: 4, width: 4 });
31-
expectAssignable<ShapeInsert>({ radius: 4 });
30+
expectAssignable<ShapeInsert>({ height: 4, width: 4, _id: new ObjectId() });
31+
expectAssignable<ShapeInsert>({ radius: 4, _id: new ObjectId() });
3232

3333
const c: Collection<Shape> = null as never;
3434
expectType<Promise<WithId<Shape> | null>>(c.findOne({ height: 4, width: 4 }));

0 commit comments

Comments
 (0)