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

[✔️] Auto-typed-virtuals #11908

Merged
merged 15 commits into from Jul 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
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
38 changes: 38 additions & 0 deletions docs/guide.md
Expand Up @@ -341,6 +341,23 @@ And what if you want to do some extra processing on the name, like
define a `fullName` property that won't get persisted to MongoDB.

```javascript
// That can be done either by adding it to schema options:
const personSchema = new Schema({
name: {
first: String,
last: String
}
},{
virtuals:{
fullName:{
get() {
return this.name.first + ' ' + this.name.last;
}
}
}
});

// Or by using the virtual method as following:
personSchema.virtual('fullName').get(function() {
return this.name.first + ' ' + this.name.last;
});
Expand All @@ -363,6 +380,27 @@ You can also add a custom setter to your virtual that will let you set both
first name and last name via the `fullName` virtual.

```javascript
// Again that can be done either by adding it to schema options:
const personSchema = new Schema({
name: {
first: String,
last: String
}
},{
virtuals:{
fullName:{
get() {
return this.name.first + ' ' + this.name.last;
}
set(v) {
this.name.first = v.substr(0, v.indexOf(' '));
this.name.last = v.substr(v.indexOf(' ') + 1);
}
}
}
});

// Or by using the virtual method as following:
personSchema.virtual('fullName').
get(function() {
return this.name.first + ' ' + this.name.last;
Expand Down
28 changes: 28 additions & 0 deletions docs/typescript/virtuals.md
@@ -1,6 +1,34 @@
# Virtuals in TypeScript

[Virtuals](/docs/tutorials/virtuals.html) are computed properties: you can access virtuals on hydrated Mongoose documents, but virtuals are **not** stored in MongoDB.
Mongoose supports auto typed virtuals so you don't need to define additional typescript interface anymore but you are still able to do so.

### Automatically Inferred Types:

To make mongoose able to infer virtuals type, You have to define them in schema constructor as following:

```ts
import { Schema, Model, model } from 'mongoose';

const schema = new Schema(
{
firstName: String,
lastName: String,
},
{
virtuals:{
fullName:{
get(){
return `${this.firstName} ${this.lastName}`;
}
// virtual setter and options can be defined here as well.
}
}
}
);
```

### Set virtuals type manually:
You shouldn't define virtuals in your TypeScript [document interface](/docs/typescript.html).
Instead, you should define a separate interface for your virtuals, and pass this interface to `Model` and `Schema`.

Expand Down
18 changes: 18 additions & 0 deletions lib/schema.js
Expand Up @@ -133,6 +133,24 @@ function Schema(obj, options) {
this.add(obj);
}

// build virtual paths
if (options && options.virtuals) {
const virtuals = options.virtuals;
const pathNames = Object.keys(virtuals);
for (const pathName of pathNames) {
const pathOptions = virtuals[pathName].options ? virtuals[pathName].options : undefined;
const virtual = this.virtual(pathName, pathOptions);

if (virtuals[pathName].get) {
virtual.get(virtuals[pathName].get);
}

if (virtuals[pathName].set) {
virtual.set(virtuals[pathName].set);
}
}
}

// check if _id's value is a subdocument (gh-2276)
const _idSubDoc = obj && obj._id && utils.isObject(obj._id);

Expand Down
40 changes: 40 additions & 0 deletions test/schema.test.js
Expand Up @@ -2792,4 +2792,44 @@ describe('schema', function() {
});
}, /Cannot use schema-level projections.*subdocument_mapping.not_selected/);
});

