diff --git a/README.md b/README.md
index d9656a5..ccad3c5 100644
--- a/README.md
+++ b/README.md
@@ -27,10 +27,11 @@ This will return the article with all nested components, dynamic zones and relat
The following configuration options can be added to your Strapi project's `config/plugins.js` or `config/plugins.ts` file.
-| Option | Description | Values |
-| ----------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `relations` | Controls which relations are populated when querying. | - `true`: Populate all relations recursively (default)
- `false`: Do not populate any relations
- `string[]`: Populate only specific collection types by UID (e.g., `["api::article.article"]`) |
-| `cache` | Enables or disables the plugin’s in-memory cache for faster populate query generation. | - `true` (default)
- `false` |
+| Option | Description | Values |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `relations` | Controls which relations are populated when querying. | - `true`: Populate all relations recursively (default)
- `false`: Do not populate any relations
- `string[]`: Populate only specific collection types by UID (e.g., `["api::article.article"]`) |
+| `cache` | Enables or disables the plugin’s in-memory cache for faster populate query generation. | - `true` (default)
- `false` |
+| `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. |
Example usage:
@@ -41,6 +42,10 @@ module.exports = {
config: {
cache: true,
relations: true,
+ recursion: {
+ // articles can populate their `related` articles 2 levels deep
+ "api::article.article": 2,
+ },
},
},
};
diff --git a/package-lock.json b/package-lock.json
index cd7541c..a3ace51 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20615,14 +20615,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
- "node_modules/npm/node_modules/imurmurhash": {
- "version": "0.1.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
"node_modules/npm/node_modules/ini": {
"version": "6.0.0",
"dev": true,
@@ -21613,28 +21605,6 @@
"node": ">=18.17"
}
},
- "node_modules/npm/node_modules/unique-filename": {
- "version": "5.0.0",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "unique-slug": "^6.0.0"
- },
- "engines": {
- "node": "^20.17.0 || >=22.9.0"
- }
- },
- "node_modules/npm/node_modules/unique-slug": {
- "version": "6.0.0",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "imurmurhash": "^0.1.4"
- },
- "engines": {
- "node": "^20.17.0 || >=22.9.0"
- }
- },
"node_modules/npm/node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
diff --git a/sandbox/config/plugins.ts b/sandbox/config/plugins.ts
index 56bf55f..f17c1c7 100644
--- a/sandbox/config/plugins.ts
+++ b/sandbox/config/plugins.ts
@@ -1 +1,10 @@
-export default () => ({});
+export default () => ({
+ "populate-all": {
+ enabled: true,
+ config: {
+ recursion: {
+ "api::article.article": 1,
+ },
+ },
+ },
+});
diff --git a/sandbox/data/data.json b/sandbox/data/data.json
index d0d0cee..018164b 100644
--- a/sandbox/data/data.json
+++ b/sandbox/data/data.json
@@ -71,6 +71,7 @@
"author": {
"id": 1
},
+ "related": [{ "id": 2 }, { "id": 3 }, { "id": 4 }],
"description": "Follow the story of Aaron Swartz, the boy who could change the world",
"cover": null,
"blocks": [
@@ -106,6 +107,7 @@
"author": {
"id": 1
},
+ "related": [{ "id": 1 }, { "id": 3 }, { "id": 5 }],
"description": "Mantis shrimps, or stomatopods, are marine crustaceans of the order Stomatopoda.",
"cover": null,
"blocks": [
@@ -141,6 +143,7 @@
"author": {
"id": 2
},
+ "related": [{ "id": 1 }, { "id": 2 }, { "id": 5 }],
"description": "How a bug on MySQL is becoming a meme on the internet",
"cover": null,
"blocks": [
@@ -176,6 +179,7 @@
"author": {
"id": 2
},
+ "related": [{ "id": 1 }, { "id": 3 }, { "id": 5 }],
"description": "Description of a beautiful picture",
"cover": null,
"blocks": [
@@ -211,6 +215,7 @@
"author": {
"id": 2
},
+ "related": [{ "id": 2 }, { "id": 3 }, { "id": 4 }],
"description": "Maybe the answer is in this article, or not...",
"cover": null,
"blocks": [
diff --git a/sandbox/scripts/seed.js b/sandbox/scripts/seed.js
index bb4756b..ede8f55 100644
--- a/sandbox/scripts/seed.js
+++ b/sandbox/scripts/seed.js
@@ -175,14 +175,16 @@ async function updateBlocks(blocks) {
}
async function importArticles() {
+ // first pass: create articles without `related` so we can resolve forward references afterwards
for (const article of articles) {
const cover = await checkFileExistsBeforeUpload([`${article.slug}.jpg`]);
const updatedBlocks = await updateBlocks(article.blocks);
+ const { related, ...articleData } = article;
await createEntry({
model: "article",
entry: {
- ...article,
+ ...articleData,
cover,
blocks: updatedBlocks,
// Make sure it's not a draft
@@ -190,6 +192,26 @@ async function importArticles() {
},
});
}
+
+ // second pass: link `related` by slug now that every article exists
+ const allArticles = await strapi.documents("api::article.article").findMany();
+ const documentIdBySlug = Object.fromEntries(
+ allArticles.map((a) => [a.slug, a.documentId])
+ );
+
+ for (const article of articles) {
+ if (!article.related?.length) continue;
+ const documentId = documentIdBySlug[article.slug];
+ if (!documentId) continue;
+ const relatedDocumentIds = article.related
+ .map((r) => documentIdBySlug[articles[r.id - 1]?.slug])
+ .filter(Boolean);
+
+ await strapi.documents("api::article.article").update({
+ documentId,
+ data: { related: relatedDocumentIds },
+ });
+ }
}
async function importGlobal() {
diff --git a/sandbox/src/api/article/content-types/article/schema.json b/sandbox/src/api/article/content-types/article/schema.json
index 9ba2bd7..57c7bad 100644
--- a/sandbox/src/api/article/content-types/article/schema.json
+++ b/sandbox/src/api/article/content-types/article/schema.json
@@ -49,6 +49,11 @@
"shared.rich-text",
"shared.slider"
]
+ },
+ "related": {
+ "type": "relation",
+ "relation": "oneToMany",
+ "target": "api::article.article"
}
}
}
diff --git a/sandbox/tests/populate-all.test.ts b/sandbox/tests/populate-all.test.ts
index 0d7a0da..ed50b99 100644
--- a/sandbox/tests/populate-all.test.ts
+++ b/sandbox/tests/populate-all.test.ts
@@ -26,9 +26,9 @@ describe("strapi-plugin-populate-all", () => {
expect(response.body.data[0]).toHaveProperty("blocks");
// doesn't loop
- expect(response.body.data[0].category.articles[0]).not.toHaveProperty(
- "category"
- );
+ expect(
+ response.body.data[0].category.articles[0].category
+ ).not.toHaveProperty("article");
});
test("if everything is populated with populateAll=true", async () => {
@@ -46,9 +46,9 @@ describe("strapi-plugin-populate-all", () => {
expect(response.body.data[0]).toHaveProperty("blocks");
// doesn't loop
- expect(response.body.data[0].category.articles[0]).not.toHaveProperty(
- "category"
- );
+ expect(
+ response.body.data[0].category.articles[0].category
+ ).not.toHaveProperty("article");
});
test("if everything is populated with ?populateAll", async () => {
@@ -66,8 +66,35 @@ describe("strapi-plugin-populate-all", () => {
expect(response.body.data[0]).toHaveProperty("blocks");
// doesn't loop
- expect(response.body.data[0].category.articles[0]).not.toHaveProperty(
- "category"
+ expect(
+ response.body.data[0].category.articles[0].category
+ ).not.toHaveProperty("article");
+ });
+
+ test("if `related` of `the-internet-s-own-boy` fully populates `this-shrimp-is-awesome`", async () => {
+ const response = await strapiRequest.get(
+ "/api/articles?status=draft&populate=all&filters[slug][$eq]=the-internet-s-own-boy"
+ );
+
+ expect(response.statusCode).toBe(200);
+
+ const article = response.body.data[0];
+ expect(article).toBeDefined();
+ expect(article.slug).toBe("the-internet-s-own-boy");
+
+ // recursion config allows article → related → article — so the nested article must be fully populated
+ const shrimp = article.related.find(
+ (a: { slug: string }) => a.slug === "this-shrimp-is-awesome"
);
+ expect(shrimp).toBeDefined();
+ expect(shrimp).toHaveProperty("title", "This shrimp is awesome");
+ expect(shrimp).toHaveProperty("description");
+ expect(shrimp).toHaveProperty("author");
+ expect(shrimp).toHaveProperty("category");
+ expect(shrimp).toHaveProperty("blocks");
+ expect(shrimp.author).toHaveProperty("name");
+ expect(shrimp.category).toHaveProperty("name");
+ expect(Array.isArray(shrimp.blocks)).toBe(true);
+ expect(shrimp.blocks.length).toBeGreaterThan(0);
});
});
diff --git a/sandbox/types/generated/contentTypes.d.ts b/sandbox/types/generated/contentTypes.d.ts
index 14c73c6..a9a3d59 100644
--- a/sandbox/types/generated/contentTypes.d.ts
+++ b/sandbox/types/generated/contentTypes.d.ts
@@ -26,6 +26,11 @@ export interface AdminApiToken extends Struct.CollectionTypeSchema {
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
+ adminPermissions: Schema.Attribute.Relation<
+ "oneToMany",
+ "admin::permission"
+ >;
+ adminUserOwner: Schema.Attribute.Relation<"manyToOne", "admin::user">;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> &
Schema.Attribute.Private;
@@ -39,6 +44,9 @@ export interface AdminApiToken extends Struct.CollectionTypeSchema {
minLength: 1;
}>;
expiresAt: Schema.Attribute.DateTime;
+ kind: Schema.Attribute.Enumeration<["content-api", "admin"]> &
+ Schema.Attribute.Required &
+ Schema.Attribute.DefaultTo<"content-api">;
lastUsedAt: Schema.Attribute.DateTime;
lifespan: Schema.Attribute.BigInteger;
locale: Schema.Attribute.String & Schema.Attribute.Private;
@@ -56,7 +64,6 @@ export interface AdminApiToken extends Struct.CollectionTypeSchema {
>;
publishedAt: Schema.Attribute.DateTime;
type: Schema.Attribute.Enumeration<["read-only", "full-access", "custom"]> &
- Schema.Attribute.Required &
Schema.Attribute.DefaultTo<"read-only">;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> &
@@ -134,6 +141,7 @@ export interface AdminPermission extends Struct.CollectionTypeSchema {
minLength: 1;
}>;
actionParameters: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<{}>;
+ apiToken: Schema.Attribute.Relation<"manyToOne", "admin::api-token">;
conditions: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<[]>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> &
@@ -385,6 +393,8 @@ export interface AdminUser extends Struct.CollectionTypeSchema {
};
};
attributes: {
+ apiTokens: Schema.Attribute.Relation<"oneToMany", "admin::api-token"> &
+ Schema.Attribute.Private;
blocked: Schema.Attribute.Boolean &
Schema.Attribute.Private &
Schema.Attribute.DefaultTo;
@@ -491,6 +501,7 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
> &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
+ related: Schema.Attribute.Relation<"oneToMany", "api::article.article">;
slug: Schema.Attribute.UID<"title">;
title: Schema.Attribute.String;
updatedAt: Schema.Attribute.DateTime;
diff --git a/server/src/config/index.ts b/server/src/config/index.ts
index 2a8db84..56002d7 100644
--- a/server/src/config/index.ts
+++ b/server/src/config/index.ts
@@ -30,6 +30,25 @@ export default {
break;
}
+ case "recursion": {
+ const isRecordOfNumbers =
+ typeof value === "object" &&
+ value !== null &&
+ !Array.isArray(value) &&
+ Object.values(value).every(
+ (depth: unknown) =>
+ typeof depth === "number" &&
+ Number.isInteger(depth) &&
+ depth >= 0
+ );
+ if (!isRecordOfNumbers) {
+ throw new Error(
+ `[populate-all] config "${key}" of type ${typeof value} is not valid. Supported is an object mapping modelUid to a non-negative integer depth.`
+ );
+ }
+ break;
+ }
+
default:
strapi.log.warn(
`[populate-all] unknown config "${key}" provided. This config will be ignored.`
diff --git a/server/src/utils/getPopulateQuery.ts b/server/src/utils/getPopulateQuery.ts
index 3eab9ad..1e0c26b 100644
--- a/server/src/utils/getPopulateQuery.ts
+++ b/server/src/utils/getPopulateQuery.ts
@@ -33,8 +33,18 @@ export const getPopulateQuery = (
return structuredClone(queryCache[cacheKey]); // return a clone to avoid mutating the original object
}
- // prevent infinite loop
- if (parentsModelUids.includes(modelUid)) {
+ // allow N-level deep self-recursion per modelUid via the `recursion` config
+ // e.g. { "api::article.article": 2 } lets articles nest into articles up to 2 levels deep
+ const recursionDepth =
+ strapi
+ .plugin("populate-all")
+ .config>("recursion")?.[modelUid] ?? 0;
+ const occurrences = parentsModelUids.filter(
+ (uid) => uid === modelUid
+ ).length;
+
+ // prevent infinite loop once the configured depth is exceeded
+ if (occurrences > recursionDepth) {
strapi.log.debug(
`[populate-all] loop detected skipping population: ${modelUid}`
);
diff --git a/server/src/utils/tests/getPopulateQuery.recursion.test.ts b/server/src/utils/tests/getPopulateQuery.recursion.test.ts
new file mode 100644
index 0000000..609650e
--- /dev/null
+++ b/server/src/utils/tests/getPopulateQuery.recursion.test.ts
@@ -0,0 +1,199 @@
+import type { UID } from "@strapi/strapi";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { getPopulateQuery, queryCache } from "../getPopulateQuery";
+
+describe("getPopulateQuery: recursion", () => {
+ const mockStrapi = {
+ log: { debug: vi.fn() },
+ getModel: vi.fn(),
+ plugin: vi.fn().mockReturnValue({ config: vi.fn() }),
+ };
+
+ beforeEach(() => {
+ // @ts-expect-error enough to mock
+ global.strapi = mockStrapi;
+ mockStrapi.log.debug.mockClear();
+ mockStrapi.getModel.mockClear();
+ mockStrapi.plugin.mockClear();
+
+ // reset cache
+ Object.keys(queryCache).forEach((key) => {
+ delete queryCache[key];
+ });
+ });
+
+ // self-referencing article model used across the tests
+ const useArticleModel = () => {
+ mockStrapi.getModel.mockImplementation((uid) => {
+ switch (uid) {
+ case "article":
+ return {
+ attributes: {
+ related: { type: "relation", target: "article" },
+ },
+ };
+ default:
+ return { attributes: {} };
+ }
+ });
+ };
+
+ test("does not recurse when the modelUid has no recursion config", () => {
+ mockStrapi.plugin.mockReturnValue({
+ config: vi
+ .fn()
+ .mockImplementation((key) => (key === "relations" ? true : undefined)),
+ });
+ useArticleModel();
+
+ const result = getPopulateQuery("article" as UID.Schema);
+
+ // related is detected but stops on the very first self-reference
+ expect(result).toEqual({
+ populate: { related: { populate: {} } },
+ });
+ });
+
+ test("recurses one level deep when configured with 1", () => {
+ mockStrapi.plugin.mockReturnValue({
+ config: vi.fn().mockImplementation((key) => {
+ if (key === "relations") return true;
+ if (key === "recursion") return { article: 1 };
+ return undefined;
+ }),
+ });
+ useArticleModel();
+
+ const result = getPopulateQuery("article" as UID.Schema);
+
+ // article → related (article) → related (stops here)
+ expect(result).toEqual({
+ populate: {
+ related: { populate: { related: { populate: {} } } },
+ },
+ });
+ });
+
+ test("recurses two levels deep when configured with 2", () => {
+ mockStrapi.plugin.mockReturnValue({
+ config: vi.fn().mockImplementation((key) => {
+ if (key === "relations") return true;
+ if (key === "recursion") return { article: 2 };
+ return undefined;
+ }),
+ });
+ useArticleModel();
+
+ const result = getPopulateQuery("article" as UID.Schema);
+
+ // article → related → related → related (stops here)
+ expect(result).toEqual({
+ populate: {
+ related: {
+ populate: {
+ related: { populate: { related: { populate: {} } } },
+ },
+ },
+ },
+ });
+ });
+
+ test("only recurses for configured modelUids", () => {
+ // article is configured but author is not — author must not self-recurse
+ mockStrapi.plugin.mockReturnValue({
+ config: vi.fn().mockImplementation((key) => {
+ if (key === "relations") return true;
+ if (key === "recursion") return { article: 1 };
+ return undefined;
+ }),
+ });
+ mockStrapi.getModel.mockImplementation((uid) => {
+ switch (uid) {
+ case "article":
+ return {
+ attributes: {
+ author: { type: "relation", target: "author" },
+ related: { type: "relation", target: "article" },
+ },
+ };
+ case "author":
+ return {
+ attributes: {
+ colleague: { type: "relation", target: "author" },
+ },
+ };
+ default:
+ return { attributes: {} };
+ }
+ });
+
+ const result = getPopulateQuery("article" as UID.Schema);
+
+ // author recurses into colleague (first hop is always allowed),
+ // but author → colleague → colleague stops because author has no recursion config
+ expect(result).toEqual({
+ populate: {
+ author: { populate: { colleague: { populate: {} } } },
+ related: {
+ populate: {
+ author: { populate: { colleague: { populate: {} } } },
+ related: { populate: {} },
+ },
+ },
+ },
+ });
+ });
+
+ test("treats depth independently per modelUid", () => {
+ // article allowed 1 level deep, page allowed 2 levels deep — counters don't bleed across uids
+ mockStrapi.plugin.mockReturnValue({
+ config: vi.fn().mockImplementation((key) => {
+ if (key === "relations") return true;
+ if (key === "recursion") return { article: 1, page: 2 };
+ return undefined;
+ }),
+ });
+ mockStrapi.getModel.mockImplementation((uid) => {
+ switch (uid) {
+ case "article":
+ return {
+ attributes: {
+ related: { type: "relation", target: "article" },
+ page: { type: "relation", target: "page" },
+ },
+ };
+ case "page":
+ return {
+ attributes: {
+ child: { type: "relation", target: "page" },
+ },
+ };
+ default:
+ return { attributes: {} };
+ }
+ });
+
+ const result = getPopulateQuery("article" as UID.Schema);
+
+ // page is allowed 2 levels deep → 3 page nodes in chain (root + 2 children)
+ const pageSubtree = {
+ populate: {
+ child: {
+ populate: { child: { populate: { child: { populate: {} } } } },
+ },
+ },
+ };
+
+ expect(result).toEqual({
+ populate: {
+ related: {
+ populate: {
+ related: { populate: {} },
+ page: pageSubtree,
+ },
+ },
+ page: pageSubtree,
+ },
+ });
+ });
+});