diff --git a/packages/core/database/lib/query/helpers/populate/apply.js b/packages/core/database/lib/query/helpers/populate/apply.js index 2ae346d135c..fee3feeeaa9 100644 --- a/packages/core/database/lib/query/helpers/populate/apply.js +++ b/packages/core/database/lib/query/helpers/populate/apply.js @@ -446,6 +446,11 @@ const morphToMany = async (input, ctx) => { .where({ [joinColumn.name]: referencedValues, ...(joinTable.on || {}), + // If the populateValue contains an "on" property, + // only populate the types defined in it + ...('on' in populateValue + ? { [morphColumn.typeColumn.name]: Object.keys(populateValue.on) } + : {}), }) .orderBy([joinColumn.name, 'order']) .execute({ mapResults: false }); @@ -470,6 +475,8 @@ const morphToMany = async (input, ctx) => { }, {}); const map = {}; + const { on, ...typePopulate } = populateValue; + for (const type of Object.keys(idsByType)) { const ids = idsByType[type]; @@ -483,7 +490,7 @@ const morphToMany = async (input, ctx) => { const qb = db.entityManager.createQueryBuilder(type); const rows = await qb - .init(populateValue) + .init(on?.[type] ?? typePopulate) .addSelect(`${qb.alias}.${idColumn.referencedColumn}`) .where({ [idColumn.referencedColumn]: ids }) .execute({ mapResults: false }); @@ -540,6 +547,8 @@ const morphToOne = async (input, ctx) => { }, {}); const map = {}; + const { on, ...typePopulate } = populateValue; + for (const type of Object.keys(idsByType)) { const ids = idsByType[type]; @@ -552,7 +561,7 @@ const morphToOne = async (input, ctx) => { const qb = db.entityManager.createQueryBuilder(type); const rows = await qb - .init(populateValue) + .init(on?.[type] ?? typePopulate) .addSelect(`${qb.alias}.${idColumn.referencedColumn}`) .where({ [idColumn.referencedColumn]: ids }) .execute({ mapResults: false }); @@ -579,7 +588,16 @@ const morphToOne = async (input, ctx) => { // TODO: Omit limit & offset to avoid needing a query per result to avoid making too many queries const pickPopulateParams = (populate) => { - const fieldsToPick = ['select', 'count', 'where', 'populate', 'orderBy', 'filters', 'ordering']; + const fieldsToPick = [ + 'select', + 'count', + 'where', + 'populate', + 'orderBy', + 'filters', + 'ordering', + 'on', + ]; if (populate.count !== true) { fieldsToPick.push('limit', 'offset'); diff --git a/packages/core/strapi/tests/api/populate/filtering/index.test.api.js b/packages/core/strapi/tests/api/populate/filtering/index.test.api.js index 24931e882fc..279bedddb3a 100644 --- a/packages/core/strapi/tests/api/populate/filtering/index.test.api.js +++ b/packages/core/strapi/tests/api/populate/filtering/index.test.api.js @@ -108,6 +108,11 @@ const fixtures = { number: 2, field: 'short string', }, + { + __component: 'default.foo', + number: 3, + field: 'long string', + }, { __component: 'default.bar', title: 'this is a title', @@ -315,4 +320,100 @@ describe('Populate filters', () => { expect(body.data[0].attributes.third).toBeUndefined(); }); }); + + describe('Populate a dynamic zone', () => { + test('Populate every component in the dynamic zone', async () => { + const qs = { + populate: { + dz: '*', + }, + }; + + const { status, body } = await rq.get(`/${schemas.contentTypes.b.pluralName}`, { qs }); + + expect(status).toBe(200); + expect(body.data).toHaveLength(2); + + fixtures.b.forEach((fixture, i) => { + const res = body.data[i]; + const { dz } = res.attributes; + + expect(dz).toHaveLength(fixture.dz.length); + expect(dz).toMatchObject( + fixture.dz.map((component) => ({ + ...omit('field', component), + id: expect.any(Number), + })) + ); + }); + }); + + test('Populate only one component type using fragment', async () => { + const qs = { + populate: { + dz: { + on: { + 'default.foo': true, + }, + }, + }, + }; + + const { status, body } = await rq.get(`/${schemas.contentTypes.b.pluralName}`, { qs }); + + expect(status).toBe(200); + expect(body.data).toHaveLength(2); + + expect(body.data[0].attributes.dz).toHaveLength(3); + expect(body.data[1].attributes.dz).toHaveLength(0); + + const expected = fixtures.b[0].dz + .filter(({ __component }) => __component === 'default.foo') + .map((component) => ({ + ...component, + id: expect.any(Number), + })); + + expect(body.data[0].attributes.dz).toMatchObject(expected); + }); + + test('Populate the dynamic zone with filters in fragments', async () => { + const qs = { + populate: { + dz: { + on: { + 'default.foo': { + filters: { number: { $lt: 3 } }, + }, + 'default.bar': { + filters: { title: { $contains: 'another' } }, + }, + }, + }, + }, + }; + + const { status, body } = await rq.get(`/${schemas.contentTypes.b.pluralName}`, { qs }); + + expect(status).toBe(200); + expect(body.data).toHaveLength(2); + expect(body.data[0].attributes.dz).toHaveLength(2); + expect(body.data[1].attributes.dz).toHaveLength(1); + + const filter = (data = []) => + data + .filter(({ __component, number, title }) => { + if (__component === 'default.foo') return number < 3; + if (__component === 'default.bar') return title.includes('another'); + return false; + }) + .map((component) => ({ + ...(component.__component === 'default.foo' ? component : omit('field', component)), + id: expect.any(Number), + })); + + expect(body.data[0].attributes.dz).toMatchObject(filter(fixtures.b[0].dz)); + expect(body.data[1].attributes.dz).toMatchObject(filter(fixtures.b[1].dz)); + }); + }); }); diff --git a/packages/core/utils/lib/content-types.js b/packages/core/utils/lib/content-types.js index cec115eab47..0303b8d2a56 100644 --- a/packages/core/utils/lib/content-types.js +++ b/packages/core/utils/lib/content-types.js @@ -104,12 +104,16 @@ const isPrivateAttribute = (model = {}, attributeName) => { }; const isScalarAttribute = (attribute) => { - return !['media', 'component', 'relation', 'dynamiczone'].includes(attribute.type); + return !['media', 'component', 'relation', 'dynamiczone'].includes(attribute?.type); +}; +const isMediaAttribute = (attribute) => attribute?.type === 'media'; +const isRelationalAttribute = (attribute) => attribute?.type === 'relation'; +const isComponentAttribute = (attribute) => ['component', 'dynamiczone'].includes(attribute?.type); + +const isDynamicZoneAttribute = (attribute) => attribute?.type === 'dynamiczone'; +const isMorphToRelationalAttribute = (attribute) => { + return isRelationalAttribute(attribute) && attribute?.relation?.startsWith?.('morphTo'); }; -const isMediaAttribute = (attribute) => attribute && attribute.type === 'media'; -const isRelationalAttribute = (attribute) => attribute && attribute.type === 'relation'; -const isComponentAttribute = (attribute) => - attribute && ['component', 'dynamiczone'].includes(attribute.type); const getComponentAttributes = (schema) => { return _.reduce( @@ -158,6 +162,8 @@ module.exports = { isMediaAttribute, isRelationalAttribute, isComponentAttribute, + isDynamicZoneAttribute, + isMorphToRelationalAttribute, isTypedAttribute, getPrivateAttributes, isPrivateAttribute, diff --git a/packages/core/utils/lib/convert-query-params.js b/packages/core/utils/lib/convert-query-params.js index 38d1af385be..3dea86ee7b0 100644 --- a/packages/core/utils/lib/convert-query-params.js +++ b/packages/core/utils/lib/convert-query-params.js @@ -6,7 +6,11 @@ * Converts the standard Strapi REST query params to a more usable format for querying * You can read more here: https://docs.strapi.io/developer-docs/latest/developer-resources/database-apis-reference/rest-api.html#filters */ + const { + isNil, + toNumber, + isInteger, has, isEmpty, isObject, @@ -14,14 +18,16 @@ const { cloneDeep, get, mergeAll, - isNil, - toNumber, - isInteger, } = require('lodash/fp'); const _ = require('lodash'); const parseType = require('./parse-type'); const contentTypesUtils = require('./content-types'); const { PaginationError } = require('./errors'); +const { + isMediaAttribute, + isDynamicZoneAttribute, + isMorphToRelationalAttribute, +} = require('./content-types'); const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants; @@ -185,8 +191,31 @@ const convertPopulateObject = (populate, schema) => { return acc; } - // FIXME: This is a temporary solution for dynamic zones that should be - // fixed when we'll implement a more accurate way to query them + // Allow adding an 'on' strategy to populate queries for polymorphic relations, media and dynamic zones + const isAllowedAttributeForFragmentPopulate = + isDynamicZoneAttribute(attribute) || + isMediaAttribute(attribute) || + isMorphToRelationalAttribute(attribute); + + const hasFragmentPopulateDefined = typeof subPopulate === 'object' && 'on' in subPopulate; + + if (isAllowedAttributeForFragmentPopulate && hasFragmentPopulateDefined) { + return { + ...acc, + [key]: { + on: Object.entries(subPopulate.on).reduce( + (acc, [type, typeSubPopulate]) => ({ + ...acc, + [type]: convertNestedPopulate(typeSubPopulate, strapi.getModel(type)), + }), + {} + ), + }, + }; + } + + // TODO: This is a query's populate fallback for DynamicZone and is kept for legacy purpose. + // Removing it could break existing user queries but it should be removed in V5. if (attribute.type === 'dynamiczone') { const populates = attribute.components .map((uid) => strapi.getModel(uid))