Skip to content

Commit 2493eff

Browse files
authored
ts: support models with fk circular dependencies (#32)
* ts: support models with fk circular dependencies * chore: fix lint
1 parent 730de2a commit 2493eff

15 files changed

+319
-106
lines changed

ts/src/sequelize_schema.js

Lines changed: 87 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,51 @@ const modelSourceFromClasses = (models, sequelize) => {
9595
}
9696
return (0, exports.modelSource)(Array.from(paths), sequelize);
9797
};
98+
function modelToSQL(sequelize, model, dialect, withoutForeignKeyConstraints = false) {
99+
const queryGenerator = sequelize.getQueryInterface().queryGenerator;
100+
const def = sequelize.modelManager.getModel(model.name);
101+
if (!def) {
102+
return "";
103+
}
104+
const options = { ...def.options };
105+
const attributesToSQLOptions = { ...options, withoutForeignKeyConstraints };
106+
let sql = "";
107+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
108+
// @ts-ignore
109+
const attr = queryGenerator.attributesToSQL(
110+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
111+
// @ts-ignore
112+
def.getAttributes(), attributesToSQLOptions);
113+
// Remove from attr all the fields that have 'VIRTUAL' type
114+
// https://sequelize.org/docs/v6/core-concepts/getters-setters-virtuals/#virtual-fields
115+
for (const key in attr) {
116+
if (attr[key].startsWith("VIRTUAL")) {
117+
delete attr[key];
118+
}
119+
}
120+
// create enum types for postgres
121+
if (dialect === "postgres") {
122+
for (const key in attr) {
123+
if (!attr[key].startsWith("ENUM")) {
124+
continue;
125+
}
126+
const enumValues = attr[key].substring(attr[key].indexOf("("), attr[key].lastIndexOf(")") + 1);
127+
const enumName = `enum_${def.getTableName()}_${key}`;
128+
sql += `CREATE TYPE "${enumName}" AS ENUM${enumValues};\n`;
129+
}
130+
}
131+
sql +=
132+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
133+
// @ts-ignore
134+
queryGenerator.createTableQuery(def.getTableName(), attr, options) + "\n";
135+
for (const index of def.options?.indexes ?? []) {
136+
sql +=
137+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
138+
// @ts-ignore
139+
queryGenerator.addIndexQuery(def.getTableName(), index, options) + ";\n";
140+
}
141+
return sql;
142+
}
98143
const loadModels = (dialect, models) => {
99144
if (!validDialects.includes(dialect)) {
100145
throw new Error(`Invalid dialect ${dialect}`);
@@ -112,14 +157,16 @@ const loadSQL = (sequelize, dialect, srcMap) => {
112157
}
113158
const orderedModels = sequelize.modelManager
114159
.getModelsTopoSortedByForeignKey()
115-
?.reverse();
116-
if (!orderedModels) {
117-
throw new Error("no models found");
118-
}
160+
?.map((m) => sequelize.modelManager.getModel(m.name))
161+
.filter((m) => !!m)
162+
.reverse();
119163
let sql = "";
120164
if (srcMap && srcMap.size > 0) {
121-
for (const model of orderedModels) {
165+
const modelsForSrcMap = orderedModels ?? sequelize.modelManager.models;
166+
for (const model of modelsForSrcMap) {
122167
const def = sequelize.modelManager.getModel(model.name);
168+
if (!def)
169+
continue;
123170
const tableName = def.getTableName();
124171
const pos = srcMap.get(model.name);
125172
if (!pos)
@@ -129,42 +176,43 @@ const loadSQL = (sequelize, dialect, srcMap) => {
129176
// Add extra newline to separate comments from SQL definitions
130177
sql += "\n";
131178
}
132-
const queryGenerator = sequelize.getQueryInterface().queryGenerator;
133-
for (const model of orderedModels) {
134-
const def = sequelize.modelManager.getModel(model.name);
135-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
136-
// @ts-ignore
137-
const attr = queryGenerator.attributesToSQL(
138-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
139-
// @ts-ignore
140-
def.getAttributes(), Object.assign({}, def.options));
141-
// Remove from attr all the fields that have 'VIRTUAL' type
142-
// https://sequelize.org/docs/v6/core-concepts/getters-setters-virtuals/#virtual-fields
143-
for (const key in attr) {
144-
if (attr[key].startsWith("VIRTUAL")) {
145-
delete attr[key];
146-
}
179+
if (orderedModels) {
180+
for (const model of orderedModels) {
181+
sql += modelToSQL(sequelize, model, dialect);
147182
}
148-
// create enum types for postgres
149-
if (dialect === "postgres") {
150-
for (const key in attr) {
151-
if (!attr[key].startsWith("ENUM")) {
152-
continue;
153-
}
154-
const enumValues = attr[key].substring(attr[key].indexOf("("), attr[key].lastIndexOf(")") + 1);
155-
const enumName = `enum_${def.getTableName()}_${key}`;
156-
sql += `CREATE TYPE "${enumName}" AS ENUM${enumValues};\n`;
157-
}
183+
return sql;
184+
}
185+
// In SQLite, foreign key constraints are not enforced by default, so there's no need for special handling of circular dependencies.
186+
if (dialect === "sqlite") {
187+
for (const model of sequelize.modelManager.models) {
188+
sql += modelToSQL(sequelize, model, dialect);
158189
}
159-
sql +=
160-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
161-
// @ts-ignore
162-
queryGenerator.createTableQuery(def.getTableName(), attr, Object.assign({}, def.options)) + "\n";
163-
for (const index of def.options?.indexes ?? []) {
164-
sql +=
165-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
166-
// @ts-ignore
167-
queryGenerator.addIndexQuery(def.getTableName(), index, Object.assign({}, def.options)) + ";\n";
190+
return sql;
191+
}
192+
// If there are circular dependencies, first create tables without foreign keys, then add them.
193+
for (const model of sequelize.modelManager.models) {
194+
sql += modelToSQL(sequelize, model, dialect, true);
195+
}
196+
const queryInterface = sequelize.getQueryInterface();
197+
for (const model of sequelize.modelManager.models) {
198+
const attributes = model.getAttributes();
199+
for (const key of Object.keys(attributes)) {
200+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
201+
const attribute = attributes[key];
202+
if (!attribute.references) {
203+
continue;
204+
}
205+
// @ts-expect-error - queryGenerator is not in the type definition
206+
const query = queryInterface.queryGenerator.attributesToSQL({
207+
// @ts-expect-error - normalizeAttribute is not in the type definition
208+
[key]: queryInterface.normalizeAttribute(attribute),
209+
}, {
210+
context: "changeColumn",
211+
table: model.getTableName(),
212+
});
213+
// @ts-expect-error - queryGenerator is not in the type definition
214+
sql += queryInterface.queryGenerator.changeColumnQuery(model.getTableName(), query);
215+
sql += "\n";
168216
}
169217
}
170218
return sql;

ts/src/sequelize_schema.ts

Lines changed: 106 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,62 @@ const modelSourceFromClasses = (models: ModelCtor[], sequelize: Sequelize) => {
6868
return modelSource(Array.from(paths), sequelize);
6969
};
7070

71+
function modelToSQL(
72+
sequelize: Sequelize,
73+
model: ModelCtor,
74+
dialect: string,
75+
withoutForeignKeyConstraints = false,
76+
): string {
77+
const queryGenerator = sequelize.getQueryInterface().queryGenerator;
78+
const def = sequelize.modelManager.getModel(model.name);
79+
if (!def) {
80+
return "";
81+
}
82+
const options = { ...def.options };
83+
const attributesToSQLOptions = { ...options, withoutForeignKeyConstraints };
84+
let sql = "";
85+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
86+
// @ts-ignore
87+
const attr = queryGenerator.attributesToSQL(
88+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
89+
// @ts-ignore
90+
def.getAttributes(),
91+
attributesToSQLOptions,
92+
);
93+
// Remove from attr all the fields that have 'VIRTUAL' type
94+
// https://sequelize.org/docs/v6/core-concepts/getters-setters-virtuals/#virtual-fields
95+
for (const key in attr) {
96+
if (attr[key].startsWith("VIRTUAL")) {
97+
delete attr[key];
98+
}
99+
}
100+
// create enum types for postgres
101+
if (dialect === "postgres") {
102+
for (const key in attr) {
103+
if (!attr[key].startsWith("ENUM")) {
104+
continue;
105+
}
106+
const enumValues = attr[key].substring(
107+
attr[key].indexOf("("),
108+
attr[key].lastIndexOf(")") + 1,
109+
);
110+
const enumName = `enum_${def.getTableName()}_${key}`;
111+
sql += `CREATE TYPE "${enumName}" AS ENUM${enumValues};\n`;
112+
}
113+
}
114+
sql +=
115+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
116+
// @ts-ignore
117+
queryGenerator.createTableQuery(def.getTableName(), attr, options) + "\n";
118+
for (const index of def.options?.indexes ?? []) {
119+
sql +=
120+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
121+
// @ts-ignore
122+
queryGenerator.addIndexQuery(def.getTableName(), index, options) + ";\n";
123+
}
124+
return sql;
125+
}
126+
71127
export const loadModels = (dialect: string, models: ModelCtor[]) => {
72128
if (!validDialects.includes(dialect)) {
73129
throw new Error(`Invalid dialect ${dialect}`);
@@ -89,15 +145,17 @@ export const loadSQL = (
89145
}
90146
const orderedModels = sequelize.modelManager
91147
.getModelsTopoSortedByForeignKey()
92-
?.reverse();
93-
if (!orderedModels) {
94-
throw new Error("no models found");
95-
}
148+
?.map((m) => sequelize.modelManager.getModel(m.name))
149+
.filter((m): m is ModelCtor => !!m)
150+
.reverse();
151+
96152
let sql = "";
97153

98154
if (srcMap && srcMap.size > 0) {
99-
for (const model of orderedModels) {
155+
const modelsForSrcMap = orderedModels ?? sequelize.modelManager.models;
156+
for (const model of modelsForSrcMap) {
100157
const def = sequelize.modelManager.getModel(model.name);
158+
if (!def) continue;
101159
const tableName = def.getTableName();
102160
const pos = srcMap.get(model.name);
103161
if (!pos) continue;
@@ -107,55 +165,51 @@ export const loadSQL = (
107165
sql += "\n";
108166
}
109167

110-
const queryGenerator = sequelize.getQueryInterface().queryGenerator;
111-
for (const model of orderedModels) {
112-
const def = sequelize.modelManager.getModel(model.name);
113-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
114-
// @ts-ignore
115-
const attr = queryGenerator.attributesToSQL(
116-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
117-
// @ts-ignore
118-
def.getAttributes(),
119-
Object.assign({}, def.options),
120-
);
121-
// Remove from attr all the fields that have 'VIRTUAL' type
122-
// https://sequelize.org/docs/v6/core-concepts/getters-setters-virtuals/#virtual-fields
123-
for (const key in attr) {
124-
if (attr[key].startsWith("VIRTUAL")) {
125-
delete attr[key];
126-
}
168+
if (orderedModels) {
169+
for (const model of orderedModels) {
170+
sql += modelToSQL(sequelize, model, dialect);
127171
}
128-
// create enum types for postgres
129-
if (dialect === "postgres") {
130-
for (const key in attr) {
131-
if (!attr[key].startsWith("ENUM")) {
132-
continue;
133-
}
134-
const enumValues = attr[key].substring(
135-
attr[key].indexOf("("),
136-
attr[key].lastIndexOf(")") + 1,
137-
);
138-
const enumName = `enum_${def.getTableName()}_${key}`;
139-
sql += `CREATE TYPE "${enumName}" AS ENUM${enumValues};\n`;
140-
}
172+
return sql;
173+
}
174+
175+
// In SQLite, foreign key constraints are not enforced by default, so there's no need for special handling of circular dependencies.
176+
if (dialect === "sqlite") {
177+
for (const model of sequelize.modelManager.models as ModelCtor[]) {
178+
sql += modelToSQL(sequelize, model, dialect);
141179
}
142-
sql +=
143-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
144-
// @ts-ignore
145-
queryGenerator.createTableQuery(
146-
def.getTableName(),
147-
attr,
148-
Object.assign({}, def.options),
149-
) + "\n";
150-
for (const index of def.options?.indexes ?? []) {
151-
sql +=
152-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
153-
// @ts-ignore
154-
queryGenerator.addIndexQuery(
155-
def.getTableName(),
156-
index,
157-
Object.assign({}, def.options),
158-
) + ";\n";
180+
return sql;
181+
}
182+
// If there are circular dependencies, first create tables without foreign keys, then add them.
183+
for (const model of sequelize.modelManager.models as ModelCtor[]) {
184+
sql += modelToSQL(sequelize, model, dialect, true);
185+
}
186+
187+
const queryInterface = sequelize.getQueryInterface();
188+
for (const model of sequelize.modelManager.models as ModelCtor[]) {
189+
const attributes = model.getAttributes();
190+
for (const key of Object.keys(attributes)) {
191+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
192+
const attribute = (attributes as Record<string, any>)[key];
193+
if (!attribute.references) {
194+
continue;
195+
}
196+
// @ts-expect-error - queryGenerator is not in the type definition
197+
const query = queryInterface.queryGenerator.attributesToSQL(
198+
{
199+
// @ts-expect-error - normalizeAttribute is not in the type definition
200+
[key]: queryInterface.normalizeAttribute(attribute),
201+
},
202+
{
203+
context: "changeColumn",
204+
table: model.getTableName() as string,
205+
},
206+
);
207+
// @ts-expect-error - queryGenerator is not in the type definition
208+
sql += queryInterface.queryGenerator.changeColumnQuery(
209+
model.getTableName(),
210+
query,
211+
);
212+
sql += "\n";
159213
}
160214
}
161215
return sql;

ts/test/expected/mariadb-fk-deps.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- atlas:pos author[type=table] test/models/Author.ts:13-32
2+
-- atlas:pos book[type=table] test/models/Book.ts:13-32
3+
4+
CREATE TABLE IF NOT EXISTS `author` (`id` INTEGER auto_increment , `name` VARCHAR(100) NOT NULL, `favoriteBookId` INTEGER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
5+
CREATE TABLE IF NOT EXISTS `book` (`id` INTEGER auto_increment , `title` VARCHAR(200) NOT NULL, `authorId` INTEGER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
6+
ALTER TABLE `author` ADD FOREIGN KEY (`favoriteBookId`) REFERENCES `book` (`id`) ON DELETE NO ACTION ON UPDATE CASCADE;
7+
ALTER TABLE `book` ADD FOREIGN KEY (`authorId`) REFERENCES `author` (`id`) ON DELETE NO ACTION ON UPDATE CASCADE;

ts/test/expected/mariadb.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
-- atlas:pos `public`.`email`[type=table] test/load-models.test.ts:19-25
2-
-- atlas:pos user[type=table] test/load-models.test.ts:27-47
3-
-- atlas:pos post[type=table] test/load-models.test.ts:49-68
1+
-- atlas:pos `public`.`email`[type=table] test/load-models.test.ts:21-27
2+
-- atlas:pos user[type=table] test/load-models.test.ts:29-49
3+
-- atlas:pos post[type=table] test/load-models.test.ts:51-70
44

55
CREATE TABLE IF NOT EXISTS `public`.`email` (`id` INTEGER auto_increment , `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
66
CREATE TABLE IF NOT EXISTS `user` (`id` INTEGER auto_increment , `name` VARCHAR(50) NOT NULL, `role` ENUM('admin', 'user', 'guest') DEFAULT 'user', `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;

ts/test/expected/mssql-fk-deps.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- atlas:pos author[type=table] test/models/Author.ts:13-32
2+
-- atlas:pos book[type=table] test/models/Book.ts:13-32
3+
4+
IF OBJECT_ID('[author]', 'U') IS NULL CREATE TABLE [author] ([id] INTEGER IDENTITY(1,1) , [name] NVARCHAR(100) NOT NULL, [favoriteBookId] INTEGER NULL, [createdAt] DATETIMEOFFSET NOT NULL, [updatedAt] DATETIMEOFFSET NOT NULL, PRIMARY KEY ([id]));
5+
IF OBJECT_ID('[book]', 'U') IS NULL CREATE TABLE [book] ([id] INTEGER IDENTITY(1,1) , [title] NVARCHAR(200) NOT NULL, [authorId] INTEGER NULL, [createdAt] DATETIMEOFFSET NOT NULL, [updatedAt] DATETIMEOFFSET NOT NULL, PRIMARY KEY ([id]));
6+
ALTER TABLE [author] ADD FOREIGN KEY ([favoriteBookId]) REFERENCES [book] ([id]) ON DELETE NO ACTION;
7+
ALTER TABLE [book] ADD FOREIGN KEY ([authorId]) REFERENCES [author] ([id]) ON DELETE NO ACTION;

ts/test/expected/mssql.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
-- atlas:pos [public].[email][type=table] test/load-models.test.ts:19-25
2-
-- atlas:pos user[type=table] test/load-models.test.ts:27-47
3-
-- atlas:pos post[type=table] test/load-models.test.ts:49-68
1+
-- atlas:pos [public].[email][type=table] test/load-models.test.ts:21-27
2+
-- atlas:pos user[type=table] test/load-models.test.ts:29-49
3+
-- atlas:pos post[type=table] test/load-models.test.ts:51-70
44

55
IF OBJECT_ID('[public].[email]', 'U') IS NULL CREATE TABLE [public].[email] ([id] INTEGER IDENTITY(1,1) , [createdAt] DATETIMEOFFSET NOT NULL, [updatedAt] DATETIMEOFFSET NOT NULL, PRIMARY KEY ([id]));
66
IF OBJECT_ID('[user]', 'U') IS NULL CREATE TABLE [user] ([id] INTEGER IDENTITY(1,1) , [name] NVARCHAR(50) NOT NULL, [role] VARCHAR(255) CHECK ([role] IN(N'admin', N'user', N'guest')), [createdAt] DATETIMEOFFSET NOT NULL, [updatedAt] DATETIMEOFFSET NOT NULL, PRIMARY KEY ([id]));

ts/test/expected/mysql-fk-deps.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- atlas:pos author[type=table] test/models/Author.ts:13-32
2+
-- atlas:pos book[type=table] test/models/Book.ts:13-32
3+
4+
CREATE TABLE IF NOT EXISTS `author` (`id` INTEGER auto_increment , `name` VARCHAR(100) NOT NULL, `favoriteBookId` INTEGER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
5+
CREATE TABLE IF NOT EXISTS `book` (`id` INTEGER auto_increment , `title` VARCHAR(200) NOT NULL, `authorId` INTEGER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
6+
ALTER TABLE `author` ADD FOREIGN KEY (`favoriteBookId`) REFERENCES `book` (`id`) ON DELETE NO ACTION ON UPDATE CASCADE;
7+
ALTER TABLE `book` ADD FOREIGN KEY (`authorId`) REFERENCES `author` (`id`) ON DELETE NO ACTION ON UPDATE CASCADE;

ts/test/expected/mysql.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
-- atlas:pos `public.email`[type=table] test/load-models.test.ts:19-25
2-
-- atlas:pos user[type=table] test/load-models.test.ts:27-47
3-
-- atlas:pos post[type=table] test/load-models.test.ts:49-68
1+
-- atlas:pos `public.email`[type=table] test/load-models.test.ts:21-27
2+
-- atlas:pos user[type=table] test/load-models.test.ts:29-49
3+
-- atlas:pos post[type=table] test/load-models.test.ts:51-70
44

55
CREATE TABLE IF NOT EXISTS `public.email` (`id` INTEGER auto_increment , `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
66
CREATE TABLE IF NOT EXISTS `user` (`id` INTEGER auto_increment , `name` VARCHAR(50) NOT NULL, `role` ENUM('admin', 'user', 'guest') DEFAULT 'user', `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;

0 commit comments

Comments
 (0)