Skip to content

Commit fa9cb4f

Browse files
committed
🐛 Support nested populate via dot notation
1 parent 965b901 commit fa9cb4f

File tree

6 files changed

+90
-15
lines changed

6 files changed

+90
-15
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.4.1 (WIP)
2+
3+
* Support nested populate via dot notation (e.g., `populate('Project.Client')`) - falls back to N+1 when `$lookup` can't handle nested associations
4+
15
## 1.4.0 (2026-01-21)
26

37
* Add permission checks to Linkup routes

lib/app/helper_datasource/00-nosql_datasource.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,7 @@ function addLookupForAssociation(aggregate, assoc, assoc_model, options = {}) {
706706
*
707707
* @author Jelle De Loecker <[email protected]>
708708
* @since 1.1.0
709-
* @version 1.4.0
709+
* @version 1.4.1
710710
*
711711
* @param {Criteria} criteria The criteria to convert
712712
* @param {Group} group The current group
@@ -1210,6 +1210,14 @@ function convertCriteriaGroupToConditions(criteria, group, config, context) {
12101210
}
12111211
}
12121212

1213+
// Skip $lookup when there are nested associations (e.g., 'Parent.Parent')
1214+
// because $lookup only handles one level - let the N+1 code path handle nested
1215+
let assoc_select = associations_to_select[alias];
1216+
if (assoc_select?.associations && Object.keys(assoc_select.associations).length > 0) {
1217+
// This association has nested associations, skip $lookup
1218+
continue;
1219+
}
1220+
12131221
// Only create aggregate when we actually need it
12141222
getAggregate();
12151223

@@ -1228,8 +1236,6 @@ function convertCriteriaGroupToConditions(criteria, group, config, context) {
12281236
};
12291237

