Source

adminjs-sequelizejs/src/resource.ts

/* eslint-disable no-param-reassign */
import { BaseResource, BaseRecord, BaseProperty, Filter, flat } from 'adminjs';
import { Op } from 'sequelize';

import { Model, ModelAttributeColumnOptions } from 'sequelize/types';
import Property from './property';
import convertFilter from './utils/convert-filter';
import createValidationError from './utils/create-validation-error';

const SEQUELIZE_VALIDATION_ERROR = 'SequelizeValidationError';
const SEQUELIZE_UNIQUE_ERROR = 'SequelizeUniqueConstraintError';

// this fixes problem with unbound this when you setup type of Mode as a member of another
// class: https://stackoverflow.com/questions/55166230/sequelize-typescript-typeof-model
type Constructor<T> = new (...args: any[]) => T;
export type ModelType<T extends Model<T>> = Constructor<T> & typeof Model;

type FindOptions = {
  limit?: number;
  offset?: number;
  sort?: {
    sortBy?: string;
    direction?: 'asc' | 'desc';
  };
}

class Resource extends BaseResource {
  private SequelizeModel: ModelType<any>

  static isAdapterFor(rawResource): boolean {
    return rawResource.sequelize && rawResource.sequelize.constructor.name === 'Sequelize';
  }

  constructor(SequelizeModel: typeof Model) {
    super(SequelizeModel);
    this.SequelizeModel = SequelizeModel as ModelType<any>;
  }

  rawAttributes(): Record<string, ModelAttributeColumnOptions> {
    // different sequelize versions stores attributes in different places
    // .rawAttributes => sequelize ^5.0.0
    // .attributes => sequelize ^4.0.0
    return ((this.SequelizeModel as any).attributes
      || (this.SequelizeModel as any).rawAttributes) as Record<string, ModelAttributeColumnOptions>;
  }

  databaseName(): string {
    return (this.SequelizeModel.sequelize as any).options.database
      || (this.SequelizeModel.sequelize as any).options.host
      || 'Sequelize';
  }

  databaseType(): string {
    return (this.SequelizeModel.sequelize as any).options.dialect || 'other';
  }

  name(): string {
    return this.SequelizeModel.tableName;
  }

  id(): string {
    return this.SequelizeModel.tableName;
  }

  properties(): Array<BaseProperty> {
    return Object.keys(this.rawAttributes()).map((key) => (
      new Property(this.rawAttributes()[key])
    ));
  }

  property(path: string): BaseProperty | null {
    const nested = path.split('.');

    // if property is an array return the array property
    if (nested.length > 1 && this.rawAttributes()[nested[0]]) {
      return new Property(this.rawAttributes()[nested[0]]);
    }

    if (!this.rawAttributes()[path]) {
      return null;
    }
    return new Property(this.rawAttributes()[path]);
  }

  async count(filter: Filter) {
    return this.SequelizeModel.count(({
      where: convertFilter(filter),
    }));
  }

  primaryKey(): string {
    return (this.SequelizeModel as any).primaryKeyField || this.SequelizeModel.primaryKeyAttribute;
  }

  async populate(baseRecords, property): Promise<Array<BaseRecord>> {
    const ids = baseRecords.map((baseRecord) => (
      baseRecord.param(property.name())
    ));
    const records = await this.SequelizeModel.findAll({
      where: { [this.primaryKey()]: ids },
    });
    const recordsHash = records.reduce((memo, record) => {
      memo[record[this.primaryKey()]] = record;
      return memo;
    }, {});
    baseRecords.forEach((baseRecord) => {
      const id = baseRecord.param(property.name());
      if (recordsHash[id]) {
        const referenceRecord = new BaseRecord(
          recordsHash[id].toJSON(), this as unknown as BaseResource,
        );
        baseRecord.populated[property.name()] = referenceRecord;
      }
    });
    return baseRecords;
  }

  async find(filter, { limit = 20, offset = 0, sort = {} }: FindOptions) {
    const { direction, sortBy } = sort;
    const sequelizeObjects = await this.SequelizeModel
      .findAll({
        where: convertFilter(filter),
        limit,
        offset,
        order: [[sortBy as string, (direction || 'asc').toUpperCase()]],
      });
    return sequelizeObjects.map(
      (sequelizeObject) => new BaseRecord(sequelizeObject.toJSON(), this),
    );
  }

  async findOne(id): Promise<BaseRecord | null> {
    const sequelizeObject = await this.findById(id);
    if (!sequelizeObject) {
      return null;
    }
    return new BaseRecord(sequelizeObject.toJSON(), this);
  }

  async findMany(ids) {
    const sequelizeObjects = await this.SequelizeModel.findAll({
      where: {
        [this.primaryKey()]: { [Op.in]: ids },
      },
    });
    return sequelizeObjects.map(
      (sequelizeObject) => new BaseRecord(sequelizeObject.toJSON(), this),
    );
  }

  async findById(id) {
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore versions of Sequelize before 5 had findById method - after that there was findByPk
    const method = this.SequelizeModel.findByPk ? 'findByPk' : 'findById';
    return this.SequelizeModel[method](id);
  }

  async create(params): Promise<Record<string, any>> {
    const parsedParams = this.parseParams(params);
    const unflattedParams = flat.unflatten<any, any>(parsedParams);
    try {
      const record = await this.SequelizeModel.create(unflattedParams);
      return record.toJSON();
    } catch (error) {
      if (error.name === SEQUELIZE_VALIDATION_ERROR) {
        throw createValidationError(error);
      }
      if (error.name === SEQUELIZE_UNIQUE_ERROR) {
        throw createValidationError(error);
      }
      throw error;
    }
  }

  async update(id, params) {
    const parsedParams = this.parseParams(params);
    const unflattedParams = flat.unflatten<any, any>(parsedParams);
    try {
      await this.SequelizeModel.update(unflattedParams, {
        where: {
          [this.primaryKey()]: id,
        },
        individualHooks: true,
        hooks: true,
      });
      const record = await this.findById(id);
      return record.toJSON();
    } catch (error) {
      if (error.name === SEQUELIZE_VALIDATION_ERROR) {
        throw createValidationError(error);
      }
      if (error.name === SEQUELIZE_UNIQUE_ERROR) {
        throw createValidationError(error);
      }
      throw error;
    }
  }

  async delete(id): Promise<void> {
    // we find first because we need to invoke destroy on model, so all hooks
    // instance hooks (not bulk) are called.
    // We cannot set {individualHooks: true, hooks: false} in this.SequelizeModel.destroy,
    // as it is in #update method because for some reason it wont delete the record
    const model = await this.SequelizeModel.findByPk(id);
    await model.destroy();
  }

  /**
   * Check all params against values they hold. In case of wrong value it corrects it.
   *
   * What it does exactly:
   * - removes keys with empty strings for the `number`, `float` and 'reference' properties.
   *
   * @param   {Object}  params  received from AdminJS form
   *
   * @return  {Object}          converted params
   */
  parseParams(params) {
    const parsedParams = { ...params };
    this.properties().forEach((property) => {
      const value = parsedParams[property.name()];
      if (value === '') {
        if (property.isArray() || property.type() !== 'string') {
          delete parsedParams[property.name()];
        }
      }
      if (!property.isEditable()) {
        delete parsedParams[property.name()];
      }
    });
    return parsedParams;
  }
}

export default Resource;