sequelize/sequelize-auto

View on GitHub
src/auto-builder.ts

Summary

Maintainability
D
2 days
Test Coverage
import _ from "lodash";
import { Dialect, QueryInterface, QueryTypes, Sequelize } from "sequelize";
import { AutoOptions } from ".";
import { ColumnElementType, ColumnPrecision, DialectOptions, FKRow, FKSpec, TriggerCount } from "./dialects/dialect-options";
import { dialects } from "./dialects/dialects";
import { Field, IndexSpec, Table, TableData } from "./types";

/** Queries the database and builds the tables, foreignKeys, indexes, and hasTriggerTables structures in TableData  */
export class AutoBuilder {
  sequelize: Sequelize;
  queryInterface: QueryInterface;
  dialect: DialectOptions;
  includeTables?: string[];
  skipTables?: string[];
  schema?: string;
  views: boolean;
  tableData: TableData;

  constructor(sequelize: Sequelize, options: AutoOptions) {
    this.sequelize = sequelize;
    this.queryInterface = this.sequelize.getQueryInterface();
    this.dialect = dialects[this.sequelize.getDialect() as Dialect];
    this.includeTables = options.tables;
    this.skipTables = options.skipTables;
    this.schema = options.schema;
    this.views = !!options.views;

    this.tableData = new TableData();
  }

  build(): Promise<TableData> {

    let prom: Promise<any[]>;
    if (this.dialect.showTablesQuery) {
      const showTablesSql = this.dialect.showTablesQuery(this.schema);
      prom = this.executeQuery<string>(showTablesSql);
    } else {
      prom = this.queryInterface.showAllTables();
    }

    if (this.views) {
      // Add views to the list of tables
      prom = prom.then(tr => {
        // in mysql, use database name instead of schema
        const vschema = this.dialect.name === 'mysql' ? this.sequelize.getDatabaseName() : this.schema;

        const showViewsSql = this.dialect.showViewsQuery(vschema);
        return this.executeQuery<string>(showViewsSql).then(tr2 => tr.concat(tr2));
      });
    }

    return prom.then(tr => this.processTables(tr))
      .catch(err => { console.error(err); return this.tableData; });
  }

  private processTables(tableResult: any[]) {
    // tables is an array of either three things:
    // * objects with two properties table_name and table_schema
    // * objects with two properties tableName and tableSchema
    // * objects with a single name property
    // The first happens for dialects which support schemas (e.g. mssql, postgres).
    // The second happens for dialects which do not support schemas (e.g. sqlite).

    let tables = _.map(tableResult, t => {
      return {
        table_name: t.table_name || t.tableName || t.name || String(t),
        table_schema: t.table_schema || t.tableSchema || t.schema || this.schema || null
      } as Table;
    });

    // include/exclude tables
    if (this.includeTables) {
      const optables = mapOptionTables(this.includeTables, this.schema);
      tables = _.intersectionWith(tables, optables, isTableEqual);
    } else if (this.skipTables) {
      const skipTables = mapOptionTables(this.skipTables, this.schema);
      tables = _.differenceWith(tables, skipTables, isTableEqual);
    }

    const promises = tables.map(t => {
      return this.mapForeignKeys(t).then(() => this.mapTable(t));
    });

    return Promise.all(promises).then(() => this.tableData);
  }

  private mapForeignKeys(table: Table) {

    const tableQname = makeTableQName(table);
    const sql = this.dialect.getForeignKeysQuery(table.table_name, table.table_schema || this.sequelize.getDatabaseName());
    const dialect = this.dialect;
    const foreignKeys = this.tableData.foreignKeys;

    return this.executeQuery<FKRow>(sql).then(res => {
      res.forEach(assignColumnDetails);
    }).catch(err => console.error(err));

    function assignColumnDetails(row: FKRow, ix: number, rows: FKRow[]) {
      let ref: FKSpec;
      if (dialect.remapForeignKeysRow) {
        ref = dialect.remapForeignKeysRow(table.table_name, row) as FKSpec;
      } else {
        ref = row as any as FKSpec;
      }

      if (!_.isEmpty(_.trim(ref.source_column)) && !_.isEmpty(_.trim(ref.target_column))) {
        ref.isForeignKey = true;
        ref.foreignSources = _.pick(ref, ['source_table', 'source_schema', 'target_schema', 'target_table', 'source_column', 'target_column']);
      }
      if (dialect.isUnique && dialect.isUnique(ref as any as FKRow, rows)) {
        ref.isUnique = ref.constraint_name || true;
      }
      if (_.isFunction(dialect.isPrimaryKey) && dialect.isPrimaryKey(ref)) {
        ref.isPrimaryKey = true;
      }
      if (dialect.isSerialKey && dialect.isSerialKey(ref)) {
        ref.isSerialKey = true;
      }
      foreignKeys[tableQname] = foreignKeys[tableQname] || {};
      foreignKeys[tableQname][ref.source_column] = _.assign({}, foreignKeys[tableQname][ref.source_column], ref);
    }
  }

