From 9fbfae7fbbc342adc24a36c882202f65fda489fa Mon Sep 17 00:00:00 2001 From: Nikola Milovic Date: Sun, 23 Mar 2025 15:13:55 +0100 Subject: [PATCH 1/7] Feat: add snapshot feature to postgres module Added snapshot and restore feature to postgres module, followed by docs and appropriate test cases. --- docs/modules/postgresql.md | 15 + .../src/postgresql-container.test.ts | 429 +++++++++++++----- .../postgresql/src/postgresql-container.ts | 257 +++++++---- 3 files changed, 512 insertions(+), 189 deletions(-) diff --git a/docs/modules/postgresql.md b/docs/modules/postgresql.md index ad9274588..ebd8ae14a 100644 --- a/docs/modules/postgresql.md +++ b/docs/modules/postgresql.md @@ -25,3 +25,18 @@ npm install @testcontainers/postgresql --save-dev [Set username:](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:setUsername + +### Using Snapshots + +This example shows the usage of the postgres module's Snapshot feature to give each test a clean database without having +to recreate the database container on every test or run heavy scripts to clean your database. This makes the individual +tests very modular, since they always run on a brand-new database. + +!!!tip + You should never pass the `"postgres"` system database as the container database name if you want to use snapshots. + The Snapshot logic requires dropping the connected database and using the system database to run commands, which will + not work if the database for the container is set to `"postgres"`. + + +[Test with a reusable Postgres container](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:createAndRestoreFromSnapshot + \ No newline at end of file diff --git a/packages/modules/postgresql/src/postgresql-container.test.ts b/packages/modules/postgresql/src/postgresql-container.test.ts index 9dbffaf02..05c1205ac 100644 --- a/packages/modules/postgresql/src/postgresql-container.test.ts +++ b/packages/modules/postgresql/src/postgresql-container.test.ts @@ -2,114 +2,323 @@ import { Client } from "pg"; import { PostgreSqlContainer } from "./postgresql-container"; describe("PostgreSqlContainer", { timeout: 180_000 }, () => { - // connect { - it("should connect and return a query result", async () => { - const container = await new PostgreSqlContainer().start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); - // } - - // uriConnect { - it("should work with database URI", async () => { - const container = await new PostgreSqlContainer().start(); - - const client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); - // } - - // setDatabase { - it("should set database", async () => { - const container = await new PostgreSqlContainer().withDatabase("customDatabase").start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT current_database()"); - expect(result.rows[0]).toEqual({ current_database: "customDatabase" }); - - await client.end(); - await container.stop(); - }); - // } - - // setUsername { - it("should set username", async () => { - const container = await new PostgreSqlContainer().withUsername("customUsername").start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT current_user"); - expect(result.rows[0]).toEqual({ current_user: "customUsername" }); - - await client.end(); - await container.stop(); - }); - // } - - it("should work with restarted container", async () => { - const container = await new PostgreSqlContainer().start(); - await container.restart(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); - - it("should allow custom healthcheck", async () => { - const container = new PostgreSqlContainer().withHealthCheck({ - test: ["CMD-SHELL", "exit 1"], - interval: 100, - retries: 0, - timeout: 0, - }); - - await expect(() => container.start()).rejects.toThrow(); - }); + // connect { + it("should connect and return a query result", async () => { + const container = await new PostgreSqlContainer().start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + // } + + // uriConnect { + it("should work with database URI", async () => { + const container = await new PostgreSqlContainer().start(); + + const client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + // } + + // setDatabase { + it("should set database", async () => { + const container = await new PostgreSqlContainer().withDatabase("customDatabase").start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT current_database()"); + expect(result.rows[0]).toEqual({ current_database: "customDatabase" }); + + await client.end(); + await container.stop(); + }); + // } + + // setUsername { + it("should set username", async () => { + const container = await new PostgreSqlContainer().withUsername("customUsername").start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT current_user"); + expect(result.rows[0]).toEqual({ current_user: "customUsername" }); + + await client.end(); + await container.stop(); + }); + // } + + it("should work with restarted container", async () => { + const container = await new PostgreSqlContainer().start(); + await container.restart(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + + it("should allow custom healthcheck", async () => { + const container = new PostgreSqlContainer().withHealthCheck({ + test: ["CMD-SHELL", "exit 1"], + interval: 100, + retries: 0, + timeout: 0, + }); + + await expect(() => container.start()).rejects.toThrow(); + }); +}); + +describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => { + // createAndRestoreFromSnapshot { + it("should create and restore from snapshot", async () => { + const container = await new PostgreSqlContainer().start(); + + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create some test data + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); + + // Close connection before snapshot (otherwise we'll get an error because user is already connected) + await client.end(); + + // Take a snapshot + await container.snapshot(); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Modify the database + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); + + // Verify both records exist + let result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].name).toEqual("initial data"); + expect(result.rows[1].name).toEqual("data after snapshot"); + + // Close connection before restore (same reason as above) + await client.end(); + + // Restore to the snapshot + await container.restore(); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify only the initial data exists after restore + result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("initial data"); + + await client.end(); + await container.stop(); + }); + // } + + it("should use custom snapshot name", async () => { + const container = await new PostgreSqlContainer().start(); + const customSnapshotName = "my_custom_snapshot"; + + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create a test table and insert data + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); + + // Close connection before snapshot + await client.end(); + + // Take a snapshot with custom name + await container.snapshot(customSnapshotName); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Modify the database + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); + + // Close connection before restore + await client.end(); + + // Restore using the custom snapshot name + await container.restore(customSnapshotName); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify only the initial data exists after restore + const result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("initial data"); + + await client.end(); + await container.stop(); + }); + + it("should handle multiple snapshots", async () => { + const container = await new PostgreSqlContainer().start(); + + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create a test table + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + + // Close connection before snapshot + await client.end(); + + // Take first snapshot with empty table + await container.snapshot("snapshot1"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Add first record + await client.query("INSERT INTO test_table (name) VALUES ('data for snapshot 2')"); + + // Close connection before snapshot + await client.end(); + + // Take second snapshot with one record + await container.snapshot("snapshot2"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Add second record + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshots')"); + + // Verify we have two records + let result = await client.query("SELECT COUNT(*) as count FROM test_table"); + expect(result.rows[0].count).toEqual("2"); + + // Close connection before restore + await client.end(); + + // Restore to first snapshot (empty table) + await container.restore("snapshot1"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify table is empty + result = await client.query("SELECT COUNT(*) as count FROM test_table"); + expect(result.rows[0].count).toEqual("0"); + + // Close connection before restore + await client.end(); + + // Restore to second snapshot (one record) + await container.restore("snapshot2"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify we have one record + result = await client.query("SELECT * FROM test_table"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("data for snapshot 2"); + + await client.end(); + await container.stop(); + }); + + it("should throw an error when trying to snapshot postgres system database", async () => { + const container = await new PostgreSqlContainer() + .withDatabase("postgres") + .start(); + + await expect(container.snapshot()).rejects.toThrow( + "cannot restore the postgres system database as it cannot be dropped to be restored" + ); + + await expect(container.restore()).rejects.toThrow( + "cannot restore the postgres system database as it cannot be dropped to be restored" + ); + + await container.stop(); + }); + }); diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index 3de192482..30ee0da0d 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -3,86 +3,185 @@ import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait const POSTGRES_PORT = 5432; export class PostgreSqlContainer extends GenericContainer { - private database = "test"; - private username = "test"; - private password = "test"; - - constructor(image = "postgres:13.3-alpine") { - super(image); - this.withExposedPorts(POSTGRES_PORT); - this.withWaitStrategy(Wait.forHealthCheck()); - this.withStartupTimeout(120_000); - } - - public withDatabase(database: string): this { - this.database = database; - return this; - } - - public withUsername(username: string): this { - this.username = username; - return this; - } - - public withPassword(password: string): this { - this.password = password; - return this; - } - - public override async start(): Promise { - this.withEnvironment({ - POSTGRES_DB: this.database, - POSTGRES_USER: this.username, - POSTGRES_PASSWORD: this.password, - }); - if (!this.healthCheck) { - this.withHealthCheck({ - test: ["CMD-SHELL", `PGPASSWORD=${this.password} psql -U ${this.username} -d ${this.database} -c 'SELECT 1;'`], - interval: 250, - timeout: 1000, - retries: 1000, - }); - } - return new StartedPostgreSqlContainer(await super.start(), this.database, this.username, this.password); - } + private database = "test"; + private username = "test"; + private password = "test"; + + constructor(image = "postgres:13.3-alpine") { + super(image); + this.withExposedPorts(POSTGRES_PORT); + this.withWaitStrategy(Wait.forHealthCheck()); + this.withStartupTimeout(120_000); + } + + public withDatabase(database: string): this { + this.database = database; + return this; + } + + public withUsername(username: string): this { + this.username = username; + return this; + } + + public withPassword(password: string): this { + this.password = password; + return this; + } + + public override async start(): Promise { + this.withEnvironment({ + POSTGRES_DB: this.database, + POSTGRES_USER: this.username, + POSTGRES_PASSWORD: this.password, + }); + if (!this.healthCheck) { + this.withHealthCheck({ + test: ["CMD-SHELL", `PGPASSWORD=${this.password} psql -U ${this.username} -d ${this.database} -c 'SELECT 1;'`], + interval: 250, + timeout: 1000, + retries: 1000, + }); + } + return new StartedPostgreSqlContainer(await super.start(), this.database, this.username, this.password); + } } export class StartedPostgreSqlContainer extends AbstractStartedContainer { - constructor( - startedTestContainer: StartedTestContainer, - private readonly database: string, - private readonly username: string, - private readonly password: string - ) { - super(startedTestContainer); - } - - public getPort(): number { - return super.getMappedPort(POSTGRES_PORT); - } - - public getDatabase(): string { - return this.database; - } - - public getUsername(): string { - return this.username; - } - - public getPassword(): string { - return this.password; - } - - /** - * @returns A connection URI in the form of `postgres[ql]://[username[:password]@][host[:port],]/database` - */ - public getConnectionUri(): string { - const url = new URL("", "postgres://"); - url.hostname = this.getHost(); - url.port = this.getPort().toString(); - url.pathname = this.getDatabase(); - url.username = this.getUsername(); - url.password = this.getPassword(); - return url.toString(); - } + private snapshotName: string = "migrated_template"; + constructor( + startedTestContainer: StartedTestContainer, + private readonly database: string, + private readonly username: string, + private readonly password: string, + ) { + super(startedTestContainer); + } + + public getPort(): number { + return super.getMappedPort(POSTGRES_PORT); + } + + public getDatabase(): string { + return this.database; + } + + public getUsername(): string { + return this.username; + } + + public getPassword(): string { + return this.password; + } + + /** + * @returns A connection URI in the form of `postgres[ql]://[username[:password]@][host[:port],]/database` + */ + public getConnectionUri(): string { + const url = new URL("", "postgres://"); + url.hostname = this.getHost(); + url.port = this.getPort().toString(); + url.pathname = this.getDatabase(); + url.username = this.getUsername(); + url.password = this.getPassword(); + return url.toString(); + } + + + public withSnapshotName(snapshotName: string): this { + this.snapshotName = snapshotName; + return this; + } + + + /** + * Takes a snapshot of the current state of the database as a template, which can then be restored using + * the restore method. By default, the snapshot will be created under a database called migrated_template. + * + * If a snapshot already exists under the given/default name, it will be overwritten with the new snapshot. + * + * @param opts Optional snapshot configuration options + * @returns Promise resolving when snapshot is complete + */ + public async snapshot(snapshotName?: string): Promise { + const name = snapshotName || this.snapshotName; + + this.snapshotSanityCheck(name); + + // Execute the commands to create the snapshot, in order + await this.execCommandsSQL([ + // Update pg_database to remove the template flag, then drop the database if it exists. + // This is needed because dropping a template database will fail. + `UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${name}'`, + `DROP DATABASE IF EXISTS "${name}"`, + // Create a copy of the database to another database to use as a template now that it was fully migrated + `CREATE DATABASE "${name}" WITH TEMPLATE "${this.getDatabase()}" OWNER "${this.getUsername()}"`, + // Snapshot the template database so we can restore it onto our original database going forward + `ALTER DATABASE "${name}" WITH is_template = TRUE`, + ]); + } + + /** + * Restores the database to a specific snapshot. By default, it will restore the last snapshot taken on the + * database by the snapshot method. If a snapshot name is provided, it will instead try to restore the snapshot by name. + * + * @param opts Optional snapshot configuration options + * @returns Promise resolving when restore is complete + */ + public async restore(snapshotName?: string): Promise { + const name = snapshotName || this.snapshotName; + + this.snapshotSanityCheck(name); + + // Execute the commands to restore the snapshot, in order + await this.execCommandsSQL([ + // Drop the entire database by connecting to the postgres global database + `DROP DATABASE "${this.getDatabase()}" WITH (FORCE)`, + // Then restore the previous snapshot + `CREATE DATABASE "${this.getDatabase()}" WITH TEMPLATE "${name}" OWNER "${this.getUsername()}"`, + ]); + } + + /** + * Executes a series of SQL commands against the Postgres database + * @param commands SQL commands to execute + */ + private async execCommandsSQL(commands: string[]): Promise { + for (const command of commands) { + try { + const result = await this.exec([ + 'psql', + '-v', + 'ON_ERROR_STOP=1', + '-U', + this.getUsername(), + '-d', + 'postgres', + '-c', + command, + ]); + + if (result.exitCode !== 0) { + throw new Error(`Command failed with exit code ${result.exitCode}: ${result.output}`); + } + } catch (error) { + console.error(`Failed to execute command: ${command}`, error); + throw error; + } + } + } + + /** + * Checks if the snapshot name is valid and if the database is not the postgres system database + * @param snapshotName The name of the snapshot to check + */ + private snapshotSanityCheck(snapshotName: string): void { + if (this.getDatabase() === "postgres") { + throw new Error("cannot restore the postgres system database as it cannot be dropped to be restored"); + } + + if (this.getDatabase() === snapshotName) { + throw new Error("cannot restore the database to itself"); + } + } } From 4bc17444ffdbacb054284794a2fa465e35758e2a Mon Sep 17 00:00:00 2001 From: Nikola Milovic Date: Tue, 25 Mar 2025 07:25:55 +0100 Subject: [PATCH 2/7] Format: run prettier --- .../src/postgresql-container.test.ts | 591 +++++++++--------- .../postgresql/src/postgresql-container.ts | 354 ++++++----- 2 files changed, 470 insertions(+), 475 deletions(-) diff --git a/packages/modules/postgresql/src/postgresql-container.test.ts b/packages/modules/postgresql/src/postgresql-container.test.ts index 05c1205ac..b2d51589c 100644 --- a/packages/modules/postgresql/src/postgresql-container.test.ts +++ b/packages/modules/postgresql/src/postgresql-container.test.ts @@ -2,323 +2,320 @@ import { Client } from "pg"; import { PostgreSqlContainer } from "./postgresql-container"; describe("PostgreSqlContainer", { timeout: 180_000 }, () => { - // connect { - it("should connect and return a query result", async () => { - const container = await new PostgreSqlContainer().start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); - // } - - // uriConnect { - it("should work with database URI", async () => { - const container = await new PostgreSqlContainer().start(); - - const client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); - // } - - // setDatabase { - it("should set database", async () => { - const container = await new PostgreSqlContainer().withDatabase("customDatabase").start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT current_database()"); - expect(result.rows[0]).toEqual({ current_database: "customDatabase" }); - - await client.end(); - await container.stop(); - }); - // } - - // setUsername { - it("should set username", async () => { - const container = await new PostgreSqlContainer().withUsername("customUsername").start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT current_user"); - expect(result.rows[0]).toEqual({ current_user: "customUsername" }); - - await client.end(); - await container.stop(); - }); - // } - - it("should work with restarted container", async () => { - const container = await new PostgreSqlContainer().start(); - await container.restart(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); - - it("should allow custom healthcheck", async () => { - const container = new PostgreSqlContainer().withHealthCheck({ - test: ["CMD-SHELL", "exit 1"], - interval: 100, - retries: 0, - timeout: 0, - }); - - await expect(() => container.start()).rejects.toThrow(); - }); + // connect { + it("should connect and return a query result", async () => { + const container = await new PostgreSqlContainer().start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + // } + + // uriConnect { + it("should work with database URI", async () => { + const container = await new PostgreSqlContainer().start(); + + const client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + // } + + // setDatabase { + it("should set database", async () => { + const container = await new PostgreSqlContainer().withDatabase("customDatabase").start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT current_database()"); + expect(result.rows[0]).toEqual({ current_database: "customDatabase" }); + + await client.end(); + await container.stop(); + }); + // } + + // setUsername { + it("should set username", async () => { + const container = await new PostgreSqlContainer().withUsername("customUsername").start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT current_user"); + expect(result.rows[0]).toEqual({ current_user: "customUsername" }); + + await client.end(); + await container.stop(); + }); + // } + + it("should work with restarted container", async () => { + const container = await new PostgreSqlContainer().start(); + await container.restart(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + + it("should allow custom healthcheck", async () => { + const container = new PostgreSqlContainer().withHealthCheck({ + test: ["CMD-SHELL", "exit 1"], + interval: 100, + retries: 0, + timeout: 0, + }); + + await expect(() => container.start()).rejects.toThrow(); + }); }); describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => { - // createAndRestoreFromSnapshot { - it("should create and restore from snapshot", async () => { - const container = await new PostgreSqlContainer().start(); - - // Connect to the database - let client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Create some test data - await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); - await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); - - // Close connection before snapshot (otherwise we'll get an error because user is already connected) - await client.end(); - - // Take a snapshot - await container.snapshot(); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Modify the database - await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); - - // Verify both records exist - let result = await client.query("SELECT * FROM test_table ORDER BY id"); - expect(result.rows).toHaveLength(2); - expect(result.rows[0].name).toEqual("initial data"); - expect(result.rows[1].name).toEqual("data after snapshot"); - - // Close connection before restore (same reason as above) - await client.end(); - - // Restore to the snapshot - await container.restore(); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Verify only the initial data exists after restore - result = await client.query("SELECT * FROM test_table ORDER BY id"); - expect(result.rows).toHaveLength(1); - expect(result.rows[0].name).toEqual("initial data"); - - await client.end(); - await container.stop(); - }); - // } - - it("should use custom snapshot name", async () => { - const container = await new PostgreSqlContainer().start(); - const customSnapshotName = "my_custom_snapshot"; - - // Connect to the database - let client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Create a test table and insert data - await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); - await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); - - // Close connection before snapshot - await client.end(); - - // Take a snapshot with custom name - await container.snapshot(customSnapshotName); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Modify the database - await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); - - // Close connection before restore - await client.end(); - - // Restore using the custom snapshot name - await container.restore(customSnapshotName); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Verify only the initial data exists after restore - const result = await client.query("SELECT * FROM test_table ORDER BY id"); - expect(result.rows).toHaveLength(1); - expect(result.rows[0].name).toEqual("initial data"); - - await client.end(); - await container.stop(); - }); - - it("should handle multiple snapshots", async () => { - const container = await new PostgreSqlContainer().start(); + // createAndRestoreFromSnapshot { + it("should create and restore from snapshot", async () => { + const container = await new PostgreSqlContainer().start(); + + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create some test data + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); + + // Close connection before snapshot (otherwise we'll get an error because user is already connected) + await client.end(); + + // Take a snapshot + await container.snapshot(); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Modify the database + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); + + // Verify both records exist + let result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].name).toEqual("initial data"); + expect(result.rows[1].name).toEqual("data after snapshot"); + + // Close connection before restore (same reason as above) + await client.end(); + + // Restore to the snapshot + await container.restore(); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify only the initial data exists after restore + result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("initial data"); + + await client.end(); + await container.stop(); + }); + // } + + it("should use custom snapshot name", async () => { + const container = await new PostgreSqlContainer().start(); + const customSnapshotName = "my_custom_snapshot"; + + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create a test table and insert data + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); + + // Close connection before snapshot + await client.end(); + + // Take a snapshot with custom name + await container.snapshot(customSnapshotName); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Modify the database + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); + + // Close connection before restore + await client.end(); + + // Restore using the custom snapshot name + await container.restore(customSnapshotName); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify only the initial data exists after restore + const result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("initial data"); + + await client.end(); + await container.stop(); + }); + + it("should handle multiple snapshots", async () => { + const container = await new PostgreSqlContainer().start(); - // Connect to the database - let client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create a test table + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + + // Close connection before snapshot + await client.end(); + + // Take first snapshot with empty table + await container.snapshot("snapshot1"); - // Create a test table - await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); - - // Close connection before snapshot - await client.end(); - - // Take first snapshot with empty table - await container.snapshot("snapshot1"); + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Add first record + await client.query("INSERT INTO test_table (name) VALUES ('data for snapshot 2')"); - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Add first record - await client.query("INSERT INTO test_table (name) VALUES ('data for snapshot 2')"); + // Close connection before snapshot + await client.end(); - // Close connection before snapshot - await client.end(); + // Take second snapshot with one record + await container.snapshot("snapshot2"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); - // Take second snapshot with one record - await container.snapshot("snapshot2"); + // Add second record + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshots')"); - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); + // Verify we have two records + let result = await client.query("SELECT COUNT(*) as count FROM test_table"); + expect(result.rows[0].count).toEqual("2"); - // Add second record - await client.query("INSERT INTO test_table (name) VALUES ('data after snapshots')"); + // Close connection before restore + await client.end(); - // Verify we have two records - let result = await client.query("SELECT COUNT(*) as count FROM test_table"); - expect(result.rows[0].count).toEqual("2"); + // Restore to first snapshot (empty table) + await container.restore("snapshot1"); - // Close connection before restore - await client.end(); + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); - // Restore to first snapshot (empty table) - await container.restore("snapshot1"); + // Verify table is empty + result = await client.query("SELECT COUNT(*) as count FROM test_table"); + expect(result.rows[0].count).toEqual("0"); - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); + // Close connection before restore + await client.end(); - // Verify table is empty - result = await client.query("SELECT COUNT(*) as count FROM test_table"); - expect(result.rows[0].count).toEqual("0"); + // Restore to second snapshot (one record) + await container.restore("snapshot2"); - // Close connection before restore - await client.end(); + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); - // Restore to second snapshot (one record) - await container.restore("snapshot2"); + // Verify we have one record + result = await client.query("SELECT * FROM test_table"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("data for snapshot 2"); - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); + await client.end(); + await container.stop(); + }); - // Verify we have one record - result = await client.query("SELECT * FROM test_table"); - expect(result.rows).toHaveLength(1); - expect(result.rows[0].name).toEqual("data for snapshot 2"); + it("should throw an error when trying to snapshot postgres system database", async () => { + const container = await new PostgreSqlContainer().withDatabase("postgres").start(); - await client.end(); - await container.stop(); - }); + await expect(container.snapshot()).rejects.toThrow( + "cannot restore the postgres system database as it cannot be dropped to be restored" + ); - it("should throw an error when trying to snapshot postgres system database", async () => { - const container = await new PostgreSqlContainer() - .withDatabase("postgres") - .start(); - - await expect(container.snapshot()).rejects.toThrow( - "cannot restore the postgres system database as it cannot be dropped to be restored" - ); - - await expect(container.restore()).rejects.toThrow( - "cannot restore the postgres system database as it cannot be dropped to be restored" - ); - - await container.stop(); - }); + await expect(container.restore()).rejects.toThrow( + "cannot restore the postgres system database as it cannot be dropped to be restored" + ); + await container.stop(); + }); }); diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index 30ee0da0d..2d1fa5cac 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -3,185 +3,183 @@ import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait const POSTGRES_PORT = 5432; export class PostgreSqlContainer extends GenericContainer { - private database = "test"; - private username = "test"; - private password = "test"; - - constructor(image = "postgres:13.3-alpine") { - super(image); - this.withExposedPorts(POSTGRES_PORT); - this.withWaitStrategy(Wait.forHealthCheck()); - this.withStartupTimeout(120_000); - } - - public withDatabase(database: string): this { - this.database = database; - return this; - } - - public withUsername(username: string): this { - this.username = username; - return this; - } - - public withPassword(password: string): this { - this.password = password; - return this; - } - - public override async start(): Promise { - this.withEnvironment({ - POSTGRES_DB: this.database, - POSTGRES_USER: this.username, - POSTGRES_PASSWORD: this.password, - }); - if (!this.healthCheck) { - this.withHealthCheck({ - test: ["CMD-SHELL", `PGPASSWORD=${this.password} psql -U ${this.username} -d ${this.database} -c 'SELECT 1;'`], - interval: 250, - timeout: 1000, - retries: 1000, - }); - } - return new StartedPostgreSqlContainer(await super.start(), this.database, this.username, this.password); - } + private database = "test"; + private username = "test"; + private password = "test"; + + constructor(image = "postgres:13.3-alpine") { + super(image); + this.withExposedPorts(POSTGRES_PORT); + this.withWaitStrategy(Wait.forHealthCheck()); + this.withStartupTimeout(120_000); + } + + public withDatabase(database: string): this { + this.database = database; + return this; + } + + public withUsername(username: string): this { + this.username = username; + return this; + } + + public withPassword(password: string): this { + this.password = password; + return this; + } + + public override async start(): Promise { + this.withEnvironment({ + POSTGRES_DB: this.database, + POSTGRES_USER: this.username, + POSTGRES_PASSWORD: this.password, + }); + if (!this.healthCheck) { + this.withHealthCheck({ + test: ["CMD-SHELL", `PGPASSWORD=${this.password} psql -U ${this.username} -d ${this.database} -c 'SELECT 1;'`], + interval: 250, + timeout: 1000, + retries: 1000, + }); + } + return new StartedPostgreSqlContainer(await super.start(), this.database, this.username, this.password); + } } export class StartedPostgreSqlContainer extends AbstractStartedContainer { - private snapshotName: string = "migrated_template"; - constructor( - startedTestContainer: StartedTestContainer, - private readonly database: string, - private readonly username: string, - private readonly password: string, - ) { - super(startedTestContainer); - } - - public getPort(): number { - return super.getMappedPort(POSTGRES_PORT); - } - - public getDatabase(): string { - return this.database; - } - - public getUsername(): string { - return this.username; - } - - public getPassword(): string { - return this.password; - } - - /** - * @returns A connection URI in the form of `postgres[ql]://[username[:password]@][host[:port],]/database` - */ - public getConnectionUri(): string { - const url = new URL("", "postgres://"); - url.hostname = this.getHost(); - url.port = this.getPort().toString(); - url.pathname = this.getDatabase(); - url.username = this.getUsername(); - url.password = this.getPassword(); - return url.toString(); - } - - - public withSnapshotName(snapshotName: string): this { - this.snapshotName = snapshotName; - return this; - } - - - /** - * Takes a snapshot of the current state of the database as a template, which can then be restored using - * the restore method. By default, the snapshot will be created under a database called migrated_template. - * - * If a snapshot already exists under the given/default name, it will be overwritten with the new snapshot. - * - * @param opts Optional snapshot configuration options - * @returns Promise resolving when snapshot is complete - */ - public async snapshot(snapshotName?: string): Promise { - const name = snapshotName || this.snapshotName; - - this.snapshotSanityCheck(name); - - // Execute the commands to create the snapshot, in order - await this.execCommandsSQL([ - // Update pg_database to remove the template flag, then drop the database if it exists. - // This is needed because dropping a template database will fail. - `UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${name}'`, - `DROP DATABASE IF EXISTS "${name}"`, - // Create a copy of the database to another database to use as a template now that it was fully migrated - `CREATE DATABASE "${name}" WITH TEMPLATE "${this.getDatabase()}" OWNER "${this.getUsername()}"`, - // Snapshot the template database so we can restore it onto our original database going forward - `ALTER DATABASE "${name}" WITH is_template = TRUE`, - ]); - } - - /** - * Restores the database to a specific snapshot. By default, it will restore the last snapshot taken on the - * database by the snapshot method. If a snapshot name is provided, it will instead try to restore the snapshot by name. - * - * @param opts Optional snapshot configuration options - * @returns Promise resolving when restore is complete - */ - public async restore(snapshotName?: string): Promise { - const name = snapshotName || this.snapshotName; - - this.snapshotSanityCheck(name); - - // Execute the commands to restore the snapshot, in order - await this.execCommandsSQL([ - // Drop the entire database by connecting to the postgres global database - `DROP DATABASE "${this.getDatabase()}" WITH (FORCE)`, - // Then restore the previous snapshot - `CREATE DATABASE "${this.getDatabase()}" WITH TEMPLATE "${name}" OWNER "${this.getUsername()}"`, - ]); - } - - /** - * Executes a series of SQL commands against the Postgres database - * @param commands SQL commands to execute - */ - private async execCommandsSQL(commands: string[]): Promise { - for (const command of commands) { - try { - const result = await this.exec([ - 'psql', - '-v', - 'ON_ERROR_STOP=1', - '-U', - this.getUsername(), - '-d', - 'postgres', - '-c', - command, - ]); - - if (result.exitCode !== 0) { - throw new Error(`Command failed with exit code ${result.exitCode}: ${result.output}`); - } - } catch (error) { - console.error(`Failed to execute command: ${command}`, error); - throw error; - } - } - } - - /** - * Checks if the snapshot name is valid and if the database is not the postgres system database - * @param snapshotName The name of the snapshot to check - */ - private snapshotSanityCheck(snapshotName: string): void { - if (this.getDatabase() === "postgres") { - throw new Error("cannot restore the postgres system database as it cannot be dropped to be restored"); - } - - if (this.getDatabase() === snapshotName) { - throw new Error("cannot restore the database to itself"); - } - } + private snapshotName: string = "migrated_template"; + constructor( + startedTestContainer: StartedTestContainer, + private readonly database: string, + private readonly username: string, + private readonly password: string + ) { + super(startedTestContainer); + } + + public getPort(): number { + return super.getMappedPort(POSTGRES_PORT); + } + + public getDatabase(): string { + return this.database; + } + + public getUsername(): string { + return this.username; + } + + public getPassword(): string { + return this.password; + } + + /** + * @returns A connection URI in the form of `postgres[ql]://[username[:password]@][host[:port],]/database` + */ + public getConnectionUri(): string { + const url = new URL("", "postgres://"); + url.hostname = this.getHost(); + url.port = this.getPort().toString(); + url.pathname = this.getDatabase(); + url.username = this.getUsername(); + url.password = this.getPassword(); + return url.toString(); + } + + public withSnapshotName(snapshotName: string): this { + this.snapshotName = snapshotName; + return this; + } + + /** + * Takes a snapshot of the current state of the database as a template, which can then be restored using + * the restore method. By default, the snapshot will be created under a database called migrated_template. + * + * If a snapshot already exists under the given/default name, it will be overwritten with the new snapshot. + * + * @param opts Optional snapshot configuration options + * @returns Promise resolving when snapshot is complete + */ + public async snapshot(snapshotName?: string): Promise { + const name = snapshotName || this.snapshotName; + + this.snapshotSanityCheck(name); + + // Execute the commands to create the snapshot, in order + await this.execCommandsSQL([ + // Update pg_database to remove the template flag, then drop the database if it exists. + // This is needed because dropping a template database will fail. + `UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${name}'`, + `DROP DATABASE IF EXISTS "${name}"`, + // Create a copy of the database to another database to use as a template now that it was fully migrated + `CREATE DATABASE "${name}" WITH TEMPLATE "${this.getDatabase()}" OWNER "${this.getUsername()}"`, + // Snapshot the template database so we can restore it onto our original database going forward + `ALTER DATABASE "${name}" WITH is_template = TRUE`, + ]); + } + + /** + * Restores the database to a specific snapshot. By default, it will restore the last snapshot taken on the + * database by the snapshot method. If a snapshot name is provided, it will instead try to restore the snapshot by name. + * + * @param opts Optional snapshot configuration options + * @returns Promise resolving when restore is complete + */ + public async restore(snapshotName?: string): Promise { + const name = snapshotName || this.snapshotName; + + this.snapshotSanityCheck(name); + + // Execute the commands to restore the snapshot, in order + await this.execCommandsSQL([ + // Drop the entire database by connecting to the postgres global database + `DROP DATABASE "${this.getDatabase()}" WITH (FORCE)`, + // Then restore the previous snapshot + `CREATE DATABASE "${this.getDatabase()}" WITH TEMPLATE "${name}" OWNER "${this.getUsername()}"`, + ]); + } + + /** + * Executes a series of SQL commands against the Postgres database + * @param commands SQL commands to execute + */ + private async execCommandsSQL(commands: string[]): Promise { + for (const command of commands) { + try { + const result = await this.exec([ + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + this.getUsername(), + "-d", + "postgres", + "-c", + command, + ]); + + if (result.exitCode !== 0) { + throw new Error(`Command failed with exit code ${result.exitCode}: ${result.output}`); + } + } catch (error) { + console.error(`Failed to execute command: ${command}`, error); + throw error; + } + } + } + + /** + * Checks if the snapshot name is valid and if the database is not the postgres system database + * @param snapshotName The name of the snapshot to check + */ + private snapshotSanityCheck(snapshotName: string): void { + if (this.getDatabase() === "postgres") { + throw new Error("cannot restore the postgres system database as it cannot be dropped to be restored"); + } + + if (this.getDatabase() === snapshotName) { + throw new Error("cannot restore the database to itself"); + } + } } From 21b40641bbc0cc947c72ec89233915f86d394acf Mon Sep 17 00:00:00 2001 From: Nikola Milovic Date: Tue, 25 Mar 2025 09:41:43 +0100 Subject: [PATCH 3/7] Fix casing of error messages --- .../modules/postgresql/src/postgresql-container.test.ts | 4 ++-- packages/modules/postgresql/src/postgresql-container.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/modules/postgresql/src/postgresql-container.test.ts b/packages/modules/postgresql/src/postgresql-container.test.ts index b2d51589c..3d3ebf332 100644 --- a/packages/modules/postgresql/src/postgresql-container.test.ts +++ b/packages/modules/postgresql/src/postgresql-container.test.ts @@ -309,11 +309,11 @@ describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => const container = await new PostgreSqlContainer().withDatabase("postgres").start(); await expect(container.snapshot()).rejects.toThrow( - "cannot restore the postgres system database as it cannot be dropped to be restored" + "Cannot restore the postgres system database as it cannot be dropped to be restored" ); await expect(container.restore()).rejects.toThrow( - "cannot restore the postgres system database as it cannot be dropped to be restored" + "Cannot restore the postgres system database as it cannot be dropped to be restored" ); await container.stop(); diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index 2d1fa5cac..23c2564ed 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -102,7 +102,7 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { * @returns Promise resolving when snapshot is complete */ public async snapshot(snapshotName?: string): Promise { - const name = snapshotName || this.snapshotName; + const name = snapshotName ?? this.snapshotName; this.snapshotSanityCheck(name); @@ -127,7 +127,7 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { * @returns Promise resolving when restore is complete */ public async restore(snapshotName?: string): Promise { - const name = snapshotName || this.snapshotName; + const name = snapshotName ?? this.snapshotName; this.snapshotSanityCheck(name); @@ -175,11 +175,11 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { */ private snapshotSanityCheck(snapshotName: string): void { if (this.getDatabase() === "postgres") { - throw new Error("cannot restore the postgres system database as it cannot be dropped to be restored"); + throw new Error("Cannot restore the postgres system database as it cannot be dropped to be restored"); } if (this.getDatabase() === snapshotName) { - throw new Error("cannot restore the database to itself"); + throw new Error("Cannot restore the database to itself"); } } } From b3bb641e9ae063ab9d30209722833703826ee7ca Mon Sep 17 00:00:00 2001 From: Nikola Milovic Date: Tue, 25 Mar 2025 09:44:35 +0100 Subject: [PATCH 4/7] Add default for snapshot arguments --- .../postgresql/src/postgresql-container.ts | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index 23c2564ed..7fd2bf9d0 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -98,24 +98,22 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { * * If a snapshot already exists under the given/default name, it will be overwritten with the new snapshot. * - * @param opts Optional snapshot configuration options + * @param snapshotName Optional name for the snapshot, defaults to this.snapshotName * @returns Promise resolving when snapshot is complete */ - public async snapshot(snapshotName?: string): Promise { - const name = snapshotName ?? this.snapshotName; - - this.snapshotSanityCheck(name); + public async snapshot(snapshotName = this.snapshotName): Promise { + this.snapshotSanityCheck(snapshotName); // Execute the commands to create the snapshot, in order await this.execCommandsSQL([ // Update pg_database to remove the template flag, then drop the database if it exists. // This is needed because dropping a template database will fail. - `UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${name}'`, - `DROP DATABASE IF EXISTS "${name}"`, + `UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${snapshotName}'`, + `DROP DATABASE IF EXISTS "${snapshotName}"`, // Create a copy of the database to another database to use as a template now that it was fully migrated - `CREATE DATABASE "${name}" WITH TEMPLATE "${this.getDatabase()}" OWNER "${this.getUsername()}"`, + `CREATE DATABASE "${snapshotName}" WITH TEMPLATE "${this.getDatabase()}" OWNER "${this.getUsername()}"`, // Snapshot the template database so we can restore it onto our original database going forward - `ALTER DATABASE "${name}" WITH is_template = TRUE`, + `ALTER DATABASE "${snapshotName}" WITH is_template = TRUE`, ]); } @@ -126,17 +124,15 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { * @param opts Optional snapshot configuration options * @returns Promise resolving when restore is complete */ - public async restore(snapshotName?: string): Promise { - const name = snapshotName ?? this.snapshotName; - - this.snapshotSanityCheck(name); + public async restore(snapshotName = this.snapshotName): Promise { + this.snapshotSanityCheck(snapshotName); // Execute the commands to restore the snapshot, in order await this.execCommandsSQL([ // Drop the entire database by connecting to the postgres global database `DROP DATABASE "${this.getDatabase()}" WITH (FORCE)`, // Then restore the previous snapshot - `CREATE DATABASE "${this.getDatabase()}" WITH TEMPLATE "${name}" OWNER "${this.getUsername()}"`, + `CREATE DATABASE "${this.getDatabase()}" WITH TEMPLATE "${snapshotName}" OWNER "${this.getUsername()}"`, ]); } From 265f447994966a471e125f30f1e3ed8afc6250e0 Mon Sep 17 00:00:00 2001 From: Nikola Milovic Date: Tue, 25 Mar 2025 09:53:11 +0100 Subject: [PATCH 5/7] Update js docs --- .../postgresql/src/postgresql-container.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index 7fd2bf9d0..654ae601e 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -87,6 +87,13 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { return url.toString(); } + /** + * Sets the name to be used for database snapshots. + * This name will be used as the default for snapshot() and restore() methods. + * + * @param snapshotName The name to use for snapshots (default is "migrated_template" if this method is not called) + * @returns this (for method chaining) + */ public withSnapshotName(snapshotName: string): this { this.snapshotName = snapshotName; return this; @@ -94,12 +101,11 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { /** * Takes a snapshot of the current state of the database as a template, which can then be restored using - * the restore method. By default, the snapshot will be created under a database called migrated_template. - * - * If a snapshot already exists under the given/default name, it will be overwritten with the new snapshot. + * the restore method. * - * @param snapshotName Optional name for the snapshot, defaults to this.snapshotName + * @param snapshotName Name for the snapshot, defaults to the value set by withSnapshotName() or "migrated_template" if not specified * @returns Promise resolving when snapshot is complete + * @throws Error if attempting to snapshot the postgres system database or if using the same name as the database */ public async snapshot(snapshotName = this.snapshotName): Promise { this.snapshotSanityCheck(snapshotName); @@ -118,11 +124,11 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { } /** - * Restores the database to a specific snapshot. By default, it will restore the last snapshot taken on the - * database by the snapshot method. If a snapshot name is provided, it will instead try to restore the snapshot by name. + * Restores the database to a specific snapshot. * - * @param opts Optional snapshot configuration options + * @param snapshotName Name of the snapshot to restore from, defaults to the value set by withSnapshotName() or "migrated_template" if not specified * @returns Promise resolving when restore is complete + * @throws Error if attempting to restore the postgres system database or if using the same name as the database */ public async restore(snapshotName = this.snapshotName): Promise { this.snapshotSanityCheck(snapshotName); @@ -138,7 +144,9 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { /** * Executes a series of SQL commands against the Postgres database - * @param commands SQL commands to execute + * + * @param commands Array of SQL commands to execute in sequence + * @throws Error if any command fails to execute with details of the failure */ private async execCommandsSQL(commands: string[]): Promise { for (const command of commands) { From 47609ff664690c8db8f127ffc9330451b80e2fa9 Mon Sep 17 00:00:00 2001 From: Nikola Milovic Date: Tue, 25 Mar 2025 10:44:59 +0100 Subject: [PATCH 6/7] Run eslint --- packages/modules/postgresql/src/postgresql-container.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index 654ae601e..5aac66edf 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -144,7 +144,7 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { /** * Executes a series of SQL commands against the Postgres database - * + * * @param commands Array of SQL commands to execute in sequence * @throws Error if any command fails to execute with details of the failure */ From 547bbccb97584efcca2643f83e141e721b684848 Mon Sep 17 00:00:00 2001 From: Nikola Milovic Date: Tue, 25 Mar 2025 12:37:36 +0100 Subject: [PATCH 7/7] Update error message and function naming --- .../postgresql/src/postgresql-container.test.ts | 14 +++++++------- .../modules/postgresql/src/postgresql-container.ts | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/modules/postgresql/src/postgresql-container.test.ts b/packages/modules/postgresql/src/postgresql-container.test.ts index 3d3ebf332..52ff5ede9 100644 --- a/packages/modules/postgresql/src/postgresql-container.test.ts +++ b/packages/modules/postgresql/src/postgresql-container.test.ts @@ -154,7 +154,7 @@ describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => await client.end(); // Restore to the snapshot - await container.restore(); + await container.restoreSnapshot(); // Reconnect to database client = new Client({ @@ -205,7 +205,7 @@ describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => await client.end(); // Restore using the custom snapshot name - await container.restore(customSnapshotName); + await container.restoreSnapshot(customSnapshotName); // Reconnect to database client = new Client({ @@ -272,7 +272,7 @@ describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => await client.end(); // Restore to first snapshot (empty table) - await container.restore("snapshot1"); + await container.restoreSnapshot("snapshot1"); // Reconnect to database client = new Client({ @@ -288,7 +288,7 @@ describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => await client.end(); // Restore to second snapshot (one record) - await container.restore("snapshot2"); + await container.restoreSnapshot("snapshot2"); // Reconnect to database client = new Client({ @@ -309,11 +309,11 @@ describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => const container = await new PostgreSqlContainer().withDatabase("postgres").start(); await expect(container.snapshot()).rejects.toThrow( - "Cannot restore the postgres system database as it cannot be dropped to be restored" + "Snapshot feature is not supported when using the postgres system database" ); - await expect(container.restore()).rejects.toThrow( - "Cannot restore the postgres system database as it cannot be dropped to be restored" + await expect(container.restoreSnapshot()).rejects.toThrow( + "Snapshot feature is not supported when using the postgres system database" ); await container.stop(); diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index 5aac66edf..b841b672d 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -130,7 +130,7 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { * @returns Promise resolving when restore is complete * @throws Error if attempting to restore the postgres system database or if using the same name as the database */ - public async restore(snapshotName = this.snapshotName): Promise { + public async restoreSnapshot(snapshotName = this.snapshotName): Promise { this.snapshotSanityCheck(snapshotName); // Execute the commands to restore the snapshot, in order @@ -179,11 +179,11 @@ export class StartedPostgreSqlContainer extends AbstractStartedContainer { */ private snapshotSanityCheck(snapshotName: string): void { if (this.getDatabase() === "postgres") { - throw new Error("Cannot restore the postgres system database as it cannot be dropped to be restored"); + throw new Error("Snapshot feature is not supported when using the postgres system database"); } if (this.getDatabase() === snapshotName) { - throw new Error("Cannot restore the database to itself"); + throw new Error("Snapshot name cannot be the same as the database name"); } } }