From 775d7183bb40e685183d5458daf4911825317f9e Mon Sep 17 00:00:00 2001
From: John Swanson <john.swanson@metabase.com>
Date: Mon, 8 Jul 2024 13:55:39 -0700
Subject: [PATCH] Nicer error messages for linting migrations (#43667)

* Nicer error messages for linting migrations

In general, just throw exceptions instead of using clojure spec here.

You'll only get one error per run, but that seems fine.

I did keep some specs around, but run them slightly differently. Rather
than validating the whole collection of changeSets at once with `s/+`,
just `doseq` through the changeSets and validate each one separately.

That way, the value that's presented as erroneous is much smaller (a
single changeSet) and it's easier to see what went wrong.

* Update bin/lint-migrations-file/test/lint_migrations_file_test.clj

Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>

* Update bin/lint-migrations-file/src/lint_migrations_file.clj

Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>

* Require preConditions for certain liquibase change types to encourage idempotence (#44578)

Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>

* remove `!`s from non-side-effecty fns

* add preConditions to a few v51 migrations

---------

Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>
Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
---
 .../src/change_set/strict.clj                 |  27 +-
 .../src/lint_migrations_file.clj              | 136 ++++++----
 .../test/lint_migrations_file_test.clj        | 241 ++++++++++++------
 .../migrations/001_update_migrations.yaml     |   4 +
 4 files changed, 279 insertions(+), 129 deletions(-)

diff --git a/bin/lint-migrations-file/src/change_set/strict.clj b/bin/lint-migrations-file/src/change_set/strict.clj
index 045b4142087..7c60f133cd1 100644
--- a/bin/lint-migrations-file/src/change_set/strict.clj
+++ b/bin/lint-migrations-file/src/change_set/strict.clj
@@ -35,7 +35,7 @@
   (s/keys :opt-un [::dbms]))
 
 (s/def ::preConditions
-  (s/coll-of ::preCondition))
+  (s/nilable (s/coll-of ::preCondition)))
 
 (s/def ::dbms
   (s/keys :req-un [::type]))
@@ -122,6 +122,30 @@
    (some change-types-supporting-rollback (mapcat keys changes))
    (contains? change-set :rollback)))
 
+(def ^:private change-types-requiring-preconditions
+  #{:createTable
+    :dropTable
+    :addColumn
+    :dropColumn
+    :createIndex
+    :dropIndex
+    :addForeignKeyConstraint
+    :dropForeignKeyConstraint})
+
+(defn- precondition-present-when-required?
+  "Ensures that certain changeSet types include a preCondition. The intent is for the preCondition to ensure that the changeSet is
+  idempotent by checking whether the table/column/index/etc does not exist before trying to create it. (Or inversely, that it does
+  exist before trying to drop it.)
+
+  We don't currently assert on the structure of the preCondition to provide flexibility if there are cases where idempotence is not
+  desired."
+  [{:keys [id changes] :as change-set}]
+  (or
+   (int? id)
+   (< (major-version id) 51)
+   (not-any? change-types-requiring-preconditions (mapcat keys changes))
+   (contains? change-set :preConditions)))
+
 (defn- disallow-delete-cascade-with-add-column
   "Returns false if addColumn changeSet uses deleteCascade. See Metabase issue #14321"
   [{:keys [changes]}]
@@ -133,6 +157,7 @@
 (s/def ::change-set
   (s/and
    rollback-present-when-required?
+   precondition-present-when-required?
    disallow-delete-cascade-with-add-column
    (s/keys :req-un [::id ::author ::changes ::comment]
            :opt-un [::preConditions])))
diff --git a/bin/lint-migrations-file/src/lint_migrations_file.clj b/bin/lint-migrations-file/src/lint_migrations_file.clj
index 2a5e914270b..5b2166cc4da 100644
--- a/bin/lint-migrations-file/src/lint_migrations_file.clj
+++ b/bin/lint-migrations-file/src/lint_migrations_file.clj
@@ -4,20 +4,32 @@
    [clj-yaml.core :as yaml]
    [clojure.java.io :as io]
    [clojure.pprint :as pprint]
+   [clojure.set :as set]
    [clojure.spec.alpha :as s]
-   [clojure.string :as str]))
+   [clojure.string :as str])
+  (:import
+   [clojure.lang ExceptionInfo]))
 
 (set! *warn-on-reflection* true)
 
 (comment change-set.strict/keep-me)
 
