From 2ebafde4974a632bac347fa233a23de8f4900d69 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 23 Sep 2026 13:01:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(pgsql-test):=20deferredConstraints=20optio?= =?UTF-8?q?n=20=E2=80=94=20run=20commit-time=20deferred=20constraint=20che?= =?UTF-8?q?cks=20under=20rollback=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pgpm/env/src/env.ts | 16 +- pgpm/types/src/pgpm.ts | 16 ++ postgres/pgsql-test/README.md | 29 ++++ ...postgres-test.deferred-constraints.test.ts | 160 ++++++++++++++++++ postgres/pgsql-test/src/connect.ts | 7 +- postgres/pgsql-test/src/test-client.ts | 48 ++++++ 6 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 postgres/pgsql-test/__tests__/postgres-test.deferred-constraints.test.ts diff --git a/pgpm/env/src/env.ts b/pgpm/env/src/env.ts index 340216e6f6..5ec9b056a9 100644 --- a/pgpm/env/src/env.ts +++ b/pgpm/env/src/env.ts @@ -1,8 +1,20 @@ -import { BucketProvider,PgpmOptions } from '@pgpmjs/types'; +import { BucketProvider, DeferredConstraintsMode, PgpmOptions } from '@pgpmjs/types'; import { parseEnvBoolean, parseEnvList, parseEnvNumber } from '12factor-env'; export { parseEnvBoolean, parseEnvList, parseEnvNumber }; +const DEFERRED_CONSTRAINTS_MODES: DeferredConstraintsMode[] = ['off', 'check', 'immediate']; + +const parseDeferredConstraintsMode = (value: string): DeferredConstraintsMode => { + const mode = value.trim().toLowerCase() as DeferredConstraintsMode; + if (!DEFERRED_CONSTRAINTS_MODES.includes(mode)) { + throw new Error( + `Invalid DB_DEFERRED_CONSTRAINTS "${value}"; expected one of ${DEFERRED_CONSTRAINTS_MODES.join(', ')}` + ); + } + return mode; +}; + /** * Parse core PGPM environment variables. * 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 => DB_PREFIX, DB_EXTENSIONS, DB_CWD, + DB_DEFERRED_CONSTRAINTS, PGPM_EXTENSIONS_DIR, PGPM_ENGINE, DB_CONNECTION_USER, @@ -90,6 +103,7 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => ...(DB_PREFIX && { prefix: DB_PREFIX }), ...(DB_EXTENSIONS && { extensions: DB_EXTENSIONS.split(',').map(ext => ext.trim()) }), ...(DB_CWD && { cwd: DB_CWD }), + ...(DB_DEFERRED_CONSTRAINTS && { deferredConstraints: parseDeferredConstraintsMode(DB_DEFERRED_CONSTRAINTS) }), ...((DB_CONNECTION_USER || DB_CONNECTION_PASSWORD || DB_CONNECTION_ROLE) && { connection: { ...(DB_CONNECTION_USER && { user: DB_CONNECTION_USER }), diff --git a/pgpm/types/src/pgpm.ts b/pgpm/types/src/pgpm.ts index 12dce4b7b3..194c9e6e09 100644 --- a/pgpm/types/src/pgpm.ts +++ b/pgpm/types/src/pgpm.ts @@ -16,10 +16,26 @@ export interface AuthOptions { userIdKey?: string; } +/** + * How DEFERRABLE INITIALLY DEFERRED constraints are treated inside a test transaction. + * + * Test isolation rolls every test back instead of committing, so constraints that + * Postgres only checks at COMMIT would otherwise never fire. + * + * - `'off'` leave them deferred and never checked (default) + * - `'check'` run the commit-time checks at the end of each test, right before the + * rollback (`SET CONSTRAINTS ALL IMMEDIATE`); deferral still works inside the test + * - `'immediate'` `SET CONSTRAINTS ALL IMMEDIATE` at the start of each test so violations + * fail on the offending statement; disables deferral the code under test may rely on + */ +export type DeferredConstraintsMode = 'off' | 'check' | 'immediate'; + /** * Configuration options for PostgreSQL test database connections */ export interface PgTestConnectionOptions { + /** How deferred constraints are handled under rollback-based test isolation (default: 'off') */ + deferredConstraints?: DeferredConstraintsMode; /** The root database to connect to for creating test databases */ rootDb?: string; /** Template database to use when creating test databases */ diff --git a/postgres/pgsql-test/README.md b/postgres/pgsql-test/README.md index 4252c96780..e95513a090 100644 --- a/postgres/pgsql-test/README.md +++ b/postgres/pgsql-test/README.md @@ -131,6 +131,7 @@ The `PgTestClient` returned by `getConnections()` wraps a `pg.Client` and provid * `beforeEach()` – Begins a transaction and sets a savepoint (called at the start of each test) * `afterEach()` – Rolls back to the savepoint and commits the outer transaction (cleans up test state) * `setContext({ key: value })` – Sets PostgreSQL config variables (like `role`) to simulate RLS contexts +* `checkConstraints()` – Runs the commit-time checks for any pending deferred constraints now, without committing (see [Deferred constraints](#deferred-constraints-under-rollback-isolation)) * `any`, `one`, `oneOrNone`, `many`, `manyOrNone`, `none`, `result` – Typed query helpers for specific result expectations 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 | `db.template` | `string` | `undefined` | Template database used for faster test DB creation | | `db.rootDb` | `string` | `'postgres'` | Root database used for administrative operations (e.g., creating databases) | | `db.prefix` | `string` | `'db-'` | Prefix used when generating test database names | +| `db.deferredConstraints` | `'off' \| 'check' \| 'immediate'` | `'off'` | How `DEFERRABLE INITIALLY DEFERRED` constraints are handled under rollback isolation (env: `DB_DEFERRED_CONSTRAINTS`). See below. | + +### Deferred constraints under rollback isolation + +`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. + +`db.deferredConstraints` controls what to do about it: + +| Mode | What happens | Trade-off | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `'off'` | Nothing (default, previous behaviour). | Deferred violations pass silently. | +| `'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. | +| `'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. | + +```ts +const { db, teardown } = await getConnections({ db: { deferredConstraints: 'check' } }); +``` + +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: + +```ts +it('rejects orphan children at commit', async () => { + await db.query(`INSERT INTO children (parent_id) VALUES (999)`); + await expect(db.checkConstraints()).rejects.toThrow(/children_parent_id_fkey/); +}); +``` + +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. ### `pg` Options (PgConfig) diff --git a/postgres/pgsql-test/__tests__/postgres-test.deferred-constraints.test.ts b/postgres/pgsql-test/__tests__/postgres-test.deferred-constraints.test.ts new file mode 100644 index 0000000000..4514109e23 --- /dev/null +++ b/postgres/pgsql-test/__tests__/postgres-test.deferred-constraints.test.ts @@ -0,0 +1,160 @@ +process.env.LOG_SCOPE = 'pgsql-test'; + +import { DeferredConstraintsMode } from '@pgpmjs/types'; + +import { getConnections } from '../src/connect'; +import { PgTestClient } from '../src/test-client'; + +const SCHEMA = ` + CREATE TABLE parents ( + id int PRIMARY KEY + ); + CREATE TABLE children ( + id int PRIMARY KEY, + parent_id int NOT NULL + REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED + ); + CREATE TABLE slots ( + id int PRIMARY KEY, + position int NOT NULL, + CONSTRAINT slots_position_key UNIQUE (position) DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO slots (id, position) VALUES (1, 1), (2, 2); +`; + +const connect = async (deferredConstraints: DeferredConstraintsMode) => { + const conn = await getConnections({ db: { deferredConstraints } }, []); + await conn.pg.query(SCHEMA); + return conn; +}; + +const insertDanglingChild = (client: PgTestClient) => + client.query(`INSERT INTO children (id, parent_id) VALUES (1, 999)`); + +const insertChildThenParent = async (client: PgTestClient) => { + await client.query(`INSERT INTO children (id, parent_id) VALUES (1, 1)`); + await client.query(`INSERT INTO parents (id) VALUES (1)`); +}; + +const swapPositions = async (client: PgTestClient) => { + await client.query(`UPDATE slots SET position = 2 WHERE id = 1`); + await client.query(`UPDATE slots SET position = 1 WHERE id = 2`); +}; + +let teardown: () => Promise; +let checkPg: PgTestClient; +let immediatePg: PgTestClient; +let offPg: PgTestClient; + +beforeAll(async () => { + ({ pg: checkPg } = await connect('check')); + ({ pg: immediatePg } = await connect('immediate')); + ({ pg: offPg, teardown } = await connect('off')); +}); + +afterAll(async () => { + await teardown(); +}); + +describe("deferredConstraints: 'check'", () => { + it('keeps deferral inside the test: child-before-parent and unique swap pass', async () => { + await checkPg.beforeEach(); + await insertChildThenParent(checkPg); + await swapPositions(checkPg); + await expect(checkPg.afterEach()).resolves.toBeUndefined(); + }); + + it('fails afterEach for a dangling deferred FK, then rolls back so the client is reusable', async () => { + await checkPg.beforeEach(); + await insertDanglingChild(checkPg); + + await expect(checkPg.afterEach()).rejects.toThrow( + /\[pgsql-test\] deferred constraint violated at end of test[\s\S]*children_parent_id_fkey/ + ); + + const res = await checkPg.query(`SELECT count(*)::int AS n FROM children`); + expect(res.rows[0].n).toBe(0); + }); + + it('does not mask an error the test body already raised (aborted transaction)', async () => { + await checkPg.beforeEach(); + await insertDanglingChild(checkPg); + await expect(checkPg.query(`SELECT 1/0`)).rejects.toThrow(/division by zero/); + + await expect(checkPg.afterEach()).resolves.toBeUndefined(); + + const res = await checkPg.query(`SELECT 1 AS ok`); + expect(res.rows[0].ok).toBe(1); + }); + + it('checkConstraints() lets a test assert enforcement and leaves afterEach clean', async () => { + await checkPg.beforeEach(); + await insertDanglingChild(checkPg); + + await expect(checkPg.checkConstraints()).rejects.toThrow(/children_parent_id_fkey/); + + await expect(checkPg.afterEach()).resolves.toBeUndefined(); + }); + + it('checkConstraints() is a no-op when nothing is pending', async () => { + await checkPg.beforeEach(); + await insertChildThenParent(checkPg); + await expect(checkPg.checkConstraints()).resolves.toBeUndefined(); + await expect(checkPg.afterEach()).resolves.toBeUndefined(); + }); + + it('survives publish(): violations after a publish are still caught', async () => { + await checkPg.beforeEach(); + await checkPg.query(`INSERT INTO parents (id) VALUES (42)`); + await checkPg.publish(); + await insertDanglingChild(checkPg); + await expect(checkPg.afterEach()).rejects.toThrow(/children_parent_id_fkey/); + await checkPg.query(`DELETE FROM parents WHERE id = 42`); + }); +}); + +describe("deferredConstraints: 'immediate'", () => { + afterEach(async () => { + await immediatePg.afterEach(); + }); + + it('fails on the offending statement', async () => { + await immediatePg.beforeEach(); + await expect(insertDanglingChild(immediatePg)).rejects.toThrow(/children_parent_id_fkey/); + }); + + it('disables deferral: child-before-parent fails', async () => { + await immediatePg.beforeEach(); + await expect(insertChildThenParent(immediatePg)).rejects.toThrow(/children_parent_id_fkey/); + }); + + it('disables deferral: unique swap fails', async () => { + await immediatePg.beforeEach(); + await expect(swapPositions(immediatePg)).rejects.toThrow(/slots_position_key/); + }); + + it('is re-applied after publish()', async () => { + await immediatePg.beforeEach(); + await immediatePg.publish(); + await expect(insertDanglingChild(immediatePg)).rejects.toThrow(/children_parent_id_fkey/); + }); +}); + +describe("deferredConstraints: 'off' (default)", () => { + it('a dangling deferred FK passes silently and is rolled back', async () => { + await offPg.beforeEach(); + await insertDanglingChild(offPg); + await expect(offPg.afterEach()).resolves.toBeUndefined(); + + const res = await offPg.query(`SELECT count(*)::int AS n FROM children`); + expect(res.rows[0].n).toBe(0); + }); + + it('is the default when no mode is given', async () => { + const { pg } = await getConnections({}, []); + await pg.query(SCHEMA); + await pg.beforeEach(); + await insertDanglingChild(pg); + await expect(pg.afterEach()).resolves.toBeUndefined(); + }); +}); diff --git a/postgres/pgsql-test/src/connect.ts b/postgres/pgsql-test/src/connect.ts index 6ec7ae4d03..661ef3d507 100644 --- a/postgres/pgsql-test/src/connect.ts +++ b/postgres/pgsql-test/src/connect.ts @@ -90,7 +90,9 @@ export const getConnections = async ( await admin.grantConnect(connOpts.connections!.app!.user!, config.database); manager = PgTestConnector.getInstance(config); - const pg = manager.getClient(config); + const pg = manager.getClient(config, { + deferredConstraints: connOpts.deferredConstraints + }); let teardownPromise: Promise | null = null; let teardownOpts: TeardownOptions = {}; @@ -135,7 +137,8 @@ export const getConnections = async ( const db = manager.getClient(dbConfig, { auth: connOpts.auth, - roles: connOpts.roles + roles: connOpts.roles, + deferredConstraints: connOpts.deferredConstraints }); db.setContext({ role: getDefaultRole(connOpts) }); diff --git a/postgres/pgsql-test/src/test-client.ts b/postgres/pgsql-test/src/test-client.ts index fd91867072..47d730c7b0 100644 --- a/postgres/pgsql-test/src/test-client.ts +++ b/postgres/pgsql-test/src/test-client.ts @@ -1,3 +1,4 @@ +import { DeferredConstraintsMode } from '@pgpmjs/types'; import { QueryResult } from 'pg'; import { PgConfig } from 'pg-env'; import { PgClient, PgClientOpts } from 'pgsql-client'; @@ -15,8 +16,15 @@ export type PgTestClientOpts = PgClientOpts & { * Can be disabled by setting enhancedErrors: false. */ enhancedErrors?: boolean; + /** + * How DEFERRABLE INITIALLY DEFERRED constraints are handled under rollback isolation. + * Defaults to 'off'. See {@link DeferredConstraintsMode}. + */ + deferredConstraints?: DeferredConstraintsMode; }; +const IN_FAILED_SQL_TRANSACTION = '25P02'; + export class PgTestClient extends PgClient { protected testOpts: PgTestClientOpts; @@ -50,14 +58,51 @@ export class PgTestClient extends PgClient { } } + private get deferredConstraintsMode(): DeferredConstraintsMode { + return this.testOpts.deferredConstraints ?? 'off'; + } + async beforeEach(): Promise { await this.begin(); await this.savepoint(); + if (this.deferredConstraintsMode === 'immediate') { + await this.setConstraintsImmediate(); + } } async afterEach(): Promise { + let violation: unknown; + if (this.deferredConstraintsMode === 'check') { + try { + await this.checkConstraints(); + } catch (err: any) { + if (err?.code !== IN_FAILED_SQL_TRANSACTION) violation = err; + } + } await this.rollback(); await this.commit(); + if (violation) throw violation; + } + + /** + * Run the commit-time checks for every pending deferred constraint now, without committing. + * Postgres checks all outstanding deferred constraint events when a constraint switches from + * DEFERRED to IMMEDIATE, so this fails exactly where a real COMMIT would have failed. + * Once it passes (or throws), the pending events are consumed. + */ + async checkConstraints(): Promise { + try { + await this.setConstraintsImmediate(); + } catch (err: any) { + if (err?.code !== IN_FAILED_SQL_TRANSACTION) { + err.message = `[pgsql-test] deferred constraint violated at end of test (a real COMMIT would have failed here):\n${err.message}`; + } + throw err; + } + } + + private async setConstraintsImmediate(): Promise { + await this.query('SET CONSTRAINTS ALL IMMEDIATE'); } /** @@ -68,6 +113,9 @@ export class PgTestClient extends PgClient { await this.commit(); // make data visible to other sessions await this.begin(); // fresh tx await this.savepoint(); // keep rollback harness + if (this.deferredConstraintsMode === 'immediate') { + await this.setConstraintsImmediate(); + } await this.ctxQuery(); // reapply all setContext() }