Skip to content

Commit

Permalink
Merge 15a33fc into 1ec3d9f
Browse files Browse the repository at this point in the history
  • Loading branch information
mmkal committed Apr 16, 2020
2 parents 1ec3d9f + 15a33fc commit 82a6a7b
Show file tree
Hide file tree
Showing 9 changed files with 241 additions and 84 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
"migrate": "lerna run migrate",
"ci": "run-s lint build migrate test build",
"coverage": "yarn test --coverage",
"coveralls": "yarn coverage --coverageReporters=text-lcov | coveralls"
"coveralls": "yarn coverage --coverageReporters=text-lcov | coveralls",
"commit-date": "git log -n 1 --date=format:'%Y-%m-%d-%H-%M-%S' --pretty=format:'%ad'",
"current-branch": "echo \"${CURRENT_BRANCH-$(git rev-parse --abbrev-ref HEAD)}\" | sed -E 's/refs\\/heads\\///' | sed -E 's/\\W|_/-/g'",
"canary-preid": "echo \"$(yarn --silent current-branch)-$(yarn --silent commit-date)\"",
"publish-canary": "lerna publish --canary --preid $(yarn --silent canary-preid) --dist-tag $(yarn --silent current-branch)"
},
"prettier": {
"bracketSpacing": false,
Expand Down
2 changes: 2 additions & 0 deletions packages/migrator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
},
"homepage": "https://github.com/mmkal/slonik-tools/tree/master/packages/migrator#readme",
"dependencies": {
"dedent": "^0.7.0",
"lodash": "^4.17.15",
"slonik-sql-tag-raw": "^1.0.1",
"umzug": "^2.3.0"
},
"devDependencies": {
"@types/dedent": "^0.7.0",
"@types/lodash": "^4.14.134",
"@types/umzug": "^2.2.2"
},
Expand Down
24 changes: 19 additions & 5 deletions packages/migrator/readme.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# @slonik/migrator

