From 12fbf7c38c928c773dde2dfbd62e839142d4ed6f Mon Sep 17 00:00:00 2001 From: Max Isom Date: Mon, 8 Apr 2024 14:41:58 -0700 Subject: [PATCH 1/6] Enable nested beforeTemplateIsBaked calls --- src/index.ts | 120 +++++++++++++++---------- src/internal-types.ts | 1 + src/public-types.ts | 5 ++ src/tests/hooks.test.ts | 46 ++++++++++ src/tests/utils/does-database-exist.ts | 12 +++ src/worker.ts | 17 +++- 6 files changed, 153 insertions(+), 48 deletions(-) create mode 100644 src/tests/utils/does-database-exist.ts diff --git a/src/index.ts b/src/index.ts index 0d08122..ecc299e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,7 @@ import type { import { Pool } from "pg" import type { Jsonifiable } from "type-fest" import type { ExecutionContext } from "ava" -import { once } from "node:events" -import { createBirpc } from "birpc" +import { BirpcReturn, createBirpc } from "birpc" import { ExecResult } from "testcontainers" import isPlainObject from "lodash/isPlainObject" @@ -136,57 +135,86 @@ export const getTestPostgresDatabaseFactory = < } let rpcCallback: (data: any) => void - const rpc = createBirpc( - { - runBeforeTemplateIsBakedHook: async (connection, params) => { - if (options?.beforeTemplateIsBaked) { - const connectionDetails = - mapWorkerConnectionDetailsToConnectionDetails(connection) - - // Ignore if the pool is terminated by the shared worker - // (This happens in CI for some reason even though we drain the pool first.) - connectionDetails.pool.on("error", (error) => { - if ( - error.message.includes( - "terminating connection due to administrator command" - ) - ) { - return - } + const rpc: BirpcReturn = + createBirpc( + { + runBeforeTemplateIsBakedHook: async (connection, params) => { + if (options?.beforeTemplateIsBaked) { + const connectionDetails = + mapWorkerConnectionDetailsToConnectionDetails(connection) + + // Ignore if the pool is terminated by the shared worker + // (This happens in CI for some reason even though we drain the pool first.) + connectionDetails.pool.on("error", (error) => { + if ( + error.message.includes( + "terminating connection due to administrator command" + ) + ) { + return + } + + throw error + }) + + const createdNestedConnections: ConnectionDetails[] = [] + const hookResult = await options.beforeTemplateIsBaked({ + params: params as any, + connection: connectionDetails, + containerExec: async (command): Promise => + rpc.execCommandInContainer(command), + // This is what allows a consumer to get a "nested" database from within their beforeTemplateIsBaked hook + beforeTemplateIsBaked: async (options) => { + const { connectionDetails, beforeTemplateIsBakedResult } = + await rpc.getTestDatabase({ + params: options.params, + databaseDedupeKey: options.databaseDedupeKey, + }) - throw error - }) + const mappedConnection = + mapWorkerConnectionDetailsToConnectionDetails( + connectionDetails + ) - const hookResult = await options.beforeTemplateIsBaked({ - params: params as any, - connection: connectionDetails, - containerExec: async (command): Promise => - rpc.execCommandInContainer(command), - }) + createdNestedConnections.push(mappedConnection) - await teardownConnection(connectionDetails) + return { + ...mappedConnection, + beforeTemplateIsBakedResult, + } + }, + }) - if (hookResult && !isSerializable(hookResult)) { - throw new TypeError( - "Return value of beforeTemplateIsBaked() hook could not be serialized. Make sure it returns only JSON-serializable values." + await Promise.all( + createdNestedConnections.map(async (connection) => { + await teardownConnection(connection) + await rpc.dropDatabase(connection.database) + }) ) - } - return hookResult - } - }, - }, - { - post: async (data) => { - const worker = await workerPromise - await worker.available - worker.publish(data) - }, - on: (data) => { - rpcCallback = data + await teardownConnection(connectionDetails) + + if (hookResult && !isSerializable(hookResult)) { + throw new TypeError( + "Return value of beforeTemplateIsBaked() hook could not be serialized. Make sure it returns only JSON-serializable values." + ) + } + + return hookResult + } + }, }, - } - ) + { + post: async (data) => { + const worker = await workerPromise + await worker.available + worker.publish(data) + }, + on: (data) => { + rpcCallback = data + }, + } + ) // Automatically cleaned up by AVA since each test file runs in a separate worker const _messageHandlerPromise = (async () => { diff --git a/src/internal-types.ts b/src/internal-types.ts index 2700f0e..cb8f11b 100644 --- a/src/internal-types.ts +++ b/src/internal-types.ts @@ -30,4 +30,5 @@ export interface SharedWorkerFunctions { beforeTemplateIsBakedResult: unknown }> execCommandInContainer: (command: string[]) => Promise + dropDatabase: (databaseName: string) => Promise } diff --git a/src/public-types.ts b/src/public-types.ts index d3ce7c7..038a25e 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -55,6 +55,11 @@ export interface GetTestPostgresDatabaseFactoryOptions< connection: ConnectionDetails params: Params containerExec: (command: string[]) => Promise + beforeTemplateIsBaked: ( + options: { + params: Params + } & Pick + ) => Promise }) => Promise } diff --git a/src/tests/hooks.test.ts b/src/tests/hooks.test.ts index 1944190..64636a3 100644 --- a/src/tests/hooks.test.ts +++ b/src/tests/hooks.test.ts @@ -1,6 +1,7 @@ import test from "ava" import { getTestPostgresDatabaseFactory } from "~/index" import { countDatabaseTemplates } from "./utils/count-database-templates" +import { doesDatabaseExist } from "./utils/does-database-exist" test("beforeTemplateIsBaked", async (t) => { let wasHookCalled = false @@ -145,3 +146,48 @@ test("beforeTemplateIsBaked (result isn't serializable)", async (t) => { } ) }) + +test("beforeTemplateIsBaked, get nested database", async (t) => { + type DatabaseParams = { + type: "foo" | "bar" + } + + let nestedDatabaseName: string | undefined = undefined + + const getTestServer = getTestPostgresDatabaseFactory({ + postgresVersion: process.env.POSTGRES_VERSION, + workerDedupeKey: "beforeTemplateIsBakedHookNestedDatabase", + beforeTemplateIsBaked: async ({ + params, + connection: { pool }, + beforeTemplateIsBaked, + }) => { + if (params.type === "foo") { + await pool.query(`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`) + return { createdFoo: true } + } + + await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`) + const fooDatabase = await beforeTemplateIsBaked({ + params: { type: "foo" }, + }) + t.deepEqual(fooDatabase.beforeTemplateIsBakedResult, { createdFoo: true }) + + nestedDatabaseName = fooDatabase.database + + await t.notThrowsAsync(async () => { + await fooDatabase.pool.query(`INSERT INTO "foo" DEFAULT VALUES`) + }) + + return { createdBar: true } + }, + }) + + const database = await getTestServer(t, { type: "bar" }) + t.deepEqual(database.beforeTemplateIsBakedResult, { createdBar: true }) + + t.false( + await doesDatabaseExist(database.pool, nestedDatabaseName!), + "Nested database should have been cleaned up after the parent hook completed" + ) +}) diff --git a/src/tests/utils/does-database-exist.ts b/src/tests/utils/does-database-exist.ts new file mode 100644 index 0000000..b1d3b42 --- /dev/null +++ b/src/tests/utils/does-database-exist.ts @@ -0,0 +1,12 @@ +import { Pool } from "pg" + +export const doesDatabaseExist = async (pool: Pool, databaseName: string) => { + const { + rows: [{ count }], + } = await pool.query( + 'SELECT COUNT(*) FROM "pg_database" WHERE "datname" = $1', + [databaseName] + ) + + return count > 0 +} diff --git a/src/worker.ts b/src/worker.ts index 8130e6f..5e2e409 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -73,6 +73,10 @@ export class Worker { const container = (await this.startContainerPromise).container return container.exec(command) }, + dropDatabase: async (databaseName) => { + const { postgresClient } = await this.startContainerPromise + await postgresClient.query(`DROP DATABASE ${databaseName}`) + }, }, rpcChannel ) @@ -148,8 +152,17 @@ export class Worker { return } - await this.forceDisconnectClientsFrom(databaseName!) - await postgresClient.query(`DROP DATABASE ${databaseName}`) + try { + await this.forceDisconnectClientsFrom(databaseName!) + await postgresClient.query(`DROP DATABASE ${databaseName}`) + } catch (error) { + if ((error as Error)?.message?.includes("does not exist")) { + // Database was likely a nested database and manually dropped by the test worker, ignore + return + } + + throw error + } }) return { From c9a0af52dbfc6321c57983b302bfcbdbf1b319d4 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Mon, 8 Apr 2024 14:59:04 -0700 Subject: [PATCH 2/6] Update docs --- README.md | 86 +++++++++++++++++++++++++++++++++++++++++++++ src/public-types.ts | 61 +++++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9eb4a8a..2538bf0 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,92 @@ test("foo bar", async (t) => { ## Advanced Usage +### Postgres container de-duping + +In rare cases, you may want to spawn more than one Postgres container. + +Internally, this library uses an AVA "shared worker". A shared worker is a singleton shared with the entire running test suite, and so one `ava-postgres` shared worker maps to exactly one Postgres container. + +To spawn separate shared workers and thus additional Postgres containers, you have two options: + +**Specify different version strings for the `postgresVersion` option in the factory function**: + +```ts +const getTestPostgresDatabase = getTestPostgresDatabaseFactory({ + postgresVersion: "14", +}) +``` + +Each unique version will map to a unique shared worker. + +**Set the `workerDedupeKey` option in the factory function**: + +```ts +const getTestPostgresDatabase = getTestPostgresDatabaseFactory({ + workerDedupeKey: "foo", +}) +``` + +Each unique key will map to a unique shared worker. + +### Database de-duping + +By default, `ava-postgres` will create a new database for each test. If you want to share a database between tests, you can use the `databaseDedupeKey` option: + +```ts +import test from "ava" +const getTestPostgresDatabase = getTestPostgresDatabaseFactory({}) + +test("foo", async (t) => { + const connection1 = await getTestPostgresDatabase(t, null, { + databaseDedupeKey: "foo", + }) + const connection2 = await getTestPostgresDatabase(t, null, { + databaseDedupeKey: "foo", + }) + t.is(connection1.database, connection2.database) +}) +``` + +This works across the entire test suite. + +Note that if unique parameters are passed to the `beforeTemplateIsBaked` (`null` in the above example), separate databases will still be created. + +### "Nested" `beforeTemplateIsBaked` calls + +In some cases, if you do extensive setup in your `beforeTemplateIsBaked` hook, you might want to obtain a separate, additional database within it if your application uses several databases for different purposes. This is possible by using the passed `beforeTemplateIsBaked` to your hook callback: + +```ts +type DatabaseParams = { + type: "foo" | "bar" +} + +const getTestServer = getTestPostgresDatabaseFactory({ + beforeTemplateIsBaked: async ({ + params, + connection: { pool }, + beforeTemplateIsBaked, + }) => { + if (params.type === "foo") { + await pool.query(`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`) + // Important: return early to avoid infinite loop + return + } + + await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`) + // This created database will be torn down at the end of the top-level `beforeTemplateIsBaked` call + const fooDatabase = await beforeTemplateIsBaked({ + params: { type: "foo" }, + }) + + // This works now + await fooDatabase.pool.query(`INSERT INTO "foo" DEFAULT VALUES`) + }, +}) +``` + +Be very careful when using this to avoid infinite loops. + ### Bind mounts & `exec`ing in the container `ava-postgres` uses [testcontainers](https://www.npmjs.com/package/testcontainers) under the hood to manage the Postgres container. diff --git a/src/public-types.ts b/src/public-types.ts index 038a25e..cbd1c20 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -48,13 +48,50 @@ export interface GetTestPostgresDatabaseFactoryOptions< } /** - * Test workers will be de-duped by this key. You probably don't need to set this. + * In rare cases, you may want to spawn more than one Postgres container. + * Internally, this library uses an AVA "shared worker". A shared worker is a singleton shared with the entire running test suite, and so one `ava-postgres` shared worker maps to exactly one Postgres container. + * To spawn separate shared workers and thus additional Postgres containers, you can specify a custom key here. + * Each unique key will map to a unique shared worker/unique Postgres container. */ workerDedupeKey?: string beforeTemplateIsBaked?: (options: { connection: ConnectionDetails params: Params containerExec: (command: string[]) => Promise + /** + * + * In some cases, if you do extensive setup in your `beforeTemplateIsBaked` hook, you might want to obtain a separate, additional database within it if your application uses several databases for different purposes. This is possible by using the passed `beforeTemplateIsBaked` to your hook callback. + * Be very careful when using this to avoid infinite loops. + * @example + * ```ts + * type DatabaseParams = { + * type: "foo" | "bar" + * } + + * const getTestServer = getTestPostgresDatabaseFactory({ + * beforeTemplateIsBaked: async ({ + * params, + * connection: { pool }, + * beforeTemplateIsBaked, + * }) => { + * if (params.type === "foo") { + * await pool.query(`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`) + * // Important: return early to avoid infinite loop + * return + * } + + * await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`) + * // This created database will be torn down at the end of the top-level `beforeTemplateIsBaked` call + * const fooDatabase = await beforeTemplateIsBaked({ + * params: { type: "foo" }, + * }) + + * // This works now + * await fooDatabase.pool.query(`INSERT INTO "foo" DEFAULT VALUES`) + * }, + * }) + * ``` + */ beforeTemplateIsBaked: ( options: { params: Params @@ -71,6 +108,28 @@ export type GetTestPostgresDatabaseOptions = { /** * If `getTestPostgresDatabase()` is called multiple times with the same `key` and `params`, the same database is guaranteed to be returned. */ + /** + * By default, `ava-postgres` will create a new database for each test. If you want to share a database between tests, you can use the `databaseDedupeKey` option. + * This works across the entire test suite. + * + * Note that if unique parameters are passed to the `beforeTemplateIsBaked` (`null` in the above example), separate databases will still be created. + * @example + * ```ts + * import test from "ava" + * + * const getTestPostgresDatabase = getTestPostgresDatabaseFactory({}) + * + * test("foo", async (t) => { + * const connection1 = await getTestPostgresDatabase(t, null, { + * databaseDedupeKey: "foo", + * }) + * const connection2 = await getTestPostgresDatabase(t, null, { + * databaseDedupeKey: "foo", + * }) + * t.is(connection1.database, connection2.database) + * }) + * ``` + */ databaseDedupeKey?: string } From 43cd15f9255760b3fd264343e8f273a88064510e Mon Sep 17 00:00:00 2001 From: Max Isom Date: Mon, 8 Apr 2024 16:58:57 -0700 Subject: [PATCH 3/6] New structure working --- src/index.ts | 53 +++++++++++++++++++++------------------- src/internal-types.ts | 8 +++++- src/public-types.ts | 32 +++++++++++++++--------- src/tests/hooks.test.ts | 51 +++++++++++++++++--------------------- src/worker.ts | 54 +++++++++++++++++++++++++++++++---------- 5 files changed, 118 insertions(+), 80 deletions(-) diff --git a/src/index.ts b/src/index.ts index ecc299e..6861bb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import type { GetTestPostgresDatabase, GetTestPostgresDatabaseFactoryOptions, GetTestPostgresDatabaseOptions, + GetTestPostgresDatabaseResult, } from "./public-types" import { Pool } from "pg" import type { Jsonifiable } from "type-fest" @@ -104,7 +105,7 @@ export const getTestPostgresDatabaseFactory = < Params extends Jsonifiable = never >( options?: GetTestPostgresDatabaseFactoryOptions -) => { +): GetTestPostgresDatabase => { const initialData: InitialWorkerData = { postgresVersion: options?.postgresVersion ?? "14", containerOptions: options?.container, @@ -157,41 +158,28 @@ export const getTestPostgresDatabaseFactory = < throw error }) - const createdNestedConnections: ConnectionDetails[] = [] const hookResult = await options.beforeTemplateIsBaked({ params: params as any, connection: connectionDetails, containerExec: async (command): Promise => rpc.execCommandInContainer(command), // This is what allows a consumer to get a "nested" database from within their beforeTemplateIsBaked hook - beforeTemplateIsBaked: async (options) => { - const { connectionDetails, beforeTemplateIsBakedResult } = - await rpc.getTestDatabase({ - params: options.params, - databaseDedupeKey: options.databaseDedupeKey, - }) - - const mappedConnection = + manuallyBuildAdditionalTemplate: async () => { + const connection = mapWorkerConnectionDetailsToConnectionDetails( - connectionDetails + await rpc.createEmptyDatabase() ) - createdNestedConnections.push(mappedConnection) - return { - ...mappedConnection, - beforeTemplateIsBakedResult, + connection, + finish: async () => { + await teardownConnection(connection) + return rpc.convertDatabaseToTemplate(connection.database) + }, } }, }) - await Promise.all( - createdNestedConnections.map(async (connection) => { - await teardownConnection(connection) - await rpc.dropDatabase(connection.database) - }) - ) - await teardownConnection(connectionDetails) if (hookResult && !isSerializable(hookResult)) { @@ -226,11 +214,11 @@ export const getTestPostgresDatabaseFactory = < } })() - const getTestPostgresDatabase: GetTestPostgresDatabase = async ( + const getTestPostgresDatabase = async ( t: ExecutionContext, params: any, getTestDatabaseOptions?: GetTestPostgresDatabaseOptions - ) => { + ): Promise => { const testDatabaseConnection = await rpc.getTestDatabase({ databaseDedupeKey: getTestDatabaseOptions?.databaseDedupeKey, params, @@ -251,7 +239,22 @@ export const getTestPostgresDatabaseFactory = < } } - return getTestPostgresDatabase + getTestPostgresDatabase.fromTemplate = async ( + t: ExecutionContext, + templateName: string + ) => { + const connection = mapWorkerConnectionDetailsToConnectionDetails( + await rpc.createDatabaseFromTemplate(templateName) + ) + + t.teardown(async () => { + await teardownConnection(connection) + }) + + return connection + } + + return getTestPostgresDatabase as any } export * from "./public-types" diff --git a/src/internal-types.ts b/src/internal-types.ts index cb8f11b..20aeac6 100644 --- a/src/internal-types.ts +++ b/src/internal-types.ts @@ -30,5 +30,11 @@ export interface SharedWorkerFunctions { beforeTemplateIsBakedResult: unknown }> execCommandInContainer: (command: string[]) => Promise - dropDatabase: (databaseName: string) => Promise + createEmptyDatabase: () => Promise + createDatabaseFromTemplate: ( + templateName: string + ) => Promise + convertDatabaseToTemplate: ( + databaseName: string + ) => Promise<{ templateName: string }> } diff --git a/src/public-types.ts b/src/public-types.ts index dc02508..4ea8e54 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -1,8 +1,8 @@ import type { Pool } from "pg" import type { Jsonifiable } from "type-fest" -import { ExecutionContext } from "ava" -import { ExecResult } from "testcontainers" -import { BindMode } from "testcontainers/build/types" +import type { ExecutionContext } from "ava" +import type { ExecResult } from "testcontainers" +import type { BindMode } from "testcontainers/build/types" export interface ConnectionDetails { connectionString: string @@ -92,11 +92,10 @@ export interface GetTestPostgresDatabaseFactoryOptions< * }) * ``` */ - beforeTemplateIsBaked: ( - options: { - params: Params - } & Pick - ) => Promise + manuallyBuildAdditionalTemplate: () => Promise<{ + connection: ConnectionDetails + finish: () => Promise<{ templateName: string }> + }> }) => Promise } @@ -155,14 +154,23 @@ export type GetTestPostgresDatabaseOptions = { // https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 type IsNeverType = [T] extends [never] ? true : false +interface BaseGetTestPostgresDatabase { + fromTemplate( + t: ExecutionContext, + templateName: string + ): Promise +} + export type GetTestPostgresDatabase = IsNeverType extends true - ? ( + ? (( t: ExecutionContext, args?: null, options?: GetTestPostgresDatabaseOptions - ) => Promise - : ( + ) => Promise) & + BaseGetTestPostgresDatabase + : (( t: ExecutionContext, args: Params, options?: GetTestPostgresDatabaseOptions - ) => Promise + ) => Promise) & + BaseGetTestPostgresDatabase diff --git a/src/tests/hooks.test.ts b/src/tests/hooks.test.ts index 64636a3..f05682f 100644 --- a/src/tests/hooks.test.ts +++ b/src/tests/hooks.test.ts @@ -148,46 +148,39 @@ test("beforeTemplateIsBaked (result isn't serializable)", async (t) => { }) test("beforeTemplateIsBaked, get nested database", async (t) => { - type DatabaseParams = { - type: "foo" | "bar" - } - - let nestedDatabaseName: string | undefined = undefined - - const getTestServer = getTestPostgresDatabaseFactory({ + const getTestDatabase = getTestPostgresDatabaseFactory({ postgresVersion: process.env.POSTGRES_VERSION, workerDedupeKey: "beforeTemplateIsBakedHookNestedDatabase", beforeTemplateIsBaked: async ({ - params, connection: { pool }, - beforeTemplateIsBaked, + manuallyBuildAdditionalTemplate, }) => { - if (params.type === "foo") { - await pool.query(`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`) - return { createdFoo: true } - } - await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`) - const fooDatabase = await beforeTemplateIsBaked({ - params: { type: "foo" }, - }) - t.deepEqual(fooDatabase.beforeTemplateIsBakedResult, { createdFoo: true }) - nestedDatabaseName = fooDatabase.database + const fooTemplateBuilder = await manuallyBuildAdditionalTemplate() + await fooTemplateBuilder.connection.pool.query( + `CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)` + ) + const { templateName: fooTemplateName } = + await fooTemplateBuilder.finish() - await t.notThrowsAsync(async () => { - await fooDatabase.pool.query(`INSERT INTO "foo" DEFAULT VALUES`) - }) - - return { createdBar: true } + return { fooTemplateName } }, }) - const database = await getTestServer(t, { type: "bar" }) - t.deepEqual(database.beforeTemplateIsBakedResult, { createdBar: true }) + const barDatabase = await getTestDatabase(t) + t.truthy(barDatabase.beforeTemplateIsBakedResult.fooTemplateName) - t.false( - await doesDatabaseExist(database.pool, nestedDatabaseName!), - "Nested database should have been cleaned up after the parent hook completed" + const fooDatabase = await getTestDatabase.fromTemplate( + t, + barDatabase.beforeTemplateIsBakedResult.fooTemplateName ) + + await t.notThrowsAsync(async () => { + await fooDatabase.pool.query('SELECT * FROM "foo"') + }) + + await t.throwsAsync(async () => { + await fooDatabase.pool.query('SELECT * FROM "bar"') + }) }) diff --git a/src/worker.ts b/src/worker.ts index 5e2e409..6ca2ee0 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -73,9 +73,31 @@ export class Worker { const container = (await this.startContainerPromise).container return container.exec(command) }, - dropDatabase: async (databaseName) => { + createEmptyDatabase: async () => { const { postgresClient } = await this.startContainerPromise - await postgresClient.query(`DROP DATABASE ${databaseName}`) + const databaseName = getRandomDatabaseName() + await postgresClient.query(`CREATE DATABASE ${databaseName}`) + return this.getConnectionDetails(databaseName) + }, + createDatabaseFromTemplate: async (templateName) => { + const { postgresClient } = await this.startContainerPromise + const databaseName = getRandomDatabaseName() + await postgresClient.query( + `CREATE DATABASE ${databaseName} WITH TEMPLATE ${templateName};` + ) + + testWorker.teardown(async () => { + await this.teardownDatabase(databaseName) + }) + + return this.getConnectionDetails(databaseName) + }, + convertDatabaseToTemplate: async (databaseName) => { + const { postgresClient } = await this.startContainerPromise + await postgresClient.query( + `ALTER DATABASE ${databaseName} WITH is_template TRUE;` + ) + return { templateName: databaseName } }, }, rpcChannel @@ -152,17 +174,7 @@ export class Worker { return } - try { - await this.forceDisconnectClientsFrom(databaseName!) - await postgresClient.query(`DROP DATABASE ${databaseName}`) - } catch (error) { - if ((error as Error)?.message?.includes("does not exist")) { - // Database was likely a nested database and manually dropped by the test worker, ignore - return - } - - throw error - } + await this.teardownDatabase(databaseName!) }) return { @@ -171,6 +183,22 @@ export class Worker { } } + private async teardownDatabase(databaseName: string) { + const { postgresClient } = await this.startContainerPromise + + try { + await this.forceDisconnectClientsFrom(databaseName!) + await postgresClient.query(`DROP DATABASE ${databaseName}`) + } catch (error) { + if ((error as Error)?.message?.includes("does not exist")) { + // Database was likely a nested database and manually dropped by the test worker, ignore + return + } + + throw error + } + } + private async createTemplate(rpc: WorkerRpc, params?: Jsonifiable) { const databaseName = getRandomDatabaseName() From 26123daf4e275bba180ccc5e292c364c5888f1c8 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Mon, 8 Apr 2024 17:11:58 -0700 Subject: [PATCH 4/6] Fix docs --- README.md | 56 +++++++++++------ src/public-types.ts | 79 +++++++++++------------- src/tests/cleanup/does-database-exist.ts | 35 ----------- src/tests/hooks.test.ts | 6 +- 4 files changed, 75 insertions(+), 101 deletions(-) delete mode 100644 src/tests/cleanup/does-database-exist.ts diff --git a/README.md b/README.md index 2538bf0..5327cd7 100644 --- a/README.md +++ b/README.md @@ -148,40 +148,56 @@ This works across the entire test suite. Note that if unique parameters are passed to the `beforeTemplateIsBaked` (`null` in the above example), separate databases will still be created. -### "Nested" `beforeTemplateIsBaked` calls +### Manual template creation -In some cases, if you do extensive setup in your `beforeTemplateIsBaked` hook, you might want to obtain a separate, additional database within it if your application uses several databases for different purposes. This is possible by using the passed `beforeTemplateIsBaked` to your hook callback: +In some cases, if you do extensive setup in your `beforeTemplateIsBaked` hook, you might want to obtain a separate, additional database within it if your application uses several databases for different purposes. This is possible by using the `manuallyBuildAdditionalTemplate()` function passed to your hook callback: ```ts -type DatabaseParams = { - type: "foo" | "bar" -} +import test from "ava" -const getTestServer = getTestPostgresDatabaseFactory({ +const getTestDatabase = getTestPostgresDatabaseFactory({ beforeTemplateIsBaked: async ({ params, connection: { pool }, - beforeTemplateIsBaked, + manuallyBuildAdditionalTemplate, }) => { - if (params.type === "foo") { - await pool.query(`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`) - // Important: return early to avoid infinite loop - return - } - await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`) - // This created database will be torn down at the end of the top-level `beforeTemplateIsBaked` call - const fooDatabase = await beforeTemplateIsBaked({ - params: { type: "foo" }, - }) - // This works now - await fooDatabase.pool.query(`INSERT INTO "foo" DEFAULT VALUES`) + const fooTemplateBuilder = await manuallyBuildAdditionalTemplate() + await fooTemplateBuilder.connection.pool.query( + `CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)` + ) + const { templateName: fooTemplateName } = await fooTemplateBuilder.finish() + + return { fooTemplateName } }, }) + +test("foo", async (t) => { + const barDatabase = await getTestDatabase({ type: "bar" }) + + // the "bar" database has the "bar" table... + await t.notThrowsAsync(async () => { + await barDatabase.pool.query(`SELECT * FROM "bar"`) + }) + + // ...but not the "foo" table... + await t.throwsAsync(async () => { + await barDatabase.pool.query(`SELECT * FROM "foo"`) + }) + + // ...and we can obtain a separate database with the "foo" table + const fooDatabase = await getTestDatabase.fromTemplate( + t, + barDatabase.beforeTemplateIsBakedResult.fooTemplateName + ) + await t.notThrowsAsync(async () => { + await fooDatabase.pool.query(`SELECT * FROM "foo"`) + }) +}) ``` -Be very careful when using this to avoid infinite loops. +Although it's not shown in the above example, because of `ava-postgres`'s automatic de-duping by parameter combinations, any returned template name is "linked" to the parameters passed to the `getTestDatabase()` function. ### Bind mounts & `exec`ing in the container diff --git a/src/public-types.ts b/src/public-types.ts index 4ea8e54..92e4d2c 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -59,37 +59,52 @@ export interface GetTestPostgresDatabaseFactoryOptions< params: Params containerExec: (command: string[]) => Promise /** + * In some cases, if you do extensive setup in your `beforeTemplateIsBaked` hook, you might want to obtain a separate, additional database within it if your application uses several databases for different purposes. * - * In some cases, if you do extensive setup in your `beforeTemplateIsBaked` hook, you might want to obtain a separate, additional database within it if your application uses several databases for different purposes. This is possible by using the passed `beforeTemplateIsBaked` to your hook callback. - * Be very careful when using this to avoid infinite loops. * @example * ```ts - * type DatabaseParams = { - * type: "foo" | "bar" - * } - - * const getTestServer = getTestPostgresDatabaseFactory({ + * import test from "ava" + * + * const getTestDatabase = getTestPostgresDatabaseFactory({ * beforeTemplateIsBaked: async ({ * params, * connection: { pool }, - * beforeTemplateIsBaked, + * manuallyBuildAdditionalTemplate, * }) => { - * if (params.type === "foo") { - * await pool.query(`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`) - * // Important: return early to avoid infinite loop - * return - * } - * await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`) - * // This created database will be torn down at the end of the top-level `beforeTemplateIsBaked` call - * const fooDatabase = await beforeTemplateIsBaked({ - * params: { type: "foo" }, - * }) - - * // This works now - * await fooDatabase.pool.query(`INSERT INTO "foo" DEFAULT VALUES`) + * + * const fooTemplateBuilder = await manuallyBuildAdditionalTemplate() + * await fooTemplateBuilder.connection.pool.query( + * `CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)` + * ) + * const { templateName: fooTemplateName } = await fooTemplateBuilder.finish() + * + * return { fooTemplateName } * }, * }) + * + * test("foo", async (t) => { + * const barDatabase = await getTestDatabase({ type: "bar" }) + * + * // the "bar" database has the "bar" table... + * await t.notThrowsAsync(async () => { + * await barDatabase.pool.query(`SELECT * FROM "bar"`) + * }) + * + * // ...but not the "foo" table... + * await t.throwsAsync(async () => { + * await barDatabase.pool.query(`SELECT * FROM "foo"`) + * }) + * + * // ...and we can obtain a separate database with the "foo" table + * const fooDatabase = await getTestDatabase.fromTemplate( + * t, + * barDatabase.beforeTemplateIsBakedResult.fooTemplateName + * ) + * await t.notThrowsAsync(async () => { + * await fooDatabase.pool.query(`SELECT * FROM "foo"`) + * }) + * }) * ``` */ manuallyBuildAdditionalTemplate: () => Promise<{ @@ -104,28 +119,6 @@ export interface GetTestPostgresDatabaseResult extends ConnectionDetails { } export type GetTestPostgresDatabaseOptions = { - /** - * By default, `ava-postgres` will create a new database for each test. If you want to share a database between tests, you can use the `databaseDedupeKey` option. - * This works across the entire test suite. - * - * Note that if unique parameters are passed to the `beforeTemplateIsBaked` (`null` in the above example), separate databases will still be created. - * @example - * ```ts - * import test from "ava" - * - * const getTestPostgresDatabase = getTestPostgresDatabaseFactory({}) - * - * test("foo", async (t) => { - * const connection1 = await getTestPostgresDatabase(t, null, { - * databaseDedupeKey: "foo", - * }) - * const connection2 = await getTestPostgresDatabase(t, null, { - * databaseDedupeKey: "foo", - * }) - * t.is(connection1.database, connection2.database) - * }) - * ``` - */ /** * By default, `ava-postgres` will create a new database for each test. If you want to share a database between tests, you can use the `databaseDedupeKey` option. * This works across the entire test suite. diff --git a/src/tests/cleanup/does-database-exist.ts b/src/tests/cleanup/does-database-exist.ts deleted file mode 100644 index c21dc81..0000000 --- a/src/tests/cleanup/does-database-exist.ts +++ /dev/null @@ -1,35 +0,0 @@ -import test from "ava" -import pRetry from "p-retry" -import { getTestPostgresDatabaseFactory } from "~/index" - -const getTestServer = getTestPostgresDatabaseFactory({ - postgresVersion: process.env.POSTGRES_VERSION, -}) - -const NUM_OF_DEFAULT_POSTGRES_DATABASES = 1 - -test("database that first test worker created should have been dropped", async (t) => { - const { pool } = await getTestServer(t) - - await pRetry( - async () => { - const { - rows: [{ count }], - } = await pool.query( - 'SELECT COUNT(*) FROM "pg_database" WHERE "datistemplate" = false' - ) - - // (Add one since we create a database in this test) - if (Number(count) !== NUM_OF_DEFAULT_POSTGRES_DATABASES + 1) { - throw new Error("Database was not dropped") - } - }, - { - minTimeout: 100, - factor: 1, - maxRetryTime: 30_000, - } - ) - - t.pass() -}) diff --git a/src/tests/hooks.test.ts b/src/tests/hooks.test.ts index f05682f..6a034f1 100644 --- a/src/tests/hooks.test.ts +++ b/src/tests/hooks.test.ts @@ -147,10 +147,10 @@ test("beforeTemplateIsBaked (result isn't serializable)", async (t) => { ) }) -test("beforeTemplateIsBaked, get nested database", async (t) => { +test("beforeTemplateIsBaked with manual template build", async (t) => { const getTestDatabase = getTestPostgresDatabaseFactory({ postgresVersion: process.env.POSTGRES_VERSION, - workerDedupeKey: "beforeTemplateIsBakedHookNestedDatabase", + workerDedupeKey: "beforeTemplateIsBakedHookManualTemplateBuild", beforeTemplateIsBaked: async ({ connection: { pool }, manuallyBuildAdditionalTemplate, @@ -178,7 +178,7 @@ test("beforeTemplateIsBaked, get nested database", async (t) => { await t.notThrowsAsync(async () => { await fooDatabase.pool.query('SELECT * FROM "foo"') - }) + }, "foo table should exist on database manually created from template") await t.throwsAsync(async () => { await fooDatabase.pool.query('SELECT * FROM "bar"') From 87eacd3ac4417031877c119ec35e18ccbfb91509 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Mon, 8 Apr 2024 17:18:06 -0700 Subject: [PATCH 5/6] Cleanup --- src/tests/cleanup/does-database-exist.ts | 35 ++++++++++++++++++++++++ src/tests/hooks.test.ts | 1 - src/tests/utils/does-database-exist.ts | 12 -------- 3 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 src/tests/cleanup/does-database-exist.ts delete mode 100644 src/tests/utils/does-database-exist.ts diff --git a/src/tests/cleanup/does-database-exist.ts b/src/tests/cleanup/does-database-exist.ts new file mode 100644 index 0000000..c21dc81 --- /dev/null +++ b/src/tests/cleanup/does-database-exist.ts @@ -0,0 +1,35 @@ +import test from "ava" +import pRetry from "p-retry" +import { getTestPostgresDatabaseFactory } from "~/index" + +const getTestServer = getTestPostgresDatabaseFactory({ + postgresVersion: process.env.POSTGRES_VERSION, +}) + +const NUM_OF_DEFAULT_POSTGRES_DATABASES = 1 + +test("database that first test worker created should have been dropped", async (t) => { + const { pool } = await getTestServer(t) + + await pRetry( + async () => { + const { + rows: [{ count }], + } = await pool.query( + 'SELECT COUNT(*) FROM "pg_database" WHERE "datistemplate" = false' + ) + + // (Add one since we create a database in this test) + if (Number(count) !== NUM_OF_DEFAULT_POSTGRES_DATABASES + 1) { + throw new Error("Database was not dropped") + } + }, + { + minTimeout: 100, + factor: 1, + maxRetryTime: 30_000, + } + ) + + t.pass() +}) diff --git a/src/tests/hooks.test.ts b/src/tests/hooks.test.ts index 6a034f1..2340170 100644 --- a/src/tests/hooks.test.ts +++ b/src/tests/hooks.test.ts @@ -1,7 +1,6 @@ import test from "ava" import { getTestPostgresDatabaseFactory } from "~/index" import { countDatabaseTemplates } from "./utils/count-database-templates" -import { doesDatabaseExist } from "./utils/does-database-exist" test("beforeTemplateIsBaked", async (t) => { let wasHookCalled = false diff --git a/src/tests/utils/does-database-exist.ts b/src/tests/utils/does-database-exist.ts deleted file mode 100644 index b1d3b42..0000000 --- a/src/tests/utils/does-database-exist.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Pool } from "pg" - -export const doesDatabaseExist = async (pool: Pool, databaseName: string) => { - const { - rows: [{ count }], - } = await pool.query( - 'SELECT COUNT(*) FROM "pg_database" WHERE "datname" = $1', - [databaseName] - ) - - return count > 0 -} From ac6644023a376ae1a36c1d2cf0f61fa29b83c58c Mon Sep 17 00:00:00 2001 From: Max Isom Date: Tue, 9 Apr 2024 15:15:43 -0700 Subject: [PATCH 6/6] Cleanup docs --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 5327cd7..5d3e60a 100644 --- a/README.md +++ b/README.md @@ -197,8 +197,6 @@ test("foo", async (t) => { }) ``` -Although it's not shown in the above example, because of `ava-postgres`'s automatic de-duping by parameter combinations, any returned template name is "linked" to the parameters passed to the `getTestDatabase()` function. - ### Bind mounts & `exec`ing in the container `ava-postgres` uses [testcontainers](https://www.npmjs.com/package/testcontainers) under the hood to manage the Postgres container.