Source

decaf-model/FromModelController.ts

import { Controller, Param, Query, Response } from "@nestjs/common";
import {
  ApiBadRequestResponse,
  ApiBody,
  ApiCreatedResponse,
  ApiExtraModels,
  ApiNotFoundResponse,
  ApiOkResponse,
  ApiOperation,
  ApiParam,
  ApiQuery,
  ApiTags,
  ApiUnprocessableEntityResponse,
  getSchemaPath,
} from "@nestjs/swagger";
import {
  type DirectionLimitOffset,
  ModelService,
  OrderDirection,
  PersistenceKeys,
  PreparedStatementKeys,
  type Repo,
  Repository,
  Service,
} from "@decaf-ts/core";
import { Model, ModelConstructor } from "@decaf-ts/decorator-validation";
import { Logging, toKebabCase } from "@decaf-ts/logging";
import {
  BulkCrudOperationKeys,
  DBKeys,
  OperationKeys,
  ValidationError,
} from "@decaf-ts/db-decorators";
import { Constructor, Metadata } from "@decaf-ts/decoration";
import {
  ApiOperationFromModel,
  ApiParamsFromModel,
  type DecafApiProperty,
  DecafBody,
  type DecafModelRoute,
  type DecafParamProps,
  DecafParams,
  DecafQuery,
  DecafRouteDecOptions,
} from "./decorators";
import { DecafRequestContext } from "../request";
import { DECAF_ROUTE } from "../constants";
import {
  applyApiDecorators,
  createRouteHandler,
  defineRouteMethod,
  getApiDecorators,
  resolvePersistenceMethod,
} from "./utils";
import { Auth } from "./decorators/decorators";
import { ControllerConstructor } from "./types";
import { DecafModelController } from "../controllers";
import { DtoFor } from "../factory/openapi/DtoBuilder";
import "../overrides";

/**
 * @description
 * Factory and utilities for generating dynamic NestJS controllers from Decaf {@link Model} definitions.
 *
 * @summary
 * The `FromModelController` class provides the infrastructure necessary to automatically generate
 * strongly-typed CRUD controllers based on a given {@link ModelConstructor}. It inspects metadata from
 * the model, derives route paths, parameters, and generates a dynamic controller class at runtime with
 * full support for querying, creation, update, and deletion of model entities through a {@link Repo}.
 *
 * @template T The {@link Model} type associated with the generated controller.
 *
 * @param ModelClazz The model class to generate the controller from.
 *
 * @class FromModelController
 *
 * @example
 * ```ts
 * // Given a Decaf Model:
 * class User extends Model<User> {
 *   id!: string;
 *   name!: string;
 * }
 *
 * // Register controller:
 * const UserController = FromModelController.create(User);
 *
 * // NestJS will expose:
 * // POST   /user
 * // GET    /user/:id
 * // GET    /user/query/:method
 * // PUT    /user/:id
 * // DELETE /user/:id
 * ```
 *
 * @mermaid
 * sequenceDiagram
 *     participant Client
 *     participant Controller
 *     participant Repo
 *     participant DB
 *
 *     Client->>Controller: HTTP Request
 *     Controller->>Repo: Resolve repository for Model
 *     Repo->>DB: Execute DB operation
 *     DB-->>Repo: DB Result
 *     Repo-->>Controller: Model Instance(s)
 *     Controller-->>Client: JSON Response
 */
export class FromModelController {
  private static readonly log = Logging.for(FromModelController.name);

  static getPersistence<T extends Model<boolean>>(
    ModelClazz: ModelConstructor<T>
  ): Repo<T> | ModelService<T> {
    try {
      return Service.get(ModelClazz as any) as ModelService<T>;
    } catch (e: unknown) {
      try {
        return ModelService.getService(ModelClazz) as ModelService<T>;
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
      } catch (e: unknown) {
        return Repository.forModel(ModelClazz) as Repo<T>;
      }
    }
  }

