Repository: Automattic/mongoose (JavaScript)

Issue:
Follow up to "populated documents get saved as strings"
### Prerequisites

- [X] I have written a descriptive issue title
- [X] I have searched existing issues to ensure the bug has not already been reported


### Mongoose version

8.5.2

### Node.js version

20.15.1

### MongoDB server version

7.0.12

### Typescript version (if applicable)

_No response_

### Description

There is an edge case that we missed in #14759. When calling populate on a document the `_id` is again stored as a string in the DB.

I was able to track it down to this line. But this may not be the whole picture.

https://github.com/Automattic/mongoose/blob/9572cbd7436f1acb2e95f89f6f1267a1201fe5c1/lib/helpers/populate/getModelsMapForPopulate.js#L629

```diff
 function convertTo_id(val, schema) { 
   if (val != null && val.$__ != null) { 
-    return val._id; 
+    return val._doc._id;
   } 
```

### Steps to Reproduce

It's the same example as in #14759 but I added `pet.populate('owner')` before `pet.save()`.

```js
mongoose.Schema.ObjectId.get(
  value => {
    if (value?.constructor?.name?.toLowerCase() === 'objectid') return String(value)
    return value
  }
)

const ownerSchema = new mongoose.Schema({ name: 'String' })
const petSchema = new mongoose.Schema({
  name: 'String',
  owner: { type: 'ObjectId', ref: 'owner' }
})

const Owner = mongoose.model('owner', ownerSchema)
const Pet = mongoose.model('pet', petSchema)

const owner = new Owner({ name: 'Alice' })
const pet = new Pet({ name: 'Kitty', owner: owner })

await owner.save()

console.log(typeof pet._doc.owner.$__.wasPopulated.value) // object 😀
await pet.populate('owner') // This breaks it 💥💥💥💥💥💥💥💥💥💥
console.log(typeof pet._doc.owner.$__.wasPopulated.value) // string 🙁

await pet.save()

/**
 * The property 'owner' of 'pet' has unexpectedly been saved
 * as a string into the mongoDB instead of ObjectId.
 * 
 * No matter how I query for it, mongoose is not
 * able to find it...
 */

const reloadAttempts = await Promise.all([
  Pet.findOne({ owner: owner }),
  Pet.findOne({ owner: owner._id }),
  Pet.findOne({ owner: String(owner._id) }),
  Pet.findOne({ owner: new mongoose.Types.ObjectId(owner._id) }),
])

console.log(reloadAttempts) // Logs [ null, null, null, null ] but shouldn't
```

### Expected Behavior

_No response_


Discussion:

