diff --git a/enterprise/backend/src/metabase_enterprise/serialization/cmd.clj b/enterprise/backend/src/metabase_enterprise/serialization/cmd.clj index f141b1ce8bd01c53a1b07498893360a40f387f66..cf9a946a969ad323deb9d54d6850a773e6ee1792 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/cmd.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/cmd.clj @@ -61,7 +61,8 @@ (reload-fn))) (log/info (trs "END LOAD from {0} with context {1}" path context)))) (catch Throwable e - (log/error e (trs "ERROR LOAD from {0}: {1}" path (.getMessage e))))))) + (log/error e (trs "ERROR LOAD from {0}: {1}" path (.getMessage e))) + (throw e))))) (defn- select-entities-in-collections ([model collections] @@ -104,13 +105,15 @@ [:= :personal_owner_id (some-> users first u/the-id)]] state-filter]})] - (-> (db/select Collection - {:where [:and - (reduce (fn [acc coll] - (conj acc [:like :location (format "/%d/%%" (:id coll))])) - [:or] base-collections) - state-filter]}) - (into base-collections))))) + (if (empty? base-collections) + [] + (-> (db/select Collection + {:where [:and + (reduce (fn [acc coll] + (conj acc [:like :location (format "/%d/%%" (:id coll))])) + [:or] base-collections) + state-filter]}) + (into base-collections)))))) (defn dump diff --git a/enterprise/backend/src/metabase_enterprise/serialization/load.clj b/enterprise/backend/src/metabase_enterprise/serialization/load.clj index f8b89bde4ee5a24731682740fc5552a6b0797bfe..83546fa60edb4dc31cf2805fd177ebb6d57d18ff 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/load.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/load.clj @@ -6,7 +6,7 @@ [clojure.tools.logging :as log] [medley.core :as m] [metabase-enterprise.serialization.names :as names :refer [fully-qualified-name->context]] - [metabase-enterprise.serialization.upsert :refer [maybe-fixup-card-template-ids! maybe-upsert-many!]] + [metabase-enterprise.serialization.upsert :refer [maybe-upsert-many!]] [metabase.config :as config] [metabase.mbql.normalize :as mbql.normalize] [metabase.mbql.util :as mbql.util] @@ -28,7 +28,7 @@ [metabase.models.segment :refer [Segment]] [metabase.models.setting :as setting] [metabase.models.table :refer [Table]] - [metabase.models.user :refer [User]] + [metabase.models.user :as user :refer [User]] [metabase.shared.models.visualization-settings :as mb.viz] [metabase.util :as u] [metabase.util.date-2 :as u.date] @@ -36,7 +36,8 @@ [toucan.db :as db] [yaml.core :as yaml] [yaml.reader :as y.reader]) - (:import java.time.temporal.Temporal)) + (:import java.time.temporal.Temporal + java.util.UUID)) (extend-type Temporal y.reader/YAMLReader (decode [data] @@ -498,7 +499,7 @@ "Retrying dashboards for collection %s: %s" (or (:collection context) "root") (str/join ", " (map :name revisit-dashboards))) - (load-dashboards context revisit-dashboards))))))) + (load-dashboards (assoc context :mode :update) revisit-dashboards))))))) (defmethod load "dashboards" [path context] @@ -545,7 +546,7 @@ (fn [] (log/infof "Reloading pulses from collection %d" (:collection context)) (let [pulse-indexes (map ::pulse-index revisit)] - (load-pulses (map (partial nth pulses) pulse-indexes) context))))))) + (load-pulses (map (partial nth pulses) pulse-indexes) (assoc context :mode :update)))))))) (defmethod load "pulses" [path context] @@ -639,16 +640,8 @@ {::revisit [] ::revisit-index #{} ::process []} (vec resolved-cards)) dummy-insert-cards (not-empty (::revisit grouped-cards)) - process-cards (::process grouped-cards) - touched-card-ids (maybe-upsert-many! - context Card - process-cards)] - (maybe-fixup-card-template-ids! - (assoc context :mode :update) - Card - (for [card (slurp-many paths)] (resolve-card card (assoc context :mode :update))) - touched-card-ids) - + process-cards (::process grouped-cards)] + (maybe-upsert-many! context Card process-cards) (when dummy-insert-cards (let [dummy-inserted-ids (maybe-upsert-many! context @@ -664,17 +657,46 @@ (fn [] (log/infof "Attempting to reload cards in collection %d" (:collection context)) (let [revisit-indexes (::revisit-index grouped-cards)] - (load-cards context paths (mapv (partial nth cards) revisit-indexes)))))))) + (load-cards (assoc context :mode :update) paths (mapv (partial nth cards) revisit-indexes)))))))) (defmethod load "cards" [path context] (binding [names/*suppress-log-name-lookup-exception* true] (load-cards context (list-dirs path) nil))) +(defn- pre-insert-user + "A function called on each User instance before it is inserted (via upsert)." + [user] + (log/infof "User with email %s is new to target DB; setting a random password" (:email user)) + (assoc user :password (str (UUID/randomUUID)))) + +;; leaving comment out for now (deliberately), because this will send a password reset email to newly inserted users +;; when enabled in a future release; see `defmethod load "users"` below +#_(defn- post-insert-user + "A function called on the ID of each `User` instance after it is inserted (via upsert)." + [user-id] + (when-let [{email :email, google-auth? :google_auth, is-active? :is_active} + (db/select-one [User :email :google_auth :is_active] :id user-id)] + (let [reset-token (user/set-password-reset-token! user-id) + site-url (public-settings/site-url) + password-reset-url (str site-url "/auth/reset_password/" reset-token) + ;; in a web server context, the server-name ultimately comes from ServletRequest/getServerName + ;; (i.e. the Java class, via Ring); this is the closest approximation in our batch context + server-name (.getHost (URL. site-url))] + (let [email-res (email/send-password-reset-email! email google-auth? server-name password-reset-url is-active?)] + (if (:error email-res) + (log/infof "Failed to send password reset email generated for user ID %d (%s): %s" + user-id + email + (:message email-res)) + (log/infof "Password reset email generated for user ID %d (%s)" user-id email))) + user-id))) + (defmethod load "users" [path context] ;; Currently we only serialize the new owner user, so it's fine to ignore mode setting - (maybe-upsert-many! context User + ;; add :post-insert-fn post-insert-user back to start sending password reset emails + (maybe-upsert-many! (assoc context :pre-insert-fn pre-insert-user) User (for [user (slurp-dir path)] (dissoc user :password)))) diff --git a/enterprise/backend/src/metabase_enterprise/serialization/upsert.clj b/enterprise/backend/src/metabase_enterprise/serialization/upsert.clj index 061c53a3f7b4a7b80801c6fc284140f53eb05e98..3e686877cf22884779c6065d349fcd2e34bd70a6 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/upsert.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/upsert.clj @@ -117,8 +117,19 @@ :insert))))))) (defn maybe-upsert-many! - "Batch upsert-or-skip" - [{:keys [mode on-error] :as context} model entities] + "Batch upsert many entities. + + Within the `context` map, the following keys are recognized: + `mode` indicates mode of operation for existing entities (`:upsert` or `:skip`), as per the `identity-condition` + `on-error` indicates what to do in case of upsert error (`:continue` or `:abort`) + `pre-insert-fn` (optional) is a function to call on each entity to be inserted, before it is inserted + `post-insert-fn` (optional) is a function to call on each entity to be inserted, after it is inserted" + [{:keys [mode on-error pre-insert-fn post-insert-fn] + :or {pre-insert-fn identity + post-insert-fn identity} + :as context} + model + entities] (let [{:keys [update insert skip]} (group-by-action context model entities)] (doseq [[_ entity _] insert] (log/info (trs "Inserting {0}" (name-for-logging (name model) entity)))) @@ -131,7 +142,8 @@ (->> (concat (for [[position _ existing] skip] [(u/the-id existing) position]) - (map vector (maybe-insert-many! model on-error (map second insert)) + (map vector (map post-insert-fn + (maybe-insert-many! model on-error (map (comp pre-insert-fn second) insert))) (map first insert)) (for [[position entity existing] update] (let [id (u/the-id existing)] @@ -143,14 +155,3 @@ [id position]))) (sort-by second) (map first)))) - -(defn maybe-fixup-card-template-ids! - "Upserts `entities` that are in `selected-ids`. Cards with template-tags that refer to other cards need a second pass - of fixing the card-ids. To not overwrite cards that were skipped in previous step, classify entities and validate - against the ones that were just modified." - [context model entities selected-ids] - (let [{:keys [update _ _]} (group-by-action context model entities) - id-set (set selected-ids) - final-ents (filter #(id-set (:id (nth % 2))) update)] - (maybe-upsert-many! context model - (map second final-ents)))) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..4827803f64c8b9bea7fc1de009c8bdbb9c9ed6da --- /dev/null +++ b/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj @@ -0,0 +1,73 @@ +(ns metabase-enterprise.serialization.cmd-test + (:require [clojure.test :as t] + [clojure.tools.logging :as log] + [metabase-enterprise.serialization.load :as load] + [metabase.cmd :as cmd] + [metabase.db.schema-migrations-test.impl :as schema-migrations-test.impl] + [metabase.models :refer [Card User]] + [metabase.models.permissions-group :as group] + [metabase.test :as mt] + [metabase.test.fixtures :as fixtures] + [metabase.util :as u] + [toucan.db :as db]) + (:import java.util.UUID)) + +(t/use-fixtures :once (fixtures/initialize :db :test-users)) + +(defmacro ^:private with-empty-h2-app-db + "Runs `body` under a new, blank, H2 application database (randomly named), in which all model tables have been + created via Liquibase schema migrations. After `body` is finished, the original app DB bindings are restored. + + Makes use of functionality in the `metabase.db.schema-migrations-test.impl` namespace since that already does + what we need." + [& body] + `(schema-migrations-test.impl/with-temp-empty-app-db [conn# :h2] + (schema-migrations-test.impl/run-migrations-in-range! conn# [0 999999]) ; this should catch all migrations) + ;; since the actual group defs are not dynamic, we need with-redefs to change them here + (with-redefs [group/all-users (#'group/get-or-create-magic-group! group/all-users-group-name) + group/admin (#'group/get-or-create-magic-group! group/admin-group-name) + group/metabot (#'group/get-or-create-magic-group! group/metabot-group-name)] + ~@body))) + +(t/deftest no-collections-test + (t/testing "Dumping a card when there are no active collection should work properly (#16931)" + ;; we need a blank H2 app db, temporarily, in order to run this test (to ensure we have no collections present, + ;; while also not deleting or messing with any existing user personal collections that the real app DB might have, + ;; since that will interfere with other tests) + ;; making use of the functionality in the metabase.db.schema-migrations-test.impl namespace for this (since it + ;; already does what we need) + (with-empty-h2-app-db + ;; create a single dummy user, to own a card + (let [user (db/simple-insert! User + :email "nobody@nowhere.com" + :first_name (mt/random-name) + :last_name (mt/random-name) + :password (str (UUID/randomUUID)) + :date_joined :%now + :is_active true + :is_superuser true)] + ;; then the card itself + (db/simple-insert! Card + :name "Single Card" + :display "Single Card" + :dataset_query {} + :creator_id (u/the-id user) + :visualization_settings "{}" + :created_at :%now + :updated_at :%now) + ;; serialize "everything" (which should just be the card and user), which should succeed if #16931 is fixed + (cmd/dump (str (System/getProperty "java.io.tmpdir") "/" (mt/random-name))))))) + +(t/deftest blank-target-db-test + (t/testing "Loading a dump into an empty app DB still works (#16639)" + (let [dump-dir (str (System/getProperty "java.io.tmpdir") "/" (mt/random-name)) + user-pre-insert-called? (atom false)] + (log/infof "Dumping to %s" dump-dir) + (cmd/dump dump-dir "--user" "crowberto@metabase.com") + (with-empty-h2-app-db + (with-redefs [load/pre-insert-user (fn [user] + (reset! user-pre-insert-called? true) + (assoc user :password "test-password"))] + (cmd/load dump-dir "--mode" :update + "--on-error" :abort) + (t/is (true? @user-pre-insert-called?))))))) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj index 8808737abe4b0471b011e9e36350d0210751b1ce..8f7ef6a9bd51ce289288786f271e194cf4b0f329 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj @@ -262,7 +262,7 @@ [entity _] entity) -(deftest dump-load-entities-testw +(deftest dump-load-entities-test (try ;; in case it already exists (u/ignore-exceptions @@ -372,7 +372,7 @@ [Card (Card card-id-with-native-snippet)] [Card (Card card-join-card-id)]]})] (with-world-cleanup - (load dump-dir {:on-error :continue :mode :update}) + (load dump-dir {:on-error :continue :mode :skip}) (mt/with-db (db/select-one Database :name ts/temp-db-name) (doseq [[model entity] (:entities fingerprint)] (testing (format "%s \"%s\"" (type model) (:name entity)) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/serialize_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/serialize_test.clj index d79f62834f83e1ebd6bbb03512c12e0c4fe0780a..0ada5e3610ed005895cec2f9681fa649920df85e 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/serialize_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/serialize_test.clj @@ -3,8 +3,8 @@ [clojure.test :refer :all] [metabase-enterprise.serialization.serialize :as serialize] [metabase-enterprise.serialization.test-util :as ts] - [metabase.models :refer [Card Collection Dashboard Database Field Metric NativeQuerySnippet Segment - Table]])) + [metabase.models :refer [Card Collection Dashboard Database Dependency Field Metric NativeQuerySnippet + Segment Table]])) (defn- all-ids-are-fully-qualified-names? [m] @@ -31,7 +31,8 @@ [Table table-id] [Field numeric-field-id] [Database db-id] - [NativeQuerySnippet snippet-id]]] + [NativeQuerySnippet snippet-id] + [Dependency dependency-id]]] (testing (name model) (let [serialization (serialize/serialize (model id))] (testing (format "\nserialization = %s" (pr-str serialization)) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/test_util.clj b/enterprise/backend/test/metabase_enterprise/serialization/test_util.clj index 1642f1bdff2b04fc8d557625b0c9d1cb6681bc1f..4a00d54bdbf147587ae1a091055d0a00393e014f 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/test_util.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/test_util.clj @@ -1,7 +1,7 @@ (ns metabase-enterprise.serialization.test-util (:require [metabase-enterprise.serialization.names :as names] - [metabase.models :refer [Card Collection Dashboard DashboardCard DashboardCardSeries Database Field Metric - NativeQuerySnippet Pulse PulseCard Segment Table User]] + [metabase.models :refer [Card Collection Dashboard DashboardCard DashboardCardSeries Database Dependency + Field Metric NativeQuerySnippet Pulse PulseCard Segment Table User]] [metabase.models.collection :as collection] [metabase.query-processor.store :as qp.store] [metabase.shared.models.visualization-settings :as mb.viz] @@ -121,6 +121,11 @@ [:field ~'category-pk-field-id {:join-alias "cat"}]]}]}}}] + Dependency [{~'dependency-id :id} {:model "Card" + :model_id ~'card-id + :dependent_on_model "Segment" + :dependent_on_id ~'segment-id + :created_at :%now}] Card [{~'card-arch-id :id} {;:archived true :table_id ~'table-id diff --git a/src/metabase/cmd.clj b/src/metabase/cmd.clj index 0e5cc98a7bb7a10d10b7ac6570946021db882cc3..a322801e577d610b1285b490b5c3c98b503fa823 100644 --- a/src/metabase/cmd.clj +++ b/src/metabase/cmd.clj @@ -52,7 +52,7 @@ (classloader/require 'metabase.cmd.dump-to-h2) (try (let [options {:keep-existing? (boolean (some #{"--keep-existing"} opts)) - :dump-plaintext? (boolean (some #{"--dump-plaintext"} opts)) }] + :dump-plaintext? (boolean (some #{"--dump-plaintext"} opts))}] ((resolve 'metabase.cmd.dump-to-h2/dump-to-h2!) h2-filename options) (println "Dump complete") (system-exit! 0)) @@ -140,7 +140,7 @@ (defn ^:command load "Load serialized metabase instance as created by `dump` command from directory `path`. - `mode` can be one of `:update` or `:skip` (default)." + `--mode` can be one of `:update` or `:skip` (default). `--on-error` can be `:abort` or `:continue` (default)." ([path] (load path {"--mode" :skip "--on-error" :continue})) diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index 6a1cecc341e5e58cd8c577c9987a779d71cfa771..5edecdeddc4362e6ae57ab86a4d3b0a59ef83798 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -6,7 +6,7 @@ [metabase.mbql.normalize :as normalize] [metabase.mbql.util :as mbql.u] [metabase.models.collection :as collection] - [metabase.models.dependency :as dependency] + [metabase.models.dependency :as dependency :refer [Dependency]] [metabase.models.field-values :as field-values] [metabase.models.interface :as i] [metabase.models.params :as params] @@ -203,7 +203,8 @@ ;; Cards don't normally get deleted (they get archived instead) so this mostly affects tests (defn- pre-delete [{:keys [id]}] (db/delete! 'ModerationReview :moderated_item_type "card", :moderated_item_id id) - (db/delete! 'Revision :model "Card", :model_id id)) + (db/delete! 'Revision :model "Card", :model_id id) + (db/delete! 'Dependency :model "Card", :model_id id)) (defn- result-metadata-out "Transform the Card result metadata as it comes out of the DB. Convert columns to keywords where appropriate." diff --git a/src/metabase/models/metric.clj b/src/metabase/models/metric.clj index 2e9fe7be533c9eef2af68f534c68795d757ae7cc..a97a21af73fe70abbf701fdcceb99b9eb67cf814 100644 --- a/src/metabase/models/metric.clj +++ b/src/metabase/models/metric.clj @@ -4,7 +4,7 @@ clauses." (:require [medley.core :as m] [metabase.mbql.util :as mbql.u] - [metabase.models.dependency :as dependency] + [metabase.models.dependency :as dependency :refer [Dependency]] [metabase.models.interface :as i] [metabase.models.revision :as revision] [metabase.util :as u] @@ -17,6 +17,9 @@ (models/defmodel Metric :metric) +(defn- pre-delete [{:keys [id]}] + (db/delete! 'Dependency :model "Metric", :model_id id)) + (defn- pre-update [{:keys [creator_id id], :as updates}] (u/prog1 updates ;; throw an Exception if someone tries to update creator_id @@ -35,7 +38,8 @@ models/IModelDefaults {:types (constantly {:definition :metric-segment-definition}) :properties (constantly {:timestamped? true}) - :pre-update pre-update}) + :pre-update pre-update + :pre-delete pre-delete}) i/IObjectPermissions (merge i/IObjectPermissionsDefaults diff --git a/src/metabase/models/permissions_group.clj b/src/metabase/models/permissions_group.clj index 2c98c873896f89c7f5afec5bd1f482b261d98ddc..39af0e75a9f45d0c8f091de3bbafaaa6e3f06a61 100644 --- a/src/metabase/models/permissions_group.clj +++ b/src/metabase/models/permissions_group.clj @@ -34,20 +34,35 @@ (fn [] (f (mdb.connection/db-type) (mdb.connection/jdbc-spec))))) +(def ^{:const true + :doc "The name of the \"All Users\" magic group." + :added "0.41.0"} all-users-group-name + "All Users") + (def ^{:arglists '([])} ^metabase.models.permissions_group.PermissionsGroupInstance all-users "Fetch the `All Users` permissions group, creating it if needed." - (get-or-create-magic-group! "All Users")) + (get-or-create-magic-group! all-users-group-name)) + +(def ^{:const true + :doc "The name of the \"Administrators\" magic group." + :added "0.41.0"} admin-group-name + "Administrators") (def ^{:arglists '([])} ^metabase.models.permissions_group.PermissionsGroupInstance admin "Fetch the `Administators` permissions group, creating it if needed." - (get-or-create-magic-group! "Administrators")) + (get-or-create-magic-group! admin-group-name)) + +(def ^{:const true + :doc "The name of the \"MetaBot\" magic group." + :added "0.41.0"} metabot-group-name + "MetaBot") (def ^{:arglists '([])} ^metabase.models.permissions_group.PermissionsGroupInstance metabot "Fetch the `MetaBot` permissions group, creating it if needed." - (get-or-create-magic-group! "MetaBot")) + (get-or-create-magic-group! metabot-group-name)) ;;; --------------------------------------------------- Validation --------------------------------------------------- diff --git a/test/metabase/db/schema_migrations_test/impl.clj b/test/metabase/db/schema_migrations_test/impl.clj index e2c96c805da5703db3e74c8c954e9e5e306090d5..e245ecefec2fab3926b66d0863332eae1744a68a 100644 --- a/test/metabase/db/schema_migrations_test/impl.clj +++ b/test/metabase/db/schema_migrations_test/impl.clj @@ -12,6 +12,7 @@ [clojure.test :refer :all] [clojure.tools.logging :as log] [metabase.db :as mdb] + [metabase.db.connection :as mdb.conn] [metabase.db.liquibase :as liquibase] [metabase.driver :as driver] [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] @@ -23,10 +24,10 @@ (:import [liquibase Contexts Liquibase] [liquibase.changelog ChangeSet DatabaseChangeLog])) -(defmulti ^:private do-with-temp-empty-app-db* +(defmulti do-with-temp-empty-app-db* "Create a new completely empty app DB for `driver`, then call `(f jdbc-spec)` with a spec for that DB. Should clean up before and after running `f` as needed." - {:arglists '([driver f])} + {:added "0.41.0", :arglists '([driver f])} driver/dispatch-on-initialized-driver :hierarchy #'driver/hierarchy) @@ -55,25 +56,34 @@ (let [jdbc-spec {:subprotocol "h2", :subname "mem:schema-migrations-test-db", :classname "org.h2.Driver"}] (f jdbc-spec))) -(defn- do-with-temp-empty-app-db [driver f] +(defn do-with-temp-empty-app-db + "The function invoked by `with-temp-empty-app-db` to execute a thunk `f` in a temporary, empty app DB. Use the macro + instead: `with-temp-empty-app-db`." + {:added "0.41.0"} + [driver f] (do-with-temp-empty-app-db* driver (fn [jdbc-spec] (with-open [conn (jdbc/get-connection jdbc-spec)] (binding [toucan.db/*db-connection* {:connection conn} - toucan.db/*quoting-style* (mdb/quoting-style driver)] + toucan.db/*quoting-style* (mdb/quoting-style driver) + mdb.conn/*db-type* driver + mdb.conn/*jdbc-spec* jdbc-spec] (f conn)))))) -(defmacro ^:private with-temp-empty-app-db +(defmacro with-temp-empty-app-db "Create a new temporary application DB of `db-type` and execute `body` with `conn-binding` bound to a `java.sql.Connection` to the database. Toucan `*db-connection*` is also bound, which means Toucan functions like - `select` or `update!` will operate against this database." + `select` or `update!` will operate against this database. + + Made public as of x.41." [[conn-binding db-type] & body] `(do-with-temp-empty-app-db ~db-type (fn [~(vary-meta conn-binding assoc :tag 'java.sql.Connection)] ~@body))) -(defn- run-migrations-in-range! +(defn run-migrations-in-range! "Run Liquibase migrations from our migrations YAML file in the range of `start-id` -> `end-id` (inclusive) against a DB with `jdbc-spec`." + {:added "0.41.0"} [^java.sql.Connection conn [start-id end-id]] (liquibase/with-liquibase [liquibase conn] (let [change-log (.getDatabaseChangeLog liquibase)