it('enable defining virtual paths by using schema constructor (gh-11908)', async function() {
function get() {return this.email.slice(this.email.indexOf('@') + 1);}
function set(v) { this.email = [this.email.slice(0, this.email.indexOf('@')), v].join('@');}
const options = {
getters: true
};

const definition = {
email: { type: String }
};
const TestSchema1 = new Schema(definition);
TestSchema1.virtual('domain', options).set(set).get(get);

const TestSchema2 = new Schema({
email: { type: String }
}, {
virtuals: {
domain: {
get,
set,
options
}
}
});

assert.deepEqual(TestSchema2.virtuals, TestSchema1.virtuals);

const doc1 = new (mongoose.model('schema1', TestSchema1))({ email: 'test@m0_0a.com' });
const doc2 = new (mongoose.model('schema2', TestSchema2))({ email: 'test@m0_0a.com' });

assert.equal(doc1.domain, doc2.domain);

const mongooseDomain = 'mongoose.com';
doc1.domain = mongooseDomain;
doc2.domain = mongooseDomain;

assert.equal(doc1.domain, mongooseDomain);
assert.equal(doc1.domain, doc2.domain);
});
});
2 changes: 1 addition & 1 deletion test/types/schema.test.ts
Expand Up @@ -550,7 +550,7 @@ export type AutoTypedSchemaType = {
},
methods: {
instanceFn: () => 'Returned from DocumentInstanceFn'
},
}
};

// discriminator
Expand Down
36 changes: 35 additions & 1 deletion test/types/virtuals.test.ts
@@ -1,4 +1,4 @@
import { Document, Model, Schema, model } from 'mongoose';
import { Document, Model, Schema, model, InferSchemaType, FlatRecord, ObtainSchemaGeneric } from 'mongoose';
import { expectType } from 'tsd';

