(ns dev.migrate
  (:gen-class)
  (:require
   [clojure.string :as str]
   [metabase.db :as mdb]
   [metabase.db.liquibase :as liquibase]
   [metabase.util.malli :as mu]
   [toucan2.core :as t2])
  (:import
   (liquibase Contexts Liquibase RuntimeEnvironment)
   (liquibase.change Change)
   (liquibase.changelog ChangeLogIterator ChangeSet DatabaseChangeLog)
   (liquibase.changelog.filter ChangeSetFilter)
   (liquibase.changelog.visitor ListVisitor)
   (liquibase.database Database)
   (liquibase.database.core H2Database MySQLDatabase PostgresDatabase MariaDBDatabase)
   (liquibase.exception RollbackImpossibleException)
   (liquibase.sql Sql)
   (liquibase.sqlgenerator SqlGeneratorFactory)
   (liquibase.statement SqlStatement)))
(set! *warn-on-reflection* true)

Run migrations for the Metabase application database. Possible directions are :up (default), :force, :down, and :release-locks. When migrating :down pass along a version to migrate to (44+).

(defn- latest-migration
  []
  ((juxt :id :comments)
   (t2/query-one {:select [:id :comments]
                  :from   [(keyword (liquibase/changelog-table-name (mdb/data-source)))]
                  :order-by [[:orderexecuted :desc]]
                  :limit 1})))
(defn migrate!
  ([]
   (migrate! :up))
  ;; do we really use this in dev?
  ([direction & [version]]
   (mdb/migrate! (mdb/data-source) direction version)
   #_{:clj-kondo/ignore [:discouraged-var]}
   (println (format "Migrated %s. Latest migration: %s" (name direction) (latest-migration)))))
(defn- rollback-n-migrations!
  [^Integer n]
  (with-open [conn (.getConnection (mdb/data-source))]
    (liquibase/with-liquibase [^Liquibase liquibase conn]
      (liquibase/with-scope-locked liquibase
        (.rollback liquibase n )))))
(defn- migration-since
  [id]
  (->> (t2/query-one {:select [[:%count.* :count]]
                      :from   [:databasechangelog]
                      :where  [:> :orderexecuted {:select   [:orderexecuted]
                                                  :from     [:databasechangelog]
                                                  :where    [:like :id (format "%s%%" id)]
                                                  :order-by [[:orderexecuted :desc]]
                                                  :limit    1}]
                      :limit 1})
       :count
       ;; includes the selected id
       inc))
(defn- maybe-parse-long
  [x]
  (cond-> x
    (string? x)
    parse-long))

Rollback helper, can take a number of migrations to rollback or a specific migration ID(inclusive).

;; Rollback 2 migrations: (rollback! :count 2)

;; rollback to "v50.2024-03-18T16:00:00" (inclusive) (rollback! :id "v50.2024-03-18T16:00:00")

(mu/defn rollback!
  [k :- [:enum :id :count "id" "count"]
   target]
  (let [n (case (keyword k)
            :id    (migration-since target)
            :count (maybe-parse-long target))]
    (rollback-n-migrations! n)
    #_{:clj-kondo/ignore [:discouraged-var]}
    (println (format "Rollbacked %d migrations. Latest migration: %s" n (latest-migration)))))

Print the latest migration ID.

(defn migration-status
  []
  #_{:clj-kondo/ignore [:discouraged-var]}
  (println "Current migration:" (latest-migration)))

Migrations helpers

Usage: clojure -M:migrate up ;; migrate up to the latest clojure -M:migrate rollback count 2 ;; rollback 2 migrations clojure -M:migrate rollback id "v40.00.001" ;; rollback to a specific migration with id clojure -M:migrate status ;; print the latest migration id

(defn -main
  [& args]
  (let [[cmd & migration-args] args]
    (case cmd
      "rollback"
      (apply rollback! migration-args)
      "up"
      (apply migrate! migration-args)
      "status"
      (migration-status)
      (throw (ex-info "Invalid command" {:command cmd
                                         :args    args})))))
(defn- stmts-to-sql
  [stmts sql-generator-factory database]
  (str/join "\n" (for [stmt stmts
                       sql (.generateSql ^SqlGeneratorFactory sql-generator-factory ^SqlStatement stmt ^Database database)]
                   (.toString ^Sql sql))))
(defn- change->sql
  [^Change change sql-generator-factory database]
  {:forward  (stmts-to-sql (.generateStatements change database) sql-generator-factory database)
   :rollback (try (stmts-to-sql (.generateRollbackStatements change database) sql-generator-factory database)
                  (catch RollbackImpossibleException e
                    (str "Rollback impossible " e)))})
(defn- liquibase-database [db-type]
  (case db-type
    :postgres (PostgresDatabase.)
    :mysql    (MySQLDatabase.)
    :mariadb  (MariaDBDatabase.)
    :h2       (H2Database.)))

Get the sql statements for a specific migration ID and DB type. If no DB type is provided, it will use the current application DB type. (migration-sql-by-id "v51.2024-06-12T18:53:02" :postgres) ;; => {:forward "DROP INDEX public.idxuseriddeviceid;", :rollback "CREATE INDEX idxuseriddeviceid ON public.loginhistory(sessionid, device_id);"}

(mu/defn migration-sql-by-id
  ([id]
   (migration-sql-by-id id (mdb/db-type)))
  ([id db-type :- [:enum :postgres :mysql :mariadb :h2]]
   (t2/with-connection [conn]
     (liquibase/with-liquibase [^Liquibase liquibase conn]
       (let [database              (liquibase-database db-type)
             change-log-iterator   (ChangeLogIterator. ^DatabaseChangeLog (.getDatabaseChangeLog liquibase)
                                                       ^"[Lliquibase.changelog.filter.ChangeSetFilter;" (into-array ChangeSetFilter []))
             list-visistor         (ListVisitor.)
             runtime-env           (RuntimeEnvironment. database (Contexts.) nil)
             _                     (.run change-log-iterator list-visistor runtime-env)
             ^ChangeSet change-set (first (filter #(= id (.getId ^ChangeSet %)) (.getSeenChangeSets list-visistor)))
             sql-generator-factory (SqlGeneratorFactory/getInstance)]
         (reduce (fn [acc data]
                  ;; merge all changes in one change set into one single :forward and :rollback
                   (merge-with (fn [x y]
                                 (str x "\n" y)) acc data))
                 {}
                 (map #(change->sql % sql-generator-factory database) (.getChanges change-set))))))))
(comment
  (rollback! :count 1)
  (rollback! :id "v51.2024-08-30T08:00:03")
  (migration-sql-by-id "v51.2024-09-05T08:00:04" :postgres)
  (migrate!))