Skip to content

Commit acdcdd2

Browse files
committed
Implement custom migration table name
Use most content of PR ThomWright#48
1 parent dbfc5cc commit acdcdd2

File tree

11 files changed

+190
-30
lines changed

11 files changed

+190
-30
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,24 @@ The `loadMigrationFiles` function can be used to check if the migration files sa
8585

8686
Alternatively, use the `pg-validate-migrations` bin script: `pg-validate-migrations "path/to/migration/files"`.
8787

88+
You can pass a custom migration table name:
89+
90+
```typescript
91+
await migrate(dbConfig, "path/to/migration/files", {
92+
migrationTableName: "my_migrations",
93+
})
94+
```
95+
96+
This could, alternatively, be a table in an existing schema:
97+
98+
```typescript
99+
await createDb(databaseName, {client})
100+
await client.query("CREATE SCHEMA IF NOT EXISTS my_schema")
101+
await migrate(dbConfig, "path/to/migration/files", {
102+
migrationTableName: "my_schema.migrations",
103+
})
104+
```
105+
88106
## Design decisions
89107

90108
### No down migrations
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
CREATE TABLE success (
2+
id integer
3+
);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module.exports = `
2+
CREATE TABLE migrations (
3+
id integer PRIMARY KEY,
4+
name character varying(100) NOT NULL UNIQUE,
5+
hash character varying(40) NOT NULL,
6+
executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
7+
);
8+
INSERT INTO migrations ("id","name","hash","executed_at")
9+
VALUES (0,E'create-migrations-table',E'e18db593bcde2aca2a408c4d1100f6abba2195df',E'2020-06-29 18:38:05.064546');
10+
`

src/__tests__/migrate.ts

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,61 @@ test("with pool client", async (t) => {
190190
}
191191
})
192192