A cli migration tool for postgres, using [slonik](https://npmjs.com/package/slonik).

[![Build Status](https://travis-ci.org/mmkal/slonik-tools.svg?branch=master)](https://travis-ci.org/mmkal/slonik-tools)
[![Coverage Status](https://coveralls.io/repos/github/mmkal/slonik-tools/badge.svg?branch=master)](https://coveralls.io/github/mmkal/slonik-tools?branch=master)

A cli migration tool for postgres sql scripts, using [slonik](https://npmjs.com/package/slonik).

## Motivation

There are already plenty of migration tools out there - but if you have an existing project that uses slonik, this will be the simplest to configure. Even if you don't, the setup required is minimal.

The migration scripts it runs are plain `.sql` files. No learning the quirks of an ORM, and how native postgres features map to API calls.
By default, the migration scripts it runs are plain `.sql` files. No learning the quirks of an ORM, and how native postgres features map to API calls. It can also run `.js` or `.ts` files - but where possible, it's often preferable to keep it simple and stick to SQL.

This isn't technically a cli - it's a cli _helper_. Most node migration libraries are command-line utilities, which require a separate `database.json` or `config.json` file where you have to hard-code in your connection credentials. This library uses a different approach - it exposes a javascript function which you pass a slonik instance into. The javascript file you make that call in then becomes a runnable migration CLI. The migrations can be invoked programmatically from the same config.

Expand Down Expand Up @@ -45,6 +45,20 @@ This generates placeholder migration sql scripts in the directory specified by `

You can now edit the generated sql files to `create table users(name text)` for the 'up' migration and `drop table users` for the 'down' migration.

Note: `node migrate create xyz` will try to detect the type of pre-existing migrations. The extension of the file generated will be `.sql`, `.js` or `.ts` to match the last migration found in the target directory, defaulting to `.sql` if none is found. You can override this behaviour by explicitly providing an extension, e.g. `node migrate create xyz.js`.

<details>
<summary>JavaScript and TypeScript migrations</summary>

These are expected to be modules with a required `up` export and an optional `down` export. Each of these functions will have an object passed to them with a `slonik` instance, and a `sql` tag function. You can see a [javascript](./test/migrations/2000-01-03T00-00.three.js) and a [typescript]([javascript](./test/migrations/2000-01-04T00-00.four.ts)) example in the tests.

Note: if writing migrations in typescript, you will likely want to use a tool like [ts-node](https://npmjs.com/package/ts-node) to enable loading typescript modules. You can either add `require('ts-node/register/transpile-only')` at the top of your `migrate.js` file, or run `node -r ts-node/register/transpile-only migrate ...` instead of `node migrate ...`.

(In general, using `ts-node/register/transpile-only` is preferable over `ts-node/register` - type-checking is best left to a separate process)

</details>


```bash
node migrate up
```
Expand Down Expand Up @@ -85,10 +99,10 @@ parameters for the `setupSlonikMigrator` function
|--------|------------|-------------|
| `slonik` | slonik database pool instance, created by `createPool`. | N/A |
| `migrationsPath` | path pointing to directory on filesystem where migration files will live. | N/A |
| `migrationsTableName` | the name for the table migrations information will be stored in. You can change this to avoid a clash with existing tables, or to conform with your team's naming standards. | `migration` |
| `migrationTableName` | the name for the table migrations information will be stored in. You can change this to avoid a clash with existing tables, or to conform with your team's naming standards. | `migration` |
| `log` | how information about the migrations will be logged. You can set to `() => {}` to prevent logs appearing at all. | `console.log` |
| `mainModule` | if set to `module`, the javascript file calling `setupSlonikMigrator` can be used as a CLI script. If left undefined, the migrator can only be used programatically. | `undefined` |

## Implementation

Under the hood, the library thinly wraps [umzug](https://npmjs.com/package/umzug) with a custom custom slonik-based storage implementation. This isn't exposed in the API of `@slonik/migrator`, so no knowledge of umzug is required (and the dependency might even be removed in a future version).
Under the hood, the library thinly wraps [umzug](https://npmjs.com/package/umzug) with a custom slonik-based storage implementation. This isn't exposed in the API of `@slonik/migrator`, so no knowledge of umzug is required (and the dependency might even be removed in a future version).
143 changes: 114 additions & 29 deletions packages/migrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import {createHash} from 'crypto'
import {readFileSync, writeFileSync, mkdirSync, readdirSync} from 'fs'
import {once, memoize} from 'lodash'
import {map, pick} from 'lodash/fp'
import {basename, dirname, join} from 'path'
import {basename, dirname, join, extname} from 'path'
import * as Umzug from 'umzug'
import {sql, DatabasePoolType} from 'slonik'
import {raw} from 'slonik-sql-tag-raw'
import {inspect} from 'util'
import * as dedent from 'dedent'
import {EOL} from 'os'

export const supportedExtensions = ['sql', 'js', 'ts'] as const

export type SupportedExtension = typeof supportedExtensions[number]

export interface SlonikMigratorOptions {
slonik: DatabasePoolType
Expand All @@ -17,15 +23,53 @@ export interface SlonikMigratorOptions {
mainModule?: NodeModule
}

export interface Migration {
export interface MigrationParams {
path: string
slonik: DatabasePoolType
sql: typeof sql
}

export type Migration = (params: MigrationParams) => PromiseLike<unknown>

export interface MigrationResult {
file: string
path: string
}

export interface SlonikMigrator {
up(migration?: string): Promise<Migration[]>
down(migration?: string): Promise<Migration[]>
create(migration: string): void
export interface SlonikMigratorCLI {
up(migration?: string): Promise<MigrationResult[]>
down(migration?: string): Promise<MigrationResult[]>
create(migration: string): string
}

/** @private not meant for general use, since this is coupled to @see umzug */
type GetUmzugResolver = (
params: MigrationParams,
) => {
up: () => PromiseLike<unknown>
down?: () => PromiseLike<unknown>
}

const scriptResolver: GetUmzugResolver = params => {
const exportedMigrations: {up: Migration; down?: Migration} = require(params.path)
return {
up: () => exportedMigrations.up(params),
down: exportedMigrations.down && (() => exportedMigrations.down!(params)),
}
}

const sqlResolver: GetUmzugResolver = ({path, slonik, sql}) => ({
up: () => slonik.query(sql`${raw(readFileSync(path, 'utf8'))}`),
down: async () => {
const downPath = join(dirname(path), 'down', basename(path))
await slonik.query(sql`${raw(readFileSync(downPath, 'utf8'))}`)
},
})

export const defaultResolvers = {
sql: sqlResolver,
js: scriptResolver,
ts: scriptResolver,
}

export const setupSlonikMigrator = ({
Expand All @@ -35,6 +79,10 @@ export const setupSlonikMigrator = ({
log = memoize(console.log, JSON.stringify),
mainModule,
}: SlonikMigratorOptions) => {
const migrationResolver: GetUmzugResolver = params => {
const ext = params.path.split('.').slice(-1)[0] as SupportedExtension
return defaultResolvers[ext](params)
}
const createMigrationTable = once(async () => {
void (await slonik.query(sql`
create table if not exists ${sql.identifier([migrationTableName])}(
Expand All @@ -49,18 +97,13 @@ export const setupSlonikMigrator = ({
.update(readFileSync(join(migrationsPath, migrationName), 'utf8').trim().replace(/\s+/g, ' '))
.digest('hex')
.slice(0, 10)

const umzug = new Umzug({
logging: log,
migrations: {
path: migrationsPath,
pattern: /\.sql$/,
customResolver: path => ({
up: () => slonik.query(sql`${raw(readFileSync(path, 'utf8'))}`),
down: async () => {
const downPath = join(dirname(path), 'down', basename(path))
await slonik.query(sql`${raw(readFileSync(downPath, 'utf8'))}`)
},
}),
pattern: /\.(sql|js|ts)$/,
customResolver: path => migrationResolver({path, slonik, sql}),
},
storage: {
async executed() {
Expand Down Expand Up @@ -104,33 +147,75 @@ export const setupSlonikMigrator = ({
},
})

const migrator: SlonikMigrator = {
const templates = (name: string) => ({
sql: {
up: `--${name} (up)`,
down: `--${name} (down)`,
},
ts: {
up: dedent`
import {Migration} from '@slonik/migrator'
export const up: Migration = ({slonik, sql}) => slonik.query(sql\`select true\`)
export const down: Migration = ({slonik, sql}) => slonik.query(sql\`select true\`)
`,
down: null,
},
js: {
up: dedent`
exports.up = ({slonik, sql}) => slonik.query(sql\`select true\`)
exports.down = ({slonik, sql}) => slonik.query(sql\`select true\`)
`,
down: null,
},
})

const migrator: SlonikMigratorCLI = {
up: (name?: string) => umzug.up(name).then(map(pick(['file', 'path']))),
down: (name?: string) => umzug.down(name).then(map(pick(['file', 'path']))),
create: (name: string) => {
create: (nameAndExtensions: string) => {
const explicitExtension = supportedExtensions.find(ex => extname(nameAndExtensions) === `.${ex}`)
const name = explicitExtension
? nameAndExtensions.slice(0, nameAndExtensions.lastIndexOf(`.${explicitExtension}`))
: nameAndExtensions

const extension =
explicitExtension ||
readdirSync(migrationsPath)
.reverse()
.map(filename => supportedExtensions.find(ex => extname(filename) === `.${ex}`))
.find(Boolean) ||
'sql'

const timestamp = new Date()
.toISOString()
.replace(/\W/g, '-')
.replace(/-\d\d-\d\d\dZ/, '')
const sqlFileName = `${timestamp}.${name}.sql`

const filename = `${timestamp}.${name}.${extension}`
const downDir = join(migrationsPath, 'down')
mkdirSync(downDir, {recursive: true})
writeFileSync(join(migrationsPath, sqlFileName), `--${name} (up)\n`, 'utf8')
writeFileSync(join(downDir, sqlFileName), `--${name} (down)\n`, 'utf8')
const {up, down} = templates(name)[extension as SupportedExtension]

const upPath = join(migrationsPath, filename)
writeFileSync(upPath, up + EOL, 'utf8')

if (down) {
mkdirSync(downDir, {recursive: true})
writeFileSync(join(downDir, filename), down + EOL, 'utf8')
}

return upPath
},
}
/* istanbul ignore if */
if (require.main === mainModule) {
const [command, name] = process.argv.slice(2)
command in migrator
? (migrator as any)[command](name)
: console.warn(
'command not found. ' +
inspect(
{'commands available': Object.keys(migrator), 'command from cli args': command},
{breakLength: Infinity},
),
)
if (command in migrator) {
migrator[command as keyof SlonikMigratorCLI](name)
} else {
const info = {'commands available': Object.keys(migrator), 'command from cli args': command}
throw `command not found. ${inspect(info, {breakLength: Infinity})}`
}
}

return migrator
Expand Down
Loading

0 comments on commit 82a6a7b

Please sign in to comment.