ForestAdmin/lumber

View on GitHub
services/analyzer/sequelize-tables-analyzer.js

Summary

Maintainability
D
2 days
Test Coverage
A
94%
const P = require('bluebird');
const _ = require('lodash');
const { plural, singular } = require('pluralize');
const ColumnTypeGetter = require('./sequelize-column-type-getter');
const DefaultValueExpression = require('./sequelize-default-value');
const TableConstraintsGetter = require('./sequelize-table-constraints-getter');
const EmptyDatabaseError = require('../../utils/errors/database/empty-database-error');
const { terminate } = require('../../utils/terminator');
const stringUtils = require('../../utils/strings');
const { isUnderscored } = require('../../utils/fields');

const ASSOCIATION_TYPE_BELONGS_TO = 'belongsTo';
const ASSOCIATION_TYPE_BELONGS_TO_MANY = 'belongsToMany';
const ASSOCIATION_TYPE_HAS_MANY = 'hasMany';
const ASSOCIATION_TYPE_HAS_ONE = 'hasOne';

const FOREIGN_KEY = 'FOREIGN KEY';

/** Queries database for default schema name */
async function getDefaultSchema(connection, userProvidedSchema) {
  if (userProvidedSchema) {
    return userProvidedSchema;
  }

  const dialect = connection.getDialect();
  const queries = {
    mssql: 'SELECT SCHEMA_NAME() AS default_schema',
    mysql: 'SELECT DATABASE() AS default_schema',
    mariadb: 'SELECT DATABASE() AS default_schema',
    postgres: 'SELECT CURRENT_SCHEMA() AS default_schema',
  };

  if (queries[dialect]) {
    const rows = await connection.query(
      queries[dialect],
      { type: connection.QueryTypes.SELECT },
    );

    return rows.length && rows[0].default_schema
      ? rows[0].default_schema : 'public';
  }

  return 'public';
}

/** Retrieve the description of the fields in a given table. */
async function analyzeFields(queryInterface, tableName, config) {
  const dialect = queryInterface.sequelize.getDialect();
  let columnsByName;

  // Workaround bug in sequelize/dialects/mysql/query-generator#describe
  // => Don't provide the schema when using mysql/mariadb
  if (['mysql', 'mariadb'].includes(dialect)) {
    columnsByName = await queryInterface.describeTable(tableName, {});
  } else {
    columnsByName = await queryInterface.describeTable(tableName, { schema: config.dbSchema });
  }

  // Workaround bug in sequelize/dialects/(postgres|mssql)/query.js#run()
  // => Fetch the unmodified default value from the information schema
  if (dialect === 'postgres' || dialect === 'mssql') {
    const getDefaultsQuery = `
      SELECT column_name as colname, column_default as coldefault
      FROM information_schema.columns
      WHERE table_schema = ? AND table_name = ?
    `;

    const rows = await queryInterface.sequelize.query(getDefaultsQuery, {
      type: queryInterface.sequelize.QueryTypes.SELECT,
      replacements: [config.dbSchema, tableName],
    });
    rows.forEach((row) => {
      columnsByName[row.colname].defaultValue = row.coldefault;
    });
  }

  Object.values(columnsByName).forEach((column) => {
    const defaultValue = new DefaultValueExpression(dialect, column.type, column.defaultValue);
    // eslint-disable-next-line no-param-reassign
    column.defaultValue = defaultValue.parse();
  });

  return columnsByName;
}

async function analyzePrimaryKeys(schema) {
  return Object.keys(schema).filter((column) => schema[column].primaryKey);
}

/** Retrieve table names from the provided schema. */
async function showAllTables(queryInterface, databaseConnection, schema) {
  const dbDialect = databaseConnection.getDialect();

  if (['mysql', 'mariadb'].includes(dbDialect)) {
    return queryInterface.showAllTables();
  }

  const tables = await queryInterface.sequelize.query(
    'SELECT table_name as table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = ? AND table_type LIKE \'%TABLE\' AND table_name != \'spatial_ref_sys\'',
    { type: queryInterface.sequelize.QueryTypes.SELECT, replacements: [schema] },
  );

  return tables.map((table) => table.table_name);
}

function hasTimestamps(fields) {
  let hasCreatedAt = false;
  let hasUpdatedAt = false;

  fields.forEach((field) => {
    if (field.name === 'createdAt') {
      hasCreatedAt = true;
    }

    if (field.name === 'updatedAt') {
      hasUpdatedAt = true;
    }
  });

  return hasCreatedAt && hasUpdatedAt;
}

