Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)<br>- `false`: Do not populate any relations<br>- `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)<br>- `false` |
| Option | Description | Values |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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"]`) |
| `cache` | Enables or disables the plugin’s in-memory cache for faster populate query generation. | - `true` (default)<br>- `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:

Expand All @@ -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,
},
},
},
};
Expand Down
30 changes: 0 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion sandbox/config/plugins.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
export default () => ({});
export default () => ({
"populate-all": {
enabled: true,
config: {
recursion: {
"api::article.article": 1,
},
},
},
});
5 changes: 5 additions & 0 deletions sandbox/data/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -176,6 +179,7 @@
"author": {
"id": 2
},
"related": [{ "id": 1 }, { "id": 3 }, { "id": 5 }],
"description": "Description of a beautiful picture",
"cover": null,
"blocks": [
Expand Down Expand Up @@ -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": [
Expand Down
24 changes: 23 additions & 1 deletion sandbox/scripts/seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,21 +175,43 @@ 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
publishedAt: Date.now(),
},
});
}

// 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() {
Expand Down
5 changes: 5 additions & 0 deletions sandbox/src/api/article/content-types/article/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
"shared.rich-text",
"shared.slider"
]
},
"related": {
"type": "relation",
"relation": "oneToMany",
"target": "api::article.article"
}
}
}
43 changes: 35 additions & 8 deletions sandbox/tests/populate-all.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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);
});
});
13 changes: 12 additions & 1 deletion sandbox/types/generated/contentTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
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;
Expand All @@ -39,6 +44,9 @@
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;
Expand All @@ -56,7 +64,6 @@
>;
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"> &
Expand Down Expand Up @@ -133,7 +140,8 @@
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
actionParameters: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<{}>;

Check warning on line 143 in sandbox/types/generated/contentTypes.d.ts

View workflow job for this annotation

GitHub Actions / biome

lint/complexity/noBannedTypes

Don't use '{}' as a type.
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"> &
Expand All @@ -141,7 +149,7 @@
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<"oneToMany", "admin::permission"> &
Schema.Attribute.Private;
properties: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<{}>;

Check warning on line 152 in sandbox/types/generated/contentTypes.d.ts

View workflow job for this annotation

GitHub Actions / biome

lint/complexity/noBannedTypes

Don't use '{}' as a type.
publishedAt: Schema.Attribute.DateTime;
role: Schema.Attribute.Relation<"manyToOne", "admin::role">;
subject: Schema.Attribute.String &
Expand Down Expand Up @@ -385,6 +393,8 @@
};
};
attributes: {
apiTokens: Schema.Attribute.Relation<"oneToMany", "admin::api-token"> &
Schema.Attribute.Private;
blocked: Schema.Attribute.Boolean &
Schema.Attribute.Private &
Schema.Attribute.DefaultTo<false>;
Expand Down Expand Up @@ -491,6 +501,7 @@
> &
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;
Expand Down
19 changes: 19 additions & 0 deletions server/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
14 changes: 12 additions & 2 deletions server/src/utils/getPopulateQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, number>>("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}`
);
Expand Down
Loading
Loading