Skip to content

Commit

Permalink
feat: support cyclic foreign keys (sequelize#14572)
Browse files Browse the repository at this point in the history
  • Loading branch information
ephys authored Jun 15, 2022
1 parent ecb08b7 commit 1e110c5
Show file tree
Hide file tree
Showing 25 changed files with 560 additions and 190 deletions.
6 changes: 5 additions & 1 deletion src/dialects/abstract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,14 @@ export type DialectSupports = {
IREGEXP: boolean,
HSTORE: boolean,
TSVECTOR: boolean,
deferrableConstraints: boolean,
tmpTableTrigger: boolean,
indexHints: boolean,
searchPath: boolean,
/**
* This dialect supports marking a column's constraints as deferrable.
* e.g. 'DEFERRABLE' and 'INITIALLY DEFERRED'
*/
deferrableConstraints: false,
};

export abstract class AbstractDialect {
Expand Down
8 changes: 8 additions & 0 deletions src/dialects/abstract/query-interface.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,14 @@ export class QueryInterface {
*/
public showAllTables(options?: QueryRawOptions): Promise<string[]>;

/**
* Returns a promise that resolves to true if the table exists in the database, false otherwise.
*
* @param tableName The name of the table
* @param options Options passed to {@link Sequelize#query}
*/
public tableExists(tableName: TableName, options?: QueryRawOptions): Promise<boolean>;

/**
* Describe a table
*/
Expand Down
24 changes: 23 additions & 1 deletion src/dialects/abstract/query-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,34 @@ export class QueryInterface {
});
}

attributes = this.queryGenerator.attributesToSQL(attributes, { table: tableName, context: 'createTable' });
attributes = this.queryGenerator.attributesToSQL(attributes, {
table: tableName,
context: 'createTable',
withoutForeignKeyConstraints: options.withoutForeignKeyConstraints,
});
sql = this.queryGenerator.createTableQuery(tableName, attributes, options);

return await this.sequelize.queryRaw(sql, options);
}

/**
* Returns a promise that will resolve to true if the table exists in the database, false otherwise.
*
* @param {TableName} tableName - The name of the table
* @param {QueryOptions} options - Query options
* @returns {Promise<boolean>}
*/
async tableExists(tableName, options) {
const sql = this.queryGenerator.tableExistsQuery(tableName);

const out = await this.sequelize.query(sql, {
...options,
type: QueryTypes.SHOWTABLES,
});

return out.length === 1;
}

