Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Getter is not applied when schema changes from string to nested schema #15301

Open
2 tasks done
timheerwagen opened this issue Mar 6, 2025 · 2 comments
Open
2 tasks done
Labels
help This issue can likely be resolved in GitHub issues. No bug fixes, features, or docs necessary
Milestone

Comments

@timheerwagen
Copy link

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure the bug has not already been reported

Mongoose version

^8.12.1

Node.js version

22.13.9

MongoDB server version

"mongodb-memory-server": "^10.1.4"

Typescript version (if applicable)

^5.8.2

Description

I am trying to migrate a schema from a string to a nested schema object. The getter works, but the new value is not applied to the returned document.

When using toObject with getters, the time string is removed but no new value is applied:

[
  {
    _id: new ObjectId('67c99f04daa6bc446e6bec72'),
    __v: 0,
    id: '67c99f04daa6bc446e6bec72'
  },
  {
    _id: new ObjectId('67c99f05daa6bc446e6bec74'),
    time: { hours: 12, minutes: 35 },
    __v: 0,
    id: '67c99f05daa6bc446e6bec74'
  }
]

When using lean getters with the "mongoose-lean-getters: "^2.2.1" plugin, the time string is not replaced.

[
  {
    _id: new ObjectId('67c99e3dec853f96ef38a72d'),
    time: '12:34',
    __v: 0
  },
  {
    _id: new ObjectId('67c99e3dec853f96ef38a72f'),
    time: { hours: 12, minutes: 35 },
    __v: 0
  }
]

Steps to Reproduce

Reproduction code:

import mongoose, { Schema } from "mongoose";
import mongooseLeanGetters from "mongoose-lean-getters";

import { MongoMemoryServer } from "mongodb-memory-server";

const mongod = await MongoMemoryServer.create();

const uri = mongod.getUri();

await mongoose.connect(uri);

type Time = { hours: number; minutes: number };

const timeStringToObject = (time: string | Time): Time => {
  console.log("runs", time);

  if (typeof time !== "string") return time;

  const [hours, minutes] = time.split(":");

  console.log("time is string", time, {
    hours,
    minutes,
  });

  return { hours: parseInt(hours), minutes: parseInt(minutes) };
};

const oldUserSchema = new Schema<{ time: string }>(
  {
    time: {
      type: String,
      required: true,
    },
  },
  {}
);

const UserModelName = "User";

const OldUser = mongoose.model(UserModelName, oldUserSchema);

await OldUser.create({ time: "12:34" });

mongoose.deleteModel(UserModelName);

const userSchema = new Schema<{ time: Time }>(
  {
    time: {
      type: new Schema(
        {
          hours: { type: Number, required: true },
          minutes: { type: Number, required: true },
        },
        { _id: false }
      ),
      required: true,
      get: timeStringToObject,
    },
  },
  {}
);

userSchema.plugin(mongooseLeanGetters, {
  defaultLeanOptions: { getters: true },
});

const User = mongoose.model(UserModelName, userSchema);

await User.create({ time: { hours: 12, minutes: 35 } });

const usersLean = await User.find().lean({ getters: true });

console.log(
  usersLean,
  (await User.find()).map((doc) => doc.toObject({ getters: true }))
);

await mongod.stop();

process.exit();

Expected Behavior

I expect the string to be replaced by the new object.

@timheerwagen timheerwagen changed the title Getter is not applied if Schema changes from String to nested Schema Getter is not applied when schema changes from string to nested schema Mar 6, 2025
@vkarpov15
Copy link
Collaborator

This is a somewhat tricky issue because Mongoose sets time to undefined and stores a CastError if time is a string but the schema expects time to be an object. You can see if you run await User.findOne().then(doc => doc.validate()) that you end up with a CastError. This is expected behavior that Mongoose's tests assert on, see tests from #7619 for example.

Our recommended approach is to use a pre('init') hook. init() is the function Mongoose runs to initialize a document loaded from MongoDB, so you can register a pre() hook to transform the value of time before it gets to Mongoose's casting logic:

userSchema.pre('init', function(rawDoc) {
  if (typeof rawDoc.time === 'string') {
    rawDoc.time = timeStringToObject(rawDoc.time);
  }
});

Below is a full script.

import mongoose, { Schema } from 'mongoose';

await mongoose.connect('mongodb://127.0.0.1:27017/mongoose_test');

const timeStringToObject = (time) => {
  if (typeof time !== "string") return time;
  const [hours, minutes] = time.split(":");
  return { hours: parseInt(hours), minutes: parseInt(minutes) };
};

const oldUserSchema = new Schema(
  {
    time: {
      type: String,
      required: true,
    },
  },
  {}
);

const UserModelName = "User";
const OldUser = mongoose.model(UserModelName, oldUserSchema);
await OldUser.deleteMany({});

await OldUser.create({ time: "12:34" });

mongoose.deleteModel(UserModelName);
const userSchema = new Schema({
  time: {
    type: new Schema(
      {
        hours: { type: Number, required: true },
        minutes: { type: Number, required: true },
      },
      { _id: false } 
    ),
    required: true
  },
});

userSchema.pre('init', function(rawDoc) {
  if (typeof rawDoc.time === 'string') {
    rawDoc.time = timeStringToObject(rawDoc.time);
  }
});

const User = mongoose.model(UserModelName, userSchema);

await User.create({ time: { hours: 12, minutes: 35 } });

const usersLean = await User.find().lean({ getters: true });

console.log(
  usersLean,
  await User.findOne(),
  (await User.find()).map((doc) => doc.toObject({ getters: true }))
);

A note of caution: if pre('init') throws an error, then the entire await User.findOne() call will error out, so be cautious about error handling. I don't see any case where timeStringToObject could throw an error in its current implementation, but please be aware of this for future changes.

@vkarpov15 vkarpov15 added the help This issue can likely be resolved in GitHub issues. No bug fixes, features, or docs necessary label Mar 7, 2025
@timheerwagen
Copy link
Author

timheerwagen commented Mar 7, 2025

Thanks for your help, I really appreciate it!

But I encounter the following problems:

  • I get a typescript error when using rawDoc.time
    Image
    Property 'time' does not exist on type 'CallbackWithoutResultAndOptionalError'.ts(2339)
  • When I use `this', the Typescript error is gone, but the function does not work.
userSchema.pre("init", function () {
  if (typeof this.time === "string") {
    this.time = timeStringToObject(this.time);
  }
});
  • There is no way to run the middleware on lean results.
  • I cannot run pre("init") middleware on timeSchema to migrate the data in each field where timeSchema is used - example:
const timeSchema = new Schema(
  {
    hours: { type: Number, required: true },
    minutes: { type: Number, required: true },
  },
  { _id: false }
);

const userSchema = new Schema<{ time: Time }>({
  time: {
    type: timeSchema,

    required: true,
  },
});

timeSchema.pre("init", function (rawDoc) {
  if (typeof rawDoc === "string") {
    rawDoc = timeStringToObject(rawDoc);
  }
});

My goal is to migrate multiple fields in multiple schemas and models with the least amount of overhead, but I feel like I am completely on the wrong track.

@vkarpov15 vkarpov15 reopened this Mar 7, 2025
@vkarpov15 vkarpov15 added this to the 8.12.2 milestone Mar 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help This issue can likely be resolved in GitHub issues. No bug fixes, features, or docs necessary
Projects
None yet
Development

No branches or pull requests

2 participants