interface IPerson {
Expand Down Expand Up @@ -86,3 +86,37 @@ function gh11543() {

expectType<PetVirtuals>(personSchema.virtuals);
}

function autoTypedVirtuals() {
type AutoTypedSchemaType = InferSchemaType<typeof testSchema>;
type VirtualsType = { domain: string };
type InferredDocType = FlatRecord<AutoTypedSchemaType & ObtainSchemaGeneric<typeof testSchema, 'TVirtuals'>>;

const testSchema = new Schema({
email: {
type: String,
required: [true, 'email is required']
}
}, {
virtuals: {
domain: {
get() {
expectType<Document<any, any, { email: string }> & AutoTypedSchemaType>(this);
return this.email.slice(this.email.indexOf('@') + 1);
},
set() {
expectType<Document<any, any, AutoTypedSchemaType> & AutoTypedSchemaType>(this);
},
options: {}
}
}
});


const TestModel = model('AutoTypedVirtuals', testSchema);

const doc = new TestModel();
expectType<string>(doc.domain);

expectType<FlatRecord<AutoTypedSchemaType & VirtualsType >>({} as InferredDocType);
}
5 changes: 3 additions & 2 deletions types/index.d.ts
Expand Up @@ -21,6 +21,7 @@
/// <reference path="./utility.d.ts" />
/// <reference path="./validation.d.ts" />
/// <reference path="./inferschematype.d.ts" />
/// <reference path="./virtuals.d.ts" />

declare class NativeDate extends global.Date { }

Expand Down Expand Up @@ -156,15 +157,15 @@ declare module 'mongoose' {

type QueryResultType<T> = T extends Query<infer ResultType, any> ? ResultType : never;

export class Schema<EnforcedDocType = any, M = Model<EnforcedDocType, any, any, any>, TInstanceMethods = {}, TQueryHelpers = {}, TVirtuals = any,
export class Schema<EnforcedDocType = any, M = Model<EnforcedDocType, any, any, any>, TInstanceMethods = {}, TQueryHelpers = {}, TVirtuals = {},
TStaticMethods = {},
TPathTypeKey extends TypeKeyBaseType = DefaultTypeKey,
DocType extends ObtainDocumentType<DocType, EnforcedDocType, TPathTypeKey> = ObtainDocumentType<any, EnforcedDocType, TPathTypeKey>>
extends events.EventEmitter {
/**
* Create a new schema
*/
constructor(definition?: SchemaDefinition<SchemaDefinitionType<EnforcedDocType>> | DocType, options?: SchemaOptions<TPathTypeKey, FlatRecord<DocType>, TInstanceMethods, TQueryHelpers, TStaticMethods>);
constructor(definition?: SchemaDefinition<SchemaDefinitionType<EnforcedDocType>> | DocType, options?: SchemaOptions<TPathTypeKey, FlatRecord<DocType>, TInstanceMethods, TQueryHelpers, TStaticMethods, TVirtuals>);

/** Adds key path / schema type pairs to this schema. */
add(obj: SchemaDefinition<SchemaDefinitionType<EnforcedDocType>> | Schema, prefix?: string): this;
Expand Down
23 changes: 3 additions & 20 deletions types/inferschematype.d.ts
Expand Up @@ -11,7 +11,8 @@ import {
DateSchemaDefinition,
ObtainDocumentType,
DefaultTypeKey,
ObjectIdSchemaDefinition
ObjectIdSchemaDefinition,
IfEquals
} from 'mongoose';

declare module 'mongoose' {
Expand All @@ -37,7 +38,7 @@ declare module 'mongoose' {
* // result
* type UserType = {userName?: string}
*/
type InferSchemaType<SchemaType> = ObtainSchemaGeneric<SchemaType, 'DocType'> ;
type InferSchemaType<SchemaType> = ObtainSchemaGeneric<SchemaType, 'DocType'>;

/**
* @summary Obtains schema Generic type by using generic alias.
Expand All @@ -58,24 +59,6 @@ declare module 'mongoose' {
}[alias]
: unknown;
}
/**
* @summary Checks if a type is "Record" or "any".
* @description It Helps to check if user has provided schema type "EnforcedDocType"
* @param {T} T A generic type to be checked.
* @returns true if {@link T} is Record OR false if {@link T} is of any type.
*/
type IsItRecordAndNotAny<T> = IfEquals<T, any, false, T extends Record<any, any> ? true : false>;

/**
* @summary Checks if two types are identical.
* @param {T} T The first type to be compared with {@link U}.
* @param {U} U The seconde type to be compared with {@link T}.
* @param {Y} Y A type to be returned if {@link T} & {@link U} are identical.
* @param {N} N A type to be returned if {@link T} & {@link U} are not identical.
*/
type IfEquals<T, U, Y = true, N = false> =
(<G>() => G extends T ? 1 : 0) extends
(<G>() => G extends U ? 1 : 0) ? Y : N;

/**
* @summary Checks if a document path is required or optional.
Expand Down
3 changes: 2 additions & 1 deletion types/models.d.ts
Expand Up @@ -119,7 +119,8 @@ declare module 'mongoose' {
AcceptsDiscriminator,
IndexManager,
SessionStarter {
new <DocType = T>(doc?: DocType, fields?: any | null, options?: boolean | AnyObject): HydratedDocument<T, TMethodsAndOverrides, TVirtuals> & ObtainSchemaGeneric<TSchema, 'TStaticMethods'>;
new <DocType = T>(doc?: DocType, fields?: any | null, options?: boolean | AnyObject): HydratedDocument<T, TMethodsAndOverrides,
IfEquals<TVirtuals, {}, ObtainSchemaGeneric<TSchema, 'TVirtuals'>, TVirtuals>> & ObtainSchemaGeneric<TSchema, 'TStaticMethods'>;

aggregate<R = any>(pipeline?: PipelineStage[], options?: mongodb.AggregateOptions, callback?: Callback<R[]>): Aggregate<Array<R>>;
aggregate<R = any>(pipeline: PipelineStage[], callback?: Callback<R[]>): Aggregate<Array<R>>;
Expand Down
13 changes: 9 additions & 4 deletions types/schemaoptions.d.ts
Expand Up @@ -10,7 +10,7 @@ declare module 'mongoose' {
type TypeKeyBaseType = string;

type DefaultTypeKey = 'type';
interface SchemaOptions<PathTypeKey extends TypeKeyBaseType = DefaultTypeKey, DocType = unknown, InstanceMethods = {}, QueryHelpers = {}, StaticMethods = {}, virtuals = {}> {
interface SchemaOptions<PathTypeKey extends TypeKeyBaseType = DefaultTypeKey, DocType = unknown, TInstanceMethods = {}, QueryHelpers = {}, TStaticMethods = {}, TVirtuals = {}> {
/**
* By default, Mongoose's init() function creates all the indexes defined in your model's schema by
* calling Model.createIndexes() after you successfully connect to MongoDB. If you want to disable
Expand Down Expand Up @@ -191,15 +191,15 @@ declare module 'mongoose' {
/**
* Model Statics methods.
*/
statics?: Record<any, (this: Model<DocType>, ...args: any) => unknown> | StaticMethods,
statics?: Record<any, (this: Model<DocType>, ...args: any) => unknown> | TStaticMethods,

/**
* Document instance methods.
*/
methods?: Record<any, (this: HydratedDocument<DocType>, ...args: any) => unknown> | InstanceMethods,
methods?: Record<any, (this: HydratedDocument<DocType>, ...args: any) => unknown> | TInstanceMethods,

/**
* Query helper functions
* Query helper functions.
*/
query?: Record<any, <T extends QueryWithHelpers<unknown, DocType>>(this: T, ...args: any) => T> | QueryHelpers,

Expand All @@ -208,5 +208,10 @@ declare module 'mongoose' {
* @default true
*/
castNonArrays?: boolean;

/**
* Virtual paths.
*/
virtuals?: SchemaOptionsVirtualsPropertyType<DocType, TVirtuals, TInstanceMethods>,
}
}
19 changes: 19 additions & 0 deletions types/utility.d.ts
Expand Up @@ -19,4 +19,23 @@ declare module 'mongoose' {
*/
type FlatRecord<T> = { [K in keyof T]: T[K] };

/**
* @summary Checks if a type is "Record" or "any".
* @description It Helps to check if user has provided schema type "EnforcedDocType"
* @param {T} T A generic type to be checked.
* @returns true if {@link T} is Record OR false if {@link T} is of any type.
*/
type IsItRecordAndNotAny<T> = IfEquals<T, any, false, T extends Record<any, any> ? true : false>;

/**
* @summary Checks if two types are identical.
* @param {T} T The first type to be compared with {@link U}.
* @param {U} U The seconde type to be compared with {@link T}.
* @param {Y} Y A type to be returned if {@link T} & {@link U} are identical.
* @param {N} N A type to be returned if {@link T} & {@link U} are not identical.
*/
type IfEquals<T, U, Y = true, N = false> =
(<G>() => G extends T ? 1 : 0) extends
(<G>() => G extends U ? 1 : 0) ? Y : N;

}
14 changes: 14 additions & 0 deletions types/virtuals.d.ts
@@ -0,0 +1,14 @@
declare module 'mongoose' {
type VirtualPathFunctions<DocType = {}, PathValueType = unknown, TInstanceMethods = {}> = {
get?: TVirtualPathFN<DocType, PathValueType, TInstanceMethods, PathValueType>;
set?: TVirtualPathFN<DocType, PathValueType, TInstanceMethods, void>;
options?: VirtualTypeOptions<HydratedDocument<DocType, TInstanceMethods>, DocType>;
};

type TVirtualPathFN<DocType = {}, PathType = unknown, TInstanceMethods = {}, TReturn = unknown> =
<T = HydratedDocument<DocType, TInstanceMethods>>(this: Document<any, any, DocType> & DocType, value: PathType, virtual: VirtualType<T>, doc: Document<any, any, DocType> & DocType) => TReturn;

type SchemaOptionsVirtualsPropertyType<DocType = any, VirtualPaths = Record<any, unknown>, TInstanceMethods = {}> = {
[K in keyof VirtualPaths]: VirtualPathFunctions<IsItRecordAndNotAny<DocType> extends true ? DocType : any, VirtualPaths[K], TInstanceMethods>
};
}