feat: type entity manager service

This commit is contained in:
Marc-Roig 2023-11-21 12:13:25 +01:00
parent a1c8cbb8a8
commit 153a3d8aec
No known key found for this signature in database
GPG Key ID: FB4E2C43A0BEE249
3 changed files with 144 additions and 108 deletions

View File

@ -1,5 +1,6 @@
import { omit } from 'lodash/fp';
import { mapAsync, errors, contentTypes, sanitize } from '@strapi/utils';
import type { LoadedStrapi as Strapi, Common, EntityService } from '@strapi/types';
import { getService } from '../utils';
import {
getDeepPopulate,
@ -18,7 +19,11 @@ const { PUBLISHED_AT_ATTRIBUTE } = contentTypes.constants;
const omitPublishedAtField = omit(PUBLISHED_AT_ATTRIBUTE);
const emitEvent = async (uid: any, event: any, entity: any) => {
// Types reused from entity service
type Entity = EntityService.Result<Common.UID.ContentType>;
type Body = EntityService.Params.Data.Input<Common.UID.ContentType>;
const emitEvent = async (uid: Common.UID.ContentType, event: string, entity: Entity) => {
const modelDef = strapi.getModel(uid);
const sanitizedEntity = await sanitize.sanitizers.defaultSanitizeOutput(modelDef, entity);
@ -28,7 +33,7 @@ const emitEvent = async (uid: any, event: any, entity: any) => {
});
};
const buildDeepPopulate = (uid: string) => {
const buildDeepPopulate = (uid: Common.UID.ContentType) => {
// User can configure to populate relations, so downstream services can use them.
// They will be transformed into counts later if this is set to true.
@ -44,14 +49,14 @@ const buildDeepPopulate = (uid: string) => {
/**
* @type {import('./entity-manager').default}
*/
export default ({ strapi }: any) => ({
export default ({ strapi }: { strapi: Strapi }) => ({
/**
* Extend this function from other plugins to add custom mapping of entity
* responses
* @param {Object} entity
* @returns
*/
mapEntity<T = any>(entity: T): T {
mapEntity<T = unknown>(entity: T): T {
return entity;
},
@ -61,9 +66,9 @@ export default ({ strapi }: any) => ({
* @param {Array|Object|null} entities
* @param {string} uid
*/
async mapEntitiesResponse(entities: any, uid: any) {
async mapEntitiesResponse(entities: any, uid: Common.UID.ContentType) {
if (entities?.results) {
const mappedResults = await mapAsync(entities.results, (entity: any) =>
const mappedResults = await mapAsync(entities.results, (entity: Entity) =>
// @ts-expect-error mapEntity can be extended
this.mapEntity(entity, uid)
);
@ -74,29 +79,35 @@ export default ({ strapi }: any) => ({
return this.mapEntity(entities, uid);
},
async find(opts: any, uid: any) {
const params = { ...opts, populate: getDeepPopulate(uid) };
async find(
opts: Parameters<typeof strapi.entityService.findMany>[1],
uid: Common.UID.ContentType
) {
const params = { ...opts, populate: getDeepPopulate(uid) } as typeof opts;
const entities = await strapi.entityService.findMany(uid, params);
return this.mapEntitiesResponse(entities, uid);
},
async findPage(opts: any, uid: any) {
async findPage(
opts: Parameters<typeof strapi.entityService.findPage>[1],
uid: Common.UID.ContentType
) {
const entities = await strapi.entityService.findPage(uid, opts);
return this.mapEntitiesResponse(entities, uid);
},
async findOne(id: any, uid: any, opts = {}) {
async findOne(id: Entity['id'], uid: Common.UID.ContentType, opts = {}) {
return (
strapi.entityService
.findOne(uid, id, opts)
// @ts-expect-error mapEntity can be extended
.then((entity: any) => this.mapEntity(entity, uid))
.then((entity: Entity) => this.mapEntity(entity, uid))
);
},
async create(body: any, uid: any) {
async create(body: Body, uid: Common.UID.ContentType) {
const modelDef = strapi.getModel(uid);
const publishData = { ...body };
const publishData = { ...body } as any;
const populate = await buildDeepPopulate(uid);
if (hasDraftAndPublish(modelDef)) {
@ -108,7 +119,7 @@ export default ({ strapi }: any) => ({
const entity = await strapi.entityService
.create(uid, params)
// @ts-expect-error mapEntity can be extended
.then((entity: any) => this.mapEntity(entity, uid));
.then((entity: Entity) => this.mapEntity(entity, uid));
if (isWebhooksPopulateRelationsEnabled()) {
return getDeepRelationsCount(entity, uid);
@ -117,7 +128,7 @@ export default ({ strapi }: any) => ({
return entity;
},
async update(entity: any, body: any, uid: any) {
async update(entity: Entity, body: Partial<Body>, uid: Common.UID.ContentType) {
const publishData = omitPublishedAtField(body);
const populate = await buildDeepPopulate(uid);
const params = { data: publishData, populate };
@ -125,7 +136,7 @@ export default ({ strapi }: any) => ({
const updatedEntity = await strapi.entityService
.update(uid, entity.id, params)
// @ts-expect-error mapEntity can be extended
.then((entity: any) => this.mapEntity(entity, uid));
.then((entity: Entity) => this.mapEntity(entity, uid));
if (isWebhooksPopulateRelationsEnabled()) {
return getDeepRelationsCount(updatedEntity, uid);
@ -133,7 +144,7 @@ export default ({ strapi }: any) => ({
return updatedEntity;
},
async clone(entity: any, body: any, uid: any) {
async clone(entity: Entity, body: Partial<Body>, uid: Common.UID.ContentType) {
const modelDef = strapi.getModel(uid);
const populate = await buildDeepPopulate(uid);
const publishData = { ...body };
@ -156,7 +167,7 @@ export default ({ strapi }: any) => ({
return clonedEntity;
},
async delete(entity: any, uid: any) {
async delete(entity: Entity, uid: Common.UID.ContentType) {
const populate = await buildDeepPopulate(uid);
const deletedEntity = await strapi.entityService.delete(uid, entity.id, { populate });
@ -169,11 +180,14 @@ export default ({ strapi }: any) => ({
},
// FIXME: handle relations
deleteMany(opts: any, uid: any) {
deleteMany(
opts: Parameters<typeof strapi.entityService.deleteMany>[1],
uid: Common.UID.ContentType
) {
return strapi.entityService.deleteMany(uid, opts);
},
async publish(entity: any, uid: any, body = {}) {
async publish(entity: Entity, uid: Common.UID.ContentType, body = {}) {
if (entity[PUBLISHED_AT_ATTRIBUTE]) {
throw new ApplicationError('already.published');
}
@ -183,6 +197,7 @@ export default ({ strapi }: any) => ({
strapi.getModel(uid),
entity,
undefined,
// @ts-expect-error - FIXME: entity here is unnecessary
entity
);
@ -193,7 +208,7 @@ export default ({ strapi }: any) => ({
const updatedEntity = await strapi.entityService.update(uid, entity.id, params);
await emitEvent(uid, ENTRY_PUBLISH, updatedEntity);
await emitEvent(uid, ENTRY_PUBLISH, updatedEntity!);
// @ts-expect-error mapEntity can be extended
const mappedEntity = await this.mapEntity(updatedEntity, uid);
@ -206,18 +221,19 @@ export default ({ strapi }: any) => ({
return mappedEntity;
},
async publishMany(entities: any, uid: any) {
async publishMany(entities: Entity[], uid: Common.UID.ContentType) {
if (!entities.length) {
return null;
}
// Validate entities before publishing, throw if invalid
await Promise.all(
entities.map((entity: any) => {
entities.map((entity: Entity) => {
return strapi.entityValidator.validateEntityCreation(
strapi.getModel(uid),
entity,
undefined,
// @ts-expect-error - FIXME: entity here is unnecessary
entity
);
})
@ -225,8 +241,8 @@ export default ({ strapi }: any) => ({
// Only publish entities without a published_at date
const entitiesToPublish = entities
.filter((entity: any) => !entity[PUBLISHED_AT_ATTRIBUTE])
.map((entity: any) => entity.id);
.filter((entity: Entity) => !entity[PUBLISHED_AT_ATTRIBUTE])
.map((entity: Entity) => entity.id);
const filters = { id: { $in: entitiesToPublish } };
const data = { [PUBLISHED_AT_ATTRIBUTE]: new Date() };
@ -241,22 +257,22 @@ export default ({ strapi }: any) => ({
const publishedEntities = await strapi.entityService.findMany(uid, { filters, populate });
// Emit the publish event for all updated entities
await Promise.all(
publishedEntities.map((entity: any) => emitEvent(uid, ENTRY_PUBLISH, entity))
publishedEntities!.map((entity: Entity) => emitEvent(uid, ENTRY_PUBLISH, entity))
);
// Return the number of published entities
return publishedEntitiesCount;
},
async unpublishMany(entities: any, uid: any) {
async unpublishMany(entities: Entity[], uid: Common.UID.ContentType) {
if (!entities.length) {
return null;
}
// Only unpublish entities with a published_at date
const entitiesToUnpublish = entities
.filter((entity: any) => entity[PUBLISHED_AT_ATTRIBUTE])
.map((entity: any) => entity.id);
.filter((entity: Entity) => entity[PUBLISHED_AT_ATTRIBUTE])
.map((entity: Entity) => entity.id);
const filters = { id: { $in: entitiesToUnpublish } };
const data = { [PUBLISHED_AT_ATTRIBUTE]: null };
@ -271,14 +287,14 @@ export default ({ strapi }: any) => ({
const unpublishedEntities = await strapi.entityService.findMany(uid, { filters, populate });
// Emit the unpublish event for all updated entities
await Promise.all(
unpublishedEntities.map((entity: any) => emitEvent(uid, ENTRY_UNPUBLISH, entity))
unpublishedEntities!.map((entity: Entity) => emitEvent(uid, ENTRY_UNPUBLISH, entity))
);
// Return the number of unpublished entities
return unpublishedEntitiesCount;
},
async unpublish(entity: any, uid: any, body = {}) {
async unpublish(entity: Entity, uid: Common.UID.ContentType, body = {}) {
if (!entity[PUBLISHED_AT_ATTRIBUTE]) {
throw new ApplicationError('already.draft');
}
@ -290,7 +306,7 @@ export default ({ strapi }: any) => ({
const updatedEntity = await strapi.entityService.update(uid, entity.id, params);
await emitEvent(uid, ENTRY_UNPUBLISH, updatedEntity);
await emitEvent(uid, ENTRY_UNPUBLISH, updatedEntity!);
// @ts-expect-error mapEntity can be extended
const mappedEntity = await this.mapEntity(updatedEntity, uid);
@ -303,7 +319,7 @@ export default ({ strapi }: any) => ({
return mappedEntity;
},
async countDraftRelations(id: string, uid: string) {
async countDraftRelations(id: Entity['id'], uid: Common.UID.ContentType) {
const { populate, hasRelations } = getDeepPopulateDraftCount(uid);
if (!hasRelations) {
@ -315,7 +331,11 @@ export default ({ strapi }: any) => ({
return sumDraftCounts(entity, uid);
},
async countManyEntriesDraftRelations(ids: number[], uid: string, locale = 'en') {
async countManyEntriesDraftRelations(
ids: number[],
uid: Common.UID.ContentType,
locale: string = 'en'
) {
const { populate, hasRelations } = getDeepPopulateDraftCount(uid);
if (!hasRelations) {
@ -328,8 +348,8 @@ export default ({ strapi }: any) => ({
locale,
});
const totalNumberDraftRelations = entities.reduce(
(count: any, entity: any) => sumDraftCounts(entity, uid) + count,
const totalNumberDraftRelations: number = entities!.reduce(
(count: number, entity: Entity) => sumDraftCounts(entity, uid) + count,
0
);

View File

@ -1,18 +1,21 @@
import { isNil } from 'lodash/fp';
import { getDeepPopulate, getQueryPopulate } from './utils/populate';
import type { Common } from '@strapi/types';
import { type Populate, getDeepPopulate, getQueryPopulate } from './utils/populate';
/**
* Builder to create a Strapi populate object.
*
* @param {string} uid - Content type UID
* @param uid - Content type UID
*
* @example
* const populate = await populateBuilder('api::article.article').countRelations().build();
* // populate = { article: { populate: { count: true } } }
*
*/
const populateBuilder = (uid: string) => {
let getInitialPopulate = async () => {};
const populateBuilder = (uid: Common.UID.Schema) => {
let getInitialPopulate = async (): Promise<undefined | Populate> => {
return undefined;
};
const deepPopulateOptions = {
countMany: false,
countOne: false,
@ -22,22 +25,20 @@ const populateBuilder = (uid: string) => {
const builder = {
/**
* Populates all attribute fields present in a query.
* @param {Object} query - Query object
* @returns {typeof builder} - Builder
* @param query - Strapi query object
*/
populateFromQuery(query: any) {
populateFromQuery(query: object) {
getInitialPopulate = async () => getQueryPopulate(uid, query);
return builder;
},
/**
* Populate relations as count if condition is true.
* @param {Boolean} condition
* @param {Object} [options]
* @param {Boolean} [options.toMany] - Populate XtoMany relations as count if true.
* @param {Boolean} [options.toOne] - Populate XtoOne relations as count if true.
* @returns {typeof builder} - Builder
* @param condition
* @param [options]
* @param [options.toMany] - Populate XtoMany relations as count if true.
* @param [options.toOne] - Populate XtoOne relations as count if true.
*/
countRelationsIf(condition: any, { toMany, toOne } = { toMany: true, toOne: true }) {
countRelationsIf(condition: boolean, { toMany, toOne } = { toMany: true, toOne: true }) {
if (condition) {
return this.countRelations({ toMany, toOne });
}
@ -45,10 +46,9 @@ const populateBuilder = (uid: string) => {
},
/**
* Populate relations as count.
* @param {Object} [options]
* @param {Boolean } [options.toMany] - Populate XtoMany relations as count if true.
* @param {Boolean} [options.toOne] - Populate XtoOne relations as count if true.
* @returns {typeof builder} - Builder
* @param [options]
* @param [options.toMany] - Populate XtoMany relations as count if true.
* @param [options.toOne] - Populate XtoOne relations as count if true.
*/
countRelations({ toMany, toOne } = { toMany: true, toOne: true }) {
if (!isNil(toMany)) {
@ -61,8 +61,7 @@ const populateBuilder = (uid: string) => {
},
/**
* Populate relations deeply, up to a certain level.
* @param {Number} [level=Infinity] - Max level of nested populate.
* @returns {typeof builder} - Builder
* @param [level=Infinity] - Max level of nested populate.
*/
populateDeep(level = Infinity) {
deepPopulateOptions.maxLevel = level;
@ -70,7 +69,7 @@ const populateBuilder = (uid: string) => {
},
/**
* Construct the populate object based on the builder options.
* @returns {Object} - Populate object
* @returns Populate object
*/
async build() {
const initialPopulate = await getInitialPopulate();

View File

@ -1,5 +1,6 @@
import { merge, isEmpty, set, propEq } from 'lodash/fp';
import strapiUtils from '@strapi/utils';
import { Common, Attribute, EntityService } from '@strapi/types';
const { hasDraftAndPublish, isVisibleAttribute } = strapiUtils.contentTypes;
const { isAnyToMany } = strapiUtils.relations;
@ -12,22 +13,30 @@ const isRelation = propEq('type', 'relation');
const isComponent = propEq('type', 'component');
const isDynamicZone = propEq('type', 'dynamiczone');
// TODO: Import from @strapi/types when it's available there
type Model = Parameters<typeof isVisibleAttribute>[0];
export type Populate = EntityService.Params.Populate.Any<Common.UID.Schema>;
type PopulateOptions = {
initialPopulate?: Populate;
countMany?: boolean;
countOne?: boolean;
maxLevel?: number;
};
/**
* Populate the model for relation
* @param {Object} attribute - Attribute containing a relation
* @param {String} attribute.relation - type of relation
* @param attribute - Attribute containing a relation
* @param attribute.relation - type of relation
* @param model - Model of the populated entity
* @param attributeName
* @param {Object} options - Options to apply while populating
* @param {Boolean} options.countMany
* @param {Boolean} options.countOne
* @returns {true|{count: true}}
* @param options - Options to apply while populating
*/
function getPopulateForRelation(
attribute: any,
model: any,
attributeName: any,
{ countMany, countOne, initialPopulate }: any
attribute: Attribute.Any,
model: Model,
attributeName: string,
{ countMany, countOne, initialPopulate }: PopulateOptions
) {
const isManyRelation = isAnyToMany(attribute);
@ -49,19 +58,18 @@ function getPopulateForRelation(
/**
* Populate the model for Dynamic Zone components
* @param {Object} attribute - Attribute containing the components
* @param {String[]} attribute.components - IDs of components
* @param {Object} options - Options to apply while populating
* @param {Boolean} options.countMany
* @param {Boolean} options.countOne
* @param {Number} options.maxLevel
* @param {Number} level
* @returns {{populate: Object}}
* @param attribute - Attribute containing the components
* @param attribute.components - IDs of components
* @param options - Options to apply while populating
*/
function getPopulateForDZ(attribute: any, options: any, level: any) {
function getPopulateForDZ(
attribute: Attribute.DynamicZone,
options: PopulateOptions,
level: number
) {
// Use fragments to populate the dynamic zone components
const populatedComponents = (attribute.components || []).reduce(
(acc: any, componentUID: any) => ({
(acc: any, componentUID: Common.UID.Component) => ({
...acc,
[componentUID]: {
populate: getDeepPopulate(componentUID, options, level + 1),
@ -75,21 +83,26 @@ function getPopulateForDZ(attribute: any, options: any, level: any) {
/**
* Get the populated value based on the type of the attribute
* @param {String} attributeName - Name of the attribute
* @param {Object} model - Model of the populated entity
* @param {Object} model.attributes
* @param {Object} options - Options to apply while populating
* @param {Boolean} options.countMany
* @param {Boolean} options.countOne
* @param {Number} options.maxLevel
* @param {Number} level
* @returns {Object}
* @param attributeName - Name of the attribute
* @param model - Model of the populated entity
* @param model.attributes
* @param options - Options to apply while populating
* @param options.countMany
* @param options.countOne
* @param options.maxLevel
* @param level
*/
function getPopulateFor(attributeName: any, model: any, options: any, level: any): any {
function getPopulateFor(
attributeName: string,
model: any,
options: PopulateOptions,
level: number
): { [key: string]: boolean | object } {
const attribute = model.attributes[attributeName];
switch (attribute.type) {
case 'relation':
// @ts-expect-error - TODO: support populate count typing
return {
[attributeName]: getPopulateForRelation(attribute, model, attributeName, options),
};
@ -114,17 +127,18 @@ function getPopulateFor(attributeName: any, model: any, options: any, level: any
/**
* Deeply populate a model based on UID
* @param {String} uid - Unique identifier of the model
* @param {Object} [options] - Options to apply while populating
* @param {Boolean} [options.countMany=false]
* @param {Boolean} [options.countOne=false]
* @param {Number} [options.maxLevel=Infinity]
* @param {Number} [level=1] - Current level of nested call
* @returns {Object}
* @param uid - Unique identifier of the model
* @param options - Options to apply while populating
* @param level - Current level of nested call
*/
const getDeepPopulate = (
uid: any,
{ initialPopulate = {}, countMany = false, countOne = false, maxLevel = Infinity }: any = {},
uid: Common.UID.Schema,
{
initialPopulate = {} as any,
countMany = false,
countOne = false,
maxLevel = Infinity,
}: PopulateOptions = {},
level = 1
) => {
if (level > maxLevel) {
@ -134,13 +148,19 @@ const getDeepPopulate = (
const model = strapi.getModel(uid);
return Object.keys(model.attributes).reduce(
(populateAcc, attributeName) =>
(populateAcc, attributeName: string) =>
merge(
populateAcc,
getPopulateFor(
attributeName,
model,
{ initialPopulate: initialPopulate?.[attributeName], countMany, countOne, maxLevel },
{
// @ts-expect-error - improve types
initialPopulate: initialPopulate?.[attributeName],
countMany,
countOne,
maxLevel,
},
level
)
),
@ -152,12 +172,12 @@ const getDeepPopulate = (
* getDeepPopulateDraftCount works recursively on the attributes of a model
* creating a populated object to count all the unpublished relations within the model
* These relations can be direct to this content type or contained within components/dynamic zones
* @param {String} uid of the model
* @returns {Object} result
* @returns {Object} result.populate
* @returns {Boolean} result.hasRelations
* @param uid of the model
* @returns result
* @returns result.populate
* @returns result.hasRelations
*/
const getDeepPopulateDraftCount = (uid: any) => {
const getDeepPopulateDraftCount = (uid: Common.UID.Schema) => {
const model = strapi.getModel(uid);
let hasRelations = false;
@ -213,13 +233,9 @@ const getDeepPopulateDraftCount = (uid: any) => {
/**
* Create a Strapi populate object which populates all attribute fields of a Strapi query.
*
* @param {string} uid
* @param {Object} query
* @returns {Object} populate object
*/
const getQueryPopulate = async (uid: any, query: any): Promise<any> => {
let populateQuery = {};
const getQueryPopulate = async (uid: Common.UID.Schema, query: object): Promise<Populate> => {
let populateQuery: Populate = {};
await strapiUtils.traverse.traverseQueryFilters(
/**
@ -239,6 +255,7 @@ const getQueryPopulate = async (uid: any, query: any): Promise<any> => {
// Populate all relations, components and media
if (isRelation(attribute) || isMedia(attribute) || isComponent(attribute)) {
const populatePath = path.attribute.replace(/\./g, '.populate.');
// @ts-expect-error - lodash doesn't resolve the Populate type correctly
populateQuery = set(populatePath, {}, populateQuery);
}
},