Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
sync_database_test.clj 15.87 KiB
(ns metabase.sync-database-test
  (:require [clojure.java.jdbc :as jdbc]
            [clojure.string :as str]
            [expectations :refer :all]
            [metabase
             [db :as mdb]
             [driver :as driver]
             [sync :refer :all]
             [util :as u]]
            [metabase.driver.generic-sql :as sql]
            [metabase.models
             [database :refer [Database]]
             [field :refer [Field]]
             [field-values :as field-values :refer [FieldValues]]
             [table :refer [Table]]]
            [metabase.test
             [data :refer :all]
             [util :as tu]]
            [toucan.db :as db]
            [toucan.util.test :as tt]))

(def ^:private ^:const sync-test-tables
  {"movie"  {:name "movie"
             :schema "default"
             :fields #{{:name      "id"
                        :base-type :type/Integer}
                       {:name      "title"
                        :base-type :type/Text}
                       {:name      "studio"
                        :base-type :type/Text}}}
   "studio" {:name "studio"
             :schema nil
             :fields #{{:name         "studio"
                        :base-type    :type/Text
                        :special-type :type/PK}
                       {:name      "name"
                        :base-type :type/Text}}}})


;; TODO - I'm 90% sure we could just reüse the "MovieDB" instead of having this subset of it used here
(defrecord SyncTestDriver []
  clojure.lang.Named
  (getName [_] "SyncTestDriver"))


(defn- describe-database [& _]
  {:tables (set (for [table (vals sync-test-tables)]
                  (dissoc table :fields)))})

(defn- describe-table [_ _ table]
  (get sync-test-tables (:name table)))

(defn- describe-table-fks [_ _ table]
  (set (when (= "movie" (:name table))
         #{{:fk-column-name   "studio"
            :dest-table       {:name   "studio"
                               :schema nil}
            :dest-column-name "studio"}})))

