mirror of
https://github.com/strapi/strapi.git
synced 2025-12-27 07:03:38 +00:00
Merge pull request #20172 from strapi/v5/remove-root-level-nested-params
Remove root level nested params
This commit is contained in:
commit
e4b2a92811
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 } });
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user