ForestAdmin/forest-express-mongoose

View on GitHub
src/services/flattener.js

Summary

Maintainability
C
1 day
Test Coverage
A
96%
import _ from 'lodash';
import Interface from 'forest-express';
import FieldIntrospector from '../utils/field-analyser';
import { getMongooseSchemaFromFieldPath } from '../utils/schema';

const FLATTEN_SEPARATOR = '@@@';

module.exports = class Flattener {
  constructor(schema, flatten, model, lianaOptions) {
    this.schema = schema;
    this.flatten = flatten;
    this.model = model;
    this.lianaOptions = lianaOptions;
  }

  static FLATTEN_SEPARATOR = FLATTEN_SEPARATOR;

  _removeWrongFlattenConfiguration(index) {
    this.flatten.splice(index, 1);
  }

  _doesFieldExist(fieldName, index) {
    const fieldToFlatten = this.schema.fields
      .find((field) => field.field === fieldName);

    if (!fieldToFlatten) {
      Interface.logger.warn(`Could not flatten field ${fieldName} because it does not exist`);
      this._removeWrongFlattenConfiguration(index);
      return false;
    }

    return true;
  }

  static _validateLevelProperty(flattenConfiguration) {
    if (flattenConfiguration.level !== undefined) {
      flattenConfiguration.level = parseInt(flattenConfiguration.level, 10);

      if (Number.isNaN(flattenConfiguration.level)) {
        Interface.logger.warn(`Could not parse flatten level for field ${flattenConfiguration.field}, defaulting to infinite`);
        delete flattenConfiguration.level;
      }
    }
  }

  _validateFlattenObjectConfiguration(flattenConfiguration, configurationIndex) {
    if (!flattenConfiguration.field) {
      Interface.logger.warn(`Could not flatten field with the following configuration ${JSON.stringify(flattenConfiguration)} because no field has been specified`);
      this._removeWrongFlattenConfiguration(configurationIndex);
    } else if (this._doesFieldExist(flattenConfiguration.field, configurationIndex)) {
      Flattener._validateLevelProperty(flattenConfiguration);
    }
  }

  static _isFieldFlattened(name) {
    return name?.includes(FLATTEN_SEPARATOR);
  }

  static _getParentFieldName(fieldName) {
    return fieldName?.split(FLATTEN_SEPARATOR)[0];
  }

  static _unflattenCollectionFields(requestedFields) {
    const fieldNames = new Set();
    requestedFields.split(',')
      .forEach((requestedField) => fieldNames.add(Flattener._getParentFieldName(requestedField)));
    return [...fieldNames].join(',');
  }

  static _unflattenFields(request) {
    Object.entries(request.query.fields).forEach(([collection, requestedFields]) => {
      if (Flattener._isFieldFlattened(requestedFields)) {
        request.query.fields[collection] = Flattener._unflattenCollectionFields(requestedFields);
      }
    });
  }

  static _unflattenAttribute(attributeName, value, attributes) {
    let accessPathArray = attributeName.split(FLATTEN_SEPARATOR);
    const parentObjectName = accessPathArray.shift();
    accessPathArray = accessPathArray.reverse();
    const parentObject = attributes[parentObjectName] || {};
    const unflattenedProperty = accessPathArray.reduce((a, prop) => ({ [prop]: a }), value);
    const unflattenedObject = _.merge(parentObject, unflattenedProperty);
    return { parentObjectName, unflattenedObject };
  }

  static _unwrapFlattenedReferences(request) {
    if (!request.body.data.attributes) request.body.data.attributes = {};

    const { attributes, relationships } = request.body.data;

    Object.entries(relationships)
      .filter(([attributeName]) => Flattener._isFieldFlattened(attributeName))
      .forEach(([attributeName, value]) => {
        const { parentObjectName, unflattenedObject } = Flattener._unflattenAttribute(
          attributeName,
          value.data?.id,
          attributes,
        );
        attributes[parentObjectName] = _.merge(attributes[parentObjectName], unflattenedObject);
        delete relationships[attributeName];
      });
  }

  static _unflattenAttributes(request) {
    Object.entries(request.body.data.attributes).forEach(([attributeName, value]) => {
      if (Flattener._isFieldFlattened(attributeName)) {
        const {
          parentObjectName,
          unflattenedObject,
        } = Flattener._unflattenAttribute(attributeName, value, request.body.data.attributes);
        delete request.body.data.attributes[attributeName];
        request.body.data.attributes[parentObjectName] = unflattenedObject;
      }
    });
  }

  static _unflattenSubsetQuery(request) {
    Object.entries(request.body.data.attributes.all_records_subset_query)
      .forEach(([key, value]) => {
        if (key.includes('fields') && Flattener._isFieldFlattened(value)) {
          request.body.data.attributes.all_records_subset_query[key] = Flattener
            ._unflattenCollectionFields(value);
        }
      });
  }

  static requestUnflattener(request, response, next) {
    if (request.originalUrl.includes('.csv?')) {
      return next();
    }

    try {
      if (!_.isEmpty(request.body?.data?.attributes)) {
        Flattener._unflattenAttributes(request);

        if (!_.isEmpty(request.body.data.attributes.all_records_subset_query)) {
          Flattener._unflattenSubsetQuery(request);
        }
      }
      if (!_.isEmpty(request.body?.data?.relationships)) {
        Flattener._unwrapFlattenedReferences(request);
      }
      if (!_.isEmpty(request.query?.fields)) {
        Flattener._unflattenFields(request);
      }
      if (!_.isEmpty(request.query?.context)) {
        request.query.context.field = Flattener.unflattenFieldName(request.query.context.field);
      }
      // Note: filter and sorts are not unflattened here because
      // they are checked against forest schema later on
      return next();
    } catch (error) { return next(error); }
  }

  static unflattenParams(params) {
    const unflattenedParams = JSON.parse(JSON.stringify(params));

    if (unflattenedParams.fields) {
      Object.entries(unflattenedParams.fields).forEach(([collection, requestedFields]) => {
        if (Flattener._isFieldFlattened(requestedFields)) {
          unflattenedParams
            .fields[collection] = Flattener._unflattenCollectionFields(requestedFields);
        }
      });
    }

    return unflattenedParams;
  }

  validateOptions() {
    if (!this.flatten) {
      this.flatten = [];
      return;
    }

    if (!Array.isArray(this.flatten)) {
      this.flatten = [];
      Interface.logger.error(`Could not flatten fields from collection ${this.schema.name}, flatten property should be an array.`);
      return;
    }

    this.flatten.forEach((flattenConfiguration, index) => {
      switch (typeof flattenConfiguration) {
        case 'string':
          this.flatten[index] = { field: flattenConfiguration };
          this._doesFieldExist(flattenConfiguration, index);
          break;
        case 'object':
          this._validateFlattenObjectConfiguration(flattenConfiguration, index);
          break;
        default: {
          Interface.logger.warn(`Could not identify the field to flatten with ${JSON.stringify(flattenConfiguration)}`);
        }
      }
    });
  }

  _flattenField(schema, parentFieldName, newFields = [], level = undefined) {
    if (schema.type?.fields && (level === undefined || level > -1)) {
      schema.type.fields.forEach((subField) => {
        const newFieldName = parentFieldName ? `${parentFieldName}${FLATTEN_SEPARATOR}${subField.field}` : schema.field;
        this._flattenField(
          subField,
          newFieldName,
          newFields,
          level === undefined ? level : level - 1,
        );
      });
    } else {
      schema.field = parentFieldName;

      const fieldInfo = getMongooseSchemaFromFieldPath(
        schema.field.split(FLATTEN_SEPARATOR).join('.'),
        this.model,
      );

      if (typeof schema.type === 'string' && fieldInfo) {
        const introspectedSchema = new FieldIntrospector(this.model, this.lianaOptions)
          .getFieldSchema(schema.field, fieldInfo);

        newFields.push(introspectedSchema);
      } else {
        newFields.push(schema);
      }
    }
  }

  flattenFields() {
    this.validateOptions();

    const newFields = [];

    this.flatten.forEach((flattenConfiguration) => {
      const fieldSchemaIndex = this.schema.fields
        .findIndex((field) => field.field === flattenConfiguration.field);
      const fieldSchema = this.schema.fields[fieldSchemaIndex];

      this._flattenField(fieldSchema, fieldSchema.field, newFields, flattenConfiguration.level);

      this.schema.fields.splice(fieldSchemaIndex, 1);
    });

    this.schema.fields = [...this.schema.fields, ...newFields];
  }

  static unflattenFieldName(fieldName) {
    if (!fieldName) return null;

    return fieldName.replace(new RegExp(FLATTEN_SEPARATOR, 'g'), '.');
  }

  static splitOnSeparator(fieldName) {
    return fieldName.split(FLATTEN_SEPARATOR);
  }

  static unflattenFieldNamesInObject(object) {
    Object.keys(object).forEach((fieldName) => {
      if (Flattener._isFieldFlattened(fieldName)) {
        object[Flattener.unflattenFieldName(fieldName)] = object[fieldName];
        delete object[fieldName];
      }
    });
  }

  static flattenRecordDataForUpdates(record, flattenComposedKey, flattenedFields) {
    if (flattenedFields.length === 0) return record;

    const flattenedRecord = {};

    Object.keys(record).forEach((attribute) => {
      if (record[attribute] !== null && typeof record[attribute] === 'object') {
        const flattenedPath = (flattenComposedKey) ? `${flattenComposedKey}${FLATTEN_SEPARATOR}${attribute}` : attribute;

        if (flattenedFields.find((flattenedField) => flattenedField === flattenedPath)) {
          flattenedRecord[attribute] = record[attribute];
        } else {
          const flattenedNested = Flattener
            .flattenRecordDataForUpdates(record[attribute], flattenedPath, flattenedFields);
          Object.keys(flattenedNested).forEach((nestedAttribute) => {
            flattenedRecord[`${attribute}.${nestedAttribute}`] = flattenedNested[nestedAttribute];
          });
        }
      } else {
        flattenedRecord[attribute] = record[attribute];
      }
    });

    return flattenedRecord;
  }

  static getFlattenedFieldsName(fields) {
    return fields
      .filter((field) => Flattener._isFieldFlattened(field.field))
      .map((field) => field.field);
  }

  static getFlattenedReferenceFieldsFromParams(collectionName, fields) {
    if (!collectionName || !fields) {
      return [];
    }

    const flattenedReferences = Object.keys(fields)
      .filter((field) => Flattener._isFieldFlattened(field));

    const collectionReferenceFields = (Interface.Schemas.schemas[collectionName]?.fields || [])
      .filter(({ reference }) => reference);

    return flattenedReferences.filter((flattenedReference) =>
      collectionReferenceFields.some(({ field }) => field === flattenedReference));
  }

  static generateNestedPathsFromModelName(modelName) {
    if (!modelName) return [];

    const modelFields = (Interface.Schemas.schemas[modelName]?.fields || []);
    const flattenedFields = modelFields
      .filter((field) => this._isFieldFlattened(field.field));

    return flattenedFields.map((field) => this.splitOnSeparator(field.field));
  }

  static flattenRecordsForExport(modelName, records) {
    const nestedPaths = this.generateNestedPathsFromModelName(modelName);

    if (!nestedPaths || nestedPaths.length === 0) return records;

    records.forEach((record) => {
      const flattenedFields = new Set();

      nestedPaths.forEach((nestedPath) => {
        const flattenFieldName = nestedPath.join(FLATTEN_SEPARATOR);
        record[flattenFieldName] = nestedPath
          .reduce((embedded, attribute) => (embedded ? embedded[attribute] : null), record);
        flattenedFields.add(nestedPath[0]);
      });

      flattenedFields.forEach((flattenedField) => delete record[flattenedField]);
    });

    return records;
  }
};