Mongoose Subdocuments in Nest.js
Asked Answered
B

3

40

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?

Boron answered 2/7, 2020 at 20:31 Comment(0)
L
94

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 as Number
  • string will be serialized as String
  • boolean will be serialized as Boolean
  • 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:

  1. If the options is a function such as String or a SchemaType it will be returned directly and used as the Mongoose options.
  2. If the options is an Array, it will return the first index of that array and wrap it in an array.
  3. If the 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.

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);
Leak answered 3/7, 2020 at 11:0 Comment(9)
Works like a charm! Great explanation, thanks a lot!Boron
This does not answer the question though. How to create nested schema with decorators?Adanadana
@Adanadana instead of @Prop({type: [Cat]}) you write @Prop([{ type: MongooseSchema.Types.ObjectId, ref: Cat.name }]) . Could you provide an example on what you mean?Boron
Well, this is not a nested schema, it's only a foreign key to the Cat schema. The question was how to create a nested schema. The cat should be saved inside the Owner, not on a separate collection.Adanadana
@Adanadana The question was "referencing one Schema inside another" not "embedding schema inside another". Your question is a different question. Please take a look at else if (typeof options.type === 'function') in the inspectTypeDefinition method. That's the answer that you want.Leak
@EdwardAnthony in your example, how would you save a Cat inside an Owner.cats array ? I use Owner.cats.push(catDocument) and it saves an entire document instead of just the id. If I push only the id - I get a type error because the Owner Schema is defined as "cats: Cat[]"Lithia
@Lithia It won't save the entire document. As long as you specify the ref in the @Prop decorator, it will be saved as a relation reference, therefore it will only save the id. This functionality doesn't come from @nestjs/mongoose library, it comes from Mongoose.Leak
What about the mongoose helper functions such as parent.subdoc.id(someID) since the types are defined on class props and typescript will complain they don't exist on the type. Simply adding an intersection like @Prop({ type: Schema.Types.ObjectId, ref: Cats.name }) cats: Cats[] & Document; allows mongoose helper functions but now TS complains anytime we try to use direct assignment for subdocs like owner.cats = [...listOfCats ]. The latter is sometimes useful for editing a document instance in place. How can we satisfy both former and the latter to make full use of mongoose in Nestjs?Purveyance
Its a bit cumbersome but I've resolved it. Setup requires casting the subdocs where using the instance as in const lineItems = order.lineItems as Document & OrderLineItem[]; const item = lineItems.id(itemId); Extra work because you have to create an extra variable to type the subdocs but this allows the use of the query helpers as well as keeping the Class ref on the prop types of the parent class.Purveyance
P
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);
Pachyderm answered 23/12, 2021 at 21:34 Comment(0)
E
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);
Earthling answered 17/6, 2021 at 2:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.