Skip to content

Commit bc608af

Browse files
authored
feat: Merge pull request #29 from seamapi/feat-nested-hook-calls
2 parents 3dd91f7 + ac66440 commit bc608af

File tree

6 files changed

+288
-60
lines changed

6 files changed

+288
-60
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,55 @@ This works across the entire test suite.
148148

149149
Note that if unique parameters are passed to the `beforeTemplateIsBaked` (`null` in the above example), separate databases will still be created.
150150

151+
### Manual template creation
152+
153+
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:
154+
155+
```ts
156+
import test from "ava"
157+
158+
const getTestDatabase = getTestPostgresDatabaseFactory<DatabaseParams>({
159+
beforeTemplateIsBaked: async ({
160+
params,
161+
connection: { pool },
162+
manuallyBuildAdditionalTemplate,
163+
}) => {
164+
await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`)
165+
166+
const fooTemplateBuilder = await manuallyBuildAdditionalTemplate()
167+
await fooTemplateBuilder.connection.pool.query(
168+
`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`
169+
)
170+
const { templateName: fooTemplateName } = await fooTemplateBuilder.finish()
171+
172+
return { fooTemplateName }
173+
},
174+
})
175+
176+
test("foo", async (t) => {
177+
const barDatabase = await getTestDatabase({ type: "bar" })
178+
179+
// the "bar" database has the "bar" table...
180+
await t.notThrowsAsync(async () => {
181+
await barDatabase.pool.query(`SELECT * FROM "bar"`)
182+
})
183+
184+
// ...but not the "foo" table...
185+
await t.throwsAsync(async () => {
186+
await barDatabase.pool.query(`SELECT * FROM "foo"`)
187+
})
188+
189+
// ...and we can obtain a separate database with the "foo" table
190+
const fooDatabase = await getTestDatabase.fromTemplate(
191+
t,
192+
barDatabase.beforeTemplateIsBakedResult.fooTemplateName
193+
)
194+
await t.notThrowsAsync(async () => {
195+
await fooDatabase.pool.query(`SELECT * FROM "foo"`)
196+
})
197+
})
198+
```
199+
151200
### Bind mounts & `exec`ing in the container
152201

153202
`ava-postgres` uses [testcontainers](https://www.npmjs.com/package/testcontainers) under the hood to manage the Postgres container.

src/index.ts

Lines changed: 82 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import type {
1212
GetTestPostgresDatabase,
1313
GetTestPostgresDatabaseFactoryOptions,
1414
GetTestPostgresDatabaseOptions,
15+
GetTestPostgresDatabaseResult,
1516
} from "./public-types"
1617
import { Pool } from "pg"
1718
import type { Jsonifiable } from "type-fest"
1819
import type { ExecutionContext } from "ava"
19-
import { once } from "node:events"
20-
import { createBirpc } from "birpc"
20+
import { BirpcReturn, createBirpc } from "birpc"
2121
import { ExecResult } from "testcontainers"
2222
import isPlainObject from "lodash/isPlainObject"
2323

@@ -105,7 +105,7 @@ export const getTestPostgresDatabaseFactory = <
105105
Params extends Jsonifiable = never
106106
>(
107107
options?: GetTestPostgresDatabaseFactoryOptions<Params>
108-
) => {
108+
): GetTestPostgresDatabase<Params> => {
109109
const initialData: InitialWorkerData = {
110110
postgresVersion: options?.postgresVersion ?? "14",
111111
containerOptions: options?.container,
@@ -136,57 +136,73 @@ export const getTestPostgresDatabaseFactory = <
136136
}
137137

138138
let rpcCallback: (data: any) => void
139-
const rpc = createBirpc<SharedWorkerFunctions, TestWorkerFunctions>(
140-
{
141-
runBeforeTemplateIsBakedHook: async (connection, params) => {
142-
if (options?.beforeTemplateIsBaked) {
143-
const connectionDetails =
144-
mapWorkerConnectionDetailsToConnectionDetails(connection)
145-
146-
// Ignore if the pool is terminated by the shared worker
147-
// (This happens in CI for some reason even though we drain the pool first.)
148-
connectionDetails.pool.on("error", (error) => {
149-
if (
150-
error.message.includes(
151-
"terminating connection due to administrator command"
152-
)
153-
) {
154-
return
155-
}
139+
const rpc: BirpcReturn<SharedWorkerFunctions, TestWorkerFunctions> =
140+
createBirpc<SharedWorkerFunctions, TestWorkerFunctions>(
141+
{
142+
runBeforeTemplateIsBakedHook: async (connection, params) => {
143+
if (options?.beforeTemplateIsBaked) {
144+
const connectionDetails =
145+
mapWorkerConnectionDetailsToConnectionDetails(connection)
156146

157-
throw error
158-
})
147+
// Ignore if the pool is terminated by the shared worker
148+
// (This happens in CI for some reason even though we drain the pool first.)
149+
connectionDetails.pool.on("error", (error) => {
150+
if (
151+
error.message.includes(
152+
"terminating connection due to administrator command"
153+
)
154+
) {
155+
return
156+
}
159157

160-
const hookResult = await options.beforeTemplateIsBaked({
161-
params: params as any,
162-
connection: connectionDetails,
163-
containerExec: async (command): Promise<ExecResult> =>
164-
rpc.execCommandInContainer(command),
165-
})
158+
throw error
159+
})
166160

167-
await teardownConnection(connectionDetails)
161+
const hookResult = await options.beforeTemplateIsBaked({
162+
params: params as any,
163+
connection: connectionDetails,
164+
containerExec: async (command): Promise<ExecResult> =>
165+
rpc.execCommandInContainer(command),
166+
// This is what allows a consumer to get a "nested" database from within their beforeTemplateIsBaked hook
167+
manuallyBuildAdditionalTemplate: async () => {
168+
const connection =
169+
mapWorkerConnectionDetailsToConnectionDetails(
170+
await rpc.createEmptyDatabase()
171+
)
168172

169-
if (hookResult && !isSerializable(hookResult)) {
170-
throw new TypeError(
171-
"Return value of beforeTemplateIsBaked() hook could not be serialized. Make sure it returns only JSON-serializable values."
172-
)
173-
}
173+
return {
174+
connection,
175+
finish: async () => {
176+
await teardownConnection(connection)
177+
return rpc.convertDatabaseToTemplate(connection.database)
178+
},
179+
}
180+
},
181+
})
174182

175-
return hookResult
176-
}
177-
},
178-
},
179-
{
180-
post: async (data) => {
181-
const worker = await workerPromise
182-
await worker.available
183-
worker.publish(data)
184-
},
185-
on: (data) => {
186-
rpcCallback = data
183+
await teardownConnection(connectionDetails)
184+
185+
if (hookResult && !isSerializable(hookResult)) {
186+
throw new TypeError(
187+
"Return value of beforeTemplateIsBaked() hook could not be serialized. Make sure it returns only JSON-serializable values."
188+
)
189+
}
190+
191+
return hookResult
192+
}
193+
},
187194
},
188-
}
189-
)
195+
{
196+
post: async (data) => {
197+
const worker = await workerPromise
198+
await worker.available
199+
worker.publish(data)
200+
},
201+
on: (data) => {
202+
rpcCallback = data
203+
},
204+
}
205+
)
190206

191207
// Automatically cleaned up by AVA since each test file runs in a separate worker
192208
const _messageHandlerPromise = (async () => {
@@ -198,11 +214,11 @@ export const getTestPostgresDatabaseFactory = <
198214
}
199215
})()
200216

201-
const getTestPostgresDatabase: GetTestPostgresDatabase<Params> = async (
217+
const getTestPostgresDatabase = async (
202218
t: ExecutionContext,
203219
params: any,
204220
getTestDatabaseOptions?: GetTestPostgresDatabaseOptions
205-
) => {
221+
): Promise<GetTestPostgresDatabaseResult> => {
206222
const testDatabaseConnection = await rpc.getTestDatabase({
207223
databaseDedupeKey: getTestDatabaseOptions?.databaseDedupeKey,
208224
params,
@@ -223,7 +239,22 @@ export const getTestPostgresDatabaseFactory = <
223239
}
224240
}
225241

226-
return getTestPostgresDatabase
242+
getTestPostgresDatabase.fromTemplate = async (
243+
t: ExecutionContext,
244+
templateName: string
245+
) => {
246+
const connection = mapWorkerConnectionDetailsToConnectionDetails(
247+
await rpc.createDatabaseFromTemplate(templateName)
248+
)
249+
250+
t.teardown(async () => {
251+
await teardownConnection(connection)
252+
})
253+
254+
return connection
255+
}
256+
257+
return getTestPostgresDatabase as any
227258
}
228259

229260
export * from "./public-types"

src/internal-types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,11 @@ export interface SharedWorkerFunctions {
3030
beforeTemplateIsBakedResult: unknown
3131
}>
3232
execCommandInContainer: (command: string[]) => Promise<ExecResult>
33+
createEmptyDatabase: () => Promise<ConnectionDetailsFromWorker>
34+
createDatabaseFromTemplate: (
35+
templateName: string
36+
) => Promise<ConnectionDetailsFromWorker>
37+
convertDatabaseToTemplate: (
38+
databaseName: string
39+
) => Promise<{ templateName: string }>
3340
}

src/public-types.ts

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Pool } from "pg"
22
import type { Jsonifiable } from "type-fest"
3-
import { ExecutionContext } from "ava"
4-
import { ExecResult } from "testcontainers"
5-
import { BindMode } from "testcontainers/build/types"
3+
import type { ExecutionContext } from "ava"
4+
import type { ExecResult } from "testcontainers"
5+
import type { BindMode } from "testcontainers/build/types"
66

77
export interface ConnectionDetails {
88
connectionString: string
@@ -58,6 +58,59 @@ export interface GetTestPostgresDatabaseFactoryOptions<
5858
connection: ConnectionDetails
5959
params: Params
6060
containerExec: (command: string[]) => Promise<ExecResult>
61+
/**
62+
* 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.
63+
*
64+
* @example
65+
* ```ts
66+
* import test from "ava"
67+
*
68+
* const getTestDatabase = getTestPostgresDatabaseFactory<DatabaseParams>({
69+
* beforeTemplateIsBaked: async ({
70+
* params,
71+
* connection: { pool },
72+
* manuallyBuildAdditionalTemplate,
73+
* }) => {
74+
* await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`)
75+
*
76+
* const fooTemplateBuilder = await manuallyBuildAdditionalTemplate()
77+
* await fooTemplateBuilder.connection.pool.query(
78+
* `CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`
79+
* )
80+
* const { templateName: fooTemplateName } = await fooTemplateBuilder.finish()
81+
*
82+
* return { fooTemplateName }
83+
* },
84+
* })
85+
*
86+
* test("foo", async (t) => {
87+
* const barDatabase = await getTestDatabase({ type: "bar" })
88+
*
89+
* // the "bar" database has the "bar" table...
90+
* await t.notThrowsAsync(async () => {
91+
* await barDatabase.pool.query(`SELECT * FROM "bar"`)
92+
* })
93+
*
94+
* // ...but not the "foo" table...
95+
* await t.throwsAsync(async () => {
96+
* await barDatabase.pool.query(`SELECT * FROM "foo"`)
97+
* })
98+
*
99+
* // ...and we can obtain a separate database with the "foo" table
100+
* const fooDatabase = await getTestDatabase.fromTemplate(
101+
* t,
102+
* barDatabase.beforeTemplateIsBakedResult.fooTemplateName
103+
* )
104+
* await t.notThrowsAsync(async () => {
105+
* await fooDatabase.pool.query(`SELECT * FROM "foo"`)
106+
* })
107+
* })
108+
* ```
109+
*/
110+
manuallyBuildAdditionalTemplate: () => Promise<{
111+
connection: ConnectionDetails
112+
finish: () => Promise<{ templateName: string }>
113+
}>
61114
}) => Promise<any>
62115
}
63116

