Skip to content

Commit d272b99

Browse files
samuelho-devclaude
andauthored
fix: DB interface uses Schema.Schema.Encoded so Kysely sees real DB columns (#27)
Generated `interface DB` previously emitted `<table>: Schema.Schema.Type<typeof X>`. For tables using `Schema.fromKey` (Prisma implicit M:N join tables — TS field `product_id` maps to DB column `A`), the Type side has the decoded names. Kysely uses the TS interface as the SQL contract — never decodes through Effect Schema. Queries like `.where('product_id', ...)` generated `WHERE product_id = ...` and Postgres rejected with "column does not exist". Fix: emit `Schema.Schema.Encoded<typeof X>` for every DB interface entry. Encoded matches Postgres exactly. For regular tables Type === Encoded, no change. For join tables, Kysely sees A/B and emits valid SQL. Application code runs Schema.decode(X) for semantic field names. ColumnType<S, I, U> brand preserves __select__/__insert__/__update__ phantoms on both Type AND Encoded sides, so Insertable<X>/Updateable<X> inference is unchanged. Adds db-interface-sql-contract.test.ts with regression tests: 1. String-grep — every DB entry uses Encoded. 2. Encoded-side preserves real DB column names for implicit M:N. 3. Kysely SQL compile — emitted SQL references "A" (real column), not product_id (decoded name). Catches the original bug structurally. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bd55caa commit d272b99

8 files changed

Lines changed: 269 additions & 48 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"prisma-effect-kysely": minor
3+
---
4+
5+
fix: DB interface uses `Schema.Schema.Encoded` so Kysely sees real DB columns
6+
7+
The generated `interface DB` previously emitted
8+
`<table>: Schema.Schema.Type<typeof X>`. For tables using `Schema.fromKey`
9+
(Prisma implicit M:N join tables, where TS field `product_id` maps to DB
10+
column `A`), the Type side has the **decoded** names. Kysely uses the TS
11+
interface as the SQL contract — it does not run the Effect schema decoder.
12+
So queries like `db.selectFrom('_product_tags').where('product_id', ...)`
13+
generated `WHERE product_id = ...` and Postgres rejected with
14+
`column _product_tags.product_id does not exist`.
15+
16+
Fix: emit `Schema.Schema.Encoded<typeof X>` for every DB interface entry.
17+
Encoded is the on-the-wire / on-disk shape that matches Postgres. For
18+
regular tables `Type === Encoded`, no behavior change. For join tables,
19+
Kysely now sees `A`/`B` and emits valid SQL. Application code that wants
20+
the semantic field names runs the row through `Schema.decode(X)`.
21+
22+
`ColumnType<S, I, U>` brand preserves `__select__`/`__insert__`/`__update__`
23+
phantoms on both sides, so `Insertable<X>`/`Updateable<X>` inference is
24+
unchanged.
25+
26+
Adds `db-interface-sql-contract.test.ts` with three regression checks:
27+
1. String-grep — every DB entry uses Encoded, none use Type.
28+
2. Encoded-side preserves real Postgres column names for implicit M:N.
29+
3. Kysely SQL compile — emitted SQL references the real `"A"` column,
30+
not the `product_id` decoded name. This catches the original bug
31+
structurally without needing a live database.
32+
33+
**Migration**: most consumers need no changes. If a consumer overrode
34+
the generated DB interface entry to expose `A`/`B` directly (workaround
35+
for this bug), the override can now be removed and the generator will
36+
do the right thing.

src/__tests__/code-generation.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,13 @@ describe('Code Generation - E2E and Validation', () => {
189189

190190
it('should generate DB interface with Schema.Schema.Type pattern', () => {
191191
expect(typesContent).toContain('export interface DB');
192-
expect(typesContent).toMatch(/:\s*Schema\.Schema\.Type<typeof \w+>;/);
192+
expect(typesContent).toMatch(/:\s*Schema\.Schema\.Encoded<typeof \w+>;/);
193193

194-
// Should use Schema.Schema.Type<typeof Model> to preserve phantom properties
194+
// Should use Schema.Schema.Encoded<typeof Model> to preserve phantom properties
195195
const dbMatch = typesContent.match(/export interface DB\s*{([^}]+)}/s);
196196
expect(dbMatch).toBeTruthy();
197197
const dbContent = dbMatch?.[1];
198-
expect(dbContent).not.toMatch(/Schema\.Schema\.Encoded/);
198+
expect(dbContent).not.toMatch(/Schema\.Schema\.Type/);
199199
});
200200

201201
it('should re-export from index', () => {
@@ -278,9 +278,9 @@ describe('Code Generation - E2E and Validation', () => {
278278

279279
it('should use @@map for table names in DB interface', () => {
280280
// CompositeIdModel has @@map("composite_id_table")
281-
// DB interface uses Schema.Schema.Type<typeof Model> to preserve phantom properties
281+
// DB interface uses Schema.Schema.Encoded<typeof Model> to preserve phantom properties
282282
expect(typesContent).toMatch(
283-
/composite_id_table:\s*Schema\.Schema\.Type<typeof CompositeIdModel>/
283+
/composite_id_table:\s*Schema\.Schema\.Encoded<typeof CompositeIdModel>/
284284
);
285285
});
286286
});
@@ -440,8 +440,8 @@ describe('Code Generation - E2E and Validation', () => {
440440

441441
const dbContent = dbMatch?.[1];
442442

443-
// Should have entries for models using Schema.Schema.Type<typeof Model> pattern
444-
expect(dbContent).toMatch(/:\s*Schema\.Schema\.Type<typeof \w+>;/);
443+
// Should have entries for models using Schema.Schema.Encoded<typeof Model> pattern
444+
expect(dbContent).toMatch(/:\s*Schema\.Schema\.Encoded<typeof \w+>;/);
445445
});
446446
});
447447

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* DB Interface SQL Contract Tests
3+
*
4+
* Regression tests for the bug where the generated DB interface used
5+
* `Schema.Schema.Type<typeof X>` instead of `Schema.Schema.Encoded<typeof X>`.
6+
*
7+
* The bug: for tables using `Schema.fromKey` mapping (Prisma implicit M:N
8+
* join tables, where TS field `product_id` maps to DB column `A`), the
9+
* Type side has the decoded names (`product_id`) while the Encoded side
10+
* has the real DB column names (`A`). Kysely uses the TS interface as
11+
* the SQL contract — it does NOT decode through the Effect schema.
12+
* So the interface MUST expose Encoded.
13+
*
14+
* Symptom that triggered this fix (2026-05-08):
15+
* PostgresError: column _product_tags.product_tag_id does not exist
16+
*
17+
* Why string-grep tests didn't catch the original bug:
18+
* They asserted the schema was emitted (it was), they asserted fromKey
19+
* mapping was present (it was), but no test asserted the DB interface
20+
* exposed the actual SQL column names. This file fixes that gap.
21+
*/
22+
23+
import * as fs from 'node:fs/promises';
24+
import * as path from 'node:path';
25+
import type { GeneratorOptions } from '@prisma/generator-helper';
26+
import prismaInternals from '@prisma/internals';
27+
import {
28+
DummyDriver,
29+
Kysely,
30+
PostgresAdapter,
31+
PostgresIntrospector,
32+
PostgresQueryCompiler,
33+
} from 'kysely';
34+
import { Schema } from 'effect';
35+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
36+
import { GeneratorOrchestrator } from '../generator/orchestrator';
37+
import { columnType } from '../kysely/helpers';
38+
39+
const { getDMMF } = prismaInternals;
40+
41+
// Mock prettier to avoid dynamic import issues
42+
vi.mock('../utils/templates', () => ({
43+
formatCode: vi.fn((code: string) => Promise.resolve(code)),
44+
}));
45+
46+
describe('DB interface — SQL contract', () => {
47+
// Schema with two M2M relations — one auto-generated implicit join,
48+
// one with a custom @relation name.
49+
const testSchema = `
50+
datasource db {
51+
provider = "postgresql"
52+
}
53+
54+
generator effectSchemas {
55+
provider = "prisma-effect-kysely"
56+
output = "./test-db-interface-sql-contract"
57+
}
58+
59+
model Product {
60+
id String @id @db.Uuid
61+
tags ProductTag[]
62+
}
63+
64+
model ProductTag {
65+
id String @id @db.Uuid
66+
products Product[]
67+
}
68+
69+
model User {
70+
id String @id @db.Uuid
71+
following User[] @relation("UserFollows")
72+
followers User[] @relation("UserFollows")
73+
}
74+
`;
75+
76+
const outputDir = path.join(import.meta.dirname, 'test-db-interface-sql-contract');
77+
78+
beforeAll(async () => {
79+
const dmmf = await getDMMF({ datamodel: testSchema });
80+
const options: GeneratorOptions = {
81+
generator: { output: { value: outputDir } },
82+
dmmf,
83+
} as GeneratorOptions;
84+
const orchestrator = new GeneratorOrchestrator(options);
85+
await orchestrator.generate(options);
86+
});
87+
88+
afterAll(async () => {
89+
await fs.rm(outputDir, { recursive: true, force: true });
90+
});
91+
92+
it('uses Schema.Schema.Encoded<typeof X> for every DB interface entry', async () => {
93+
const typesContent = await fs.readFile(path.join(outputDir, 'types.ts'), 'utf-8');
94+
95+
// Every line inside `export interface DB { ... }` must use Encoded.
96+
const dbBlock = typesContent.match(/export interface DB\s*\{([\s\S]+?)\n\}/);
97+
expect(dbBlock).toBeTruthy();
98+
const dbBody = dbBlock?.[1] ?? '';
99+
100+
// Catch any lingering Schema.Schema.Type — that would silently break
101+
// Kysely queries against any table using Schema.fromKey.
102+
expect(dbBody).not.toMatch(/Schema\.Schema\.Type</);
103+
104+
// Every entry must use Encoded.
105+
const entries = dbBody.split('\n').filter((l) => l.includes(':'));
106+
for (const entry of entries) {
107+
expect(entry).toMatch(/Schema\.Schema\.Encoded<typeof \w+>/);
108+
}
109+
});
110+
111+
it('exposes the real Postgres A/B columns for implicit M:N join tables', async () => {
112+
const typesContent = await fs.readFile(path.join(outputDir, 'types.ts'), 'utf-8');
113+
114+
// The schema itself uses Schema.fromKey to map A→product_id, B→product_tag_id.
115+
// That mapping is correct for app-level decoding.
116+
expect(typesContent).toMatch(/Schema\.fromKey\("A"\)/);
117+
expect(typesContent).toMatch(/Schema\.fromKey\("B"\)/);
118+
119+
// But the DB interface entry MUST go through Encoded so Kysely sees A/B.
120+
// (Schema.Schema.Type would expose product_id/product_tag_id, which Kysely
121+
// would pass to Postgres verbatim → "column does not exist".)
122+
expect(typesContent).toMatch(
123+
/_ProductToProductTag:\s*Schema\.Schema\.Encoded<typeof ProductToProductTag>/
124+
);
125+
});
126+
127+
it('produces SQL referencing the real A/B columns (Kysely compile check)', () => {
128+
// Recreate the generated schema shape locally — this is what the codegen
129+
// emits for an implicit M:N join table. Validates that with our
130+
// generator's Encoded-side DB interface, Kysely emits SQL that targets
131+
// the actual Postgres columns ("A", "B"), not the Schema.fromKey-decoded
132+
// names ("product_id", "product_tag_id").
133+
const ProductToProductTag = Schema.Struct({
134+
product_id: Schema.propertySignature(
135+
columnType(Schema.UUID, Schema.Never, Schema.Never)
136+
).pipe(Schema.fromKey('A')),
137+
product_tag_id: Schema.propertySignature(
138+
columnType(Schema.UUID, Schema.Never, Schema.Never)
139+
).pipe(Schema.fromKey('B')),
140+
});
141+
142+
interface DB {
143+
_ProductToProductTag: Schema.Schema.Encoded<typeof ProductToProductTag>;
144+
}
145+
146+
const db = new Kysely<DB>({
147+
dialect: {
148+
createAdapter: () => new PostgresAdapter(),
149+
createDriver: () => new DummyDriver(),
150+
createIntrospector: (db) => new PostgresIntrospector(db),
151+
createQueryCompiler: () => new PostgresQueryCompiler(),
152+
},
153+
});
154+
155+
// Realistic query that the wishlist / catalog services issue.
156+
const compiled = db
157+
.selectFrom('_ProductToProductTag')
158+
.where('A', '=', '00000000-0000-0000-0000-000000000000')
159+
.selectAll()
160+
.compile();
161+
162+
// SQL must reference A — NOT product_id.
163+
expect(compiled.sql).toMatch(/"A"\s*=/);
164+
expect(compiled.sql).not.toMatch(/product_id/);
165+
expect(compiled.sql).not.toMatch(/product_tag_id/);
166+
});
167+
});

src/__tests__/kysely-integration.test.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ describe('Kysely Integration - Functional Tests', () => {
7979
});
8080

8181
it('should generate DB interface with Schema.Schema.Type pattern', () => {
82-
// DB interface uses Schema.Schema.Type<typeof Model> to preserve phantom properties
82+
// DB interface uses Schema.Schema.Encoded<typeof Model> to preserve phantom properties
8383
expect(typesContent).toContain('export interface DB');
84-
expect(typesContent).toMatch(/:\s*Schema\.Schema\.Type<typeof \w+>;/);
84+
expect(typesContent).toMatch(/:\s*Schema\.Schema\.Encoded<typeof \w+>;/);
8585
});
8686

8787
it('should define DB as interface not type', () => {
@@ -112,9 +112,9 @@ describe('Kysely Integration - Functional Tests', () => {
112112

113113
it('should use @@map for table names in DB interface', () => {
114114
// CompositeIdModel has @@map("composite_id_table")
115-
// DB interface uses mapped table name with Schema.Schema.Type<typeof Model>
115+
// DB interface uses mapped table name with Schema.Schema.Encoded<typeof Model>
116116
expect(typesContent).toMatch(
117-
/composite_id_table:\s*Schema\.Schema\.Type<typeof CompositeIdModel>/
117+
/composite_id_table:\s*Schema\.Schema\.Encoded<typeof CompositeIdModel>/
118118
);
119119
});
120120
});
@@ -152,8 +152,8 @@ describe('Kysely Integration - Functional Tests', () => {
152152

153153
const dbContent = dbMatch?.[1];
154154

155-
// Should have entries for each model using Schema.Schema.Type<typeof Model>
156-
expect(dbContent).toMatch(/:\s*Schema\.Schema\.Type<typeof \w+>;/);
155+
// Should have entries for each model using Schema.Schema.Encoded<typeof Model>
156+
expect(dbContent).toMatch(/:\s*Schema\.Schema\.Encoded<typeof \w+>;/);
157157
});
158158
});
159159

@@ -172,29 +172,31 @@ describe('Kysely Integration - Functional Tests', () => {
172172
});
173173

174174
it('should use Schema.Schema.Type pattern for type safety', () => {
175-
// DB interface uses Schema.Schema.Type<typeof Model> to preserve phantom properties
175+
// DB interface uses Schema.Schema.Encoded<typeof Model> to preserve phantom properties
176176
// Consumers use Insertable<User>, Selectable<User>, Updateable<User>
177-
expect(typesContent).toMatch(/Schema\.Schema\.Type<typeof \w+>/);
177+
expect(typesContent).toMatch(/Schema\.Schema\.Encoded<typeof \w+>/);
178178
});
179179

180-
it('should generate DB interface with Schema.Schema.Type pattern', () => {
181-
// DB interface uses Schema.Schema.Type<typeof Model> to preserve phantom properties
180+
it('should generate DB interface with Schema.Schema.Encoded pattern', () => {
181+
// DB interface uses Schema.Schema.Encoded<typeof Model> so column names
182+
// match the real DB (Encoded side). Type would expose Schema.fromKey-mapped
183+
// names that Kysely would pass to SQL verbatim, breaking junction tables.
182184
const dbMatch = typesContent.match(/export interface DB\s*{([^}]+)}/s);
183185
expect(dbMatch).toBeTruthy();
184186

185187
const dbContent = dbMatch?.[1];
186188

187-
// Should use Schema.Schema.Type<typeof Model> pattern
188-
expect(dbContent).toMatch(/:\s*Schema\.Schema\.Type<typeof \w+>;/);
189+
// Should use Schema.Schema.Encoded<typeof Model> pattern
190+
expect(dbContent).toMatch(/:\s*Schema\.Schema\.Encoded<typeof \w+>;/);
189191

190-
// Should NOT use Schema.Schema.Encoded inline
191-
expect(dbContent).not.toMatch(/Schema\.Schema\.Encoded/);
192+
// Should NOT use Schema.Schema.Type — that would expose decoded names
193+
expect(dbContent).not.toMatch(/Schema\.Schema\.Type/);
192194
});
193195

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+>/);
196+
it('should support junction table queries with Schema.Schema.Encoded pattern', () => {
197+
// Junction tables (implicit M2M) must use Encoded so DB columns A/B
198+
// are exposed to Kysely (not the Schema.fromKey-decoded names).
199+
expect(typesContent).toMatch(/_product_tags:\s*Schema\.Schema\.Encoded<typeof \w+>/);
198200
});
199201
});
200202

