Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(NODE-3468): remove generic overrides from find #2935

Merged
merged 12 commits into from Aug 24, 2021
11 changes: 9 additions & 2 deletions package.json
Expand Up @@ -107,8 +107,9 @@
"build:docs": "typedoc",
"check:bench": "node test/benchmarks/driverBench",
"check:coverage": "nyc npm run check:test",
"check:lint": "npm run build:dts && npm run check:dts && npm run check:eslint",
"check:lint": "npm run build:dts && npm run check:dts && npm run check:eslint && npm run check:tsd",
"check:eslint": "eslint -v && eslint --max-warnings=0 --ext '.js,.ts' src test",
"check:tsd": "tsd --version && tsd",
"check:dts": "tsc --noEmit mongodb.d.ts && tsd",
"check:test": "mocha --recursive test/functional test/unit",
"check:ts": "tsc -v && tsc --noEmit",
Expand All @@ -124,6 +125,12 @@
"test": "npm run check:lint && npm run check:test"
},
"tsd": {
"directory": "test/types"
"directory": "test/types",
"compilerOptions": {
"strict": true,
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node"
}
}
}
29 changes: 17 additions & 12 deletions src/collection.ts
Expand Up @@ -414,34 +414,39 @@ export class Collection<TSchema extends Document = Document> {
updateOne(
filter: Filter<TSchema>,
update: UpdateFilter<TSchema> | Partial<TSchema>
): Promise<UpdateResult | Document>;
): Promise<UpdateResult>;
updateOne(
filter: Filter<TSchema>,
update: UpdateFilter<TSchema> | Partial<TSchema>,
callback: Callback<UpdateResult | Document>
callback: Callback<UpdateResult>
): void;
updateOne(
filter: Filter<TSchema>,
update: UpdateFilter<TSchema> | Partial<TSchema>,
options: UpdateOptions
): Promise<UpdateResult | Document>;
): Promise<UpdateResult>;
updateOne(
filter: Filter<TSchema>,
update: UpdateFilter<TSchema> | Partial<TSchema>,
options: UpdateOptions,
callback: Callback<UpdateResult | Document>
callback: Callback<UpdateResult>
): void;
updateOne(
filter: Filter<TSchema>,
update: UpdateFilter<TSchema> | Partial<TSchema>,
options?: UpdateOptions | Callback<UpdateResult | Document>,
callback?: Callback<UpdateResult | Document>
): Promise<UpdateResult | Document> | void {
options?: UpdateOptions | Callback<UpdateResult>,
callback?: Callback<UpdateResult>
): Promise<UpdateResult> | void {
if (typeof options === 'function') (callback = options), (options = {});

return executeOperation(
getTopology(this),
new UpdateOneOperation(this as TODO_NODE_3286, filter, update, resolveOptions(this, options)),
new UpdateOneOperation(
this as TODO_NODE_3286,
filter,
update,
resolveOptions(this, options)
) as TODO_NODE_3286,
callback
);
}
Expand Down Expand Up @@ -685,10 +690,10 @@ export class Collection<TSchema extends Document = Document> {
// allow an override of the schema.
findOne<T = TSchema>(): Promise<T | undefined>;
findOne<T = TSchema>(callback: Callback<T | undefined>): void;
findOne<T = TSchema>(filter: Filter<T>): Promise<T | undefined>;
findOne<T = TSchema>(filter: Filter<T>, options?: FindOptions): Promise<T | undefined>;
findOne<T = TSchema>(filter: Filter<TSchema>): Promise<T | undefined>;
findOne<T = TSchema>(filter: Filter<TSchema>, options?: FindOptions): Promise<T | undefined>;
findOne<T = TSchema>(
filter: Filter<T>,
filter: Filter<TSchema>,
options?: FindOptions,
callback?: Callback<T | undefined>
): void;
Expand Down Expand Up @@ -725,7 +730,7 @@ export class Collection<TSchema extends Document = Document> {
*/
find(): FindCursor<TSchema>;
find(filter: Filter<TSchema>, options?: FindOptions): FindCursor<TSchema>;
find<T = TSchema>(filter: Filter<T>, options?: FindOptions): FindCursor<T>;
find<T>(filter: Filter<TSchema>, options?: FindOptions): FindCursor<T>;
find(filter?: Filter<TSchema>, options?: FindOptions): FindCursor<TSchema> {
if (arguments.length > 2) {
throw new MongoInvalidArgumentError(
Expand Down
48 changes: 24 additions & 24 deletions src/cursor/abstract_cursor.ts
Expand Up @@ -280,8 +280,7 @@ export abstract class AbstractCursor<
return done(undefined, true);
}

next<any>(this, true, (err, doc) => {
// FIXME(NODE):
next<TSchema>(this, true, (err, doc) => {
if (err) return done(err);

if (doc) {
Expand All @@ -296,9 +295,9 @@ export abstract class AbstractCursor<
}

/** Get the next available document from the cursor, returns null if no more documents are available. */
next<T = TSchema>(): Promise<T | null>;
next<T = TSchema>(callback: Callback<T | null>): void;
next<T = TSchema>(callback?: Callback<T | null>): Promise<T | null> | void {
next(): Promise<TSchema | null>;
next(callback: Callback<TSchema | null>): void;

This comment was marked as resolved.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our next functions explicitly return null when the end of the cursor is reached. Our callback definition only lets the second are be potentially undefined, so the | null is very technically necessary, if one is triple equals checking for null.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. The following is purely pedantry and feel free to ignore it, because I likely would if I were you lol.

For transparency, do think a helper type here would be useful? So instead of showing the | null it would be Promise<EndOfCursor<TSchema>> where EndOfCursor is defined as T | null? Again, super minute and what not. Marking this comment as resolved so it's out of mind lol. Thanks for answering my questions!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's true we have this repeated in a number of places, consolidating it to type would help us make sure we don't get one override different from the others, but I'll defer that to an enhancement effort down the line for now I want this PR to unblock the issue with the generics, but I do appreciate the suggestion! thanks!

next(callback?: Callback<TSchema | null>): Promise<TSchema | null> | void {
return maybePromise(callback, done => {
if (this[kId] === Long.ZERO) {
return done(new MongoCursorExhaustedError());
Expand All @@ -311,9 +310,9 @@ export abstract class AbstractCursor<
/**
* Try to get the next available document from the cursor or `null` if an empty batch is returned
*/
tryNext<T = TSchema>(): Promise<T | null>;
tryNext<T = TSchema>(callback: Callback<T | null>): void;
tryNext<T = TSchema>(callback?: Callback<T | null>): Promise<T | null> | void {
tryNext(): Promise<TSchema | null>;
tryNext(callback: Callback<TSchema | null>): void;
tryNext(callback?: Callback<TSchema | null>): Promise<TSchema | null> | void {
return maybePromise(callback, done => {
if (this[kId] === Long.ZERO) {
return done(new MongoCursorExhaustedError());
Expand All @@ -329,10 +328,10 @@ export abstract class AbstractCursor<
* @param iterator - The iteration callback.
* @param callback - The end callback.
*/
forEach<T = TSchema>(iterator: (doc: T) => boolean | void): Promise<void>;
forEach<T = TSchema>(iterator: (doc: T) => boolean | void, callback: Callback<void>): void;
forEach<T = TSchema>(
iterator: (doc: T) => boolean | void,
forEach(iterator: (doc: TSchema) => boolean | void): Promise<void>;
forEach(iterator: (doc: TSchema) => boolean | void, callback: Callback<void>): void;
forEach(
iterator: (doc: TSchema) => boolean | void,
callback?: Callback<void>
): Promise<void> | void {
if (typeof iterator !== 'function') {
Expand All @@ -341,7 +340,7 @@ export abstract class AbstractCursor<
return maybePromise(callback, done => {
const transform = this[kTransform];
const fetchDocs = () => {
next<T>(this, true, (err, doc) => {
next<TSchema>(this, true, (err, doc) => {
if (err || doc == null) return done(err);
let result;
// NOTE: no need to transform because `next` will do this automatically
Expand All @@ -358,7 +357,7 @@ export abstract class AbstractCursor<
for (let i = 0; i < internalDocs.length; ++i) {
try {
result = iterator(
(transform ? transform(internalDocs[i]) : internalDocs[i]) as T // TODO(NODE-3283): Improve transform typing
(transform ? transform(internalDocs[i]) : internalDocs[i]) as TSchema // TODO(NODE-3283): Improve transform typing
);
} catch (error) {
return done(error);
Expand Down Expand Up @@ -402,15 +401,15 @@ export abstract class AbstractCursor<
*
* @param callback - The result callback.
*/
toArray<T = TSchema>(): Promise<T[]>;
toArray<T = TSchema>(callback: Callback<T[]>): void;
toArray<T = TSchema>(callback?: Callback<T[]>): Promise<T[]> | void {
toArray(): Promise<TSchema[]>;
toArray(callback: Callback<TSchema[]>): void;
toArray(callback?: Callback<TSchema[]>): Promise<TSchema[]> | void {
return maybePromise(callback, done => {
const docs: T[] = [];
const docs: TSchema[] = [];
const transform = this[kTransform];
const fetchDocs = () => {
// NOTE: if we add a `nextBatch` then we should use it here
next<T>(this, true, (err, doc) => {
next<TSchema>(this, true, (err, doc) => {
if (err) return done(err);
if (doc == null) return done(undefined, docs);

Expand All @@ -420,7 +419,7 @@ export abstract class AbstractCursor<
// these do need to be transformed since they are copying the rest of the batch
const internalDocs = (transform
? this[kDocuments].splice(0, this[kDocuments].length).map(transform)
: this[kDocuments].splice(0, this[kDocuments].length)) as T[]; // TODO(NODE-3283): Improve transform typing
: this[kDocuments].splice(0, this[kDocuments].length)) as TSchema[]; // TODO(NODE-3283): Improve transform typing
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this[kDocuments] be typed as TSchema[] ? In the class definition itself so these as TSchema[] hacks can be removed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is the type of this[kDocuments] although you did make me think of a potential improvement (really just internally for us) but if we instead type this[kTransform] to (doc: TSchema) => TSchema then we don't need these casts.

When you pass the transform into .map() it will be (doc: TSchema) => T but map will return a new Cursor<T>. Where T is from the result type of the function passed to .map. So the argument that goes into this[kTransform] is the old schema and it returns the "current" one.

I suppose the most accurate would be this[kTransform]: (doc: unknown) => TSchema but then we need equivalent casting/whatever to narrow unknown

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well my original comment was because I didn't see a type on kDocument and was thinking about ways to get rid of the as because this[kDocuments].splice should automatically return something with the type of <Type of<this[kDocuments]>>[] so in this case if the source array was already typed, then this would automatically be TSchema[] so the typing here made me curious.

I actually played around a lot with prototyping a change to ktransform and others when thinking about ways to be able to type projections fully, and narrowing that down was like, a several hundred line TS object that existed only for being able to pass around the type accurately, and I think the unknown above might run into the same situation? Although would you be able to use the Partial keyword here for some help? I like the idea of the different typing, just wondering if the effort required to type that unknown is worth it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the lack of reply, been focused on getting this PR ready for fixing the user issues. I think accurately typing the transform is something we should look into, but it would only affect our internal TS code so at the moment we wouldn't need to prioritize it. but going forward more accuracy even if it requires TS helpers etc. could help catch any issues here.


if (internalDocs) {
docs.push(...internalDocs);
Expand Down Expand Up @@ -458,11 +457,12 @@ export abstract class AbstractCursor<
* Map all documents using the provided function
* If there is a transform set on the cursor, that will be called first and the result passed to
* this function's transform.
* @remarks
*
* **NOTE:** adding a transform changes the return type of the iteration of this cursor, it **does not** return
* a new instance of a cursor. This means when calling map, you should always assign the result to a new
* variable. Take note of the following example:
* @remarks
* **Note for Typescript Users:** adding a transform changes the return type of the iteration of this cursor,
* it **does not** return a new instance of a cursor. This means when calling map,
* you should always assign the result to a new variable in order to get a correctly typed cursor variable.
* Take note of the following example:
*
* @example
* ```typescript
Expand Down
5 changes: 3 additions & 2 deletions src/cursor/aggregation_cursor.ts
Expand Up @@ -149,9 +149,10 @@ export class AggregationCursor<TSchema = Document> extends AbstractCursor<TSchem
* In order to strictly type this function you must provide an interface
* that represents the effect of your projection on the result documents.
*
* Adding a projection changes the return type of the iteration of this cursor,
* **Note for Typescript Users:** adding a transform changes the return type of the iteration of this cursor,
* it **does not** return a new instance of a cursor. This means when calling project,
* you should always assign the result to a new variable. Take note of the following example:
* you should always assign the result to a new variable in order to get a correctly typed cursor variable.
* Take note of the following example:
*
* @example
* ```typescript
Expand Down
5 changes: 3 additions & 2 deletions src/cursor/find_cursor.ts
Expand Up @@ -357,9 +357,10 @@ export class FindCursor<TSchema = Document> extends AbstractCursor<TSchema> {
*
* @remarks
*
* Adding a projection changes the return type of the iteration of this cursor,
* **Note for Typescript Users:** adding a transform changes the return type of the iteration of this cursor,
* it **does not** return a new instance of a cursor. This means when calling project,
* you should always assign the result to a new variable. Take note of the following example:
* you should always assign the result to a new variable in order to get a correctly typed cursor variable.
* Take note of the following example:
*
* @example
* ```typescript
Expand Down
5 changes: 4 additions & 1 deletion src/db.ts
Expand Up @@ -289,7 +289,10 @@ export class Db {
* @param pipeline - An array of aggregation stages to be executed
* @param options - Optional settings for the command
*/
aggregate(pipeline: Document[] = [], options?: AggregateOptions): AggregationCursor {
aggregate<T = Document>(
pipeline: Document[] = [],
options?: AggregateOptions
): AggregationCursor<T> {
if (arguments.length > 2) {
throw new MongoInvalidArgumentError('Method "db.aggregate()" accepts at most two arguments');
}
Expand Down
8 changes: 5 additions & 3 deletions src/error.ts
Expand Up @@ -78,6 +78,11 @@ export interface ErrorDescription extends Document {
export class MongoError extends Error {
/** @internal */
[kErrorLabels]: Set<string>;
/**
* This is a number in MongoServerError and a string in MongoDriverError
* @privateRemarks
* Define the type override on the subclasses when we can use the override keyword
*/
code?: number | string;
topologyVersion?: TopologyVersion;

Expand Down Expand Up @@ -132,12 +137,10 @@ export class MongoError extends Error {
* @category Error
*/
export class MongoServerError extends MongoError {
code?: number;
codeName?: string;
writeConcernError?: Document;
errInfo?: Document;
ok?: number;
topologyVersion?: TopologyVersion;
[key: string]: any;

constructor(message: ErrorDescription) {
Expand All @@ -164,7 +167,6 @@ export class MongoServerError extends MongoError {
* @category Error
*/
export class MongoDriverError extends MongoError {
code?: string;
constructor(message: string) {
super(message);
}
Expand Down
4 changes: 3 additions & 1 deletion src/operations/list_collections.ts
Expand Up @@ -115,7 +115,9 @@ export interface CollectionInfo extends Document {

/** @public */
export class ListCollectionsCursor<
T extends Pick<CollectionInfo, 'name' | 'type'> | CollectionInfo = CollectionInfo
T extends Pick<CollectionInfo, 'name' | 'type'> | CollectionInfo =
| Pick<CollectionInfo, 'name' | 'type'>
| CollectionInfo
> extends AbstractCursor<T> {
parent: Db;
filter: Document;
Expand Down
4 changes: 3 additions & 1 deletion test/types/.eslintrc.json
Expand Up @@ -24,6 +24,8 @@
"prettier/prettier": "error",
"tsdoc/syntax": "warn",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "error"
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function":"off"
}
}
5 changes: 5 additions & 0 deletions test/types/community/collection/filterQuery.test-d.ts
Expand Up @@ -50,6 +50,7 @@ const collectionT = db.collection<PetModel>('test.filterQuery');
// Assert that collection.find uses the Filter helper like so:
const filter: Filter<PetModel> = {};
collectionT.find(filter);
collectionT.find(spot); // a whole model definition is also a valid filter
// Now tests below can directly test the Filter helper, and are implicitly checking collection.find

/**
Expand Down Expand Up @@ -226,3 +227,7 @@ await collectionT.find({ playmates: { $elemMatch: { name: 'MrMeow' } } }).toArra
expectNotType<Filter<PetModel>>({ name: { $all: ['world', 'world'] } });
expectNotType<Filter<PetModel>>({ age: { $elemMatch: [1, 2] } });
expectNotType<Filter<PetModel>>({ type: { $size: 2 } });

// dot key case that shows it is assignable even when the referenced key is the wrong type
expectAssignable<Filter<PetModel>>({ 'bestFriend.name': 23 }); // using dot notation permits any type for the key
expectNotType<Filter<PetModel>>({ bestFriend: { name: 23 } });
18 changes: 16 additions & 2 deletions test/types/community/collection/findX.test-d.ts
Expand Up @@ -102,6 +102,16 @@ expectType<{ cost: number } | undefined>(
await collectionBag.findOne<{ cost: number }>({ color: 'red' }, { projection: { cost: 1 } })
);

// NODE-3468 The generic in find and findOne no longer affect the filter type
type Pet = { type: string; age: number };
const pets = db.collection<Pet>('pets');

expectType<{ crazy: number }[]>(
await pets
.find<{ crazy: number }>({ type: 'dog', age: 1 })
.toArray()
);

interface Car {
make: string;
}
Expand Down Expand Up @@ -186,12 +196,16 @@ expectNotType<FindCursor<{ color: string | { $in: ReadonlyArray<string> } }>>(
colorCollection.find({ color: { $in: colorsFreeze } })
);

// This is related to another bug that will be fixed in NODE-3454
expectType<FindCursor<{ color: { $in: number } }>>(colorCollection.find({ color: { $in: 3 } }));
// NODE-3454: Using the incorrect $in value doesn't mess with the resulting schema
expectNotType<FindCursor<{ color: { $in: number } }>>(
colorCollection.find({ color: { $in: 3 as any } }) // `as any` is to let us make this mistake and still show the result type isn't broken
);
expectType<FindCursor<{ color: string }>>(colorCollection.find({ color: { $in: 3 as any } }));

// When you use the override, $in doesn't permit readonly
colorCollection.find<{ color: string }>({ color: { $in: colorsFreeze } });
colorCollection.find<{ color: string }>({ color: { $in: ['regularArray'] } });

// This is a regression test that we don't remove the unused generic in FindOptions
const findOptions: FindOptions<{ a: number }> = {};
expectType<FindOptions>(findOptions);
Expand Down