ForestAdmin/forest-express-sequelize

View on GitHub
src/services/query-options.js

Summary

Maintainability
A
45 mins
Test Coverage
A
99%
import { logger, Schemas } from 'forest-express';
import _ from 'lodash';
import { isMSSQL } from '../utils/database';
import Operators from '../utils/operators';
import QueryUtils from '../utils/query';
import SequelizeCompatibility from '../utils/sequelize-compatibility';
import { ErrorHTTP422 } from './errors';
import FiltersParser from './filters-parser';
import PrimaryKeysManager from './primary-keys-manager';
import QueryBuilder from './query-builder';
import SearchBuilder from './search-builder';

/**
 * Sequelize query options generator which is configured using forest admin concepts (filters,
 * search, segments, ...).
 * Those can be used for update, findAll, destroy, ...
 */
class QueryOptions {
  /**
   * Query options which can be used with sequelize.
   * i.e: Books.findAll(queryOptions.sequelizeOptions);
   */
  get sequelizeOptions() {
    const options = {};
    if (this._sequelizeWhere) options.where = this._sequelizeWhere;
    if (this._sequelizeInclude) options.include = this._sequelizeInclude;
    if (this._sequelizeOrder.length) options.order = this._sequelizeOrder;
    if (this._offset !== undefined && this._limit !== undefined) {
      options.offset = this._offset;
      options.limit = this._limit;
    }

    if (this._requestedFields.size && !this._hasRequestedSmartFields) {
      // Restricting loaded fields on the root model is opt-in with sequelize to avoid
      // side-effects as this was not supported historically and it would probably break
      // smart fields.
      // @see https://github.com/ForestAdmin/forest-express-sequelize/blob/7d7ad0/src/services/resources-getter.js#L142

      const simpleSchemaFields = this._schema.fields
        .filter((field) => !field.reference)
        .map((field) => field.field);
      options.attributes = [...this._requestedFields]
        .filter((field) => simpleSchemaFields.includes(field));
      options.attributes.push(...this._schema.primaryKeys);
    }

    return SequelizeCompatibility.postProcess(this._model, options);
  }

  /**
   * Used to support segments defined as a sequelize scope.
   * This feature is _not_ in the documentation, but support should be kept.
   */
  get sequelizeScopes() {
    return this._scopes;
  }

  /** Compute sequelize query `.where` property */
  get _sequelizeWhere() {
    const operators = Operators.getInstance({ Sequelize: this._Sequelize });

    switch (this._where.length) {
      case 0:
        return null;
      case 1:
        return this._where[0];
      default:
        return QueryUtils.mergeWhere(operators, ...this._where);
    }
  }

  /** Compute sequelize query `.include` property */
  get _sequelizeInclude() {
    const fields = [...this._requestedFields, ...this._requestedRelations, ...this._neededFields];
    const include = [
      ...new QueryBuilder().getIncludes(this._model, fields),
      ...this._customerIncludes,
    ];

    return include.length ? include : null;
  }

  /** Compute sequelize query `.order` property */
  get _sequelizeOrder() {
    if (isMSSQL(this._model.sequelize)) {
      // Work around sequelize bug: https://github.com/sequelize/sequelize/issues/11258
      const primaryKeys = Object.keys(this._model.primaryKeys);
      return this._order.filter((order) => !primaryKeys.includes(order[0]));
    }

    return this._order;
  }

  get _hasRequestedSmartFields() {
    return this._schema.fields
      .some((field) => field.isVirtual && this._requestedFields.has(field.field));
  }

  /**
   * @param {sequelize.model} model Sequelize model that should be targeted
   * @param {boolean} options.includeRelations Include BelongsTo and HasOne relations by default
   * @param {boolean} options.tableAlias Alias that will be used for this table on the final query
   *  This should have been handled by sequelize but it was needed in order to use
   *  sequelize.fn('lower', sequelize.col('<alias>.col')) in the search-builder with
   *  has-many-getter.
   */
  constructor(model, options = {}) {
    this._Sequelize = model.sequelize.constructor;
    this._schema = Schemas.schemas[model.name];
    this._model = model.unscoped();
    this._options = options;

    // Used to compute relations that will go in the final 'include'
    this._requestedFields = new Set();
    this._requestedRelations = new Set();
    this._neededFields = new Set();
    this._scopes = []; // @see sequelizeScopes getter

    // Other sequelize params
    this._where = [];
    this._order = [];
    this._offset = undefined;
    this._limit = undefined;
    this._customerIncludes = [];

    if (this._options.includeRelations) {
      _.values(this._model.associations)
        .filter((association) => ['HasOne', 'BelongsTo'].includes(association.associationType))
        .forEach((association) => this._requestedRelations.add(association.associationAccessor));
    }
  }

