Merge pull request #20172 from strapi/v5/remove-root-level-nested-params

Remove root level nested params
This commit is contained in:
Bassel Kanso 2024-05-28 10:56:21 +03:00 committed by GitHub
commit e4b2a92811
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 105 additions and 142 deletions

View File

@ -1,10 +1,15 @@
import { setCreatorFields, async, errors } from '@strapi/utils';
import type { Modules, UID } from '@strapi/types';
import { getService } from '../utils';
import { validateBulkActionInput } from './validation';
import { getProhibitedCloningFields, excludeNotCreatableFields } from './utils/clone';
import { getDocumentLocaleAndStatus } from './validation/dimensions';
import { formatDocumentWithMetadata } from './utils/metadata';
type Options = Modules.Documents.Params.Pick<UID.ContentType, 'populate:object'>;
/**
* Create a new document.
*
@ -13,7 +18,7 @@ import { formatDocumentWithMetadata } from './utils/metadata';
* @param opts.populate - Populate options of the returned document.
* By default documentManager will populate all relations.
*/
const createDocument = async (ctx: any, opts?: { populate?: object }) => {
const createDocument = async (ctx: any, opts?: Options) => {
const { userAbility, user } = ctx.state;
const { model } = ctx.params;
const { body } = ctx.request;
@ -55,7 +60,7 @@ const createDocument = async (ctx: any, opts?: { populate?: object }) => {
* @param opts - Options
* @param opts.populate - Populate options of the returned document
*/
const updateDocument = async (ctx: any, opts?: { populate?: object }) => {
const updateDocument = async (ctx: any, opts?: Options) => {
const { userAbility, user } = ctx.state;
const { id, model } = ctx.params;
const { body } = ctx.request;

View File

@ -1,10 +1,12 @@
import type { UID } from '@strapi/types';
import type { UID, Modules } from '@strapi/types';
import { setCreatorFields, async, errors } from '@strapi/utils';
import { getDocumentLocaleAndStatus } from './validation/dimensions';
import { getService } from '../utils';
import { formatDocumentWithMetadata } from './utils/metadata';
type OptionsWithPopulate = Modules.Documents.Params.Pick<UID.ContentType, 'populate:object'>;
const buildPopulateFromQuery = async (query: any, model: any) => {
return getService('populate-builder')(model)
.populateFromQuery(query)
@ -25,7 +27,7 @@ const findDocument = async (query: any, uid: UID.SingleType, opts: any = {}) =>
);
};
const createOrUpdateDocument = async (ctx: any, opts?: { populate: object }) => {
const createOrUpdateDocument = async (ctx: any, opts?: OptionsWithPopulate) => {
const { user, userAbility } = ctx.state;
const { model } = ctx.params;
const { body, query } = ctx.request;

View File

@ -240,7 +240,7 @@ const getDeepPopulateDraftCount = (uid: UID.Schema) => {
let hasRelations = false;
const populate = Object.keys(model.attributes).reduce((populateAcc: any, attributeName) => {
const attribute: any = model.attributes[attributeName];
const attribute: Schema.Attribute.AnyAttribute = model.attributes[attributeName];
switch (attribute.type) {
case 'relation': {
@ -258,24 +258,29 @@ const getDeepPopulateDraftCount = (uid: UID.Schema) => {
attribute.component
);
if (childHasRelations) {
populateAcc[attributeName] = { populate };
populateAcc[attributeName] = {
populate,
};
hasRelations = true;
}
break;
}
case 'dynamiczone': {
const dzPopulate = (attribute.components || []).reduce((acc: any, componentUID: any) => {
const { populate, hasRelations: childHasRelations } =
const dzPopulateFragment = attribute.components?.reduce((acc, componentUID) => {
const { populate: componentPopulate, hasRelations: componentHasRelations } =
getDeepPopulateDraftCount(componentUID);
if (childHasRelations) {
if (componentHasRelations) {
hasRelations = true;
return merge(acc, populate);
return { ...acc, [componentUID]: { populate: componentPopulate } };
}
return acc;
}, {});
if (!isEmpty(dzPopulate)) {
populateAcc[attributeName] = { populate: dzPopulate };
if (!isEmpty(dzPopulateFragment)) {
populateAcc[attributeName] = { on: dzPopulateFragment };
}
break;
}

View File

@ -1,7 +1,7 @@
import type * as Schema from '../../../schema';
import type * as UID from '../../../uid';
import type { Constants, Guard, If, And, DoesNotExtends, IsNotNever } from '../../../utils';
import type { Constants, Guard, If, And, DoesNotExtends, IsNotNever, XOR } from '../../../utils';
import type { Params } from '..';
@ -108,20 +108,16 @@ export type ObjectNotation<TSchemaUID extends UID.Schema> = [
Schema.Attribute.MorphTargets<Schema.AttributeByName<TSchemaUID, TKey>>,
UID.Schema
>
>
// TODO: V5: Remove root-level nested params for morph data structures and only allow fragments
| NestedParams<UID.Schema>;
>;
}
>,
// Loose fallback when registries are not extended
| { [TKey in string]?: boolean | NestedParams<UID.Schema> }
| {
[TKey in string]?:
| boolean
| Fragment<UID.Schema>
// TODO: V5: Remove root-level nested params for morph data structures and only allow fragments
| NestedParams<UID.Schema>;
}
{
[key: string]:
| boolean
// We can't have both populate fragments and nested params, hence the xor
| XOR<NestedParams<UID.Schema>, Fragment<UID.Schema>>;
}
>
: never;

View File

@ -1,7 +1,7 @@
import type * as Schema from '../../../schema';
import type * as UID from '../../../uid';
import type { Constants, Guard, If, And, DoesNotExtends, IsNotNever } from '../../../utils';
import type { Constants, Guard, If, And, DoesNotExtends, IsNotNever, XOR } from '../../../utils';
import type { Params } from '..';
@ -60,7 +60,7 @@ type GetPopulatableKeysWithoutTarget<TSchemaUID extends UID.Schema> = Exclude<
* Fragment populate notation for polymorphic attributes
*/
export type Fragment<TMaybeTargets extends UID.Schema> = {
on?: { [TKey in TMaybeTargets]: boolean | NestedParams<TKey> };
on?: { [TKey in TMaybeTargets]?: boolean | NestedParams<TKey> };
};
type PopulateClause<
@ -108,20 +108,16 @@ export type ObjectNotation<TSchemaUID extends UID.Schema> = [
Schema.Attribute.MorphTargets<Schema.AttributeByName<TSchemaUID, TKey>>,
UID.Schema
>
>
// TODO: V5: Remove root-level nested params for morph data structures and only allow fragments
| NestedParams<UID.Schema>;
>;
}
>,
// Loose fallback when registries are not extended
| { [key: string]: boolean | NestedParams<UID.Schema> }
| {
[key: string]:
| boolean
| Fragment<UID.Schema>
// TODO: V5: Remove root-level nested params for morph data structures and only allow fragments
| NestedParams<UID.Schema>;
}
{
[key: string]:
| boolean
// We can't have both populate fragments and nested params, hence the xor
| XOR<NestedParams<UID.Schema>, Fragment<UID.Schema>>;
}
>
: never;

View File

@ -13,7 +13,6 @@ import {
isObject,
cloneDeep,
get,
mergeAll,
isArray,
isString,
} from 'lodash/fp';
@ -341,10 +340,20 @@ const createTransformer = ({ getModel }: TransformerOptions) => {
}
const { attributes } = schema;
return Object.entries(populate).reduce((acc, [key, subPopulate]) => {
if (_.isString(subPopulate)) {
try {
const subPopulateAsBoolean = parseType({ type: 'boolean', value: subPopulate });
// Only true is accepted as a boolean populate value
return subPopulateAsBoolean === true ? { ...acc, [key]: true } : acc;
} catch {
// ignore
}
}
if (_.isBoolean(subPopulate)) {
return { ...acc, [key]: subPopulate };
// Only true is accepted as a boolean populate value
return subPopulate === true ? { ...acc, [key]: true } : acc;
}
const attribute = attributes[key];
@ -357,42 +366,29 @@ const createTransformer = ({ getModel }: TransformerOptions) => {
const isAllowedAttributeForFragmentPopulate =
isDynamicZoneAttribute(attribute) || isMorphToRelationalAttribute(attribute);
if (isAllowedAttributeForFragmentPopulate && hasFragmentPopulateDefined(subPopulate)) {
return {
...acc,
[key]: {
on: Object.entries(subPopulate.on).reduce(
(acc, [type, typeSubPopulate]) => ({
...acc,
[type]: convertNestedPopulate(typeSubPopulate, 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 (isDynamicZoneAttribute(attribute)) {
const populates = attribute.components
.map((uid) => getModel(uid))
.map((schema) => convertNestedPopulate(subPopulate, schema))
.map((populate) => (populate === true ? {} : populate)) // cast boolean to empty object to avoid merging issues
.filter((populate) => populate !== false);
if (isEmpty(populates)) {
return acc;
if (isAllowedAttributeForFragmentPopulate) {
if (hasFragmentPopulateDefined(subPopulate)) {
// does it have 'on' AND no other property
return {
...acc,
[key]: {
on: Object.entries(subPopulate.on).reduce(
(acc, [type, typeSubPopulate]) => ({
...acc,
[type]: convertNestedPopulate(typeSubPopulate, getModel(type)),
}),
{}
),
},
};
}
return {
...acc,
[key]: mergeAll(populates),
};
throw new Error(
`Invalid nested populate. Expected a fragment ("on") but found ${JSON.stringify(subPopulate)}`
);
}
if (isMorphToRelationalAttribute(attribute)) {
return { ...acc, [key]: convertNestedPopulate(subPopulate, undefined) };
if (!isAllowedAttributeForFragmentPopulate && hasFragmentPopulateDefined(subPopulate)) {
throw new Error(`Using fragments is not permitted to populate "${key}" in "${schema.uid}"`);
}
// NOTE: Retrieve the target schema UID.

View File

@ -12,8 +12,6 @@ import {
cloneDeep,
join,
first,
omit,
merge,
} from 'lodash/fp';
import traverseFactory from './factory';
@ -178,6 +176,8 @@ const populate = traverseFactory()
const newValue = await recurse(visitor, { schema, path, getModel }, { on: value?.on });
set(key, { on: newValue });
return;
}
const targetSchemaUID = attribute.target;
@ -214,48 +214,17 @@ const populate = traverseFactory()
set(key, newValue);
})
// Handle populate on dynamic zones
.onDynamicZone(
async ({ key, value, attribute, schema, visitor, path, getModel }, { set, recurse }) => {
if (isNil(value)) {
return;
}
if (isObject(value)) {
const { components } = attribute;
const newValue = {};
// Handle legacy DZ params
let newProperties: unknown = omit('on', value);
for (const componentUID of components) {
const componentSchema = getModel(componentUID);
const properties = await recurse(
visitor,
{ schema: componentSchema, path, getModel },
value
);
newProperties = merge(newProperties, properties);
}
Object.assign(newValue, newProperties);
// Handle new morph fragment syntax
if ('on' in value && value.on) {
const newOn = await recurse(visitor, { schema, path, getModel }, { on: value.on });
// Recompose both syntaxes
Object.assign(newValue, newOn);
}
set(key, newValue);
} else {
const newValue = await recurse(visitor, { schema, path, getModel }, value);
set(key, newValue);
}
.onDynamicZone(async ({ key, value, schema, visitor, path, getModel }, { set, recurse }) => {
if (isNil(value) || !isObject(value)) {
return;
}
);
// Handle fragment syntax
if ('on' in value && value.on) {
const newOn = await recurse(visitor, { schema, path, getModel }, { on: value.on });
set(key, newOn);
}
});
export default curry(populate.traverse);

View File

@ -325,7 +325,12 @@ describe('Populate filters', () => {
test('Populate every component in the dynamic zone', async () => {
const qs = {
populate: {
dz: '*',
dz: {
on: {
'default.foo': true,
'default.bar': true,
},
},
},
};

View File

@ -21,7 +21,9 @@ const schemas = {
singularName: 'a',
pluralName: 'as',
attributes: {
cover: { type: 'media' },
cover: {
type: 'media',
},
},
},
b: {
@ -143,9 +145,12 @@ describe('Sanitize populated entries', () => {
test("Media's relations (from related) can be populated without restricted attributes", async () => {
const { status, body } = await contentAPIRequest.get(`/upload/files/${file.id}`, {
qs: { populate: { related: { populate: '*' } } },
qs: {
populate: {
related: true,
},
},
});
expect(status).toBe(200);
expect(body.related).toBeDefined();
expect(Array.isArray(body.related)).toBeTruthy();
@ -170,7 +175,10 @@ describe('Sanitize populated entries', () => {
});
const { status } = await contentAPIRequest.get(`/${schemas.contentTypes.b.pluralName}`, {
qs: { fields: ['id'], populate: '*' },
qs: {
fields: ['id'],
populate: '*',
},
});
expect(status).toBe(200);

View File

@ -941,30 +941,11 @@ describe('Core API - Validate', () => {
it.todo('Populates a media');
describe('Dynamic Zone', () => {
it.each([{ dz: { populate: '*' } }, { dz: { populate: true } }, { dz: '*' }, { dz: true }])(
'Populates a dynamic-zone (%s)',
async (populate) => {
const res = await rq.get('/api/documents', { qs: { populate } });
checkAPIResultValidity(res);
res.body.data.forEach((document) => {
expect(document).toHaveProperty(
'dz',
expect.arrayContaining([
expect.objectContaining({ __component: expect.any(String) }),
])
);
expect(document.dz).toHaveLength(3);
});
}
);
it.each([
[{ dz: { on: { 'default.component-a': true } } }, 'default.component-a', 2],
[{ dz: { on: { 'default.component-b': true } } }, 'default.component-b', 1],
])(
'Populates a dynamic-use using populate fragments (%s)',
'Populates a dynamic-zone using populate fragments (%s)',
async (populate, componentUID, expectedLength) => {
const res = await rq.get('/api/documents', { qs: { populate } });