From be0d5b907b37b2726006dcebd70be6618bfae499 Mon Sep 17 00:00:00 2001 From: Cam Saul <cammsaul@gmail.com> Date: Mon, 8 Jul 2019 15:00:08 -0700 Subject: [PATCH] Include Table info in Metric/Segment search results (#10305) --- src/metabase/api/search.clj | 374 ++++++++++++++++++------------ test/metabase/api/search_test.clj | 139 ++++++----- 2 files changed, 318 insertions(+), 195 deletions(-) diff --git a/src/metabase/api/search.clj b/src/metabase/api/search.clj index 14f80b6be04..28016d83e1a 100644 --- a/src/metabase/api/search.clj +++ b/src/metabase/api/search.clj @@ -1,9 +1,14 @@ (ns metabase.api.search (:require [clojure.string :as str] [compojure.core :refer [GET]] - [honeysql.helpers :as h] - [metabase.api.common :refer [*current-user-id* *current-user-permissions-set* check-403 defendpoint - define-routes]] + [flatland.ordered.map :as ordered-map] + [honeysql + [core :as hsql] + [helpers :as h]] + [metabase + [db :as mdb] + [util :as u]] + [metabase.api.common :as api] [metabase.models [card :refer [Card]] [card-favorite :refer [CardFavorite]] @@ -12,202 +17,285 @@ [dashboard-favorite :refer [DashboardFavorite]] [metric :refer [Metric]] [pulse :refer [Pulse]] - [segment :refer [Segment]]] - [metabase.util :as u] + [segment :refer [Segment]] + [table :refer [Table]]] [metabase.util [honeysql-extensions :as hx] [schema :as su]] [schema.core :as s] [toucan.db :as db])) +(def ^:private SearchContext + "Map with the various allowed search parameters, used to construct the SQL query" + {:search-string (s/maybe su/NonBlankString) + :archived? s/Bool + :visible-collections coll/VisibleCollections}) + +(def ^:private searchable-models + [Card Dashboard Pulse Collection Segment Metric]) + +(def ^:private SearchableModel + (apply s/enum searchable-models)) + +(def ^:private HoneySQLColumn + (s/cond-pre + s/Keyword + [(s/one s/Any "column or value") + (s/one s/Keyword "alias")])) + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Columns for each Entity | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(def ^:private all-search-columns + "All columns that will appear in the search results, and the types of those columns. The generated search query is a + `UNION ALL` of the queries for each different entity; it looks something like: + + SELECT 'card' AS model, id, cast(NULL AS integer) AS table_id, ... + FROM report_card + UNION ALL + SELECT 'metric' as model, id, table_id, ... + FROM metric + + Columns that aren't used in any individual query are replaced with `SELECT cast(NULL AS <type>)` statements. (These + are cast to the appropriate type because Postgres will assume `SELECT NULL` is `TEXT` by default and will refuse to + `UNION` two columns of two different types.)" + (ordered-map/ordered-map + ;; returned for all models + :model :text + :id :integer + :name :text + :description :text + :archived :boolean + ;; returned for Card, Dashboard, Pulse, and Collection + :collection_id :integer + ;; returned for Card and Dashboard + :collection_position :integer + :favorite :boolean + ;; returned for Metric and Segment + :table_id :integer + :database_id :integer + :table_schema :text + :table_name :text + :table_description :text)) + +;; below are the actual columns returned for any given entity + (def ^:private default-columns + "Columns returned for all models." [:id :name :description :archived]) -(def ^:private card-columns-without-type - (concat default-columns - [:collection_id :collection_position [:card_fav.id :favorite]])) +(def ^:private favorite-col + "Case statement to return boolean values of `:favorite` for Card and Dashboard." + [(hsql/call :case [:not= :fave.id nil] true :else false) :favorite]) + +(def ^:private table-columns + "Columns containing information about the table this model references. Returned for Metrics and Segments." + [:table_id + [:table.db_id :database_id] + [:table.schema :table_schema] + [:table.name :table_name] + [:table.description :table_description]]) -(def ^:private dashboard-columns-without-type - (concat default-columns - [:collection_id :collection_position [:dashboard_fav.id :favorite]])) +(defmulti ^:private columns-for-model + "The columns that will be returned by the query for `model`, excluding `:model`, which is added automatically." + {:arglists '([model])} + class) -(def ^:private pulse-columns-without-type +(defmethod columns-for-model (class Card) + [_] + (conj default-columns :collection_id :collection_position favorite-col)) + +(defmethod columns-for-model (class Dashboard) + [_] + (conj default-columns :collection_id :collection_position favorite-col)) + +(defmethod columns-for-model (class Pulse) + [_] [:id :name :collection_id]) -(def ^:private collection-columns-without-type - (concat default-columns - [[:id :collection_id]])) +(defmethod columns-for-model (class Collection) + [_] + (conj default-columns [:id :collection_id])) + +(defmethod columns-for-model (class Segment) + [_] + (into default-columns table-columns)) + +(defmethod columns-for-model (class Metric) + [_] + (into default-columns table-columns)) -(def ^:private segment-columns-without-type - default-columns) -(def ^:private metric-columns-without-type - default-columns) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Shared Query Logic | +;;; +----------------------------------------------------------------------------------------------------------------+ -(defn- ->column +(s/defn ^:private model->alias :- s/Keyword + [model :- SearchableModel] + (keyword (str/lower-case (name model)))) + +(s/defn ^:private ->column-alias :- s/Keyword "Returns the column name. If the column is aliased, i.e. [`:original_name` `:aliased_name`], return the aliased column name" - [column-or-aliased] + [column-or-aliased :- HoneySQLColumn] (if (sequential? column-or-aliased) (second column-or-aliased) column-or-aliased)) -(def ^:private search-columns-without-type - "The columns found in search query clauses except type. Type is added automatically" - (set (map ->column (concat card-columns-without-type - dashboard-columns-without-type - pulse-columns-without-type - collection-columns-without-type - segment-columns-without-type - metric-columns-without-type)))) +(s/defn ^:private canonical-columns :- [HoneySQLColumn] + "Returns a seq of canonicalized list of columns for the search query with the given `model` Will return column names + prefixed with the `model` name so that it can be used in criteria. Projects a `nil` for columns the `model` doesn't + have and doesn't modify aliases." + [model :- SearchableModel, col-alias->honeysql-clause :- {s/Keyword HoneySQLColumn}] + (for [[search-col col-type] all-search-columns + :let [maybe-aliased-col (get col-alias->honeysql-clause search-col)]] + (cond + (= search-col :model) + [(hx/literal (name (model->alias model))) :model] -(def ^:private SearchContext - "Map with the various allowed search parameters, used to construct the SQL query" - {:search-string (s/maybe su/NonBlankString) - :archived? s/Bool - :visible-collections coll/VisibleCollections}) + ;; This is an aliased column, no need to include the table alias + (sequential? maybe-aliased-col) + maybe-aliased-col + + ;; This is a column reference, need to add the table alias to the column + maybe-aliased-col + (hsql/qualify (model->alias model) (name maybe-aliased-col)) -(defn- make-canonical-columns - "Returns a seq of canonicalized list of columns for the search query with the given `entity-type`. Will return - column names prefixed with the `entity-type` name so that it can be used in criteria. Projects a nil for columns the - `entity-type` doesn't have and doesn't modify aliases." - [entity-type col-name->columns] - (concat (for [search-col search-columns-without-type - :let [maybe-aliased-col (get col-name->columns search-col)]] - (cond - ;; This is an aliased column, no need to include the table alias - (sequential? maybe-aliased-col) - maybe-aliased-col - - ;; This is a column reference, need to add the table alias to the column - maybe-aliased-col - (keyword (str entity-type "." (name maybe-aliased-col))) - - ;; This entity is missing the column, project a null for that column value - :else - [nil search-col])) - [[(hx/literal entity-type) :model]])) - -(defn- merge-search-select + ;; This entity is missing the column, project a null for that column value. For Postgres and H2, cast it to the + ;; correct type, e.g. + ;; + ;; SELECT cast(NULL AS integer) + ;; + ;; For MySQL, this is not needed. + :else + [(if (= (mdb/db-type) :mysql) + nil + (hx/cast col-type nil)) + search-col]))) + +(s/defn ^:private select-clause-for-model :- [HoneySQLColumn] "The search query uses a `union-all` which requires that there be the same number of columns in each of the segments - of the query. This function will take `entity-columns` and will inject constant `nil` values for any column missing - from `entity-columns` but found in `search-columns`" - [query-map entity-type entity-columns] - (let [col-name->column (u/key-by ->column entity-columns) - cols-or-nils (make-canonical-columns entity-type col-name->column)] - (apply h/merge-select query-map (concat cols-or-nils)))) - -;; TODO - not used anywhere except `merge-name-and-archived-search` anymore so we can roll it into that -(s/defn ^:private merge-name-search - "Add case-insensitive name query criteria to `query-map`" - [query-map {:keys [search-string]} :- SearchContext] - (if (empty? search-string) - query-map - (h/merge-where query-map [:like :%lower.name (str "%" (str/lower-case search-string) "%")]))) - -(s/defn ^:private merge-name-and-archived-search - "Add name and archived query criteria to `query-map`" - [query-map {:keys [search-string archived?] :as search-ctx} :- SearchContext] - (-> query-map - (merge-name-search search-ctx) - (h/merge-where [:= :archived archived?]))) - -(s/defn ^:private add-collection-criteria + of the query. This function will take the columns for `model` and will inject constant `nil` values for any column + missing from `entity-columns` but found in `all-search-columns`." + [model :- SearchableModel] + (let [entity-columns (columns-for-model model) + column-alias->honeysql-clause (u/key-by ->column-alias entity-columns) + cols-or-nils (canonical-columns model column-alias->honeysql-clause)] + cols-or-nils)) + +(s/defn ^:private from-clause-for-model :- [(s/one [(s/one SearchableModel "model") (s/one s/Keyword "alias")] + "from clause")] + [model :- SearchableModel] + [[model (model->alias model)]]) + +(s/defn ^:private base-where-clause-for-model :- [(s/one (s/enum :and :=) "type") s/Any] + [model :- SearchableModel, {:keys [search-string archived?]} :- SearchContext] + (let [archived-clause [:= (hsql/qualify (model->alias model) :archived) archived?] + search-string-clause (when (seq search-string) + [:like + (hsql/call :lower (hsql/qualify (model->alias model) :name)) + (str "%" (str/lower-case search-string) "%")])] + (if search-string-clause + [:and archived-clause search-string-clause] + archived-clause))) + +(s/defn ^:private base-query-for-model :- {:select s/Any, :from s/Any, :where s/Any} + "Create a HoneySQL query map with `:select`, `:from`, and `:where` clauses for `model`, suitable for the `UNION ALL` + used in search." + [model :- SearchableModel, context :- SearchContext] + {:select (select-clause-for-model model) + :from (from-clause-for-model model) + :where (base-where-clause-for-model model context)}) + +(s/defn ^:private add-where-clause-for-collection-id "Update the query to only include collections the user has access to" - [query-map, column-kwd :- s/Keyword, {:keys [visible-collections]} :- SearchContext] + [honeysql-query :- su/Map, collection-id-column :- s/Keyword, {:keys [visible-collections]} :- SearchContext] (h/merge-where - query-map - (coll/visible-collection-ids->honeysql-filter-clause - column-kwd - visible-collections))) + honeysql-query + (coll/visible-collection-ids->honeysql-filter-clause collection-id-column visible-collections))) -(defn- make-honeysql-search-query - "Create a HoneySQL query map to search for `entity`, suitable for the UNION ALL used in search." - [entity search-type projected-columns] - (-> {} - (merge-search-select search-type projected-columns) - (h/merge-from [entity (keyword search-type)]))) -(defmulti ^:private create-search-query (fn [entity search-context] entity)) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Search Queries for each Toucan Model | +;;; +----------------------------------------------------------------------------------------------------------------+ -(s/defmethod ^:private create-search-query :card +(defmulti ^:private search-query-for-model + {:arglists '([model search-context])} + (fn [model _] (class model))) + +(s/defmethod ^:private search-query-for-model (class Card) [_ search-ctx :- SearchContext] - (-> (make-honeysql-search-query Card "card" card-columns-without-type) - (h/left-join [(-> (h/select :id :card_id) - (h/merge-from CardFavorite) - (h/merge-where [:= :owner_id *current-user-id*])) - :card_fav] - [:= :card.id :card_fav.card_id]) - (merge-name-and-archived-search search-ctx) - (add-collection-criteria :collection_id search-ctx))) - -(s/defmethod ^:private create-search-query :collection + (-> (base-query-for-model Card search-ctx) + (h/left-join [CardFavorite :fave] + [:and + [:= :card.id :fave.card_id] + [:= :fave.owner_id api/*current-user-id*]]) + (add-where-clause-for-collection-id :card.collection_id search-ctx))) + +(s/defmethod ^:private search-query-for-model (class Collection) [_ search-ctx :- SearchContext] - (-> (make-honeysql-search-query Collection "collection" collection-columns-without-type) - (merge-name-and-archived-search search-ctx) - (add-collection-criteria :id search-ctx))) + (-> (base-query-for-model Collection search-ctx) + (add-where-clause-for-collection-id :collection.id search-ctx))) -(s/defmethod ^:private create-search-query :dashboard +(s/defmethod ^:private search-query-for-model (class Dashboard) [_ search-ctx :- SearchContext] - (-> (make-honeysql-search-query Dashboard "dashboard" dashboard-columns-without-type) - (h/left-join [(-> (h/select :id :dashboard_id) - (h/merge-from DashboardFavorite) - (h/merge-where [:= :user_id *current-user-id*])) - :dashboard_fav] - [:= :dashboard.id :dashboard_fav.dashboard_id]) - (merge-name-and-archived-search search-ctx) - (add-collection-criteria :collection_id search-ctx))) - -(s/defmethod ^:private create-search-query :pulse + (-> (base-query-for-model Dashboard search-ctx) + (h/left-join [DashboardFavorite :fave] + [:and + [:= :dashboard.id :fave.dashboard_id] + [:= :fave.user_id api/*current-user-id*]]) + (add-where-clause-for-collection-id :dashboard.collection_id search-ctx))) + +(s/defmethod ^:private search-query-for-model (class Pulse) [_ search-ctx :- SearchContext] ;; Pulses don't currently support being archived, omit if archived is true - (-> (make-honeysql-search-query Pulse "pulse" pulse-columns-without-type) - (merge-name-and-archived-search search-ctx) - (add-collection-criteria :collection_id search-ctx) + (-> (base-query-for-model Pulse search-ctx) + (add-where-clause-for-collection-id :pulse.collection_id search-ctx) ;; We don't want alerts included in pulse results (h/merge-where [:= :alert_condition nil]))) -(s/defmethod ^:private create-search-query :metric +(s/defmethod ^:private search-query-for-model (class Metric) [_ search-ctx :- SearchContext] - (-> (make-honeysql-search-query Metric "metric" metric-columns-without-type) - (merge-name-and-archived-search search-ctx))) + (-> (base-query-for-model Metric search-ctx) + (h/left-join [Table :table] [:= :metric.table_id :table.id]))) -(s/defmethod ^:private create-search-query :segment +(s/defmethod ^:private search-query-for-model (class Segment) [_ search-ctx :- SearchContext] - (-> (make-honeysql-search-query Segment "segment" segment-columns-without-type) - (merge-name-and-archived-search search-ctx))) - -(defn- favorited->boolean [row] - (if-let [fav-value (get row :favorite)] - (assoc row :favorite (and (integer? fav-value) - (not (zero? fav-value)))) - row)) + (-> (base-query-for-model Segment search-ctx) + (h/left-join [Table :table] [:= :segment.table_id :table.id]))) (s/defn ^:private search "Builds a search query that includes all of the searchable entities and runs it" [search-ctx :- SearchContext] - (map favorited->boolean - (db/query {:union-all (for [entity [:card :collection :dashboard :pulse :segment :metric] - :let [query-map (create-search-query entity search-ctx)] - :when query-map] - query-map)}))) + (for [row (db/query {:union-all (for [model searchable-models] + (search-query-for-model model search-ctx))})] + ;; MySQL returns `:favorite` as `1` or `0` so convert those to boolean as needed + (update row :favorite (fn [favorite] + (if (integer? favorite) + (not (zero? favorite)) + favorite))))) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Endpoint | +;;; +----------------------------------------------------------------------------------------------------------------+ (s/defn ^:private make-search-context :- SearchContext - [search-string :- (s/maybe su/NonBlankString) - archived-string :- (s/maybe su/BooleanString)] + [search-string :- (s/maybe su/NonBlankString), archived-string :- (s/maybe su/BooleanString)] {:search-string search-string :archived? (Boolean/parseBoolean archived-string) - :visible-collections (coll/permissions-set->visible-collection-ids @*current-user-permissions-set*)}) + :visible-collections (coll/permissions-set->visible-collection-ids @api/*current-user-permissions-set*)}) -(defendpoint GET "/" +(api/defendpoint GET "/" "Search Cards, Dashboards, Collections and Pulses for the substring `q`." [q archived] {q (s/maybe su/NonBlankString) archived (s/maybe su/BooleanString)} (let [{:keys [visible-collections] :as search-ctx} (make-search-context q archived)] ;; Throw if the user doesn't have access to any collections - (check-403 (or (= :all visible-collections) - (seq visible-collections))) + (api/check-403 (or (= :all visible-collections) + (seq visible-collections))) (search search-ctx))) -(define-routes) +(api/define-routes) diff --git a/test/metabase/api/search_test.clj b/test/metabase/api/search_test.clj index 722570ba45e..29d7b1bf8ef 100644 --- a/test/metabase/api/search_test.clj +++ b/test/metabase/api/search_test.clj @@ -2,7 +2,7 @@ (:require [clojure [set :as set] [string :as str]] - [expectations :refer :all] + [expectations :refer [expect]] [metabase.models [card :refer [Card]] [card-favorite :refer [CardFavorite]] @@ -14,30 +14,65 @@ [permissions-group :as group :refer [PermissionsGroup]] [permissions-group-membership :refer [PermissionsGroupMembership]] [pulse :refer [Pulse]] - [segment :refer [Segment]]] - [metabase.test.data.users :refer :all] - [metabase.test.util :as tu] + [segment :refer [Segment]] + [table :refer [Table]]] + [metabase.test + [data :as data] + [util :as tu]] + [metabase.test.data.users :as test-users] [metabase.util :as u] + [toucan.db :as db] [toucan.util.test :as tt])) (def default-search-row - {:description nil, :id true, :collection_id false, - :collection_position nil, :archived false, :favorite nil}) - -(def ^:private default-search-results - (set (map #(merge default-search-row %) - [{:name "dashboard test dashboard", :model "dashboard"} - {:name "collection test collection", :model "collection", :collection_id true} - {:name "card test card", :model "card"} - {:name "pulse test pulse", :model "pulse", :archived nil} - {:name "metric test metric", :description "Lookin' for a blueberry", :model "metric"} - {:name "segment test segment", :description "Lookin' for a blueberry", :model "segment"}]))) - -(def ^:private default-metric-segment-results - (set (filter (comp #{"metric" "segment"} :model) default-search-results))) - -(def ^:private default-archived-results - (set (for [result default-search-results + {:id true + :description nil + :archived false + :collection_id false + :collection_position nil + :favorite nil + :table_id false + :database_id false + :table_schema nil + :table_name nil + :table_description nil}) + +(defn- table-search-results + "Segments and Metrics come back with information about their Tables as of 0.33.0. The `model-defaults` for Segment and + Metric put them both in the `:checkins` Table." + [] + (merge + {:table_id true, :database_id true} + (db/select-one [Table [:name :table_name] [:schema :table_schema] [:description :table_description]] + :id (data/id :checkins)))) + +(defn- default-search-results [] + #{(merge + default-search-row + {:name "dashboard test dashboard", :model "dashboard", :favorite false}) + (merge + default-search-row + {:name "collection test collection", :model "collection", :collection_id true}) + (merge + default-search-row + {:name "card test card", :model "card", :favorite false}) + (merge + default-search-row + {:name "pulse test pulse", :model "pulse", :archived nil}) + (merge + default-search-row + {:model "metric", :name "metric test metric", :description "Lookin' for a blueberry"} + (table-search-results)) + (merge + default-search-row + {:model "segment", :name "segment test segment", :description "Lookin' for a blueberry"} + (table-search-results))}) + +(defn- default-metric-segment-results [] + (set (filter (comp #{"metric" "segment"} :model) (default-search-results)))) + +(defn- default-archived-results [] + (set (for [result (default-search-results) :when (false? (:archived result))] (assoc result :archived true)))) @@ -47,10 +82,10 @@ (f search-item) search-item)))) -(def ^:private default-results-with-collection +(defn- default-results-with-collection [] (on-search-types #{"dashboard" "pulse" "card"} #(assoc % :collection_id true) - default-search-results)) + (default-search-results))) (defn- do-with-search-items [search-string in-root-collection? f] (let [data-map (fn [instance-name] @@ -73,17 +108,17 @@ :segment segment})))) (defmacro ^:private with-search-items-in-root-collection [search-string & body] - `(do-with-search-items ~search-string true (fn [_#] ~@body))) + `(do-with-search-items ~search-string true (fn [~'_] ~@body))) (defmacro ^:private with-search-items-in-collection [created-items-sym search-string & body] `(do-with-search-items ~search-string false (fn [~created-items-sym] ~@body))) (defn- search-request [user-kwd & params] - (tu/boolean-ids-and-timestamps (set (apply (user->client user-kwd) :get 200 "search" params)))) + (tu/boolean-ids-and-timestamps (set (apply (test-users/user->client user-kwd) :get 200 "search" params)))) ;; Basic search, should find 1 of each entity type, all items in the root collection (expect - default-search-results + (default-search-results) (with-search-items-in-root-collection "test" (search-request :crowberto :q "test"))) @@ -91,110 +126,110 @@ ;; previous tests. Instead of an = comparison here, just ensure our default results are included (expect (set/subset? - default-search-results + (default-search-results) (with-search-items-in-root-collection "test" (search-request :crowberto)))) ;; Ensure that users without perms for the root collection don't get results ;; NOTE: Metrics and segments don't have collections, so they'll be returned (expect - default-metric-segment-results + (default-metric-segment-results) (tu/with-non-admin-groups-no-root-collection-perms (with-search-items-in-root-collection "test" (search-request :rasta :q "test")))) ;; Users that have root collection permissions should get root collection search results (expect - (set (remove (comp #{"collection"} :model) default-search-results)) + (set (remove (comp #{"collection"} :model) (default-search-results))) (tu/with-non-admin-groups-no-root-collection-perms (with-search-items-in-root-collection "test" (tt/with-temp* [PermissionsGroup [group] - PermissionsGroupMembership [_ {:user_id (user->id :rasta), :group_id (u/get-id group)}]] + PermissionsGroupMembership [_ {:user_id (test-users/user->id :rasta), :group_id (u/get-id group)}]] (perms/grant-permissions! group (perms/collection-read-path {:metabase.models.collection/is-root? true})) (search-request :rasta :q "test"))))) ;; Users without root collection permissions should still see other collections they have access to (expect - (into default-results-with-collection - (map #(merge default-search-row %) + (into (default-results-with-collection) + (map #(merge default-search-row % (table-search-results)) [{:name "metric test2 metric", :description "Lookin' for a blueberry", :model "metric"} {:name "segment test2 segment", :description "Lookin' for a blueberry", :model "segment"}])) (tu/with-non-admin-groups-no-root-collection-perms (with-search-items-in-collection {:keys [collection]} "test" (with-search-items-in-root-collection "test2" (tt/with-temp* [PermissionsGroup [group] - PermissionsGroupMembership [_ {:user_id (user->id :rasta), :group_id (u/get-id group)}]] + PermissionsGroupMembership [_ {:user_id (test-users/user->id :rasta), :group_id (u/get-id group)}]] (perms/grant-collection-read-permissions! group (u/get-id collection)) (search-request :rasta :q "test")))))) ;; Users with root collection permissions should be able to search root collection data long with collections they ;; have access to (expect - (into default-results-with-collection - (for [row default-search-results + (into (default-results-with-collection) + (for [row (default-search-results) :when (not= "collection" (:model row))] (update row :name #(str/replace % "test" "test2")))) (tu/with-non-admin-groups-no-root-collection-perms (with-search-items-in-collection {:keys [collection]} "test" (with-search-items-in-root-collection "test2" (tt/with-temp* [PermissionsGroup [group] - PermissionsGroupMembership [_ {:user_id (user->id :rasta), :group_id (u/get-id group)}]] + PermissionsGroupMembership [_ {:user_id (test-users/user->id :rasta), :group_id (u/get-id group)}]] (perms/grant-permissions! group (perms/collection-read-path {:metabase.models.collection/is-root? true})) (perms/grant-collection-read-permissions! group collection) (search-request :rasta :q "test")))))) ;; Users with access to multiple collections should see results from all collections they have access to (expect - (into default-results-with-collection + (into (default-results-with-collection) (map (fn [row] (update row :name #(str/replace % "test" "test2"))) - default-results-with-collection)) + (default-results-with-collection))) (with-search-items-in-collection {coll-1 :collection} "test" (with-search-items-in-collection {coll-2 :collection} "test2" (tt/with-temp* [PermissionsGroup [group] - PermissionsGroupMembership [_ {:user_id (user->id :rasta), :group_id (u/get-id group)}]] + PermissionsGroupMembership [_ {:user_id (test-users/user->id :rasta), :group_id (u/get-id group)}]] (perms/grant-collection-read-permissions! group (u/get-id coll-1)) (perms/grant-collection-read-permissions! group (u/get-id coll-2)) (search-request :rasta :q "test"))))) ;; User should only see results in the collection they have access to (expect - (into default-results-with-collection - (map #(merge default-search-row %) + (into (default-results-with-collection) + (map #(merge default-search-row % (table-search-results)) [{:name "metric test2 metric", :description "Lookin' for a blueberry", :model "metric"} {:name "segment test2 segment", :description "Lookin' for a blueberry", :model "segment"}])) (tu/with-non-admin-groups-no-root-collection-perms (with-search-items-in-collection {coll-1 :collection} "test" (with-search-items-in-collection {coll-2 :collection} "test2" (tt/with-temp* [PermissionsGroup [group] - PermissionsGroupMembership [_ {:user_id (user->id :rasta), :group_id (u/get-id group)}]] + PermissionsGroupMembership [_ {:user_id (test-users/user->id :rasta), :group_id (u/get-id group)}]] (perms/grant-collection-read-permissions! group (u/get-id coll-1)) (search-request :rasta :q "test")))))) ;; Favorites are per user, so other user's favorites don't cause search results to be favorited (expect - default-results-with-collection + (default-results-with-collection) (with-search-items-in-collection {:keys [card dashboard]} "test" (tt/with-temp* [CardFavorite [_ {:card_id (u/get-id card) - :owner_id (user->id :rasta)}] + :owner_id (test-users/user->id :rasta)}] DashboardFavorite [_ {:dashboard_id (u/get-id dashboard) - :user_id (user->id :rasta)}]] + :user_id (test-users/user->id :rasta)}]] (search-request :crowberto :q "test")))) ;; Basic search, should find 1 of each entity type and include favorites when available (expect (on-search-types #{"dashboard" "card"} #(assoc % :favorite true) - default-results-with-collection) + (default-results-with-collection)) (with-search-items-in-collection {:keys [card dashboard]} "test" (tt/with-temp* [CardFavorite [_ {:card_id (u/get-id card) - :owner_id (user->id :crowberto)}] + :owner_id (test-users/user->id :crowberto)}] DashboardFavorite [_ {:dashboard_id (u/get-id dashboard) - :user_id (user->id :crowberto)}]] + :user_id (test-users/user->id :crowberto)}]] (search-request :crowberto :q "test")))) ;; Basic search should only return substring matches (expect - default-search-results + (default-search-results) (with-search-items-in-root-collection "test" (with-search-items-in-root-collection "something different" (search-request :crowberto :q "test")))) @@ -204,7 +239,7 @@ ;; Should return unarchived results by default (expect - default-search-results + (default-search-results) (with-search-items-in-root-collection "test" (tt/with-temp* [Card [_ (archived {:name "card test card 2"})] Dashboard [_ (archived {:name "dashboard test dashboard 2"})] @@ -215,7 +250,7 @@ ;; Should return archived results when specified (expect - default-archived-results + (default-archived-results) (with-search-items-in-root-collection "test2" (tt/with-temp* [Card [_ (archived {:name "card test card"})] Dashboard [_ (archived {:name "dashboard test dashboard"})] @@ -235,4 +270,4 @@ (filter (fn [{:keys [model id]}] (and (= id (u/get-id pulse)) (= "pulse" model))) - ((user->client :crowberto) :get 200 "search"))))) + ((test-users/user->client :crowberto) :get 200 "search"))))) -- GitLab