From 5b9de9b10f3c10d3e510b91ac1df11f657e1f1d6 Mon Sep 17 00:00:00 2001
From: Jonathan Bernard <jdb@jdb-labs.com>
Date: Mon, 11 Apr 2016 17:16:30 -0500
Subject: [PATCH] Added initial, untested groovy implementation.

---
 .../com/jdblabs/dbmigrate/DbMigrate.groovy    | 196 ++++++++++++++++++
 1 file changed, 196 insertions(+)
 create mode 100644 src/main/groovy/com/jdblabs/dbmigrate/DbMigrate.groovy

diff --git a/src/main/groovy/com/jdblabs/dbmigrate/DbMigrate.groovy b/src/main/groovy/com/jdblabs/dbmigrate/DbMigrate.groovy
new file mode 100644
index 0000000..61cbd97
--- /dev/null
+++ b/src/main/groovy/com/jdblabs/dbmigrate/DbMigrate.groovy
@@ -0,0 +1,196 @@
+package com.jdblabs.dbmigrate
+
+import groovy.sql.Sql
+
+import com.jdbernard.util.AnsiEscapeCodeSequence as ANSI
+import com.zaxxer.hikari.HikariConfig
+import com.zaxxer.hikari.HikariDataSource
+import java.text.SimpleDateFormat
+import org.docopt.Docopt
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+public class DbMigrate {
+
+  public static final VERSION = "ALPHA"
+
+  public static final def DOC = """\
+db-migrate (groovy) v${VERSION}
+
+Usage:
+
+  db_migrate [options] create <migration-name>
+  db_migrate [options] up [<count>]
+  db_migrate [options] down [<count>]
+  db_migrate [options] init <schema-name>
+  db_migrate (-V | --version)
+  db_migrate (-h | --help)
+
+Options:
+
+  -c --config <config-file>   Use the given configuration file (defaults to
+                              "database.properties").
+
+  -q --quiet                  Suppress log information.
+
+  -v --verbose                Print detailed log information.
+
+     --very-verbose           Print very detailed log information.
+
+  -V --version                Print the tools version information.
+
+  -h --help                   Print this usage information.
+"""
+
+  private static sdf = new SimpleDateFormat('yyyyMMddHHmmss')
+  private static Logger LOGGER = LoggerFactory.getLogger(DbMigrate)
+
+  Sql sql
+  File migrationsDir
+
+  public static void main(String[] args) {
+
+    // Parse arguments
+    def opts = new Docopt(DOC).withVersion("wdiwtlt v$VERSION").parse(args)
+
+    if (opts['--version']) {
+      println "db-migrate.groovy v$VERSION"
+      System.exit(0) }
+
+    if (opts['--help']) { println DOC; System.exit(0) }
+
+    // TODO: Setup logging & output levels
+    Logger clilog = LoggerFactory.getLogger("db-migrate.cli")
+
+    // Load the configuration file
+    def givenCfg = new Properties()
+    File cfgFile
+    if (opts["--config"]) cfgFile = new File(opts["--config"])
+
+    if (!cfgFile || !cfgFile.exists() || !cfgFile.isFile())
+      cfgFile = new File("database.properties")
+
+    if (!cfgFile.exists() || !cfgFile.isFile()) {
+      clilog.warn("Config file '{}' does not exist or is not a regular file.",
+        cfgFile.canonicalPath) }
+
+    if (cfgFile.exists() && cfgFile.isFile()) {
+      try { cfgFile.withInputStream { givenCfg.load(it) } }
+      catch (Exception e) {
+        clilog.error("Could not read configuration file.", e)
+        givenCfg.clear() } }
+
+    // Check for migrations directory
+    File migrationsDir = new File(givenCfg["migrations.dir"])
+    if (!migrationsDir.exists() || !migrationsDir.isDirectory()) {
+      clilog.error("'{}' does not exist or is not a directory.",
+        migrationsDir.canonicalPath)
+      System.exit(1) }
+
+    // Create the datasource
+    HikariConfig hcfg = new HikariConfig(givenCfg)
+    HikariDataSource hds = new HikariDataSource(hcfg)
+
+    // Instantiate the DbMigrate instance
+    DbMigrate dbmigrate = new DbMigrate(new Sql(hds))
+
+    // Execute the appropriate command.
+    if (opts['create']) {
+      try {
+        List<File> files = dbmigrate.createMigration(opts['<migration-name>'])
+        clilog.info("Creted new migration files:\n\t${files.name.join('\n\t')}") }
+      catch (Exception e) {
+        clilog.error('Unable to create migration scripts.', e) } }
+
+    else if (opts['up']) dbmigrate.up(opts['<count>'])
+    else if (opts['down']) dbmigrate.down(opts['<count>'] ?: 1) }
+
+  public File createMigration(String migrationName) {
+    String timestamp = sdf.format(new Date())
+
+    File upFile = new File(migrationsDir, "$timestamp-$migrationName-up.sql")
+    File downFiles = new File(migrationsDir, "$timestamp-$migrationName-down.sql")
+
+    upFile.text = "-- UP script for $migrationName ($timestamp)"
+    downFile.text = "-- DOWN script for $migrationName ($timestamp)"
+
+    return [upFile, downFile] }
+
+  public static def createMigrationsTable() {
+    sql.execute('''
+CREATE TABLE IF NOT EXISTS migrations (
+  id SERIAL PRIMARY KEY,
+  name VARCHAR NOT NULL,
+  run_at TIMESTAMP NOT NULL DEFAULT NOW())''') }
+
+  public def diffMigrations() {
+    def results = [notRun: [], missing: []]
+
+    results.run = sql.rows('SELECT name FROM migrations ORDER BY name')
+      .collect { it.name }.sort()
+
+    SortedSet<String> available = new TreeSet<>()
+    migrationsDir.eachFile { f ->
+      available << f.name.replaceAll(/-(up|down).sql$/, '') }
+
+    available.each { migrationName ->
+      if (!results.run.contains(migrationName))
+        results.notRun << migrationName
+
+      // If we've already seen some migrations that have not been run but this
+      // one has been run, that means  we have a gap and are missing migrations.
+      else if (results.notRun.size() > 0) {
+        results.missing += reults.notRun
+        results.notRun = [] } }
+
+    return results }
+
+  public List<String> up(Integer count = null) {
+    createMigrationsTable()
+    def diff = diffMigrations()
+
+    List<String> toRun = count < diff.notRun.size() ?
+      notRun[0..<count] : notRun
+
+    return runMigrations(toRun, true) }
+
+  public List<File> down(Integer count = 1) {
+    createMigrationsTable()
+    def diff = diffMigrations()
+
+    List<String> toRun = count < diff.notRun.size() ?
+      notRun.reverse()[0..<count] : notRun.reverse()
+
+    return runMigrations(toRun, false) }
+
+  private List<String> runMigrations(List<String> toRun, boolean up = true) {
+    List<String> migrationsRun = []
+
+    try {
+      sql.execute('BEGIN')
+
+      toRun.each { migrationName ->
+        File migrationFile = new File(migrationsDir,
+          "$migrationName-${up ? 'up' : 'down'}.sql")
+
+        if (!migrationFile.exists() || !migrationFile.isFile())
+          throw new FileNotFoundException(migrationFile.canonicalPath +
+            "does not exist or is not a regular file.")
+
+        List<String> statements = migrationFile.text.split(/;/)
+          .findAll { it.trim().length() > 0 && !it.startsWith('--') }
+          .collect { "$it;" }
+
+        statements.each { sql.execute(it) }
+        if (up) sql.executeInsert(
+          'INSERT INTO migrations (name) VALUES (?);', migrationName)
+        else sql.execute(
+          'DELETE FROM migrations WHERE name = ?;', migrationName)
+
+        migrationsRun << migrationName }
+
+      sql.execute('COMMIT') }
+    catch (Exception e) { sql.execute('ROLLBACK'); }
+
+    return migrationsRun }
+}