'Mongoose Subdocuments in Nest.js
I'm moving my app from express.js to Nest.js, and I can't find a way to reference one mongoose Schema in another, without using old way of declaring Schema with mongoose.Schema({...}).
Let's use example from docs, so I can clarify my problem:
@Schema()
export class Cat extends Document {
@Prop()
name: string;
}
export const CatSchema = SchemaFactory.createForClass(Cat);
Now, what I want is something like this:
@Schema()
export class Owner extends Document {
@Prop({type: [Cat], required: true})
cats: Cat[];
}
export const OwnerSchema = SchemaFactory.createForClass(Owner);
When I define schemas this way I'd get an error, something like this: Invalid schema configuration: Cat
is not a valid
type within the array cats
So, what is the proper way for referencing one Schema inside another, using this more OO approach for defining Schemas?
Solution 1:[1]
I dug into the source code and learned how Schema class is converted by the SchemaFactory.createForClass
method.
Well so how it works?
1. Take a look at this example below:
@Schema()
export class Cat extends Document {
@Prop()
name: string;
}
export const catSchema = SchemaFactory.createForClass(Cat);
Basically, when you do SchemaFactory.createForClass(Cat)
Nest will convert the class syntax into the Mongoose schema syntax, so in the end, the result of the conversion would be like this:
const schema = new mongoose.Schema({
name: { type: String } // Notice that `String` is now uppercase.
});
2. How does the conversion work?
Take a look at this file: mongoose/prop.decorator.ts at master · nestjs/mongoose · GitHub
export function Prop(options?: PropOptions): PropertyDecorator {
return (target: object, propertyKey: string | symbol) => {
options = (options || {}) as mongoose.SchemaTypeOpts<unknown>;
const isRawDefinition = options[RAW_OBJECT_DEFINITION];
if (!options.type && !Array.isArray(options) && !isRawDefinition) {
const type = Reflect.getMetadata(TYPE_METADATA_KEY, target, propertyKey);
if (type === Array) {
options.type = [];
} else if (type && type !== Object) {
options.type = type;
}
}
TypeMetadataStorage.addPropertyMetadata({
target: target.constructor,
propertyKey: propertyKey as string,
options,
});
};
}
Here you could see what the Prop()
decorator does behind the scene.
When you do:
@Prop()
name: string;
Prop
function would be called, in this case with no arguments.
const type = Reflect.getMetadata(TYPE_METADATA_KEY, target, propertyKey);
Using the Reflect
API, we can get the data type that you use when you do name: string
. The value of type
variable is now set to String
. Notice that it’s not string
, the Reflect
API will always return the constructor version of the data type so:
number
will be serialized asNumber
string
will be serialized asString
boolean
will be serialized asBoolean
- and so on
TypeMetadataStorage.addPropertyMetadata
will then store the object below into the store.
{
target: User,
propertyKey: ‘name’,
options: { type: String }
}
Let’s take a look at the: mongoose/type-metadata.storage.ts at master · nestjs/mongoose · GitHub
export class TypeMetadataStorageHost {
private schemas = new Array<SchemaMetadata>();
private properties = new Array<PropertyMetadata>();
addPropertyMetadata(metadata: PropertyMetadata) {
this.properties.push(metadata);
}
}
So basically that object will be stored into the properties
variable in TypeMetadataStorageHost
.
TypeMetadataStorageHost
is a singleton that will store a lot of these objects.
3. Schema generation
To understand how the SchemaFactory.createForClass(Cat)
produce the Mongoose schema, take a look at this: mongoose/schema.factory.ts at master · nestjs/mongoose · GitHub
export class SchemaFactory {
static createForClass(target: Type<unknown>) {
const schemaDefinition = DefinitionsFactory.createForClass(target);
const schemaMetadata = TypeMetadataStorage.getSchemaMetadataByTarget(
target,
);
return new mongoose.Schema(
schemaDefinition,
schemaMetadata && schemaMetadata.options,
);
}
}
The most important part is:
const schemaDefinition = DefinitionsFactory.createForClass(target);
. Notice that the target here is your Cat
class.
You could see the method definition here: mongoose/definitions.factory.ts at master · nestjs/mongoose · GitHub
export class DefinitionsFactory {
static createForClass(target: Type<unknown>): mongoose.SchemaDefinition {
let schemaDefinition: mongoose.SchemaDefinition = {};
schemaMetadata.properties?.forEach((item) => {
const options = this.inspectTypeDefinition(item.options as any);
schemaDefinition = {
[item.propertyKey]: options as any,
…schemaDefinition,
};
});
return schemaDefinition;
}
schemaMetadata.properties
contains the object that you stored when you did TypeMetadataStorage.addPropertyMetadata
:
[
{
target: User,
propertyKey: ‘name’,
options: { type: String }
}
]
The forEach
will produce:
{
name: { type: String }
}
In the end, it will be used as the argument to the mongoose.Schema
constructor mongoose/schema.factory.ts at master · nestjs/mongoose · GitHub:
return new mongoose.Schema(
schemaDefinition,
schemaMetadata && schemaMetadata.options,
);
4. So to answer the question:
What should you put as the Prop()
argument?
Remember when Nest does the forEach
to generate the Mongoose Schema?
schemaMetadata.properties?.forEach((item) => {
const options = this.inspectTypeDefinition(item.options as any);
schemaDefinition = {
[item.propertyKey]: options as any,
…schemaDefinition,
};
});
To get the options
it uses inspectTypeDefinition
method. You could see the definition below:
private static inspectTypeDefinition(options: mongoose.SchemaTypeOpts<unknown> | Function): PropOptions {
if (typeof options === 'function') {
if (this.isPrimitive(options)) {
return options;
} else if (this.isMongooseSchemaType(options)) {
return options;
}
return this.createForClass(options as Type<unknown>);
} else if (typeof options.type === 'function') {
options.type = this.inspectTypeDefinition(options.type);
return options;
} else if (Array.isArray(options)) {
return options.length > 0
? [this.inspectTypeDefinition(options[0])]
: options;
}
return options;
}
Here you could draw the conclusion that:
- If the
options
is afunction
such asString
or aSchemaType
it will be returned directly and used as the Mongoose options. - If the
options
is anArray
, it will return the first index of that array and wrap it in an array. - If the
options
is not anArray
orfunction
, for example, if it’s only a plainobject
such as{ type: String, required: true }
, it will be returned directly and used as the Mongoose options.
Answer
So to add a reference from Cat
to Owner
, you could do:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Schema as MongooseSchema } from 'mongoose';
import { Owner } from './owner.schema.ts';
@Schema()
export class Cat extends Document {
@Prop()
name: string;
@Prop({ type: MongooseSchema.Types.ObjectId, ref: Owner.name })
owner: Owner;
}
export const catSchema = SchemaFactory.createForClass(Cat);
As for how to add a reference from Owner
to Cat
, we could do:
@Prop([{ type: MongooseSchema.Types.ObjectId, ref: Cat.name }])
Update
To answer the question in the comment section about:
How to embed schema in another schema?
If you read the answer properly, you should have enough knowledge to do this. But if you didn't, here's the TLDR answer.
Note that I strongly recommend you to read the entire answer before you go here.
image-variant.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema()
export class ImageVariant {
@Prop()
url: string;
@Prop()
width: number;
@Prop()
height: number;
@Prop()
size: number;
}
export const imageVariantSchema = SchemaFactory.createForClass(ImageVariant);
image.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { imageVariantSchema, ImageVariant } from './imagevariant.schema';
@Schema()
export class Image extends Document {
@Prop({ type: imageVariantSchema })
large: ImageVariant;
@Prop({ type: imageVariantSchema })
medium: ImageVariant;
@Prop({ type: imageVariantSchema })
small: ImageVariant;
}
export const imageSchema = SchemaFactory.createForClass(Image);
Solution 2:[2]
import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import * as mongoose from 'mongoose';
import { Education } from '../../education/schemas';
import { RECORD_STATUS } from '../../common/common.constants';
import { Employment } from '../../employment/schemas';
import {
JOB_SEARCH_STATUS,
LANGUAGE_PROFICIENCY
} from '../user-profile.constants';
const externalLinks = {
linkedInUrl: { type: String },
githubUrl: { type: String },
twitterUrl: { type: String },
blogUrl: { type: String },
websiteUrl: { type: String },
stackoverflowUrl: { type: String }
};
const address = {
line1: { type: String, required: true },
line2: { type: String },
zipCode: { type: String },
cityId: { type: Number },
countryId: { type: Number }
};
const language = {
name: { type: String, require: true },
code: { type: String, required: true },
proficiency: { type: String, required: true, enum: LANGUAGE_PROFICIENCY }
};
const options = {
timestamps: true,
};
export type UserProfileDocument = UserProfile & mongoose.Document;
@Schema(options)
export class UserProfile {
_id: string;
@Prop()
firstName: string;
@Prop()
lastName: string;
@Prop()
headline: string;
@Prop({
unique: true,
trim: true,
lowercase: true
})
email: string;
@Prop()
phoneNumber: string
@Prop(raw({
jobSearchStatus: { type: String, enum: JOB_SEARCH_STATUS, required: true }
}))
jobPreferences: Record<string, any>;
@Prop(raw(externalLinks))
externalLinks: Record<string, any>;
@Prop([String])
skills: string[];
@Prop(raw({ type: address, required: false }))
address: Record<string, any>;
@Prop()
birthDate: Date;
@Prop({ type: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Employment' }] })
employments: Employment[];
@Prop({ type: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Education' }] })
educations: Education[];
@Prop(raw([language]))
languages: Record<string, any>[];
@Prop()
timeZone: string;
@Prop()
createdAt: Date;
@Prop()
updatedAt: Date;
@Prop({
enum: RECORD_STATUS,
required: true,
default: RECORD_STATUS.Active
})
recordStatus: string;
}
export const UserProfileSchema = SchemaFactory.createForClass(UserProfile);
Solution 3:[3]
Create SchemaFactory.createForClass
for the SubDocument and refer to its type in the Document.
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema()
export class SubDocument {
@Prop()
name: string;
@Prop()
description: number;
}
const subDocumentSchema = SchemaFactory.createForClass(SubDocument);
@Schema()
export class Document {
@Prop()
name: string;
@Prop({ type: subDocumentSchema })
subDocument: SubDocument;
}
export const documentSchema = SchemaFactory.createForClass(Document);
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | zcoop98 |
Solution 3 |