Skip to content
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

Implement custom migration table name #48

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
68 changes: 59 additions & 9 deletions src/__tests__/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,57 @@ test("with pool client", async (t) => {
}
})

test("with custom migration table name", async (t) => {
const databaseName = "migration-test-concurrent-no-tx"
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-concurrent-no-tx"
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)

await pool.query("CREATE SCHEMA my_schema")
await createDb(databaseName, dbConfig)
await migrateWithCustomMigrationTable()

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

await migrateWithCustomMigrationTable()
})

test("successful first migration", (t) => {
const databaseName = "migration-test-success-first"
const dbConfig = {
Expand Down Expand Up @@ -636,28 +687,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
}
})
}
8 changes: 4 additions & 4 deletions src/__unit__/migration-file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import {load} from "../../migration-file"

test("Hashes of JS files should be the same when the SQL is the same", async (t) => {
const [js1, js2] = await Promise.all([
load(__dirname + "/fixtures/different-js-same-sql-1/1_js.js"),
load(__dirname + "/fixtures/different-js-same-sql-2/1_js.js"),
load({filePath: __dirname + "/fixtures/different-js-same-sql-1/1_js.js"}),
load({filePath: __dirname + "/fixtures/different-js-same-sql-2/1_js.js"}),
])

t.is(js1.hash, js2.hash)
})

test("Hashes of JS files should be different when the SQL is different", async (t) => {
const [js1, js2] = await Promise.all([
load(__dirname + "/fixtures/same-js-different-sql-1/1_js.js"),
load(__dirname + "/fixtures/same-js-different-sql-2/1_js.js"),
load({filePath: __dirname + "/fixtures/same-js-different-sql-1/1_js.js"}),
load({filePath: __dirname + "/fixtures/same-js-different-sql-2/1_js.js"}),
])

t.not(js1.hash, js2.hash)
Expand Down
15 changes: 12 additions & 3 deletions src/files-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const isValidFile = (fileName: string) => /\.(sql|js)$/gi.test(fileName)
export const load = async (
directory: string,
log: Logger,
setupContext: {migrationTableName: string},
): Promise<Array<Migration>> => {
log(`Loading migrations from: ${directory}`)

Expand All @@ -19,9 +20,17 @@ export const load = async (

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

const unorderedMigrations = await Promise.all(
migrationFiles.map(loadMigrationFile),
Expand Down
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
43 changes: 28 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,16 @@ 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 +67,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 +203,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
}
13 changes: 10 additions & 3 deletions src/migration-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,33 @@ 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
}
}
}

export const load = async (filePath: string) => {
export const load = async ({
filePath,
context,
}: {
filePath: string
context?: {}
}) => {
const fileName = getFileName(filePath)

try {
const {id, name, type} = parseFileName(fileName)
const contents = await getFileContents(filePath)
const sql = getSqlStringLiteral(filePath, contents, type)
const sql = getSqlStringLiteral(filePath, contents, type, context)
const hash = hashString(fileName + sql)

return {
Expand Down
9 changes: 9 additions & 0 deletions src/migrations/0_create-migrations-table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
generateSql: ({migrationTableName}) => `
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
)`,
}
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