Search code examples
mongodbtypescriptmongoosetypegoose

Typegoose Models and Many to Many Relationships


So I'm building a backend with NestJs and Typegoose, having the following models:

DEPARTMENT

@modelOptions({ schemaOptions: { collection: 'user_department', toJSON: { virtuals: true }, toObject: { virtuals: true }, id: false } })
export class Department {

    @prop({ required: true })
    _id: mongoose.Types.ObjectId;

    @prop({ required: true })
    name: string;

    @prop({ ref: () => User, type: String })
    public supervisors: Ref<User>[];

    members: User[];

    static paginate: PaginateMethod<Department>;
}

USER

@modelOptions({ schemaOptions: { collection: 'user' } })
export class User {

    @prop({ required: true, type: String })
    _id: string;

    @prop({ required: true })
    userName: string;

    @prop({ required: true })
    firstName: string;

    @prop({ required: true })
    lastName: string;

    [...]

    @prop({ ref: () => Department, default: [] })
    memberOfDepartments?: Ref<Department>[];

    static paginate: PaginateMethod<User>;
}

As you might guess, one user might be in many departments and one department can have many members(users). As the count of departments is more or less limited (compared with users), I decided to use one way embedding like described here: Two Way Embedding vs. One Way Embedding in MongoDB (Many-To-Many). That's the reason User holds the array "memberOfDepartments", but Department does not save a Member-array (as the @prop is missing).

The first question is, when I request the Department-object, how can I query members of it? The query must look for users where the department._id is in the array memberOfDepartments.

I tried multiple stuff here, like virtual populate: https://typegoose.github.io/typegoose/docs/api/virtuals/#virtual-populate like this on department.model:

@prop({
    ref: () => User,
    foreignField: 'memberOfDepartments', 
    localField: '_id', // compare this to the foreign document's value defined in "foreignField"
    justOne: false
})
public members: Ref<User>[];

But it won't output that property. My guess is, that this only works for one-to-many on the one site... I also tried with set/get but I have trouble using the UserModel inside DepartmentModel.

Currently I'm "cheating" by doing this in the service:

async findDepartmentById(id: string): Promise<Department> {
    const res = await this.departmentModel
        .findById(id)
        .populate({ path: 'supervisors', model: User })
        .lean()
        .exec();

    res.members = await this.userModel.find({ memberOfDepartments: res._id })
        .lean()
        .exec()

    if (!res) {
        throw new HttpException(
            'No Department with the id=' + id + ' found.',
            HttpStatus.NOT_FOUND,
        );
    }
    return res;
}

.. but I think this is not the proper solution to this, as my guess is it belongs in the model.

The second question is, how would I handle a delete of a department resulting in that i have to delete the references to that dep. in the user?

I know that there is documentation for mongodb and mongoose out there, but I just could not get my head arround how this would be done "the typegoose way", since the typegoose docs seem very limited to me. Any hints appreciated.


Solution

  • So, this was not easy to find out, hope this answer helps others. I still think there is the need to document more of the basic stuff - like deleting the references to an object when the object gets deleted. Like, anyone with references will need this, yet not in any documentation (typegoose, mongoose, mongodb) is given a complete example.

    Answer 1:

    @prop({
        ref: () => User,
        foreignField: 'memberOfDepartments', 
        localField: '_id', // compare this to the foreign document's value defined in "foreignField"
        justOne: false
    })
    public members: Ref<User>[];
    

    This is, as it is in the question, the correct way to define the virtual. But what I did wrong and I think is not so obvious: I had to call

    .populate({ path: 'members', model: User })
    

    explicitly as in

     const res = await this.departmentModel
                .findById(id)
                .populate({ path: 'supervisors', model: User })
                .populate({ path: 'members', model: User })
                .lean()
                .exec();
    

    If you don't do this, you won't see the property members at all. I had problems with this because, if you do it on a reference field like supervisors, you get at least an array ob objectIds. But if you don't pupulate the virtuals, you get no members-field back at all.

    Answer 2: My research lead me to the conclusion that the best solution tho this is to use a pre-hook. Basically you can define a function, that gets called before (if you want after, use a post-hook) a specific operation gets executed. In my case, the operation is "delete", because I want to delete the references before i want to delete the document itself. You can define a pre-hook in typegoose with this decorator, just put it in front of your model:

    @pre<Department>('deleteOne', function (next) {
        const depId = this.getFilter()["_id"];
        getModelForClass(User).updateMany(
            { 'timeTrackingProfile.memberOfDepartments': depId },
            { $pull: { 'timeTrackingProfile.memberOfDepartments': depId } },
            { multi: true }
        ).exec();
        next();
    })
    export class Department {
    [...]   
    }
    

    A lot of soultions found in my research used "remove", that gets called when you call f.e. departmentmodel.remove(). Do not use this, as remove() is deprecated. Use "deleteOne()" instead. With "const depId = this.getFilter()["_id"];" you are able to access the id of the document thats going to be deletet within the operation.