Skip to content

Instantly share code, notes, and snippets.

@abrkn
Last active May 22, 2020 14:59
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save abrkn/5567117 to your computer and use it in GitHub Desktop.
Save abrkn/5567117 to your computer and use it in GitHub Desktop.
Migrations and testing for PostgreSQL using node.js and Travis-CI

Migrations and testing for PostgreSQL using node.js and Travis-CI

We're looking to add a column to the table user called admin (boolean) in an existing database.

Project layout

migrations
   001-admin.sql
test
   001-admin.sql
   index.js
.travis.yml
package.json
replay.js

Writing a test (test/001-admin.sql)

-- New users should not be admin
BEGIN; DO $$ <<fn>>
DECLARE
    uid int;
    a boolean;
BEGIN
    uid := (SELECT create_user('test@fest.com', repeat('X', 64)));

    RAISE NOTICE 'user % created', uid;

    a := (SELECT admin FROM "user" WHERE user_id = uid);

    IF a IS NULL THEN
        RAISE 'admin is null';
    END IF;

    IF a = TRUE THEN
        RAISE 'admin is true by default';
    END IF;
END; $$; ROLLBACK;

First line

BEGIN; DO $$ <<fn>>

We're doing a few things here. The BEGIN; starts a transaction explicitly. The DO-keyword executes an anonymous code block. (When a language is not specified, plpgsql is used) $$ marks the beginning of a string. Notice how there is a matching $$ at the end of the script. <<fn>> is optional. It lets you qualify your function variables (the ones under DECLARE) with fn.varname, for example fn.uid. In this test, there are no name ambiguities.

Function body

The rest is pretty straight forward plpgsql. We create a user, in this case with the function create_user. If admin is NULL by default, an exception is RAISE'd.

Last line

END; marks the end of the function and $$; marks the end of the anonymous code block and concludes the DO statement. Finally, we ROLLBACK;, which only occurs in the case of a success. In the case of a failure, an exception is raised and the transaction is aborted.

Another test

-- admin can not be null
BEGIN; DO $$ <<fn>>
DECLARE
    uid int;
    a boolean;
BEGIN
    uid := (SELECT create_user('test@fest.com', repeat('X', 64)));

    BEGIN
        UPDATE "user" SET admin = NULL WHERE user_id = uid;
    EXCEPTION
        WHEN OTHERS THEN
            RETURN;
    END;

    RAISE 'fail';
END; $$; ROLLBACK;

In this test, we want the test to fail. The admin column should be NOT NULL.

Running the tests (test/index.js)

To run the tests, I use mocha (because I use it for everything else) and expect.js for assertions.

var fs = require('fs')
, path  = require('path')
, Client = require('pg').Client
, client = new Client(process.env.DB)
, files = fs.readdirSync(__dirname).filter(function(fn) {
    return fn.match((/\.sql$/))
})

describe('database', function() {
    client.connect()
    files.forEach(function(fn) {
        it(fn, function(done) {
            var q = fs.readFileSync(path.join(__dirname, fn), 'utf8')
            client.query(q, done)
        })
    })

    after(client.end.bind(client))
})

This script reads every file in its own directory that has the extension .sql and describes a test for it using the it-function exposed by mocha. Each test is simply to run the query against the PostgreSQL client.

Let's try:

PS C:\git\snow\db> mocha -b

  ....

  × 1 of 1 tests failed:

  1) database 001-admin.sql:
     error: column "admin" does not exist
      at Connection.parseE (C:\git\snow\db\node_modules\pg\lib\connection.js:537:11)
      at Connection.parseMessage (C:\git\snow\db\node_modules\pg\lib\connection.js:382:17)
      at Socket.<anonymous> (C:\git\snow\db\node_modules\pg\lib\connection.js:101:18)
      at Socket.EventEmitter.emit (events.js:95:17)
      at Socket.<anonymous> (_stream_readable.js:710:14)
      at Socket.EventEmitter.emit (events.js:92:17)
      at emitReadable_ (_stream_readable.js:382:10)
      at emitReadable (_stream_readable.js:378:5)
      at readableAddChunk (_stream_readable.js:143:7)
      at Socket.Readable.push (_stream_readable.js:113:10)
      at TCP.onread (net.js:511:21)

That's what we expected. Next, we'll write a migration script that adds this column.

Migration (migrations/001-admin.sql)

We have now written a test and a script that runs it. The next step is to describe the migration script to add the admin column to the database.

ALTER TABLE "user"
ADD COLUMN admin BOOLEAN NOT NULL DEFAULT(FALSE);

Migration runner (replay.js)

The migration runner is a small script that runs migration scripts on our database.

PS C:\git\snow\db> node .\replay.js
Run migration scripts.
Usage: C:\Program Files\nodejs\node.exe C:\git\snow\db\replay.js

