diff --git a/project.clj b/project.clj index 9027b350a1040de533a172ec90ae07b832451a1d..39d788309d2ebc445469620d2b6573f71e0aa8e2 100644 --- a/project.clj +++ b/project.clj @@ -48,7 +48,7 @@ [org.clojure/math.combinatorics "0.1.4"] ; combinatorics functions [org.clojure/math.numeric-tower "0.0.4"] ; math functions like `ceil` [org.clojure/tools.logging "1.1.0"] ; logging framework - [org.clojure/tools.namespace "0.2.11"] + [org.clojure/tools.namespace "1.0.0"] [org.clojure/tools.trace "0.7.10"] ; function tracing [amalloy/ring-buffer "1.2.2" :exclusions [org.clojure/clojure @@ -369,8 +369,8 @@ :cloverage [:test-common - {:dependencies [[cloverage "1.2.0" :exclusions [riddley]]] - :plugins [[lein-cloverage "1.2.0"]] + {:dependencies [[camsaul/cloverage "1.2.1.1" :exclusions [riddley]]] + :plugins [[camsaul/lein-cloverage "1.2.1.1"]] :source-paths ^:replace ["src" "backend/mbql/src"] :test-paths ^:replace ["test" "backend/mbql/test"] :cloverage {:fail-threshold 69 diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index ae4fd4d1dcd920c60bceecba5aa200d60b5fb73f..58e5ece6a5c29b500ac7de5f714dd42aa1a36826 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -611,8 +611,9 @@ (qp/process-query-and-save-execution! query info context)))}}] {:pre [(u/maybe? sequential? parameters)]} (let [card (api/read-check (Card card-id)) - query (assoc (query-for-card card parameters constraints middleware) - :async? true) + query (-> (assoc (query-for-card card parameters constraints middleware) + :async? true) + (assoc-in [:middleware :js-int-to-string?] true)) info {:executed-by api/*current-user-id* :context context :card-id card-id diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index fcce07f5322dcd8b7154fee107c8fb624d895631..67bd74d11796ef8115cad075982b88ed088eccb2 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -49,7 +49,8 @@ info {:executed-by api/*current-user-id* :context :ad-hoc :card-id source-card-id - :nested? (boolean source-card-id)}] + :nested? (boolean source-card-id)} + query (update query :middleware assoc :js-int-to-string? true)] (qp.streaming/streaming-response [context :api] (qp/process-query-and-save-with-max-results-constraints! query info context)))) diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index ce46e5afe24a80af341b980c9118bc444df2c37a..12204ff12eb3d4103ea18cbb6cea73fc5733dfbb 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -29,6 +29,7 @@ [expand-macros :as expand-macros] [fetch-source-query :as fetch-source-query] [format-rows :as format-rows] + [large-int-id :as large-int-id] [limit :as limit] [mbql-to-native :as mbql-to-native] [normalize-query :as normalize] @@ -68,6 +69,7 @@ #'cumulative-ags/handle-cumulative-aggregations #'resolve-joins/resolve-joins #'add-implicit-joins/add-implicit-joins + #'large-int-id/convert-id-to-string #'limit/limit #'format-rows/format-rows #'desugar/desugar diff --git a/src/metabase/query_processor/middleware/large_int_id.clj b/src/metabase/query_processor/middleware/large_int_id.clj new file mode 100644 index 0000000000000000000000000000000000000000..116cf11352e0bdd3f99ff66f0a22e10f766d63e1 --- /dev/null +++ b/src/metabase/query_processor/middleware/large_int_id.clj @@ -0,0 +1,48 @@ +(ns metabase.query-processor.middleware.large-int-id + "Middleware for handling conversion of IDs to strings for proper display of large numbers" + (:require [clojure.tools.logging :as log] + [metabase.mbql.util :as mbql.u] + [metabase.models.field :refer [Field]])) + +(defn- result-int->string + [field-indexes rf] + (fn + ([] (rf)) + ([result] (rf result)) + ([result row] + (rf result (reduce #(update-in %1 [%2] str) row field-indexes))))) + +(defn convert-id-to-string + "Converts any ID (:type/PK and :type/FK) in a result to a string to handle a number > 2^51 + or < -2^51, the JavaScript float mantissa. This will allow proper display of large numbers, + like IDs from services like social media. All ID numbers are converted to avoid the performance + penalty of a comparison based on size." + [qp] + (fn [{{:keys [js-int-to-string?] :or {js-int-to-string? false}} :middleware, :as query} rff context] + ;; currently, this excludes `:field-literal` values like aggregations. + ;; + ;; for a query like below, *no* conversion will occur + ;; (mt/mbql-query venues + ;; {:source-query {:source-table $$venues + ;; :aggregation [[:aggregation-options + ;; [:avg $id] + ;; {:name "some_generated_name", :display-name "My Cool Ag"}]] + ;; :breakout [$price]}}) + ;; when you run in this fashion, you lose the ability to determine if it's an ID - you get a `:fields` value + ;; like: `:fields [[:field-literal "PRICE" :type/Integer] [:field-literal "some_generated_name" :type/BigInteger]]` + ;; so, short of turning all `:type/Integer` derived values into strings, this is the best approximation + ;; of a fix that can be accomplished. + (let [field-indexes (keep-indexed + (fn [idx val] + (let [field-id (mbql.u/field-clause->id-or-literal val)] + (when-let [field (when (number? field-id) + (Field field-id))] + (when (and (or (isa? (:special_type field) :type/PK) + (isa? (:special_type field) :type/FK)) + (isa? (:base_type field) :type/Integer)) + idx)))) + (log/spy :error (:fields (:query query))))] + (qp query (if (and js-int-to-string? (seq field-indexes)) + #(result-int->string field-indexes (rff %)) + rff) + context)))) diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index 881c5fd7a96259848171e5261330d23a231e79f6..651fe21945e2b533dacd9a845bc3e52ac8e91d38 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -51,7 +51,8 @@ (defn- most-recent-query-execution [] (db/select-one QueryExecution {:order-by [[:id :desc]]})) (def ^:private query-defaults - {:middleware {:add-default-userland-constraints? true}}) + {:middleware {:add-default-userland-constraints? true + :js-int-to-string? true}}) (deftest basic-query-test (testing "POST /api/dataset" diff --git a/test/metabase/query_processor/middleware/large_int_id_test.clj b/test/metabase/query_processor/middleware/large_int_id_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..671c6d80e05f1f333fd7a3868bbef295a010ccee --- /dev/null +++ b/test/metabase/query_processor/middleware/large_int_id_test.clj @@ -0,0 +1,97 @@ +(ns metabase.query-processor.middleware.large-int-id-test + (:require [clojure.test :refer :all] + [metabase + [query-processor :as qp] + [test :as mt]])) + +(deftest convert-ids + (let [query (mt/mbql-query users + {:order-by [[:asc $id]] + :limit 5})] + (testing "PKs become strings when middleware enabled" + (is (= [["1" "Plato Yeshua" "2014-04-01T08:30:00Z"] + ["2" "Felipinho Asklepios" "2014-12-05T15:15:00Z"] + ["3" "Kaneonuskatew Eiran" "2014-11-06T16:15:00Z"] + ["4" "Simcha Yan" "2014-01-01T08:30:00Z"] + ["5" "Quentin Sören" "2014-10-03T17:30:00Z"]] + (mt/rows + (qp/process-query (assoc query :middleware {:js-int-to-string? true})))))) + + (testing "PKs are left alone when middleware disabled (default)" + (is (= [[1 "Plato Yeshua" "2014-04-01T08:30:00Z"] + [2 "Felipinho Asklepios" "2014-12-05T15:15:00Z"] + [3 "Kaneonuskatew Eiran" "2014-11-06T16:15:00Z"] + [4 "Simcha Yan" "2014-01-01T08:30:00Z"] + [5 "Quentin Sören" "2014-10-03T17:30:00Z"]] + (mt/rows + (qp/process-query (assoc query :middleware {}))))))) + + (let [query (mt/mbql-query users + {:fields [$name] + :order-by [[:asc $name]] + :limit 5})] + (testing "handle when there are no ID columns in the query but the middleware is enabled" + (is (= [["Broen Olujimi"] + ["Conchúr Tihomir"] + ["Dwight Gresham"] + ["Felipinho Asklepios"] + ["Frans Hevel"]] + (mt/rows + (qp/process-query (assoc query :middleware {:js-int-to-string? true}))))))) + + (let [query (mt/mbql-query venues + {:order-by [[:asc $id]] + :limit 5})] + (testing "FKs become strings when middleware enabled" + (is (= [["1" "Red Medicine" "4" 10.0646 -165.374 3] + ["2" "Stout Burgers & Beers" "11" 34.0996 -118.329 2] + ["3" "The Apple Pan" "11" 34.0406 -118.428 2] + ["4" "Wurstküche" "29" 33.9997 -118.465 2] + ["5" "Brite Spot Family Restaurant" "20" 34.0778 -118.261 2]] + (mt/rows + (qp/process-query (assoc query :middleware {:js-int-to-string? true})))))) + + (testing "FKs are left alone when middleware disabled (default)" + (is (= [[1 "Red Medicine" 4 10.0646 -165.374 3] + [2 "Stout Burgers & Beers" 11 34.0996 -118.329 2] + [3 "The Apple Pan" 11 34.0406 -118.428 2] + [4 "Wurstküche" 29 33.9997 -118.465 2] + [5 "Brite Spot Family Restaurant" 20 34.0778 -118.261 2]] + (mt/rows + (qp/process-query (assoc query :middleware {}))))))) + + (let [query (mt/mbql-query checkins + {:fields [$id $user_id->users.id $user_id->users.name $venue_id->venues.id $venue_id->venues.name] + :order-by [[:asc $id]] + :limit 5})] + (testing "joins work correctly" + (is (= [["1" "5" "Quentin Sören" "12" "The Misfit Restaurant + Bar"] + ["2" "1" "Plato Yeshua" "31" "Bludso's BBQ"] + ["3" "8" "Szymon Theutrich" "56" "Philippe the Original"] + ["4" "5" "Quentin Sören" "4" "Wurstküche"] + ["5" "3" "Kaneonuskatew Eiran" "49" "Hotel Biron"]] + (mt/rows + (qp/process-query (assoc query :middleware {:js-int-to-string? true}))))))) + + (let [query (mt/mbql-query venues + {:source-query {:source-table $$venues + :aggregation [[:aggregation-options + [:avg $id] + {:name "some_generated_name", :display-name "My Cool Ag"}]] + :breakout [$price]}})] + ;; see comment in `metabase.query-processor.middleware.large-int-id/convert-id-to-string` + ;; for why this value does not change + (testing "aggregations are not converted to strings with middleware enabled" + (is (= [[1 55] + [2 48] + [3 47] + [4 62]] + (mt/rows + (qp/process-query (assoc query :middleware {:js-int-to-string? true}))))) ) + (testing "aggregation does not convert to strings with middleware disabled (default)" + (is (= [[1 55] + [2 48] + [3 47] + [4 62]] + (mt/rows + (qp/process-query (assoc query :middleware {}))))))))