Search code examples
node.jstypescriptmongodbmongoosenestjs

NestJS mongoose schema with array of schema doesn't populate virtual id


I have the following schema

export type MapDocument = Map & Document

@Schema({ 
  timestamps: true, 
  versionKey: false,
  id: true
})
export class Map {
  
  constructor(partial?: Partial<Map>) {
    if (partial)
      Object.assign(this, partial)
  }

  @IsOptional()
  @IsUUID()
  @Prop({ type: Object, default: uuidv4, required: false})
  @Exclude({ toPlainOnly: true })
  _id?: Object

  @ApiPropertyOptional({ type: String, format: 'uuid' })
  @IsOptional()
  @IsUUID()
  id?: string

  @ApiProperty()
  @IsAscii()
  @MaxLength(10)
  @Prop()
  building: string

  @ApiProperty()
  @IsInt()
  @Prop()
  @Transform(({ value }) => +value, { toClassOnly: true })  
  floor: number

  @ApiPropertyOptional({ type: Image, format: 'uuid'})
  @IsOptional()
  @IsUUID()
  @Prop({ type: String, ref: 'Image' })
  @Transform(({ value }) => new Image(value).id, { toClassOnly: true })
  image?: string

  @ApiProperty({ type: [Marker] })
  @IsArray()
  @Type(() => Marker)
  @Prop({ type: [MarkerSchema] })
  markers?: Marker[]
  
}

const MapSchema = SchemaFactory.createForClass(Map)
MapSchema.index({ building: 1, floor: 1 }, { unique: true });
const mongooseLeanVirtuals = require('mongoose-lean-virtuals')
MapSchema.plugin(mongooseLeanVirtuals);
export { MapSchema }

export class UpdateMap extends PartialType(Map) {}

Marker is declared as follows

export type MarkerDocument = Marker & Document

@Schema({ 
  timestamps: true, 
  versionKey: false,
  id: true
})
export class Marker {
  
  constructor(partial?: Partial<Marker>) {
    if (partial)
      Object.assign(this, partial)
  }

  @IsOptional()
  @IsUUID()
  @Prop({ type: Object, default: uuidv4, required: false})
  @Exclude({ toPlainOnly: true })
  _id?: Object

  @ApiPropertyOptional({ type: String, format: 'uuid' })
  @IsOptional()
  @IsUUID()
  id?: string

  @ApiPropertyOptional({ type: Desk, format: 'uuid'})
  @IsOptional()
  @IsUUID()
  @Prop({ type: String, required: false, ref: 'Desk' })
  @Transform(({ value }) => new Desk(value).id, { toClassOnly: true })
  desk?: string

  @ApiProperty()
  @IsNumberString()
  @Prop()
  xPercent: string

  @ApiProperty()
  @IsNumberString()
  @Prop()
  yPercent: string
  
}

const MarkerSchema = SchemaFactory.createForClass(Marker)
const mongooseLeanVirtuals = require('mongoose-lean-virtuals')
MarkerSchema.plugin(mongooseLeanVirtuals);
export { MarkerSchema }

export class UpdateMarker extends PartialType(Marker) {}

Important to note that has a field (desk) referencing another collection but I don't want these items to be stored in their own collection but as a subdocument of the 'maps' collection directly

The 'desk' schema is declared as follows

export type DeskDocument = Desk & Document

@Schema({ 
  timestamps: true, 
  versionKey: false,
  id: true
})
@ApiExtraModels(Image)
export class Desk {

  constructor(partial?: Partial<Desk>) {
    if (partial)
      Object.assign(this, partial)
  }
  
  @IsOptional()
  @IsUUID()
  @Prop({ type: Object, default: uuidv4, required: false})
  @Exclude({ toPlainOnly: true })
  _id?: Object

  @ApiPropertyOptional({ type: String, format: 'uuid' })
  @IsOptional()
  @IsUUID()
  id?: string

  @ApiProperty({ type: Board, format: 'uuid'})
  @IsUUID('all')
  @Prop({ type: String, required: true, ref: 'Board' })
  @Transform(({ value }) => value.id, { toClassOnly: true })
  board: string

  @ApiProperty({ type: [Number] })
  @IsArray()
  @Prop({ type: () => [Number], required: true })
  relays?: number[]

  @ApiProperty()
  @IsAscii()
  @MaxLength(250)
  @Prop()
  name: string