@@ -273,8 +275,8 @@ describe('Kysely Integration - Functional Tests', () => {
273275

274276
const dbContent = dbMatch?.[1];
275277

276-
// Should have table entries with Schema.Schema.Type<typeof Model>
277-
expect(dbContent).toMatch(/\w+:\s*Schema\.Schema\.Type<typeof \w+>;/);
278+
// Should have table entries with Schema.Schema.Encoded<typeof Model>
279+
expect(dbContent).toMatch(/\w+:\s*Schema\.Schema\.Encoded<typeof \w+>;/);
278280
});
279281

280282
it('should generate schemas compatible with Effect runtime', () => {

src/__tests__/kysely-native-integration.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,13 @@ describe('Kysely Native Integration', () => {
9090
});
9191

9292
describe('DB Interface with Schema.Schema.Type Pattern', () => {
93-
it('should use Schema.Schema.Type<typeof Model> in DB interface', async () => {
93+
it('should use Schema.Schema.Encoded<typeof Model> in DB interface', async () => {
9494
const typesContent = await fs.readFile(path.join(outputDir, 'types.ts'), 'utf-8');
9595

96-
// DB interface should use Schema.Schema.Type<typeof Model> to preserve phantom properties
96+
// DB interface should use Schema.Schema.Encoded<typeof Model> to preserve phantom properties
9797
expect(typesContent).toMatch(/export interface DB \{/);
98-
expect(typesContent).toMatch(/User:\s*Schema\.Schema\.Type<typeof User>/);
99-
expect(typesContent).toMatch(/Post:\s*Schema\.Schema\.Type<typeof Post>/);
98+
expect(typesContent).toMatch(/User:\s*Schema\.Schema\.Encoded<typeof User>/);
99+
expect(typesContent).toMatch(/Post:\s*Schema\.Schema\.Encoded<typeof Post>/);
100100

101101
// Should NOT use SelectEncoded in DB interface
102102
expect(typesContent).not.toMatch(/User:\s*UserSelectEncoded/);
@@ -109,8 +109,8 @@ describe('Kysely Native Integration', () => {
109109
const typesContent = await fs.readFile(path.join(outputDir, 'types.ts'), 'utf-8');
110110

111111
// Package provides Insertable, Selectable, Updateable utilities
112-
// DB interface uses Schema.Schema.Type<typeof Model> to preserve phantom properties
113-
expect(typesContent).toMatch(/Schema\.Schema\.Type<typeof User>/);
112+
// DB interface uses Schema.Schema.Encoded<typeof Model> to preserve phantom properties
113+
expect(typesContent).toMatch(/Schema\.Schema\.Encoded<typeof User>/);
114114

115115
// Effect schemas use columnType for read-only ID fields
116116
expect(typesContent).toContain('columnType(');

src/__tests__/kysely-phantom-props.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,12 @@ describe('Kysely Phantom Property Compatibility', () => {
122122
it('should work with Schema.Schema.Type pattern used in DB interface', () => {
123123
// This simulates how the generator produces DB interface:
124124
// export interface DB {
125-
// User: Schema.Schema.Type<typeof User>;
125+
// User: Schema.Schema.Encoded<typeof User>;
126126
// }
127127
//
128128
// Where User schema has fields with ColumnType and Generated wrappers
129129

130-
// Simulated row type (what Schema.Schema.Type<typeof User> produces)
130+
// Simulated row type (what Schema.Schema.Encoded<typeof User> produces)
131131
interface UserRow {
132132
id: ColumnType<string, never, never>;
133133
email: string;

0 commit comments

Comments
 (0)