(extend SyncTestDriver
  driver/IDriver
  (merge driver/IDriverDefaultsMixin
         {:describe-database     describe-database
          :describe-table        describe-table
          :describe-table-fks    describe-table-fks
          :features              (constantly #{:foreign-keys})
          :details-fields        (constantly [])
          ;; enough values that it won't get marked as a Category, but still get a fingerprint or w/e
          :field-values-lazy-seq (fn [& _] (range 500))}))

(driver/register-driver! :sync-test (SyncTestDriver.))


(defn- table-details [table]
  (into {} (-> (dissoc table :db :pk_field :field_values)
               (assoc :fields (for [field (db/select Field, :table_id (:id table), {:order-by [:name]})]
                                (into {} (-> (dissoc field
                                                     :table :db :children :qualified-name :qualified-name-components
                                                     :values :target)
                                             (update :fingerprint map?)
                                             (update :fingerprint_version (complement zero?))))))
               tu/boolean-ids-and-timestamps)))

(def ^:private table-defaults
  {:id                      true
   :db_id                   true
   :raw_table_id            false
   :schema                  nil
   :description             nil
   :caveats                 nil
   :points_of_interest      nil
   :show_in_getting_started false
   :entity_type             nil
   :entity_name             nil
   :visibility_type         nil
   :rows                    nil
   :active                  true
   :created_at              true
   :updated_at              true})

(def ^:private field-defaults
  {:id                  true
   :table_id            true
   :raw_column_id       false
   :description         nil
   :caveats             nil
   :points_of_interest  nil
   :active              true
   :parent_id           false
   :position            0
   :preview_display     true
   :visibility_type     :normal
   :fk_target_field_id  false
   :created_at          true
   :updated_at          true
   :last_analyzed       true
   :fingerprint         true
   :fingerprint_version true})

;; ## SYNC DATABASE
(expect
  [(merge table-defaults
          {:schema       "default"
           :name         "movie"
           :display_name "Movie"
           :fields       [(merge field-defaults
                                 {:special_type :type/PK
                                  :name         "id"
                                  :display_name "ID"
                                  :base_type    :type/Integer})
                          (merge field-defaults
                                 {:special_type       :type/FK
                                  :name               "studio"
                                  :display_name       "Studio"
                                  :base_type          :type/Text
                                  :fk_target_field_id true})
                          (merge field-defaults
                                 {:special_type nil
                                  :name         "title"
                                  :display_name "Title"
                                  :base_type    :type/Text})]})
   (merge table-defaults
          {:name         "studio"
           :display_name "Studio"
           :fields       [(merge field-defaults
                                 {:special_type :type/Name
                                  :name         "name"
                                  :display_name "Name"
                                  :base_type    :type/Text})
                          (merge field-defaults
                                 {:special_type :type/PK
                                  :name         "studio"
                                  :display_name "Studio"
                                  :base_type    :type/Text})]})]
  (tt/with-temp Database [db {:engine :sync-test}]
    (sync-database! db)
    ;; we are purposely running the sync twice to test for possible logic issues which only manifest
    ;; on resync of a database, such as adding tables that already exist or duplicating fields
    (sync-database! db)
    (mapv table-details (db/select Table, :db_id (u/get-id db), {:order-by [:name]}))))


;; ## SYNC TABLE

(expect
  (merge table-defaults
         {:schema       "default"
          :name         "movie"
          :display_name "Movie"
          :fields       [(merge field-defaults
                                {:special_type :type/PK
                                 :name         "id"
                                 :display_name "ID"
                                 :base_type    :type/Integer})
                         (merge field-defaults
                                {:special_type nil
                                 :name         "studio"
                                 :display_name "Studio"
                                 :base_type    :type/Text})
                         (merge field-defaults
                                {:special_type nil
                                 :name         "title"
                                 :display_name "Title"
                                 :base_type    :type/Text})]})
  (tt/with-temp* [Database [db    {:engine :sync-test}]
                  Table    [table {:name   "movie"
                                   :schema "default"
                                   :db_id  (u/get-id db)}]]
    (sync-table! table)
    (table-details (Table (:id table)))))


;; test that we prevent running simultaneous syncs on the same database

(defonce ^:private calls-to-describe-database (atom 0))

(defrecord ConcurrentSyncTestDriver []
  clojure.lang.Named
  (getName [_] "ConcurrentSyncTestDriver"))

(extend ConcurrentSyncTestDriver
  driver/IDriver
  (merge driver/IDriverDefaultsMixin
         {:describe-database (fn [_ _]
                               (swap! calls-to-describe-database inc)
                               (Thread/sleep 1000)
                               {:tables #{}})
          :describe-table    (constantly nil)
          :details-fields    (constantly [])}))

(driver/register-driver! :concurrent-sync-test (ConcurrentSyncTestDriver.))

;; only one sync should be going on at a time
(expect
 ;; describe-database gets called twice during a single sync process, once for syncing tables and a second time for syncing the _metabase_metadata table
 2
 (tt/with-temp* [Database [db {:engine :concurrent-sync-test}]]
   (reset! calls-to-describe-database 0)
   ;; start a sync processes in the background. It should take 1000 ms to finish
   (let [f1 (future (sync-database! db))
         f2 (do
              ;; wait 200 ms to make sure everything is going
              (Thread/sleep 200)
              ;; Start another in the background. Nothing should happen here because the first is already running
              (future (sync-database! db)))]
     ;; Start another in the foreground. Again, nothing should happen here because the original should still be running
     (sync-database! db)
     ;; make sure both of the futures have finished
     (deref f1)
     (deref f2)
     ;; Check the number of syncs that took place. Should be 2 (just the first)
     @calls-to-describe-database)))


;; Test that we will remove field-values when they aren't appropriate.
;; Calling `sync-database!` below should cause them to get removed since the Field doesn't have an appropriate special type
(expect
  [[1 2 3]
   nil]
  (tt/with-temp* [Database [db {:engine :sync-test}]]
    (sync-database! db)
    (let [table-id (db/select-one-id Table, :schema "default", :name "movie")
          field-id (db/select-one-id Field, :table_id table-id, :name "title")]
      (tt/with-temp FieldValues [_ {:field_id field-id
                                    :values   "[1,2,3]"}]
        (let [initial-field-values (db/select-one-field :values FieldValues, :field_id field-id)]
          (sync-database! db)
          [initial-field-values
           (db/select-one-field :values FieldValues, :field_id field-id)])))))


;; ## Individual Helper Fns

;; ## TEST PK SYNCING
(expect [:type/PK
         nil
         :type/PK
         :type/Latitude
         :type/PK]
  (let [get-special-type (fn [] (db/select-one-field :special_type Field, :id (id :venues :id)))]
    [;; Special type should be :id to begin with
     (get-special-type)
     ;; Clear out the special type
     (do (db/update! Field (id :venues :id), :special_type nil)
         (get-special-type))
     ;; Calling sync-table! should set the special type again
     (do (sync-table! (Table (id :venues)))
         (get-special-type))
     ;; sync-table! should *not* change the special type of fields that are marked with a different type
     (do (db/update! Field (id :venues :id), :special_type :type/Latitude)
         (get-special-type))
     ;; Make sure that sync-table runs set-table-pks-if-needed!
     (do (db/update! Field (id :venues :id), :special_type nil)
         (sync-table! (Table (id :venues)))
         (get-special-type))]))

;; ## FK SYNCING

;; Check that Foreign Key relationships were created on sync as we expect

(expect (id :venues :id)
  (db/select-one-field :fk_target_field_id Field, :id (id :checkins :venue_id)))

(expect (id :users :id)
  (db/select-one-field :fk_target_field_id Field, :id (id :checkins :user_id)))

(expect (id :categories :id)
  (db/select-one-field :fk_target_field_id Field, :id (id :venues :category_id)))

;; Check that sync-table! causes FKs to be set like we'd expect
(expect [{:special_type :type/FK, :fk_target_field_id true}
         {:special_type nil, :fk_target_field_id false}
         {:special_type :type/FK, :fk_target_field_id true}]
  (let [field-id (id :checkins :user_id)
        get-special-type-and-fk-exists? (fn []
                                          (into {} (-> (db/select-one [Field :special_type :fk_target_field_id], :id field-id)
                                                       (update :fk_target_field_id #(db/exists? Field :id %)))))]
    [ ;; FK should exist to start with
     (get-special-type-and-fk-exists?)
     ;; Clear out FK / special_type
     (do (db/update! Field field-id, :special_type nil, :fk_target_field_id nil)
         (get-special-type-and-fk-exists?))
     ;; Run sync-table and they should be set again
     (let [table (Table (id :checkins))]
       (sync-table! table)
       (get-special-type-and-fk-exists?))]))


;;; ## FieldValues Syncing

(let [get-field-values    (fn [] (db/select-one-field :values FieldValues, :field_id (id :venues :price)))
      get-field-values-id (fn [] (db/select-one-id FieldValues, :field_id (id :venues :price)))]
  ;; Test that when we delete FieldValues syncing the Table again will cause them to be re-created
  (expect
    [[1 2 3 4]  ; 1
     nil        ; 2
     [1 2 3 4]] ; 3
    [ ;; 1. Check that we have expected field values to start with
     (get-field-values)
     ;; 2. Delete the Field values, make sure they're gone
     (do (db/delete! FieldValues :id (get-field-values-id))
         (get-field-values))
     ;; 3. Now re-sync the table and make sure they're back
     (do (sync-table! (Table (id :venues)))
         (get-field-values))])

  ;; Test that syncing will cause FieldValues to be updated
  (expect
    [[1 2 3 4]  ; 1
     [1 2 3]    ; 2
     [1 2 3 4]] ; 3
    [ ;; 1. Check that we have expected field values to start with
     (get-field-values)
     ;; 2. Update the FieldValues, remove one of the values that should be there
     (do (db/update! FieldValues (get-field-values-id), :values [1 2 3])
         (get-field-values))
     ;; 3. Now re-sync the table and make sure the value is back
     (do (sync-table! (Table (id :venues)))
         (get-field-values))]))

;; Make sure that if a Field's cardinality passes `low-cardinality-threshold` (currently 300)
;; the corresponding FieldValues entry will be deleted (#3215)
(defn- insert-range-sql [rang]
  (str "INSERT INTO blueberries_consumed (num) VALUES "
       (str/join ", " (for [n rang]
                        (str "(" n ")")))))

(expect
  false
  (let [details {:db (str "mem:" (tu/random-name) ";DB_CLOSE_DELAY=10")}]
    (binding [mdb/*allow-potentailly-unsafe-connections* true]
      (tt/with-temp Database [db {:engine :h2, :details details}]
        (jdbc/with-db-connection [conn (sql/connection-details->spec (driver/engine->driver :h2) details)]
          (let [exec! #(doseq [statement %]
                         (jdbc/execute! conn [statement]))]
            ;; create the `blueberries_consumed` table and insert a 100 values
            (exec! ["CREATE TABLE blueberries_consumed (num INTEGER NOT NULL);"
                    (insert-range-sql (range 100))])
            (sync-database! db)
            (let [table-id (db/select-one-id Table :db_id (u/get-id db))
                  field-id (db/select-one-id Field :table_id table-id)]
              ;; field values should exist...
              (assert (= (count (db/select-one-field :values FieldValues :field_id field-id))
                         100))
              ;; ok, now insert enough rows to push the field past the `low-cardinality-threshold` and sync again, there should be no more field values
              (exec! [(insert-range-sql (range 100 (+ 100 field-values/low-cardinality-threshold)))])
              (sync-database! db)
              (db/exists? FieldValues :field_id field-id))))))))

(defn- narrow-to-min-max [row]
  (-> row
      (get-in [:type :type/Number])
      (select-keys [:min :max])
      (update :min #(u/round-to-decimals 4 %))
      (update :max #(u/round-to-decimals 4 %))))

(expect
  [{:min -165.374 :max -73.9533}
   {:min 10.0646 :max 40.7794}]
  (tt/with-temp* [Database [database {:details (:details (Database (id))), :engine :h2}]
                  Table    [table    {:db_id (u/get-id database), :name "VENUES"}]]
    (sync-table! table)
    (map narrow-to-min-max
         [(db/select-one-field :fingerprint Field, :id (id :venues :longitude))
          (db/select-one-field :fingerprint Field, :id (id :venues :latitude))])))