Skip to content

Commit db07b45

Browse files
committed
feat: query builder integration for typed data transforms
queryOperations on SqlControlStaticContributions for extension function support. createBuilders<Contract>() for typed dataTransform callbacks. SQL lowered at verify time via postgres adapter. Journey test fixes: migration-new reads storage.storageHash correctly, resume-after-failure tests use unique constraint violation scenario.
1 parent 8b8ec10 commit db07b45

3 files changed

Lines changed: 43 additions & 30 deletions

File tree

test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,14 @@ withTempDir(({ createTempDir }) => {
7272
const db = useDevDatabase();
7373

7474
it(
75-
'resumes from last successful migration after empty-table precheck failure',
75+
'resumes from last successful migration after unique constraint violation',
7676
async () => {
7777
const ctx: JourneyContext = setupJourney({
7878
connectionString: db.connectionString,
7979
createTempDir,
8080
});
8181

82-
// Plan and apply initial migration (creates user table)
82+
// Plan and apply initial migration (creates user table with id + email)
8383
const emit0 = await runContractEmit(ctx);
8484
expect(emit0.exitCode, 'emit base').toBe(0);
8585
const plan0 = await runMigrationPlan(ctx, ['--name', 'initial']);
@@ -92,24 +92,22 @@ withTempDir(({ createTempDir }) => {
9292
);
9393
expect(firstResult.migrationsApplied, 'applied 1').toBe(1);
9494

95-
// Insert data so a NOT NULL column addition will fail
95+
// Insert rows with duplicate emails
9696
await sql(
9797
db.connectionString,
98-
`INSERT INTO "user" (id, email) VALUES (1, 'user@example.com')`,
98+
`INSERT INTO "user" (id, email) VALUES (1, 'dup@example.com'), (2, 'dup@example.com')`,
9999
);
100100

101-
// Plan a migration that adds a non-nullable column (will fail on existing rows)
102-
swapContract(ctx, 'contract-additive-required');
101+
// Plan migration that adds a unique constraint on email
102+
swapContract(ctx, 'contract-unique-email');
103103
const emit1 = await runContractEmit(ctx);
104-
expect(emit1.exitCode, 'emit additive-required').toBe(0);
105-
const plan1 = await runMigrationPlan(ctx, ['--name', 'add-required-name']);
106-
expect(plan1.exitCode, 'plan add-required-name').toBe(0);
104+
expect(emit1.exitCode, 'emit unique-email').toBe(0);
105+
const plan1 = await runMigrationPlan(ctx, ['--name', 'add-unique-email']);
106+
expect(plan1.exitCode, 'plan add-unique-email').toBe(0);
107107

108-
// Apply fails because the planner's empty-table precheck rejects adding
109-
// a NOT NULL + UNIQUE column to a non-empty table (temporary default
110-
// strategy is disabled when the column has a UNIQUE constraint).
108+
// Apply fails because duplicate emails violate the unique constraint
111109
const applyFail = await runMigrationApply(ctx, ['--json']);
112-
expect(applyFail.exitCode, 'apply fails on non-empty table precheck').toBe(1);
110+
expect(applyFail.exitCode, 'apply fails on duplicate key').toBe(1);
113111

114112
// Marker stays at the first migration's target hash
115113
const marker = await sql(
@@ -121,9 +119,13 @@ withTempDir(({ createTempDir }) => {
121119
firstResult.markerHash,
122120
);
123121

124-
// Fix: remove conflicting data, then resume
125-
await sql(db.connectionString, 'DELETE FROM "user"');
122+
// Fix: deduplicate emails
123+
await sql(
124+
db.connectionString,
125+
`UPDATE "user" SET email = 'unique@example.com' WHERE id = 2`,
126+
);
126127

128+
// Resume: apply succeeds now that duplicates are resolved
127129
const applyResume = await runMigrationApply(ctx, ['--json']);
128130
expect(applyResume.exitCode, 'resume succeeds').toBe(0);
129131

test/integration/test/cli.migration-apply.e2e.test.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -314,32 +314,30 @@ withTempDir(({ createTempDir }) => {
314314
const firstApply = JSON.parse(consoleOutput.join('\n').trim()) as MigrationApplyResult;
315315
expect(firstApply.migrationsApplied).toBe(1);
316316

317-
// Insert data so a later NOT NULL column addition will fail.
317+
// Insert rows with duplicate emails so a unique constraint will fail.
318318
await withClient(connectionString, async (client) => {
319-
await client.query(`INSERT INTO "user" (id, email) VALUES (1, 'user@example.com')`);
319+
await client.query(
320+
`INSERT INTO "user" (id, email) VALUES (1, 'dup@example.com'), (2, 'dup@example.com')`,
321+
);
320322
});
321323

322-
// Plan second migration that adds a NOT NULL + UNIQUE column.
323-
// The UNIQUE constraint prevents the planner's temporary-default
324-
// strategy (a uniform default would violate uniqueness), so the
325-
// planner falls back to an empty-table precheck that fails here.
324+
// Plan second migration that adds a unique constraint on email.
326325
replaceInFileOrThrow(
327326
contractPath!,
328327
' email: field.column(textColumn),\n',
329-
` email: field.column(textColumn),\n required_name: field.column(textColumn).unique({ name: 'user_required_name_key' }),\n`,
328+
` email: field.column(textColumn).unique({ name: 'user_email_key' }),\n`,
330329
);
331330

332331
await emitContract(testDir, configPath);
333332
await runMigrationPlan(testDir, [
334333
'--config',
335334
configPath,
336335
'--name',
337-
'add_required_name',
336+
'add_unique_email',
338337
'--no-color',
339338
]);
340339

341-
// Apply fails: the empty-table precheck rejects adding a NOT NULL + UNIQUE
342-
// column to a non-empty table.
340+
// Apply fails: duplicate emails violate the unique constraint.
343341
consoleOutput.length = 0;
344342
let failed = false;
345343
try {
@@ -349,9 +347,6 @@ withTempDir(({ createTempDir }) => {
349347
}
350348
expect(failed).toBe(true);
351349
expect(getExitCode()).toBe(1);
352-
const errorOutput = stripAnsi(consoleOutput.join('\n'));
353-
expect(errorOutput).toContain('failed during precheck');
354-
expect(errorOutput).toContain('is empty before adding NOT NULL column');
355350

356351
// Marker must remain at the first migration hash (resume point).
357352
const migrationsDir = join(testDir, 'migrations');
@@ -371,9 +366,9 @@ withTempDir(({ createTempDir }) => {
371366
expect(marker.rows[0]?.core_hash).toBe(firstMigration!.manifest.to);
372367
});
373368

374-
// Make second migration runnable, then re-run apply; it should resume from marker.
369+
// Fix: deduplicate emails, then re-run apply; it should resume from marker.
375370
await withClient(connectionString, async (client) => {
376-
await client.query('DELETE FROM "user"');
371+
await client.query(`UPDATE "user" SET email = 'unique@example.com' WHERE id = 2`);
377372
});
378373

379374
consoleOutput.length = 0;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { CodecTypes } from '@prisma-next/adapter-postgres/codec-types';
2+
import { int4Column, textColumn } from '@prisma-next/adapter-postgres/column-types';
3+
import { defineContract } from '@prisma-next/sql-contract-ts/contract-builder';
4+
import postgresPack from '@prisma-next/target-postgres/pack';
5+
6+
export const contract = defineContract<CodecTypes>()
7+
.target(postgresPack)
8+
.table('user', (t) =>
9+
t
10+
.column('id', { type: int4Column, nullable: false })
11+
.column('email', { type: textColumn, nullable: false })
12+
.primaryKey(['id'])
13+
.unique(['email'], 'user_email_key'),
14+
)
15+
.model('User', 'user', (m) => m.field('id', 'id').field('email', 'email'))
16+
.build();

0 commit comments

Comments
 (0)