@@ -94,14 +147,23 @@ export type GetTestPostgresDatabaseOptions = {
94147
// https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887
95148
type IsNeverType<T> = [T] extends [never] ? true : false
96149

150+
interface BaseGetTestPostgresDatabase {
151+
fromTemplate(
152+
t: ExecutionContext,
153+
templateName: string
154+
): Promise<ConnectionDetails>
155+
}
156+
97157
export type GetTestPostgresDatabase<Params> = IsNeverType<Params> extends true
98-
? (
158+
? ((
99159
t: ExecutionContext,
100160
args?: null,
101161
options?: GetTestPostgresDatabaseOptions
102-
) => Promise<GetTestPostgresDatabaseResult>
103-
: (
162+
) => Promise<GetTestPostgresDatabaseResult>) &
163+
BaseGetTestPostgresDatabase
164+
: ((
104165
t: ExecutionContext,
105166
args: Params,
106167
options?: GetTestPostgresDatabaseOptions
107-
) => Promise<GetTestPostgresDatabaseResult>
168+
) => Promise<GetTestPostgresDatabaseResult>) &
169+
BaseGetTestPostgresDatabase

src/tests/hooks.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,41 @@ test("beforeTemplateIsBaked (result isn't serializable)", async (t) => {
145145
}
146146
)
147147
})
148+
149+
test("beforeTemplateIsBaked with manual template build", async (t) => {
150+
const getTestDatabase = getTestPostgresDatabaseFactory({
151+
postgresVersion: process.env.POSTGRES_VERSION,
152+
workerDedupeKey: "beforeTemplateIsBakedHookManualTemplateBuild",
153+
beforeTemplateIsBaked: async ({
154+
connection: { pool },
155+
manuallyBuildAdditionalTemplate,
156+
}) => {
157+
await pool.query(`CREATE TABLE "bar" ("id" SERIAL PRIMARY KEY)`)
158+
159+
const fooTemplateBuilder = await manuallyBuildAdditionalTemplate()
160+
await fooTemplateBuilder.connection.pool.query(
161+
`CREATE TABLE "foo" ("id" SERIAL PRIMARY KEY)`
162+
)
163+
const { templateName: fooTemplateName } =
164+
await fooTemplateBuilder.finish()
165+
166+
return { fooTemplateName }
167+
},
168+
})
169+
170+
const barDatabase = await getTestDatabase(t)
171+
t.truthy(barDatabase.beforeTemplateIsBakedResult.fooTemplateName)
172+
173+
const fooDatabase = await getTestDatabase.fromTemplate(
174+
t,
175+
barDatabase.beforeTemplateIsBakedResult.fooTemplateName
176+
)
177+
178+
await t.notThrowsAsync(async () => {
179+
await fooDatabase.pool.query('SELECT * FROM "foo"')
180+
}, "foo table should exist on database manually created from template")
181+
182+
await t.throwsAsync(async () => {
183+
await fooDatabase.pool.query('SELECT * FROM "bar"')
184+
})
185+
})

0 commit comments

Comments
 (0)