+(defmacro validation-error
+  "An exception with `::validation-error` in the data, for easy id later."
+  [msg & [data]]
+  `(ex-info ~msg (merge ~data {::validation-error true})))
+
+(defn- validation-error? [e]
+  (::validation-error (ex-data e)))
+
 ;; just print ordered maps like normal maps.
 (defmethod print-method flatland.ordered.map.OrderedMap
   [m writer]
   (print-method (into {} m) writer))
 
-(s/def ::migrations
-  (s/keys :req-un [::databaseChangeLog]))
+(defn- require-database-change-log! [migrations]
+  (when-not (contains? migrations :databaseChangeLog)
+    (throw (validation-error "Missing `databaseChangeLog` key."))))
 
 (defn- change-set-ids
   "Returns all the change set ids given a change-log."
@@ -26,14 +38,26 @@
         :when id]
     id))
 
-(defn- distinct-change-set-ids? [change-log]
-  (let [ids (change-set-ids change-log)]
-    ;; can't apply distinct? with so many IDs
-    (= (count ids) (count (set ids)))))
-
-(defn- change-set-ids-in-order? [change-log]
-  (let [ids (change-set-ids change-log)]
-    (= ids (sort-by identity compare ids))))
+(defn- require-distinct-change-set-ids [change-log]
+  (let [ids (change-set-ids change-log)
+        duplicates (->> ids
+                        (group-by identity)
+                        (keep (fn [[k v]]
+                                (when (< 1 (count v))
+                                  k))))]
+    (when (seq duplicates)
+      (throw (validation-error "Change set IDs are not distinct." {:duplicates duplicates})))))
+
+(defn- require-change-set-ids-in-order [change-log]
+  (let [ids (change-set-ids change-log)
+        out-of-order-ids (->> ids
+                             (partition 2 1)
+                             (filter (fn [[id1 id2]]
+                                       (pos? (compare id1 id2)))))]
+
+    (when (seq out-of-order-ids)
+      (throw (validation-error "Change set IDs are not in order"
+                              {:out-of-order-ids out-of-order-ids})))))
 
 (defn- check-change-use-types?
   "Return `true` if change use any type in `types`."
@@ -59,40 +83,45 @@
   [target-types change-set]
   (some #(check-change-use-types? target-types %) (get-in change-set [:changeSet :changes])))
 
-(defn- assert-no-types-in-change-log
+(defn- require-no-types-in-change-log!
   "Returns true if none of the changes in the change-log contain usage of any type specified in `target-types`.
 
   `id-filter-fn` is a function that takes an ID and return true if the changeset should be checked."
   ([target-types change-log]
-   (assert-no-types-in-change-log target-types change-log (constantly true)))
+   (require-no-types-in-change-log! target-types change-log (constantly true)))
   ([target-types change-log id-filter-fn]
    {:pre [(set? target-types)]}
-   (->> change-log
-        (filter (fn [change-set]
-                  (let [id (get-in change-set [:changeSet :id])]
-                    (and (string? id)
-                         (id-filter-fn id)))))
-        (some #(check-change-set-use-types? target-types %))
-        not)))
-
-(defn no-bare-blob-or-text-types?
-  "Ensures that no \"text\" or \"blob\" type columns are added in changesets with id later than 320 (i.e. version
-  0.42.0).  From that point on, \"${text.type}\" should be used instead, so that MySQL can handle it correctly (by using
-  `LONGTEXT`).  And similarly, from an earlier point, \"${blob.type}\" should be used instead of \"blob\"."
+   (when-let [using-types? (->> change-log
+                                (filter (fn [change-set]
+                                          (let [id (get-in change-set [:changeSet :id])]
+                                            (and (string? id)
+                                                 (id-filter-fn id)))))
+                                (filter #(check-change-set-use-types? target-types %))
+                                (map #(get-in % [:changeSet :id]))
+                                seq)]
+     (throw (validation-error
+             (format "Migration(s) [%s] uses invalid types (in %s)"
+                     (str/join "," (map #(str "'" % "'") using-types?))
+                     (str/join "," (map #(str "'" % "'") target-types)))
+             {:invalid-ids using-types?
+              :target-types target-types})))))
+
+(defn require-no-bare-blob-or-text-types
+  "Ensures that no \"text\" or \"blob\" type columns are added in any changesets."
   [change-log]
-  (assert-no-types-in-change-log #{"blob" "text"} change-log))
+  (require-no-types-in-change-log! #{"blob" "text"} change-log))
 
-(defn no-bare-boolean-types?
+(defn require-no-bare-boolean-types
   "Ensures that no \"boolean\" type columns are added in changesets with id later than v49.00-032. From that point on,
   \"${boolean.type}\" should be used instead, so that we can consistently use `BIT(1)` for Boolean columns on MySQL."
   [change-log]
-  (assert-no-types-in-change-log #{"boolean"} change-log #(pos? (compare % "v49.00-032"))))
+  (require-no-types-in-change-log! #{"boolean"} change-log #(pos? (compare % "v49.00-032"))))
 
-(defn no-datetime-type?
+(defn require-no-datetime-type
   "Ensures that no \"datetime\" or \"timestamp without time zone\".
   From that point on, \"${timestamp_type}\" should be used instead, so that all of our time related columsn are tz-aware."
   [change-log]
-  (assert-no-types-in-change-log
+  (require-no-types-in-change-log!
    #{"datetime" "timestamp" "timestamp without time zone"}
    change-log
    #(pos? (compare % "v49.00-000"))))
@@ -100,22 +129,30 @@
 (s/def ::changeSet
   (s/spec :change-set.strict/change-set))
 
-(s/def ::databaseChangeLog
-  (s/and distinct-change-set-ids?
-         change-set-ids-in-order?
-         no-bare-blob-or-text-types?
-         no-bare-boolean-types?
-         no-datetime-type?
-         (s/+ (s/alt :property              (s/keys :req-un [::property])
-                     :objectQuotingStrategy (s/keys :req-un [::objectQuotingStrategy])
-                     :changeSet             (s/keys :req-un [::changeSet])))))
+(defn- only-key [m]
+  (let [keys (keys m)]
+    (when-not (= 1 (count keys))
+      (throw (validation-error "Expected exactly one key." {:keys keys})))
+    (first keys)))
+
+(defn- validate-database-change-log [change-log]
+  (require-distinct-change-set-ids change-log)
+  (require-change-set-ids-in-order change-log)
+  (require-no-bare-blob-or-text-types change-log)
+  (require-no-bare-boolean-types change-log)
+  (require-no-datetime-type change-log)
+  (let [{:keys [changeSet]
+         :as all} (group-by only-key change-log)]
+    (when-not (set/subset? (set (keys all)) #{:property :objectQuotingStrategy :changeSet})
+      (throw (validation-error "Expected exactly one of :property, :objectQuotingStrategy, or :changeSet."
+                               {:keys (keys all)})))
+    (doseq [{:keys [changeSet]} changeSet
+            :when (not (s/valid? ::changeSet changeSet))]
+      (throw (validation-error "Invalid change set." (s/explain-data ::changeSet changeSet))))))
 
 (defn- validate-migrations [migrations]
-  (when (= (s/conform ::migrations migrations) ::s/invalid)
-    (let [data (s/explain-data ::migrations migrations)]
-      (throw (ex-info (str "Validation failed:\n" (with-out-str (pprint/pprint (mapv #(dissoc % :val)
-                                                                                     (::s/problems data)))))
-                      (or (dissoc data ::s/value) {})))))
+  (require-database-change-log! migrations)
+  (validate-database-change-log (:databaseChangeLog migrations))
   :ok)
 
 (def ^:private filename
@@ -144,6 +181,17 @@
     (validate-all)
     (println "Ok.")
     (System/exit 0)
+    (catch ExceptionInfo e
+      (if (validation-error? e)
+        (do
+          (println)
+          (printf "Error:\t%s\n" (.getMessage e))
+          (printf "Details:\n\n %s" (with-out-str (pprint/pprint (dissoc (ex-data e) ::validation-error))))
+          (println))
+        (do
+          (pprint/pprint (Throwable->map e))
+          (println (.getMessage e))))
+      (System/exit 1))
     (catch Throwable e
       (pprint/pprint (Throwable->map e))
       (println (.getMessage e))
diff --git a/bin/lint-migrations-file/test/lint_migrations_file_test.clj b/bin/lint-migrations-file/test/lint_migrations_file_test.clj
index 079c6b8392a..b3a4da842ab 100644
--- a/bin/lint-migrations-file/test/lint_migrations_file_test.clj
+++ b/bin/lint-migrations-file/test/lint_migrations_file_test.clj
@@ -38,30 +38,52 @@
   (try (#'lint-migrations-file/validate-migrations {:databaseChangeLog changes})
        (catch Exception e (ex-data e))))
 
+(defmacro is-thrown-with-error-info? [msg info & body]
+  `(let [exception# (try (do ~@body)
+                         nil
+                         (catch clojure.lang.ExceptionInfo e# e#)
+                         (catch Throwable t#
+                           (throw (ex-info "An unexpected exception type was thrown"
+                                           {:expected clojure.lang.ExceptionInfo
+                                            :actual t#}))))]
+     (is (instance? clojure.lang.ExceptionInfo exception#)
+         "Expected clojure.lang.ExceptionInfo but caught different type.")
+     (is (not (nil? exception#))
+         "No exception was thrown.")
+     (is (::lint-migrations-file/validation-error (ex-data exception#))
+         "The exception was not a validation error.")
+     (let [ex-msg# (.getMessage exception#)
+           ex-data# (dissoc (ex-data exception#) ::lint-migrations-file/validation-error)]
+       (is (= ~msg ex-msg#)
+           "Error message does not match expected.")
+       (is (= ~info ex-data#)
+           "Error info does not match expected."))))
+
 (deftest require-unique-ids-test
   (testing "Make sure all migration IDs are unique"
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"distinct-change-set-ids"
-         (validate
-          (mock-change-set :id "v49.2024-01-01T10:30:00")
-          (mock-change-set :id "v49.2024-01-01T10:30:00"))))))
+    (is-thrown-with-error-info?
+     "Change set IDs are not distinct."
+     {:duplicates ["v49.2024-01-01T10:30:00"]}
+     (validate
+      (mock-change-set :id "v49.2024-01-01T10:30:00")
+      (mock-change-set :id "v49.2024-01-01T10:30:00")))))
 
 (deftest require-migrations-in-order-test
   (testing "Migrations must be in order"
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"change-set-ids-in-order"
-         (validate
-          (mock-change-set :id "v45.00-002")
-          (mock-change-set :id "v45.00-001"))))
+    (is-thrown-with-error-info?
+     "Change set IDs are not in order"
+     {:out-of-order-ids [["v45.00-002" "v45.00-001"]]}
+     (validate
+      (mock-change-set :id "v45.00-002")
+      (mock-change-set :id "v45.00-001")))
 
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"change-set-ids-in-order"
-         (validate
-          (mock-change-set :id "v49.2023-12-14T08:54:54")
-          (mock-change-set :id "v49.2023-12-14T08:54:53"))))))
+    (is-thrown-with-error-info?
+     "Change set IDs are not in order"
+     {:out-of-order-ids [["v49.2023-12-14T08:54:54"
+                          "v49.2023-12-14T08:54:53"]]}
+     (validate
+      (mock-change-set :id "v49.2023-12-14T08:54:54")
+      (mock-change-set :id "v49.2023-12-14T08:54:53")))))
 
 (deftest only-one-column-per-add-column-test
   (testing "we should only allow one column per addColumn change"
@@ -73,7 +95,7 @@
                :changes [(mock-add-column-changes)]))))
       (is (thrown-with-msg?
            clojure.lang.ExceptionInfo
-           #"Extra input"
+           #"Invalid change set\."
            (validate
             (mock-change-set
              :id id
@@ -84,7 +106,7 @@
   (testing "only allow one change per change set"
     (is (thrown-with-msg?
          clojure.lang.ExceptionInfo
-         #"Extra input"
+         #"Invalid change set\."
          (validate
           (mock-change-set :changes [(mock-add-column-changes) (mock-add-column-changes)]))))))
 
@@ -92,7 +114,7 @@
   (testing "require a comment for a change set"
     (is (thrown-with-msg?
          clojure.lang.ExceptionInfo
-         #"Validation failed:"
+         #"Invalid change set\."
          (validate (update (mock-change-set) :changeSet dissoc :comment))))
     (is (= :ok
            (validate (mock-change-set :id "v49.2024-01-01T10:30:00", :comment "Added x.45.0"))))))
@@ -111,14 +133,14 @@
       (testing (format "Change set =\n%s" (pr-str change-set))
         (is (thrown-with-msg?
              clojure.lang.ExceptionInfo
-             #"onDelete is only for addForeignKeyConstraints"
+             #"Invalid change set\."
              (validate change-set)))))))
 
 (deftest require-remarks-for-create-table-test
   (testing "require remarks for newly created tables"
     (is (thrown-with-msg?
          clojure.lang.ExceptionInfo
-         #":remarks"
+         #"Invalid change set\."
          (validate
           (mock-change-set
            :id 200
@@ -137,7 +159,7 @@
   (testing "should fail if *any* change is missing dbms"
     (is (thrown-with-msg?
          clojure.lang.ExceptionInfo
-         #":dbms"
+         #"Invalid change set\."
          (validate
           (mock-change-set
            :changes
@@ -147,7 +169,7 @@
   (testing "should fail if a DBMS is repeated"
     (is (thrown-with-msg?
          clojure.lang.ExceptionInfo
-         #":changes"
+         #"Invalid change set\."
          (validate
           (mock-change-set
            :changes
@@ -166,11 +188,11 @@
 
     (testing "invalid date components should throw an error"
       (are [msg id]
-        (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"Validation failed"
-         (validate-id "v49.2024-30-01T10:30:00")
-         msg)
+          (thrown-with-msg?
+           clojure.lang.ExceptionInfo
+           #"Invalid change set\."
+           (validate-id "v49.2024-30-01T10:30:00")
+           msg)
         "invalid month"  "v49.2024-13-01T10:30:00"
         "invalid day"    "v49.2024-01-32T10:30:00"
         "invalid hour"   "v49.2024-01-01T25:30:00"
@@ -180,42 +202,43 @@
 (deftest prevent-text-types-test
   (testing "should allow \"${text.type}\" columns from being added"
     (is (= :ok
-          (validate
-           (mock-change-set
+           (validate
+            (mock-change-set
              :id "v49.2024-01-01T10:30:00"
              :changes [(mock-add-column-changes :columns [(mock-column :type "${text.type}")])]))))
     (doseq [problem-type ["blob" "text"]]
       (testing (format "should prevent \"%s\" columns from being added after ID 320" problem-type)
-        (is (thrown-with-msg?
-              clojure.lang.ExceptionInfo
-              #"(?s)^.*no-bare-blob-or-text-types\\?.*$"
-              (validate
-                (mock-change-set
-                  :id "v49.2024-01-01T10:30:00"
-                  :changes [(mock-add-column-changes :columns [(mock-column :type problem-type)])]))))))))
+        (is-thrown-with-error-info?
+         "Migration(s) ['v49.2024-01-01T10:30:00'] uses invalid types (in 'blob','text')"
+         {:invalid-ids ["v49.2024-01-01T10:30:00"]
+          :target-types #{"blob" "text"}}
+         (validate
+          (mock-change-set
+           :id "v49.2024-01-01T10:30:00"
+           :changes [(mock-add-column-changes :columns [(mock-column :type problem-type)])])))))))
 
 (deftest prevent-bare-boolean-type-test
   (testing "should allow adding \"${boolean.type}\" columns"
     (is (= :ok
-          (validate
-           (mock-change-set
+           (validate
+            (mock-change-set
              :id "v49.00-033"
              :changes [(mock-add-column-changes :columns [(mock-column :type "${boolean.type}")])]))))
-    (testing (format "should prevent \"boolean\" columns from being added after ID v49.00-033")
-      (is (thrown-with-msg?
-            clojure.lang.ExceptionInfo
-            #"(?s)^.*no-bare-boolean-types\\?.*$"
-            (validate
-              (mock-change-set
-                :id "v49.00-033"
-                :changes [(mock-add-column-changes :columns [(mock-column :type "boolean")])])))))))
+    (testing "should prevent \"boolean\" columns from being added after ID v49.00-033"
+      (is-thrown-with-error-info?
+       "Migration(s) ['v49.00-033'] uses invalid types (in 'boolean')"
+       {:invalid-ids ["v49.00-033"]
+        :target-types #{"boolean"}}
+       (validate (mock-change-set
+                  :id "v49.00-033"
+                  :changes [(mock-add-column-changes :columns [(mock-column :type "boolean")])]))))))
 
 (deftest require-rollback-test
   (testing "change types with no automatic rollback support"
     (testing "missing rollback key fails"
       (is (thrown-with-msg?
            clojure.lang.ExceptionInfo
-           #"rollback-present-when-required"
+           #"Invalid change set\."
            (validate (update (mock-change-set :id "v49.2024-01-01T10:30:00" :changes [{:sql {:sql "select 1"}}])
                              :changeSet dissoc :rollback)))))
     (testing "nil rollback is allowed"
@@ -229,11 +252,40 @@
   (testing "change types with automatic rollback support are allowed"
     (is (= :ok (validate (mock-change-set :id "v49.2024-01-01T10:30:00" :changes [(mock-add-column-changes)]))))))
 
+(deftest require-precondition-test
+  (testing "certain change types require preConditions"
+    (is (thrown-with-msg?
+         clojure.lang.ExceptionInfo
+         #"Invalid change set\."
+         (validate (mock-change-set
+                    :id "v51.2024-01-01T10:30:00"
+                    :changes [(mock-create-table-changes)])))))
+
+  (testing "other change types are exempt"
+    (is (= :ok
+           (validate
+            (mock-change-set
+             :changes
+             [{:sql {:dbms "h2", :sql "1"}}])))))
+
+  (testing "nil preConditions is allowed"
+    (is (= :ok
+           (validate (mock-change-set
+                      :id "v51.2024-01-01T10:30:00"
+                      :changes [(mock-create-table-changes)]
+                      :preConditions nil)))))
+
+  (testing "changeSets prior to v51 are exempt"
+    (is (= :ok
+           (validate (mock-change-set
+                      :id "v50.2024-01-01T10:30:00"
+                      :changes [(mock-create-table-changes)]))))))
+
 (deftest disallow-deletecascade-in-addcolumn-test
   (testing "addColumn with deleteCascade fails"
     (is (thrown-with-msg?
          clojure.lang.ExceptionInfo
-         #"disallow-delete-cascade"
+         #"Invalid change set."
          (validate (mock-change-set :id "v49.2024-01-01T10:30:00"
                                     :changes [(mock-add-column-changes
                                                :columns [(mock-column :constraints {:deleteCascade true})])]))))))
@@ -267,45 +319,66 @@
 
 (deftest forbidden-new-types-test
   (testing "should throw if changes contains text type"
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"Validation failed"
+    (is (is-thrown-with-error-info?
+         "Migration(s) ['v45.12-345'] uses invalid types (in 'blob','text')"
+         {:invalid-ids ["v45.12-345"]
+          :target-types #{"blob" "text"}}
          (validate (mock-change-set :id "v45.12-345"
                                     :changes [{:modifyDataType {:newDataType "text"}}]
                                     :rollback nil))))
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"Validation failed"
-         (validate (mock-change-set :id "v45.12-345"
-                                    :changes [{:createTable {:columns [{:column {:type "text"}}]}}]
-                                    :rollback nil)))))
+    (is-thrown-with-error-info?
+     "Migration(s) ['v45.12-345'] uses invalid types (in 'blob','text')"
+     {:invalid-ids ["v45.12-345"]
+      :target-types #{"blob" "text"}}
+     (validate (mock-change-set :id "v45.12-345"
+                                :changes [{:createTable {:tableName "my_table"
+                                                         :remarks "meow"
+                                                         :columns [{:column {:name "foo"
+                                                                             :remarks "none"
+                                                                             :type "text"}}]}}]
+                                :rollback nil))))
 
   (testing "should throw if changes contains boolean type"
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"Validation failed"
-         (validate (mock-change-set :id "v49.00-033"
-                                    :changes [{:modifyDataType {:newDataType "boolean"}}]
-                                    :rollback nil))))
+    (is-thrown-with-error-info?
+     "Migration(s) ['v49.00-033'] uses invalid types (in 'boolean')"
+     {:invalid-ids ["v49.00-033"]
+      :target-types #{"boolean"}}
+     (validate (mock-change-set :id "v49.00-033"
+                                :changes [{:modifyDataType {:newDataType "boolean"}}]
+                                :rollback nil)))
 
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"Validation failed"
-         (validate (mock-change-set :id "v45.12-345"
-                                    :changes [{:createTable {:columns [{:column {:type "boolean"}}]}}]
-                                    :rollback nil)))))
+    (is-thrown-with-error-info?
+     "Migration(s) ['v49.00-033'] uses invalid types (in 'boolean')"
+     {:invalid-ids ["v49.00-033"]
+      :target-types #{"boolean"}}
+     (validate (mock-change-set :id "v49.00-033"
+                                :changes [{:createTable {:tableName "my_table"
+                                                         :remarks "meow"
+                                                         :columns [{:column {:name "foo"
+                                                                             :remarks "none"
+                                                                             :type "boolean"}}]}}])))
+    (testing "does not throw for older migrations"
+      (is (validate (mock-change-set :id "v45.00-033"
+                                     :changes [{:createTable {:tableName "my_table"
+                                                              :remarks "meow"
+                                                              :columns [{:column {:name "foo"
+                                                                                  :remarks "none"
+                                                                                  :type "boolean"}}]}}])))))
 
   (testing "should throw if changes contains datetime type"
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"Validation failed"
-         (validate (mock-change-set :id "v49.00-033"
-                                    :changes [{:modifyDataType {:newDataType "datetime"}}]
-                                    :rollback nil))))
+    (is-thrown-with-error-info?
+     "Migration(s) ['v49.00-033'] uses invalid types (in 'timestamp','timestamp without time zone','datetime')"
+     {:invalid-ids ["v49.00-033"]
+      :target-types #{"timestamp" "timestamp without time zone" "datetime"}}
+     (validate (mock-change-set :id "v49.00-033"
+                                :changes [{:modifyDataType {:newDataType "datetime"}}]
+                                :rollback nil)))
 
-    (is (thrown-with-msg?
-         clojure.lang.ExceptionInfo
-         #"Validation failed"
-         (validate (mock-change-set :id "v45.12-345"
-                                    :changes [{:createTable {:columns [{:column {:type "timestamp with time zone"}}]}}]
-                                    :rollback nil))))))
+    (testing "(but not if it's an older migration)"
+      (is (validate (mock-change-set :id "v45.12-345"
+                                     :changes [{:createTable {:tableName "my_table"
+                                                              :remarks "meow"
+                                                              :columns [{:column {:name "foo"
+                                                                                  :remarks "none"
+                                                                                  :type "timestamp with time zone"}}]}}]
+                                     :rollback nil))))))
diff --git a/resources/migrations/001_update_migrations.yaml b/resources/migrations/001_update_migrations.yaml
index 828294b6cbf..0fe9355a23b 100644
--- a/resources/migrations/001_update_migrations.yaml
+++ b/resources/migrations/001_update_migrations.yaml
@@ -8181,6 +8181,7 @@ databaseChangeLog:
                   name: dataset_query_metrics_v2_migration_backup
                   remarks: The copy of dataset_query before the metrics v2 migration
                   type: ${text.type}
+      preConditions:
 
   - changeSet:
       id: v51.2024-05-13T16:00:00
@@ -8205,6 +8206,7 @@ databaseChangeLog:
                   defaultValue: "view"
                   constraints:
                     nullable: false
+      preConditions:
 
   - changeSet:
       id: v51.2024-06-07T12:37:36
@@ -8234,6 +8236,7 @@ databaseChangeLog:
                   name: session_id
               - column:
                   name: device_id
+      preConditions:
 
   - changeSet:
       id: v51.2024-06-12T18:53:03
@@ -8252,6 +8255,7 @@ databaseChangeLog:
         - dropIndex:
             tableName: login_history
             indexName: idx_user_id_device_id
+      preConditions:
 
   # >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
 
-- 
GitLab