193+
test("with custom migration table name", async (t) => {
194+
const databaseName = "migration-test-custom-migration-table"
195+
const dbConfig = {
196+
database: databaseName,
197+
user: "postgres",
198+
password: PASSWORD,
199+
host: "localhost",
200+
port,
201+
}
202+
203+
const migrateWithCustomMigrationTable = () =>
204+
migrate(dbConfig, "src/__tests__/fixtures/success-first", {
205+
migrationTableName: "my_migrations",
206+
})
207+
208+
await createDb(databaseName, dbConfig)
209+
await migrateWithCustomMigrationTable()
210+
211+
t.truthy(await doesTableExist(dbConfig, "my_migrations"))
212+
t.truthy(await doesTableExist(dbConfig, "success"))
213+
214+
await migrateWithCustomMigrationTable()
215+
})
216+
217+
test("with custom migration table name in a custom schema", async (t) => {
218+
const databaseName = "migration-test-custom-schema-custom-migration-table"
219+
const dbConfig = {
220+
database: databaseName,
221+
user: "postgres",
222+
password: PASSWORD,
223+
host: "localhost",
224+
port,
225+
}
226+
227+
const migrateWithCustomMigrationTable = () =>
228+
migrate(dbConfig, "src/__tests__/fixtures/success-first", {
229+
migrationTableName: "my_schema.my_migrations",
230+
})
231+
232+
const pool = new pg.Pool(dbConfig)
233+
234+
try {
235+
await createDb(databaseName, dbConfig)
236+
await pool.query("CREATE SCHEMA IF NOT EXISTS my_schema")
237+
await migrateWithCustomMigrationTable()
238+
239+
t.truthy(await doesTableExist(dbConfig, "my_schema.my_migrations"))
240+
t.truthy(await doesTableExist(dbConfig, "success"))
241+
242+
await migrateWithCustomMigrationTable()
243+
} finally {
244+
await pool.end()
245+
}
246+
})
247+
193248
test("successful first migration", (t) => {
194249
const databaseName = "migration-test-success-first"
195250
const dbConfig = {
@@ -284,6 +339,31 @@ test("successful complex js migration", (t) => {
284339
})
285340
})
286341

342+
test("successful migration on an existing database", async (t) => {
343+
const databaseName = "migration-test-success-existing-db"
344+
const dbConfig = {
345+
database: databaseName,
346+
user: "postgres",
347+
password: PASSWORD,
348+
host: "localhost",
349+
port,
350+
}
351+
352+
const pool = new pg.Pool(dbConfig)
353+
354+
try {
355+
await createDb(databaseName, dbConfig)
356+
await pool.query(require("./fixtures/success-existing-db/restore.sql"))
357+
await migrate(
358+
dbConfig,
359+
"src/__tests__/fixtures/success-existing-db/migrations",
360+
)
361+
t.truthy(await doesTableExist(dbConfig, "success"))
362+
} finally {
363+
await pool.end()
364+
}
365+
})
366+
287367
test("bad arguments - no db config", (t) => {
288368
// tslint:disable-next-line no-any
289369
return t.throwsAsync((migrate as any)()).then((err) => {
@@ -695,28 +775,27 @@ function doesTableExist(dbConfig: pg.ClientConfig, tableName: string) {
695775
.connect()
696776
.then(() =>
697777
client.query(SQL`
698-
SELECT EXISTS (
699-
SELECT 1
700-
FROM pg_catalog.pg_class c
701-
WHERE c.relname = ${tableName}
702-
AND c.relkind = 'r'
703-
);
778+
SELECT to_regclass(${tableName}) as matching_tables
704779
`),
705780
)
706781
.then((result) => {
707782
try {
708783
return client
709784
.end()
710785
.then(() => {
711-
return result.rows.length > 0 && result.rows[0].exists
786+
return (
787+
result.rows.length > 0 && result.rows[0].matching_tables !== null
788+
)
712789
})
713790
.catch((error) => {
714791
console.log("Async error in 'doesTableExist", error)
715-
return result.rows.length > 0 && result.rows[0].exists
792+
return (
793+
result.rows.length > 0 && result.rows[0].matching_tables !== null
794+
)
716795
})
717796
} catch (error) {
718797
console.log("Sync error in 'doesTableExist", error)
719-
return result.rows.length > 0 && result.rows[0].exists
798+
return result.rows.length > 0 && result.rows[0].matching_tables !== null
720799
}
721800
})
722801
}

src/__unit__/migration-file-validation/validate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ test("two migrations with the same id", async (t) => {
99
const error = await t.throwsAsync(async () =>
1010
loadMigrationFiles(
1111
"src/__unit__/migration-file-validation/fixtures/conflict",
12+
() => {}, // tslint:disable-line no-empty
13+
"migrations",
1214
),
1315
)
1416
t.regex(error.message, /non-consecutive/)

src/bin/validate.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ import {loadMigrationFiles} from "../files-loader"
66

77
async function main(args: Array<string>) {
88
const directory = args[0]
9+
const migrationTableName = args[1] ?? "migrations"
910

10-
await loadMigrationFiles(directory, (x) => console.error(x))
11+
await loadMigrationFiles(
12+
directory,
13+
(x) => console.error(x),
14+
migrationTableName,
15+
)
1116
}
1217

1318
main(argv.slice(2)).catch((e) => {

src/files-loader.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from "fs"
22
import * as path from "path"
33
import {promisify} from "util"
4+
import {loadInitialMigration} from "./initial-migration"
45
import {loadMigrationFile} from "./migration-file"
56
import {Logger, Migration} from "./types"
67
import {validateMigrationOrdering} from "./validation"
@@ -21,6 +22,7 @@ export const loadMigrationFiles = async (
2122
directory: string,
2223
// tslint:disable-next-line no-empty
2324
log: Logger = () => {},
25+
migrationTableName: string,
2426
): Promise<Array<Migration>> => {
2527
log(`Loading migrations from: ${directory}`)
2628

@@ -31,17 +33,20 @@ export const loadMigrationFiles = async (
3133
return []
3234
}
3335

34-
const migrationFiles = [
35-
path.join(__dirname, "migrations/0_create-migrations-table.sql"),
36-
...fileNames.map((fileName) => path.resolve(directory, fileName)),
37-
].filter(isValidFile)
36+
const migrationFiles = fileNames
37+
.map((fileName) => path.resolve(directory, fileName))
38+
.filter(isValidFile)
3839

3940
const unorderedMigrations = await Promise.all(
4041
migrationFiles.map(loadMigrationFile),
4142
)
4243

44+
const initialMigration = await loadInitialMigration(migrationTableName)
45+
4346
// Arrange in ID order
44-
const orderedMigrations = unorderedMigrations.sort((a, b) => a.id - b.id)
47+
const orderedMigrations = [initialMigration, ...unorderedMigrations].sort(
48+
(a, b) => a.id - b.id,
49+
)
4550

4651
validateMigrationOrdering(orderedMigrations)
4752

src/initial-migration.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {hashString} from "./migration-file"
2+
3+
export const loadInitialMigration = async (migrationTableName: string) => {
4+
// Since the hash of the initial migration is distributed across users' databases
5+
// the values `fileName` and `sql` must NEVER change!
6+
const fileName = "0_create-migrations-table.sql"
7+
const sql = getInitialMigrationSql(migrationTableName)
8+
const hash = hashString(fileName + sql)
9+
10+
return {
11+
id: 0,
12+
name: "create-migrations-table",
13+
contents: sql,
14+
fileName,
15+
hash,
16+
sql,
17+
}
18+
}
19+
20+
// Formatting must not change to ensure content hash remains the same
21+
export const getInitialMigrationSql = (
22+
migrationTableName: string,
23+
) => `CREATE TABLE IF NOT EXISTS ${migrationTableName} (
24+
id integer PRIMARY KEY,
25+
name varchar(100) UNIQUE NOT NULL,
26+
hash varchar(40) NOT NULL, -- sha1 hex encoded hash of the file name and contents, to ensure it hasn't been altered since applying the migration
27+
executed_at timestamp DEFAULT current_timestamp
28+
);
29+
`

src/migrate.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,26 @@ export async function migrate(
3838
//
3939
}
4040

41+
const migrationTableName = config.migrationTableName ?? "migrations"
42+
4143
if (dbConfig == null) {
4244
throw new Error("No config object")
4345
}
4446

4547
if (typeof migrationsDirectory !== "string") {
4648
throw new Error("Must pass migrations directory as a string")
4749
}
48-
const intendedMigrations = await loadMigrationFiles(migrationsDirectory, log)
50+
const intendedMigrations = await loadMigrationFiles(
51+
migrationsDirectory,
52+
log,
53+
migrationTableName,
54+
)
4955

5056
if ("client" in dbConfig) {
5157
// we have been given a client to use, it should already be connected
5258
return withAdvisoryLock(
5359
log,
54-
runMigrations(intendedMigrations, log),
60+
runMigrations(intendedMigrations, log, migrationTableName),
5561
)(dbConfig.client)
5662
}
5763

@@ -99,18 +105,23 @@ export async function migrate(
99105

100106
const runWith = withConnection(
101107
log,
102-
withAdvisoryLock(log, runMigrations(intendedMigrations, log)),
108+
withAdvisoryLock(
109+
log,
110+
runMigrations(intendedMigrations, log, migrationTableName),
111+
),
103112
)
104113

105114
return runWith(client)
106115
}
107116
}
108117

109-
function runMigrations(intendedMigrations: Array<Migration>, log: Logger) {
118+
function runMigrations(
119+
intendedMigrations: Array<Migration>,
120+
log: Logger,
121+
migrationTableName: string,
122+
) {
110123
return async (client: BasicPgClient) => {
111124
try {
112-
const migrationTableName = "migrations"
113-
114125
log("Starting migrations")
115126

116127
const appliedMigrations = await fetchAppliedMigrationFromDB(
@@ -202,12 +213,9 @@ function logResult(completedMigrations: Array<Migration>, log: Logger) {
202213

203214
/** Check whether table exists in postgres - http://stackoverflow.com/a/24089729 */
204215
async function doesTableExist(client: BasicPgClient, tableName: string) {
205-
const result = await client.query(SQL`SELECT EXISTS (
206-
SELECT 1
207-
FROM pg_catalog.pg_class c
208-
WHERE c.relname = ${tableName}
209-
AND c.relkind = 'r'
210-
);`)
211-
212-
return result.rows.length > 0 && result.rows[0].exists
216+
const result = await client.query(SQL`
217+
SELECT to_regclass(${tableName}) as matching_tables;
218+
`)
219+
220+
return result.rows.length > 0 && result.rows[0].matching_tables !== null
213221
}

src/migration-file.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const getFileName = (filePath: string) => path.basename(filePath)
1111

1212
const getFileContents = async (filePath: string) => readFile(filePath, "utf8")
1313

14-
const hashString = (s: string) =>
14+
export const hashString = (s: string) =>
1515
crypto.createHash("sha1").update(s, "utf8").digest("hex")
1616

1717
const getSqlStringLiteral = (

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export type Config = Partial<FullConfig>
5959

6060
export interface FullConfig {
6161
readonly logger: Logger
62+
readonly migrationTableName: string
6263
}
6364

6465
export class MigrationError extends Error {

0 commit comments

Comments
 (0)