/**
* Drop a table from database
*
Expand Down
6 changes: 6 additions & 0 deletions src/dialects/abstract/query.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ type replacementFuncType = ((match: string, key: string, values: unknown[], time
* This interface is only exposed when running before/afterQuery lifecycle events.
*/
export class AbstractQuery {
/**
* The SQL being executed by this Query.
* @type {string}
*/
sql: string;

/**
* Returns a unique identifier assigned to a query internally by Sequelize.
*/
Expand Down
12 changes: 11 additions & 1 deletion src/dialects/db2/query-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ export class Db2QueryGenerator extends AbstractQueryGenerator {
return 'SELECT TABNAME AS "tableName", TRIM(TABSCHEMA) AS "tableSchema" FROM SYSCAT.TABLES WHERE TABSCHEMA = USER AND TYPE = \'T\' ORDER BY TABSCHEMA, TABNAME';
}

tableExistsQuery(table) {
const tableName = table.tableName || table;
// The default schema is the authorization ID of the owner of the plan or package.
// https://www.ibm.com/docs/en/db2-for-zos/12?topic=concepts-db2-schemas-schema-qualifiers
const schemaName = table.schema || this.sequelize.config.username.toUpperCase();

// https://www.ibm.com/docs/en/db2-for-zos/11?topic=tables-systables
return `SELECT name FROM sysibm.systables WHERE NAME = ${wrapSingleQuote(tableName)} AND CREATOR = ${wrapSingleQuote(schemaName)}`;
}

addColumnQuery(table, key, dataType) {
dataType.field = key;

Expand Down Expand Up @@ -641,7 +651,7 @@ export class Db2QueryGenerator extends AbstractQueryGenerator {
template += ' PRIMARY KEY';
}

if (attribute.references) {
if ((!options || !options.withoutForeignKeyConstraints) && attribute.references) {
if (options && options.context === 'addColumn' && options.foreignKey) {
const attrName = this.quoteIdentifier(options.foreignKey);
const fkName = `${options.tableName}_${attrName}_fidx`;
Expand Down
2 changes: 1 addition & 1 deletion src/dialects/db2/query-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export class Db2QueryInterface extends QueryInterface {
});
}

attributes = this.queryGenerator.attributesToSQL(attributes, { table: tableName, context: 'createTable' });
attributes = this.queryGenerator.attributesToSQL(attributes, { table: tableName, context: 'createTable', withoutForeignKeyConstraints: options.withoutForeignKeyConstraints });
sql = this.queryGenerator.createTableQuery(tableName, attributes, options);

return await this.sequelize.queryRaw(sql, options);
Expand Down
9 changes: 8 additions & 1 deletion src/dialects/mssql/query-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ export class MsSqlQueryGenerator extends AbstractQueryGenerator {
return 'SELECT TABLE_NAME, TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = \'BASE TABLE\';';
}

tableExistsQuery(table) {
const tableName = table.tableName || table;
const schemaName = table.schema || 'dbo';

return `SELECT TABLE_NAME, TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = ${this.escape(tableName)} AND TABLE_SCHEMA = ${this.escape(schemaName)}`;
}

dropTableQuery(tableName) {
const quoteTbl = this.quoteTable(tableName);

Expand Down Expand Up @@ -631,7 +638,7 @@ export class MsSqlQueryGenerator extends AbstractQueryGenerator {
template += ' PRIMARY KEY';
}

if (attribute.references) {
if ((!options || !options.withoutForeignKeyConstraints) && attribute.references) {
template += ` REFERENCES ${this.quoteTable(attribute.references.model)}`;

if (attribute.references.key) {
Expand Down
11 changes: 9 additions & 2 deletions src/dialects/mysql/query-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,19 @@ export class MySqlQueryGenerator extends AbstractQueryGenerator {
if (database) {
query += ` AND TABLE_SCHEMA = ${this.escape(database)}`;
} else {
query += ' AND TABLE_SCHEMA NOT IN (\'MYSQL\', \'INFORMATION_SCHEMA\', \'PERFORMANCE_SCHEMA\', \'SYS\')';
query += ' AND TABLE_SCHEMA NOT IN (\'MYSQL\', \'INFORMATION_SCHEMA\', \'PERFORMANCE_SCHEMA\', \'SYS\', \'mysql\', \'information_schema\', \'performance_schema\', \'sys\')';
}

return `${query};`;
}

tableExistsQuery(table) {
// remove first & last `, then escape as SQL string
const tableName = this.escape(this.quoteTable(table).slice(1, -1));

return `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = ${tableName} AND TABLE_SCHEMA = ${this.escape(this.sequelize.config.database)}`;
}

addColumnQuery(table, key, dataType) {
return Utils.joinSQLFragments([
'ALTER TABLE',
Expand Down Expand Up @@ -402,7 +409,7 @@ export class MySqlQueryGenerator extends AbstractQueryGenerator {
template += ` AFTER ${this.quoteIdentifier(attribute.after)}`;
}

if (attribute.references) {
if ((!options || !options.withoutForeignKeyConstraints) && attribute.references) {
if (options && options.context === 'addColumn' && options.foreignKey) {
const attrName = this.quoteIdentifier(options.foreignKey);
const fkName = this.quoteIdentifier(`${options.tableName}_${attrName}_foreign_idx`);
Expand Down
39 changes: 25 additions & 14 deletions src/dialects/postgres/query-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ export class PostgresQueryGenerator extends AbstractQueryGenerator {
return 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\' AND table_type LIKE \'%TABLE\' AND table_name != \'spatial_ref_sys\';';
}

tableExistsQuery(tableName) {
const table = tableName.tableName || tableName;
const schema = tableName.schema || 'public';

return `SELECT table_name FROM information_schema.tables WHERE table_schema = ${this.escape(schema)} AND table_name = ${this.escape(table)}`;
}

describeTableQuery(tableName, schema) {
if (!schema) {
schema = 'public';
Expand Down Expand Up @@ -537,24 +544,26 @@ export class PostgresQueryGenerator extends AbstractQueryGenerator {

let referencesKey;

if (attribute.references.key) {
referencesKey = this.quoteIdentifiers(attribute.references.key);
} else {
referencesKey = this.quoteIdentifier('id');
}
if (!options.withoutForeignKeyConstraints) {
if (attribute.references.key) {
referencesKey = this.quoteIdentifiers(attribute.references.key);
} else {
referencesKey = this.quoteIdentifier('id');
}

sql += ` REFERENCES ${referencesTable} (${referencesKey})`;
sql += ` REFERENCES ${referencesTable} (${referencesKey})`;

if (attribute.onDelete) {
sql += ` ON DELETE ${attribute.onDelete.toUpperCase()}`;
}
if (attribute.onDelete) {
sql += ` ON DELETE ${attribute.onDelete.toUpperCase()}`;
}

if (attribute.onUpdate) {
sql += ` ON UPDATE ${attribute.onUpdate.toUpperCase()}`;
}
if (attribute.onUpdate) {
sql += ` ON UPDATE ${attribute.onUpdate.toUpperCase()}`;
}

if (attribute.references.deferrable) {
sql += ` ${attribute.references.deferrable.toString(this)}`;
if (attribute.references.deferrable) {
sql += ` ${attribute.references.deferrable.toString(this)}`;
}
}
}

Expand Down Expand Up @@ -899,6 +908,8 @@ export class PostgresQueryGenerator extends AbstractQueryGenerator {
+ 'tc.table_name as table_name,'
+ 'tc.table_schema as table_schema,'
+ 'tc.table_catalog as table_catalog,'
+ 'tc.initially_deferred as initially_deferred,'
+ 'tc.is_deferrable as is_deferrable,'
+ 'kcu.column_name as column_name,'
+ 'ccu.table_schema AS referenced_table_schema,'
+ 'ccu.table_catalog AS referenced_table_catalog,'
Expand Down
13 changes: 12 additions & 1 deletion src/dialects/postgres/query-interface.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

import { Deferrable } from '../../deferrable';

const DataTypes = require('../../data-types');
const { QueryTypes } = require('../../query-types');
const { QueryInterface } = require('../abstract/query-interface');
Expand Down Expand Up @@ -160,7 +162,16 @@ export class PostgresQueryInterface extends QueryInterface {
const query = this.queryGenerator.getForeignKeyReferencesQuery(table.tableName || table, this.sequelize.config.database);
const result = await this.sequelize.queryRaw(query, queryOptions);

return result.map(Utils.camelizeObjectKeys);
return result.map(fkMeta => {
const { initiallyDeferred, isDeferrable, ...remaining } = Utils.camelizeObjectKeys(fkMeta);

return {
...remaining,
deferrable: isDeferrable === 'NO' ? Deferrable.NOT
: initiallyDeferred === 'NO' ? Deferrable.INITIALLY_IMMEDIATE
: Deferrable.INITIALLY_DEFERRED,
};
});
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/dialects/sqlite/query-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -493,14 +493,18 @@ export class SqliteQueryGenerator extends MySqlQueryGenerator {
/**
* Generates an SQL query that returns all foreign keys of a table.
*
* @param {string} tableName The name of the table.
* @param {TableName} tableName The name of the table.
* @returns {string} The generated sql query.
* @private
*/
getForeignKeysQuery(tableName) {
return `PRAGMA foreign_key_list(${this.quoteTable(this.addSchema(tableName))})`;
}

tableExistsQuery(tableName) {
return `SELECT name FROM sqlite_master WHERE type='table' AND name=${this.escape(this.addSchema(tableName))};`;
}

/**
* Quote identifier in sql clause
*
Expand Down
15 changes: 15 additions & 0 deletions src/dialects/sqlite/sqlite-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Sequelize, QueryRawOptions } from '../../sequelize.js';

export async function withSqliteForeignKeysOff<T>(
sequelize: Sequelize,
options: QueryRawOptions,
cb: () => Promise<T>,
): Promise<T> {
try {
await sequelize.queryRaw('PRAGMA foreign_keys = OFF', options);

return await cb();
} finally {
await sequelize.queryRaw('PRAGMA foreign_keys = ON', options);
}
}
9 changes: 9 additions & 0 deletions src/model-manager.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,13 @@ export class ModelManager {
public addModel<T extends ModelStatic>(model: T): T;
public removeModel(model: ModelStatic): void;
public getModel(against: unknown, options?: { attribute?: string }): typeof Model;

/**
* Returns an array that lists every model, sorted in order
* of foreign key references: The first model is a model that is depended upon,
* the last model is a model that is not depended upon.
*
* If there is a cyclic dependency, this returns null.
*/
public getModelsTopoSortedByForeignKey(): ModelStatic[] | null;
}
Loading

0 comments on commit 1e110c5

Please sign in to comment.