From 486950219e504c8922b283e0f483822f7049e9c6 Mon Sep 17 00:00:00 2001 From: Cam Saul <1455846+camsaul@users.noreply.github.com> Date: Mon, 2 Mar 2020 16:37:40 -0800 Subject: [PATCH] Implement for GET /api/database?include=tables and ?saved=true (#12027) --- src/metabase/api/database.clj | 68 +++++-- test/metabase/api/database_test.clj | 285 +++++++++++++++------------- 2 files changed, 199 insertions(+), 154 deletions(-) diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index 67d83853998..13bbcaa3466 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -4,6 +4,7 @@ [clojure.tools.logging :as log] [compojure.core :refer [DELETE GET POST PUT]] [metabase + [config :as config] [driver :as driver] [events :as events] [public-settings :as public-settings] @@ -134,37 +135,64 @@ (for [card (source-query-cards)] (table-api/card->virtual-table card :include-fields? include-fields?))) -(defn- saved-cards-virtual-db-metadata [& {:keys [include-fields?]}] +(defn- saved-cards-virtual-db-metadata [& {:keys [include-tables? include-fields?]}] (when (public-settings/enable-nested-queries) - (when-let [virtual-tables (seq (cards-virtual-tables :include-fields? include-fields?))] - {:name "Saved Questions" - :id mbql.s/saved-questions-virtual-database-id - :features #{:basic-aggregations} - :tables virtual-tables - :is_saved_questions true}))) + (cond-> {:name "Saved Questions" + :id mbql.s/saved-questions-virtual-database-id + :features #{:basic-aggregations} + :is_saved_questions true} + include-tables? (assoc :tables (cards-virtual-tables :include-fields? include-fields?))))) ;; "Virtual" tables for saved cards simulate the db->schema->table hierarchy by doing fake-db->collection->card -(defn- add-virtual-tables-for-saved-cards [dbs] - (if-let [virtual-db-metadata (saved-cards-virtual-db-metadata)] +(defn- add-saved-questions-virtual-database [dbs & options] + (if-let [virtual-db-metadata (apply saved-cards-virtual-db-metadata options)] ;; only add the 'Saved Questions' DB if there are Cards that can be used (conj (vec dbs) virtual-db-metadata) dbs)) -(defn- dbs-list [include-tables? include-cards?] +(defn- dbs-list [& {:keys [include-tables? include-saved-questions-db? include-saved-questions-tables?]}] (when-let [dbs (seq (filter mi/can-read? (db/select Database {:order-by [:%lower.name :%lower.engine]})))] (cond-> (add-native-perms-info dbs) - include-tables? add-tables - include-cards? add-virtual-tables-for-saved-cards))) + include-tables? add-tables + include-saved-questions-db? (add-saved-questions-virtual-database :include-tables? include-saved-questions-tables?)))) (api/defendpoint GET "/" - "Fetch all `Databases`. `include_tables` means we should hydrate the Tables belonging to each DB. `include_cards` here - means we should also include virtual Table entries for saved Questions, e.g. so we can easily use them as source - Tables in queries. Default for both is `false`." - [include_tables include_cards] + "Fetch all `Databases`. + + * `include=tables` means we should hydrate the Tables belonging to each DB. Default: `false`. + + * `saved` means we should include the saved questions virtual database. Default: `false`. + + * `include_tables` is a legacy alias for `include=tables`, but should be considered deprecated as of 0.35.0, and will + be removed in a future release. + + * `include_cards` here means we should also include virtual Table entries for saved Questions, e.g. so we can easily + use them as source Tables in queries. This is a deprecated alias for `saved=true` + `include=tables` (for the saved + questions virtual DB). Prefer using `include` and `saved` instead. " + [include_tables include_cards include saved] {include_tables (s/maybe su/BooleanString) - include_cards (s/maybe su/BooleanString)} - (or (dbs-list (Boolean/parseBoolean include_tables) (Boolean/parseBoolean include_cards)) - [])) + include_cards (s/maybe su/BooleanString) + include (s/maybe (s/eq "tables")) + saved (s/maybe su/BooleanString)} + (when (and config/is-dev? + (or include_tables include_cards)) + ;; don't need to i18n since this is dev-facing only + (log/warn "GET /api/database?include_tables and ?include_cards are deprecated." + "Prefer using ?include=tables and ?saved=true instead.")) + (let [include-tables? (cond + (seq include) (= include "tables") + (seq include_tables) (Boolean/parseBoolean include_tables)) + include-saved-questions-db? (cond + (seq saved) (Boolean/parseBoolean saved) + (seq include_cards) (Boolean/parseBoolean include_cards)) + include-saved-questions-tables? (when include-saved-questions-db? + (if (seq include_cards) + true + include-tables?))] + (or (dbs-list :include-tables? include-tables? + :include-saved-questions-db? include-saved-questions-db? + :include-saved-questions-tables? include-saved-questions-tables?) + []))) ;;; --------------------------------------------- GET /api/database/:id ---------------------------------------------- @@ -220,7 +248,7 @@ "Endpoint that provides metadata for the Saved Questions 'virtual' database. Used for fooling the frontend and allowing it to treat the Saved Questions virtual DB just like any other database." [] - (saved-cards-virtual-db-metadata :include-fields? true)) + (saved-cards-virtual-db-metadata :include-tables? true, :include-fields? true)) (defn- db-metadata [id] (-> (api/read-check Database id) diff --git a/test/metabase/api/database_test.clj b/test/metabase/api/database_test.clj index 7ad8c8437e8..a43ddd49bf4 100644 --- a/test/metabase/api/database_test.clj +++ b/test/metabase/api/database_test.clj @@ -112,24 +112,6 @@ field [:updated_at :id :created_at :last_analyzed :fingerprint :fingerprint_version :fk_target_field_id :position]))) -(deftest databases-list-test - (testing "GET /api/database" - (testing "Test that we can get all the DBs (ordered by name, then driver)" - (testing "Database details *should not* come back for Rasta since she's not a superuser" - (let [expected-keys (-> (into #{:features :native_permissions} (keys (Database (mt/id)))) - (disj :details))] - (doseq [db ((mt/user->client :rasta) :get 200 "database")] - (testing (format "Database %s %d %s" (:engine db) (u/get-id db) (pr-str (:name db))) - (is (= expected-keys - (set (keys db))))))))) - - (testing "?include_tables=true" - (mt/with-temp Database [{db-id :id, db-name :name} {:engine (u/qualified-name ::test-driver)}] - (doseq [db ((mt/user->client :rasta) :get 200 "database" :include_tables true)] - (testing (format "Database %s %d %s" (:engine db) (u/get-id db) (pr-str (:name db))) - (is (= (expected-tables db) - (:tables db))))))))) - (defn- add-schedules [db] (assoc db :schedules {:cache_field_values {:schedule_day nil :schedule_frame nil @@ -344,11 +326,6 @@ :description nil} kvs)) -(defn- fetch-virtual-database [] - (some #(when (= (:name %) "Saved Questions") - %) - ((mt/user->client :crowberto) :get 200 "database" :include_cards true))) - (driver/register! ::no-nested-query-support :parent :sql-jdbc :abstract? true) @@ -360,82 +337,116 @@ :source-table (mt/id :checkins)) :result_metadata [{:name "num_toucans"}])) +(deftest databases-list-test + (testing "GET /api/database" + (testing "Test that we can get all the DBs (ordered by name, then driver)" + (testing "Database details *should not* come back for Rasta since she's not a superuser" + (let [expected-keys (-> (into #{:features :native_permissions} (keys (Database (mt/id)))) + (disj :details))] + (doseq [db ((mt/user->client :rasta) :get 200 "database")] + (testing (format "Database %s %d %s" (:engine db) (u/get-id db) (pr-str (:name db))) + (is (= expected-keys + (set (keys db))))))))) + + ;; ?include=tables and ?include_tables=true mean the same thing so test them both the same way + (doseq [query-param ["?include_tables=true" + "?include=tables"]] + (testing query-param + (mt/with-temp Database [{db-id :id, db-name :name} {:engine (u/qualified-name ::test-driver)}] + (doseq [db ((mt/user->client :rasta) :get 200 (str "database" query-param))] + (testing (format "Database %s %d %s" (:engine db) (u/get-id db) (pr-str (:name db))) + (is (= (expected-tables db) + (:tables db)))))))))) + (deftest databases-list-include-saved-questions-test - (testing "GET /api/database?include_cards=true" - (testing "Check that we get back 'virtual' tables for Saved Questions" - (testing "The saved questions virtual DB should be the last DB in the list" - (mt/with-temp* [Card [card (card-with-native-query "Kanye West Quote Views Per Month")]] - ;; run the Card which will populate its result_metadata column - ((mt/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) - ;; Now fetch the database list. The 'Saved Questions' DB should be last on the list - (is (= (-> card - virtual-table-for-card - saved-questions-virtual-db) - (last ((mt/user->client :crowberto) :get 200 "database" :include_cards true)))))) - - (testing "Make sure saved questions are NOT included if the setting is disabled" - (mt/with-temp Card [card (card-with-native-query "Kanye West Quote Views Per Month")] - (mt/with-temporary-setting-values [enable-nested-queries false] - ;; run the Card which will populate its result_metadata column - ((mt/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) - ;; Now fetch the database list. The 'Saved Questions' DB should NOT be in the list - (is (= nil - (some (fn [database] - (when (= (u/get-id database) mbql.s/saved-questions-virtual-database-id) - database)) - ((mt/user->client :crowberto) :get 200 "database" :include_cards true)))))))) - - (testing "should pretend Collections are schemas" - (mt/with-temp* [Collection [stamp-collection {:name "Stamps"}] - Collection [coin-collection {:name "Coins"}] - Card [stamp-card (card-with-native-query "Total Stamp Count", :collection_id (u/get-id stamp-collection))] - Card [coin-card (card-with-native-query "Total Coin Count", :collection_id (u/get-id coin-collection))]] - ;; run the Cards which will populate their result_metadata columns - (doseq [card [stamp-card coin-card]] - ((mt/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card)))) - ;; Now fetch the database list. The 'Saved Questions' DB should be last on the list. Cards should have their - ;; Collection name as their Schema - (is (= (saved-questions-virtual-db - (virtual-table-for-card coin-card :schema "Coins") - (virtual-table-for-card stamp-card :schema "Stamps")) - (last ((mt/user->client :crowberto) :get 200 "database" :include_cards true)))))) - - (testing "should remove Cards that have ambiguous columns" - (mt/with-temp* [Card [ok-card (assoc (card-with-native-query "OK Card") :result_metadata [{:name "cam"}])] - Card [cambiguous-card (assoc (card-with-native-query "Cambiguous Card") :result_metadata [{:name "cam"} {:name "cam_2"}])]] - (is (= (-> ok-card - virtual-table-for-card - saved-questions-virtual-db) - (fetch-virtual-database))))) - - (testing "should remove Cards that belong to a driver that doesn't support nested queries" - (mt/with-temp* [Database [bad-db {:engine ::no-nested-query-support, :details {}}] - Card [bad-card {:name "Bad Card" - :dataset_query {:database (u/get-id bad-db) - :type :native - :native {:query "[QUERY GOES HERE]"}} - :result_metadata [{:name "sparrows"}] - :database_id (u/get-id bad-db)}] - Card [ok-card (assoc (card-with-native-query "OK Card") - :result_metadata [{:name "finches"}])]] - (is (= (-> ok-card - virtual-table-for-card - saved-questions-virtual-db) - (fetch-virtual-database))))) - - (testing "should remove Cards that use cumulative-sum and cumulative-count aggregations" - (mt/with-temp* [Card [ok-card (ok-mbql-card)] - Card [_ (merge - (mt/$ids checkins - (card-with-mbql-query "Cum Count Card" - :source-table $$checkins - :aggregation [[:cum-count]] - :breakout [!month.date])) - {:result_metadata [{:name "num_toucans"}]})]] - (is (= (-> ok-card - virtual-table-for-card - saved-questions-virtual-db) - (fetch-virtual-database))))))) + (testing "GET /api/database?saved=true" + (testing "We should be able to include the saved questions virtual DB (without Tables) with the param ?saved=true" + (is (= {:name "Saved Questions" + :id mbql.s/saved-questions-virtual-database-id + :features ["basic-aggregations"] + :is_saved_questions true} + (last ((mt/user->client :lucky) :get 200 "database?saved=true"))))))) + +(deftest databases-list-include-saved-questions-tables-test + ;; `?saved=true&include=tables` and `?include_cards=true` mean the same thing, so test them both + (doseq [params ["?saved=true&include=tables" + "?include_cards=true"]] + (testing (str "GET /api/database" params) + (letfn [(fetch-virtual-database [] + (some #(when (= (:name %) "Saved Questions") + %) + ((mt/user->client :crowberto) :get 200 (str "database" params))))] + (testing "Check that we get back 'virtual' tables for Saved Questions" + (testing "The saved questions virtual DB should be the last DB in the list" + (mt/with-temp* [Card [card (card-with-native-query "Kanye West Quote Views Per Month")]] + ;; run the Card which will populate its result_metadata column + ((mt/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) + ;; Now fetch the database list. The 'Saved Questions' DB should be last on the list + (is (= (-> card + virtual-table-for-card + saved-questions-virtual-db) + (last ((mt/user->client :crowberto) :get 200 (str "database" params))))))) + + (testing "Make sure saved questions are NOT included if the setting is disabled" + (mt/with-temp Card [card (card-with-native-query "Kanye West Quote Views Per Month")] + (mt/with-temporary-setting-values [enable-nested-queries false] + ;; run the Card which will populate its result_metadata column + ((mt/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card))) + ;; Now fetch the database list. The 'Saved Questions' DB should NOT be in the list + (is (= nil + (fetch-virtual-database))))))) + + (testing "should pretend Collections are schemas" + (mt/with-temp* [Collection [stamp-collection {:name "Stamps"}] + Collection [coin-collection {:name "Coins"}] + Card [stamp-card (card-with-native-query "Total Stamp Count", :collection_id (u/get-id stamp-collection))] + Card [coin-card (card-with-native-query "Total Coin Count", :collection_id (u/get-id coin-collection))]] + ;; run the Cards which will populate their result_metadata columns + (doseq [card [stamp-card coin-card]] + ((mt/user->client :crowberto) :post 202 (format "card/%d/query" (u/get-id card)))) + ;; Now fetch the database list. The 'Saved Questions' DB should be last on the list. Cards should have their + ;; Collection name as their Schema + (is (= (saved-questions-virtual-db + (virtual-table-for-card coin-card :schema "Coins") + (virtual-table-for-card stamp-card :schema "Stamps")) + (last ((mt/user->client :crowberto) :get 200 (str "database" params))))))) + + (testing "should remove Cards that have ambiguous columns" + (mt/with-temp* [Card [ok-card (assoc (card-with-native-query "OK Card") :result_metadata [{:name "cam"}])] + Card [cambiguous-card (assoc (card-with-native-query "Cambiguous Card") :result_metadata [{:name "cam"} {:name "cam_2"}])]] + (is (= (-> ok-card + virtual-table-for-card + saved-questions-virtual-db) + (fetch-virtual-database))))) + + (testing "should remove Cards that belong to a driver that doesn't support nested queries" + (mt/with-temp* [Database [bad-db {:engine ::no-nested-query-support, :details {}}] + Card [bad-card {:name "Bad Card" + :dataset_query {:database (u/get-id bad-db) + :type :native + :native {:query "[QUERY GOES HERE]"}} + :result_metadata [{:name "sparrows"}] + :database_id (u/get-id bad-db)}] + Card [ok-card (assoc (card-with-native-query "OK Card") + :result_metadata [{:name "finches"}])]] + (is (= (-> ok-card + virtual-table-for-card + saved-questions-virtual-db) + (fetch-virtual-database))))) + + (testing "should remove Cards that use cumulative-sum and cumulative-count aggregations" + (mt/with-temp* [Card [ok-card (ok-mbql-card)] + Card [_ (merge + (mt/$ids checkins + (card-with-mbql-query "Cum Count Card" + :source-table $$checkins + :aggregation [[:cum-count]] + :breakout [!month.date])) + {:result_metadata [{:name "num_toucans"}]})]] + (is (= (-> ok-card + virtual-table-for-card + saved-questions-virtual-db) + (fetch-virtual-database))))))))) (deftest db-metadata-saved-questions-db-test (testing "GET /api/database/:id/metadata works for the Saved Questions 'virtual' database" @@ -453,9 +464,13 @@ ((mt/user->client :crowberto) :get 200 (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id))))) - (testing "if no eligible Saved Questions exist the virtual DB metadata endpoint should just return `nil`" - (is (= nil - ((mt/user->client :crowberto) :get 204 + (testing "if no eligible Saved Questions exist the endpoint should return empty tables" + (is (= {:name "Saved Questions" + :id mbql.s/saved-questions-virtual-database-id + :features ["basic-aggregations"] + :is_saved_questions true + :tables []} + ((mt/user->client :crowberto) :get 200 (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id))))))) @@ -537,46 +552,48 @@ (is (= true (deref analyze-called? long-timeout :analyze-never-called))))))))) -;; (Non-admins should not be allowed to trigger sync) (deftest non-admins-cant-trigger-sync - (is (= "You don't have permissions to do that." - ((mt/user->client :rasta) :post 403 (format "database/%d/sync_schema" (mt/id)))))) + (testing "Non-admins should not be allowed to trigger sync" + (is (= "You don't have permissions to do that." + ((mt/user->client :rasta) :post 403 (format "database/%d/sync_schema" (mt/id))))))) -;; Can we RESCAN all the FieldValues for a DB? (deftest can-rescan-fieldvalues-for-a-db - (is (= :sync-called - (let [update-field-values-called? (promise)] - (mt/with-temp Database [db {:engine "h2", :details (:details (mt/db))}] - (with-redefs [field-values/update-field-values! (fn [synced-db] - (when (= (u/get-id synced-db) (u/get-id db)) - (deliver update-field-values-called? :sync-called)))] - ((mt/user->client :crowberto) :post 200 (format "database/%d/rescan_values" (u/get-id db))) - (deref update-field-values-called? long-timeout :sync-never-called))))))) - -;; (Non-admins should not be allowed to trigger re-scan) + (testing "Can we RESCAN all the FieldValues for a DB?" + (let [update-field-values-called? (promise)] + (mt/with-temp Database [db {:engine "h2", :details (:details (mt/db))}] + (with-redefs [field-values/update-field-values! (fn [synced-db] + (when (= (u/get-id synced-db) (u/get-id db)) + (deliver update-field-values-called? :sync-called)))] + ((mt/user->client :crowberto) :post 200 (format "database/%d/rescan_values" (u/get-id db))) + (is (= :sync-called + (deref update-field-values-called? long-timeout :sync-never-called)))))))) + (deftest nonadmins-cant-trigger-rescan - (is (= "You don't have permissions to do that." - ((mt/user->client :rasta) :post 403 (format "database/%d/rescan_values" (mt/id)))))) + (testing "Non-admins should not be allowed to trigger re-scan" + (is (= "You don't have permissions to do that." + ((mt/user->client :rasta) :post 403 (format "database/%d/rescan_values" (mt/id))))))) -;; Can we DISCARD all the FieldValues for a DB? (deftest discard-db-fieldvalues - (is (= {:values-1-still-exists? false - :values-2-still-exists? false} - (mt/with-temp* [Database [db {:engine "h2", :details (:details (mt/db))}] - Table [table-1 {:db_id (u/get-id db)}] - Table [table-2 {:db_id (u/get-id db)}] - Field [field-1 {:table_id (u/get-id table-1)}] - Field [field-2 {:table_id (u/get-id table-2)}] - FieldValues [values-1 {:field_id (u/get-id field-1), :values [1 2 3 4]}] - FieldValues [values-2 {:field_id (u/get-id field-2), :values [1 2 3 4]}]] - ((mt/user->client :crowberto) :post 200 (format "database/%d/discard_values" (u/get-id db))) - {:values-1-still-exists? (db/exists? FieldValues :id (u/get-id values-1)) - :values-2-still-exists? (db/exists? FieldValues :id (u/get-id values-2))})))) - -;; (Non-admins should not be allowed to discard all FieldValues) + (testing "Can we DISCARD all the FieldValues for a DB?" + (mt/with-temp* [Database [db {:engine "h2", :details (:details (mt/db))}] + Table [table-1 {:db_id (u/get-id db)}] + Table [table-2 {:db_id (u/get-id db)}] + Field [field-1 {:table_id (u/get-id table-1)}] + Field [field-2 {:table_id (u/get-id table-2)}] + FieldValues [values-1 {:field_id (u/get-id field-1), :values [1 2 3 4]}] + FieldValues [values-2 {:field_id (u/get-id field-2), :values [1 2 3 4]}]] + ((mt/user->client :crowberto) :post 200 (format "database/%d/discard_values" (u/get-id db))) + (testing "values-1 still exists?" + (is (= false + (db/exists? FieldValues :id (u/get-id values-1))))) + (testing "values-2 still exists?" + (is (= false + (db/exists? FieldValues :id (u/get-id values-2)))))))) + (deftest nonadmins-cant-discard-all-fieldvalues - (is (= "You don't have permissions to do that." - ((mt/user->client :rasta) :post 403 (format "database/%d/discard_values" (mt/id)))))) + (testing "Non-admins should not be allowed to discard all FieldValues" + (is (= "You don't have permissions to do that." + ((mt/user->client :rasta) :post 403 (format "database/%d/discard_values" (mt/id))))))) ;; For some stupid reason the *real* version of `test-database-connection` is set up to do nothing for tests. I'm -- GitLab