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

types: enhanced Document's methods #13739

Merged
merged 2 commits into from Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
34 changes: 34 additions & 0 deletions test/types/document.test.ts
Expand Up @@ -312,3 +312,37 @@ function gh13094() {
const doc3: UserDocumentAny = null as any;
expectType<string>(doc3.name); */
}

function gh13738() {
interface IPerson {
age: number;
dob: Date;
settings: {
theme: string;
alerts: {
sms: boolean;
}
}
}

const schema = new Schema<IPerson>({
age: Number,
dob: Date,
settings: {
theme: String,
alerts: {
sms: Boolean
}
}
});

const Person = model<IPerson>('Person', schema);

const person = new Person({ name: 'person', dob: new Date(), settings: { alerts: { sms: true }, theme: 'light' } });

expectType<number>(person.get('age'));
expectType<Date>(person.get('dob'));
expectType<string>(person.get('settings.theme'));
expectType<boolean>(person.get('settings.alerts.sms'));
expectType<{ sms: boolean }>(person.get('settings.alerts'));
}
30 changes: 16 additions & 14 deletions types/document.d.ts
Expand Up @@ -135,7 +135,7 @@ declare module 'mongoose' {
errors?: Error.ValidationError;

/** Returns the value of a path. */
get(path: string, type?: any, options?: any): any;
get<T extends FlatPath<DocType>>(path: T, type?: any, options?: any): ExtractFromPath<DocType, T>;

/**
* Returns the changes that happened to the document
Expand All @@ -157,31 +157,33 @@ declare module 'mongoose' {
init(obj: AnyObject, opts?: AnyObject): this;

/** Marks a path as invalid, causing validation to fail. */
invalidate(path: string, errorMsg: string | NativeError, value?: any, kind?: string): NativeError | null;
invalidate<T extends FlatPath<DocType>>(path: T, errorMsg: string | NativeError, value?: any, kind?: string): NativeError | null;

/** Returns true if `path` was directly set and modified, else false. */
isDirectModified(path: string | Array<string>): boolean;
isDirectModified<T extends FlatPath<DocType>>(path: T): boolean;

/** Checks if `path` was explicitly selected. If no projection, always returns true. */
isDirectSelected(path: string): boolean;
isDirectSelected<T extends FlatPath<DocType>>(path: T): boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This syntax is nice, but would be good to keep the isDirectSelected(path: string): boolean; syntax as a fallback because it is perfectly valid to call isDirectSelected() on a field that's not in the schema.


/** Checks if `path` is in the `init` state, that is, it was set by `Document#init()` and not modified since. */
isInit(path: string): boolean;
isInit<T extends keyof DocType>(path: T): boolean;

/**
* Returns true if any of the given paths are modified, else false. If no arguments, returns `true` if any path
* in this document is modified.
*/
isModified(path?: string | Array<string>): boolean;
isModified<T extends FlatPath<DocType>>(path?: T | Array<T>): boolean;

/** Boolean flag specifying if the document is new. */
isNew: boolean;

/** Checks if `path` was selected in the source query which initialized this document. */
isSelected(path: string): boolean;
isSelected<T extends FlatPath<DocType>>(path: T): boolean;

/** Marks the path as having pending changes to write to the db. */
markModified(path: string, scope?: any): void;
markModified<T extends FlatPath<DocType>>(path: T, scope?: any): void;

/** Returns the list of paths that have been modified. */
modifiedPaths(options?: { includeChildren?: boolean }): Array<string>;
Expand All @@ -191,7 +193,7 @@ declare module 'mongoose' {
* for immutable properties. Behaves similarly to `set()`, except for it
* unsets all properties that aren't in `obj`.
*/
overwrite(obj: AnyObject): this;
overwrite(obj: Partial<Omit<DocType, '_id'>>): this;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Similar here. AnyObject is more correct, Partial<> is wrong, and omitting _id is wrong.


/**
* If this document is a subdocument or populated document, returns the
Expand All @@ -207,7 +209,7 @@ declare module 'mongoose' {
populated(path: string): any;

/** Sends a replaceOne command with this document `_id` as the query selector. */
replaceOne(replacement?: AnyObject, options?: QueryOptions | null): Query<any, this>;
replaceOne(replacement?: Partial<Omit<DocType, '_id'>>, options?: QueryOptions | null): Query<any, this>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is incorrect. 1) AnyObject is more correct, because Mongoose will run casting to handle any types that don't match, 2) Partial<> is incorrect because required fields are still required, 3) omitting _id is incorrect: you can specify _id as long as the replacement has the same _id as the document, but there's no way to know whether the _id is the same at compile time.


/** Saves this document by inserting a new document into the database if [document.isNew](/docs/api/document.html#document_Document-isNew) is `true`, or sends an [updateOne](/docs/api/document.html#document_Document-updateOne) operation with just the modified paths if `isNew` is `false`. */
save(options?: SaveOptions): Promise<this>;
Expand All @@ -216,9 +218,9 @@ declare module 'mongoose' {
schema: Schema;

/** Sets the value of a path, or many paths. */
set(path: string | Record<string, any>, val: any, type: any, options?: DocumentSetOptions): this;
set(path: string | Record<string, any>, val: any, options?: DocumentSetOptions): this;
set(value: string | Record<string, any>): this;
set<T extends FlatPath<DocType>>(path: T, val: ExtractFromPath<DocType, T>, type: any, options?: DocumentSetOptions): this;
set<T extends FlatPath<DocType>>(path: T, val: ExtractFromPath<DocType, T>, options?: DocumentSetOptions): this;
set(value: Partial<Omit<DocType, '_id'>>, options?: DocumentSetOptions): this;

/** The return value of this method is used in calls to JSON.stringify(doc). */
toJSON<T = Require_id<DocType>>(options?: ToObjectOptions & { flattenMaps?: true }): FlattenMaps<T>;
Expand All @@ -228,17 +230,17 @@ declare module 'mongoose' {
toObject<T = Require_id<DocType>>(options?: ToObjectOptions): Require_id<T>;

/** Clears the modified state on the specified path. */
unmarkModified(path: string): void;
unmarkModified<T extends FlatPath<DocType>>(path: T): void;

/** Sends an updateOne command with this document `_id` as the query selector. */
updateOne(update?: UpdateQuery<this> | UpdateWithAggregationPipeline, options?: QueryOptions | null): Query<any, this>;

/** Executes registered validation rules for this document. */
validate(pathsToValidate?: pathsToValidate, options?: AnyObject): Promise<void>;
validate<T extends FlatPath<DocType>>(pathsToValidate?: T | T[], options?: AnyObject): Promise<void>;
validate(options: { pathsToSkip?: pathsToSkip }): Promise<void>;

/** Executes registered validation rules (skipping asynchronous validators) for this document. */
validateSync(options: { pathsToSkip?: pathsToSkip, [k: string]: any }): Error.ValidationError | null;
validateSync(pathsToValidate?: pathsToValidate, options?: AnyObject): Error.ValidationError | null;
validateSync<T extends FlatPath<DocType>>(pathsToValidate?: T | T[], options?: AnyObject): Error.ValidationError | null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm a bit wary of using FlatPath<> by default because of potential infinite recursion issues. Any self-referencing objects will lead to FlatPath<> blowing up, which is one of the reasons why the MongoDB Node driver no longer uses their Join helper by default.

}
}
30 changes: 30 additions & 0 deletions types/utility.d.ts
Expand Up @@ -40,4 +40,34 @@ type IfEquals<T, U, Y = true, N = false> =
(<G>() => G extends T ? 1 : 0) extends
(<G>() => G extends U ? 1 : 0) ? Y : N;

type IsAny<T> = unknown extends T ? ([keyof T] extends [never] ? false : true) : false;
Copy link
Collaborator

Choose a reason for hiding this comment

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

We already have IfAny<>, so this is unnecessary


type FlatPath<T> = keyof {
[key in keyof Required<T> as NonNullable<T[key]> extends Record<any, any>
? Required<T>[key] extends Array<any>
? never
: Required<T>[key] extends Date | Types.ObjectId | Buffer
? key
: IsAny<Required<T>[key]> extends true
? key
: `${key extends string ? key : ''}.${FlatPath<Required<T>[key]> extends string
? FlatPath<Required<T>[key]>
: ''}`
: keyof Required<T>]: 1;
};


type ExtractFromPath<
Obj,
Path extends FlatPath<Obj>,
> = Path extends `${infer A}.${infer B}`
? A extends keyof Required<Obj>
? B extends FlatPath<Required<Obj>[A]>
? ExtractFromPath<Required<Obj>[A], B>
: never
: never
: Path extends keyof Required<Obj>
? Required<Obj>[Path]
: never;

}