Options:
  -d, --db    database uri          [required]
  -f, --from  from migration index  [default: 0]
  -t, --to    to migration index    [default: 1000]

Missing required arguments: d
var fs = require('fs')
, colors = require('colors')
, path  = require('path')
, Client = require('pg').Client
, argv = require('optimist')
.usage('Run migration scripts.\nUsage: $0')
.describe('d', 'database uri').demand('d').alias('d', 'db')
.describe('f', 'from migration index').alias('f', 'from').default('f', 0)
.describe('t', 'to migration index').alias('t', 'to').default('t', 1000)
.argv

var client = new Client(argv.db)
client.connect()

var dir = path.join(__dirname, './migrations')
, files = fs.readdirSync(dir).sort()

function nextFile(cb) {
    var fn = files.shift()
    if (!fn) return cb()

    var n = +fn.substr(0, 3)
    if (n < argv.from) return nextFile(cb)
    if (n > argv.to) return cb()

    var q = fs.readFileSync(path.join(dir, fn), 'utf8')
    process.stdout.write(fn.substr(0, 3) + '... ')
    client.query(q, function(err) {
        if (err) {
            console.error('ERROR: %s\n'.red, err.message)
            throw err
        }
        console.log('OK'.green)
        nextFile(cb)
    })
}

if (argv.f === 0 && argv.t == 1000) console.log('running all migrations')
else console.log('running migrations %s to %s', argv.f, argv.t)

nextFile(function() {
    process.exit()
})

Let's give it a shot:

PS C:\git\snow\db> node .\replay.js -d postgres://postgres@localhost/test
running all migrations
001... OK

To see what happens if a migration fails, try to run it again:

PS C:\git\snow\db> node .\replay.js -d postgres://postgres@localhost/test -f 1
running migrations 1 to 1000
001... ERROR: column "admin" of relation "user" already exists


C:\git\snow\db\replay.js:31
            throw err
                  ^
error: column "admin" of relation "user" already exists
    at Connection.parseE (C:\git\snow\db\node_modules\pg\lib\connection.js:537:11)
    at Connection.parseMessage (C:\git\snow\db\node_modules\pg\lib\connection.js:382:17)
    at Socket.<anonymous> (C:\git\snow\db\node_modules\pg\lib\connection.js:97:20)
    at Socket.EventEmitter.emit (events.js:95:17)
    at Socket.<anonymous> (_stream_readable.js:710:14)
    at Socket.EventEmitter.emit (events.js:92:17)
    at emitReadable_ (_stream_readable.js:382:10)
    at emitReadable (_stream_readable.js:378:5)
    at readableAddChunk (_stream_readable.js:143:7)
    at Socket.Readable.push (_stream_readable.js:113:10

Putting it all together

We've now peformed the migration and the test should pass.

PS C:\git\snow\db> mocha -R spec


  database
    V 001-admin.sql


  1 test complete (31 ms)

Perfect!

Travis-CI

Let's add some continuous integration:

.travis.yml

language: node_js
env:
  - DB=postgres://postgres@localhost/test
node_js:
  - 0.8
before_script:
  - psql -c 'create database test;' -U postgres
  - node ./replay.js -d postgres://postgres@localhost/test

Push to Github, which triggers Travis-CI:

Using worker: worker-linux-6-1.bb.travis-ci.org:travis-linux-18
$ export DB=postgres://postgres@localhost/test
git.1
$ git clone --depth=50 --branch=master git://github.com/justcoin/snow-db.git justcoin/snow-db
Cloning into 'justcoin/snow-db'...
remote: Counting objects: 265, done.
remote: Compressing objects: 100% (124/124), done.
remote: Total 265 (delta 143), reused 256 (delta 138)
Receiving objects: 100% (265/265), 46.56 KiB, done.
Resolving deltas: 100% (143/143), done.
$ cd justcoin/snow-db
git.2
$ git checkout -qf 9bcb57b8841f681b1df4339dafb259a43d8f8a30
$ nvm use 0.8
Now using node v0.8.22
$ node --version
v0.8.22
$ npm --version
1.2.14
install
$ npm install 
npm http GET https://registry.npmjs.org/pg
npm http GET https://registry.npmjs.org/optimist
...
colors@0.6.0-1 node_modules/colors
optimist@0.4.0 node_modules/optimist
└── wordwrap@0.0.2
...
└── buffer-writer@1.0.0 (async@0.2.8, cloned@0.0.1)
before_script.1
$ psql -c 'create database test;' -U postgres
CREATE DATABASE
before_script.2
$ node ./replay.js -d postgres://postgres@localhost/test
running all migrations
001... OK
$ npm test
> snow-db@0.0.2 test /home/travis/build/justcoin/snow-db
> mocha -b
  ․․․․․․․․․․․․․
  1 test complete (78 ms)
The command "npm test" exited with 0.
Done. Your build exited with 0.

More posts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment