Skip to content

Commit ff58e31

Browse files
samuelho-devclaude
andcommitted
fix: join-table DB interface uses physical A/B columns (Codec.Encoded)
Implicit M:N join tables were typed in the Kysely DB interface as Schema.Schema.Type<typeof JoinTable> — the DECODED shape, whose keys are the semantic *_id names. But Kysely uses DB-interface field names as literal SQL column identifiers, and an implicit M:N table's physical Postgres columns are A/B. So `db.selectFrom('_x').where('_x.product_id', ...)` emitted `WHERE product_id` against an A/B-only table → runtime SQL error. Emit Schema.Codec.Encoded<typeof JoinTable> instead — the ENCODED shape with physical A/B keys, carrying the branded columnType values so joins stay type-safe against the parent table's branded id. The semantic-name mapping stays only in the schema's Schema.encodeKeys (decode side). Regular model tables are unchanged. Tests: unit (encode→{A,B}, decode→{product_id,...}, DB-entry string match) + pglite integration running the exact previously-broken query `where('_product_tags.A', '=', productId)` against a real A/B table with branded values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 558268b commit ff58e31

7 files changed

Lines changed: 124 additions & 5 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'prisma-effect-kysely': patch
3+
---
4+
5+
Fix: implicit M:N join tables now expose their physical `A`/`B` columns in the
6+
Kysely `DB` interface.
7+
8+
Kysely uses `DB`-interface field names as literal SQL column identifiers. The
9+
join-table entry was emitted as `Schema.Schema.Type<typeof JoinTable>`, whose
10+
keys are the **decoded** semantic names (`product_id`, `product_tag_id`). But an
11+
implicit many-to-many table's physical Postgres columns are `A`/`B` (Prisma's
12+
convention), so a query like
13+
`db.selectFrom('_product_tags').where('_product_tags.product_id', ...)` emitted
14+
`WHERE product_id` against a table that only has `A`/`B` → runtime SQL error.
15+
16+
The join-table `DB` entry is now `Schema.Codec.Encoded<typeof JoinTable>`, whose
17+
keys are the **encoded** physical columns `A`/`B` (carrying the branded
18+
`columnType` values, so joins remain type-safe against the parent table's branded
19+
id). The semantic-name mapping still lives only in the schema's
20+
`Schema.encodeKeys`, used when decoding a raw DB row. Regular (non-join) model
21+
tables are unchanged (`Schema.Schema.Type<typeof Model>`).

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ Consumers use `Selectable<typeof User>` / `Insertable<typeof User>` / `Updateabl
8787

8888
Prisma stores `A`/`B` columns; we emit semantic snake_case fields on the struct, then map them to the DB columns with a struct-level `.pipe(Schema.encodeKeys({ <model_a>_id: "A", <model_b>_id: "B" }))`. FK columns are `columnType(Id, Id, Schema.Never)` — insertable (you supply both keys when linking) but read-only on update (composite-PK rows are inserted/deleted, not updated). Join tables get NO branded ID (composite key).
8989

90+
The `DB`-interface entry for a join table uses `Schema.Codec.Encoded<typeof JoinTable>` (the **encoded** `A`/`B` shape), NOT `Schema.Schema.Type` — Kysely uses DB field names as literal SQL identifiers, and the physical columns are `A`/`B`. The semantic `*_id` names exist only on the decode side (`Schema.decode` output). Regular model tables still use `Schema.Schema.Type<typeof Model>`.
91+
9092
## UUID detection
9193

9294
`isUuidField()` in `src/prisma/type.ts`, priority order:

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,11 @@ export const ProductToProductTag = Schema.Struct({
169169
}).pipe(Schema.encodeKeys({ product_id: 'A', product_tag_id: 'B' }));
170170
```
171171

172+
In the Kysely `DB` interface the join table is typed by its **encoded** shape
173+
(`Schema.Codec.Encoded<typeof ProductToProductTag>``{ A, B }`), so you query
174+
the physical columns directly: `db.selectFrom('_product_tags').where('_product_tags.A', '=', productId)`.
175+
`Schema.decode` of a raw row maps `A`/`B` back to `product_id`/`product_tag_id`.
176+
172177
## Package Exports
173178

174179
| Entry | Contents |

src/__tests__/effect-schema.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,25 @@ describe('Effect Schema - Runtime Behavior', () => {
888888
expect(fieldNamesOf(Updateable(JoinTable))).toEqual([]);
889889
});
890890

891+
it('ENCODES to the physical A/B columns (what the Kysely DB interface must use)', () => {
892+
// The DB interface is typed `Schema.Codec.Encoded<typeof JoinTable>`, i.e. the
893+
// encoded shape — physical Postgres columns A/B. Encoding a semantic row must
894+
// produce { A, B } so Kysely emits valid SQL against the real columns.
895+
const encoded = Schema.encodeUnknownSync(JoinTable)({
896+
product_id: 'p1',
897+
product_tag_id: 't1',
898+
});
899+
expect(Object.keys(encoded).sort()).toEqual(['A', 'B']);
900+
expect(encoded).toEqual({ A: 'p1', B: 't1' });
901+
});
902+
903+
it('DECODES from the physical A/B columns back to semantic names', () => {
904+
// The decode direction (Schema.Schema.Type) keeps the semantic *_id names —
905+
// this is what consumers get from Schema.decode of a raw DB row.
906+
const decoded = Schema.decodeUnknownSync(JoinTable)({ A: 'p1', B: 't1' });
907+
expect(decoded).toEqual({ product_id: 'p1', product_tag_id: 't1' });
908+
});
909+
891910
it('an encodeKeys struct NESTED as a field is not misdetected as generated', () => {
892911
// An encodeKeys transform exposes a `.from` own-property, same as a
893912
// generated() field. Generated detection is gated on the GeneratedId

src/__tests__/integration/pglite-roundtrip.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,64 @@ describe('pglite roundtrip', () => {
155155
expect(row.title).toBe('n');
156156
expect(row.body).toBeNull();
157157
});
158+
159+
it('implicit M:N join table is queryable by its physical A/B columns', async () => {
160+
// Regression: the Kysely DB-interface type for an implicit M:N join table must
161+
// use the ENCODED shape (physical Postgres columns A/B), because Kysely emits
162+
// DB-interface field names as literal SQL identifiers. The physical table only
163+
// has columns A/B; the semantic *_id names live solely in the Schema's
164+
// encodeKeys (decode) mapping. This test runs the exact query that previously
165+
// failed — `where('_product_tags.A', ...)` — against a real A/B table.
166+
const JoinDDL = `
167+
CREATE TABLE "_product_tags" (
168+
"A" uuid NOT NULL,
169+
"B" uuid NOT NULL,
170+
PRIMARY KEY ("A", "B")
171+
);
172+
`;
173+
const ProductId = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('ProductId'));
174+
const ProductTagId = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('ProductTagId'));
175+
const ProductTags = Schema.Struct({
176+
product_id: columnType(ProductId, ProductId, Schema.Never),
177+
product_tag_id: columnType(ProductTagId, ProductTagId, Schema.Never),
178+
}).pipe(Schema.encodeKeys({ product_id: 'A', product_tag_id: 'B' }));
179+
180+
// The generator emits this exact DB-interface entry for join tables.
181+
interface JoinDB {
182+
_product_tags: Schema.Codec.Encoded<typeof ProductTags>;
183+
}
184+
185+
// Branded values, as a real consumer would supply them — the encoded A/B
186+
// columns are branded (ProductId/ProductTagId), so the join stays type-safe.
187+
const pid = Schema.decodeUnknownSync(ProductId)('11111111-1111-4111-8111-111111111111');
188+
const tid = Schema.decodeUnknownSync(ProductTagId)('22222222-2222-4222-8222-222222222222');
189+
190+
const program = Effect.gen(function* () {
191+
const db = (yield* KyselyDb) as Kysely<JoinDB>;
192+
193+
yield* Effect.tryPromise({
194+
try: () => db.insertInto('_product_tags').values({ A: pid, B: tid }).execute(),
195+
catch: (cause) => new Error(`insert failed: ${String(cause)}`),
196+
});
197+
198+
// The previously-broken query: reference the PHYSICAL column A.
199+
const found = yield* Effect.tryPromise({
200+
try: () =>
201+
db
202+
.selectFrom('_product_tags')
203+
.selectAll()
204+
.where('_product_tags.A', '=', pid)
205+
.executeTakeFirst(),
206+
catch: (cause) => new Error(`select failed: ${String(cause)}`),
207+
});
208+
209+
// Decode the raw {A,B} row back to semantic names via the schema.
210+
return found ? Schema.decodeUnknownSync(ProductTags)(found) : undefined;
211+
});
212+
213+
const Live = makePgliteLayer<JoinDB>(JoinDDL);
214+
const decoded = await Effect.runPromise(Effect.scoped(program.pipe(Effect.provide(Live))));
215+
216+
expect(decoded).toEqual({ product_id: pid, product_tag_id: tid });
217+
});
158218
});

