From 027f565989a9208ea8d0266bee5d65f2635b97e2 Mon Sep 17 00:00:00 2001 From: Case Nelson <snoe@snoe.ca> Date: Wed, 13 Apr 2022 15:02:11 -0600 Subject: [PATCH] Add bookmark ordering (#21652) * Add bookmark ordering Bookmark ordering is applied to bookmark fetches order-by clauses. To save ordering send a sequence of `[{type, item_id}]` values. There are no foreign keys to the `*_bookmark` tables and bookmark ordering handle extra or missing rows gracefully by ignoring them or falling back to order by created_at as a secondary sort. Pruning is done on save. * Fix missing docstring * Fix refer order * Use varchar for type because h2 is unhappy with text index * Cast untyped literal in union for h2 * Use hx/literal thanks @dpsutton --- resources/migrations/000_migrations.yaml | 73 ++++++++++++++++++++++++ src/metabase/api/bookmark.clj | 12 ++++ src/metabase/cmd/copy.clj | 7 ++- src/metabase/models.clj | 1 + src/metabase/models/bookmark.clj | 63 +++++++++++--------- test/metabase/api/bookmark_test.clj | 63 +++++++++++++++++--- 6 files changed, 180 insertions(+), 39 deletions(-) diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 65fd4dfa7b0..5d306090963 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -11372,6 +11372,79 @@ databaseChangeLog: nullable: false tableName: timeline + - changeSet: + id: v43.00-052 + author: snoe + comment: Added 0.43.0 - bookmark ordering + changes: + - createTable: + tableName: bookmark_ordering + remarks: Table holding ordering information for various bookmark tables + columns: + - column: + name: id + type: int + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: user_id + type: int + remarks: 'ID of the User who ordered bookmarks' + constraints: + nullable: false + references: core_user(id) + foreignKeyName: fk_bookmark_ordering_user_id + deleteCascade: true + - column: + name: type + type: varchar(255) + remarks: 'type of the Bookmark' + constraints: + nullable: false + - column: + name: item_id + type: int + remarks: 'id of the item being bookmarked (Card, Collection, Dashboard, ...) no FK, so may no longer exist' + constraints: + nullable: false + - column: + name: ordering + type: int + remarks: 'order of bookmark for user' + constraints: + nullable: false + - changeSet: + id: v43.00-053 + author: snoe + comment: Added 0.43.0 - bookmark ordering + changes: + - addUniqueConstraint: + tableName: bookmark_ordering + columnNames: user_id, type, item_id + constraintName: unique_bookmark_user_id_type_item_id + - changeSet: + id: v43.00-054 + author: snoe + comment: Added 0.43.0 - bookmark ordering + changes: + - addUniqueConstraint: + tableName: bookmark_ordering + columnNames: user_id, ordering + constraintName: unique_bookmark_user_id_ordering + - changeSet: + id: v43.00-055 + author: snoe + comment: Added 0.43.0 - bookmark ordering + changes: + - createIndex: + tableName: bookmark_ordering + columns: + - column: + name: user_id + indexName: idx_bookmark_ordering_user_id + # >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<< ######################################################################################################################## diff --git a/src/metabase/api/bookmark.clj b/src/metabase/api/bookmark.clj index 37496a6da6a..1edf7f9eebd 100644 --- a/src/metabase/api/bookmark.clj +++ b/src/metabase/api/bookmark.clj @@ -19,6 +19,11 @@ "Schema enumerating bookmarkable models." (s/enum "card" "dashboard" "collection")) +(def BookmarkOrderings + "Schema for an ordered of boomark orderings" + [{:type Models + :item_id su/IntGreaterThanZero}]) + (def ^:private lookup "Lookup map from model as a string to [model bookmark-model item-id-key]." {"card" [Card CardBookmark :card_id] @@ -56,4 +61,11 @@ item-key id) api/generic-204-no-content)) +(api/defendpoint PUT "/ordering" + "Sets the order of bookmarks for user." + [:as {{:keys [orderings]} :body}] + {orderings BookmarkOrderings} + (bookmarks/save-ordering api/*current-user-id* orderings) + api/generic-204-no-content) + (api/define-routes) diff --git a/src/metabase/cmd/copy.clj b/src/metabase/cmd/copy.clj index fbe0e01ac40..c22762ef4a9 100644 --- a/src/metabase/cmd/copy.clj +++ b/src/metabase/cmd/copy.clj @@ -9,8 +9,10 @@ [metabase.db.connection :as mdb.conn] [metabase.db.data-migrations :refer [DataMigrations]] [metabase.db.setup :as mdb.setup] - [metabase.models :refer [Activity Card CardBookmark Collection CollectionBookmark CollectionPermissionGraphRevision Dashboard DashboardBookmark - DashboardCard DashboardCardSeries Database Dependency Dimension Field FieldValues + [metabase.models :refer [Activity BookmarkOrdering Card CardBookmark + Collection CollectionBookmark CollectionPermissionGraphRevision + Dashboard DashboardBookmark DashboardCard DashboardCardSeries + Database Dependency Dimension Field FieldValues GeneralPermissionsRevision LoginHistory Metric MetricImportantField ModerationReview NativeQuerySnippet Permissions PermissionsGroup PermissionsGroupMembership PermissionsRevision Pulse PulseCard PulseChannel PulseChannelRecipient Revision Secret Segment Session Setting Table @@ -64,6 +66,7 @@ CardBookmark DashboardBookmark CollectionBookmark + BookmarkOrdering DashboardCard DashboardCardSeries Activity diff --git a/src/metabase/models.clj b/src/metabase/models.clj index c875c984227..d2846fc6bb8 100644 --- a/src/metabase/models.clj +++ b/src/metabase/models.clj @@ -88,6 +88,7 @@ [bookmark CardBookmark] [bookmark DashboardBookmark] [bookmark CollectionBookmark] + [bookmark BookmarkOrdering] [card Card] [collection Collection] [c-perm-revision CollectionPermissionGraphRevision] diff --git a/src/metabase/models/bookmark.clj b/src/metabase/models/bookmark.clj index a398f0620f4..27de8f024c5 100644 --- a/src/metabase/models/bookmark.clj +++ b/src/metabase/models/bookmark.clj @@ -13,6 +13,7 @@ (models/defmodel CardBookmark :card_bookmark) (models/defmodel DashboardBookmark :dashboard_bookmark) (models/defmodel CollectionBookmark :collection_bookmark) +(models/defmodel BookmarkOrdering :bookmark_ordering) (defn- unqualify-key [k] @@ -23,7 +24,7 @@ id. This is required for the frontend entity loading system and does not refer to any particular bookmark id, although the compound key can be inferred from it." {:id s/Str - :type (s/enum :card :collection :dashboard) + :type (s/enum "card" "collection" "dashboard") :item_id su/IntGreaterThanZero :name su/NonBlankString (s/optional-key :dataset) (s/maybe s/Bool) @@ -37,74 +38,80 @@ description." [result] (let [result (into {} (remove (comp nil? second) result)) - lookup {"report_card" "card" "report_dashboard" "dashboard" "collection" "collection"} - ttype (-> (keys result) - first - name - (str/split #"\.") - first - lookup - keyword) normalized-result (zipmap (map unqualify-key (keys result)) (vals result)) - item-id-str (str (:item_id normalized-result))] - (merge - {:id (str (name ttype) "-" item-id-str) - :type ttype} - (select-keys normalized-result [:item_id :name :dataset :description :display :authority_level])))) + id-str (str (:type normalized-result) "-" (:item_id normalized-result))] + (-> normalized-result + (select-keys [:item_id :type :name :dataset :description :display :authority_level]) + (assoc :id id-str)))) (defn- bookmarks-union-query - [id] + [user-id] (let [as-null (when (= (mdb/db-type) :postgres) (hx/->integer nil))] {:union-all [{:select [:card_id [as-null :dashboard_id] [as-null :collection_id] - :id + [:card_id :item_id] + [(hx/literal "card") :type] :created_at] :from [CardBookmark] - :where [:= :user_id id]} + :where [:= :user_id user-id]} {:select [[as-null :card_id] :dashboard_id [as-null :collection_id] - :id + [:dashboard_id :item_id] + [(hx/literal "dashboard") :type] :created_at] :from [DashboardBookmark] - :where [:= :user_id id]} + :where [:= :user_id user-id]} {:select [[as-null :card_id] [as-null :dashboard_id] :collection_id - :id + [:collection_id :item_id] + [(hx/literal "collection") :type] :created_at] :from [CollectionBookmark] - :where [:= :user_id id]}]})) + :where [:= :user_id user-id]}]})) (s/defn bookmarks-for-user :- [BookmarkResult] "Get all bookmarks for a user. Each bookmark will have a string id made of the model and model-id, a type, and item_id, name, and description from the underlying bookmarked item." - [id] + [user-id] (->> (db/query {:select [[:bookmark.created_at :created_at] - [:card.id (db/qualify 'Card :item_id)] + [:bookmark.type :type] + [:bookmark.item_id :item_id] [:card.name (db/qualify 'Card :name)] [:card.dataset (db/qualify 'Card :dataset)] [:card.display (db/qualify 'Card :display)] [:card.description (db/qualify 'Card :description)] [:card.archived (db/qualify 'Card :archived)] - [:dashboard.id (db/qualify 'Dashboard :item_id)] [:dashboard.name (db/qualify 'Dashboard :name)] [:dashboard.description (db/qualify 'Dashboard :description)] [:dashboard.archived (db/qualify 'Dashboard :archived)] - [:collection.id (db/qualify 'Collection :item_id)] [:collection.name (db/qualify 'Collection :name)] [:collection.authority_level (db/qualify 'Collection :authority_level)] [:collection.description (db/qualify 'Collection :description)] [:collection.archived (db/qualify 'Collection :archived)]] - :from [[(bookmarks-union-query id) :bookmark]] + :from [[(bookmarks-union-query user-id) :bookmark]] :left-join [[Card :card] [:= :bookmark.card_id :card.id] [Dashboard :dashboard] [:= :bookmark.dashboard_id :dashboard.id] - [Collection :collection] [:= :bookmark.collection_id :collection.id]] + [Collection :collection] [:= :bookmark.collection_id :collection.id] + [BookmarkOrdering :bookmark_ordering] [:and + [:= :bookmark_ordering.user_id user-id] + [:= :bookmark_ordering.type :bookmark.type] + [:= :bookmark_ordering.item_id :bookmark.item_id]]] :where (into [:and] (for [table [:card :dashboard :collection] :let [field (keyword (str (name table) "." "archived"))]] [:or [:= field false] [:= field nil]])) - :order-by [[:created_at :desc]]}) + :order-by [[:bookmark_ordering.ordering :asc-nulls-last] [:created_at :desc]]}) (map normalize-bookmark-result))) + +(defn save-ordering + "Saves a bookmark ordering of shape `[{:type, :item_id}]` + Deletes all existing orderings for user so should be given a total ordering." + [user-id orderings] + (db/delete! BookmarkOrdering :user_id user-id) + (db/insert-many! BookmarkOrdering (->> orderings + (map #(select-keys % [:type :item_id])) + (map-indexed #(assoc %2 :user_id user-id :ordering %1))))) diff --git a/test/metabase/api/bookmark_test.clj b/test/metabase/api/bookmark_test.clj index 36e1d156010..c515c79fac4 100644 --- a/test/metabase/api/bookmark_test.clj +++ b/test/metabase/api/bookmark_test.clj @@ -1,12 +1,15 @@ (ns metabase.api.bookmark-test "Tests for /api/bookmark endpoints." (:require [clojure.test :refer :all] - [metabase.models.bookmark :refer [CardBookmark CollectionBookmark DashboardBookmark]] + [metabase.models.bookmark :refer [BookmarkOrdering CardBookmark + CollectionBookmark + DashboardBookmark]] [metabase.models.card :refer [Card]] [metabase.models.collection :refer [Collection]] [metabase.models.dashboard :refer [Dashboard]] [metabase.test :as mt] - [metabase.util :as u])) + [metabase.util :as u] + [toucan.db :as db])) (deftest bookmarks-test (testing "POST /api/bookmark/:model/:model-id" @@ -50,19 +53,61 @@ (map :type) set))))))) +(defn bookmark-models [user-id & models] + (doseq [model models] + (cond + (instance? (class Collection) model) + (db/insert! CollectionBookmark + {:user_id user-id + :collection_id (u/the-id model)}) + + (instance? (class Card) model) + (db/insert! CardBookmark + {:user_id user-id + :card_id (u/the-id model)}) + + (instance? (class Dashboard) model) + (db/insert! DashboardBookmark + {:user_id user-id + :dashboard_id (u/the-id model)}) + + :else + (throw (ex-info "Unknown type" {:user-id user-id :model model}))))) + (deftest bookmarks-on-archived-items-test (testing "POST /api/bookmark/:model/:model-id" (mt/with-temp* [Collection [archived-collection {:name "Test Collection" :archived true}] Card [archived-card {:name "Test Card" :archived true}] - Dashboard [archived-dashboard {:name "Test Dashboard" :archived true}] - CardBookmark [card-bookmark {:user_id (mt/user->id :rasta) - :card_id (u/the-id archived-card)}] - CollectionBookmark [collection-bookmark {:user_id (mt/user->id :rasta) - :collection_id (u/the-id archived-collection)}] - DashboardBookmark [dashboard-bookmark {:user_id (mt/user->id :rasta) - :dashboard_id (u/the-id archived-dashboard)}]] + Dashboard [archived-dashboard {:name "Test Dashboard" :archived true}]] + (bookmark-models (mt/user->id :rasta) archived-collection archived-card archived-dashboard) (testing "check that we don't receive bookmarks of archived items" (is (= #{} (->> (mt/user-http-request :rasta :get 200 "bookmark") (map :type) set))))))) + +(deftest bookmarks-ordering-test + (testing "PUT /api/bookmark/ordering" + (mt/with-temp* [Collection [collection {:name "Test Collection"}] + Card [card {:name "Test Card"}] + Dashboard [dashboard {:name "Test Dashboard"}]] + (mt/with-model-cleanup [BookmarkOrdering] + (bookmark-models (mt/user->id :rasta) collection card dashboard) + (testing "Check that ordering works" + (is (= nil + (mt/user-http-request :rasta :put 204 "bookmark/ordering" + {:orderings [{:type "dashboard" :item_id (u/the-id dashboard)} + {:type "card" :item_id (u/the-id card)} + {:type "collection" :item_id (u/the-id collection)}]}))) + (is (= ["dashboard" "card" "collection"] + (map :type + (mt/user-http-request :rasta :get 200 "bookmark"))))) + (testing "Check that re-ordering works" + (is (= nil + (mt/user-http-request :rasta :put 204 "bookmark/ordering" + {:orderings [{:type "card" :item_id (u/the-id card)} + {:type "collection" :item_id (u/the-id collection)} + {:type "dashboard" :item_id (u/the-id dashboard)}]}))) + (is (= ["card" "collection" "dashboard"] + (map :type + (mt/user-http-request :rasta :get 200 "bookmark"))))))))) -- GitLab