Skip to content

Commit

Permalink
Merge pull request #11908 from mohammad0-0ahmad-forks/auto-typed-virt…
Browse files Browse the repository at this point in the history
…uals

[✔️] Auto-typed-virtuals
  • Loading branch information
vkarpov15 committed Jul 20, 2022
2 parents 067e6fe + 917d331 commit 0c92558
Show file tree
Hide file tree
Showing 12 changed files with 210 additions and 29 deletions.
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>
};
}

0 comments on commit 0c92558

Please sign in to comment.