src/__tests__/kysely-integration.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,14 @@ describe('Kysely Integration - Functional Tests', () => {
191191
expect(dbContent).not.toMatch(/Schema\.Schema\.Encoded/);
192192
});
193193

194-
it('should support junction table queries with Schema.Schema.Type pattern', () => {
195-
// Junction tables (implicit M2M) should be in DB interface
196-
// Example: _product_tags, _CategoryToPost
197-
expect(typesContent).toMatch(/_product_tags:\s*Schema\.Schema\.Type<typeof \w+>/);
194+
it('should expose junction tables by their ENCODED (physical A/B) shape', () => {
195+
// Implicit M2M join tables have physical Postgres columns `A`/`B`. Kysely
196+
// uses DB-interface field names as literal SQL identifiers, so the join
197+
// table must be typed by its ENCODED shape (Schema.Codec.Encoded → { A, B }),
198+
// NOT the decoded Schema.Schema.Type (which has the semantic *_id names used
199+
// only for decode output). Emitting the decoded names would make
200+
// `db.selectFrom('_x').where('_x.A', ...)` impossible / emit invalid SQL.
201+
expect(typesContent).toMatch(/_product_tags:\s*Schema\.Codec\.Encoded<typeof \w+>/);
198202
});
199203
});
200204

src/kysely/type.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,15 @@ export function generateDBInterfaceEntry(model: DMMF.Model) {
9191
export function generateJoinTableDBInterfaceEntry(joinTable: JoinTableInfo) {
9292
const { tableName, relationName } = joinTable;
9393
const schemaName = toPascalCase(relationName);
94-
return ` ${tableName}: Schema.Schema.Type<typeof ${schemaName}>;`;
94+
// Use the ENCODED type (the `Schema.encodeKeys` mapping), NOT the decoded
95+
// `Schema.Schema.Type`. Kysely uses the DB-interface field names as literal SQL
96+
// column identifiers, and an implicit M:N join table's physical columns are
97+
// `A`/`B` (Prisma's convention) — not the semantic `*_id` names. The decoded
98+
// type has the semantic names (for `Schema.decode` output); the encoded type
99+
// keeps `A`/`B` with the branded `columnType` values, so queries like
100+
// `db.selectFrom('_x').where('_x.A', ...)` emit valid SQL while joins stay
101+
// type-safe against the parent table's branded id.
102+
return ` ${tableName}: Schema.Codec.Encoded<typeof ${schemaName}>;`;
95103
}
96104

97105
/**

0 commit comments

Comments
 (0)