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, + }, + }); + }); +});