12301238
// Add projection if specific fields are selected (not just the whole association)
1231-
let assoc_select = associations_to_select[alias];
1232-
12331239
if (assoc_select && assoc_select.fields && assoc_select.fields.length > 0) {
12341240
let projection = { _id: 1 }; // Always include _id
12351241
for (let field of assoc_select.fields) {

lib/app/helper_model/10-model_criteria.js

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ Criteria.setMethod(function getAssociationConfiguration(alias) {
398398
*
399399
* @author Jelle De Loecker <[email protected]>
400400
* @since 1.1.0
401-
* @version 1.4.0
401+
* @version 1.4.1
402402
*
403403
* @param {string} name
404404
* @param {Object} item
@@ -426,14 +426,20 @@ Criteria.setMethod(function getCriteriaForAssociation(name, item) {
426426
options = this.options;
427427

428428
// For self-referencing associations (e.g., Person.Parent → Person),
429-
// only allow if we have recursive depth remaining.
429+
// only allow if we have recursive depth remaining OR explicit nested associations.
430430
// This prevents infinite loops while still supporting hierarchical data.
431431
if (assoc_model.name == options.init_model) {
432432
// Check if we have recursive depth available
433433
let recursive = options.recursive;
434434

435-
if (!Number.isSafeInteger(recursive) || recursive <= 0) {
436-
// No recursive depth - block to prevent potential infinite loops
435+
// Also check for explicit nested associations via dot notation (e.g., 'Parent.Parent')
436+
// If there's an explicit Select for this association with nested associations,
437+
// we should allow it even without recursive depth
438+
let has_explicit_nested = options.select?.associations?.[name]?.associations;
439+
let has_nested_associations = has_explicit_nested && Object.keys(has_explicit_nested).length > 0;
440+
441+
if (!has_nested_associations && (!Number.isSafeInteger(recursive) || recursive <= 0)) {
442+
// No recursive depth and no explicit nested associations - block to prevent infinite loops
437443
return;
438444
}
439445
}
@@ -985,20 +991,44 @@ Select.setMethod(Blast.checksumSymbol, function toChecksum() {
985991
return result;
986992
});
987993

994+
/**
995+
* Get the model to use for association lookups.
996+
* For nested Selects (e.g., when populating 'Project.Client'),
997+
* this returns the associated model, not the criteria's root model.
998+
*
999+
* @author Jelle De Loecker <[email protected]>
1000+
* @since 1.4.1
1001+
* @version 1.4.1
1002+
*
1003+
* @return {Model}
1004+
*/
1005+
Select.setMethod(function getModelForAssociations() {
1006+
1007+
// If this Select has an associated model set (for nested associations),
1008+
// use that instead of the criteria's model
1009+
if (this.associated_model) {
1010+
return this.associated_model;
1011+
}
1012+
1013+
return this.criteria?.model;
1014+
});
1015+
9881016
/**
9891017
* Add an association
9901018
*
9911019
* @author Jelle De Loecker <[email protected]>
9921020
* @since 1.1.0
993-
* @version 1.3.4
1021+
* @version 1.4.1
9941022
*
9951023
* @param {string} name
9961024
*
9971025
* @return {Select} This creates a new Select instance
9981026
*/
9991027
Select.setMethod(function addAssociation(name) {
10001028

1001-
if (!this.criteria?.model) {
1029+
let model = this.getModelForAssociations();
1030+
1031+
if (!model) {
10021032
throw new Error('Unable to select an association: this Criteria has no model info');
10031033
}
10041034

@@ -1032,14 +1062,23 @@ Select.setMethod(function addAssociation(name) {
10321062

10331063
// Get the association data
10341064
try {
1035-
let info = this.criteria.model.getAssociation(name);
1065+
let info = model.getAssociation(name);
10361066

10371067
if (info) {
10381068
// Make sure the localkey is added to the resultset
10391069
this.requireFieldForQuery(info.options.localKey);
1070+
1071+
// Store the associated model on the nested Select so it can
1072+
// resolve its own nested associations correctly
1073+
// (e.g., for 'Project.Client', the Project Select needs to know
1074+
// to look up 'Client' on the Project model, not the root model)
1075+
let associated_model = this.getModel(info.modelName);
1076+
if (associated_model) {
1077+
this.associations[name].associated_model = associated_model;
1078+
}
10401079
}
10411080
} catch (err) {
1042-
console.warn('Failed to find "' + name + '" association for ' + this.criteria.model.model_name);
1081+
console.warn('Failed to find "' + name + '" association for ' + model.model_name);
10431082
}
10441083

10451084
return this.associations[name];

lib/app/helper_model/model.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,7 +1362,7 @@ Model.setMethod(function afterFindCount(options, records, callback) {
13621362
*
13631363
* @author Jelle De Loecker <[email protected]>
13641364
* @since 0.1.0
1365-
* @version 1.1.5
1365+
* @version 1.4.1
13661366
*
13671367
* @param {Criteria} criteria
13681368
* @param {Object} item
@@ -1404,13 +1404,16 @@ Model.setMethod(function addAssociatedDataToRecord(criteria, item, callback) {
14041404
if (Object.isPlainObject(item[alias])) {
14051405
let assoc_crit = criteria.getCriteriaForAssociation(alias, item);
14061406

1407-
if (!(item[alias] instanceof assoc_crit.model.constructor.Document)) {
1407+
// assoc_crit can be undefined for self-referencing without recursive depth
1408+
if (assoc_crit && !(item[alias] instanceof assoc_crit.model.constructor.Document)) {
14081409
item[alias] = assoc_crit.model.createDocument(item[alias]);
14091410
}
14101411
}
14111412
} else if (Array.isArray(item[alias])) {
14121413
let assoc_crit = criteria.getCriteriaForAssociation(alias, item);
1413-
item[alias] = assoc_crit.model.createDocumentList(item[alias]);
1414+
if (assoc_crit) {
1415+
item[alias] = assoc_crit.model.createDocumentList(item[alias]);
1416+
}
14141417
}
14151418
}
14161419

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "alchemymvc",
33
"description": "MVC framework for Node.js",
4-
"version": "1.4.0",
4+
"version": "1.4.1-alpha",
55
"author": "Jelle De Loecker <[email protected]>",
66
"keywords": [
77
"alchemy",

test/09-datasource.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,29 @@ describe('FieldConfig', function() {
579579
}
580580
// If no pipeline at all, that's also acceptable (means no $lookup)
581581
});
582+
583+
it('should populate nested associations using dot notation (Parent.Parent)', async function() {
584+
// This tests that nested populate paths like 'Project.Client' work correctly
585+
// by using the self-referencing Person model as a proxy
586+
let Person = Model.get('Person');
587+
let criteria = Person.find();
588+
criteria.where('firstname').equals('ChildPerson');
589+
590+
// Use dot notation to populate Parent and Parent's Parent
591+
criteria.populate('Parent.Parent');
592+
593+
let records = await Person.find('all', criteria);
594+
595+
assert.strictEqual(records.length, 1, 'Should find ChildPerson');
596+
assert.strictEqual(!!records[0].Parent, true, 'Parent should be populated');
597+
assert.strictEqual(records[0].Parent.firstname, 'ParentPerson', 'Parent should be ParentPerson');
598+
599+
// The key test - Parent.Parent should be populated via dot notation
600+
assert.strictEqual(!!records[0].Parent.Parent, true,
601+
'Parent.Parent (grandparent) should be populated via dot notation');
602+
assert.strictEqual(records[0].Parent.Parent.firstname, 'Grandparent',
603+
'Grandparent should be correct');
604+
});
582605
});
583606

584607
describe('Nested OR/AND groups with associations', function() {

0 commit comments

Comments
 (0)