  @ApiProperty()
  @IsAscii()
  @MaxLength(250)
  @Prop()
  description: string

  @ApiProperty()
  @IsAscii()
  @MaxLength(10)
  @Prop()
  building: string

  @ApiProperty()
  @IsInt()
  @Prop()
  @Transform(({ value }) => +value, { toClassOnly: true })  
  floor: number

  @ApiProperty()
  @IsAscii()
  @MaxLength(10)
  @Prop()
  code: string

  @ApiPropertyOptional({ type: [Image], format: 'uuid'})
  @IsOptional()
  @IsUUID('all', { each: true })
  @Prop({ type: [String], required: false, ref: 'Image' })
  @Transform(({ value }) => value.id, { toClassOnly: true })
  images?: String[]

}

const DeskSchema = SchemaFactory.createForClass(Desk)
DeskSchema.index({ board: 1, relays: 1 }, { unique: true });
const mongooseLeanVirtuals = require('mongoose-lean-virtuals')
DeskSchema.plugin(mongooseLeanVirtuals)

export { DeskSchema }

export class UpdateDesk extends PartialType(Desk) {}

The find methods tried to populate everything down to desk (no need for me to populate deeper than that)

async findAll(): Promise<Map[]> {
    return (await this.mapModel.find().populate({
      path: 'image',
      model: Image,
      transform: (doc: Image) => new Image(doc),
    }).populate({
      path: 'markers',
      model: Marker,
      transform: (doc: Marker) => new Marker(doc),
      populate: {
        path: 'desk',
        model: Desk,
        transform: (doc: Desk) => new Desk(doc),
        options: { lean: true, virtuals: true },
      }
    }).lean({ virtuals: true }).exec())
    .map(map => new Map(map))
  }

Two issues

  1. Minor: If I don't specify lean: true as an option for the desk populate I get the full mongo document. This is not the case for the 'markers' array which relies on the 'lean' settings
  2. Main: the desk object gets populated but the virtual 'id' field doesn't This is the output I get:
{
  "building": "A",
  "floor": 1,
  "image": {
    "name": "OfficePlan12",
    "url": "https://drive.google.com/uc?id=1H2nnIRjR2e7Z7yoVnHxLCTaYs8s5iHrT",
    "updatedAt": "2022-06-24T09:03:03.786Z",
    "id": "2b31f419-e069-4058-813e-54ce0a941440"
  },
  "updatedAt": "2022-06-26T10:19:22.650Z",
  "markers": [
    {
      "yPercent": "15.853658536585366",
      "xPercent": "18.083462132921174",
      "desk": {
        "images": [
          "b85eefee-eeca-4193-87ae-9329fad8256a",
          "692743d0-a860-4451-b313-b21a144ef387"
        ],
        "description": "Work like a boss",
        "name": "Management desk",
        "createdAt": "2022-02-19T21:12:18.832Z",
        "updatedAt": "2022-06-07T14:02:13.556Z",
        "building": "A",
        "code": "01",
        "floor": 1,
        "relays": [
          "1"
        ],
        "board": "932c3e9b-85bd-42c8-9bc0-a318eea7b036"
      },
      "updatedAt": "2022-06-26T10:19:22.650Z",
      "createdAt": "2022-06-26T10:19:22.650Z",
      "id": "a8149e84-2f62-46c3-990f-531eff82f6d5"
    }
  ],
  "id": "37dc791f-724b-44e2-baaf-bfc606385996"
}

As you can see the 'desk' object doesn't have 'id' field. Any help would be much appreciated, thanks!


Solution

  • I finally found a workaround. It looks pretty bad to be fair but it does the trick. Basically I modified the transform function for the 'desk' document which is the first 'outer' document of my 'inner' document 'image'. The function looks as follows:

    transform: (doc: Desk) => new Desk((new this.deskModel(doc)).toObject({ virtuals: true })),
        options: { lean: true },
        populate: {
          path: 'images',
          model: Image,
          transform: (doc: Image) => new Image(doc)
        }
    

    Basically I needed to inject the corresponding model in the service constructor to be able to call the 'toObject' function with 'options' and 'populate'. Finally I need to use the obtained object to build a new instance of 'Desk' to make sure that class validation functions are applied correctly to my endpoints. Hope this will help someone and that maybe someone can suggest a more elegant solution.