function formatAliasName(columnName) {
  const alias = _.camelCase(columnName);
  if (alias.endsWith('Id') && alias.length > 2) {
    return alias.substring(0, alias.length - 2);
  }
  if (alias.endsWith('Uuid') && alias.length > 4) {
    return alias.substring(0, alias.length - 4);
  }
  return alias;
}

// NOTICE: Look for the id column in both fields and primary keys.
function hasIdColumn(fields, primaryKeys) {
  return fields.some((field) => field.name === 'id' || field.nameColumn === 'id')
    || _.includes(primaryKeys, 'id');
}

function isTechnicalTimestamp({ type, name }) {
  // NOTICE: Ignore technical timestamp fields.
  const FIELDS_TO_IGNORE = [
    'createdAt', 'updatedAt', 'deletedAt',
    'createDate', 'updateDate', 'deleteDate',
    'creationDate', 'deletionDate',
  ];

  return type === 'DATE' && FIELDS_TO_IGNORE.includes(name);
}

function isJunctionTable(fields, constraints) {
  for (let index = 0; index < fields.length; index += 1) {
    const field = fields[index];

    // NOTICE: The only fields accepted are primary keys, technical timestamps and foreignKeys
    if (!isTechnicalTimestamp(field) && !field.primaryKey) {
      return false;
    }
  }

  const foreignKeys = constraints.filter((constraint) => constraint.foreignTableName
    && constraint.columnName
    && constraint.columnType === FOREIGN_KEY);
  // NOTICE: To be a junction table it means you have 2 foreignKeys, no more no less
  return foreignKeys.length === 2;
}

// NOTICE: Check the foreign key's reference unicity
function checkUnicity(primaryKeys, uniqueIndexes, columnName) {
  const isUnique = uniqueIndexes !== null
    && uniqueIndexes.find((indexColumnName) =>
      indexColumnName.length === 1 && indexColumnName.includes(columnName));

  const isPrimary = _.isEqual([columnName], primaryKeys);

  return isPrimary || isUnique;
}

function associationNameAlreadyExists(existingReferences, newReference) {
  return existingReferences.some((reference) => reference && reference.as === newReference.as);
}

function referenceAlreadyExists(existingReferences, newReference) {
  return existingReferences.some((reference) => (
    reference
    && reference.ref === newReference.ref
    && reference.association === newReference.association
    && reference.foreignKey === newReference.foreignKey
  ));
}

// NOTICE: Format the references depending on the type of the association
function createReference(
  tableName,
  existingsReferences,
  association,
  foreignKey,
  manyToManyForeignKey,
) {
  const foreignKeyName = _.camelCase(foreignKey.columnName);
  const reference = {
    foreignKey: foreignKey.columnName,
    foreignKeyName: `${foreignKeyName}Key`,
    association,
  };

  if (association === ASSOCIATION_TYPE_BELONGS_TO) {
    reference.ref = foreignKey.foreignTableName;
    reference.as = formatAliasName(foreignKey.columnName);
    if (foreignKey.foreignColumnName !== 'id') {
      reference.targetKey = foreignKey.foreignColumnName;
    }
  } else if (association === ASSOCIATION_TYPE_BELONGS_TO_MANY) {
    reference.ref = manyToManyForeignKey.foreignTableName;
    reference.otherKey = manyToManyForeignKey.columnName;
    reference.through = stringUtils.camelCase(
      stringUtils.transformToSafeString(foreignKey.tableName),
    );
    reference.as = _.camelCase(plural(`${manyToManyForeignKey.foreignTableName}_through_${foreignKey.tableName}`));
  } else {
    reference.ref = foreignKey.tableName;

    const formater = association === ASSOCIATION_TYPE_HAS_MANY ? plural : singular;
    const prefix = (singular(tableName) === formatAliasName(foreignKeyName))
      ? ''
      : `${formatAliasName(foreignKeyName)}_`;

    if (foreignKey.foreignColumnName !== 'id') {
      reference.sourceKey = foreignKey.foreignColumnName;
    }
    reference.as = _.camelCase(formater(`${prefix}${foreignKey.tableName}`));
  }

  if (referenceAlreadyExists(existingsReferences, reference)) return null;

  if (associationNameAlreadyExists(existingsReferences, reference)) {
    reference.as = _.camelCase(`${reference.as} ${reference.foreignKey}`);
  }

  return reference;
}

async function analyzeTable(queryInterface, tableConstraintsGetter, table, config) {
  const schema = await analyzeFields(queryInterface, table, config);

  return {
    schema,
    constraints: await tableConstraintsGetter.perform(table),
    primaryKeys: await analyzePrimaryKeys(schema),
  };
}

