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?
Schemas are used to define Models. Models are responsible for creating and reading documents from the underlying MongoDB database. Schemas can be created with NestJS decorators, or with Mongoose itself manually.
Schemas can be nested to represent relationships between objects (e.g. foreign key relationships). For example, a Blog may have an author represented by a User object. Use a Nested field to represent the relationship, passing in a nested schema.
Mongoose has two distinct notions of subdocuments: arrays of subdocuments and single nested subdocuments. var childSchema = new Schema ( { name: 'string' }); var parentSchema = new Schema ( { // Array of subdocuments children: [childSchema], // Single nested subdocuments.
I highly prefer to use a combination of Mongoose and the mongoose nestjs library. The pro of use only the Nestjs library is that you basically don’t need to use an interface, only the schema directly. If you use mongoose directly to define the schema, you need to use an interface to create each object in the MongoDB.
For an array subdocument, this is equivalent to calling .pull () on the subdocument. For a single nested subdocument, remove () is equivalent to setting the subdocument to null. If you create a schema with an array of objects, mongoose will automatically convert the object to a schema for you:
In Mongoose, subdocuments are documents that are nested in other documents. You can spot a subdocument when a schema is nested in another schema. Note: MongoDB calls subdocuments embedded documents. In practice, you don’t have to create a separate childSchema like the example above.
I dug into the source code and learned how Schema class is converted by the SchemaFactory.createForClass method.
@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. }); 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 as Number string will be serialized as String boolean will be serialized as Boolean 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.
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, ); 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; } options is a function such as String or a SchemaType it will be returned directly and used as the Mongoose options.options is an Array, it will return the first index of that array and wrap it in an array.options is not an Array or function, for example, if it’s only a plain object such as { type: String, required: true }, it will be returned directly and used as the Mongoose options.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 }]) To answer the question in the comment section about:
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);
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With