  static createQueryRoutesFromRepository<T extends Model<boolean>>(
    persistence: Repo<T> | ModelService<T>,
    prefix: string = PersistenceKeys.QUERY
  ): ControllerConstructor<T> {
    const log = FromModelController.log.for(
      FromModelController.createQueryRoutesFromRepository
    );
    const repo: Repo<T> =
      persistence instanceof ModelService ? persistence.repo : persistence;
    const ModelConstr: Constructor = repo.class;
    const queryMethods: Record<string, { fields?: string[] | undefined }> =
      Metadata.get(
        repo.constructor as Constructor,
        Metadata.key(PersistenceKeys.QUERY)
      ) ?? {};

    const routeMethods: Record<string, DecafRouteDecOptions> =
      Metadata.get(
        persistence.constructor as Constructor,
        Metadata.key(DECAF_ROUTE)
      ) ?? {};

    // create base class
    class QueryController extends DecafModelController<T> {
      override get class(): ModelConstructor<T> {
        throw new Error("Method not implemented.");
      }
      constructor(clientContext: DecafRequestContext, name: string) {
        super(clientContext, name);
      }
    }

    for (const [methodName, params] of Object.entries(routeMethods)) {
      // regex to trim slashes from start and end
      const routePath = [params.path.replace(/^\/+|\/+$/g, "")]
        .filter((segment) => segment && segment.trim())
        .join("/");

      // const handler = params.handler.value;
      const handler = createRouteHandler(methodName) as any;
      if (!handler) {
        const message = `Invalid or missing handler for model ${ModelConstr.name} on decorated method ${methodName}`;
        log.error(message);
        throw new Error(message);
      }

      const descriptor = defineRouteMethod(
        QueryController,
        methodName,
        handler
      );

      if (descriptor) {
        const decorators = getApiDecorators(
          methodName,
          routePath,
          params.httpMethod
        );
        applyApiDecorators(QueryController, methodName, descriptor, decorators);
      }
    }

    for (const [methodName, objValues] of Object.entries(queryMethods)) {
      const fields = objValues.fields ?? [];
      const routePath = [prefix, methodName, ...fields.map((f) => `:${f}`)]
        .filter((segment) => segment && segment.trim())
        .join("/");

      const handler = createRouteHandler(methodName) as any;
      const descriptor = defineRouteMethod(
        QueryController,
        methodName,
        handler
      );

      if (descriptor) {
        const decorators = getApiDecorators(methodName, routePath, "GET", true);
        applyApiDecorators(QueryController, methodName, descriptor, decorators);
      }
    }

    return QueryController;
  }

