Skip to content

Commit 40c4594

Browse files
ymc9claude
andauthored
fix(orm): resolve implicit m2m join table schema for non-public PostgreSQL schemas (#2606)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1cf4345 commit 40c4594

2 files changed

Lines changed: 155 additions & 1 deletion

File tree

packages/orm/src/client/executor/name-mapper.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
extractModelName,
3737
getEnum,
3838
getField,
39+
getManyToManyRelation,
3940
getModel,
4041
getModelFields,
4142
isEnum,
@@ -54,6 +55,8 @@ export class QueryNameMapper extends OperationNodeTransformer {
5455
private readonly modelToTableMap = new Map<string, string>();
5556
private readonly fieldToColumnMap = new Map<string, string>();
5657
private readonly enumTypeMap = new Map<string, string>();
58+
// Maps implicit many-to-many join table names to their PostgreSQL schema
59+
private readonly joinTableSchemaMap = new Map<string, string>();
5760
private readonly scopes: Scope[] = [];
5861
private readonly dialect: BaseCrudDialect<SchemaDef>;
5962

@@ -80,6 +83,23 @@ export class QueryNameMapper extends OperationNodeTransformer {
8083
this.enumTypeMap.set(enumName, mappedName);
8184
}
8285
}
86+
87+
// Build a map from implicit many-to-many join table names to their PostgreSQL schema.
88+
// Join tables live in the schema of the alphabetically-first model in the relation
89+
// (matching Prisma's convention for both naming and placement).
90+
if (client.$schema.provider.type === 'postgresql') {
91+
for (const modelName of Object.keys(client.$schema.models)) {
92+
for (const fieldDef of getModelFields(this.schema, modelName, { relations: true })) {
93+
const m2m = getManyToManyRelation(this.schema, modelName, fieldDef.name);
94+
if (m2m && !this.joinTableSchemaMap.has(m2m.joinTable)) {
95+
// Use the schema of whichever model comes first alphabetically —
96+
// that is where Prisma creates the join table.
97+
const owningModel = [modelName, m2m.otherModel].sort()[0]!;
98+
this.joinTableSchemaMap.set(m2m.joinTable, this.getTableSchema(owningModel) ?? 'public');
99+
}
100+
}
101+
}
102+
}
83103
}
84104

85105
private get schema() {
@@ -548,8 +568,13 @@ export class QueryNameMapper extends OperationNodeTransformer {
548568
if (this.schema.provider.type !== 'postgresql') {
549569
return undefined;
550570
}
571+
// Implicit many-to-many join tables (e.g. _AToB) are not represented as models.
572+
// Their schema is pre-computed in the constructor from the models they join.
573+
if (!this.schema.models[model]) {
574+
return this.joinTableSchemaMap.get(model) ?? 'public';
575+
}
551576
let schema = this.schema.provider.defaultSchema ?? 'public';
552-
const schemaAttr = this.schema.models[model]?.attributes?.find((attr) => attr.name === '@@schema');
577+
const schemaAttr = this.schema.models[model].attributes?.find((attr) => attr.name === '@@schema');
553578
if (schemaAttr) {
554579
const mapArg = schemaAttr.args?.find((arg) => arg.name === 'map');
555580
if (mapArg && mapArg.value.kind === 'literal') {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { createTestClient } from '@zenstackhq/testtools';
2+
import { describe, expect, it } from 'vitest';
3+
4+
// https://github.com/zenstackhq/zenstack/issues/2603
5+
// Implicit many-to-many join tables live in the same schema as their models.
6+
// The ORM must derive that schema from the models involved rather than
7+
// defaulting to 'public', which would generate SQL referencing a non-existent relation.
8+
describe('Regression for issue 2603', () => {
9+
it('implicit m2m with defaultSchema set to non-public schema', async () => {
10+
const db = await createTestClient(
11+
`
12+
datasource db {
13+
provider = 'postgresql'
14+
schemas = ['public', 'mySchema']
15+
defaultSchema = 'mySchema'
16+
url = '$DB_URL'
17+
}
18+
19+
model Post {
20+
id Int @id @default(autoincrement())
21+
tags Tag[]
22+
}
23+
24+
model Tag {
25+
id Int @id @default(autoincrement())
26+
name String
27+
posts Post[]
28+
}
29+
`,
30+
{
31+
provider: 'postgresql',
32+
usePrismaPush: true,
33+
},
34+
);
35+
36+
const post = await db.post.create({
37+
data: {
38+
tags: {
39+
create: [{ name: 'foo' }, { name: 'bar' }],
40+
},
41+
},
42+
include: { tags: true },
43+
});
44+
45+
expect(post.tags).toHaveLength(2);
46+
expect(post.tags.map((t: any) => t.name).sort()).toEqual(['bar', 'foo']);
47+
48+
const fetched = await db.post.findFirst({ include: { tags: true } });
49+
expect(fetched?.tags).toHaveLength(2);
50+
});
51+
52+
it('implicit m2m with explicit @@schema on models', async () => {
53+
const db = await createTestClient(
54+
`
55+
datasource db {
56+
provider = 'postgresql'
57+
schemas = ['public', 'mySchema']
58+
url = '$DB_URL'
59+
}
60+
61+
model Post {
62+
id Int @id @default(autoincrement())
63+
tags Tag[]
64+
@@schema('mySchema')
65+
}
66+
67+
model Tag {
68+
id Int @id @default(autoincrement())
69+
name String
70+
posts Post[]
71+
@@schema('mySchema')
72+
}
73+
`,
74+
{
75+
provider: 'postgresql',
76+
usePrismaPush: true,
77+
},
78+
);
79+
80+
const post = await db.post.create({
81+
data: {
82+
tags: {
83+
create: [{ name: 'alpha' }, { name: 'beta' }],
84+
},
85+
},
86+
include: { tags: true },
87+
});
88+
89+
expect(post.tags).toHaveLength(2);
90+
expect(post.tags.map((t: any) => t.name).sort()).toEqual(['alpha', 'beta']);
91+
});
92+
93+
it('implicit m2m with models in different custom schemas', async () => {
94+
// Prisma places the join table in the schema of the alphabetically-first model
95+
// (_PostToTag goes to schema1 because 'Post' < 'Tag').
96+
const db = await createTestClient(
97+
`
98+
datasource db {
99+
provider = 'postgresql'
100+
schemas = ['schema1', 'schema2', 'public']
101+
url = '$DB_URL'
102+
}
103+
104+
model Post {
105+
id Int @id @default(autoincrement())
106+
tags Tag[]
107+
@@schema('schema1')
108+
}
109+
110+
model Tag {
111+
id Int @id @default(autoincrement())
112+
name String
113+
posts Post[]
114+
@@schema('schema2')
115+
}
116+
`,
117+
{
118+
provider: 'postgresql',
119+
usePrismaPush: true,
120+
},
121+
);
122+
123+
const post = await db.post.create({
124+
data: { tags: { create: [{ name: 'foo' }] } },
125+
include: { tags: true },
126+
});
127+
expect(post.tags.map((t: any) => t.name)).toEqual(['foo']);
128+
});
129+
});

0 commit comments

Comments
 (0)