Skip to content
4 changes: 2 additions & 2 deletions benchmarks/mapOfSubdocs.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async function run() {
);
const MinisMap = mongoose.model('MinisMap', minisMap);
await MinisMap.init();

const mini = new Map();
for (let i = 0; i < 2000; ++i) {
const miniID = new mongoose.Types.ObjectId();
Expand All @@ -49,4 +49,4 @@ async function run() {
};

console.log(JSON.stringify(results, null, ' '));
}
}
14 changes: 9 additions & 5 deletions lib/schema/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,22 @@ class SchemaMap extends SchemaType {
return val;
}

const path = options?.path ?? this.path;
const path = this.path;

if (init) {
const map = new MongooseMap({}, path, doc, this.$__schemaType);
const map = new MongooseMap({}, path, doc, this.$__schemaType, options);

// Use the map's path for passing to nested casts.
// If map's parent is a subdocument, use the relative path so nested casts get relative paths.
const mapPath = map.$__pathRelativeToParent != null ? map.$__pathRelativeToParent : map.$__path;

if (val instanceof global.Map) {
for (const key of val.keys()) {
let _val = val.get(key);
if (_val == null) {
_val = map.$__schemaType._castNullish(_val);
} else {
_val = map.$__schemaType.cast(_val, doc, true, null, { ...options, path: path + '.' + key });
_val = map.$__schemaType.cast(_val, doc, true, null, { ...options, path: mapPath + '.' + key });
}
map.$init(key, _val);
}
Expand All @@ -49,7 +53,7 @@ class SchemaMap extends SchemaType {
if (_val == null) {
_val = map.$__schemaType._castNullish(_val);
} else {
_val = map.$__schemaType.cast(_val, doc, true, null, { ...options, path: path + '.' + key });
_val = map.$__schemaType.cast(_val, doc, true, null, { ...options, path: mapPath + '.' + key });
}
map.$init(key, _val);
}
Expand All @@ -58,7 +62,7 @@ class SchemaMap extends SchemaType {
return map;
}

return new MongooseMap(val, path, doc, this.$__schemaType);
return new MongooseMap(val, path, doc, this.$__schemaType, options);
}

clone() {
Expand Down
7 changes: 7 additions & 0 deletions lib/schema/subdocument.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ SchemaSubdocument.prototype.cast = function(val, doc, init, priorVal, options) {
if (init) {
subdoc = new Constructor(void 0, selected, doc, false, { defaults: false });
delete subdoc.$__.defaults;
// Don't pass `path` to $init - it's only for the subdocument itself, not its fields.
// For change tracking, subdocuments use relative paths internally.
// Here, `options.path` contains the absolute path and is only used by the subdocument constructor, not by $init.
if (options.path != null) {
options = { ...options };
delete options.path;
}
subdoc.$init(val, options);
const exclude = isExclusive(selected);
applyDefaults(subdoc, selected, exclude);
Expand Down
2 changes: 1 addition & 1 deletion lib/types/array/methods/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ const methods = {

pull() {
const values = [].map.call(arguments, (v, i) => this._cast(v, i, { defaults: false }), this);
let cur = this[arrayParentSymbol].get(this[arrayPathSymbol]);
let cur = this;
if (utils.isMongooseArray(cur)) {
cur = cur.__array;
}
Expand Down
68 changes: 60 additions & 8 deletions lib/types/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,29 @@ const populateModelSymbol = require('../helpers/symbols').populateModelSymbol;
*/

class MongooseMap extends Map {
constructor(v, path, doc, schemaType) {
constructor(v, path, doc, schemaType, options) {
if (getConstructorName(v) === 'Object') {
v = Object.keys(v).reduce((arr, key) => arr.concat([[key, v[key]]]), []);
}
super(v);
this.$__parent = doc != null && doc.$__ != null ? doc : null;
this.$__path = path;

// Calculate the full path from the root document
// Priority: parent.$basePath (from subdoc) > options.path (from parent map/structure) > path (schema path)
// Subdocuments have the most up-to-date path info, so prefer that over options.path
if (this.$__parent?.$isSingleNested && this.$__parent.$basePath) {
this.$__path = this.$__parent.$basePath + '.' + path;
// Performance optimization: store path relative to parent subdocument
// to avoid string operations in set() hot path
this.$__pathRelativeToParent = path;
} else if (options?.path) {
this.$__path = options.path;
this.$__pathRelativeToParent = null;
} else {
this.$__path = path;
this.$__pathRelativeToParent = null;
}

this.$__schemaType = schemaType == null ? new Mixed(path) : schemaType;

this.$__runDeferred();
Expand All @@ -37,6 +53,14 @@ class MongooseMap extends Map {

if (value != null && value.$isSingleNested) {
value.$basePath = this.$__path + '.' + key;
// Store the path relative to parent subdoc for efficient markModified()
if (this.$__pathRelativeToParent != null) {
// Map's parent is a subdocument, store path relative to that subdoc
value.$pathRelativeToParent = this.$__pathRelativeToParent + '.' + key;
} else {
// Map's parent is root document, store the full path
value.$pathRelativeToParent = this.$__path + '.' + key;
}
}
}

Expand Down Expand Up @@ -136,9 +160,16 @@ class MongooseMap extends Map {
}
} else {
try {
const options = this.$__schemaType.$isMongooseDocumentArray || this.$__schemaType.$isSingleNested || this.$__schemaType.$isMongooseArray || this.$__schemaType.$isSchemaMap ?
{ path: fullPath.call(this) } :
null;
let options = null;
if (this.$__schemaType.$isMongooseDocumentArray || this.$__schemaType.$isSingleNested || this.$__schemaType.$isMongooseArray || this.$__schemaType.$isSchemaMap) {
options = { path: fullPath.call(this) };
// For subdocuments, also pass the relative path to avoid string operations
if (this.$__schemaType.$isSingleNested) {
options.pathRelativeToParent = this.$__pathRelativeToParent != null ?
this.$__pathRelativeToParent + '.' + key :
this.$__path + '.' + key;
}
}
value = this.$__schemaType.applySetters(
value,
this.$__parent,
Expand All @@ -157,13 +188,34 @@ class MongooseMap extends Map {

super.set(key, value);

// Set relative path on subdocuments to avoid string operations in markModified()
// The path should be relative to the parent subdocument (if any), not just the key
if (value != null && value.$isSingleNested) {
if (this.$__pathRelativeToParent != null) {
// Map's parent is a subdocument, store path relative to that subdoc (e.g., 'items.i2')
value.$pathRelativeToParent = this.$__pathRelativeToParent + '.' + key;
} else {
// Map's parent is root document, store just the full path
value.$pathRelativeToParent = this.$__path + '.' + key;
}
}

if (parent != null && parent.$__ != null && !deepEqual(value, priorVal)) {
const path = fullPath.call(this);
parent.markModified(path);
// Optimization: if parent is a subdocument, use precalculated relative path
// to avoid building a full path just to strip the parent's prefix
let pathToMark;
if (this.$__pathRelativeToParent != null) {
// Parent is a subdocument - use precalculated relative path (e.g., 'items.i1')
pathToMark = this.$__pathRelativeToParent + '.' + key;
} else {
// Parent is root document or map - use full path
pathToMark = fullPath.call(this);
}
parent.markModified(pathToMark);
// If overwriting the full document array or subdoc, make sure to clean up any paths that were modified
// before re: #15108
if (this.$__schemaType.$isMongooseDocumentArray || this.$__schemaType.$isSingleNested) {
cleanModifiedSubpaths(parent, path);
cleanModifiedSubpaths(parent, pathToMark);
}
}

Expand Down
36 changes: 32 additions & 4 deletions lib/types/subdocument.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,21 @@ function Subdocument(value, fields, parent, skipId, options) {
if (options != null && options.path != null) {
this.$basePath = options.path;
}
Document.call(this, value, fields, skipId, options);
if (options != null && options.pathRelativeToParent != null) {
this.$pathRelativeToParent = options.pathRelativeToParent;
}

// Don't pass `path` to Document constructor: path is used for storing the
// absolute path to this schematype relative to the top-level document, but
// subdocuments use relative paths (relative to the parent document) to track changes.
// This avoids the subdocument's fields receiving the subdocument's path as options.path.
let documentOptions = options;
if (options != null && options.path != null) {
documentOptions = Object.assign({}, options);
delete documentOptions.path;
}

Document.call(this, value, fields, skipId, documentOptions);

delete this.$__.priorDoc;
}
Expand Down Expand Up @@ -123,9 +137,18 @@ Subdocument.prototype.$__fullPath = function(path) {
*/

Subdocument.prototype.$__pathRelativeToParent = function(p) {
// If this subdocument has a stored relative path (set by map when subdoc is created),
// use it directly to avoid string operations
if (this.$pathRelativeToParent != null) {
return p == null ? this.$pathRelativeToParent : this.$pathRelativeToParent + '.' + p;
}

if (p == null) {
return this.$basePath;
}
if (!this.$basePath) {
return p;
}
return [this.$basePath, p].join('.');
};

Expand Down Expand Up @@ -165,17 +188,22 @@ Subdocument.prototype.$isValid = function(path) {
Subdocument.prototype.markModified = function(path) {
Document.prototype.markModified.call(this, path);
const parent = this.$parent();
const fullPath = this.$__pathRelativeToParent(path);

if (parent == null || fullPath == null) {
if (parent == null) {
return;
}

const pathToMark = this.$__pathRelativeToParent(path);
if (pathToMark == null) {
return;
}

const myPath = this.$__pathRelativeToParent().replace(/\.$/, '');
if (parent.isDirectModified(myPath) || this.isNew) {
return;
}
this.$__parent.markModified(fullPath, this);

this.$__parent.markModified(pathToMark, this);
};

/*!
Expand Down
Loading