Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion pgpm/env/src/env.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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 }),
Expand Down
16 changes: 16 additions & 0 deletions pgpm/types/src/pgpm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
29 changes: 29 additions & 0 deletions postgres/pgsql-test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;
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();
});
});
7 changes: 5 additions & 2 deletions postgres/pgsql-test/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null = null;
let teardownOpts: TeardownOptions = {};
Expand Down Expand Up @@ -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) });

Expand Down
48 changes: 48 additions & 0 deletions postgres/pgsql-test/src/test-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DeferredConstraintsMode } from '@pgpmjs/types';
import { QueryResult } from 'pg';
import { PgConfig } from 'pg-env';
import { PgClient, PgClientOpts } from 'pgsql-client';
Expand All @@ -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;

Expand Down Expand Up @@ -50,14 +58,51 @@ export class PgTestClient extends PgClient {
}
}

private get deferredConstraintsMode(): DeferredConstraintsMode {
return this.testOpts.deferredConstraints ?? 'off';
}

async beforeEach(): Promise<void> {
await this.begin();
await this.savepoint();
if (this.deferredConstraintsMode === 'immediate') {
await this.setConstraintsImmediate();
}
}

async afterEach(): Promise<void> {
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<void> {
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<void> {
await this.query('SET CONSTRAINTS ALL IMMEDIATE');
}

/**
Expand All @@ -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()
}

Expand Down
Loading