  private async mapTable(table: Table) {
    try {
      const fields = await this.queryInterface.describeTable(table.table_name, table.table_schema);
      this.tableData.tables[makeTableQName(table)] = fields;

      // for postgres array or user-defined types, get element type
      if (this.dialect.showElementTypeQuery && (_.some(fields, { type: "ARRAY" }) || _.some(fields, { type: "USER-DEFINED" }))) {
        // get the subtype of the fields
        const stquery = this.dialect.showElementTypeQuery(table.table_name, table.table_schema);

        const elementTypes = await this.executeQuery<ColumnElementType>(stquery);
        // add element type to "elementType" property of field
        elementTypes.forEach(et => {
          const fld = fields[et.column_name] as Field;
          if (fld.type === "ARRAY") {
            fld.elementType = et.element_type;
            if (et.element_type === "USER-DEFINED" && et.enum_values && !fld.special.length) {
              fld.elementType = "ENUM";
              // fromArray is a method defined on Postgres QueryGenerator only
              fld.special = (this.queryInterface as any).queryGenerator.fromArray(et.enum_values);
            }
          } else if (fld.type === "USER-DEFINED") {
            fld.type = !fld.special.length ? et.udt_name : "ENUM";
          }
        });

        // TODO - in postgres, query geography_columns and geometry_columns for detail type and srid
        if (elementTypes.some(et => et.udt_name === 'geography') && this.dialect.showGeographyTypeQuery) {
          const gquery = this.dialect.showGeographyTypeQuery(table.table_name, table.table_schema);
          const gtypes = await this.executeQuery<ColumnElementType>(gquery);
          gtypes.forEach(gt => {
            const fld = fields[gt.column_name] as Field;
            if (fld.type === 'geography') {
              fld.elementType = `'${gt.udt_name}', ${gt.data_type}`;
            }
          });
        }

        if (elementTypes.some(et => et.udt_name === 'geometry') && this.dialect.showGeometryTypeQuery) {
          const gquery = this.dialect.showGeometryTypeQuery(table.table_name, table.table_schema);
          const gtypes = await this.executeQuery<ColumnElementType>(gquery);
          gtypes.forEach(gt => {
            const fld = fields[gt.column_name] as Field;
            if (fld.type === 'geometry') {
              fld.elementType = `'${gt.udt_name}', ${gt.data_type}`;
            }
          });
        }

      }

      // for mssql numeric types, get the precision. QueryInterface.describeTable does not return it
      if (this.dialect.showPrecisionQuery && (_.some(fields, { type: "DECIMAL" }) || _.some(fields, { type: "NUMERIC" }))) {
        const prequery = this.dialect.showPrecisionQuery(table.table_name, table.table_schema);
        const columnPrec = await this.executeQuery<ColumnPrecision>(prequery);
        columnPrec.forEach(cp => {
          const fld = fields[cp.column_name] as Field;
          if (cp.numeric_precision && (fld.type === 'DECIMAL' || fld.type === 'NUMERIC')) {
            fld.type = `${fld.type}(${cp.numeric_precision},${cp.numeric_scale})`;
          }
        });
      }

      this.tableData.indexes[makeTableQName(table)] = await this.queryInterface.showIndex(
        { tableName: table.table_name, schema: table.table_schema }) as IndexSpec[];

      // if there is no primaryKey, and `id` field exists, then make id the primaryKey (#480)
      if (!_.some(fields, { primaryKey: true })) {
        const idname = _.keys(fields).find(f => f.toLowerCase() === 'id');
        const idfield = idname && fields[idname];
        if (idfield) {
          idfield.primaryKey = true;
        }
      }

      const countTriggerSql = this.dialect.countTriggerQuery(table.table_name, table.table_schema || "");
      const triggerResult = await this.executeQuery<TriggerCount>(countTriggerSql);
      const triggerCount = triggerResult && triggerResult[0] && triggerResult[0].trigger_count;
      if (triggerCount > 0) {
        this.tableData.hasTriggerTables[makeTableQName(table)] = true;
      }
    } catch (err) {
      console.error(err);
    }
  }

  private executeQuery<T>(query: string): Promise<T[]> {
    return this.sequelize.query(query, {
      type: QueryTypes.SELECT,
      raw: true
    }) as any as Promise<T[]>;
  }

}

// option tables are a list of strings; each string is either
// table name (e.g. "Customer") or schema dot table name (e.g. "dbo.Customer")
function mapOptionTables(arr: string[], defaultSchema: string | undefined): Table[] {
  return _.map(arr, (t: string) => {
    const sp = t.split('.');
    return {
      table_name: sp[sp.length - 1],
      table_schema: sp.length > 1 ? sp[sp.length - 2] : defaultSchema
    };
  });
}

function isTableEqual(a: Table, b: Table) {
  return a.table_name === b.table_name && (!b.table_schema || a.table_schema === b.table_schema);
}

function makeTableQName(table: Table) {
  return [table.table_schema, table.table_name].filter(Boolean).join(".");
}