Skip to content

Commit 70eb5e4

Browse files
authored
Merge pull request #1859 from constructive-io/devin/1790168515-pgsql-test-deferred-constraints
feat(pgsql-test): `deferredConstraints` — run commit-time deferred constraint checks under rollback isolation
2 parents b981569 + 2ebafde commit 70eb5e4

6 files changed

Lines changed: 273 additions & 3 deletions

File tree

pgpm/env/src/env.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1-
import { BucketProvider,PgpmOptions } from '@pgpmjs/types';
1+
import { BucketProvider, DeferredConstraintsMode, PgpmOptions } from '@pgpmjs/types';
22
import { parseEnvBoolean, parseEnvList, parseEnvNumber } from '12factor-env';
33

44
export { parseEnvBoolean, parseEnvList, parseEnvNumber };
55

6+
const DEFERRED_CONSTRAINTS_MODES: DeferredConstraintsMode[] = ['off', 'check', 'immediate'];
7+
8+
const parseDeferredConstraintsMode = (value: string): DeferredConstraintsMode => {
9+
const mode = value.trim().toLowerCase() as DeferredConstraintsMode;
10+
if (!DEFERRED_CONSTRAINTS_MODES.includes(mode)) {
11+
throw new Error(
12+
`Invalid DB_DEFERRED_CONSTRAINTS "${value}"; expected one of ${DEFERRED_CONSTRAINTS_MODES.join(', ')}`
13+
);
14+
}
15+
return mode;
16+
};
17+
618
/**
719
* Parse core PGPM environment variables.
820
* GraphQL-related env vars (GRAPHILE_*, FEATURES_*, API_*) are handled by @constructive-io/graphql-env.
@@ -16,6 +28,7 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions =>
1628
DB_PREFIX,
1729
DB_EXTENSIONS,
1830
DB_CWD,
31+
DB_DEFERRED_CONSTRAINTS,
1932
PGPM_EXTENSIONS_DIR,
2033
PGPM_ENGINE,
2134
DB_CONNECTION_USER,
@@ -90,6 +103,7 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions =>
90103
...(DB_PREFIX && { prefix: DB_PREFIX }),
91104
...(DB_EXTENSIONS && { extensions: DB_EXTENSIONS.split(',').map(ext => ext.trim()) }),
92105
...(DB_CWD && { cwd: DB_CWD }),
106+
...(DB_DEFERRED_CONSTRAINTS && { deferredConstraints: parseDeferredConstraintsMode(DB_DEFERRED_CONSTRAINTS) }),
93107
...((DB_CONNECTION_USER || DB_CONNECTION_PASSWORD || DB_CONNECTION_ROLE) && {
94108
connection: {
95109
...(DB_CONNECTION_USER && { user: DB_CONNECTION_USER }),

pgpm/types/src/pgpm.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,26 @@ export interface AuthOptions {
1616
userIdKey?: string;
1717
}
1818

19+
/**
20+
* How DEFERRABLE INITIALLY DEFERRED constraints are treated inside a test transaction.
21+
*
22+
* Test isolation rolls every test back instead of committing, so constraints that
23+
* Postgres only checks at COMMIT would otherwise never fire.
24+
*
25+
* - `'off'` leave them deferred and never checked (default)
26+
* - `'check'` run the commit-time checks at the end of each test, right before the
27+
* rollback (`SET CONSTRAINTS ALL IMMEDIATE`); deferral still works inside the test
28+
* - `'immediate'` `SET CONSTRAINTS ALL IMMEDIATE` at the start of each test so violations
29+
* fail on the offending statement; disables deferral the code under test may rely on
30+
*/
31+
export type DeferredConstraintsMode = 'off' | 'check' | 'immediate';
32+
1933
/**
2034
* Configuration options for PostgreSQL test database connections
2135
*/
2236
export interface PgTestConnectionOptions {
37+
/** How deferred constraints are handled under rollback-based test isolation (default: 'off') */
38+
deferredConstraints?: DeferredConstraintsMode;
2339
/** The root database to connect to for creating test databases */
2440
rootDb?: string;
2541
/** Template database to use when creating test databases */

postgres/pgsql-test/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ The `PgTestClient` returned by `getConnections()` wraps a `pg.Client` and provid
131131
* `beforeEach()` – Begins a transaction and sets a savepoint (called at the start of each test)
132132
* `afterEach()` – Rolls back to the savepoint and commits the outer transaction (cleans up test state)
133133
* `setContext({ key: value })` – Sets PostgreSQL config variables (like `role`) to simulate RLS contexts
134+
* `checkConstraints()` – Runs the commit-time checks for any pending deferred constraints now, without committing (see [Deferred constraints](#deferred-constraints-under-rollback-isolation))
134135
* `any`, `one`, `oneOrNone`, `many`, `manyOrNone`, `none`, `result` – Typed query helpers for specific result expectations
135136

136137
These methods make it easier to build expressive and isolated integration tests with strong typing and error handling.
@@ -601,6 +602,34 @@ This table documents the available options for the `getConnections` function. Th
601602
| `db.template` | `string` | `undefined` | Template database used for faster test DB creation |
602603
| `db.rootDb` | `string` | `'postgres'` | Root database used for administrative operations (e.g., creating databases) |
603604
| `db.prefix` | `string` | `'db-'` | Prefix used when generating test database names |
605+
| `db.deferredConstraints` | `'off' \| 'check' \| 'immediate'` | `'off'` | How `DEFERRABLE INITIALLY DEFERRED` constraints are handled under rollback isolation (env: `DB_DEFERRED_CONSTRAINTS`). See below. |
606+
607+
### Deferred constraints under rollback isolation
608+
609+
`beforeEach()`/`afterEach()` isolate tests by rolling back instead of committing. Anything Postgres only does **at COMMIT** therefore never happens inside a test. The one that bites is `DEFERRABLE INITIALLY DEFERRED` constraints (FK / UNIQUE / EXCLUDE / `CONSTRAINT TRIGGER`): a test can leave a dangling deferred foreign key and still pass, because the check that would have failed the commit is discarded with the rollback. This is inherent to every rollback-based test harness, not specific to pgsql-test.
610+
611+
`db.deferredConstraints` controls what to do about it:
612+
613+
| Mode | What happens | Trade-off |
614+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
615+
| `'off'` | Nothing (default, previous behaviour). | Deferred violations pass silently. |
616+
| `'check'` | `afterEach()` runs `SET CONSTRAINTS ALL IMMEDIATE` as the last statement before rolling back. Postgres defines this as running exactly the checks COMMIT would have run, so deferral still works *inside* the test and a pending violation fails the test. | The failure surfaces in `afterEach`, not on the offending line (the message names the constraint and table). If the test body already raised a Postgres error, the check is skipped so the original error is not masked. |
617+
| `'immediate'` | `beforeEach()` runs `SET CONSTRAINTS ALL IMMEDIATE`, so every deferred constraint is checked per statement and a violation fails on the exact line. | Changes semantics: code that legitimately relies on deferral (insert child before parent, swap two values under a deferred UNIQUE) fails in tests but works in production. |
618+
619+
```ts
620+
const { db, teardown } = await getConnections({ db: { deferredConstraints: 'check' } });
621+
```
622+
623+
A test that wants to *assert* that a deferred constraint is enforced can call `checkConstraints()` itself; once it has thrown, the pending events are consumed and `afterEach()` is clean:
624+
625+
```ts
626+
it('rejects orphan children at commit', async () => {
627+
await db.query(`INSERT INTO children (parent_id) VALUES (999)`);
628+
await expect(db.checkConstraints()).rejects.toThrow(/children_parent_id_fkey/);
629+
});
630+
```
631+
632+
Other commit-only effects — `NOTIFY` delivery and visibility to other sessions — are not covered by any mode; use `publish()` (which really commits) when a test needs them.
604633

605634
### `pg` Options (PgConfig)
606635

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
process.env.LOG_SCOPE = 'pgsql-test';
2+
3+
import { DeferredConstraintsMode } from '@pgpmjs/types';
4+
5+
import { getConnections } from '../src/connect';
6+
import { PgTestClient } from '../src/test-client';
7+
8+
const SCHEMA = `
9+
CREATE TABLE parents (
10+
id int PRIMARY KEY
11+
);
12+
CREATE TABLE children (
13+
id int PRIMARY KEY,
14+
parent_id int NOT NULL
15+
REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED
16+
);
17+
CREATE TABLE slots (
18+
id int PRIMARY KEY,
19+
position int NOT NULL,
20+
CONSTRAINT slots_position_key UNIQUE (position) DEFERRABLE INITIALLY DEFERRED
21+
);
22+
INSERT INTO slots (id, position) VALUES (1, 1), (2, 2);
23+
`;
24+
25+
const connect = async (deferredConstraints: DeferredConstraintsMode) => {
26+
const conn = await getConnections({ db: { deferredConstraints } }, []);
27+
await conn.pg.query(SCHEMA);
28+
return conn;
29+
};
30+
31+
const insertDanglingChild = (client: PgTestClient) =>
32+
client.query(`INSERT INTO children (id, parent_id) VALUES (1, 999)`);
33+
34+
const insertChildThenParent = async (client: PgTestClient) => {
35+
await client.query(`INSERT INTO children (id, parent_id) VALUES (1, 1)`);
36+
await client.query(`INSERT INTO parents (id) VALUES (1)`);
37+
};
38+
39+
const swapPositions = async (client: PgTestClient) => {
40+
await client.query(`UPDATE slots SET position = 2 WHERE id = 1`);
41+
await client.query(`UPDATE slots SET position = 1 WHERE id = 2`);
42+
};
43+
44+
let teardown: () => Promise<void>;
45+
let checkPg: PgTestClient;
46+
let immediatePg: PgTestClient;
47+
let offPg: PgTestClient;
48+
49+
beforeAll(async () => {
50+
({ pg: checkPg } = await connect('check'));
51+
({ pg: immediatePg } = await connect('immediate'));
52+
({ pg: offPg, teardown } = await connect('off'));
53+
});
54+
55+
afterAll(async () => {
56+
await teardown();
57+
});
58+
59+
describe("deferredConstraints: 'check'", () => {
60+
it('keeps deferral inside the test: child-before-parent and unique swap pass', async () => {
61+
await checkPg.beforeEach();
62+
await insertChildThenParent(checkPg);
63+
await swapPositions(checkPg);
64+
await expect(checkPg.afterEach()).resolves.toBeUndefined();
65+
});
66+
67+
it('fails afterEach for a dangling deferred FK, then rolls back so the client is reusable', async () => {
68+
await checkPg.beforeEach();
69+
await insertDanglingChild(checkPg);
70+
71+
await expect(checkPg.afterEach()).rejects.toThrow(
72+
/\[pgsql-test\] deferred constraint violated at end of test[\s\S]*children_parent_id_fkey/
73+
);
74+
75+
const res = await checkPg.query(`SELECT count(*)::int AS n FROM children`);
76+
expect(res.rows[0].n).toBe(0);
77+
});
78+
79+
it('does not mask an error the test body already raised (aborted transaction)', async () => {
80+
await checkPg.beforeEach();
81+
await insertDanglingChild(checkPg);
82+
await expect(checkPg.query(`SELECT 1/0`)).rejects.toThrow(/division by zero/);
83+
84+
await expect(checkPg.afterEach()).resolves.toBeUndefined();
85+
86+
const res = await checkPg.query(`SELECT 1 AS ok`);
87+
expect(res.rows[0].ok).toBe(1);
88+
});
89+
90+
it('checkConstraints() lets a test assert enforcement and leaves afterEach clean', async () => {
91+
await checkPg.beforeEach();
92+
await insertDanglingChild(checkPg);
93+
94+
await expect(checkPg.checkConstraints()).rejects.toThrow(/children_parent_id_fkey/);
95+
96+
await expect(checkPg.afterEach()).resolves.toBeUndefined();
97+
});
98+
99+
it('checkConstraints() is a no-op when nothing is pending', async () => {
100+
await checkPg.beforeEach();
101+
await insertChildThenParent(checkPg);
102+
await expect(checkPg.checkConstraints()).resolves.toBeUndefined();
103+
await expect(checkPg.afterEach()).resolves.toBeUndefined();
104+
});
105+
106+
it('survives publish(): violations after a publish are still caught', async () => {
107+
await checkPg.beforeEach();
108+
await checkPg.query(`INSERT INTO parents (id) VALUES (42)`);
109+
await checkPg.publish();
110+
await insertDanglingChild(checkPg);
111+
await expect(checkPg.afterEach()).rejects.toThrow(/children_parent_id_fkey/);
112+
await checkPg.query(`DELETE FROM parents WHERE id = 42`);
113+
});
114+
});
115+
116+
describe("deferredConstraints: 'immediate'", () => {
117+
afterEach(async () => {
118+
await immediatePg.afterEach();
119+
});
120+
121+
it('fails on the offending statement', async () => {
122+
await immediatePg.beforeEach();
123+
await expect(insertDanglingChild(immediatePg)).rejects.toThrow(/children_parent_id_fkey/);
124+
});
125+
126+
it('disables deferral: child-before-parent fails', async () => {
127+
await immediatePg.beforeEach();
128+
await expect(insertChildThenParent(immediatePg)).rejects.toThrow(/children_parent_id_fkey/);
129+
});
130+
131+
it('disables deferral: unique swap fails', async () => {
132+
await immediatePg.beforeEach();
133+
await expect(swapPositions(immediatePg)).rejects.toThrow(/slots_position_key/);
134+
});
135+
136+
it('is re-applied after publish()', async () => {
137+
await immediatePg.beforeEach();
138+
await immediatePg.publish();
139+
await expect(insertDanglingChild(immediatePg)).rejects.toThrow(/children_parent_id_fkey/);
140+
});
141+
});
142+
143+
describe("deferredConstraints: 'off' (default)", () => {
144+
it('a dangling deferred FK passes silently and is rolled back', async () => {
145+
await offPg.beforeEach();
146+
await insertDanglingChild(offPg);
147+
await expect(offPg.afterEach()).resolves.toBeUndefined();
148+
149+
const res = await offPg.query(`SELECT count(*)::int AS n FROM children`);
150+
expect(res.rows[0].n).toBe(0);
151+
});
152+
153+
it('is the default when no mode is given', async () => {
154+
const { pg } = await getConnections({}, []);
155+
await pg.query(SCHEMA);
156+
await pg.beforeEach();
157+
await insertDanglingChild(pg);
158+
await expect(pg.afterEach()).resolves.toBeUndefined();
159+
});
160+
});

postgres/pgsql-test/src/connect.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ export const getConnections = async (
9090
await admin.grantConnect(connOpts.connections!.app!.user!, config.database);
9191

9292
manager = PgTestConnector.getInstance(config);
93-
const pg = manager.getClient(config);
93+
const pg = manager.getClient(config, {
94+
deferredConstraints: connOpts.deferredConstraints
95+
});
9496

9597
let teardownPromise: Promise<void> | null = null;
9698
let teardownOpts: TeardownOptions = {};
@@ -135,7 +137,8 @@ export const getConnections = async (
135137

136138
const db = manager.getClient(dbConfig, {
137139
auth: connOpts.auth,
138-
roles: connOpts.roles
140+
roles: connOpts.roles,
141+
deferredConstraints: connOpts.deferredConstraints
139142
});
140143
db.setContext({ role: getDefaultRole(connOpts) });
141144

postgres/pgsql-test/src/test-client.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DeferredConstraintsMode } from '@pgpmjs/types';
12
import { QueryResult } from 'pg';
23
import { PgConfig } from 'pg-env';
34
import { PgClient, PgClientOpts } from 'pgsql-client';
@@ -15,8 +16,15 @@ export type PgTestClientOpts = PgClientOpts & {
1516
* Can be disabled by setting enhancedErrors: false.
1617
*/
1718
enhancedErrors?: boolean;
19+
/**
20+
* How DEFERRABLE INITIALLY DEFERRED constraints are handled under rollback isolation.
21+
* Defaults to 'off'. See {@link DeferredConstraintsMode}.
22+
*/
23+
deferredConstraints?: DeferredConstraintsMode;
1824
};
1925

26+
const IN_FAILED_SQL_TRANSACTION = '25P02';
27+
2028
export class PgTestClient extends PgClient {
2129
protected testOpts: PgTestClientOpts;
2230

@@ -50,14 +58,51 @@ export class PgTestClient extends PgClient {
5058
}
5159
}
5260

61+
private get deferredConstraintsMode(): DeferredConstraintsMode {
62+
return this.testOpts.deferredConstraints ?? 'off';
63+
}
64+
5365
async beforeEach(): Promise<void> {
5466
await this.begin();
5567
await this.savepoint();
68+
if (this.deferredConstraintsMode === 'immediate') {
69+
await this.setConstraintsImmediate();
70+
}
5671
}
5772

5873
async afterEach(): Promise<void> {
74+
let violation: unknown;
75+
if (this.deferredConstraintsMode === 'check') {
76+
try {
77+
await this.checkConstraints();
78+
} catch (err: any) {
79+
if (err?.code !== IN_FAILED_SQL_TRANSACTION) violation = err;
80+
}
81+
}
5982
await this.rollback();
6083
await this.commit();
84+
if (violation) throw violation;
85+
}
86+
87+
/**
88+
* Run the commit-time checks for every pending deferred constraint now, without committing.
89+
* Postgres checks all outstanding deferred constraint events when a constraint switches from
90+
* DEFERRED to IMMEDIATE, so this fails exactly where a real COMMIT would have failed.
91+
* Once it passes (or throws), the pending events are consumed.
92+
*/
93+
async checkConstraints(): Promise<void> {
94+
try {
95+
await this.setConstraintsImmediate();
96+
} catch (err: any) {
97+
if (err?.code !== IN_FAILED_SQL_TRANSACTION) {
98+
err.message = `[pgsql-test] deferred constraint violated at end of test (a real COMMIT would have failed here):\n${err.message}`;
99+
}
100+
throw err;
101+
}
102+
}
103+
104+
private async setConstraintsImmediate(): Promise<void> {
105+
await this.query('SET CONSTRAINTS ALL IMMEDIATE');
61106
}
62107

63108
/**
@@ -68,6 +113,9 @@ export class PgTestClient extends PgClient {
68113
await this.commit(); // make data visible to other sessions
69114
await this.begin(); // fresh tx
70115
await this.savepoint(); // keep rollback harness
116+
if (this.deferredConstraintsMode === 'immediate') {
117+
await this.setConstraintsImmediate();
118+
}
71119
await this.ctxQuery(); // reapply all setContext()
72120
}
73121

0 commit comments

Comments
 (0)