Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Populate fragments for polymorphic relations #14879

Merged
merged 12 commits into from
Nov 29, 2022
24 changes: 21 additions & 3 deletions packages/core/database/lib/query/helpers/populate/apply.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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];

Expand All @@ -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 });
Expand Down Expand Up @@ -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];

Expand All @@ -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 });
Expand All @@ -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');
Expand Down
101 changes: 101 additions & 0 deletions packages/core/strapi/tests/api/populate/filtering/index.test.api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Convly marked this conversation as resolved.
Show resolved Hide resolved
},
},
},
};

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));
});
});
});
16 changes: 11 additions & 5 deletions packages/core/utils/lib/content-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -158,6 +162,8 @@ module.exports = {
isMediaAttribute,
isRelationalAttribute,
isComponentAttribute,
isDynamicZoneAttribute,
isMorphToRelationalAttribute,
isTypedAttribute,
getPrivateAttributes,
isPrivateAttribute,
Expand Down
39 changes: 34 additions & 5 deletions packages/core/utils/lib/convert-query-params.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,28 @@
* 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,
isPlainObject,
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;

Expand Down Expand Up @@ -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 =
Convly marked this conversation as resolved.
Show resolved Hide resolved
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))
Expand Down