@@ -9,6 +9,165 @@ import {
99import setupEmptyDatabase from '../_helpers/setup-empty-database' ;
1010
1111describe ( '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