diff --git a/modules/drivers/presto/src/metabase/driver/presto.clj b/modules/drivers/presto/src/metabase/driver/presto.clj index 1cf1dfabb16c21c6c83653324103fa648c0be5f8..dae2693c9cf67b75f68b682aa7da7f450c24609e 100644 --- a/modules/drivers/presto/src/metabase/driver/presto.clj +++ b/modules/drivers/presto/src/metabase/driver/presto.clj @@ -19,7 +19,7 @@ [metabase.driver.sql [query-processor :as sql.qp] [util :as sql.u]] - [metabase.driver.sql-jdbc.sync :as sql-jdbc.sync] + [metabase.driver.sql-jdbc.sync.describe-database :as sql-jdbc.describe-database] [metabase.driver.sql.util.unprepare :as unprepare] [metabase.query-processor [context :as context] @@ -199,12 +199,14 @@ (def ^:private presto-metadata-sync-query-timeout (u/minutes->ms 2)) -(defmethod sql-jdbc.sync/execute-query-for-sync :presto - [_ details query] +(defn- execute-query-for-sync + [details query] (let [result-chan (a/promise-chan) query (if (string? query) query - (first query))] + (do + (assert (empty? (second query)) "Presto doesn't allow parameterized queries") + (first query)))] (execute-presto-query details query nil (fn [cols rows] (a/>!! result-chan {:cols cols, :rows rows}))) (let [[val] (a/alts!! [result-chan (a/timeout presto-metadata-sync-query-timeout)])] @@ -215,7 +217,7 @@ (s/defmethod driver/can-connect? :presto [driver {:keys [catalog] :as details} :- PrestoConnectionDetails] - (let [{[[v]] :rows} (sql-jdbc.sync/execute-query-for-sync :presto details + (let [{[[v]] :rows} (execute-query-for-sync details (format "SHOW SCHEMAS FROM %s LIKE 'information_schema'" (sql.u/quote-name driver :database catalog)))] (= v "information_schema"))) @@ -228,14 +230,22 @@ "Return a set of all schema names in this `database`." [driver {{:keys [catalog schema] :as details} :details :as database}] (let [sql (str "SHOW SCHEMAS FROM " (sql.u/quote-name driver :database catalog)) - {:keys [rows]} (sql-jdbc.sync/execute-query-for-sync :presto details sql)] + {:keys [rows]} (execute-query-for-sync details sql)] (set (map first rows)))) +(defn- have-select-privilege? [driver details schema table-name] + (try + (let [sql-args (sql-jdbc.describe-database/simple-select-probe-query driver schema table-name)] + ;; if the query completes without throwing an Exception, we can SELECT from this table + (execute-query-for-sync details sql-args) + true) + (catch Throwable _ + false))) + (defn- describe-schema [driver {{:keys [catalog user] :as details} :details :as db} {:keys [schema]}] (let [sql (str "SHOW TABLES FROM " (sql.u/quote-name driver :schema catalog schema))] - (set (for [[table-name & _] (:rows (sql-jdbc.sync/execute-query-for-sync :presto details sql)) - :when (sql-jdbc.sync/have-select-privilege? driver details {:table_schem schema - :table_name table-name})] + (set (for [[table-name & _] (:rows (execute-query-for-sync details sql)) + :when (have-select-privilege? driver details schema table-name)] {:name table-name :schema schema})))) @@ -250,7 +260,7 @@ (defmethod driver/describe-table :presto [driver {{:keys [catalog] :as details} :details} {schema :schema, table-name :name}] (let [sql (str "DESCRIBE " (sql.u/quote-name driver :table catalog schema table-name)) - {:keys [rows]} (sql-jdbc.sync/execute-query-for-sync :presto details sql)] + {:keys [rows]} (execute-query-for-sync details sql)] {:schema schema :name table-name :fields (set (for [[idx [name type]] (m/indexed rows)] diff --git a/modules/drivers/presto/test/metabase/test/data/presto.clj b/modules/drivers/presto/test/metabase/test/data/presto.clj index 3105e8d49fe11c499894a30400ef066fc8eff1d9..e89297395e47f690e1ca6fcfb87cc08472ae71d8 100644 --- a/modules/drivers/presto/test/metabase/test/data/presto.clj +++ b/modules/drivers/presto/test/metabase/test/data/presto.clj @@ -9,7 +9,6 @@ [config :as config] [driver :as driver]] [metabase.driver.presto :as presto] - [metabase.driver.sql-jdbc.sync :as sql-jdbc.sync] [metabase.driver.sql.util :as sql.u] [metabase.driver.sql.util.unprepare :as unprepare] [metabase.test.data @@ -95,7 +94,7 @@ (defmethod tx/create-db! :presto [driver {:keys [table-definitions database-name] :as dbdef} & {:keys [skip-drop-db?]}] (let [details (tx/dbdef->connection-details driver :db dbdef) - execute! (partial sql-jdbc.sync/execute-query-for-sync :presto details)] + execute! (partial #'presto/execute-query-for-sync details)] (doseq [tabledef table-definitions :let [rows (:rows tabledef) ;; generate an ID for each row because we don't have auto increments @@ -111,7 +110,7 @@ (defmethod tx/destroy-db! :presto [driver {:keys [database-name table-definitions], :as dbdef}] (let [details (tx/dbdef->connection-details driver :db dbdef) - execute! (partial sql-jdbc.sync/execute-query-for-sync :presto details)] + execute! (partial #'presto/execute-query-for-sync details)] (doseq [{:keys [table-name], :as tabledef} table-definitions] (println (format "[Presto] destroying %s.%s" (pr-str database-name) (pr-str table-name))) (execute! (sql.tx/drop-table-if-exists-sql driver dbdef tabledef)) diff --git a/modules/drivers/redshift/test/metabase/driver/redshift_test.clj b/modules/drivers/redshift/test/metabase/driver/redshift_test.clj index 3ae404cf51718e9a8c3ea8ab2395d4c25589b7e5..9caeb9ce2eef3cf286003e2e6875398172095c55 100644 --- a/modules/drivers/redshift/test/metabase/driver/redshift_test.clj +++ b/modules/drivers/redshift/test/metabase/driver/redshift_test.clj @@ -10,17 +10,21 @@ [metabase.driver.sql-jdbc.execute :as execute] [metabase.plugins.jdbc-proxy :as jdbc-proxy] [metabase.test.data.redshift :as rstest] - [metabase.test.fixtures :as fixtures])) + [metabase.test.fixtures :as fixtures]) + (:import metabase.plugins.jdbc_proxy.ProxyDriver)) (use-fixtures :once (fixtures/initialize :plugins)) (use-fixtures :once (fixtures/initialize :db)) (deftest correct-driver-test - (mt/test-driver - :redshift - (is (= "com.amazon.redshift.jdbc.Driver" - (.getName (class (jdbc-proxy/wrapped-driver (java.sql.DriverManager/getDriver "jdbc:redshift://host:5432/testdb"))))) - "Make sure we're using the correct driver for Redshift"))) + (mt/test-driver :redshift + (testing "Make sure we're using the correct driver for Redshift" + (let [driver (java.sql.DriverManager/getDriver "jdbc:redshift://host:5432/testdb") + driver (if (instance? metabase.plugins.jdbc_proxy.ProxyDriver driver) + (jdbc-proxy/wrapped-driver driver) + driver)] + (is (= "com.amazon.redshift.jdbc.Driver" + (.getName (class driver)))))))) (defn- query->native [query] (let [native-query (atom nil)] diff --git a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj index 121260f356ede53af40e708cd6653ba1206a9051..e17a087f2d97b0bfd92b281b945d9b96f66f40f5 100644 --- a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj +++ b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj @@ -25,7 +25,7 @@ [metabase.util [date-2 :as u.date] [honeysql-extensions :as hx] - [i18n :refer [tru]]]) + [i18n :refer [trs tru]]]) (:import [java.sql ResultSet Types] [java.time OffsetDateTime ZonedDateTime] metabase.util.honeysql_extensions.Identifier)) @@ -46,7 +46,6 @@ [_] :sunday) - (defmethod sql-jdbc.conn/connection-details->spec :snowflake [_ {:keys [account regionid], :as opts}] (let [host (if regionid @@ -248,24 +247,31 @@ excluded-schemas (set (sql-jdbc.sync/excluded-schemas driver))] (qp.store/with-store (qp.store/fetch-and-store-database! (u/get-id database)) - {:tables (set (for [table (jdbc/query - (sql-jdbc.conn/db->pooled-connection-spec database) - (format "SHOW OBJECTS IN DATABASE \"%s\"" db-name)) - :when (and (not (contains? excluded-schemas (:schema_name table))) - (sql-jdbc.sync/have-select-privilege? driver database - {:table_name (:name table) - :table_schem (:schema_name table)}))] - {:name (:name table) - :schema (:schema_name table) - :description (not-empty (:comment table))}))}))) + (let [spec (sql-jdbc.conn/db->pooled-connection-spec database) + sql (format "SHOW OBJECTS IN DATABASE \"%s\"" db-name)] + (with-open [conn (jdbc/get-connection spec)] + {:tables (into + #{} + (comp (filter (fn [{schema :schema_name, table-name :name}] + (and (not (contains? excluded-schemas schema)) + (sql-jdbc.sync/have-select-privilege? driver conn schema table-name)))) + (map (fn [{schema :schema_name, table-name :name, remark :comment}] + {:name table-name + :schema schema + :description (not-empty remark)}))) + (try + (jdbc/reducible-query {:connection conn} sql) + (catch Throwable e + (throw (ex-info (trs "Error executing query") {:sql sql} e)))))}))))) (defmethod driver/describe-table :snowflake [driver database table] - (jdbc/with-db-metadata [metadata (sql-jdbc.conn/db->pooled-connection-spec database)] - (->> (assoc (select-keys table [:name :schema]) - :fields (sql-jdbc.sync/describe-table-fields metadata driver table (db-name database))) - ;; find PKs and mark them - (sql-jdbc.sync/add-table-pks metadata)))) + (let [spec (sql-jdbc.conn/db->pooled-connection-spec database)] + (with-open [conn (jdbc/get-connection spec)] + (->> (assoc (select-keys table [:name :schema]) + :fields (sql-jdbc.sync/describe-table-fields driver conn table (db-name database))) + ;; find PKs and mark them + (sql-jdbc.sync/add-table-pks (.getMetaData conn)))))) (defmethod driver/describe-table-fks :snowflake [driver database table] diff --git a/modules/drivers/sqlite/src/metabase/driver/sqlite.clj b/modules/drivers/sqlite/src/metabase/driver/sqlite.clj index 1c0c27d32f736a871fc3da4a971c16f801a8aa04..3adaf3a751b1330b4bd5e7ec80f0ab57ae6fcdea 100644 --- a/modules/drivers/sqlite/src/metabase/driver/sqlite.clj +++ b/modules/drivers/sqlite/src/metabase/driver/sqlite.clj @@ -82,6 +82,14 @@ [_ database-type] (database-type->base-type database-type)) +;; The normal SELECT * FROM table WHERE 1 <> 1 LIMIT 0 query doesn't return any information for SQLite views -- it +;; seems to be the case that the query has to return at least one row +(defmethod sql-jdbc.sync/fallback-metadata-query :sqlite + [driver schema table] + (sql.qp/format-honeysql driver {:select [:*] + :from [(sql.qp/->honeysql driver (hx/identifier :table schema table))] + :limit 1})) + ;; register the SQLite concatnation operator `||` with HoneySQL as `sqlite-concat` ;; (hsql/format (hsql/call :sqlite-concat :a :b)) -> "(a || b)" (defmethod hformat/fn-handler "sqlite-concat" diff --git a/modules/drivers/sqlite/test/metabase/driver/sqlite_test.clj b/modules/drivers/sqlite/test/metabase/driver/sqlite_test.clj index de820df30221bde3ce942338c544bc1dbfaa1939..bdc060cfb89405ca16490fe60aadd95d2d4f93aa 100644 --- a/modules/drivers/sqlite/test/metabase/driver/sqlite_test.clj +++ b/modules/drivers/sqlite/test/metabase/driver/sqlite_test.clj @@ -6,18 +6,13 @@ [query-processor-test :as qp.test] [sync :as sync] [test :as mt]] - [metabase.driver.sql-jdbc - [connection :as sql-jdbc.conn] - [execute :as sql-jdbc.execute]] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] [metabase.models [database :refer [Database]] - [field :refer [Field]] [table :refer [Table]]] - [metabase.query-processor-test :as qp.test] [metabase.test [data :as data] [util :as tu]] - [metabase.test.data.datasets :as datasets] [toucan [db :as db] [hydrate :refer [hydrate]]])) @@ -59,8 +54,12 @@ (testing "Make sure we correctly infer complex types in views (#8630, #9276, #12191, #12547, #10681)" (let [details (mt/dbdef->connection-details :sqlite :db {:database-name "views_test"}) spec (sql-jdbc.conn/connection-details->spec :sqlite details)] - (mt/with-temp Database [{db-id :id :as database} {:engine :sqlite, :details (assoc details :dbname "viwes_test")}] - (doseq [statement ["create table if not exists src(id integer, time text);" + (mt/with-temp Database [{db-id :id :as database} {:engine :sqlite, :details (assoc details :dbname "views_test")}] + (doseq [statement ["drop view if exists v_groupby_test;" + "drop table if exists groupby_test;" + "drop view if exists v_src;" + "drop table if exists src;" + "create table if not exists src(id integer, time text);" "create view if not exists v_src as select id, strftime('%s', time) as time from src;" "insert into src values(1, '2020-03-01 12:20:35');"]] (jdbc/execute! spec [statement])) @@ -77,8 +76,10 @@ :base_type :type/Text}]}] (->> (hydrate (db/select Table :db_id db-id {:order-by [:name]}) :fields) (map table-fingerprint)))) - (doseq [statement ["CREATE TABLE IF NOT EXISTS groupby_test ( - id INTEGER primary key unique, + (doseq [statement ["drop view if exists v_groupby_test;" + "drop table if exists groupby_test;" + "CREATE TABLE IF NOT EXISTS groupby_test ( + id INTEGER primary key unique, symbol VARCHAR, dt DATETIME, value FLOAT);" diff --git a/src/metabase/driver/sql/query_processor.clj b/src/metabase/driver/sql/query_processor.clj index 5a407a48adf93c8f0c80a74be2d420f9931ddf6d..95b9fd030088ee19d9bf5cc893d9498a3bb0776a 100644 --- a/src/metabase/driver/sql/query_processor.clj +++ b/src/metabase/driver/sql/query_processor.clj @@ -17,6 +17,7 @@ [field :as field :refer [Field]] [table :refer [Table]]] [metabase.query-processor + [error-type :as qp.error-type] [interface :as i] [store :as qp.store]] [metabase.query-processor.middleware @@ -24,7 +25,7 @@ [wrap-value-literals :as value-literal]] [metabase.util [honeysql-extensions :as hx] - [i18n :refer [deferred-tru]] + [i18n :refer [deferred-tru tru]] [schema :as su]] [potemkin.types :as p.types] [pretty.core :refer [PrettyPrintable]] @@ -815,7 +816,11 @@ "\n" (u/pprint-to-str honeysql-form)))) (finally - (throw e)))))) + (throw (ex-info (tru "Error compiling HoneySQL form") + {:driver driver + :form honeysql-form + :type qp.error-type/driver} + e))))))) (defn- add-default-select "Add `SELECT *` to `honeysql-form` if no `:select` clause is present." diff --git a/src/metabase/driver/sql_jdbc/execute.clj b/src/metabase/driver/sql_jdbc/execute.clj index 596d5a82547091fe8227545e4a4d60e9dbd9d25e..4e01ea6be940ca518341cf094c4599b0b8cd2cfc 100644 --- a/src/metabase/driver/sql_jdbc/execute.clj +++ b/src/metabase/driver/sql_jdbc/execute.clj @@ -11,10 +11,9 @@ [metabase [driver :as driver] [util :as u]] - [metabase.driver.sql-jdbc - [connection :as sql-jdbc.conn] - [sync :as sql-jdbc.sync]] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] [metabase.driver.sql-jdbc.execute.old-impl :as execute.old] + [metabase.driver.sql-jdbc.sync.interface :as sql-jdbc.sync] [metabase.mbql.util :as mbql.u] [metabase.query-processor [context :as context] diff --git a/src/metabase/driver/sql_jdbc/sync.clj b/src/metabase/driver/sql_jdbc/sync.clj index 8f0b3d8701181c358d9f533b94518818ec4a1aa7..0cd279bd902a93f9048070a582ce61bf590530b0 100644 --- a/src/metabase/driver/sql_jdbc/sync.clj +++ b/src/metabase/driver/sql_jdbc/sync.clj @@ -1,268 +1,31 @@ (ns metabase.driver.sql-jdbc.sync "Implementations for sync-related driver multimethods for SQL JDBC drivers, using JDBC DatabaseMetaData." - (:require [clojure - [set :as set] - [string :as str]] - [clojure.java.jdbc :as jdbc] - [clojure.tools.logging :as log] - [medley.core :as m] - [metabase - [driver :as driver] - [util :as u]] - [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] - [metabase.driver.sql.query-processor :as sql.qp] - [metabase.util.honeysql-extensions :as hx]) - (:import (java.sql Connection DatabaseMetaData ResultSetMetaData))) - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | Interface (Multimethods) | -;;; +----------------------------------------------------------------------------------------------------------------+ - -(defmulti active-tables - "Return a set of maps containing information about the active tables/views, collections, or equivalent that currently - exist in a database. Each map should contain the key `:name`, which is the string name of the table. For databases - that have a concept of schemas, this map should also include the string name of the table's `:schema`. - - Two different implementations are provided in this namespace: `fast-active-tables` (the default), and - `post-filtered-active-tables`. You should be fine using the default, but refer to the documentation for those - functions for more details on the differences. - - `metabase` is an instance of `DatabaseMetaData`." - {:arglists '([driver database metadata])} - driver/dispatch-on-initialized-driver - :hierarchy #'driver/hierarchy) - -(declare fast-active-tables) - -(defmethod active-tables :sql-jdbc [driver database metadata] - (fast-active-tables driver database metadata)) - - -(defmulti excluded-schemas - "Return set of string names of schemas to skip syncing tables from." - {:arglists '([driver])} - driver/dispatch-on-initialized-driver - :hierarchy #'driver/hierarchy) - -(defmethod excluded-schemas :sql-jdbc [_] nil) - - -;; TODO - why don't we just use JDBC `DatabaseMetaData` to do this? This is wacky -(defmulti database-type->base-type - "Given a native DB column type (as a keyword), return the corresponding `Field` `base-type`, which should derive from - `:type/*`. You can use `pattern-based-database-type->base-type` in this namespace to implement this using regex - patterns." - {:arglists '([driver database-type])} - driver/dispatch-on-initialized-driver - :hierarchy #'driver/hierarchy) - - -(defmulti column->special-type - "Attempt to determine the special-type of a field given the column name and native type. For example, the Postgres - driver can mark Postgres JSON type columns as `:type/SerializedJSON` special type. - - `database-type` and `column-name` will be strings." - {:arglists '([driver database-type column-name])} - driver/dispatch-on-initialized-driver - :hierarchy #'driver/hierarchy) - -(defmethod column->special-type :sql-jdbc [_ _ _] nil) - - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | Common | -;;; +----------------------------------------------------------------------------------------------------------------+ - -(defn pattern-based-database-type->base-type - "Return a `database-type->base-type` function that matches types based on a sequence of pattern / base-type pairs." - [pattern->type] - (fn [column-type] - (let [column-type (name column-type)] - (loop [[[pattern base-type] & more] pattern->type] - (cond - (re-find pattern column-type) base-type - (seq more) (recur more)))))) - -(defn- ->spec [db-or-id-or-spec] - (if (u/id db-or-id-or-spec) - (sql-jdbc.conn/db->pooled-connection-spec db-or-id-or-spec) - db-or-id-or-spec)) - - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | Sync Impl | -;;; +----------------------------------------------------------------------------------------------------------------+ - -(defn- db-tables - "Fetch a JDBC Metadata ResultSet of tables in the DB, optionally limited to ones belonging to a given - schema." - [driver ^DatabaseMetaData metadata ^String schema-or-nil ^String db-name-or-nil] - ;; tablePattern "%" = match all tables - (with-open [rs (.getTables metadata db-name-or-nil (driver/escape-entity-name-for-metadata driver schema-or-nil) "%" - (into-array String ["TABLE" "VIEW" "FOREIGN TABLE" "MATERIALIZED VIEW" "EXTERNAL TABLE"]))] - (into [] - (map (fn [row] (select-keys row [:table_name :remarks :table_schem]))) - (jdbc/reducible-result-set rs {})))) - -(defn- simple-select-probe - "Simple (ie. cheap) SELECT on a given table to test for access and get column metadata." - [driver schema table] - ;; Using our SQL compiler here to get portable LIMIT - (sql.qp/format-honeysql driver - (sql.qp/apply-top-level-clause driver :limit - {:select [:*] - :from [(sql.qp/->honeysql driver (hx/identifier :table schema table))]} - {:limit 1}))) - -(defmulti execute-query-for-sync - "Execute given SQL query. Used during parts of sync where we don't have metadata populated and - can't use our default query processing pipeline." - {:arglists '([driver db-or-id-or-spec query])} - driver/dispatch-on-initialized-driver - :hierarchy #'driver/hierarchy) - -(defmethod execute-query-for-sync :sql-jdbc - [_ db-or-id-or-spec query] - (jdbc/query (sql-jdbc.conn/db->pooled-connection-spec db-or-id-or-spec) query)) - -(defn have-select-privilege? - "Check if we have select privilege for given table" - [driver db-or-id-or-spec {:keys [table_name table_schem] :as table}] - (when (u/ignore-exceptions - (execute-query-for-sync driver db-or-id-or-spec - (simple-select-probe driver table_schem table_name))) - table)) - -(defn- all-schemas - [^DatabaseMetaData metadata] - (with-open [rs (.getSchemas metadata)] - (set (map :table_schem (jdbc/result-set-seq rs))))) - -(defn fast-active-tables - "Default, fast implementation of `active-tables` best suited for DBs with lots of system tables (like Oracle). Fetch - list of schemas, then for each one not in `excluded-schemas`, fetch its Tables, and combine the results. - - This is as much as 15x faster for Databases with lots of system tables than `post-filtered-active-tables` (4 seconds - vs 60)." - [driver, db-or-id-or-spec, ^DatabaseMetaData metadata, & [db-name-or-nil]] - (->> (set/difference (all-schemas metadata) (excluded-schemas driver)) - (mapcat (fn [schema] - (db-tables driver metadata schema db-name-or-nil))) - (filter (partial have-select-privilege? driver db-or-id-or-spec)))) - -(defn post-filtered-active-tables - "Alternative implementation of `active-tables` best suited for DBs with little or no support for schemas. Fetch *all* - Tables, then filter out ones whose schema is in `excluded-schemas` Clojure-side." - [driver, db-or-id-or-spec, ^DatabaseMetaData metadata, & [db-name-or-nil]] - (filter (every-pred (partial have-select-privilege? driver db-or-id-or-spec) - (comp not (partial contains? (excluded-schemas driver)) :table_schem)) - (db-tables driver metadata nil db-name-or-nil))) - -(defn get-catalogs - "Returns a set of all of the catalogs found via `metadata`" - [^DatabaseMetaData metadata] - (with-open [rs (.getCatalogs metadata)] - (set (map :table_cat (jdbc/metadata-result rs))))) - -(defn- database-type->base-type-or-warn - "Given a `database-type` (e.g. `VARCHAR`) return the mapped Metabase type (e.g. `:type/Text`)." - [driver database-type] - (or (database-type->base-type driver (keyword database-type)) - (do (log/warn (format "Don't know how to map column type '%s' to a Field base_type, falling back to :type/*." - database-type)) - :type/*))) - -(defn- calculated-special-type - "Get an appropriate special type for a column with `column-name` of type `database-type`." - [driver, ^String column-name, ^String database-type] - (when-let [special-type (column->special-type driver database-type column-name)] - (assert (isa? special-type :type/*) - (str "Invalid type: " special-type)) - special-type)) - -(defn- fields-metadata - [^DatabaseMetaData metadata, driver, {^String schema :schema, ^String table-name :name :as table}, & [^String db-name-or-nil]] - (with-open [rs (.getColumns metadata - db-name-or-nil - (driver/escape-entity-name-for-metadata driver schema) - (driver/escape-entity-name-for-metadata driver table-name) - nil)] - (let [result (jdbc/result-set-seq rs)] - ;; In some rare cases `:column_name` is blank (eg. SQLite's views with group by), - ;; fallback to sniffing the type from a SELECT * query - (if (some (comp str/blank? :type_name) result) - (jdbc/with-db-connection [conn (->spec (:db_id table))] - (let [^Connection conn (:connection conn) - ^ResultSetMetaData metadata (->> (simple-select-probe driver schema table-name) - first - (.executeQuery (.createStatement conn)) - (.getMetaData))] - (doall - (for [i (range 1 (inc (.getColumnCount metadata)))] - {:type_name (.getColumnTypeName metadata i) - :column_name (.getColumnName metadata i)})))) - result)))) - -(defn describe-table-fields - "Returns a set of column metadata for `schema` and `table-name` using `metadata`. " - [^DatabaseMetaData metadata, driver, table, & [^String db-name-or-nil]] - (set - (for [[idx {database-type :type_name - column-name :column_name - remarks :remarks}] (m/indexed (fields-metadata metadata driver table db-name-or-nil))] - (merge - {:name column-name - :database-type database-type - :base-type (database-type->base-type-or-warn driver database-type) - :database-position idx} - (when (not (str/blank? remarks)) - {:field-comment remarks}) - (when-let [special-type (calculated-special-type driver column-name database-type)] - {:special-type special-type}))))) - -(defn add-table-pks - "Using `metadata` find any primary keys for `table` and assoc `:pk?` to true for those columns." - [^DatabaseMetaData metadata, table] - (with-open [rs (.getPrimaryKeys metadata nil nil (:name table))] - (let [pks (set (map :column_name (jdbc/metadata-result rs)))] - (update table :fields (fn [fields] - (set (for [field fields] - (if-not (contains? pks (:name field)) - field - (assoc field :pk? true))))))))) - - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | Default SQL JDBC metabase.driver impls for sync methods | -;;; +----------------------------------------------------------------------------------------------------------------+ - -(defn describe-database - "Default implementation of `driver/describe-database` for SQL JDBC drivers. Uses JDBC DatabaseMetaData." - [driver db-or-id-or-spec] - (jdbc/with-db-metadata [metadata (sql-jdbc.conn/db->pooled-connection-spec db-or-id-or-spec)] - {:tables (set (for [table (active-tables driver db-or-id-or-spec metadata)] - (let [remarks (:remarks table)] - {:name (:table_name table) - :schema (:table_schem table) - :description (when-not (str/blank? remarks) - remarks)})))})) - -(defn describe-table - "Default implementation of `driver/describe-table` for SQL JDBC drivers. Uses JDBC DatabaseMetaData." - [driver db-or-id-or-spec table] - (jdbc/with-db-metadata [metadata (sql-jdbc.conn/db->pooled-connection-spec db-or-id-or-spec)] - (->> (assoc (select-keys table [:name :schema]) :fields (describe-table-fields metadata driver table)) - ;; find PKs and mark them - (add-table-pks metadata)))) - -(defn describe-table-fks - "Default implementation of `driver/describe-table-fks` for SQL JDBC drivers. Uses JDBC DatabaseMetaData." - [driver db-or-id-or-spec table & [^String db-name-or-nil]] - (jdbc/with-db-metadata [metadata (sql-jdbc.conn/db->pooled-connection-spec db-or-id-or-spec)] - (with-open [rs (.getImportedKeys metadata db-name-or-nil, ^String (:schema table), ^String (:name table))] - (set - (for [result (jdbc/metadata-result rs)] - {:fk-column-name (:fkcolumn_name result) - :dest-table {:name (:pktable_name result) - :schema (:pktable_schem result)} - :dest-column-name (:pkcolumn_name result)}))))) + (:require [metabase.driver.sql-jdbc.sync + [describe-database :as sync.describe-database] + [describe-table :as sync.describe-table] + [interface :as i]] + [potemkin :as p])) + +(comment i/keep-me sync.describe-database/keep-me sync.describe-table/keep-me) + +(p/import-vars + [i + active-tables + column->special-type + database-type->base-type + excluded-schemas + fallback-metadata-query + have-select-privilege?] + + [sync.describe-table + add-table-pks + describe-table + describe-table-fields + describe-table-fks + get-catalogs + pattern-based-database-type->base-type] + + [sync.describe-database + describe-database + fast-active-tables + post-filtered-active-tables]) diff --git a/src/metabase/driver/sql_jdbc/sync/common.clj b/src/metabase/driver/sql_jdbc/sync/common.clj new file mode 100644 index 0000000000000000000000000000000000000000..b37cd0642104f1c2c441bd2afd2ab82abffe3f01 --- /dev/null +++ b/src/metabase/driver/sql_jdbc/sync/common.clj @@ -0,0 +1,40 @@ +(ns metabase.driver.sql-jdbc.sync.common + (:require [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute]) + (:import [java.sql Connection PreparedStatement ResultSet])) + +(defn prepare-statement + "Create a PreparedStatement for metadata queries; set `TYPE_FORWARD_ONLY`/`CONCUR_READ_ONLY`/`FETCH_FORWARD` options + if possible. These queries return no rows." + ^PreparedStatement [driver ^Connection conn ^String sql params] + ;; `sql-jdbc.execute/prepared-statement` will set `TYPE_FORWARD_ONLY`/`CONCUR_READ_ONLY`/`FETCH_FORWARD` if + ;; possible, although I'm not sure if that will make a difference if we don't actually realize the ResultSet + (doto ^PreparedStatement (sql-jdbc.execute/prepared-statement driver conn sql params) + (.setMaxRows 0))) + +(defn reducible-results + "Creates an `IReduceInit` for a function that returns a `ResultSet`, and a function that is called once for each row. + `rs-thunk` should return a `ResultSet`; `rs->row-thunk` has the signature + + (rs->row-thunk rs)-> row-thunk + + `rs->row-thunk` is called once with the ResultSet, and should return a thunk; the resulting thunk is called once for + each row. Example: + + (reducible-results + ;; `rs-thunk` should return a `ResultSet` + #(.getSchemas metadata) + ;; `rs->row-thunk` is called once with the `ResultSet`, and returns a thunk + (fn [rs] + ;; the thunk is called once for each row to get results + (fn [] + (.getString rs \"TABLE_SCHEM\"))))" + [rs-thunk rs->row-thunk] + (reify clojure.lang.IReduceInit + (reduce [_ rf init] + (with-open [^ResultSet rs (rs-thunk)] + (reduce + ((take-while some?) rf) + init + (let [row-thunk (rs->row-thunk rs)] + (repeatedly #(when (.next rs) + (row-thunk))))))))) diff --git a/src/metabase/driver/sql_jdbc/sync/describe_database.clj b/src/metabase/driver/sql_jdbc/sync/describe_database.clj new file mode 100644 index 0000000000000000000000000000000000000000..838c415cc41533e71831e262d15c781e0c55a514 --- /dev/null +++ b/src/metabase/driver/sql_jdbc/sync/describe_database.clj @@ -0,0 +1,124 @@ +(ns metabase.driver.sql-jdbc.sync.describe-database + "SQL JDBC impl for `describe-database`." + (:require [clojure.java.jdbc :as jdbc] + [clojure.string :as str] + [metabase.driver :as driver] + [metabase.driver.sql-jdbc + [connection :as sql-jdbc.conn] + [execute :as sql-jdbc.execute]] + [metabase.driver.sql-jdbc.sync + [common :as common] + [interface :as i]] + [metabase.driver.sql.query-processor :as sql.qp] + [metabase.util.honeysql-extensions :as hx]) + (:import [java.sql Connection DatabaseMetaData ResultSet])) + +(defmethod i/excluded-schemas :sql-jdbc [_] nil) + +(defn- all-schemas [^DatabaseMetaData metadata] + {:pre [(instance? DatabaseMetaData metadata)]} + (common/reducible-results + #(.getSchemas metadata) + (fn [^ResultSet rs] + #(.getString rs "TABLE_SCHEM")))) + +(defn- syncable-schemas + [driver metadata] + (eduction (remove (set (i/excluded-schemas driver))) + (all-schemas metadata))) + +(defn simple-select-probe-query + "Simple (ie. cheap) SELECT on a given table to test for access and get column metadata. Doesn't return + anything useful (only used to check whether we can execute a SELECT query) + + (simple-select-probe-query :postgres \"public\" \"my_table\") + ;; -> [\"SELECT TRUE FROM public.my_table WHERE 1 <> 1 LIMIT 0\"]" + [driver schema table] + {:pre [(string? table)]} + ;; Using our SQL compiler here to get portable LIMIT (e.g. `SELECT TOP n ...` for SQL Server/Oracle) + (let [honeysql {:select [[(sql.qp/->honeysql driver true) :_]] + :from [(sql.qp/->honeysql driver (hx/identifier :table schema table))] + :where [:not= 1 1]} + honeysql (sql.qp/apply-top-level-clause driver :limit honeysql {:limit 0})] + (sql.qp/format-honeysql driver honeysql))) + +(defn- execute-select-probe-query + "Execute the simple SELECT query defined above. The main goal here is to check whether we're able to execute a SELECT + query against the Table in question -- we don't care about the results themselves -- so the query and the logic + around executing it should be as simple as possible. We need to highly optimize this logic because it's executed for + every Table on every sync." + [driver ^Connection conn [sql & params]] + {:pre [(string? sql)]} + (with-open [stmt (common/prepare-statement driver conn sql params)] + ;; attempting to execute the SQL statement will throw an Exception if we don't have permissions; otherwise it will + ;; truthy wheter or not it returns a ResultSet, but we can ignore that since we have enough info to proceed at + ;; this point. + (.execute stmt))) + +(defmethod i/have-select-privilege? :sql-jdbc + [driver conn table-schema table-name] + ;; Query completes = we have SELECT privileges + ;; Query throws some sort of no permissions exception = no SELECT privileges + (let [sql-args (simple-select-probe-query driver table-schema table-name)] + (try + (execute-select-probe-query driver conn sql-args) + true + (catch Throwable _ + false)))) + +(defn- db-tables + "Fetch a JDBC Metadata ResultSet of tables in the DB, optionally limited to ones belonging to a given + schema. Returns a reducible sequence of results." + [driver ^DatabaseMetaData metadata ^String schema-or-nil ^String db-name-or-nil] + (common/reducible-results + #(.getTables metadata db-name-or-nil (some->> schema-or-nil (driver/escape-entity-name-for-metadata driver)) "%" + (into-array String ["TABLE" "VIEW" "FOREIGN TABLE" "MATERIALIZED VIEW" "EXTERNAL TABLE"])) + (fn [^ResultSet rs] + (fn [] + {:name (.getString rs "TABLE_NAME") + :schema (.getString rs "TABLE_SCHEM") + :description (when-let [remarks (.getString rs "REMARKS")] + (when-not (str/blank? remarks) + remarks))})))) + +(defn fast-active-tables + "Default, fast implementation of `active-tables` best suited for DBs with lots of system tables (like Oracle). Fetch + list of schemas, then for each one not in `excluded-schemas`, fetch its Tables, and combine the results. + + This is as much as 15x faster for Databases with lots of system tables than `post-filtered-active-tables` (4 seconds + vs 60)." + [driver ^Connection conn & [db-name-or-nil]] + {:pre [(instance? Connection conn)]} + (let [metadata (.getMetaData conn)] + (eduction + (comp (mapcat (fn [schema] + (db-tables driver metadata schema db-name-or-nil))) + (filter (fn [{table-schema :schema, table-name :name}] + (i/have-select-privilege? driver conn table-schema table-name)))) + (syncable-schemas driver metadata)))) + +(defmethod i/active-tables :sql-jdbc + [driver connection] + (fast-active-tables driver connection)) + +(defn post-filtered-active-tables + "Alternative implementation of `active-tables` best suited for DBs with little or no support for schemas. Fetch *all* + Tables, then filter out ones whose schema is in `excluded-schemas` Clojure-side." + [driver ^Connection conn & [db-name-or-nil]] + {:pre [(instance? Connection conn)]} + (eduction + (filter (let [excluded (i/excluded-schemas driver)] + (fn [{table-schema :schema, table-name :name}] + (and (not (contains? excluded table-schema)) + (i/have-select-privilege? driver conn table-schema table-name))))) + (db-tables driver (.getMetaData conn) nil db-name-or-nil))) + +(defn describe-database + "Default implementation of `driver/describe-database` for SQL JDBC drivers. Uses JDBC DatabaseMetaData." + [driver db-or-id-or-spec] + {:tables (with-open [conn (jdbc/get-connection (sql-jdbc.conn/db->pooled-connection-spec db-or-id-or-spec))] + ;; try to set the Connection to `READ_UNCOMMITED` if possible, or whatever the next least-locking level + ;; is. Not sure how much of a difference that makes since we're not running this inside a transaction, + ;; but better safe than sorry + (sql-jdbc.execute/set-best-transaction-level! driver conn) + (into #{} (i/active-tables driver conn)))}) diff --git a/src/metabase/driver/sql_jdbc/sync/describe_table.clj b/src/metabase/driver/sql_jdbc/sync/describe_table.clj new file mode 100644 index 0000000000000000000000000000000000000000..497aa87a3b9146056e182d489d55896991d6a532 --- /dev/null +++ b/src/metabase/driver/sql_jdbc/sync/describe_table.clj @@ -0,0 +1,193 @@ +(ns metabase.driver.sql-jdbc.sync.describe-table + "SQL JDBC impl for `describe-table` and `describe-table-fks`." + (:require [clojure.java.jdbc :as jdbc] + [clojure.string :as str] + [clojure.tools.logging :as log] + [medley.core :as m] + [metabase + [driver :as driver] + [util :as u]] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] + [metabase.driver.sql-jdbc.sync + [common :as common] + [interface :as i]] + [metabase.driver.sql.query-processor :as sql.qp] + [metabase.util.honeysql-extensions :as hx]) + (:import [java.sql Connection DatabaseMetaData ResultSet])) + +(defmethod i/column->special-type :sql-jdbc [_ _ _] nil) + +(defn pattern-based-database-type->base-type + "Return a `database-type->base-type` function that matches types based on a sequence of pattern / base-type pairs. + `pattern->type` is a map of regex pattern to MBQL type keyword." + [pattern->type] + (fn database-type->base-type [column-type] + (let [column-type (name column-type)] + (some + (fn [[pattern base-type]] + (when (re-find pattern column-type) + base-type)) + pattern->type)))) + +(defn get-catalogs + "Returns a set of all of the catalogs found via `metadata`" + [^DatabaseMetaData metadata] + (with-open [rs (.getCatalogs metadata)] + (set (map :table_cat (jdbc/metadata-result rs))))) + +(defn- database-type->base-type-or-warn + "Given a `database-type` (e.g. `VARCHAR`) return the mapped Metabase type (e.g. `:type/Text`)." + [driver database-type] + (or (i/database-type->base-type driver (keyword database-type)) + (do (log/warn (format "Don't know how to map column type '%s' to a Field base_type, falling back to :type/*." + database-type)) + :type/*))) + +(defn- calculated-special-type + "Get an appropriate special type for a column with `column-name` of type `database-type`." + [driver ^String column-name ^String database-type] + (when-let [special-type (i/column->special-type driver database-type column-name)] + (assert (isa? special-type :type/*) + (str "Invalid type: " special-type)) + special-type)) + +(defmethod i/fallback-metadata-query :sql-jdbc + [driver schema table] + {:pre [(string? table)]} + ;; Using our SQL compiler here to get portable LIMIT (e.g. `SELECT TOP n ...` for SQL Server/Oracle) + (let [honeysql {:select [:*] + :from [(sql.qp/->honeysql driver (hx/identifier :table schema table))] + :where [:not= 1 1]} + honeysql (sql.qp/apply-top-level-clause driver :limit honeysql {:limit 0})] + (sql.qp/format-honeysql driver honeysql))) + +(defn- fallback-fields-metadata-from-select-query + "In some rare cases `:column_name` is blank (eg. SQLite's views with group by) fallback to sniffing the type from a + SELECT * query." + [driver ^Connection conn table-schema table-name] + ;; some DBs (:sqlite) don't actually return the correct metadata for LIMIT 0 queries + (let [[sql & params] (i/fallback-metadata-query driver table-schema table-name)] + (reify clojure.lang.IReduceInit + (reduce [_ rf init] + (with-open [stmt (common/prepare-statement driver conn sql params) + rs (.executeQuery stmt)] + (let [metadata (.getMetaData rs)] + (reduce + ((map (fn [^Integer i] + {:name (.getColumnName metadata i) + :database-type (.getColumnTypeName metadata i)})) rf) + init + (range 1 (inc (.getColumnCount metadata)))))))))) + +(defn- jdbc-fields-metadata + "Reducible metadata about the Fields belonging to a Table, fetching using JDBC DatabaseMetaData methods." + [driver ^Connection conn db-name-or-nil schema table-name] + (common/reducible-results #(.getColumns (.getMetaData conn) + db-name-or-nil + (some->> schema (driver/escape-entity-name-for-metadata driver)) + (some->> table-name (driver/escape-entity-name-for-metadata driver)) + nil) + (fn [^ResultSet rs] + #(merge + {:name (.getString rs "COLUMN_NAME") + :database-type (.getString rs "TYPE_NAME")} + (when-let [remarks (.getString rs "REMARKS")] + (when-not (str/blank? remarks) + {:field-comment remarks})))))) + +(defn- fields-metadata + "Returns reducible metadata for the Fields in a `table`." + [driver ^Connection conn {schema :schema, table-name :name} & [^String db-name-or-nil]] + {:pre [(instance? Connection conn) (string? table-name)]} + (reify clojure.lang.IReduceInit + (reduce [_ rf init] + ;; 1. Return all the Fields that come back from DatabaseMetaData that include type info. + ;; + ;; 2. Iff there are some Fields that don't have type info, concatenate + ;; `fallback-fields-metadata-from-select-query`, which fetches the same Fields using a different method. + ;; + ;; 3. Filter out any duplicates between the two methods using `m/distinct-by`. + (let [has-fields-without-type-info? (volatile! false) + jdbc-metadata (eduction + (remove (fn [{:keys [database-type]}] + (when (str/blank? database-type) + (vreset! has-fields-without-type-info? true) + true))) + (jdbc-fields-metadata driver conn db-name-or-nil schema table-name)) + fallback-metadata (reify clojure.lang.IReduceInit + (reduce [_ rf init] + (reduce + rf + init + (when @has-fields-without-type-info? + (fallback-fields-metadata-from-select-query driver conn schema table-name)))))] + ;; VERY IMPORTANT! DO NOT REWRITE THIS TO BE LAZY! IT ONLY WORKS BECAUSE AS NORMAL-FIELDS GETS REDUCED, + ;; HAS-FIELDS-WITHOUT-TYPE-INFO? WILL GET SET TO TRUE IF APPLICABLE AND THEN FALLBACK-FIELDS WILL RUN WHEN + ;; IT'S TIME TO START EVALUATING THAT. + (reduce + ((comp cat (m/distinct-by :name)) rf) + init + [jdbc-metadata fallback-metadata]))))) + +(defn describe-table-fields + "Returns a set of column metadata for `table` using JDBC Connection `conn`." + [driver conn table & [db-name-or-nil]] + (into + #{} + (map-indexed (fn [i {:keys [database-type], column-name :name, :as col}] + (merge + (u/select-non-nil-keys col [:name :database-type :field-comment]) + {:base-type (database-type->base-type-or-warn driver database-type) + :database-position i} + (when-let [special-type (calculated-special-type driver column-name database-type)] + {:special-type special-type})))) + (fields-metadata driver conn table db-name-or-nil))) + +(defn add-table-pks + "Using `metadata` find any primary keys for `table` and assoc `:pk?` to true for those columns." + [^DatabaseMetaData metadata table] + (let [pks (into #{} (common/reducible-results #(.getPrimaryKeys metadata nil nil (:name table)) + (fn [^ResultSet rs] + #(.getString rs "COLUMN_NAME"))))] + (update table :fields (fn [fields] + (set (for [field fields] + (if-not (contains? pks (:name field)) + field + (assoc field :pk? true)))))))) + +(defn- describe-table* [driver ^Connection conn table] + {:pre [(instance? Connection conn)]} + (->> (assoc (select-keys table [:name :schema]) + :fields (describe-table-fields driver conn table)) + ;; find PKs and mark them + (add-table-pks (.getMetaData conn)))) + +(defn describe-table + "Default implementation of `driver/describe-table` for SQL JDBC drivers. Uses JDBC DatabaseMetaData." + [driver db-or-id-or-spec-or-conn table] + (if (instance? Connection db-or-id-or-spec-or-conn) + (describe-table* driver db-or-id-or-spec-or-conn table) + (let [spec (sql-jdbc.conn/db->pooled-connection-spec db-or-id-or-spec-or-conn)] + (with-open [conn (jdbc/get-connection spec)] + (describe-table* driver conn table))))) + +(defn- describe-table-fks* + [driver ^Connection conn {^String schema :schema, ^String table-name :name} & [^String db-name-or-nil]] + (into + #{} + (common/reducible-results #(.getImportedKeys (.getMetaData conn) db-name-or-nil schema table-name) + (fn [^ResultSet rs] + (fn [] + {:fk-column-name (.getString rs "FKCOLUMN_NAME") + :dest-table {:name (.getString rs "PKTABLE_NAME") + :schema (.getString rs "PKTABLE_SCHEM")} + :dest-column-name (.getString rs "PKCOLUMN_NAME")}))))) + +(defn describe-table-fks + "Default implementation of `driver/describe-table-fks` for SQL JDBC drivers. Uses JDBC DatabaseMetaData." + [driver db-or-id-or-spec-or-conn table & [db-name-or-nil]] + (if (instance? Connection db-or-id-or-spec-or-conn) + (describe-table-fks* driver db-or-id-or-spec-or-conn table db-name-or-nil) + (let [spec (sql-jdbc.conn/db->pooled-connection-spec db-or-id-or-spec-or-conn)] + (with-open [conn (jdbc/get-connection spec)] + (describe-table-fks* driver conn table db-name-or-nil))))) diff --git a/src/metabase/driver/sql_jdbc/sync/interface.clj b/src/metabase/driver/sql_jdbc/sync/interface.clj new file mode 100644 index 0000000000000000000000000000000000000000..4f82f6ad972638334e3b89b816c9c0d5d518836d --- /dev/null +++ b/src/metabase/driver/sql_jdbc/sync/interface.clj @@ -0,0 +1,56 @@ +(ns metabase.driver.sql-jdbc.sync.interface + (:require [metabase.driver :as driver])) + +(defmulti active-tables + "Return a reducible sequence of maps containing information about the active tables/views, collections, or equivalent + that currently exist in a database. Each map should contain the key `:name`, which is the string name of the table. + For databases that have a concept of schemas, this map should also include the string name of the table's `:schema`. + + Two different implementations are provided in this namespace: `fast-active-tables` (the default), and + `post-filtered-active-tables`. You should be fine using the default, but refer to the documentation for those + functions for more details on the differences. + + `metabase` is an instance of `DatabaseMetaData`." + {:arglists '([driver ^java.sql.Connection connection])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) + +(defmulti excluded-schemas + "Return set of string names of schemas to skip syncing tables from." + {:arglists '([driver])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) + +(defmulti have-select-privilege? + "Check if we have SELECT privileges for given `table`." + {:arglists '([driver ^java.sql.Connection connection ^String table-schema ^String table-name])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) + +(defmulti database-type->base-type + "Given a native DB column type (as a keyword), return the corresponding `Field` `base-type`, which should derive from + `:type/*`. You can use `pattern-based-database-type->base-type` in this namespace to implement this using regex + patterns." + {:arglists '([driver database-type])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) + +(defmulti column->special-type + "Attempt to determine the special-type of a field given the column name and native type. For example, the Postgres + driver can mark Postgres JSON type columns as `:type/SerializedJSON` special type. + + `database-type` and `column-name` will be strings." + {:arglists '([driver database-type column-name])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) + +(defmulti fallback-metadata-query + "SELECT columns from a given table so we can get column metadata. By default doesn't return any rows. This can be + overriden because SQLite is silly and only returns column information for views if the query returns a non-zero + number of rows. + + (fallback-metadata-query :postgres \"public\" \"my_table\") + ;; -> [\"SELECT * FROM public.my_table WHERE 1 <> 1 LIMIT 0\"]" + {:arglists '([driver schema table])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) diff --git a/src/metabase/plugins/jdbc_proxy.clj b/src/metabase/plugins/jdbc_proxy.clj index a04db1d7a8c5a4e256c4a927bb0f542d5c322f60..852b2d96771c6f7fd8082efa91c53ef8e84eccd9 100644 --- a/src/metabase/plugins/jdbc_proxy.clj +++ b/src/metabase/plugins/jdbc_proxy.clj @@ -11,6 +11,7 @@ ;;; -------------------------------------------------- Proxy Driver -------------------------------------------------- +;; TODO -- why not use `java.sql.Wrapper` here instead of defining a new protocol that basically does the same thing? (p.types/defprotocol+ ^:private ProxyDriver (wrapped-driver [this] "Get the JDBC driver wrapped by a Metabase JDBC proxy driver.")) @@ -42,17 +43,28 @@ (.jdbcCompliant driver)))) (defn create-and-register-proxy-driver! - "Create a new JDBC proxy driver to wrap driver with `class-name`. Registers the driver with JDBC, and deregisters the - class it wraps if that class is already registered." + "Create a new JDBC proxy driver to wrap driver with `class-name`, but only if that class WAS NOT loaded by the System + ClassLoader. Registers the driver with JDBC, and deregisters the class it wraps if that class is already + registered. + + This is necessary because the DriverManager will not recognize any drivers that are *NOT* loaded by the System + ClassLoader." [^String class-name] - (let [klass (Class/forName class-name true (classloader/the-classloader)) - driver (proxy-driver (.newInstance klass))] - (log/debug (u/format-color 'blue (trs "Registering JDBC proxy driver for {0}..." klass))) - (DriverManager/registerDriver driver) - - ;; deregister the non-proxy version of the driver so it doesn't try to handle our URLs. Most JDBC drivers register - ;; themseleves when the classes are loaded - (doseq [driver (enumeration-seq (DriverManager/getDrivers)) - :when (instance? klass driver)] - (log/debug (u/format-color 'cyan (trs "Deregistering original JDBC driver {0}..." driver))) - (DriverManager/deregisterDriver driver)))) + (let [klass (Class/forName class-name true (classloader/the-classloader)) + loaded-by-system-classloader? (identical? (.getClassLoader klass) (ClassLoader/getSystemClassLoader))] + ;; if the System ClassLoader loaded this class, don't create the proxy driver, because that can break things in + ;; some situations -- Oracle for example doesn't seem to behave properly when you do this. This mainly affects dev + ;; which merges driver dependencies into the core project deps. + (if loaded-by-system-classloader? + (log/debug (u/format-color 'cyan (trs "Not creating proxy JDBC driver for class {0} -- original driver was loaded by system ClassLoader" + class-name))) + (let [driver (proxy-driver (.newInstance klass))] + (log/debug (u/format-color 'blue (trs "Registering JDBC proxy driver for {0}..." class-name))) + (DriverManager/registerDriver driver) + + ;; deregister the non-proxy version of the driver so it doesn't try to handle our URLs. Most JDBC drivers register + ;; themseleves when the classes are loaded + (doseq [driver (enumeration-seq (DriverManager/getDrivers)) + :when (instance? klass driver)] + (log/debug (u/format-color 'cyan (trs "Deregistering original JDBC driver {0}..." driver))) + (DriverManager/deregisterDriver driver)))))) diff --git a/src/metabase/sync/interface.clj b/src/metabase/sync/interface.clj index b280d076dc0eaf3744c44fc5dc9e7335a2bc89df..41e5b04000e67e3c3e4612a156e57dbdf76b3f40 100644 --- a/src/metabase/sync/interface.clj +++ b/src/metabase/sync/interface.clj @@ -20,7 +20,6 @@ "Schema for the expected output of `describe-database`." {:tables #{DatabaseMetadataTable}}) - (def TableMetadataField "Schema for a given Field as provided in `describe-table`." {:name su/NonBlankString diff --git a/src/metabase/sync/sync_metadata/fields/fetch_metadata.clj b/src/metabase/sync/sync_metadata/fields/fetch_metadata.clj index 622eb2af33b778d4de895a43c4a35fe5aa25402d..81104e479d8a45efc65f07570e747047486bb5e8 100644 --- a/src/metabase/sync/sync_metadata/fields/fetch_metadata.clj +++ b/src/metabase/sync/sync_metadata/fields/fetch_metadata.clj @@ -80,7 +80,7 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (s/defn db-metadata :- #{i/TableMetadataField} - "Fetch metadata about Fields belonging to a given TABLE directly from an external database by calling its - driver's implementation of `describe-table`." - [database :- i/DatabaseInstance, table :- i/TableInstance] + "Fetch metadata about Fields belonging to a given `table` directly from an external database by calling its driver's + implementation of `describe-table`." + [database :- i/DatabaseInstance table :- i/TableInstance] (:fields (fetch-metadata/table-metadata database table))) diff --git a/src/metabase/util.clj b/src/metabase/util.clj index e9519c8819e282481c2d699e008c7b505ee00320..232e5aa57b95de56ba1726e056c43e673ac40d4f 100644 --- a/src/metabase/util.clj +++ b/src/metabase/util.clj @@ -204,7 +204,7 @@ and [Common Lisp](http://www.lispworks.com/documentation/HyperSpec/Body/m_prog1c.htm#prog1). Style note: Prefer `doto` when appropriate, e.g. when dealing with Java objects." - {:style/indent 1} + {:style/indent :defn} [first-form & body] `(let [~'<> ~first-form] ~@body diff --git a/test/metabase/driver/postgres_test.clj b/test/metabase/driver/postgres_test.clj index b90c6686107117e2fb8da83e515844027ab134ff..843999e48c6960fc01d4ebe83385859beafe3568 100644 --- a/test/metabase/driver/postgres_test.clj +++ b/test/metabase/driver/postgres_test.clj @@ -531,3 +531,23 @@ (mt/rows (mt/run-mbql-query venues {:filter [:= $id "2"]}))))))) + +(deftest dont-sync-tables-with-no-select-permissions-test + (testing "Make sure we only sync databases for which the current user has SELECT permissions" + (mt/test-driver :postgres + (drop-if-exists-and-create-db! "no-select-test") + (let [details (mt/dbdef->connection-details :postgres :db {:database-name "no-select-test"}) + spec (sql-jdbc.conn/connection-details->spec :postgres details)] + (doseq [statement ["CREATE TABLE PUBLIC.table_with_perms (x INTEGER NOT NULL);" + "CREATE TABLE PUBLIC.table_with_no_perms (y INTEGER NOT NULL);" + "DROP USER IF EXISTS no_select_test_user;" + "CREATE USER no_select_test_user WITH PASSWORD '123456';" + "GRANT SELECT ON TABLE \"no-select-test\".PUBLIC.table_with_perms TO no_select_test_user;"]] + (jdbc/execute! spec [statement]))) + (let [test-user-details (assoc (mt/dbdef->connection-details :postgres :db {:database-name "no-select-test"}) + :user "no_select_test_user" + :password "123456")] + (mt/with-temp Database [database {:engine :postgres, :details test-user-details}] + (sync/sync-database! database) + (is (= #{"table_with_perms"} + (db/select-field :name Table :db_id (:id database))))))))) diff --git a/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj b/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..ddb61a59e19bb4ad79676540a8543028ab3d3f25 --- /dev/null +++ b/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj @@ -0,0 +1,106 @@ +(ns metabase.driver.sql-jdbc.sync.describe-database-test + (:require [clojure.java.jdbc :as jdbc] + [clojure.test :refer :all] + [metabase + [driver :as driver] + [query-processor :as qp] + [sync :as sync] + [test :as mt]] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] + [metabase.driver.sql-jdbc.sync + [describe-database :as describe-database] + [interface :as i]] + [metabase.models.table :refer [Table]] + [metabase.test.data.one-off-dbs :as one-off-dbs] + [toucan.db :as db]) + (:import java.sql.ResultSet)) + +(deftest simple-select-probe-query-test + (testing "simple-select-probe-query shouldn't actually return any rows" + (let [{:keys [name schema]} (Table (mt/id :venues))] + (is (= [] + (mt/rows + (qp/process-query + (let [[sql] (describe-database/simple-select-probe-query (or driver/*driver* :h2) schema name)] + (mt/native-query {:query sql}))))))))) + +(defn- sql-jdbc-drivers-with-default-describe-database-impl + "All SQL JDBC drivers that use the default SQL JDBC implementation of `describe-database`. (As far as I know, this is + all of them.)" + [] + (set + (filter + #(identical? (get-method driver/describe-database :sql-jdbc) (get-method driver/describe-database %)) + (descendants driver/hierarchy :sql-jdbc)))) + +(deftest fast-active-tables-test + (let [spec (sql-jdbc.conn/db->pooled-connection-spec (mt/db))] + (with-open [conn (jdbc/get-connection spec)] + ;; We have to mock this to make it work with all DBs + (with-redefs [describe-database/all-schemas (constantly #{"PUBLIC"})] + (is (= ["CATEGORIES" "CHECKINS" "USERS" "VENUES"] + (->> (into [] (describe-database/fast-active-tables (or driver/*driver* :h2) conn)) + (map :name) + sort))))))) + +(deftest post-filtered-active-tables-test + (let [spec (sql-jdbc.conn/db->pooled-connection-spec (mt/db))] + (with-open [conn (jdbc/get-connection spec)] + (is (= ["CATEGORIES" "CHECKINS" "USERS" "VENUES"] + (->> (into [] (describe-database/post-filtered-active-tables :h2 conn)) + (map :name) + sort)))))) + +(deftest describe-database-test + (is (= {:tables #{{:name "USERS", :schema "PUBLIC", :description nil} + {:name "VENUES", :schema "PUBLIC", :description nil} + {:name "CATEGORIES", :schema "PUBLIC", :description nil} + {:name "CHECKINS", :schema "PUBLIC", :description nil}}} + (describe-database/describe-database :h2 (mt/id))))) + +(defn- describe-database-with-open-resultset-count + "Just like `describe-database`, but instead of returning the database description returns the number of ResultSet + objects the sync process left open. Make sure you wrap ResultSets with `with-open`! Otherwise some JDBC drivers like + Oracle and Redshift will keep open cursors indefinitely." + [driver db] + (let [orig-result-set-seq jdbc/result-set-seq + resultsets (atom [])] + ;; swap out `jdbc/result-set-seq` which is what ultimately gets called on result sets with a function that will + ;; stash the ResultSet object in an atom so we can check whether its closed later + (with-redefs [jdbc/result-set-seq (fn [^ResultSet rs & more] + (swap! resultsets conj rs) + (apply orig-result-set-seq rs more))] + ;; taking advantage of the fact that `describe-database/describe-database` can accept JBDC connections instead of + ;; databases; by doing this we can keep the connection open and check whether resultsets are still open before + ;; they would normally get closed + (jdbc/with-db-connection [conn (sql-jdbc.conn/db->pooled-connection-spec db)] + (describe-database/describe-database driver conn) + (reduce + (for [^ResultSet rs @resultsets] + (if (.isClosed rs) 0 1))))))) + +(defn- count-active-tables-in-db + [db-id] + (db/count Table + :db_id db-id + :active true)) + +(deftest sync-only-accessable + (one-off-dbs/with-blank-db + (doseq [statement ["set db_close_delay -1;" + "drop table if exists \"birds\";" + "create table \"birds\" ();"]] + (jdbc/execute! one-off-dbs/*conn* [statement])) + (sync/sync-database! (mt/db)) + (is (= 1 (count-active-tables-in-db (mt/id)))) + ;; We have to mock this as H2 doesn't have the notion of a user connecting to it + (with-redefs [i/have-select-privilege? (constantly false)] + (sync/sync-database! (mt/db)) + (is (= 0 (count-active-tables-in-db (mt/id))) + "We shouldn't sync tables for which we don't have select privilege")))) + +(deftest dont-leak-resultsets-test + (mt/test-drivers (sql-jdbc-drivers-with-default-describe-database-impl) + (testing (str "make sure that running the sync process doesn't leak cursors because it's not closing the ResultSets. " + "See issues #4389, #6028, and #6467 (Oracle) and #7609 (Redshift)") + (is (= 0 + (describe-database-with-open-resultset-count driver/*driver* (mt/db))))))) diff --git a/test/metabase/driver/sql_jdbc/sync/describe_table_test.clj b/test/metabase/driver/sql_jdbc/sync/describe_table_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..53c89279a42689b3a015682fa8444f6bae32b32e --- /dev/null +++ b/test/metabase/driver/sql_jdbc/sync/describe_table_test.clj @@ -0,0 +1,73 @@ +(ns metabase.driver.sql-jdbc.sync.describe-table-test + (:require [clojure + [string :as str] + [test :refer :all]] + [clojure.java.jdbc :as jdbc] + [metabase + [driver :as driver] + [test :as mt]] + [metabase.driver.sql-jdbc.sync + [describe-table :as describe-table] + [interface :as i]] + [metabase.models.table :refer [Table]])) + +(defn- sql-jdbc-drivers-with-default-describe-table-impl + "All SQL JDBC drivers that use the default SQL JDBC implementation of `describe-table`. (As far as I know, this is + all of them.)" + [] + (set + (filter + #(identical? (get-method driver/describe-table :sql-jdbc) (get-method driver/describe-table %)) + (descendants driver/hierarchy :sql-jdbc)))) + +(deftest describe-table-test + (is (= {:name "VENUES", + :fields + #{{:name "ID", :database-type "BIGINT", :base-type :type/BigInteger, :database-position 0, :pk? true} + {:name "NAME", :database-type "VARCHAR", :base-type :type/Text, :database-position 1} + {:name "CATEGORY_ID", :database-type "INTEGER", :base-type :type/Integer, :database-position 2} + {:name "LATITUDE", :database-type "DOUBLE", :base-type :type/Float, :database-position 3} + {:name "LONGITUDE", :database-type "DOUBLE", :base-type :type/Float, :database-position 4} + {:name "PRICE", :database-type "INTEGER", :base-type :type/Integer, :database-position 5}}} + (describe-table/describe-table :h2 (mt/id) {:name "VENUES"})))) + +(deftest describe-table-fks-test + (is (= #{{:fk-column-name "CATEGORY_ID" + :dest-table {:name "CATEGORIES", :schema "PUBLIC"} + :dest-column-name "ID"}} + (describe-table/describe-table-fks :h2 (mt/id) {:name "VENUES"}))) + (is (= #{{:fk-column-name "USER_ID", :dest-table {:name "USERS", :schema "PUBLIC"}, :dest-column-name "ID"} + {:fk-column-name "VENUE_ID", :dest-table {:name "VENUES", :schema "PUBLIC"}, :dest-column-name "ID"}} + (describe-table/describe-table-fks :h2 (mt/id) {:name "CHECKINS"})))) + +(deftest database-types-fallback-test + (mt/test-drivers (sql-jdbc-drivers-with-default-describe-table-impl) + (let [org-result-set-seq jdbc/result-set-seq] + (with-redefs [jdbc/result-set-seq (fn [& args] + (map #(dissoc % :type_name) (apply org-result-set-seq args)))] + (is (= #{{:name "longitude" :base-type :type/Float} + {:name "category_id" :base-type :type/Integer} + {:name "price" :base-type :type/Integer} + {:name "latitude" :base-type :type/Float} + {:name "name" :base-type :type/Text} + {:name "id" :base-type :type/Integer}} + (->> (describe-table/describe-table driver/*driver* (mt/id) (Table (mt/id :venues))) + :fields + (map (fn [{:keys [name base-type]}] + {:name (str/lower-case name) + :base-type (if (or (isa? base-type :type/Integer) + (isa? base-type :type/Decimal)) ; H2 DBs returns the ID as BigInt, Oracle as Decimal; + :type/Integer + base-type)})) + set))))))) + +(deftest calculated-special-type-test + (mt/test-drivers (sql-jdbc-drivers-with-default-describe-table-impl) + (with-redefs [i/column->special-type (fn [_ _ column-name] + (when (= (str/lower-case column-name) "longitude") + :type/Longitude))] + (is (= [["longitude" :type/Longitude]] + (->> (describe-table/describe-table (or driver/*driver* :h2) (mt/id) (Table (mt/id :venues))) + :fields + (filter :special-type) + (map (juxt (comp str/lower-case :name) :special-type)))))))) diff --git a/test/metabase/driver/sql_jdbc/sync_test.clj b/test/metabase/driver/sql_jdbc/sync_test.clj deleted file mode 100644 index 78aa8dd2f699d7fec00f8eb115b66035cddc82a9..0000000000000000000000000000000000000000 --- a/test/metabase/driver/sql_jdbc/sync_test.clj +++ /dev/null @@ -1,131 +0,0 @@ -(ns metabase.driver.sql-jdbc.sync-test - (:require [clojure - [string :as str] - [test :refer :all]] - [clojure.java.jdbc :as jdbc] - [metabase - [driver :as driver] - [query-processor :as qp] - [sync :as sync] - [test :as mt]] - [metabase.driver.sql-jdbc - [connection :as sql-jdbc.conn] - [sync :as sql-jdbc.sync]] - [metabase.models.table :refer [Table]] - [metabase.test.data.one-off-dbs :as one-off-dbs] - [toucan.db :as db]) - (:import java.sql.ResultSet)) - -(defn- sql-jdbc-drivers-with-default-describe-database-impl - "All SQL JDBC drivers that use the default SQL JDBC implementation of `describe-database`. (As far as I know, this is - all of them.)" - [] - (set - (filter - #(identical? (get-method driver/describe-database :sql-jdbc) (get-method driver/describe-database %)) - (descendants driver/hierarchy :sql-jdbc)))) - -(defn- sql-jdbc-drivers-with-default-describe-table-impl - "All SQL JDBC drivers that use the default SQL JDBC implementation of `describe-table`. (As far as I know, this is - all of them.)" - [] - (set - (filter - #(identical? (get-method driver/describe-table :sql-jdbc) (get-method driver/describe-table %)) - (descendants driver/hierarchy :sql-jdbc)))) - -(defn- describe-database-with-open-resultset-count - "Just like `describe-database`, but instead of returning the database description returns the number of ResultSet - objects the sync process left open. Make sure you wrap ResultSets with `with-open`! Otherwise some JDBC drivers like - Oracle and Redshift will keep open cursors indefinitely." - [driver db] - (let [orig-result-set-seq jdbc/result-set-seq - resultsets (atom [])] - ;; swap out `jdbc/result-set-seq` which is what ultimately gets called on result sets with a function that will - ;; stash the ResultSet object in an atom so we can check whether its closed later - (with-redefs [jdbc/result-set-seq (fn [^ResultSet rs & more] - (swap! resultsets conj rs) - (apply orig-result-set-seq rs more))] - ;; taking advantage of the fact that `sql-jdbc.sync/describe-database` can accept JBDC connections instead of - ;; databases; by doing this we can keep the connection open and check whether resultsets are still open before - ;; they would normally get closed - (jdbc/with-db-connection [conn (sql-jdbc.conn/db->pooled-connection-spec db)] - (sql-jdbc.sync/describe-database driver conn) - (reduce + (for [^ResultSet rs @resultsets] - (if (.isClosed rs) 0 1))))))) - -(defn- count-active-tables-in-db - [db-id] - (db/count Table - :db_id db-id - :active true)) - -(deftest sync-only-accessable - (one-off-dbs/with-blank-db - (doseq [statement ["set db_close_delay -1;" - "drop table if exists \"birds\";" - "create table \"birds\" ();"]] - (jdbc/execute! one-off-dbs/*conn* [statement])) - (sync/sync-database! (mt/db)) - (is (= 1 (count-active-tables-in-db (mt/id)))) - ;; We have to mock this as H2 doesn't have the notion of a user connecting to it - (with-redefs [sql-jdbc.sync/have-select-privilege? (constantly false)] - (sync/sync-database! (mt/db)) - (is (= 0 (count-active-tables-in-db (mt/id))) - "We shouldn't sync tables for which we don't have select privilege")))) - -(deftest dont-leak-resultsets-test - (mt/test-drivers (sql-jdbc-drivers-with-default-describe-database-impl) - (testing (str "make sure that running the sync process doesn't leak cursors because it's not closing the ResultSets. " - "See issues #4389, #6028, and #6467 (Oracle) and #7609 (Redshift)") - (is (= 0 - (describe-database-with-open-resultset-count driver/*driver* (mt/db))))))) - -(deftest simple-select-probe-test - (let [{:keys [name schema]} (Table (mt/id :venues))] - (is (= [[1 "Red Medicine" 4 10.0646 -165.374 3]] - (mt/rows - (qp/process-query - (mt/native-query {:query (first (#'sql-jdbc.sync/simple-select-probe (or driver/*driver* :h2) schema name))}))))))) - -(deftest database-types-fallback-test - (mt/test-drivers (sql-jdbc-drivers-with-default-describe-table-impl) - (let [org-result-set-seq jdbc/result-set-seq] - (with-redefs [jdbc/result-set-seq (fn [& args] - (map #(dissoc % :type_name) (apply org-result-set-seq args)))] - (is (= #{{:name "longitude" :base-type :type/Float} - {:name "category_id" :base-type :type/Integer} - {:name "price" :base-type :type/Integer} - {:name "latitude" :base-type :type/Float} - {:name "name" :base-type :type/Text} - {:name "id" :base-type :type/Integer}} - (->> (sql-jdbc.sync/describe-table driver/*driver* (mt/id) (Table (mt/id :venues))) - :fields - (map (fn [{:keys [name base-type]}] - {:name (str/lower-case name) - :base-type (if (or (isa? base-type :type/Integer) - (isa? base-type :type/Decimal)) ; H2 DBs returns the ID as BigInt, Oracle as Decimal; - :type/Integer - base-type)})) - set))))))) - -(deftest calculated-special-type-test - (mt/test-drivers (sql-jdbc-drivers-with-default-describe-table-impl) - (with-redefs [sql-jdbc.sync/column->special-type (fn [_ _ column-name] - (when (= (str/lower-case column-name) "longitude") - :type/Longitude))] - (is (= [["longitude" :type/Longitude]] - (->> (sql-jdbc.sync/describe-table (or driver/*driver* :h2) (mt/id) (Table (mt/id :venues))) - :fields - (filter :special-type) - (map (juxt (comp str/lower-case :name) :special-type)))))))) - -(deftest fast-active-tables-test - (jdbc/with-db-metadata [metadata (sql-jdbc.conn/db->pooled-connection-spec (mt/db))] - ;; We have to mock this to make it work with all DBs - (with-redefs [sql-jdbc.sync/all-schemas (constantly #{"PUBLIC"})] - (is (= ["CATEGORIES" "CHECKINS" "USERS" "VENUES"] - (->> metadata - (sql-jdbc.sync/fast-active-tables (or driver/*driver* :h2) (mt/db)) - (map :table_name) - sort)))))) diff --git a/test/metabase/sync/sync_metadata/comments_test.clj b/test/metabase/sync/sync_metadata/comments_test.clj index 9372ba69057e3997e72b848e84d5b563c03d8809..e36fdaa76402b485e8098a570871b05d6e0e9ab1 100644 --- a/test/metabase/sync/sync_metadata/comments_test.clj +++ b/test/metabase/sync/sync_metadata/comments_test.clj @@ -1,85 +1,83 @@ (ns metabase.sync.sync-metadata.comments-test "Test for the logic that syncs Table column descriptions with the comments fetched from a DB." - (:require [metabase + (:require [clojure.test :refer :all] + [metabase [driver :as driver] [sync :as sync] + [test :as mt] [util :as u]] [metabase.models [field :refer [Field]] [table :refer [Table]]] [metabase.sync.sync-metadata.tables :as sync-tables] - [metabase.test.data :as data] - [metabase.test.data - [datasets :as datasets] - [interface :as tx]] + [metabase.test.data.interface :as tx] [toucan.db :as db])) -;; Tests for field comments: ------------------ - (defn- db->fields [db] - (let [table-ids (db/select-ids 'Table :db_id (u/get-id db))] + (let [table-ids (db/select-ids Table :db_id (u/get-id db))] (set (map (partial into {}) (db/select ['Field :name :description] :table_id [:in table-ids]))))) -;; test basic field comments sync (tx/defdataset ^:private basic-field-comments [["basic_field_comments" [{:field-name "with_comment", :base-type :type/Text, :field-comment "comment"} {:field-name "no_comment", :base-type :type/Text}] [["foo" "bar"]]]]) -(datasets/expect-with-drivers #{:h2 :postgres} - #{{:name (data/format-name "id"), :description nil} - {:name (data/format-name "with_comment"), :description "comment"} - {:name (data/format-name "no_comment"), :description nil}} - (data/dataset basic-field-comments - (db->fields (data/db)))) +(deftest basic-field-comments-test + (testing "test basic field comments sync" + (mt/test-drivers #{:h2 :postgres} + (mt/dataset basic-field-comments + (is (= #{{:name (mt/format-name "id"), :description nil} + {:name (mt/format-name "with_comment"), :description "comment"} + {:name (mt/format-name "no_comment"), :description nil}} + (db->fields (mt/db)))))))) -;; test changing the description in metabase db so we can check it is not overwritten by comment in source db when resyncing (tx/defdataset ^:private update-desc [["update_desc" [{:field-name "updated_desc", :base-type :type/Text, :field-comment "original comment"}] [["foo"]]]]) -(datasets/expect-with-drivers #{:h2 :postgres} - #{{:name (data/format-name "id"), :description nil} - {:name (data/format-name "updated_desc"), :description "updated description"}} - (data/dataset update-desc - (data/with-temp-copy-of-db - ;; change the description in metabase while the source table comment remains the same - (db/update-where! Field {:id (data/id "update_desc" "updated_desc")}, :description "updated description") - ;; now sync the DB again, this should NOT overwrite the manually updated description - (sync/sync-table! (Table (data/id "update_desc"))) - (db->fields (data/db))))) +(deftest comment-should-not-overwrite-custom-description-test + (testing (str "test changing the description in metabase db so we can check it is not overwritten by comment in " + "source db when resyncing")) + (mt/test-drivers #{:h2 :postgres} + (mt/dataset update-desc + (mt/with-temp-copy-of-db + ;; change the description in metabase while the source table comment remains the same + (db/update-where! Field {:id (mt/id "update_desc" "updated_desc")}, :description "updated description") + ;; now sync the DB again, this should NOT overwrite the manually updated description + (sync/sync-table! (Table (mt/id "update_desc"))) + (is (= #{{:name (mt/format-name "id"), :description nil} + {:name (mt/format-name "updated_desc"), :description "updated description"}} + (db->fields (mt/db)))))))) -;; test adding a comment to the source data that was initially empty, so we can check that the resync picks it up (tx/defdataset ^:private comment-after-sync [["comment_after_sync" [{:field-name "comment_after_sync", :base-type :type/Text}] [["foo"]]]]) -(datasets/expect-with-drivers #{:h2 :postgres} - #{{:name (data/format-name "id"), :description nil} - {:name (data/format-name "comment_after_sync"), :description "added comment"}} - (data/dataset comment-after-sync - ;; modify the source DB to add the comment and resync. The easiest way to do this is just destroy the entire DB - ;; and re-create a modified version. As such, let the SQL JDBC driver know the DB is being "modified" so it can - ;; destroy its current connection pool - (driver/notify-database-updated driver/*driver* (data/db)) - (let [modified-dbdef (update - comment-after-sync - :table-definitions - (fn [[tabledef]] - [(update - tabledef - :field-definitions - (fn [[fielddef]] - [(assoc fielddef :field-comment "added comment")]))]))] - (tx/create-db! driver/*driver* modified-dbdef)) - (sync/sync-table! (Table (data/id "comment_after_sync"))) - (db->fields (data/db)))) - - -;; Tests for table comments: ------------------ +(deftest sync-comment-on-existing-field-test + (testing "test adding a comment to the source data that was initially empty, so we can check that the resync picks it up" + (mt/test-drivers #{:h2 :postgres} + (mt/dataset comment-after-sync + ;; modify the source DB to add the comment and resync. The easiest way to do this is just destroy the entire DB + ;; and re-create a modified version. As such, let the SQL JDBC driver know the DB is being "modified" so it can + ;; destroy its current connection pool + (driver/notify-database-updated driver/*driver* (mt/db)) + (let [modified-dbdef (update + comment-after-sync + :table-definitions + (fn [[tabledef]] + [(update + tabledef + :field-definitions + (fn [[fielddef]] + [(assoc fielddef :field-comment "added comment")]))]))] + (tx/create-db! driver/*driver* modified-dbdef)) + (sync/sync-table! (Table (mt/id "comment_after_sync"))) + (is (= #{{:name (mt/format-name "id"), :description nil} + {:name (mt/format-name "comment_after_sync"), :description "added comment"}} + (db->fields (mt/db)))))))) (defn- basic-table [table-name comment] (tx/map->DatabaseDefinition {:database-name (str table-name "_db") @@ -89,32 +87,35 @@ :table-comment comment}]})) (defn- db->tables [db] - (set (map (partial into {}) (db/select ['Table :name :description] :db_id (u/get-id db))))) + (set (map (partial into {}) (db/select [Table :name :description] :db_id (u/get-id db))))) -;; test basic comments on table -(datasets/expect-with-drivers #{:h2 :postgres} - #{{:name (data/format-name "table_with_comment"), :description "table comment"}} - (data/dataset (basic-table "table_with_comment" "table comment") - (db->tables (data/db)))) +(deftest table-comments-test + (testing "test basic comments on table" + (mt/test-drivers #{:h2 :postgres} + (mt/dataset (basic-table "table_with_comment" "table comment") + (is (= #{{:name (mt/format-name "table_with_comment"), :description "table comment"}} + (db->tables (mt/db)))))))) -;; test changing the description in metabase on table to check it is not overwritten by comment in source db when -;; resyncing -(datasets/expect-with-drivers #{:h2 :postgres} - #{{:name (data/format-name "table_with_updated_desc"), :description "updated table description"}} - (data/dataset (basic-table "table_with_updated_desc" "table comment") - (data/with-temp-copy-of-db - ;; change the description in metabase while the source table comment remains the same - (db/update-where! Table {:id (data/id "table_with_updated_desc")}, :description "updated table description") - ;; now sync the DB again, this should NOT overwrite the manually updated description - (sync-tables/sync-tables! (data/db)) - (db->tables (data/db))))) +(deftest dont-overwrite-table-custom-description-test + (testing (str "test changing the description in metabase on table to check it is not overwritten by comment in " + "source db when resyncing") + (mt/test-drivers #{:h2 :postgres} + (mt/dataset (basic-table "table_with_updated_desc" "table comment") + (mt/with-temp-copy-of-db + ;; change the description in metabase while the source table comment remains the same + (db/update-where! Table {:id (mt/id "table_with_updated_desc")}, :description "updated table description") + ;; now sync the DB again, this should NOT overwrite the manually updated description + (sync-tables/sync-tables! (mt/db)) + (is (= #{{:name (mt/format-name "table_with_updated_desc"), :description "updated table description"}} + (db->tables (mt/db))))))))) -;; test adding a comment to the source table that was initially empty, so we can check that the resync picks it up -(datasets/expect-with-drivers #{:h2 :postgres} - #{{:name (data/format-name "table_with_comment_after_sync"), :description "added comment"}} - (data/dataset (basic-table "table_with_comment_after_sync" nil) - ;; modify the source DB to add the comment and resync - (driver/notify-database-updated driver/*driver* (data/db)) - (tx/create-db! driver/*driver* (basic-table "table_with_comment_after_sync" "added comment")) - (sync-tables/sync-tables! (data/db)) - (db->tables (data/db)))) +(deftest sync-existing-table-comment-test + (testing "test adding a comment to the source table that was initially empty, so we can check that the resync picks it up" + (mt/test-drivers #{:h2 :postgres} + (mt/dataset (basic-table "table_with_comment_after_sync" nil) + ;; modify the source DB to add the comment and resync + (driver/notify-database-updated driver/*driver* (mt/db)) + (tx/create-db! driver/*driver* (basic-table "table_with_comment_after_sync" "added comment")) + (sync-tables/sync-tables! (mt/db)) + (is (= #{{:name (mt/format-name "table_with_comment_after_sync"), :description "added comment"}} + (db->tables (mt/db)))))))) diff --git a/test/metabase/test/data/impl/verify.clj b/test/metabase/test/data/impl/verify.clj index 298ce8c23c31e5116efb8f1fad57f1cf89a13edb..9323fdda51d1e0a9f6e5c0f5e49ff2ce9516e5fe 100644 --- a/test/metabase/test/data/impl/verify.clj +++ b/test/metabase/test/data/impl/verify.clj @@ -27,7 +27,7 @@ (defn- loaded-fields "Actual Fields loaded into a Table. Returns set of field names." [{:keys [driver database actual-schema actual-table-name]}] - (->> (driver/describe-table driver database {:schema actual-schema, :name actual-table-name}) + (->> (driver/describe-table driver database {:schema actual-schema, :name actual-table-name, :db_id (:id database)}) :fields (map :name) set))