  /**
   * Add the required includes from a list of field names.
   * @param {string[]} fields Fields of HasOne and BelongTo relations are
   *  accepted (ie. 'book.name').
   * @param {string[]} fields the output of the extractRequestedFields() util function
   * @param {boolean} applyOnRootModel restrict fetched fields also on the root
   */
  async requireFields(fields) {
    if (fields) {
      fields.forEach((field) => this._requestedFields.add(field));
    }
  }

  /**
   * Filter resulting query set with packed primary ids.
   * This works both for normal collection, and those which use composite primary keys.
   * @param {string[]} recordIds Packed record ids
   */
  async filterByIds(recordIds) {
    this._where.push(new PrimaryKeysManager(this._model).getRecordsConditions(recordIds));
  }

  /**
   * Apply condition tree to those query options (scopes, user filters, charts, ...)
   * @param {*} filters standard forest filters
   * @param {string} timezone timezone of the user (required if filtering on dates)
   */
  async filterByConditionTree(filters, timezone) {
    if (!filters) return;

    const filterParser = new FiltersParser(this._schema, timezone, { Sequelize: this._Sequelize });
    const whereClause = await filterParser.perform(filters);
    this._where.push(whereClause);

    const associations = await filterParser.getAssociations(filters);
    associations.forEach((association) => this._neededFields.add(association));
  }

  /**
   * Retrict rows to those matching a search string
   * @param {string} search search string
   * @param {boolean} searchExtended if truthy, enable search in relations
   */
  async search(search, searchExtended) {
    if (!search) return [];

    const options = { Sequelize: this._Sequelize };
    const fieldNames = this._requestedFields.size ? [...this._requestedFields] : null;
    const helper = new SearchBuilder(this._model, options, { search, searchExtended }, fieldNames);

    const { conditions, include } = await helper.performWithSmartFields(this._options.tableAlias);
    if (conditions) {
      this._where.push(conditions);
    } else {
      this._where.push(this._Sequelize.literal('(0=1)'));
    }

    if (include) {
      if (Array.isArray(include)) {
        this._customerIncludes.push(...include);
      } else {
        this._customerIncludes.push(include);
      }
    }

    return helper.getFieldsSearched();
  }

  /**
   * Apply a forestadmin segment
   * @param {string} name name of the segment (from the querystring)
   * @param {string} segmentQuery SQL query of the segment (also from querystring)
   */
  async segment(name) {
    if (!name) return;

    const segment = this._schema.segments?.find((s) => s.name === name);

    // Segments can be provided as a sequelize scope (undocumented).
    if (segment?.scope) {
      this._scopes.push(segment.scope);
    }

    // ... or as a function which returns a sequelize where clause ...
    if (typeof segment?.where === 'function') {
      this._where.push(await segment.where());
    }
  }

  /**
   * Apply a segment query.
   * FIXME: Select SQL injection allows to fetch any information from database.
   * @param {string} query
   */
  async segmentQuery(query) {
    if (!query) return;

    const primaryKey = _.values(this._model.primaryKeys)[0].field;
    const queryToFilterRecords = query.trim();

    try {
      const options = { type: this._Sequelize.QueryTypes.SELECT };
      const records = await this._model.sequelize.query(queryToFilterRecords, options);
      const recordIds = records.map((result) => result[primaryKey] || result.id);

      this.filterByIds(recordIds);
    } catch (error) {
      const errorMessage = `Invalid SQL query for this Live Query segment:\n${error.message}`;
      logger.error(errorMessage);
      throw new ErrorHTTP422(errorMessage);
    }
  }

  /**
   * Apply sort instructions from a sort string in the form 'field', '-field' or 'field.subfield'.
   * Multiple sorts are not supported
   * @param {string} sortString a sort string
   */
  async sort(sortString) {
    if (!sortString) return;

    const [sortField, order] = sortString[0] === '-'
      ? [sortString.substring(1), 'DESC']
      : [sortString, 'ASC'];

    if (sortField.includes('.')) {
      // Sort on the belongsTo displayed field
      const [associationName, fieldName] = sortField.split('.');
      this._order.push([associationName, fieldName, order]);
      this._neededFields.add(sortField);
    } else {
      this._order.push([sortField, order]);
    }
  }

  /**
   * Apply pagination.
   * When called with invalid parameters the query will be paginated using default values.
   * @param {number|string} number page number (starting at one)
   * @param {number|string} size page size
   */
  async paginate(number, size) {
    const limit = Number.parseInt(size, 10);
    const offset = (Number.parseInt(number, 10) - 1) * limit;

    if (offset >= 0 && limit > 0) {
      this._offset = offset;
      this._limit = limit;
    } else {
      this._offset = 0;
      this._limit = 10;
    }
  }
}

module.exports = QueryOptions;