function createBelongsToReference(referenceTable, tableReferences, constraint) {
  const referenceColumnName = constraint.foreignColumnName;
  const referencePrimaryKeys = referenceTable.primaryKeys;
  const referenceUniqueConstraint = referenceTable.constraints
    .find(({ columnType }) => ['UNIQUE', 'PRIMARY KEY'].includes(columnType));
  const referenceUniqueIndexes = referenceUniqueConstraint
    ? referenceUniqueConstraint.uniqueIndexes
    : null;
  const isReferencePrimaryOrUnique = checkUnicity(
    referencePrimaryKeys,
    referenceUniqueIndexes,
    referenceColumnName,
  );

  if (isReferencePrimaryOrUnique) {
    return createReference(
      null,
      tableReferences,
      ASSOCIATION_TYPE_BELONGS_TO,
      constraint,
    );
  }
  return null;
}

// NOTICE: Use the foreign key and reference properties to determine the associations
//         and push them as references of the table.
function createAllReferences(databaseSchema, schemaGenerated) {
  const references = {};
  Object.keys(databaseSchema).forEach((tableName) => { references[tableName] = []; });

  Object.keys(databaseSchema).forEach((tableName) => {
    const table = databaseSchema[tableName];
    const { constraints, primaryKeys } = table;
    const { isJunction } = schemaGenerated[tableName].options;

    const foreignKeysWithExistingTable = constraints
      .filter((constraint) => constraint.columnType === FOREIGN_KEY
        && databaseSchema[constraint.foreignTableName]);

    foreignKeysWithExistingTable.forEach((constraint) => {
      const { columnName } = constraint;
      const uniqueIndexes = constraint.uniqueIndexes || null;

      const isPrimaryOrUnique = checkUnicity(primaryKeys, uniqueIndexes, columnName);

      const referenceTableName = constraint.foreignTableName;

      if (isJunction) {
        const manyToManyKeys = foreignKeysWithExistingTable
          .filter((otherKey) => otherKey.columnName !== constraint.columnName);

        manyToManyKeys.forEach((manyToManyKey) => {
          references[referenceTableName].push(
            createReference(
              referenceTableName,
              references[referenceTableName],
              ASSOCIATION_TYPE_BELONGS_TO_MANY,
              constraint,
              manyToManyKey,
            ),
          );
        });
      } else {
        references[referenceTableName].push(
          createReference(
            referenceTableName,
            references[referenceTableName],
            isPrimaryOrUnique ? ASSOCIATION_TYPE_HAS_ONE : ASSOCIATION_TYPE_HAS_MANY,
            constraint,
          ),
        );
      }

      references[tableName].push(
        createBelongsToReference(
          databaseSchema[referenceTableName],
          references[tableName],
          constraint,
        ),
      );
    });
  });

  // remove null references
  return Object.entries(references)
    .reduce(
      (accumulator, [tableName, tableReferences]) => {
        accumulator[tableName] = tableReferences.filter(Boolean);
        return accumulator;
      },
      {},
    );
}

function isOnlyJoinTableWithId(schema, constraints) {
  const idColumn = Object.keys(schema).find((columnName) => columnName === 'id');

  if (!idColumn) return false;

  const possibleForeignColumnNames = Object.keys(schema)
    .filter((columnName) => !isTechnicalTimestamp(schema[columnName]) && columnName !== 'id');

  const columnWithoutForeignKey = possibleForeignColumnNames
    .find((columnName) => !_.find(constraints, { columnName, columnType: FOREIGN_KEY }));

  return !columnWithoutForeignKey;
}

