Skip to content
Snippets Groups Projects
Unverified Commit 7dbd457e authored by john-metabase's avatar john-metabase Committed by GitHub
Browse files

Adds tools.cli command parsing and improved option behavior for serialization commands (#30435)

Commands can now specify a command-line arg spec to enable automatic argument parsing and improved help text.
New commands should use this arg spec format and existing commands can be updated if desired.

* Adds tools.cli command parsing and improved option behavior for v2 export and load
* Fixes --no-collections export, empty settings export
* Improves command error handling, adds help text on error
* Lists valid commands when unknown command is given
* Cleans up collection selection for generic export
* Supports --user by email in serdes v2 export
parent 1a44c235
No related branches found
No related tags found
No related merge requests found
Showing
with 332 additions and 284 deletions
......@@ -128,6 +128,7 @@
org.clojure/java.jmx {:mvn/version "1.0.0"} ; JMX bean library, for exporting diagnostic info
org.clojure/math.combinatorics {:mvn/version "0.1.6"} ; combinatorics functions
org.clojure/math.numeric-tower {:mvn/version "0.0.5"} ; math functions like `ceil`
org.clojure/tools.cli {:mvn/version "1.0.214"} ; command-line argument parsing
org.clojure/tools.logging {:mvn/version "1.2.4"} ; logging framework
org.clojure/tools.macro {:mvn/version "0.1.5"} ; local macros
org.clojure/tools.namespace {:mvn/version "1.4.4"}
......
......@@ -179,27 +179,15 @@
(dump/dump-dimensions path)
(log/info (trs "END DUMP to {0} via user {1}" path user)))
(defn- v2-extract
"Extract entities to store. Takes map of options.
:collections - optional seq of collection IDs"
[{:keys [collections] :as opts}]
(let [opts (cond-> opts
collections
(assoc :targets (for [c collections] ["Collection" c])))]
;; if we have `:targets` (either because we created them from `:collections`, or because they were specified
;; elsewhere) use [[v2.extract/extract-subtrees]]
(if (:targets opts)
(v2.extract/extract-subtrees opts)
(v2.extract/extract-metabase opts))))
(defn v2-dump
"Exports Metabase app data to directory at path"
[path opts]
[path {:keys [user-email collections] :as opts}]
(log/info (trs "Exporting Metabase to {0}" path) (u/emoji "🏭 🚛💨"))
(mdb/setup-db!)
(t2/select User) ;; TODO -- why??? [editor's note: this comment originally from Cam]
(serdes/with-cache
(-> (v2-extract opts)
(-> (v2.extract/extract (merge opts {:targets (v2.extract/make-targets-of-type "Collection" collections)
:user-id (t2/select-one-pk User :email user-email :is_superuser true)}))
(v2.storage/store! path)))
(log/info (trs "Export to {0} complete!" path) (u/emoji "🚛💨 📦")))
......
......@@ -5,7 +5,6 @@
(:require
[clojure.set :as set]
[clojure.string :as str]
[medley.core :as m]
[metabase-enterprise.serialization.v2.backfill-ids :as serdes.backfill]
[metabase-enterprise.serialization.v2.models :as serdes.models]
[metabase.models :refer [Card Collection Dashboard DashboardCard]]
......@@ -18,53 +17,54 @@
(set! *warn-on-reflection* true)
(defn collection-set-for-user
"Given an optional user ID, find the transitive set of all Collection IDs which are either:
(a) global (ie. no one's personal collection);
(b) owned by this user (when user ID is non-nil); or
(c) descended from one of the above."
[user-or-nil]
(let [roots (t2/select ['Collection :id :location :personal_owner_id] :location "/")
unowned (remove :personal_owner_id roots)
owned (when user-or-nil
(filter #(= user-or-nil (:personal_owner_id %)) roots))
top-ids (->> (concat owned unowned)
(map :id)
set)]
(->> (concat unowned owned)
(map collection/descendant-ids)
(reduce set/union top-ids)
(set/union #{nil}))))
(defn extract-metabase
"Extracts the appdb into a reducible stream of serializable maps, with `:serdes/meta` keys.
This is the first step in serialization; see [[metabase-enterprise.serialization.v2.storage]] for actually writing to
files. Only the models listed in [[serdes.models/exported-models]] get exported.
Takes an options map which is passed on to [[serdes/extract-all]] for each model. The options are documented
there."
[opts]
(defn- model-set
"Returns a set of models to export based on export opts"
[{:keys [include-field-values targets no-data-model]}]
(cond-> #{}
include-field-values
(conj "FieldValues")
;; If `targets` is not specified, or if it is a non-empty collection, then we
;; extract all content models. If `targets` is an empty collection, then do not export
;; any content.
(or (nil? targets) (seq targets))
(into serdes.models/content)
(not no-data-model)
(into serdes.models/data-model)))
(defn targets-of-type
"Returns target seq filtered on given model name"
[targets model-name]
(filter #(= (first %) model-name) targets))
(defn make-targets-of-type
"Returns a targets seq with model type and given ids"
[model-name ids]
(for [id ids] [model-name id]))
(defn- collection-set-for-user
"Returns a set of collection IDs to export for the provided user, if any.
If user-id is nil, do not include any personally-owned collections."
[user-id]
(let [roots (t2/select Collection {:where [:and [:= :location "/"]
[:or [:= :personal_owner_id nil]
[:= :personal_owner_id user-id]]]})]
;; start with the special "nil" root collection ID
(-> #{nil}
(into (map :id) roots)
(into (mapcat collection/descendant-ids) roots))))
(defn- extract-metabase
"Returns reducible stream of serializable entity maps, with `:serdes/meta` keys.
Takes an options map which is passed on to [[serdes/extract-all]] for each model."
[{:keys [user-id] :as opts}]
(log/tracef "Extracting Metabase with options: %s" (pr-str opts))
(serdes.backfill/backfill-ids)
;; TODO document and test data-model-only if we want to keep this feature...
(let [model-pred (cond
(:data-model-only opts)
#{"Database" "Dimension" "Field" "FieldValues" "Metric" "Segment" "Table"}
(:include-field-values opts)
(constantly true)
:else
(complement #{"FieldValues"}))
;; This set of unowned top-level collections is used in several `extract-query` implementations.
opts (assoc opts :collection-set (collection-set-for-user (:user opts)))]
(eduction cat (for [model serdes.models/exported-models
:when (model-pred model)]
(serdes/extract-all model opts)))))
(let [extract-opts (assoc opts :collection-set (collection-set-for-user user-id))]
(eduction (map #(serdes/extract-all % extract-opts)) cat (model-set opts))))
;; TODO Properly support "continue" - it should be contagious. Eg. a Dashboard with an illegal Card gets excluded too.
(defn- descendants-closure [_opts targets]
(defn- descendants-closure [targets]
(loop [to-chase (set targets)
chased #{}]
(let [[m i :as item] (first to-chase)
......@@ -76,17 +76,15 @@
(recur to-chase chased)))))
(defn- escape-analysis
"Given a seq of collection IDs, explore the contents of these collections looking for \"leaks\". For example, a
"Given a target seq, explore the contents of any collections looking for \"leaks\". For example, a
Dashboard that contains Cards which are not (transitively) in the given set of collections, or a Card that depends on
a Card as a model, which is not in the given collections.
Returns a data structure detailing the gaps. Use [[escape-report]] to output this data in a human-friendly format.
Returns nil if there are no escaped values, which is useful for a test."
[collection-ids]
(let [collection-set (->> (t2/select Collection :id [:in (set collection-ids)])
(mapcat metabase.models.collection/descendant-ids)
set
(set/union (set collection-ids)))
[targets]
(let [collection-ids (into #{} (map second) (targets-of-type targets "Collection"))
collection-set (into collection-ids (mapcat collection/descendant-ids) (t2/select Collection :id [:in collection-ids]))
dashboards (t2/select Dashboard :collection_id [:in collection-set])
;; All cards that are in this collection set.
cards (reduce set/union (for [coll-id collection-set]
......@@ -162,7 +160,7 @@
curated-id (:name curated-card) (collection-label (:collection_id curated-card))
alien-id (:name alien-card) (collection-label (:collection_id alien-card))))))
(defn extract-subtrees
(defn- extract-subtrees
"Extracts the targeted entities and all their descendants into a reducible stream of extracted maps.
The targeted entities are specified as a list of `[\"SomeModel\" database-id]` pairs.
......@@ -175,15 +173,25 @@ Eg. if Dashboard B includes a Card A that is derived from a
serialized output."
[{:keys [targets] :as opts}]
(log/tracef "Extracting subtrees with options: %s" (pr-str opts))
(serdes.backfill/backfill-ids)
(if-let [analysis (escape-analysis (set (for [[model id] targets :when (= model "Collection")] id)))]
;; If that is non-nil, emit the report.
(if-let [analysis (escape-analysis targets)]
;; If that is non-nil, emit the report.
(escape-report analysis)
;; If it's nil, there are no errors, and we can proceed to do the dump.
(let [closure (descendants-closure opts targets)
by-model (->> closure
(group-by first)
(m/map-vals #(set (map second %))))]
(eduction cat (for [[model ids] by-model]
(eduction (map #(serdes/extract-one model opts %))
(db/select-reducible (symbol model) :id [:in ids])))))))
;; If it's nil, there are no errors, and we can proceed to do the dump.
(let [closure (descendants-closure targets)
;; filter the selected models based on user options
by-model (-> (group-by first closure)
(select-keys (model-set opts))
(update-vals #(set (map second %))))]
(eduction (map (fn [[model ids]]
(eduction (map #(serdes/extract-one model opts %))
(db/select-reducible (symbol model) :id [:in ids]))))
cat
by-model))))
(defn extract
"Returns a reducible stream of entities to serialize"
[{:keys [targets] :as opts}]
(serdes.backfill/backfill-ids)
(if (seq targets)
(extract-subtrees opts)
(extract-metabase opts)))
(ns metabase-enterprise.serialization.v2.models)
(def exported-models
"The list of models which are exported by serialization. Used for production code and by tests."
(def data-model
"Schema model types"
["Database"
"Field"
"Metric"
"Segment"
"Table"])
(def content
"Content model types"
["Action"
"Card"
"Collection"
"Dashboard"
"Database"
"Field"
"FieldValues"
"Metric"
"NativeQuerySnippet"
"Segment"
"Setting"
"Table"
"Timeline"])
(def exported-models
"The list of all models exported by serialization by default. Used for production code and by tests."
(concat data-model
content
["FieldValues"]))
(def inlined-models
"An additional list of models which are inlined into parent entities for serialization.
These are not extracted and serialized separately, but they may need some processing done.
......
......@@ -28,10 +28,11 @@
(spit-yaml (file opts entity) entity))
(defn- store-settings! [{:keys [root-dir]} settings]
(let [as-map (into (sorted-map)
(for [{:keys [key value]} settings]
[key value]))]
(spit-yaml (io/file root-dir "settings.yaml") as-map)))
(when (seq settings)
(let [as-map (into (sorted-map)
(for [{:keys [key value]} settings]
[key value]))]
(spit-yaml (io/file root-dir "settings.yaml") as-map))))
(defn store!
"Helper for storing a serialized database to a tree of YAML files."
......
......@@ -43,7 +43,7 @@
(path-files (apply u.files/get-path dir path-components)))]
(is (= (map
#(.toString (u.files/get-path (System/getProperty "java.io.tmpdir") "serdes-dir" %))
["collections" "settings.yaml"])
["collections"])
(files)))
(testing "subdirs"
(testing "cards"
......
......@@ -68,8 +68,8 @@
(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)
(cmd/load dump-dir "--mode" "update"
"--on-error" "abort")
(is (true? @user-pre-insert-called?)))))))
(deftest mode-update-remove-cards-test
......@@ -108,7 +108,7 @@
(testing "Create admin user"
(is (some? (ts/create! User, :is_superuser true)))
(is (t2/exists? User :is_superuser true)))
(is (nil? (cmd/load dump-dir "--on-error" :abort)))
(is (nil? (cmd/load dump-dir "--on-error" "abort")))
(testing "verify that things were loaded as expected"
(is (= 1 (t2/count Dashboard)) "# Dashboards")
(is (= 2 (t2/count Card)) "# Cards")
......@@ -128,7 +128,7 @@
yaml)))))
(testing "load again, with --mode update, destination Dashboard should now only have one question."
(ts/with-dest-db
(is (nil? (cmd/load dump-dir "--mode" :update, "--on-error" :abort)))
(is (nil? (cmd/load dump-dir "--mode" "update", "--on-error" "abort")))
(is (= 1 (t2/count Dashboard)) "# Dashboards")
(testing "Don't delete the Card even tho it was deleted. Just delete the DashboardCard"
(is (= 2 (t2/count Card)) "# Cards"))
......
......@@ -225,7 +225,7 @@
(is (= 100 (count (t2/select-fn-set :email 'User))))
(testing "extraction"
(reset! extraction (serdes/with-cache (into [] (extract/extract-metabase {}))))
(reset! extraction (serdes/with-cache (into [] (extract/extract {}))))
(reset! entities (reduce (fn [m entity]
(update m (-> entity :serdes/meta last :model)
(fnil conj []) entity))
......@@ -466,7 +466,7 @@
(is (= 2 (t2/count ParameterCard))))
(testing "extract and store"
(let [extraction (serdes/with-cache (into [] (extract/extract-metabase {})))]
(let [extraction (serdes/with-cache (into [] (extract/extract {})))]
(is (= [{:id "abc",
:name "CATEGORY",
:type :category,
......@@ -575,7 +575,7 @@
DashboardCard _ {:dashboard_id dashboard-id
:visualization_settings (link-card-viz-setting "dataset" model-id)}]
(testing "extract and store"
(let [extraction (serdes/with-cache (into [] (extract/extract-metabase {})))
(let [extraction (serdes/with-cache (into [] (extract/extract {})))
extracted-dashboard (first (filter #(= (:name %) "Test Dashboard") (by-model extraction "Dashboard")))]
(is (= [{:model "collection" :id coll-eid}
{:model "database" :id "Linked database"}
......
......@@ -89,26 +89,26 @@
(testing "overall extraction returns the expected set"
(testing "no user specified"
(is (= #{coll-eid child-eid}
(by-model "Collection" (extract/extract-metabase nil)))))
(by-model "Collection" (extract/extract nil)))))
(testing "valid user specified"
(is (= #{coll-eid child-eid pc-eid}
(by-model "Collection" (extract/extract-metabase {:user mark-id})))))
(by-model "Collection" (extract/extract {:user-id mark-id})))))
(testing "invalid user specified"
(is (= #{coll-eid child-eid}
(by-model "Collection" (extract/extract-metabase {:user 218921})))))))))
(by-model "Collection" (extract/extract {:user-id 218921})))))))))
(deftest database-test
(mt/with-empty-h2-app-db
(ts/with-temp-dpc [Database [_ {:name "My Database"}]]
(testing "without :include-database-secrets"
(let [extracted (extract/extract-metabase {})
(let [extracted (extract/extract {})
dbs (filter #(= "Database" (:model (last (serdes/path %)))) extracted)]
(is (= 1 (count dbs)))
(is (not-any? :details dbs))))
(testing "with :include-database-secrets"
(let [extracted (extract/extract-metabase {:include-database-secrets true})
(let [extracted (extract/extract {:include-database-secrets true})
dbs (filter #(= "Database" (:model (last (serdes/path %)))) extracted)]
(is (= 1 (count dbs)))
(is (every? :details dbs)))))))
......@@ -470,11 +470,11 @@
(map :name)))))
(testing "unowned collections and the personal one with a user"
(is (= #{coll-eid mark-coll-eid}
(->> {:collection-set (extract/collection-set-for-user mark-id)}
(->> {:collection-set (#'extract/collection-set-for-user mark-id)}
(serdes/extract-all "Collection")
(by-model "Collection"))))
(is (= #{coll-eid dave-coll-eid}
(->> {:collection-set (extract/collection-set-for-user dave-id)}
(->> {:collection-set (#'extract/collection-set-for-user dave-id)}
(serdes/extract-all "Collection")
(by-model "Collection"))))))
......@@ -485,12 +485,12 @@
(serdes/extract-all "Dashboard")
(by-model "Dashboard"))))
(is (= #{dash-eid}
(->> {:collection-set (extract/collection-set-for-user mark-id)}
(->> {:collection-set (#'extract/collection-set-for-user mark-id)}
(serdes/extract-all "Dashboard")
(by-model "Dashboard")))))
(testing "dashboards in personal collections are returned for the :user"
(is (= #{dash-eid other-dash param-dash}
(->> {:collection-set (extract/collection-set-for-user dave-id)}
(->> {:collection-set (#'extract/collection-set-for-user dave-id)}
(serdes/extract-all "Dashboard")
(by-model "Dashboard")))))))))
......@@ -915,9 +915,9 @@
(testing "extract-metabase behavior"
(testing "without :include-field-values"
(is (= #{}
(by-model "FieldValues" (extract/extract-metabase {})))))
(by-model "FieldValues" (extract/extract {})))))
(testing "with :include-field-values true"
(let [models (->> {:include-field-values true} extract/extract-metabase (map (comp :model last :serdes/meta)))]
(let [models (->> {:include-field-values true} extract/extract (map (comp :model last :serdes/meta)))]
;; why 6?
(is (= 6 (count (filter #{"FieldValues"} models))))))))))
......@@ -1292,7 +1292,7 @@
(is (= #{[{:model "Dashboard" :id dash1-eid :label "dashboard_1"}]
[{:model "Card" :id c1-1-eid :label "question_1_1"}]
[{:model "Card" :id c1-2-eid :label "question_1_2"}]}
(->> (extract/extract-subtrees {:targets [["Dashboard" dash1-id]]})
(->> (extract/extract {:targets [["Dashboard" dash1-id]]})
(map serdes/path)
set))))
......@@ -1300,7 +1300,7 @@
(is (= #{[{:model "Dashboard" :id dash2-eid :label "dashboard_2"}]
[{:model "Card" :id c2-1-eid :label "question_2_1"}]
[{:model "Card" :id c2-2-eid :label "question_2_2"}]}
(->> (extract/extract-subtrees {:targets [["Dashboard" dash2-id]]})
(->> (extract/extract {:targets [["Dashboard" dash2-id]]})
(map serdes/path)
set))))
......@@ -1308,7 +1308,7 @@
(is (= #{[{:model "Dashboard" :id dash3-eid :label "dashboard_3"}]
[{:model "Card" :id c3-1-eid :label "question_3_1"}]
[{:model "Card" :id c3-2-eid :label "question_3_2"}]}
(->> (extract/extract-subtrees {:targets [["Dashboard" dash3-id]]})
(->> (extract/extract {:targets [["Dashboard" dash3-id]]})
(map serdes/path)
set))))
......@@ -1319,7 +1319,7 @@
[{:model "Card" :id c1-1-eid :label "question_1_1"}]
;; card that the card on dashboard linked to
[{:model "Card" :id c1-2-eid :label "question_1_2"}]}
(->> (extract/extract-subtrees {:targets [["Dashboard" dash4-id]]})
(->> (extract/extract {:targets [["Dashboard" dash4-id]]})
(map serdes/path)
set)))))
......@@ -1341,17 +1341,17 @@
[{:model "Card" :id c1-3-eid :label "question_1_3"}]}]
(testing "grandchild collection has all its own contents"
(is (= grandchild-paths ; Includes the third card not found in the collection
(->> (extract/extract-subtrees {:targets [["Collection" coll3-id]]})
(->> (extract/extract {:targets [["Collection" coll3-id]]})
(map serdes/path)
set))))
(testing "middle collection has all its own plus the grandchild and its contents"
(is (= (set/union middle-paths grandchild-paths)
(->> (extract/extract-subtrees {:targets [["Collection" coll2-id]]})
(->> (extract/extract {:targets [["Collection" coll2-id]]})
(map serdes/path)
set))))
(testing "grandparent collection has all its own plus the grandchild and middle collections with contents"
(is (= (set/union grandparent-paths middle-paths grandchild-paths)
(->> (extract/extract-subtrees {:targets [["Collection" coll1-id]]})
(->> (extract/extract {:targets [["Collection" coll1-id]]})
(map serdes/path)
set))))
......@@ -1363,7 +1363,7 @@
[{:model "Card" :id c1-1-eid :label "question_1_1"}]
;; card that the card on dashboard linked to
[{:model "Card" :id c1-2-eid :label "question_1_2"}]}
(->> (extract/extract-subtrees {:targets [["Collection" coll4-id]]})
(->> (extract/extract {:targets [["Collection" coll4-id]]})
(map serdes/path)
set)))))))))
......
......@@ -44,9 +44,9 @@
(throw (ex-info (format "Unknown ingestion target: %s" path)
{:path path :world mapped})))))))
;;; WARNING for test authors: [[extract/extract-metabase]] returns a lazy reducible value. To make sure you don't
;;; WARNING for test authors: [[extract/extract]] returns a lazy reducible value. To make sure you don't
;;; confound your tests with data from your dev appdb, remember to eagerly
;;; `(into [] (extract/extract-metabase ...))` in these tests.
;;; `(into [] (extract/extract ...))` in these tests.
(deftest load-basics-test
(testing "a simple, fresh collection is imported"
......@@ -56,7 +56,7 @@
(testing "extraction succeeds"
(ts/with-source-db
(ts/create! Collection :name "Basic Collection" :entity_id eid1)
(reset! serialized (into [] (serdes.extract/extract-metabase {})))
(reset! serialized (into [] (serdes.extract/extract {})))
(is (some (fn [{[{:keys [model id]}] :serdes/meta}]
(and (= model "Collection") (= id eid1)))
@serialized))))
......@@ -93,7 +93,7 @@
(reset! grandchild (ts/create! Collection
:name "Grandchild Collection"
:location (format "/%d/%d/" (:id @parent) (:id @child))))
(reset! serialized (into [] (serdes.extract/extract-metabase {})))))
(reset! serialized (into [] (serdes.extract/extract {})))))
(testing "deserialization into a database that already has the parent, but with a different ID"
(ts/with-dest-db
......@@ -135,7 +135,7 @@
(reset! t2s (ts/create! Table :name "posts" :db_id (:id @db2s))) ; Deliberately the same name!
(reset! f1s (ts/create! Field :name "Target Field" :table_id (:id @t1s)))
(reset! f2s (ts/create! Field :name "Foreign Key" :table_id (:id @t2s) :fk_target_field_id (:id @f1s)))
(reset! serialized (into [] (serdes.extract/extract-metabase {})))))
(reset! serialized (into [] (serdes.extract/extract {})))))
(testing "serialization of databases is based on the :name"
(is (= #{(:name @db1s) (:name @db2s) "test-data"} ; TODO I'm not sure where the `test-data` one comes from.
......@@ -214,7 +214,7 @@
:aggregation [[:count]]}
:database (:id @db1s)}
:display :line))
(reset! serialized (into [] (serdes.extract/extract-metabase {})))))
(reset! serialized (into [] (serdes.extract/extract {})))))
(testing "the serialized form is as desired"
(is (= {:type :query
......@@ -294,7 +294,7 @@
:aggregation [[:count]]
:filter [:< [:field (:id @field1s) nil] 18]}
:creator_id (:id @user1s)))
(reset! serialized (into [] (serdes.extract/extract-metabase {})))))
(reset! serialized (into [] (serdes.extract/extract {})))))
(testing "exported form is properly converted"
(is (= {:source-table ["my-db" nil "customers"]
......@@ -370,7 +370,7 @@
:definition {:source-table (:id @table1s)
:aggregation [[:sum [:field (:id @field1s) nil]]]}
:creator_id (:id @user1s)))
(reset! serialized (into [] (serdes.extract/extract-metabase {})))))
(reset! serialized (into [] (serdes.extract/extract {})))))
(testing "exported form is properly converted"
(is (= {:source-table ["my-db" nil "orders"]
......@@ -493,7 +493,7 @@
:card_id (:id @card1s)
:target [:dimension [:field (:id @field1s) {:source-field (:id @field2s)}]]}]))
(reset! serialized (into [] (serdes.extract/extract-metabase {})))
(reset! serialized (into [] (serdes.extract/extract {})))
(let [card (-> @serialized (by-model "Card") first)
dash (-> @serialized (by-model "Dashboard") first)]
(testing "exported :parameter_mappings are properly converted"
......@@ -614,7 +614,7 @@
(testing "expecting 3 events"
(is (= 3 (t2/count TimelineEvent))))
(reset! serialized (into [] (serdes.extract/extract-metabase {})))
(reset! serialized (into [] (serdes.extract/extract {})))
(let [timelines (by-model @serialized "Timeline")
timeline1 (first (filter #(= (:entity_id %) (:entity_id @timeline1s)) timelines))
......@@ -704,7 +704,7 @@
(reset! user2s (ts/create! User :first_name "Neil" :last_name "Peart" :email "neil@rush.yyz"))
(reset! metric1s (ts/create! Metric :name "Large Users" :creator_id (:id @user1s) :definition {:aggregation [[:count]]}))
(reset! metric2s (ts/create! Metric :name "Support Headaches" :creator_id (:id @user2s) :definition {:aggregation [[:count]]}))
(reset! serialized (into [] (serdes.extract/extract-metabase {})))))
(reset! serialized (into [] (serdes.extract/extract {})))))
(testing "exported form is properly converted"
(is (= "tom@bost.on"
......@@ -779,7 +779,7 @@
(reset! fv2s (ts/create! FieldValues :field_id (:id @field2s)
:values ["CONSTRUCTION" "DAYLIGHTING" "DELIVERY" "HAULING"]))
(reset! serialized (into [] (serdes.extract/extract-metabase {:include-field-values true})))
(reset! serialized (into [] (serdes.extract/extract {:include-field-values true})))
(testing "the expected fields are serialized"
(is (= 1
......@@ -953,7 +953,7 @@
:type :query
:dataset_query "wow"
:database_id (:id db)})]
(reset! serialized (into [] (serdes.extract/extract-metabase {})))
(reset! serialized (into [] (serdes.extract/extract {})))
(let [action-serialized (first (filter (fn [{[{:keys [model id]}] :serdes/meta}]
(and (= model "Action") (= id eid)))
@serialized))]
......
......@@ -27,7 +27,7 @@
(mt/with-empty-h2-app-db
(ts/with-temp-dpc [Collection [parent {:name "Some Collection"}]
Collection [child {:name "Child Collection" :location (format "/%d/" (:id parent))}]]
(let [export (into [] (extract/extract-metabase nil))
(let [export (into [] (extract/extract nil))
parent-filename (format "%s_some_collection" (:entity_id parent))
child-filename (format "%s_child_collection" (:entity_id child))]
(storage/store! export dump-dir)
......@@ -72,7 +72,7 @@
Card [c3 {:name "parent card" :collection_id (:id parent)}]
Card [c4 {:name "child card" :collection_id (:id child)}]
Dashboard [d1 {:name "parent dash" :collection_id (:id parent)}]]
(let [export (into [] (extract/extract-metabase nil))]
(let [export (into [] (extract/extract nil))]
(storage/store! export dump-dir)
(testing "the right files in the right places"
(let [gp-dir (str (:entity_id grandparent) "_grandparent_collection")
......@@ -104,7 +104,7 @@
NativeQuerySnippet [c2 {:name "grandparent snippet" :collection_id (:id grandparent)}]
NativeQuerySnippet [c3 {:name "parent snippet" :collection_id (:id parent)}]
NativeQuerySnippet [c4 {:name "child snippet" :collection_id (:id child)}]]
(let [export (into [] (extract/extract-metabase nil))]
(let [export (into [] (extract/extract nil))]
(storage/store! export dump-dir)
(let [gp-dir (str (:entity_id grandparent) "_grandparent_collection")
p-dir (str (:entity_id parent) "_parent_collection")
......@@ -130,7 +130,7 @@
Field [website {:name "Company/organization website" :table_id (:id table)}]
FieldValues [_ {:field_id (:id website)}]
Table [_ {:name "Orders/Invoices" :db_id (:id db)}]]
(let [export (into [] (extract/extract-metabase {:include-field-values true}))]
(let [export (into [] (extract/extract {:include-field-values true}))]
(storage/store! export dump-dir)
(testing "the right files in the right places"
(is (= #{["Company__SLASH__organization website.yaml"]
......
......@@ -17,8 +17,8 @@
(:refer-clojure :exclude [load import])
(:require
[clojure.string :as str]
[clojure.tools.cli :as cli]
[environ.core :as env]
[medley.core :as m]
[metabase.config :as config]
[metabase.mbql.util :as mbql.u]
[metabase.plugins.classloader :as classloader]
......@@ -28,11 +28,36 @@
(set! *warn-on-reflection* true)
;; Command processing and option parsing utilities, etc.
(defn- system-exit!
"Proxy function to System/exit to enable the use of `with-redefs`."
[return-code]
(System/exit return-code))
(defn- cmd->var
"Looks up a command var by name"
[command-name]
(ns-resolve 'metabase.cmd (symbol command-name)))
(defn- call-enterprise
"Resolves enterprise command by symbol and calls with args, or else throws error if not EE"
[symb & args]
(let [f (try
(classloader/require (symbol (namespace symb)))
(resolve symb)
(catch Throwable e
(throw (ex-info (trs "The ''{0}'' command is only available in Metabase Enterprise Edition." (name symb))
{:command symb}
e))))]
(apply f args)))
(defn- get-parsed-options
[iref options]
(:options (cli/parse-opts options (:arg-spec (meta iref)))))
;; Command implementations
(defn ^:command migrate
"Run database migrations. Valid options for `direction` are `up`, `force`, `down`, `print`, or `release-locks`."
[direction]
......@@ -48,14 +73,17 @@
((resolve 'metabase.cmd.load-from-h2/load-from-h2!) h2-connection-string)))
(defn ^:command dump-to-h2
"Transfer data from existing database to newly created H2 DB with specified filename.
{:doc "Transfer data from existing database to newly created H2 DB with specified filename.
Target H2 file is deleted before dump, unless the --keep-existing flag is given."
Target H2 file is deleted before dump, unless the --keep-existing flag is given."
:arg-spec [["-k" "--keep-existing" "Do not delete target H2 file if it exists."
:id :keep-existing?]
["-p" "--dump-plaintext" "Do not encrypt dumped contents."
:id :dump-plaintext?]]}
[h2-filename & opts]
(classloader/require 'metabase.cmd.dump-to-h2)
(try
(let [options {:keep-existing? (boolean (some #{"--keep-existing"} opts))
:dump-plaintext? (boolean (some #{"--dump-plaintext"} opts))}]
(let [options (get-parsed-options #'dump-to-h2 opts)]
((resolve 'metabase.cmd.dump-to-h2/dump-to-h2!) h2-filename options)
(println "Dump complete")
(system-exit! 0))
......@@ -78,16 +106,26 @@
(defn ^:command help
"Show this help message listing valid Metabase commands."
[]
(println "Valid commands are:")
(doseq [[symb varr] (sort (ns-interns 'metabase.cmd))
:when (:command (meta varr))]
(println symb (str/join " " (:arglists (meta varr))))
(println "\t" (when-let [dox (:doc (meta varr))]
(str/replace dox #"\s+" " ")))) ; replace newlines or multiple spaces with single spaces
(println "\nSome other commands you might find useful:\n")
(println "java -cp metabase.jar org.h2.tools.Shell -url jdbc:h2:/path/to/metabase.db")
(println "\tOpen an SQL shell for the Metabase H2 DB"))
([command-name]
(let [{:keys [doc arg-spec arglists]} (meta (cmd->var command-name))]
(doseq [arglist arglists]
(apply println command-name arglist))
(when doc
(doseq [doc-line (str/split doc #"\n\s+")]
(println "\t" doc-line)))
(when arg-spec
(println "\t" "Options:")
(doseq [opt-line (str/split (:summary (cli/parse-opts [] arg-spec)) #"\n")]
(println "\t" opt-line)))))
([]
(println "Valid commands are:")
(doseq [[symb varr] (sort (ns-interns 'metabase.cmd))
:when (:command (meta varr))]
(help symb)
(println))
(println "\nSome other commands you might find useful:\n")
(println "java -cp metabase.jar org.h2.tools.Shell -url jdbc:h2:/path/to/metabase.db")
(println "\tOpen an SQL shell for the Metabase H2 DB")))
(defn ^:command version
"Print version information about Metabase and the current system."
......@@ -127,78 +165,61 @@
(classloader/require 'metabase.cmd.driver-methods)
((resolve 'metabase.cmd.driver-methods/print-available-multimethods) true)))
(defn- cmd-args->map
"Returns a map of keywords parsed from command-line argument flags and values. Handles
boolean flags as well as explicit values."
[args]
(m/map-keys #(keyword (str/replace-first % "--" ""))
(loop [parsed {}
[arg & [maybe-val :as more]] args]
(if arg
(if (or (nil? maybe-val) (str/starts-with? maybe-val "--"))
(recur (assoc parsed arg true) more)
(recur (assoc parsed arg maybe-val) (rest more)))
parsed))))
(defn- call-enterprise
"Resolves enterprise command by symbol and calls with args, or else throws error if not EE"
[symb & args]
(let [f (try
(classloader/require (symbol (namespace symb)))
(resolve symb)
(catch Throwable e
(throw (ex-info (trs "The ''{0}'' command is only available in Metabase Enterprise Edition." (name symb))
{:command symb}
e))))]
(apply f args)))
(defn ^:command load
"Load serialized metabase instance as created by [[dump]] command from directory `path`.
`--mode` can be one of `:update` or `:skip` (default). `--on-error` can be `:abort` or `:continue` (default)."
{:doc "Note: this command is deprecated. Use `import` instead.
Load serialized Metabase instance as created by [[dump]] command from directory `path`."
:arg-spec [["-m" "--mode (skip|update)" "Update or skip on conflicts."
:default :skip
:default-desc "skip"
:parse-fn mbql.u/normalize-token
:validate [#{:skip :update} "Must be 'skip' or 'update'"]]
["-e" "--on-error (continue|abort)" "Abort or continue on error."
:default :continue
:default-desc "continue"
:parse-fn mbql.u/normalize-token
:validate [#{:continue :abort} "Must be 'continue' or 'abort'"]]]}
[path & options]
(log/warn (u/colorize :red (trs "''load'' is deprecated and will be removed in a future release. Please migrate to ''import''.")))
(let [opts (merge {:mode :skip
:on-error :continue}
(m/map-vals mbql.u/normalize-token (cmd-args->map options)))]
(call-enterprise 'metabase-enterprise.serialization.cmd/v1-load path opts)))
(call-enterprise 'metabase-enterprise.serialization.cmd/v1-load path (get-parsed-options #'load options)))
(defn ^:command import
"This command is in development. For now, use [[load]].
Load serialized Metabase instance as created by the [[export]] command from directory `path`."
{:doc "Load serialized Metabase instance as created by the [[export]] command from directory `path`."
:arg-spec [["-e" "--abort-on-error" "Stops import on any errors, default is to continue."]]}
[path & options]
(let [opts {:abort-on-error (boolean (some #{"--abort-on-error"} options))}]
(call-enterprise 'metabase-enterprise.serialization.cmd/v2-load path opts)))
(call-enterprise 'metabase-enterprise.serialization.cmd/v2-load path (get-parsed-options #'import options)))
(defn ^:command dump
"Serialized metabase instance into directory `path`. `args` options may contain --state option with one of
`active` (default), `all`. With `active` option, do not dump archived entities."
{:doc "Note: this command is deprecated. Use `export` instead.
Serializes Metabase instance into directory `path`."
:arg-spec [["-u" "--user EMAIL" "Export collections owned by the specified user"]
["-s" "--state (active|all)" "When set to `active`, do not dump archived entities. Default behavior is `all`."
:default :all
:default-desc "all"
:parse-fn mbql.u/normalize-token
:validate [#{:active :all} "Must be 'active' or 'all'"]]]}
[path & options]
(log/warn (u/colorize :red (trs "''dump'' is deprecated and will be removed in a future release. Please migrate to ''export''.")))
(let [options (merge {:mode :skip
:on-error :continue}
(cmd-args->map options))]
(call-enterprise 'metabase-enterprise.serialization.cmd/v1-dump path options)))
(defn- parse-int-list
[s]
(when-not (str/blank? s)
(map #(Integer/parseInt %) (str/split s #","))))
(call-enterprise 'metabase-enterprise.serialization.cmd/v1-dump path (get-parsed-options #'dump options)))
(defn ^:command export
"This command is in development. For now, use [[dump]].
Serialize a Metabase into directory `path`.
Options:
--collections [collection-id-list] - a comma-separated list of IDs of collection to export
--include-field-values - flag, default false, controls export of field values
--include-database-secrets - flag, default false, include database connection details"
{:doc "Serialize Metabase instance into directory at `path`."
:arg-spec [["-u" "--user EMAIL" "Include collections owned by the specified user"
:id :user-email]
["-c" "--collection ID" "Export only specified ID; may occur multiple times."
:id :collections
:multi true
:parse-fn #(Integer/parseInt %)
:update-fn (fnil conj [])]
[nil "--collections ID_LIST" "(Legacy-style) Export collections in comma-separated list of IDs, e.g. '123,456'."
:parse-fn (fn [s] (map #(Integer/parseInt %) (str/split s #"\s*,\s*")))]
["-C" "--no-collections" "Do not export any collections or contents; overrides -c."
:id :collections
:update-fn (constantly [])]
["-D" "--no-data-model" "Do not export any data model entities; useful for subsequent exports."]
["-f" "--include-field-values" "Include field values along with field metadata."]
["-s" "--include-database-secrets" "Include database connection details (in plain text; use caution)."]]}
[path & options]
(let [opts (-> options cmd-args->map (update :collections parse-int-list))]
(call-enterprise 'metabase-enterprise.serialization.cmd/v2-dump path opts)))
(call-enterprise 'metabase-enterprise.serialization.cmd/v2-dump path (get-parsed-options #'export options)))
(defn ^:command seed-entity-ids
"Add entity IDs for instances of serializable models that don't already have them."
......@@ -221,9 +242,6 @@
;;; ------------------------------------------------ Validate Commands ----------------------------------------------
(defn- cmd->var [command-name]
(ns-resolve 'metabase.cmd (symbol command-name)))
(defn- arg-list-count-ok? [arg-list arg-count]
(if (some #{'&} arg-list)
;; subtract 1 for the & and 1 for the symbol after &
......@@ -231,48 +249,54 @@
(>= arg-count (- (count arg-list) 2))
(= arg-count (count arg-list))))
(defn- arg-count-good? [command-name args]
(let [arg-lists (-> command-name cmd->var meta :arglists)
arg-count-matches (mapv #(arg-list-count-ok? % (count args)) arg-lists)]
(if (some true? arg-count-matches)
[true]
[false (str "The '" command-name "' command requires "
(when (> 1 (count arg-lists)) "one of ")
"the following arguments: "
(str/join " | " (map pr-str arg-lists))
", but received: " (pr-str (vec args)) ".")])))
(defn- arg-count-errors
[command-name args]
(let [arg-lists (-> command-name cmd->var meta :arglists)]
(when-not (some #(arg-list-count-ok? % (count args)) arg-lists)
(str "The '" command-name "' command requires "
(when (> 1 (count arg-lists)) "one of ")
"the following arguments: "
(str/join " | " (map pr-str arg-lists))
", but received: " (pr-str (vec args)) "."))))
;;; ------------------------------------------------ Running Commands ------------------------------------------------
(defn- cmd->fn
(defn- validate
"Returns [error-message] if there is an error, otherwise [nil command-fn]"
[command-name args]
(cond
(not (seq command-name))
["No command given."]
(nil? (:command (meta (cmd->var command-name))))
[(str "Unrecognized command: '" command-name "'")]
(let [[ok? _message] (arg-count-good? command-name args)]
(not ok?))
[(second (arg-count-good? command-name args))]
:else
[nil @(cmd->var command-name)]))
(let [varr (cmd->var command-name)
{:keys [command arg-spec]} (meta varr)
err (arg-count-errors command-name args)]
(cond
(not command)
[(str "Unrecognized command: '" command-name "'")
(str "Valid commands: " (str/join ", " (map key (filter (comp :command meta val) (ns-interns 'metabase.cmd)))))]
err
[err]
arg-spec
(:errors (cli/parse-opts args arg-spec)))))
(defn- fail!
[& messages]
(doseq [msg messages]
(println (u/format-color 'red msg)))
(System/exit 1))
(defn run-cmd
"Run `cmd` with `args`. This is a function above. e.g. `clojure -M:run metabase migrate force` becomes
`(migrate \"force\")`."
[cmd args]
(let [[error-msg command-fn] (cmd->fn cmd args)]
(if error-msg
(do
(println (u/format-color 'red error-msg))
(System/exit 1))
(try (apply command-fn args)
(catch Throwable e
(.printStackTrace e)
(println (u/format-color 'red "Command failed with exception: %s" (.getMessage e)))
(System/exit 1)))))
[command-name args]
(if-let [errors (validate command-name args)]
(do
(when (cmd->var command-name)
(println "Usage:")
(help command-name))
(apply fail! errors))
(try
(apply @(cmd->var command-name) args)
(catch Throwable e
(.printStackTrace e)
(fail! (str "Command failed with exception: " (.getMessage e))))))
(System/exit 0))
(ns metabase.cmd-test
(:require
[clojure.test :as t :refer [deftest is testing]]
[clojure.test :as t :refer [deftest is are testing use-fixtures]]
[metabase.cmd :as cmd]))
(use-fixtures :each
(fn [t]
(with-redefs [cmd/call-enterprise list]
(t))))
(deftest ^:parallel error-message-test
(is (= ["No command given."] (#'cmd/cmd->fn nil [])))
(is (= ["Unrecognized command: 'a-command-that-does-not-exist'"] (#'cmd/cmd->fn "a-command-that-does-not-exist" [])))
(is (= ["Unrecognized command: 'a-command-that-does-not-exist'"
"Valid commands: version, help, import, dump, profile, api-documentation, load, seed-entity-ids, dump-to-h2, environment-variables-documentation, migrate, driver-methods, load-from-h2, export, rotate-encryption-key, reset-password"]
(#'cmd/validate "a-command-that-does-not-exist" [])))
(is (= ["The 'rotate-encryption-key' command requires the following arguments: [new-key], but received: []."]
(#'cmd/cmd->fn "rotate-encryption-key" [])))
(let [[error? the-fxn] (#'cmd/cmd->fn "rotate-encryption-key" [:some-arg])]
(is (nil? error?))
(is (fn? the-fxn))))
(deftest import-test
(with-redefs [cmd/call-enterprise list]
(testing "load (v1)"
(testing "with no options"
(is (= '(metabase-enterprise.serialization.cmd/v1-load "/path/" {:mode :skip, :on-error :continue})
(cmd/load "/path/"))))
(testing "with options"
(is (= '(metabase-enterprise.serialization.cmd/v1-load "/path/" {:mode :skip, :on-error :continue :num-cans :2})
(cmd/load "/path/" "--num-cans" "2")))))
(testing "import (v2)"
(testing "with no options"
(is (= '(metabase-enterprise.serialization.cmd/v2-load "/path/" {:abort-on-error false})
(cmd/import "/path/")))))))
(deftest export-test
(with-redefs [cmd/call-enterprise list]
(testing "dump (v1)"
(testing "with no options"
(is (= '(metabase-enterprise.serialization.cmd/v1-dump "/path/" {:mode :skip, :on-error :continue})
(cmd/dump "/path/"))))
(testing "with options"
(is (= '(metabase-enterprise.serialization.cmd/v1-dump "/path/" {:mode :skip, :on-error :continue, :num-cans "2"})
(cmd/dump "/path/" "--num-cans" "2")))))
(testing "export (v2)"
(testing "with no options"
(is (= '(metabase-enterprise.serialization.cmd/v2-dump "/path/" {:collections nil})
(cmd/export "/path/"))))
(testing "with --collections list"
(is (= '(metabase-enterprise.serialization.cmd/v2-dump "/path/" {:collections [1 2 3] :include-field-values true})
(cmd/export "/path/" "--collections" "1,2,3" "--include-field-values")))))))
(#'cmd/validate "rotate-encryption-key" [])))
(is (nil? (#'cmd/validate "rotate-encryption-key" [:some-arg]))))
(deftest load-command-test
(testing "with no options"
(is (= '(metabase-enterprise.serialization.cmd/v1-load "/path/" {:mode :skip, :on-error :continue})
(cmd/load "/path/"))))
(testing "with options"
(is (= '(metabase-enterprise.serialization.cmd/v1-load "/path/" {:mode :skip, :on-error :abort})
(cmd/load "/path/" "--on-error" "abort")))))
(deftest import-command-test
(testing "with no options"
(is (= '(metabase-enterprise.serialization.cmd/v2-load "/path/" {})
(cmd/import "/path/"))))
(testing "with options"
(is (= '(metabase-enterprise.serialization.cmd/v2-load "/path/" {:abort-on-error true})
(cmd/import "/path/" "--abort-on-error")))))
(deftest dump-command-test
(testing "with no options"
(is (= '(metabase-enterprise.serialization.cmd/v1-dump "/path/" {:state :all})
(cmd/dump "/path/"))))
(testing "with options"
(is (= '(metabase-enterprise.serialization.cmd/v1-dump "/path/" {:state :active})
(cmd/dump "/path/" "--state" "active")))))
(deftest export-command-test
(are [cmd-args v2-dump-args] (= '(metabase-enterprise.serialization.cmd/v2-dump "/path/" v2-dump-args)
(apply cmd/export "/path/" cmd-args))
nil
{}
["--collection" "123"]
{:collections [123]}
["-c" "123" "-c" "456"]
{:collections [123 456]}
["--include-field-values"]
{:include-field-values true}
["--no-collections"]
{:collections []}
["--no-data-model"]
{:no-data-model true}))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment