Skip to content

fix(populate): make refPath work as a function#16035

Merged
vkarpov15 merged 10 commits intomasterfrom
codex/gh-16028-codex
Feb 22, 2026
Merged

fix(populate): make refPath work as a function#16035
vkarpov15 merged 10 commits intomasterfrom
codex/gh-16028-codex

Conversation

@AbdelrahmanHafez
Copy link
Collaborator

@AbdelrahmanHafez AbdelrahmanHafez commented Feb 20, 2026

Fixes #16028
Re #6669 #8452 #8469 #8731

While working on refPath as a function, I found two behavior gaps:

  1. In document assignment/casting ($set / create), function refPath was not handled correctly for the "assign a document to an ObjectId ref" flow, leading to confusing cast errors (for example, path.split is not a function wrapped in CastError).
  2. In populate, function refPath was called with options.path (for example notifications.target) instead of an indexed path per subdoc (for example notifications.0.target).

That mismatch meant array subdocs could not reliably resolve per-element model paths, and $set vs populate could disagree.

What this PR changes:

  • Unifies function refPath call semantics across assignment/casting and populate.
  • Keeps the established design from Run refPath and ref functions on subdocument being populated rather than top level doc #8469 (comment):
    • this is the subdocument/document currently being processed.
    • returned refPath is interpreted as root-relative.
  • For array subdocs, passes root-relative indexed path to function refPath (for example notifications.0.target, channels.1.notifications.0.target).
  • Uses the same indexed path format in both $set and populate flows.
  • Throws MongooseError (instead of leaking TypeError) when function refPath returns a non-string.
  • Preserves discriminator behavior by normalizing numeric segments only for internal discriminator schema traversal (not for user-facing path passed to refPath).

I chose to always use indexed paths because assignment/casting needs a concrete element path to resolve the correct model for that exact subdocument.
If populate uses an unindexed path while $set uses indexed resolution, behavior diverges.
Using indexed root-relative paths everywhere gives one consistent contract and ensures function refPath computes the same result in both flows.
This passes all the current tests since the existing tests relied on regex replacements like path.replace(/\.item$/, '.kind');

Given how broken refPath currently is, I think it's safe to introduce this change in a minor version

I tested this feature extensively with different scenarios:

  • top-level docs
  • subdoc arrays
  • deeply nested arrays
  • lean()
  • doc.populate()
  • shared schema reused across multiple arrays
  • single nested subdocs
  • parity of path argument between $set and populate
  • explicit non-string function refPath error handling in both assignment and populate.

Additionally, I updated populate docs to clarify function refPath contract, root-relative indexed path behavior, and the array-subdocument pattern.

In particular, for array subdocs, static string refPath like items.targetModel is insufficient when per-element resolution is needed. The recommended pattern is function refPath, for example:

refPath: function(doc, path) {
  return path.replace(/\.target$/, '.targetModel');
}

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes and clarifies support for function-style refPath across both document assignment/casting ($set / create) and populate(), ensuring consistent (doc, path) semantics (including indexed paths for array subdocs) and improving error handling when refPath returns invalid values.

Changes:

  • Aligns refPath function invocation semantics between assignment/casting and populate (correct this, doc, and root-relative indexed path).
  • Improves error reporting by throwing MongooseError when a function refPath returns a non-string, rather than leaking TypeError.
  • Adds extensive regression tests and updates populate documentation to codify the function refPath contract.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/model.populate.test.js Adds comprehensive populate coverage for function refPath, including indexed paths in arrays and lean/doc.populate parity.
test/document.test.js Adds assignment/casting ($set / create) coverage for function refPath, including non-string return error handling.
lib/helpers/populate/modelNamesFromRefPath.js Ensures non-string refPath values throw a MongooseError early with a clear message.
lib/helpers/populate/getModelsMapForPopulate.js Updates populate model resolution for function refPath to use indexed paths per subdoc and avoids discriminator traversal issues with numeric segments.
lib/document.js Fixes assignment/casting flow to call function refPath correctly (with root-relative indexed path) and validates return type.
docs/populate.md Documents the function refPath contract (root-relative path, indexed array paths, consistent behavior across assignment and populate).

@AbdelrahmanHafez AbdelrahmanHafez marked this pull request as ready for review February 20, 2026 04:54
Copy link
Collaborator

@hasezoey hasezoey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, some minor nitpicks.

Comment on lines 19 to +20
const StrictPopulate = require('../../error/strictPopulate');
const numericPathSegmentPattern = '\\.\\d+(?=\\.|$)';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe there should be a empty line in between (as the first part are just imports)

Comment on lines +460 to +461
for (const key of Object.keys(source)) {
const value = source[key];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this faster than just using Object.entries()?

Comment on lines +3235 to +3246
it('should work with string refPath when assigning a document (baseline)', async function() {
// Arrange
const { User, ActivityLog } = createTestContext({ refPath: 'targetModel' });
const user = await User.create({ name: 'Hafez' });

// Act
const activityLog = await ActivityLog.create({ targetModel: 'User', targetUser: user });
const activityLogFromDb = await ActivityLog.findById(activityLog._id).populate('targetUser');

// Assert
assert.equal(activityLogFromDb.targetUser.name, 'Hafez');
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the baseline, shouldnt it be ordered before the other tests?

Copy link
Collaborator

@vkarpov15 vkarpov15 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a couple of minor improvements, mostly perf related, but LGTM overall thanks.

Reading through this PR made me just want to get rid of refPath() function support though - we added that in #6683 but I find the refPath() function API to be inferior to ref() function, which we added even earlier (at least #6554) . WDYT?

}
const modelName = val.get(refPath);

if (typeof refPath === 'function' && !refPath[modelSymbol]) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for this refPath[modelSymbol] check here?

let normalizedRefPath = null;
const schemaPath = schema?.path;

if (schemaPath != null && populatePath.endsWith('.' + schemaPath)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For performance reasons, we should avoid using startsWith() and endsWith(), in favor of charAt() and slice()

Image

*
* @param {Function} refPath
* @param {Document|Object} doc
* @param {SchemaType} schema
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going forward, we should make sure we do not name SchemaType variables schema, that is confusing since a Schema is a distinct class. I know this is far from the only place where we use schema to refer to a SchemaType, but I would like to change that going forward.

@vkarpov15 vkarpov15 merged commit 36b0729 into master Feb 22, 2026
52 checks passed
@vkarpov15 vkarpov15 added this to the 9.2.2 milestone Feb 22, 2026
@AbdelrahmanHafez AbdelrahmanHafez deleted the codex/gh-16028-codex branch February 23, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ValidationError: CastError: TypeError: path.split is not a function with refPath as a function

4 participants