|
| 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 | +}); |
0 commit comments