diff --git a/src/metabase/api/qs.clj b/src/metabase/api/qs.clj index bfed2e0147d67d25c8f1a067d2b984fe45152b03..67a338a80326de6a7cdd48f82b0424ab1ccc53a9 100644 --- a/src/metabase/api/qs.clj +++ b/src/metabase/api/qs.clj @@ -10,8 +10,6 @@ [metabase.util :refer [contains-many? now-iso8601]])) -(declare execute-query) - (defendpoint POST "/" [:as {{:keys [timezone database sql] :as body} :body}] {database [Required Integer] sql [Required NonEmptyString]} ; TODO - check timezone diff --git a/src/metabase/api/query.clj b/src/metabase/api/query.clj index 946109a13b15ecf138a7190a074784b691aa759a..44e0c12e2ac0d769247b7ab494599b2520ed2eeb 100644 --- a/src/metabase/api/query.clj +++ b/src/metabase/api/query.clj @@ -108,7 +108,7 @@ (let-404 [{database-id :database_id {:keys [sql timezone]} :details :as query} (sel :one Query :id id)] (read-check query) - (let [dataset-query {:type "native" + (let [dataset-query {:type :native :database database-id :native {:query sql :timezone timezone}} diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index 8918540b6a54a99a3e85c994318f99db99f7cc91..0b84d9120ad1f48267eb7ad4d68847f84d729864 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -69,19 +69,20 @@ (defn dataset-query "Process and run a json based dataset query and return results. - Takes 2 arguments: - 1. the json query as a dictionary - 2. query execution options specified in a dictionary - - Depending on the database specified in the query this function will delegate to a driver specific implementation. - For the purposes of tracking we record each call to this function as a QueryExecution in the database. - - Possible caller-options include: - :executed_by [int] (user_id of caller) - :saved_query [{}] (dictionary representing Query model) - :synchronously [true|false] (default true) - :cache_result [true|false] (default false) - " + Takes 2 arguments: + + 1. the json query as a dictionary + 2. query execution options specified in a dictionary + + Depending on the database specified in the query this function will delegate to a driver specific implementation. + For the purposes of tracking we record each call to this function as a QueryExecution in the database. + + Possible caller-options include: + + :executed_by [int] (user_id of caller) + :saved_query [{}] (dictionary representing Query model) + :synchronously [true|false] (default true) + :cache_result [true|false] (default false)" {:arglists '([query caller-options])} [query {:keys [executed_by synchronously saved_query] :or {synchronously true} @@ -115,16 +116,17 @@ ;; this ensures the currently saved query-execution is what gets returned query-execution)))) - (defn -dataset-query "Execute a query and record the outcome. Entire execution is wrapped in a try-catch to prevent Exceptions - from leaking outside the function call." + from leaking outside the function call." [query options query-execution] (let [query-execution (assoc query-execution :start_time_millis (System/currentTimeMillis))] (try (let [query-result (execute-query query)] - (when-not (contains? query-result :status) (throw (Exception. "invalid response from database driver. no :status provided"))) - (when (= :failed (:status query-result)) (throw (Exception. ^String (get query-result :error "general error")))) + (when-not (contains? query-result :status) + (throw (Exception. "invalid response from database driver. no :status provided"))) + (when (= :failed (:status query-result)) + (throw (Exception. ^String (get query-result :error "general error")))) (query-complete query-execution query-result (:cache_result options))) (catch Exception ex (log/warn ex) @@ -140,14 +142,14 @@ :running_time (- (System/currentTimeMillis) (:start_time_millis query-execution))}] ;; record our query execution and format response (-> query-execution - (dissoc :start_time_millis) - (merge updates) - (save-query-execution) - ;; this is just for the response for clien - (assoc :row_count 0 - :data {:rows [] - :cols [] - :columns []})))) + (dissoc :start_time_millis) + (merge updates) + (save-query-execution) + ;; this is just for the response for clien + (assoc :row_count 0 + :data {:rows [] + :cols [] + :columns []})))) (defn query-complete "Save QueryExecution state and construct a completed (successful) query response" diff --git a/src/metabase/driver/generic_sql/native.clj b/src/metabase/driver/generic_sql/native.clj index 50ba2b1706980698813f60c292954ec61fd4d908..70f762ce1046f263cd6c9eeb8e2abec08e600ba0 100644 --- a/src/metabase/driver/generic_sql/native.clj +++ b/src/metabase/driver/generic_sql/native.clj @@ -1,8 +1,10 @@ (ns metabase.driver.generic-sql.native "The `native` query processor." (:import com.metabase.corvus.api.ApiException) - (:require [clojure.tools.logging :as log] + (:require [clojure.java.jdbc :as jdbc] + [clojure.tools.logging :as log] [korma.core :as korma] + korma.db [metabase.db :refer [sel]] [metabase.driver.generic-sql.util :refer :all] [metabase.models.database :refer [Database]])) @@ -23,23 +25,28 @@ (or (class->base-type (type v)) (throw (ApiException. (int 500) (format "Missing base type mapping for %s in metabase.driver.native/class->base-type. Please add an entry." (str (type v)))))))) - -(defn- get-cols [row] - (->> row - (map (fn [[k v]] - {:name k - :base_type (value->base-type v)})))) - - -(defn process-and-run [{:keys [native database] :as query}] +(defn process-and-run + "Process and run a native (raw SQL) QUERY." + {:arglists '([query])} + [{{sql :query} :native + database-id :database :as query}] + {:pre [(string? sql) + (integer? database-id)]} (log/debug "QUERY: " query) - (let [db (sel :one Database :id database) - sql (:query native) - ;; this is where we actually run things - ;; TODO - exception catching - results (korma/exec-raw (korma-db db) sql :results)] - {:status :completed - :row_count (count results) - :data {:rows (map vals results) - :columns (keys (first results)) - :cols (get-cols (first results))}})) + (try (let [db (-> (sel :one Database :id database-id) + korma-db + korma.db/get-connection) + [columns & [first-row :as rows]] (jdbc/query db sql :as-arrays? true)] + {:status :completed + :row_count (count rows) + :data {:rows rows + :columns columns + :cols (map (fn [column first-value] + {:name column + :base_type (value->base-type first-value)}) + columns first-row)}}) + (catch java.sql.SQLException e + {:status :failed + :error (->> (.getMessage e) ; error message comes back like 'Column "ZID" not found; SQL statement: ... [error-code] + (re-find #"^(.*);") ; the user already knows the SQL, and error code is meaningless + second)}))) ; so just return the part of the exception that is relevant diff --git a/test/metabase/driver/generic_sql/native_test.clj b/test/metabase/driver/generic_sql/native_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..f3de50e1425106907ff604a4cf8e59af7cd1e4b1 --- /dev/null +++ b/test/metabase/driver/generic_sql/native_test.clj @@ -0,0 +1,34 @@ +(ns metabase.driver.generic-sql.native-test + (:require [expectations :refer :all] + [metabase.driver.generic-sql.native :refer :all] + [metabase.test-data :refer :all] + metabase.test-setup)) + +;; Just check that a basic query works +(expect {:status :completed + :row_count 2 + :data {:rows [[100] + [99]] + :columns [:id] + :cols [{:name :id, :base_type :IntegerField}]}} + (process-and-run {:native {:query "SELECT ID FROM VENUES ORDER BY ID DESC LIMIT 2;"} + :database @db-id})) + +;; Check that column ordering is maintained +(expect + {:status :completed + :row_count 2 + :data {:rows [[100 "Mohawk Bend" 46] + [99 "Golden Road Brewing" 10]] + :columns [:id :name :category_id] + :cols [{:name :id, :base_type :IntegerField} + {:name :name, :base_type :TextField} + {:name :category_id, :base_type :IntegerField}]}} + (process-and-run {:native {:query "SELECT ID, NAME, CATEGORY_ID FROM VENUES ORDER BY ID DESC LIMIT 2;"} + :database @db-id})) + +;; Check that we get proper error responses for malformed SQL +(expect {:status :failed + :error "Column \"ZID\" not found"} + (process-and-run {:native {:query "SELECT ZID FROM CHECKINS LIMIT 2;"} + :database @db-id}))