Skip to content

Commit 5ab0344

Browse files
authored
fix(datasource-sql): filter sequelize constraints on schema to patch sequelize bug (#1478)
1 parent 590359b commit 5ab0344

File tree

2 files changed

+188
-21
lines changed

2 files changed

+188
-21
lines changed

packages/datasource-sql/src/introspection/introspector.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -188,16 +188,36 @@ export default class Introspector {
188188
queryInterface: QueryInterfaceExt,
189189
logger: Logger,
190190
) {
191-
const tableReferences = await queryInterface.getForeignKeyReferencesForTable(
192-
tableIdentifierForQuery,
193-
);
191+
const tableReferences = (
192+
await queryInterface.getForeignKeyReferencesForTable(tableIdentifierForQuery)
193+
)
194+
.map(r => ({
195+
...r,
196+
tableName: typeof r.tableName === 'string' ? r.tableName : r.tableName.tableName,
197+
}))
198+
.filter(r => {
199+
// There is a bug right now with sequelize on postgresql: returned association
200+
// are not filtered on the schema. So we have to filter them manually.
201+
// Should be fixed with Sequelize v7
202+
if (
203+
r.tableName === tableIdentifier.tableName &&
204+
r.tableSchema === tableIdentifier.schema &&
205+
r.referencedTableSchema === tableIdentifier.schema
206+
) {
207+
return true;
208+
}
209+
210+
logger?.(
211+
'Warn',
212+
`Relations between different schemas are not supported. Skipping '${r.constraintName}' on '${tableIdentifierForQuery.tableName}'.
213+
This warning can also occur when the same contraint name is present on multiple schemas, it will be ignored.`,
214+
);
215+
216+
return false;
217+
});
218+
194219
const processedTableReferences = tableReferences.map(tableReference => ({
195220
...tableReference,
196-
tableName:
197-
typeof tableReference.tableName === 'string'
198-
? tableReference.tableName
199-
: // On SQLite, the query interface returns an object with a tableName property
200-
tableReference.tableName.tableName,
201221
composite:
202222
tableReferences.filter(
203223
reference =>
@@ -221,15 +241,7 @@ export default class Introspector {
221241
);
222242
}
223243

224-
return processedTableReferences.filter(
225-
// There is a bug right now with sequelize on postgresql: returned association
226-
// are not filtered on the schema. So we have to filter them manually.
227-
// Should be fixed with Sequelize v7
228-
r =>
229-
r.tableName === tableIdentifier.tableName &&
230-
r.tableSchema === tableIdentifier.schema &&
231-
!r.composite,
232-
);
244+
return processedTableReferences.filter(r => !r.composite);
233245
}
234246

235247
private static async getColumn(

packages/datasource-sql/test/introspection/introspector.integration.test.ts

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,165 @@ import {
99
import setupEmptyDatabase from '../_helpers/setup-empty-database';
1010

1111
describe('Introspector > Integration', () => {
12+
/**
13+
* Bug reproduction: FK relations disappear when another schema has same table names.
14+
* @see https://community.forestadmin.com/t/missing-related-data/8385
15+
*
16+
* Root cause: Sequelize's FK query joins both key_column_usage and constraint_column_usage
17+
* on constraint_name without schema qualifiers. When two schemas have identical table/column
18+
* names, PostgreSQL generates identical auto-constraint names (e.g. "xxx_model_code_fkey")
19+
* in both schemas. The unqualified joins produce extra row matches, which were misdetected
20+
* as composite foreign keys and filtered out.
21+
*/
22+
describe('relations with same table names across schemas', () => {
23+
const db = 'database_introspector_multi_schema_fk';
24+
25+
describe.each(POSTGRESQL_DETAILS)('on $name', connectionDetails => {
26+
let sequelize: Sequelize;
27+
let sequelizeSchema1: Sequelize;
28+
29+
beforeEach(async () => {
30+
sequelize = await setupEmptyDatabase(connectionDetails, db);
31+
32+
await sequelize.query('DROP SCHEMA IF EXISTS schema1 CASCADE');
33+
await sequelize.query('DROP SCHEMA IF EXISTS schema2 CASCADE');
34+
35+
await sequelize.getQueryInterface().createSchema('schema1');
36+
await sequelize.getQueryInterface().createSchema('schema2');
37+
38+
sequelizeSchema1 = new Sequelize(connectionDetails.url(db), {
39+
logging: false,
40+
schema: 'schema1',
41+
});
42+
});
43+
44+
afterEach(async () => {
45+
await sequelizeSchema1?.close();
46+
await sequelize?.close();
47+
});
48+
49+
it('should not misdetect composite FK when another schema has same constraint names', async () => {
50+
// Two schemas with matching table names and FK column names cause PostgreSQL to generate
51+
// identical auto-constraint names, triggering the Sequelize cross-schema join bug.
52+
await sequelize.query(`
53+
CREATE TABLE schema1.main_table (
54+
id SERIAL PRIMARY KEY,
55+
unique_code TEXT NOT NULL UNIQUE
56+
);
57+
58+
CREATE TABLE schema1.xxx_model (
59+
id SERIAL PRIMARY KEY,
60+
code TEXT NOT NULL REFERENCES schema1.main_table(unique_code)
61+
);
62+
63+
CREATE TABLE schema1.yyy_model (
64+
id SERIAL PRIMARY KEY,
65+
code TEXT NOT NULL REFERENCES schema1.main_table(unique_code)
66+
);
67+
68+
CREATE TABLE schema1.a_b_c (
69+
id SERIAL PRIMARY KEY,
70+
code TEXT NOT NULL REFERENCES schema1.main_table(unique_code)
71+
);
72+
73+
-- Schema2: same table names AND same FK column names → same auto-constraint names
74+
CREATE TABLE schema2.main_table (
75+
id SERIAL PRIMARY KEY,
76+
unique_code TEXT NOT NULL UNIQUE
77+
);
78+
79+
CREATE TABLE schema2.xxx_model (
80+
id SERIAL PRIMARY KEY,
81+
code TEXT NOT NULL REFERENCES schema2.main_table(unique_code)
82+
);
83+
84+
CREATE TABLE schema2.yyy_model (
85+
id SERIAL PRIMARY KEY,
86+
code TEXT NOT NULL REFERENCES schema2.main_table(unique_code)
87+
);
88+
89+
CREATE TABLE schema2.a_b_c (
90+
id SERIAL PRIMARY KEY,
91+
code TEXT NOT NULL REFERENCES schema2.main_table(unique_code)
92+
);
93+
`);
94+
95+
const logger = jest.fn();
96+
const { tables } = await Introspector.introspect(sequelizeSchema1, logger);
97+
98+
expect(tables).toHaveLength(4);
99+
100+
const xxxModel = tables.find(t => t.name === 'xxx_model');
101+
const yyyModel = tables.find(t => t.name === 'yyy_model');
102+
const abcModel = tables.find(t => t.name === 'a_b_c');
103+
104+
// All 3 models should preserve their FK constraint to main_table.unique_code
105+
expect(xxxModel?.columns.find(c => c.name === 'code')?.constraints).toEqual([
106+
{ table: 'main_table', column: 'unique_code' },
107+
]);
108+
expect(yyyModel?.columns.find(c => c.name === 'code')?.constraints).toEqual([
109+
{ table: 'main_table', column: 'unique_code' },
110+
]);
111+
expect(abcModel?.columns.find(c => c.name === 'code')?.constraints).toEqual([
112+
{ table: 'main_table', column: 'unique_code' },
113+
]);
114+
115+
// Should NOT log composite relation warnings for these single-column FKs
116+
expect(logger).not.toHaveBeenCalledWith(
117+
'Warn',
118+
expect.stringContaining('Composite relations are not supported'),
119+
);
120+
});
121+
122+
it('should still detect real composite FKs when another schema has same constraint names', async () => {
123+
await sequelize.query(`
124+
CREATE TABLE schema1.target (
125+
type TEXT NOT NULL,
126+
code TEXT NOT NULL,
127+
PRIMARY KEY (type, code)
128+
);
129+
130+
CREATE TABLE schema1.source (
131+
id SERIAL PRIMARY KEY,
132+
fk_type TEXT NOT NULL,
133+
fk_code TEXT NOT NULL,
134+
CONSTRAINT source_composite_fkey FOREIGN KEY (fk_type, fk_code)
135+
REFERENCES schema1.target(type, code)
136+
);
137+
138+
-- Schema2: identical structure produces identical auto-constraint names
139+
CREATE TABLE schema2.target (
140+
type TEXT NOT NULL,
141+
code TEXT NOT NULL,
142+
PRIMARY KEY (type, code)
143+
);
144+
145+
CREATE TABLE schema2.source (
146+
id SERIAL PRIMARY KEY,
147+
fk_type TEXT NOT NULL,
148+
fk_code TEXT NOT NULL,
149+
CONSTRAINT source_composite_fkey FOREIGN KEY (fk_type, fk_code)
150+
REFERENCES schema2.target(type, code)
151+
);
152+
`);
153+
154+
const logger = jest.fn();
155+
const { tables } = await Introspector.introspect(sequelizeSchema1, logger);
156+
157+
const sourceTable = tables.find(t => t.name === 'source');
158+
159+
// Composite FKs should be filtered out (not supported by Sequelize)
160+
expect(sourceTable?.columns.find(c => c.name === 'fk_type')?.constraints).toEqual([]);
161+
expect(sourceTable?.columns.find(c => c.name === 'fk_code')?.constraints).toEqual([]);
162+
163+
expect(logger).toHaveBeenCalledWith(
164+
'Warn',
165+
expect.stringContaining('Composite relations are not supported'),
166+
);
167+
});
168+
});
169+
});
170+
12171
describe('relations to different schemas', () => {
13172
const db = 'database_introspector';
14173

@@ -90,10 +249,6 @@ describe('Introspector > Integration', () => {
90249
],
91250
},
92251
]);
93-
expect(logger).toHaveBeenCalledWith(
94-
'Error',
95-
"Failed to load constraints on relation on table 'elements' referencing 'users.id'. The relation will be ignored.",
96-
);
97252
});
98253
},
99254
);

0 commit comments

Comments
 (0)