Patch:
diff --git a/lib/document.js b/lib/document.js
index c6214d477f2..f6a6016c72e 100644
--- a/lib/document.js
+++ b/lib/document.js
@@ -842,7 +842,7 @@ function init(self, obj, doc, opts, prefix) {
  */
 
 Document.prototype.updateOne = function updateOne(doc, options, callback) {
-  const query = this.constructor.updateOne({ _id: this._id }, doc, options);
+  const query = this.constructor.updateOne({ _id: this._doc._id }, doc, options);
   const self = this;
   query.pre(function queryPreUpdateOne(cb) {
     self.constructor._middleware.execPre('updateOne', self, [self], cb);
@@ -883,7 +883,7 @@ Document.prototype.updateOne = function updateOne(doc, options, callback) {
 
 Document.prototype.replaceOne = function replaceOne() {
   const args = [...arguments];
-  args.unshift({ _id: this._id });
+  args.unshift({ _id: this._doc._id });
   return this.constructor.replaceOne.apply(this.constructor, args);
 };
 
@@ -3050,7 +3050,7 @@ Document.prototype.$__validate = function(pathsToValidate, options, callback) {
       } else if (val != null && val.$__ != null && val.$__.wasPopulated) {
         // Array paths, like `somearray.1`, do not show up as populated with `$populated()`,
         // so in that case pull out the document's id
-        val = val._id;
+        val = val._doc._id;
       }
       const scope = _this.$__.pathsToScopes != null && path in _this.$__.pathsToScopes ?
         _this.$__.pathsToScopes[path] :
diff --git a/lib/error/parallelSave.js b/lib/error/parallelSave.js
index 57ac458238d..25e12481d49 100644
--- a/lib/error/parallelSave.js
+++ b/lib/error/parallelSave.js
@@ -15,7 +15,7 @@ class ParallelSaveError extends MongooseError {
    */
   constructor(doc) {
     const msg = 'Can\'t save() the same doc multiple times in parallel. Document: ';
-    super(msg + doc._id);
+    super(msg + doc._doc._id);
   }
 }
 
diff --git a/lib/error/parallelValidate.js b/lib/error/parallelValidate.js
index 477954dc870..84b7940d6df 100644
--- a/lib/error/parallelValidate.js
+++ b/lib/error/parallelValidate.js
@@ -16,7 +16,7 @@ class ParallelValidateError extends MongooseError {
    */
   constructor(doc) {
     const msg = 'Can\'t validate() the same doc multiple times in parallel. Document: ';
-    super(msg + doc._id);
+    super(msg + doc._doc._id);
   }
 }
 
diff --git a/lib/error/version.js b/lib/error/version.js
index 85f2921a517..6bc2b5d3af5 100644
--- a/lib/error/version.js
+++ b/lib/error/version.js
@@ -17,7 +17,7 @@ class VersionError extends MongooseError {
    */
   constructor(doc, currentVersion, modifiedPaths) {
     const modifiedPathsStr = modifiedPaths.join(', ');
-    super('No matching document found for id "' + doc._id +
+    super('No matching document found for id "' + doc._doc._id +
       '" version ' + currentVersion + ' modifiedPaths "' + modifiedPathsStr + '"');
     this.version = currentVersion;
     this.modifiedPaths = modifiedPaths;
diff --git a/lib/helpers/model/discriminator.js b/lib/helpers/model/discriminator.js
index d587c7930bb..7c8633ab5dc 100644
--- a/lib/helpers/model/discriminator.js
+++ b/lib/helpers/model/discriminator.js
@@ -115,6 +115,9 @@ module.exports = function discriminator(model, name, schema, tiedValue, applyPlu
       }
     }
 
+    // Shallow clone `obj` so we can add additional properties without modifying original
+    // schema. `Schema.prototype.clone()` copies `obj` by reference, no cloning.
+    schema.obj = { ...schema.obj };
     mergeDiscriminatorSchema(schema, baseSchema);
 
     // Clean up conflicting paths _after_ merging re: gh-6076
diff --git a/lib/helpers/populate/getModelsMapForPopulate.js b/lib/helpers/populate/getModelsMapForPopulate.js
index 276698217d0..2b2a2b40312 100644
--- a/lib/helpers/populate/getModelsMapForPopulate.js
+++ b/lib/helpers/populate/getModelsMapForPopulate.js
@@ -626,7 +626,7 @@ function _getLocalFieldValues(doc, localField, model, options, virtual, schema)
 
 function convertTo_id(val, schema) {
   if (val != null && val.$__ != null) {
-    return val._id;
+    return val._doc._id;
   }
   if (val != null && val._id != null && (schema == null || !schema.$isSchemaMap)) {
     return val._id;
@@ -636,7 +636,7 @@ function convertTo_id(val, schema) {
     const rawVal = val.__array != null ? val.__array : val;
     for (let i = 0; i < rawVal.length; ++i) {
       if (rawVal[i] != null && rawVal[i].$__ != null) {
-        rawVal[i] = rawVal[i]._id;
+        rawVal[i] = rawVal[i]._doc._id;
       }
     }
     if (utils.isMongooseArray(val) && val.$schema()) {
diff --git a/lib/helpers/populate/markArraySubdocsPopulated.js b/lib/helpers/populate/markArraySubdocsPopulated.js
index f9cc83cb6bf..c0ea5ef5207 100644
--- a/lib/helpers/populate/markArraySubdocsPopulated.js
+++ b/lib/helpers/populate/markArraySubdocsPopulated.js
@@ -17,11 +17,11 @@ const utils = require('../../utils');
  */
 
 module.exports = function markArraySubdocsPopulated(doc, populated) {
-  if (doc._id == null || populated == null || populated.length === 0) {
+  if (doc._doc._id == null || populated == null || populated.length === 0) {
     return;
   }
 
-  const id = String(doc._id);
+  const id = String(doc._doc._id);
   for (const item of populated) {
     if (item.isVirtual) {
       continue;
diff --git a/lib/model.js b/lib/model.js
index 05d89db8d76..e0270872155 100644
--- a/lib/model.js
+++ b/lib/model.js
@@ -2345,7 +2345,7 @@ Model.findByIdAndUpdate = function(id, update, options) {
 
   // if a model is passed in instead of an id
   if (id instanceof Document) {
-    id = id._id;
+    id = id._doc._id;
   }
 
   return this.findOneAndUpdate.call(this, { _id: id }, update, options);
@@ -3408,7 +3408,7 @@ Model.bulkSave = async function bulkSave(documents, options) {
     documents.map(async(document) => {
       const documentError = bulkWriteError && bulkWriteError.writeErrors.find(writeError => {
         const writeErrorDocumentId = writeError.err.op._id || writeError.err.op.q._id;
-        return writeErrorDocumentId.toString() === document._id.toString();
+        return writeErrorDocumentId.toString() === document._doc._id.toString();
       });
 
       if (documentError == null) {
@@ -4436,7 +4436,7 @@ function _assign(model, vals, mod, assignmentOpts) {
 
         for (let __val of _val) {
           if (__val instanceof Document) {
-            __val = __val._id;
+            __val = __val._doc._id;
           }
           key = String(__val);
           if (rawDocs[key]) {
diff --git a/lib/query.js b/lib/query.js
index 55e5d3f02af..f928ca75375 100644
--- a/lib/query.js
+++ b/lib/query.js
@@ -3380,9 +3380,9 @@ Query.prototype._findOneAndUpdate = async function _findOneAndUpdate() {
   if (!this._update || Object.keys(this._update).length === 0) {
     if (options.upsert) {
       // still need to do the upsert to empty doc
-      const doc = clone(this._update);
-      delete doc._id;
-      this._update = { $set: doc };
+      const $set = clone(this._update);
+      delete $set._id;
+      this._update = { $set };
     } else {
       this._executionStack = null;
       const res = await this._findOne();


Test patch:
diff --git a/test/model.discriminator.test.js b/test/model.discriminator.test.js
index 8e8d529e2df..a829211b236 100644
--- a/test/model.discriminator.test.js
+++ b/test/model.discriminator.test.js
@@ -620,6 +620,23 @@ describe('model', function() {
         Person.discriminator('Parent2', parentSchema.clone());
       });
 
+      it('clone() does not modify original schema `obj` (gh-14821)', function() {
+        const personSchema = new Schema({
+          name: String
+        }, { discriminatorKey: 'kind' });
+
+        const parentSchema = new Schema({
+          child: String
+        });
+
+        const Person = db.model('Person', personSchema);
+        const Parent = Person.discriminator('Parent', parentSchema.clone());
+
+        assert.ok(!Person.schema.obj.child);
+        assert.ok(!personSchema.obj.child);
+        assert.ok(Parent.schema.obj.child);
+      });
+
       it('clone() allows reusing with different models (gh-5721)', async function() {
         const schema = new mongoose.Schema({
           name: String
diff --git a/test/model.populate.test.js b/test/model.populate.test.js
index bbdfadfe281..7f0fe844eb8 100644
--- a/test/model.populate.test.js
+++ b/test/model.populate.test.js
@@ -11049,6 +11049,41 @@ describe('model: populate:', function() {
     assert.equal(pet2.owner.name, 'Alice');
   });
 
+  it('avoids populating manually populated doc as getter value (gh-14827)', async function() {
+    const ownerSchema = new mongoose.Schema({
+      _id: {
+        type: 'ObjectId',
+        get(value) {
+          return value == null ? value : value.toString();
+        }
+      },
+      name: 'String'
+    });
+    const petSchema = new mongoose.Schema({
+      name: 'String',
+      owner: { type: 'ObjectId', ref: 'Owner' }
+    });
+
+    const Owner = db.model('Owner', ownerSchema);
+    const Pet = db.model('Pet', petSchema);
+
+    const _id = new mongoose.Types.ObjectId();
+    const owner = new Owner({ _id, name: 'Alice' });
+    const pet = new Pet({ name: 'Kitty', owner: owner });
+
+    await owner.save();
+
+    assert.equal(typeof pet._doc.owner.$__.wasPopulated.value, 'object');
+    await pet.populate('owner');
+    assert.equal(typeof pet._doc.owner.$__.wasPopulated.value, 'object');
+
+    await pet.save();
+
+
+    const fromDb = await Pet.findOne({ owner: _id }).lean().orFail();
+    assert.ok(fromDb.owner instanceof mongoose.Types.ObjectId);
+  });
+
   it('makes sure that populate works correctly with duplicate foreignField with lean(); (gh-14794)', async function() {
     const authorSchema = new mongoose.Schema({
       group: String,