Functions for updating an H2 v1.x database to v2.x

(ns metabase.db.update-h2
  (:require
   [clj-http.client :as http]
   [clojure.java.io :as io]
   [clojure.java.jdbc :as jdbc]
   [clojure.java.shell :as sh]
   [clojure.string :as str]
   [metabase.util.files :as u.files]
   [metabase.util.log :as log])
  (:import
   (java.nio.file Files)))
(set! *warn-on-reflection* true)

Generic utils

Returns seq of first n bytes of file at path

(defn- head
  [path n]
  (let [f (io/file path)
        bytes (byte-array n)]
    (with-open [input (io/input-stream f)]
      (take (.read input bytes) bytes))))

Tries to parse and return x as char, else nil

(defn- try-char
  [x]
  (try (char x) (catch IllegalArgumentException _ nil)))

H2-specific utils

Returns H2 database base path from JDBC URL, i.e. without .mv.db

(defn- h2-base-path
  [jdbc-url]
  (second (re-matches #"jdbc:h2:file:(.*)$" jdbc-url)))

Returns the H2 major version number of H2 MV database file at path, or nil if no file exists

(defn- db-version!
  [jdbc-url]
  ;; The H2 database version is indicated in the "format:" key of the MV file header, which is 4096 bytes
  ;; See: https://www.h2database.com/html/mvstore.html
  (when-let [path (str (h2-base-path jdbc-url) ".mv.db")]
    (when (.exists (io/file path))
      (let [header     (str/join (map try-char (head path 4096)))
            format-key "format:"]
        (when-not (.startsWith header "H:2")
          (throw (IllegalArgumentException. "File does not appear to be an H2 MV database file")))
        (Integer/parseInt (str (nth header (+ (.indexOf header format-key) (count format-key)))))))))

Migration constants/utils

(def ^:private v1-jar-url
  "https://repo1.maven.org/maven2/com/h2database/h2/1.4.197/h2-1.4.197.jar")
(defn- tmp-path
  [& components]
  (str (apply u.files/get-path (System/getProperty "java.io.tmpdir") components)))
(def ^:private jar-path
  (tmp-path (last (.split ^String v1-jar-url "/"))))
(def ^:private migration-sql-path
  (tmp-path "metabase-migrate-h2-db-v1-v2.sql"))

Migration logic

Updates existing H2 v1 database to H2 v2

(defn- update!
  [jdbc-url]
  (when-not (.exists (io/file jar-path))
    (log/info "Downloading" v1-jar-url)
    (io/copy (:body (http/get v1-jar-url {:as :stream})) (io/file jar-path)))
  (log/info "Creating v1 database backup at" migration-sql-path)
  (let [result (sh/sh "java" "-cp" jar-path "org.h2.tools.Script" "-url" jdbc-url "-script" migration-sql-path)]
    (when-not (= 0 (:exit result))
      (throw (ex-info "Dumping H2 database failed." {:result result}))))
  (let [base-path (h2-base-path jdbc-url)
        backup-path (str base-path ".v1-backup.mv.db")]
    (log/info "Moving old app database to" backup-path)
    (Files/move (u.files/get-path (str base-path ".mv.db"))
                (u.files/get-path backup-path)
                (into-array java.nio.file.CopyOption [])))
  (log/info "Restoring backup into v2 database")
  (jdbc/execute! {:connection-uri jdbc-url} ["RUNSCRIPT FROM ? FROM_1X" migration-sql-path])
  (log/info "Backup restored into H2 v2 database. Update complete!"))
(def ^:private h2-lock (Object.))
(defn- update-needed?
  [jdbc-url]
  (= 1 (db-version! jdbc-url)))

Updates H2 database at db-path from version 1.x to 2.x if jdbc-url points to version 1 H2 database.

(defn update-if-needed!
  [jdbc-url]
  (when (and (h2-base-path jdbc-url) (update-needed? jdbc-url))
    (locking h2-lock
      ;; the database may have been upgraded while we waited for the lock
      (when (update-needed? jdbc-url)
        (log/info "H2 v1 database detected, updating...")
        (try
          (update! jdbc-url)
          (catch Exception e
            (log/error e "Failed to update H2 database:")
            (throw e)))))))