Skip to content

Commit 839f664

Browse files
committed
feat(recursion): allow N-level deep self-recursion population
1 parent 2b44641 commit 839f664

11 files changed

Lines changed: 329 additions & 47 deletions

File tree

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ This will return the article with all nested components, dynamic zones and relat
2727

2828
The following configuration options can be added to your Strapi project's `config/plugins.js` or `config/plugins.ts` file.
2929

30-
| Option | Description | Values |
31-
| ----------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32-
| `relations` | Controls which relations are populated when querying. | - `true`: Populate all relations recursively (default)<br>- `false`: Do not populate any relations<br>- `string[]`: Populate only specific collection types by UID (e.g., `["api::article.article"]`) |
33-
| `cache` | Enables or disables the plugin’s in-memory cache for faster populate query generation. | - `true` (default)<br>- `false` |
30+
| Option | Description | Values |
31+
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32+
| `relations` | Controls which relations are populated when querying. | - `true`: Populate all relations recursively (default)<br>- `false`: Do not populate any relations<br>- `string[]`: Populate only specific collection types by UID (e.g., `["api::article.article"]`) |
33+
| `cache` | Enables or disables the plugin’s in-memory cache for faster populate query generation. | - `true` (default)<br>- `false` |
34+
| `recursion` | Allows a model to populate itself N levels deep when the populate tree revisits the same `modelUid`. By default, self-references are skipped after the first encounter to prevent infinite loops. Use this to opt specific models in to deeper nested self-populations. | An object mapping `modelUid` to a non-negative integer depth, e.g. `{ "api::article.article": 2 }`. `0` (default) means no self-recursion; `1` allows one nested level; `2` allows two; and so on. |
3435

3536
Example usage:
3637

@@ -41,6 +42,10 @@ module.exports = {
4142
config: {
4243
cache: true,
4344
relations: true,
45+
recursion: {
46+
// articles can populate their `related` articles 2 levels deep
47+
"api::article.article": 2,
48+
},
4449
},
4550
},
4651
};

package-lock.json

Lines changed: 0 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sandbox/config/plugins.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
1-
export default () => ({});
1+
export default () => ({
2+
"populate-all": {
3+
enabled: true,
4+
config: {
5+
recursion: {
6+
"api::article.article": 1,
7+
},
8+
},
9+
},
10+
});