async function createTableSchema(columnTypeGetter, {
  schema,
  constraints,
  primaryKeys,
}, tableName) {
  const fields = [];

  await P.each(Object.keys(schema), async (columnName) => {
    const columnInfo = schema[columnName];
    const type = await columnTypeGetter.perform(columnInfo, columnName, tableName);
    const foreignKey = _.find(constraints, { columnName, columnType: FOREIGN_KEY });
    const isValidField = type && (!foreignKey
      || !foreignKey.foreignTableName
      || !foreignKey.columnName || columnInfo.primaryKey);
    // NOTICE: If the column is of integer type, named "id" and primary, Sequelize will handle it
    //         automatically without necessary declaration.
    const isIdIntegerPrimaryColumn = columnName === 'id'
      && ['INTEGER', 'BIGINT'].includes(type)
      && columnInfo.primaryKey;
    // NOTICE: But in some cases we want to force the id to be still generated.
    //         For example, Sequelize will not use a default id field on a model
    //         that has only foreign keys, so if the id primary key is present, we need to force it.
    const forceIdColumn = isIdIntegerPrimaryColumn && isOnlyJoinTableWithId(schema, constraints);

    if (isValidField && (!isIdIntegerPrimaryColumn || forceIdColumn)) {
      // NOTICE: sequelize considers column name with parenthesis as raw Attributes
      // do not try to camelCase the name for avoiding sequelize issues
      const hasParenthesis = columnName.includes('(') || columnName.includes(')');
      const name = hasParenthesis ? columnName : _.camelCase(columnName);
      let isRequired = !columnInfo.allowNull;
      if (isTechnicalTimestamp({ name, type })) {
        isRequired = false;
      }

      const field = {
        name,
        nameColumn: columnName,
        type,
        primaryKey: columnInfo.primaryKey,
        defaultValue: columnInfo.defaultValue,
        isRequired,
      };

      fields.push(field);
    }
  });

  const options = {
    underscored: isUnderscored(fields),
    timestamps: hasTimestamps(fields),
    hasIdColumn: hasIdColumn(fields, primaryKeys),
    hasPrimaryKeys: !_.isEmpty(primaryKeys),
    isJunction: isJunctionTable(fields, constraints),
  };

  return {
    fields,
    primaryKeys,
    options,
  };
}

// NOTICE: This detects two generated fields (regular or reference's alias) with the same name
//         and rename reference's alias as `Linked${collectionReferenced}` to prevent Sequelize
//         from crashing at startup.
function fixAliasConflicts(wholeSchema) {
  const tablesName = Object.keys(wholeSchema);

  if (!tablesName.length) { return; }

  tablesName.forEach((tableName) => {
    const table = wholeSchema[tableName];

    if (table.references.length && table.fields.length) {
      const fieldNames = table.fields.map((field) => field.name);

      table.references.forEach((reference, index) => {
        if (fieldNames.includes(reference.as)) {
          table.references[index].as = `linked${_.upperFirst(reference.as)}`;
        }
      });
    }
  });
}

async function analyzeSequelizeTables(connection, config, allowWarning) {
  // User provided a schema, check if it exists
  if (config.dbSchema) {
    const schemas = await connection.query(
      'SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?;',
      { type: connection.QueryTypes.SELECT, replacements: [config.dbSchema] },
    );

    if (!schemas.length) {
      const message = 'This schema does not exists.';

      return terminate(1, {
        errorCode: 'database_authentication_error',
        errorMessage: message,
        logs: [message],
      });
    }
  }

  // If dbSchema was not provided by user, get default one.
  const configWithSchema = {
    ...config,
    dbSchema: await getDefaultSchema(connection, config.dbSchema),
  };

  // Build the db schema.
  const schemaAllTables = {};
  const queryInterface = connection.getQueryInterface();
  const databaseSchema = {};
  const tableNames = await showAllTables(queryInterface, connection, configWithSchema.dbSchema);
  const constraintsGetter = new TableConstraintsGetter(connection, configWithSchema.dbSchema);

  await P.each(tableNames, async (tableName) => {
    const {
      schema,
      constraints,
      primaryKeys,
    } = await analyzeTable(queryInterface, constraintsGetter, tableName, configWithSchema);
    databaseSchema[tableName] = {
      schema, constraints, primaryKeys, references: [],
    };
  });

  const columnTypeGetter = new ColumnTypeGetter(
    connection, configWithSchema.dbSchema, allowWarning,
  );

  await P.each(tableNames, async (tableName) => {
    schemaAllTables[tableName] = await createTableSchema(
      columnTypeGetter,
      databaseSchema[tableName],
      tableName,
    );
  });

  // NOTICE: Fill the references field for each table schema
  const referencesPerTable = createAllReferences(databaseSchema, schemaAllTables);
  Object.keys(referencesPerTable).forEach((tableName) => {
    schemaAllTables[tableName].references = _.sortBy(referencesPerTable[tableName], 'association');

    // NOTE: When a table contains no field, it will be considered camelCased
    //       by default, so we need to check its references to ensure whether
    //       it is camelCased or not.
    if (!schemaAllTables[tableName].fields.length) {
      schemaAllTables[tableName].options.underscored = isUnderscored(
        schemaAllTables[tableName].references.map(({ foreignKey }) => ({ nameColumn: foreignKey })),
      );
    }
  });

  if (_.isEmpty(schemaAllTables)) {
    throw new EmptyDatabaseError('no tables found', {
      orm: 'sequelize',
      dialect: connection.getDialect(),
    });
  }

  fixAliasConflicts(schemaAllTables);

  return schemaAllTables;
}

module.exports = analyzeSequelizeTables;