  static create<T extends Model<any>>(ModelConstr: ModelConstructor<T>) {
    const log = FromModelController.log.for(FromModelController.create);
    const tableName = Model.tableName(ModelConstr);
    const routePath = toKebabCase(tableName);
    const modelClazzName = ModelConstr.name;
    const persistence = FromModelController.getPersistence(ModelConstr);

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { description, getPK, apiProperties, path } =
      FromModelController.getRouteParametersFromModel(ModelConstr);

    log.debug(`Creating controller for model: ${modelClazzName}`);

    const BaseController = FromModelController.createQueryRoutesFromRepository(
      persistence // instanceof ModelService ? persistence.repo : persistence
    ) as Constructor<DecafModelController<T>>;

    @Controller(routePath)
    @ApiTags(modelClazzName)
    @ApiExtraModels(ModelConstr)
    @Auth(ModelConstr)
    class DynamicModelController extends BaseController {
      private readonly pk: string = Model.pk(ModelConstr) as string;

      protected static get class() {
        return ModelConstr;
      }

      override get class(): ModelConstructor<T> {
        return ModelConstr;
        // return DynamicModelController.class;
      }

      constructor(clientContext: DecafRequestContext) {
        super(clientContext);
        log.info(
          `Registering dynamic controller for model: ${this.class.name} route: /${routePath}`
        );
      }

      //
      // @ApiOperationFromModel(ModelClazz, "GET", "query/:condition/:orderBy")
      // @ApiOperation({ summary: `Retrieve ${modelClazzName} records by query.` })
      // @ApiParam({ name: "method", description: "Query method to be called" })
      // @ApiOkResponse({
      //   description: `${modelClazzName} retrieved successfully.`,
      // })
      // @ApiNotFoundResponse({
      //   description: `No ${modelClazzName} records matches the query.`,
      // })
      // async query(
      //   @Param("condition") condition: Condition<any>,
      //   @Param("orderBy") orderBy: string,
      //   @QueryDetails() details: DirectionLimitOffset,
      // ) {
      //   const {direction, limit, offset} = details;
      //   return this.persistence.query(condition, orderBy as keyof Model, direction, limit, offset);
      // }

      @ApiOperationFromModel(ModelConstr, "GET", "listBy/:key")
      @ApiOperation({ summary: `Retrieve ${modelClazzName} records by query.` })
      @ApiParam({ name: "key", description: "the model key to sort by" })
      @ApiQuery({ name: "direction", required: true, enum: OrderDirection })
      @ApiOkResponse({
        description: `${modelClazzName} listed successfully.`,
      })
      async listBy(
        @Param("key") key: string,
        @DecafQuery() details: DirectionLimitOffset
      ) {
        const { ctx } = (
          await this.logCtx([], PreparedStatementKeys.LIST_BY, true)
        ).for(this.listBy);
        return this.persistence(ctx).listBy(
          key as keyof T,
          details.direction as OrderDirection,
          ctx
        );
      }

      @ApiOperationFromModel(ModelConstr, "GET", "paginateBy/:key/:page")
      @ApiOperation({ summary: `Retrieve ${modelClazzName} records by query.` })
      @ApiParam({ name: "key", description: "the model key to sort by" })
      @ApiParam({
        name: "page",
        description: "the page to retrieve or the bookmark",
      })
      @ApiQuery({
        name: "direction",
        required: true,
        enum: OrderDirection,
        description: "the sort order",
      })
      @ApiQuery({ name: "limit", required: true, description: "the page size" })
      @ApiQuery({ name: "offset", description: "the bookmark when necessary" })
      @ApiOkResponse({
        description: `${modelClazzName} listed paginated.`,
      })
      async paginateBy(
        @Param("key") key: string,
        @DecafQuery() details: DirectionLimitOffset
      ) {
        const { ctx } = (
          await this.logCtx([], PreparedStatementKeys.PAGE_BY, true)
        ).for(this.paginateBy);
        return this.persistence(ctx).paginateBy(
          key as keyof T,
          details.direction as OrderDirection,
          details as Omit<DirectionLimitOffset, "direction">,
          ctx
        );
      }

      @ApiOperationFromModel(ModelConstr, "GET", "find/:value")
      @ApiOperation({
        summary: `Find ${modelClazzName} records using the default query attributes.`,
      })
      @ApiParam({
        name: "value",
        description: "The string to match against the default query attributes",
      })
      @ApiQuery({
        name: "direction",
        required: false,
        enum: OrderDirection,
        description: "the sort order for the matching results",
      })
      @ApiOkResponse({
        description: `${modelClazzName} records matching the provided prefix.`,
      })
      async find(
        @Param("value") value: string,
        @DecafQuery() details: DirectionLimitOffset
      ) {
        const { ctx } = (
          await this.logCtx([], PreparedStatementKeys.FIND, true)
        ).for(this.find);
        const direction = (details.direction ??
          OrderDirection.ASC) as OrderDirection;
        return resolvePersistenceMethod(
          this.persistence(ctx),
          this.find.name,
          value,
          direction,
          ctx
        );
      }

      @ApiOperationFromModel(ModelConstr, "GET", "page/:value")
      @ApiOperation({
        summary: `Page ${modelClazzName} records using the default query attributes.`,
      })
      @ApiParam({
        name: "value",
        description: "The string to match against the default query attributes",
      })
      @ApiQuery({
        name: "direction",
        required: false,
        enum: OrderDirection,
        description: "the sort order for the paged results",
      })
      @ApiQuery({
        name: "limit",
        required: false,
        description: "page size",
      })
      @ApiQuery({
        name: "offset",
        required: false,
        description: "page number",
      })
      @ApiQuery({
        name: "bookmark",
        required: false,
        description: "bookmark for cursor pagination",
      })
      @ApiOkResponse({
        description: `${modelClazzName} records paged by the provided prefix.`,
      })
      async page(
        @Param("value") value: string,
        @DecafQuery() details: DirectionLimitOffset
      ) {
        const { ctx } = (
          await this.logCtx([], PreparedStatementKeys.PAGE, true)
        ).for(this.page);
        const {
          direction = OrderDirection.ASC,
          limit,
          offset,
          bookmark,
        } = details;
        const ref: Omit<DirectionLimitOffset, "direction"> = {
          offset: offset ?? 1,
          limit: limit ?? 10,
          bookmark,
        };
        return resolvePersistenceMethod(
          this.persistence(ctx),
          this.page.name,
          value,
          direction as OrderDirection,
          ref,
          ctx
        );
      }

      @ApiOperationFromModel(ModelConstr, "GET", "findOneBy/:key")
      @ApiOperation({ summary: `Retrieve ${modelClazzName} records by query.` })
      @ApiParam({ name: "key", description: "the model key to sort by" })
      @ApiOkResponse({
        description: `${modelClazzName} listed found.`,
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      async findOneBy(@Param("key") key: string, @Param("value") value: any) {
        const { ctx } = (
          await this.logCtx([], PreparedStatementKeys.FIND_ONE_BY, true)
        ).for(this.findOneBy);
        return this.persistence(ctx).findOneBy(key as keyof T, value, ctx);
      }

      @ApiOperationFromModel(ModelConstr, "GET", "findBy/:key/:value")
      @ApiOperation({ summary: `Retrieve ${modelClazzName} records by query.` })
      @ApiParam({ name: "key", description: "the model key to compare" })
      @ApiParam({ name: "value", description: "the value to match" })
      @ApiQuery({
        name: "direction",
        required: true,
        enum: OrderDirection,
        description: "the sort order when  applicable",
      })
      @ApiOkResponse({
        description: `${modelClazzName} listed found.`,
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      async findBy(
        @Param("key") key: string,
        @Param("value") value: any,
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        @DecafQuery() details: DirectionLimitOffset
      ) {
        const { ctx } = (
          await this.logCtx([], PreparedStatementKeys.FIND_BY, true)
        ).for(this.findBy);
        return this.persistence(ctx)
          .for(ctx.toOverrides())
          .findBy(key as keyof T, value, ctx);
      }

      @ApiOperationFromModel(ModelConstr, "GET", "statement/:method/*args")
      @ApiOperation({
        summary: `Executes a prepared statement on ${modelClazzName}.`,
      })
      @ApiParam({
        name: "method",
        description: "the prepared statement to execute",
      })
      @ApiParam({
        name: "args",
        description:
          "concatenated list of arguments the prepared statement can accept",
      })
      @ApiQuery({
        name: "direction",
        required: true,
        enum: OrderDirection,
        description: "the sort order when  applicable",
      })
      @ApiQuery({
        name: "limit",
        required: true,
        description: "limit or page size when applicable",
      })
      @ApiQuery({
        name: "offset",
        required: true,
        description: "offset or bookmark when applicable",
      })
      @ApiOkResponse({
        description: `${modelClazzName} listed found.`,
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      async statement(
        @Param("method") name: string,
        @Param("args") args: (string | number)[],
        @DecafQuery() details: DirectionLimitOffset
      ) {
        const { ctx } = (
          await this.logCtx([], PersistenceKeys.STATEMENT, true)
        ).for(this.statement);
        const { direction, offset, limit, bookmark } = details;
        args = args.map(
          (a) => (typeof a === "string" ? parseInt(a) : a) || a
        ) as any[];
        const pathDirection = args.length > 1 ? args[1] : undefined;
        const resolvedDirection = (direction ?? pathDirection) as
          | string
          | undefined;
        if (resolvedDirection && args.length > 1) args[1] = resolvedDirection;
        switch (name) {
          case PreparedStatementKeys.FIND:
          case PreparedStatementKeys.FIND_BY:
            break;
          case PreparedStatementKeys.LIST_BY:
            args.push(direction as string);
            break;
          case PreparedStatementKeys.PAGE:
          case PreparedStatementKeys.PAGE_BY:
            args = [
              args[0],
              resolvedDirection as any,
              {
                limit: limit,
                offset: offset,
                bookmark: bookmark,
              },
            ];
            break;
          case PreparedStatementKeys.FIND_ONE_BY:
            break;
          case PreparedStatementKeys.COUNT_OF:
          case PreparedStatementKeys.MAX_OF:
          case PreparedStatementKeys.MIN_OF:
          case PreparedStatementKeys.AVG_OF:
          case PreparedStatementKeys.SUM_OF:
          case PreparedStatementKeys.DISTINCT_OF:
          case PreparedStatementKeys.GROUP_OF:
            // Aggregation methods - args[0] is the field name (if provided)
            break;
        }
        return this.persistence(ctx).statement(name, ...args, ctx);
      }
      //
      // @ApiOperationFromModel(ModelConstr, "GET", "countOf")
      // @ApiOperation({ summary: `Count all ${modelClazzName} records.` })
      // @ApiOkResponse({
      //   description: `Count of ${modelClazzName} records.`,
      //   type: Number,
      // })
      // async countOf() {
      //   const { ctx } = (
      //     await this.logCtx([], PreparedStatementKeys.COUNT_OF, true)
      //   ).for(this.countOf);
      //   return this.persistence(ctx).statement(
      //     PreparedStatementKeys.COUNT_OF,
      //     ctx
      //   );
      // }
      //
      // @ApiOperationFromModel(ModelConstr, "GET", "countOf/:field")
      // @ApiOperation({ summary: `Count ${modelClazzName} records by field.` })
      // @ApiParam({ name: "field", description: "The field to count" })
      // @ApiOkResponse({
      //   description: `Count of ${modelClazzName} records.`,
      //   type: Number,
      // })
      // async countOfField(@Param("field") field: string) {
      //   const { ctx } = (
      //     await this.logCtx([], PreparedStatementKeys.COUNT_OF, true)
      //   ).for(this.countOfField);
      //   return this.persistence(ctx).statement(
      //     PreparedStatementKeys.COUNT_OF,
      //     field,
      //     ctx
      //   );
      // }
      //
      // @ApiOperationFromModel(ModelConstr, "GET", "maxOf/:field")
      // @ApiOperation({
      //   summary: `Find maximum value of a field in ${modelClazzName}.`,
      // })
      // @ApiParam({ name: "field", description: "The field to find max of" })
      // @ApiOkResponse({
      //   description: `Maximum value of the field in ${modelClazzName}.`,
      // })
      // async maxOf(@Param("field") field: string) {
      //   const { ctx } = (
      //     await this.logCtx([], PreparedStatementKeys.MAX_OF, true)
      //   ).for(this.maxOf);
      //   return this.persistence(ctx).statement(
      //     PreparedStatementKeys.MAX_OF,
      //     field,
      //     ctx
      //   );
      // }
      //
      // @ApiOperationFromModel(ModelConstr, "GET", "minOf/:field")
      // @ApiOperation({
      //   summary: `Find minimum value of a field in ${modelClazzName}.`,
      // })
      // @ApiParam({ name: "field", description: "The field to find min of" })
      // @ApiOkResponse({
      //   description: `Minimum value of the field in ${modelClazzName}.`,
      // })
      // async minOf(@Param("field") field: string) {
      //   const { ctx } = (
      //     await this.logCtx([], PreparedStatementKeys.MIN_OF, true)
      //   ).for(this.minOf);
      //   return this.persistence(ctx).statement(
      //     PreparedStatementKeys.MIN_OF,
      //     field,
      //     ctx
      //   );
      // }
      //
      // @ApiOperationFromModel(ModelConstr, "GET", "avgOf/:field")
      // @ApiOperation({
      //   summary: `Calculate average of a field in ${modelClazzName}.`,
      // })
      // @ApiParam({
      //   name: "field",
      //   description: "The field to calculate average of",
      // })
      // @ApiOkResponse({
      //   description: `Average value of the field in ${modelClazzName}.`,
      //   type: Number,
      // })
      // async avgOf(@Param("field") field: string) {
      //   const { ctx } = (
      //     await this.logCtx([], PreparedStatementKeys.AVG_OF, true)
      //   ).for(this.avgOf);
      //   return this.persistence(ctx).statement(
      //     PreparedStatementKeys.AVG_OF,
      //     field,
      //     ctx
      //   );
      // }
      //
      // @ApiOperationFromModel(ModelConstr, "GET", "sumOf/:field")
      // @ApiOperation({
      //   summary: `Calculate sum of a field in ${modelClazzName}.`,
      // })
      // @ApiParam({ name: "field", description: "The field to calculate sum of" })
      // @ApiOkResponse({
      //   description: `Sum of the field in ${modelClazzName}.`,
      //   type: Number,
      // })
      // async sumOf(@Param("field") field: string) {
      //   const { ctx } = (
      //     await this.logCtx([], PreparedStatementKeys.SUM_OF, true)
      //   ).for(this.sumOf);
      //   return this.persistence(ctx).statement(
      //     PreparedStatementKeys.SUM_OF,
      //     field,
      //     ctx
      //   );
      // }
      //
      // @ApiOperationFromModel(ModelConstr, "GET", "distinctOf/:field")
      // @ApiOperation({
      //   summary: `Find distinct values of a field in ${modelClazzName}.`,
      // })
      // @ApiParam({
      //   name: "field",
      //   description: "The field to find distinct values of",
      // })
      // @ApiOkResponse({
      //   description: `Distinct values of the field in ${modelClazzName}.`,
      //   type: [String],
      // })
      // async distinctOf(@Param("field") field: string) {
      //   const { ctx } = (
      //     await this.logCtx([], PreparedStatementKeys.DISTINCT_OF, true)
      //   ).for(this.distinctOf);
      //   return this.persistence(ctx).statement(
      //     PreparedStatementKeys.DISTINCT_OF,
      //     field,
      //     ctx
      //   );
      // }
      //
      // @ApiOperationFromModel(ModelConstr, "GET", "groupOf/:field")
      // @ApiOperation({
      //   summary: `Group ${modelClazzName} records by a field.`,
      // })
      // @ApiParam({ name: "field", description: "The field to group by" })
      // @ApiOkResponse({
      //   description: `${modelClazzName} records grouped by the field.`,
      // })
      // async groupOf(@Param("field") field: string) {
      //   const { ctx } = (
      //     await this.logCtx([], PreparedStatementKeys.GROUP_OF, true)
      //   ).for(this.groupOf);
      //   return this.persistence(ctx).statement(
      //     PreparedStatementKeys.GROUP_OF,
      //     field,
      //     ctx
      //   );
      // }

      @ApiOperationFromModel(ModelConstr, "POST", "bulk")
      @ApiOperation({ summary: `Create a new ${modelClazzName}.` })
      @ApiBody({
        description: `Payload for ${modelClazzName}`,
        schema: {
          type: "array",
          items: {
            $ref: getSchemaPath(ModelConstr),
            // $ref: getSchemaPath(DtoFor(OperationKeys.CREATE, ModelConstr)),
          },
        },
      })
      @ApiCreatedResponse({
        description: `${modelClazzName} created successfully.`,
        schema: {
          type: "array",
          items: {
            $ref: getSchemaPath(ModelConstr),
          },
        },
      })
      @ApiBadRequestResponse({ description: "Payload validation failed." })
      @ApiUnprocessableEntityResponse({
        description: "Repository rejected the provided payload.",
      })
      async createAll(
        @DecafBody() data: T[],
        @Response({ passthrough: true }) resp: any
      ): Promise<Model[]> {
        const { ctx, log } = (
          await this.logCtx([], BulkCrudOperationKeys.CREATE_ALL, true)
        ).for(this.createAll);
        log.verbose(`creating new ${modelClazzName}`);
        let created: T[];
        try {
          created = await this.persistence(ctx).createAll(
            data.map((d) => new ModelConstr(d)),
            ctx
          );
        } catch (e: unknown) {
          log.error(`Failed to create new ${modelClazzName}`, e as Error);
          throw e;
        }
        log.info(
          `created new ${modelClazzName} with id ${(created as any)[this.pk]}`
        );

        ctx.toResponse(resp);
        return created;
      }

      @ApiOperationFromModel(ModelConstr, "POST")
      @ApiOperation({ summary: `Create a new ${modelClazzName}.` })
      @ApiBody({
        description: `Payload for ${modelClazzName}`,
        type: DtoFor(OperationKeys.CREATE, ModelConstr),
      })
      @ApiCreatedResponse({
        description: `${modelClazzName} created successfully.`,
        schema: {
          $ref: getSchemaPath(ModelConstr),
        },
      })
      @ApiBadRequestResponse({ description: "Payload validation failed." })
      @ApiUnprocessableEntityResponse({
        description: "Repository rejected the provided payload.",
      })
      async create(
        @DecafBody() data: T,
        @Response({ passthrough: true }) resp: any
      ): Promise<Model<any>> {
        const { ctx, log } = (
          await this.logCtx([], OperationKeys.CREATE, true)
        ).for(this.create);
        log.verbose(`creating new ${modelClazzName}`);
        let created: Model;
        try {
          const persistence = this.persistence(ctx);
          created = await persistence.create(data, ctx);
        } catch (e: unknown) {
          log.error(`Failed to create new ${modelClazzName}`, e as Error);
          throw e;
        }
        log.info(
          `created new ${modelClazzName} with id ${(created as any)[this.pk]}`
        );
        ctx.toResponse(resp);
        return created;
      }

      @ApiOperationFromModel(ModelConstr, "GET", "bulk")
      @ApiOperation({ summary: `Retrieve a ${modelClazzName} record by id.` })
      @ApiQuery({ name: "ids", required: true, type: "array" })
      @ApiOkResponse({
        description: `${modelClazzName} retrieved successfully.`,
        schema: {
          type: "array",
          items: {
            $ref: getSchemaPath(ModelConstr),
          },
        },
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      async readAll(@Query("ids") ids: string[]) {
        const { ctx, log } = (
          await this.logCtx([], BulkCrudOperationKeys.READ_ALL, true)
        ).for(this.readAll);
        let read: Model[];
        try {
          log.debug(`reading ${ids.length} ${modelClazzName}: ${ids}`);
          const persistence = this.persistence(ctx);
          read = await persistence.readAll(ids as any, ctx);
        } catch (e: unknown) {
          log.error(`Failed to ${modelClazzName} with id ${ids}`, e as Error);
          throw e;
        }

        log.info(`read ${read.length} ${modelClazzName}`);
        return read;
      }

      @ApiOperationFromModel(ModelConstr, "GET", path)
      @ApiParamsFromModel(apiProperties)
      @ApiOperation({ summary: `Retrieve a ${modelClazzName} record by id.` })
      @ApiOkResponse({
        description: `${modelClazzName} retrieved successfully.`,
        schema: {
          $ref: getSchemaPath(ModelConstr),
        },
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      async read(@DecafParams(apiProperties) routeParams: DecafParamProps) {
        const { ctx, log } = (
          await this.logCtx([], OperationKeys.READ, true)
        ).for(this.read);
        const id = getPK(...routeParams.valuesInOrder);
        if (typeof id === "undefined")
          throw new ValidationError(`No ${this.pk} provided`);

        let read: Model;
        try {
          log.debug(`reading ${modelClazzName} with ${this.pk} ${id}`);
          const persistence = this.persistence(ctx);
          read = await persistence.read(id, ctx);
        } catch (e: unknown) {
          log.error(
            `Failed to read ${modelClazzName} with id ${id}`,
            e as Error
          );
          throw e;
        }

        log.info(`read ${modelClazzName} with id ${(read as any)[this.pk]}`);
        return read;
      }

      @ApiOperationFromModel(ModelConstr, "PUT", `bulk`)
      @ApiParamsFromModel(apiProperties)
      @ApiOperation({
        summary: `Replace an existing ${modelClazzName} record with a new payload.`,
      })
      @ApiBody({
        description: `Payload for replace a existing record of ${modelClazzName}`,
        schema: {
          type: "array",
          $ref: getSchemaPath(DtoFor(OperationKeys.UPDATE, ModelConstr)),
        },
      })
      @ApiOkResponse({
        description: `${modelClazzName} updated successfully.`,
        schema: {
          type: "array",
          items: {
            $ref: getSchemaPath(ModelConstr),
          },
        },
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      @ApiBadRequestResponse({ description: "Payload validation failed." })
      async updateAll(
        @DecafBody() body: T[],
        @Response({ passthrough: true }) resp: any
      ) {
        const { ctx, log } = (
          await this.logCtx([], BulkCrudOperationKeys.UPDATE_ALL, true)
        ).for(this.updateAll);

        let updated: T[];
        try {
          log.info(`updating ${body.length} ${modelClazzName}`);
          updated = await this.persistence(ctx).updateAll(body, ctx);
        } catch (e: unknown) {
          log.error(e as Error);
          throw e;
        }
        ctx.toResponse(resp);
        return updated;
      }

      @ApiOperationFromModel(ModelConstr, "PUT", path)
      @ApiParamsFromModel(apiProperties)
      @ApiOperation({
        summary: `Replace an existing ${modelClazzName} record with a new payload.`,
      })
      @ApiBody({
        description: `Payload for replace a existing record of ${modelClazzName}`,
        type: DtoFor(OperationKeys.UPDATE, ModelConstr),
      })
      @ApiOkResponse({
        description: `${modelClazzName} updated successfully.`,
        schema: {
          $ref: getSchemaPath(ModelConstr),
        },
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      @ApiBadRequestResponse({ description: "Payload validation failed." })
      async update(
        @DecafParams(apiProperties) routeParams: DecafParamProps,
        @DecafBody() body: T,
        @Response({ passthrough: true }) resp: any
      ) {
        const { ctx, log } = (
          await this.logCtx([], OperationKeys.UPDATE, true)
        ).for(this.update);

        const id = getPK(...routeParams.valuesInOrder);
        if (typeof id === "undefined")
          throw new ValidationError(`No ${this.pk} provided`);

        let updated: T;
        try {
          log.info(`updating ${modelClazzName} with ${this.pk} ${id}`);
          const payload = JSON.parse(JSON.stringify(body));
          const persistence = this.persistence(ctx);
          updated = await persistence.update(
            new ModelConstr({
              ...payload,
              [this.pk]: id,
            }),
            ctx
          );
        } catch (e: unknown) {
          log.error(e as Error);
          throw e;
        }
        ctx.toResponse(resp);
        return updated;
      }

      @ApiOperationFromModel(ModelConstr, "DELETE", "bulk")
      @ApiParamsFromModel(apiProperties)
      @ApiOperation({ summary: `Retrieve a ${modelClazzName} record by id.` })
      @ApiQuery({ name: "ids", required: true, type: "array" })
      @ApiOkResponse({
        description: `${modelClazzName} deleted successfully.`,
        schema: {
          type: "array",
          items: {
            $ref: getSchemaPath(ModelConstr),
          },
        },
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      async deleteAll(
        @Query("ids") ids: string[],
        @Response({ passthrough: true }) resp: any
      ) {
        const { ctx, log } = (
          await this.logCtx([], BulkCrudOperationKeys.DELETE_ALL, true)
        ).for(this.deleteAll);
        let read: Model[];
        try {
          log.debug(`deleting ${ids.length} ${modelClazzName}: ${ids}`);
          read = await this.persistence(ctx).deleteAll(ids, ctx);
        } catch (e: unknown) {
          log.error(
            `Failed to delete ${modelClazzName} with id ${ids}`,
            e as Error
          );
          throw e;
        }

        log.info(`deleted ${read.length} ${modelClazzName}`);
        ctx.toResponse(resp);
        return read;
      }

      @ApiOperationFromModel(ModelConstr, "DELETE", path)
      @ApiParamsFromModel(apiProperties)
      @ApiOperation({ summary: `Delete a ${modelClazzName} record by id.` })
      @ApiOkResponse({
        description: `${modelClazzName} deleted successfully.`,
        schema: {
          $ref: getSchemaPath(ModelConstr),
        },
      })
      @ApiNotFoundResponse({
        description: `No ${modelClazzName} record matches the provided identifier.`,
      })
      async delete(
        @DecafParams(apiProperties) routeParams: DecafParamProps,
        @Response({ passthrough: true }) resp: any
      ) {
        const { ctx, log } = (
          await this.logCtx([], OperationKeys.DELETE, true)
        ).for(this.delete);

        const id = getPK(...routeParams.valuesInOrder);
        if (typeof id === "undefined")
          throw new ValidationError(`No ${this.pk} provided`);

        let del: Model;
        try {
          log.debug(
            `deleting ${modelClazzName} with ${this.pk as string} ${id}`
          );
          del = await this.persistence(ctx).delete(id, ctx);
        } catch (e: unknown) {
          log.error(
            `Failed to delete ${modelClazzName} with id ${id}`,
            e as Error
          );
          throw e;
        }
        log.info(`deleted ${modelClazzName} with id ${id}`);
        ctx.toResponse(resp);
        return del;
      }
    }

    return DynamicModelController as any;
  }

  static getRouteParametersFromModel<T extends Model<any>>(
    ModelClazz: ModelConstructor<T>
  ): DecafModelRoute {
    const pk = Model.pk(ModelClazz) as keyof Model<any>;
    const composed = Metadata.get(
      ModelClazz,
      Metadata.key(DBKeys.COMPOSED, pk)
    );
    const composedKeys = composed?.args ?? [];

    // remove duplicates while preserving order
    const uniqueKeys =
      Array.isArray(composedKeys) && composedKeys.length > 0
        ? Array.from(new Set([...composedKeys]))
        : Array.from(new Set([pk]));

    const description = Metadata.description(ModelClazz);
    const path = `:${uniqueKeys.join("/:")}`;
    const apiProperties: DecafApiProperty[] = uniqueKeys.map((key) => {
      return {
        name: key,
        description: Metadata.description(ModelClazz, key),
        required: true,
        type: String,
      };
    });

    return {
      path,
      description,
      apiProperties,
      getPK: (...params: Array<string | number>) =>
        composed?.separator ? params.join(composed.separator) : params.join(""),
    };
  }
}