sandbox/data/data.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"author": {
7272
"id": 1
7373
},
74+
"related": [{ "id": 2 }, { "id": 3 }, { "id": 4 }],
7475
"description": "Follow the story of Aaron Swartz, the boy who could change the world",
7576
"cover": null,
7677
"blocks": [
@@ -106,6 +107,7 @@
106107
"author": {
107108
"id": 1
108109
},
110+
"related": [{ "id": 1 }, { "id": 3 }, { "id": 5 }],
109111
"description": "Mantis shrimps, or stomatopods, are marine crustaceans of the order Stomatopoda.",
110112
"cover": null,
111113
"blocks": [
@@ -141,6 +143,7 @@
141143
"author": {
142144
"id": 2
143145
},
146+
"related": [{ "id": 1 }, { "id": 2 }, { "id": 5 }],
144147
"description": "How a bug on MySQL is becoming a meme on the internet",
145148
"cover": null,
146149
"blocks": [
@@ -176,6 +179,7 @@
176179
"author": {
177180
"id": 2
178181
},
182+
"related": [{ "id": 1 }, { "id": 3 }, { "id": 5 }],
179183
"description": "Description of a beautiful picture",
180184
"cover": null,
181185
"blocks": [
@@ -211,6 +215,7 @@
211215
"author": {
212216
"id": 2
213217
},
218+
"related": [{ "id": 2 }, { "id": 3 }, { "id": 4 }],
214219
"description": "Maybe the answer is in this article, or not...",
215220
"cover": null,
216221
"blocks": [

sandbox/scripts/seed.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,21 +175,43 @@ async function updateBlocks(blocks) {
175175
}
176176

177177
async function importArticles() {
178+
// first pass: create articles without `related` so we can resolve forward references afterwards
178179
for (const article of articles) {
179180
const cover = await checkFileExistsBeforeUpload([`${article.slug}.jpg`]);
180181
const updatedBlocks = await updateBlocks(article.blocks);
182+
const { related, ...articleData } = article;
181183

182184
await createEntry({
183185
model: "article",
184186
entry: {
185-
...article,
187+
...articleData,
186188
cover,
187189
blocks: updatedBlocks,
188190
// Make sure it's not a draft
189191
publishedAt: Date.now(),
190192
},
191193
});
192194
}
195+
196+
// second pass: link `related` by slug now that every article exists
197+
const allArticles = await strapi.documents("api::article.article").findMany();
198+
const documentIdBySlug = Object.fromEntries(
199+
allArticles.map((a) => [a.slug, a.documentId])
200+
);
201+
202+
for (const article of articles) {
203+
if (!article.related?.length) continue;
204+
const documentId = documentIdBySlug[article.slug];
205+
if (!documentId) continue;
206+
const relatedDocumentIds = article.related
207+
.map((r) => documentIdBySlug[articles[r.id - 1]?.slug])
208+
.filter(Boolean);
209+
210+
await strapi.documents("api::article.article").update({
211+
documentId,
212+
data: { related: relatedDocumentIds },
213+
});
214+
}
193215
}
194216

195217
async function importGlobal() {

sandbox/src/api/article/content-types/article/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
"shared.rich-text",
5050
"shared.slider"
5151
]
52+
},
53+
"related": {
54+
"type": "relation",
55+
"relation": "oneToMany",
56+
"target": "api::article.article"
5257
}
5358
}
5459
}

sandbox/tests/populate-all.test.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ describe("strapi-plugin-populate-all", () => {
2626
expect(response.body.data[0]).toHaveProperty("blocks");
2727

2828
// doesn't loop
29-
expect(response.body.data[0].category.articles[0]).not.toHaveProperty(
30-
"category"
31-
);
29+
expect(
30+
response.body.data[0].category.articles[0].category
31+
).not.toHaveProperty("article");
3232
});
3333

3434
test("if everything is populated with populateAll=true", async () => {
@@ -46,9 +46,9 @@ describe("strapi-plugin-populate-all", () => {
4646
expect(response.body.data[0]).toHaveProperty("blocks");
4747

4848
// doesn't loop
49-
expect(response.body.data[0].category.articles[0]).not.toHaveProperty(
50-
"category"
51-
);
49+
expect(
50+
response.body.data[0].category.articles[0].category
51+
).not.toHaveProperty("article");
5252
});
5353

5454
test("if everything is populated with ?populateAll", async () => {
@@ -66,8 +66,35 @@ describe("strapi-plugin-populate-all", () => {
6666
expect(response.body.data[0]).toHaveProperty("blocks");
6767

6868
// doesn't loop
69-
expect(response.body.data[0].category.articles[0]).not.toHaveProperty(
70-
"category"
69+
expect(
70+
response.body.data[0].category.articles[0].category
71+
).not.toHaveProperty("article");
72+
});
73+
74+
test("if `related` of `the-internet-s-own-boy` fully populates `this-shrimp-is-awesome`", async () => {
75+
const response = await strapiRequest.get(
76+
"/api/articles?status=draft&populate=all&filters[slug][$eq]=the-internet-s-own-boy"
77+
);
78+
79+
expect(response.statusCode).toBe(200);
80+
81+
const article = response.body.data[0];
82+
expect(article).toBeDefined();
83+
expect(article.slug).toBe("the-internet-s-own-boy");
84+
85+
// recursion config allows article → related → article — so the nested article must be fully populated
86+
const shrimp = article.related.find(
87+
(a: { slug: string }) => a.slug === "this-shrimp-is-awesome"
7188
);
89+
expect(shrimp).toBeDefined();
90+
expect(shrimp).toHaveProperty("title", "This shrimp is awesome");
91+
expect(shrimp).toHaveProperty("description");
92+
expect(shrimp).toHaveProperty("author");
93+
expect(shrimp).toHaveProperty("category");
94+
expect(shrimp).toHaveProperty("blocks");
95+
expect(shrimp.author).toHaveProperty("name");
96+
expect(shrimp.category).toHaveProperty("name");
97+
expect(Array.isArray(shrimp.blocks)).toBe(true);
98+
expect(shrimp.blocks.length).toBeGreaterThan(0);
7299
});
73100
});

sandbox/types/generated/contentTypes.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export interface AdminApiToken extends Struct.CollectionTypeSchema {
2626
Schema.Attribute.SetMinMaxLength<{
2727
minLength: 1;
2828
}>;
29+
adminPermissions: Schema.Attribute.Relation<
30+
"oneToMany",
31+
"admin::permission"
32+
>;
33+
adminUserOwner: Schema.Attribute.Relation<"manyToOne", "admin::user">;
2934
createdAt: Schema.Attribute.DateTime;
3035
createdBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> &
3136
Schema.Attribute.Private;
@@ -39,6 +44,9 @@ export interface AdminApiToken extends Struct.CollectionTypeSchema {
3944
minLength: 1;
4045
}>;
4146
expiresAt: Schema.Attribute.DateTime;
47+
kind: Schema.Attribute.Enumeration<["content-api", "admin"]> &
48+
Schema.Attribute.Required &
49+
Schema.Attribute.DefaultTo<"content-api">;
4250
lastUsedAt: Schema.Attribute.DateTime;
4351
lifespan: Schema.Attribute.BigInteger;
4452
locale: Schema.Attribute.String & Schema.Attribute.Private;
@@ -56,7 +64,6 @@ export interface AdminApiToken extends Struct.CollectionTypeSchema {
5664
>;
5765
publishedAt: Schema.Attribute.DateTime;
5866
type: Schema.Attribute.Enumeration<["read-only", "full-access", "custom"]> &
59-
Schema.Attribute.Required &
6067
Schema.Attribute.DefaultTo<"read-only">;
6168
updatedAt: Schema.Attribute.DateTime;
6269
updatedBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> &
@@ -134,6 +141,7 @@ export interface AdminPermission extends Struct.CollectionTypeSchema {
134141
minLength: 1;
135142
}>;
136143
actionParameters: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<{}>;
144+
apiToken: Schema.Attribute.Relation<"manyToOne", "admin::api-token">;
137145
conditions: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<[]>;
138146
createdAt: Schema.Attribute.DateTime;
139147
createdBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> &
@@ -385,6 +393,8 @@ export interface AdminUser extends Struct.CollectionTypeSchema {
385393
};
386394
};
387395
attributes: {
396+
apiTokens: Schema.Attribute.Relation<"oneToMany", "admin::api-token"> &
397+
Schema.Attribute.Private;
388398
blocked: Schema.Attribute.Boolean &
389399
Schema.Attribute.Private &
390400
Schema.Attribute.DefaultTo<false>;
@@ -491,6 +501,7 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
491501
> &
492502
Schema.Attribute.Private;
493503
publishedAt: Schema.Attribute.DateTime;
504+
related: Schema.Attribute.Relation<"oneToMany", "api::article.article">;
494505
slug: Schema.Attribute.UID<"title">;
495506
title: Schema.Attribute.String;
496507
updatedAt: Schema.Attribute.DateTime;

server/src/config/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@ export default {
3030
break;
3131
}
3232

33+
case "recursion": {
34+
const isRecordOfNumbers =
35+
typeof value === "object" &&
36+
value !== null &&
37+
!Array.isArray(value) &&
38+
Object.values(value).every(
39+
(depth: unknown) =>
40+
typeof depth === "number" &&
41+
Number.isInteger(depth) &&
42+
depth >= 0
43+
);
44+
if (!isRecordOfNumbers) {
45+
throw new Error(
46+
`[populate-all] config "${key}" of type ${typeof value} is not valid. Supported is an object mapping modelUid to a non-negative integer depth.`
47+
);
48+
}
49+
break;
50+
}
51+
3352
default:
3453
strapi.log.warn(
3554
`[populate-all] unknown config "${key}" provided. This config will be ignored.`

server/src/utils/getPopulateQuery.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,18 @@ export const getPopulateQuery = (
3333
return structuredClone(queryCache[cacheKey]); // return a clone to avoid mutating the original object
3434
}
3535

36-
// prevent infinite loop
37-
if (parentsModelUids.includes(modelUid)) {
36+
// allow N-level deep self-recursion per modelUid via the `recursion` config
37+
// e.g. { "api::article.article": 2 } lets articles nest into articles up to 2 levels deep
38+
const recursionDepth =
39+
strapi
40+
.plugin("populate-all")
41+
.config<Record<string, number>>("recursion")?.[modelUid] ?? 0;
42+
const occurrences = parentsModelUids.filter(
43+
(uid) => uid === modelUid
44+
).length;
45+
46+
// prevent infinite loop once the configured depth is exceeded
47+
if (occurrences > recursionDepth) {
3848
strapi.log.debug(
3949
`[populate-all] loop detected skipping population: ${modelUid}`
4050
);

0 commit comments

Comments
 (0)