Skip to content

Implement custom migration table name #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,24 @@ async function() {
}
```

You can pass a custom migration table name:

```typescript
await migrate(dbConfig, "path/to/migration/files", {
migrationTableName: "my_migrations",
})
```

This could, alternatively, be a table in an existing schema:

```typescript
await createDb(databaseName, {client})
await client.query("CREATE SCHEMA IF NOT EXISTS my_schema")
await migrate(dbConfig, "path/to/migration/files", {
migrationTableName: "my_schema.migrations",
})
```

## Design decisions

### No down migrations
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"check-formatting": "./node_modules/.bin/prettier '**/*.ts' --list-different",
"fix-formatting": "./node_modules/.bin/prettier '**/*.ts' --write",
"lint": "npm run tslint && npm run check-formatting",
"tslint": "tslint 'src/**/*.ts' --type-check --project tsconfig.json --format verbose",
"tslint": "tslint 'src/**/*.ts' --project tsconfig.json --format verbose",
"test-integration": "ava --config ava.config.integration.cjs",
"test-unit": "ava --config ava.config.unit.cjs",
"test": "npm run test-unit && npm run lint && npm run test-integration",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE TABLE success (
id integer
);
11 changes: 11 additions & 0 deletions src/__tests__/fixtures/success-existing-db/restore.sql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = `
CREATE TABLE migrations (
id integer PRIMARY KEY,
name character varying(100) NOT NULL UNIQUE,
hash character varying(40) NOT NULL,
executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO migrations ("id","name","hash","executed_at")
VALUES (0,E'create-migrations-table',E'e18db593bcde2aca2a408c4d1100f6abba2195df',E'2020-06-29 18:38:05.064546');
`
97 changes: 88 additions & 9 deletions src/__tests__/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,61 @@ test("with pool client", async (t) => {
}
})

test("with custom migration table name", async (t) => {
const databaseName = "migration-test-custom-migration-table"
const dbConfig = {
database: databaseName,
user: "postgres",
password: PASSWORD,
host: "localhost",
port,
}

const migrateWithCustomMigrationTable = () =>
migrate(dbConfig, "src/__tests__/fixtures/success-first", {
migrationTableName: "my_migrations",
})

await createDb(databaseName, dbConfig)
await migrateWithCustomMigrationTable()

t.truthy(await doesTableExist(dbConfig, "my_migrations"))
t.truthy(await doesTableExist(dbConfig, "success"))

await migrateWithCustomMigrationTable()
})

test("with custom migration table name in a custom schema", async (t) => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this test satisfy what you were asking for in #33 (comment)?

const databaseName = "migration-test-custom-schema-custom-migration-table"
const dbConfig = {
database: databaseName,
user: "postgres",
password: PASSWORD,
host: "localhost",
port,
}

const migrateWithCustomMigrationTable = () =>
migrate(dbConfig, "src/__tests__/fixtures/success-first", {
migrationTableName: "my_schema.my_migrations",
})

const pool = new pg.Pool(dbConfig)

try {
await createDb(databaseName, dbConfig)
await pool.query("CREATE SCHEMA IF NOT EXISTS my_schema")
await migrateWithCustomMigrationTable()

t.truthy(await doesTableExist(dbConfig, "my_schema.my_migrations"))
t.truthy(await doesTableExist(dbConfig, "success"))

await migrateWithCustomMigrationTable()
} finally {
await pool.end()
}
})

test("successful first migration", (t) => {
const databaseName = "migration-test-success-first"
const dbConfig = {
Expand Down Expand Up @@ -284,6 +339,31 @@ test("successful complex js migration", (t) => {
})
})

test("successful migration on an existing database", async (t) => {
const databaseName = "migration-test-success-existing-db"
const dbConfig = {
database: databaseName,
user: "postgres",
password: PASSWORD,
host: "localhost",
port,
}

const pool = new pg.Pool(dbConfig)

try {
await createDb(databaseName, dbConfig)
await pool.query(require("./fixtures/success-existing-db/restore.sql"))
await migrate(
dbConfig,
"src/__tests__/fixtures/success-existing-db/migrations",
)
t.truthy(await doesTableExist(dbConfig, "success"))
} finally {
await pool.end()
}
})

test("bad arguments - no db config", (t) => {
// tslint:disable-next-line no-any
return t.throwsAsync((migrate as any)()).then((err) => {
Expand Down Expand Up @@ -636,28 +716,27 @@ function doesTableExist(dbConfig: pg.ClientConfig, tableName: string) {
.connect()
.then(() =>
client.query(SQL`
SELECT EXISTS (
SELECT 1
FROM pg_catalog.pg_class c
WHERE c.relname = ${tableName}
AND c.relkind = 'r'
);
SELECT to_regclass(${tableName}) as matching_tables
`),
)
.then((result) => {
try {
return client
.end()
.then(() => {
return result.rows.length > 0 && result.rows[0].exists
return (
result.rows.length > 0 && result.rows[0].matching_tables !== null
)
})
.catch((error) => {
console.log("Async error in 'doesTableExist", error)
return result.rows.length > 0 && result.rows[0].exists
return (
result.rows.length > 0 && result.rows[0].matching_tables !== null
)
})
} catch (error) {
console.log("Sync error in 'doesTableExist", error)
return result.rows.length > 0 && result.rows[0].exists
return result.rows.length > 0 && result.rows[0].matching_tables !== null
}
})
}
16 changes: 11 additions & 5 deletions src/files-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as fs from "fs"
import * as path from "path"
import {promisify} from "util"
import {load as loadMigrationFile} from "./migration-file"
import {loadInitialMigration} from "./initial-migration"
import {Logger, Migration} from "./types"

const readDir = promisify(fs.readdir)
Expand All @@ -11,24 +12,29 @@ const isValidFile = (fileName: string) => /\.(sql|js)$/gi.test(fileName)
export const load = async (
directory: string,
log: Logger,
migrationTableName: string,
): Promise<Array<Migration>> => {
log(`Loading migrations from: ${directory}`)

const fileNames = await readDir(directory)
log(`Found migration files: ${fileNames}`)

if (fileNames != null) {
const migrationFiles = [
path.join(__dirname, "migrations/0_create-migrations-table.sql"),
...fileNames.map((fileName) => path.resolve(directory, fileName)),
].filter(isValidFile)
const migrationFiles = fileNames
.map((fileName) => path.resolve(directory, fileName))
.filter(isValidFile)

const unorderedMigrations = await Promise.all(
migrationFiles.map(loadMigrationFile),
)

const initialMigration = await loadInitialMigration(migrationTableName)

// Arrange in ID order
return unorderedMigrations.sort((a, b) => a.id - b.id)
return [
initialMigration,
...unorderedMigrations.sort((a, b) => a.id - b.id),
]
}

return []
Expand Down
29 changes: 29 additions & 0 deletions src/initial-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {hashString} from "./migration-file"

export const loadInitialMigration = async (migrationTableName: string) => {
// Since the hash of the initial migration is distributed across users' databases
// the values `fileName` and `sql` must NEVER change!
const fileName = "0_create-migrations-table.sql"
const sql = getInitialMigrationSql(migrationTableName)
const hash = hashString(fileName + sql)

return {
id: 0,
name: "create-migrations-table",
contents: sql,
fileName,
hash,
sql,
}
}

// Formatting must not change to ensure content hash remains the same
export const getInitialMigrationSql = (
migrationTableName: string,
) => `CREATE TABLE IF NOT EXISTS ${migrationTableName} (
id integer PRIMARY KEY,
name varchar(100) UNIQUE NOT NULL,
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
executed_at timestamp DEFAULT current_timestamp
);
`
4 changes: 2 additions & 2 deletions src/load-sql-from-js.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as path from "path"

export const loadSqlFromJs = (filePath: string): string => {
export const loadSqlFromJs = (filePath: string, context?: {}): string => {
const migrationModule = require(filePath)
if (!migrationModule.generateSql) {
throw new Error(`Invalid javascript migration file: '${path.basename(
filePath,
)}'.
It must to export a 'generateSql' function.`)
}
const generatedValue = migrationModule.generateSql()
const generatedValue = migrationModule.generateSql(context)
if (typeof generatedValue !== "string") {
throw new Error(`Invalid javascript migration file: '${path.basename(
filePath,
Expand Down
45 changes: 30 additions & 15 deletions src/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export async function migrate(
migrationsDirectory: string,
config: Config = {},
): Promise<Array<Migration>> {
const migrationTableName =
typeof config.migrationTableName === "string"
? config.migrationTableName
: "migrations"

const log =
config.logger != null
? config.logger
Expand All @@ -32,13 +37,18 @@ export async function migrate(
if (typeof migrationsDirectory !== "string") {
throw new Error("Must pass migrations directory as a string")
}
const intendedMigrations = await load(migrationsDirectory, log)

const intendedMigrations = await load(
migrationsDirectory,
log,
migrationTableName,
)

if ("client" in dbConfig) {
// we have been given a client to use, it should already be connected
return withAdvisoryLock(
log,
runMigrations(intendedMigrations, log),
runMigrations(intendedMigrations, log, migrationTableName),
)(dbConfig.client)
}

Expand All @@ -59,17 +69,22 @@ export async function migrate(

const runWith = withConnection(
log,
withAdvisoryLock(log, runMigrations(intendedMigrations, log)),
withAdvisoryLock(
log,
runMigrations(intendedMigrations, log, migrationTableName),
),
)

return runWith(client)
}

function runMigrations(intendedMigrations: Array<Migration>, log: Logger) {
function runMigrations(
intendedMigrations: Array<Migration>,
log: Logger,
migrationTableName: string,
) {
return async (client: BasicPgClient) => {
try {
const migrationTableName = "migrations"

log("Starting migrations")

const appliedMigrations = await fetchAppliedMigrationFromDB(
Expand Down Expand Up @@ -190,13 +205,13 @@ function logResult(completedMigrations: Array<Migration>, log: Logger) {
}

/** Check whether table exists in postgres - http://stackoverflow.com/a/24089729 */
async function doesTableExist(client: BasicPgClient, tableName: string) {
const result = await client.query(SQL`SELECT EXISTS (
SELECT 1
FROM pg_catalog.pg_class c
WHERE c.relname = ${tableName}
AND c.relkind = 'r'
);`)

return result.rows.length > 0 && result.rows[0].exists
async function doesTableExist(
client: BasicPgClient,
migrationTableName: string,
) {
const result = await client.query(SQL`
SELECT to_regclass(${migrationTableName}) as matching_tables
Copy link
Author

@djgrant djgrant Jun 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed to to_reclass as the original query would check if a table name exists in any schema. This meant migrations would broke in the case that a user specifies a schema in migrationTableName.

`)

return result.rows.length > 0 && result.rows[0].matching_tables !== null
}
5 changes: 3 additions & 2 deletions src/migration-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@ const getFileName = (filePath: string) => path.basename(filePath)

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

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

const getSqlStringLiteral = (
filePath: string,
contents: string,
type: "js" | "sql",
context?: {},
) => {
switch (type) {
case "sql":
return contents
case "js":
return loadSqlFromJs(filePath)
return loadSqlFromJs(filePath, context)
default: {
const exhaustiveCheck: never = type
return exhaustiveCheck
Expand Down
6 changes: 0 additions & 6 deletions src/migrations/0_create-migrations-table.sql

This file was deleted.

1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type Config = Partial<FullConfig>

export interface FullConfig {
readonly logger: Logger
readonly migrationTableName: string
}

export class MigrationError extends Error {
Expand Down