Skip to content
Snippets Groups Projects
Commit a3f6cab5 authored by Cam Saül's avatar Cam Saül
Browse files

More improvements backported from nested-queries

parent cd43de3f
No related branches found
No related tags found
No related merge requests found
......@@ -17,6 +17,7 @@
[permissions :as perms]
[revision :as revision]]
[metabase.query-processor.middleware.permissions :as qp-perms]
[metabase.query-processor.util :as qputil]
[toucan
[db :as db]
[models :as models]]))
......@@ -43,36 +44,58 @@
;;; ------------------------------------------------------------ Permissions Checking ------------------------------------------------------------
(defn- permissions-path-set:mbql [{database-id :database, :as query}]
{:pre [(integer? database-id) (map? (:query query))]}
(try (let [{{:keys [source-table join-tables]} :query} (qp/expand query)]
(set (for [table (cons source-table join-tables)]
(perms/object-path database-id
(:schema table)
(or (:id table) (:table-id table))))))
(defn- native-permissions-path
"Return the `:read` (for running) or `:write` (for saving) native permissions path for DATABASE-OR-ID."
[read-or-write database-or-id]
((case read-or-write
:read perms/native-read-path
:write perms/native-readwrite-path) (u/get-id database-or-id)))
(defn- query->source-and-join-tables
"Return a sequence of all Tables (as TableInstance maps) referenced by QUERY."
[{:keys [source-table join-tables native], :as query}]
(cond
;; if we come across a native query just put a placeholder (`::native`) there so we know we need to add native permissions to the complete set below.
native [::native]
;; for root MBQL queries just return source-table + join-tables
:else (cons source-table join-tables)))
(defn- tables->permissions-path-set
"Given a sequence of TABLES referenced by a query, return a set of required permissions."
[read-or-write database-or-id tables]
(set (for [table tables]
(if (= ::native table)
;; Any `::native` placeholders from above mean we need READ-OR-WRITE native permissions for this DATABASE
(native-permissions-path read-or-write database-or-id)
;; anything else (i.e., a normal table) just gets normal table permissions
(perms/object-path (u/get-id database-or-id)
(:schema table)
(or (:id table) (:table-id table)))))))
(defn- mbql-permissions-path-set
"Return the set of required permissions needed to run QUERY."
[read-or-write query]
{:pre [(map? query) (map? (:query query))]}
(try (let [{:keys [query database]} (qp/expand query)]
(tables->permissions-path-set read-or-write database (query->source-and-join-tables query)))
;; if for some reason we can't expand the Card (i.e. it's an invalid legacy card)
;; just return a set of permissions that means no one will ever get to see it
(catch Throwable e
(log/warn "Error getting permissions for card:" (.getMessage e) "\n" (u/pprint-to-str (u/filtered-stacktrace e)))
#{"/db/0/"}))) ; DB 0 will never exist
(defn- permissions-path-set:native [read-or-write {database-id :database}]
#{((case read-or-write
:read perms/native-read-path
:write perms/native-readwrite-path) database-id)})
#{"/db/0/"}))) ; DB 0 will never exist
;; it takes a lot of DB calls and function calls to expand/resolve a query, and since they're pure functions we can save ourselves some a lot of DB calls
;; by caching the results. Cache the permissions reqquired to run a given query dictionary for up to 6 hours
(defn- query-perms-set* [{query-type :type, :as query} read-or-write]
(defn- query-perms-set* [{query-type :type, database :database, :as query} read-or-write]
(cond
(= query {}) #{}
(= (keyword query-type) :native) (permissions-path-set:native read-or-write query)
(= (keyword query-type) :query) (permissions-path-set:mbql query)
(= (keyword query-type) :native) #{(native-permissions-path read-or-write database)}
(= (keyword query-type) :query) (mbql-permissions-path-set read-or-write query)
:else (throw (Exception. (str "Invalid query type: " query-type)))))
(def ^{:arglists '([query read-or-write])} query-perms-set
"Return a set of required permissions for *running* QUERY (if READ-OR-WRITE is `:read`) or *saving* it (if READ-OR-WRITE is `:write`)."
(memoize/ttl query-perms-set* :ttl/threshold (* 6 60 60 1000)))
(memoize/ttl query-perms-set* :ttl/threshold (* 6 60 60 1000))) ; memoize for 6 hours
(defn- perms-objects-set
......@@ -111,17 +134,21 @@
;;; ------------------------------------------------------------ Lifecycle ------------------------------------------------------------
(defn- populate-query-fields [card]
(let [{query :query, database-id :database, query-type :type} (:dataset_query card)
table-id (or (:source_table query) ; legacy (MBQL '95)
(:source-table query))
defaults {:database_id database-id
:table_id table-id
:query_type (keyword query-type)}]
(if query-type
(merge defaults card)
card)))
(defn- query->database-and-table-ids
"Return a map with `:database-id` and source `:table-id` that should be saved for a Card."
[outer-query]
(let [database (qputil/get-normalized outer-query :database)
source-table (qputil/get-in-normalized outer-query [:query :source-table])]
(when source-table
{:database-id (u/get-id database), :table-id (u/get-id source-table)})))
(defn- populate-query-fields [{{query-type :type, :as outer-query} :dataset_query, :as card}]
(merge (when query-type
(let [{:keys [database-id table-id]} (query->database-and-table-ids outer-query)]
{:database_id database-id
:table_id table-id
:query_type (keyword query-type)}))
card))
(defn- pre-insert [{:keys [dataset_query], :as card}]
;; TODO - make sure if `collection_id` is specified that we have write permissions for tha tcollection
......
(ns metabase.models.user
(:require [cemerick.friend.credentials :as creds]
[clojure.string :as s]
[clojure.tools.logging :as log]
[metabase
[public-settings :as public-settings]
[util :as u]]
......@@ -42,12 +43,12 @@
(u/prog1 user
;; add the newly created user to the magic perms groups
(binding [perm-membership/*allow-changing-all-users-group-members* true]
#_(log/info (format "Adding user %d to All Users permissions group..." user-id))
(log/info (format "Adding user %d to All Users permissions group..." user-id))
(db/insert! PermissionsGroupMembership
:user_id user-id
:group_id (:id (group/all-users))))
(when superuser?
#_(log/info (format "Adding user %d to Admin permissions group..." user-id))
(log/info (format "Adding user %d to Admin permissions group..." user-id))
(db/insert! PermissionsGroupMembership
:user_id user-id
:group_id (:id (group/admin))))))
......
......@@ -61,34 +61,55 @@
;; PRE-PROCESSING fns are applied from bottom to top, and POST-PROCESSING from top to bottom;
;; the easiest way to wrap your head around this is picturing a the query as a ball being thrown in the air
;; (up through the preprocessing fns, back down through the post-processing ones)
(defn- qp-pipeline
"Construct a new Query Processor pipeline with F as the final 'piviotal' function. e.g.:
All PRE-PROCESSING (query) --> F --> All POST-PROCESSING (result)
Or another way of looking at it is
(post-process (f (pre-process query)))
Normally F is something that runs the query, like the `execute-query` function above, but this can be swapped out when we want to do things like
process a query without actually running it."
[f]
;; ▼▼▼ POST-PROCESSING ▼▼▼ happens from TOP-TO-BOTTOM, e.g. the results of `f` are (eventually) passed to `limit`
(-> f
dev/guard-multiple-calls
mbql-to-native/mbql->native ; ▲▲▲ NATIVE-ONLY POINT ▲▲▲ Query converted from MBQL to native here; all functions *above* will only see the native query
annotate-and-sort/annotate-and-sort
perms/check-query-permissions
log-query/log-expanded-query
dev/check-results-format
limit/limit
cumulative-ags/handle-cumulative-aggregations
implicit-clauses/add-implicit-clauses
format-rows/format-rows
expand-resolve/expand-resolve ; ▲▲▲ QUERY EXPANSION POINT ▲▲▲ All functions *above* will see EXPANDED query during PRE-PROCESSING
row-count-and-status/add-row-count-and-status ; ▼▼▼ RESULTS WRAPPING POINT ▼▼▼ All functions *below* will see results WRAPPED in `:data` during POST-PROCESSING
parameters/substitute-parameters
expand-macros/expand-macros
driver-specific/process-query-in-context ; (drivers can inject custom middleware if they implement IDriver's `process-query-in-context`)
add-settings/add-settings
resolve-driver/resolve-driver ; ▲▲▲ DRIVER RESOLUTION POINT ▲▲▲ All functions *above* will have access to the driver during PRE- *and* POST-PROCESSING
log-query/log-initial-query
cache/maybe-return-cached-results
catch-exceptions/catch-exceptions))
;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP, e.g. the results of `expand-macros` are (eventually) passed to `expand-resolve`
(defn query->native
"Return the native form for QUERY (e.g. for a MBQL query on Postgres this would return a map containing the compiled SQL form)."
{:style/indent 0}
[query]
(-> ((qp-pipeline identity) query)
(get-in [:data :native_form])))
(defn process-query
"A pipeline of various QP functions (including middleware) that are used to process MB queries."
{:style/indent 0}
[query]
;; ▼▼▼ POST-PROCESSING ▼▼▼ happens from TOP-TO-BOTTOM, e.g. the results of `run-query` are (eventually) passed to `limit`
((-> execute-query
dev/guard-multiple-calls
mbql-to-native/mbql->native ; ▲▲▲ NATIVE-ONLY POINT ▲▲▲ Query converted from MBQL to native here; all functions *above* will only see the native query
annotate-and-sort/annotate-and-sort
perms/check-query-permissions
log-query/log-expanded-query
dev/check-results-format
limit/limit
cumulative-ags/handle-cumulative-aggregations
implicit-clauses/add-implicit-clauses
format-rows/format-rows
expand-resolve/expand-resolve ; ▲▲▲ QUERY EXPANSION POINT ▲▲▲ All functions *above* will see EXPANDED query during PRE-PROCESSING
row-count-and-status/add-row-count-and-status ; ▼▼▼ RESULTS WRAPPING POINT ▼▼▼ All functions *below* will see results WRAPPED in `:data` during POST-PROCESSING
parameters/substitute-parameters
expand-macros/expand-macros
driver-specific/process-query-in-context ; (drivers can inject custom middleware if they implement IDriver's `process-query-in-context`)
add-settings/add-settings
resolve-driver/resolve-driver ; ▲▲▲ DRIVER RESOLUTION POINT ▲▲▲ All functions *above* will have access to the driver during PRE- *and* POST-PROCESSING
log-query/log-initial-query
cache/maybe-return-cached-results
catch-exceptions/catch-exceptions)
query))
;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP, e.g. the results of `expand-macros` are (eventually) passed to `expand-resolve`
((qp-pipeline execute-query) query))
(def ^{:arglists '([query])} expand
......
......@@ -8,7 +8,7 @@
[field :refer [Field]]
[table :refer [Table]]]
[metabase.test
[data :refer :all]
[data :as data :refer :all]
[util :as tu :refer [match-$]]]
[metabase.test.data
[datasets :as datasets]
......@@ -49,24 +49,28 @@
(u/ignore-exceptions (first @~result)) ; in case @result# barfs we don't want the test to succeed (Exception == Exception for expectations)
(second @~result)))))
(def ^:private default-db-details
{:engine "h2"
:name "test-data"
:is_sample false
:is_full_sync true
:description nil
:caveats nil
:points_of_interest nil})
(defn- db-details
"Return default column values for a database (either the test database, via `(db)`, or optionally passed in)."
([]
(db-details (db)))
([db]
(match-$ db
{:created_at $
:engine "h2"
:id $
:details $
:updated_at $
:name "test-data"
:is_sample false
:is_full_sync true
:description nil
:caveats nil
:points_of_interest nil
:features (mapv name (driver/features (driver/engine->driver (:engine db))))})))
(merge default-db-details
(match-$ db
{:created_at $
:id $
:details $
:updated_at $
:features (mapv name (driver/features (driver/engine->driver (:engine db))))}))))
;; # DB LIFECYCLE ENDPOINTS
......@@ -85,19 +89,16 @@
;; ## POST /api/database
;; Check that we can create a Database
(expect-with-temp-db-created-via-api [db {:is_full_sync false}]
(match-$ db
{:created_at $
:engine :postgres
:id $
:details {:host "localhost", :port 5432, :dbname "fakedb", :user "cam", :ssl true}
:updated_at $
:name $
:is_sample false
:is_full_sync false
:description nil
:caveats nil
:points_of_interest nil
:features (driver/features (driver/engine->driver :postgres))})
(merge default-db-details
(match-$ db
{:created_at $
:engine :postgres
:is_full_sync false
:id $
:details {:host "localhost", :port 5432, :dbname "fakedb", :user "cam", :ssl true}
:updated_at $
:name $
:features (driver/features (driver/engine->driver :postgres))}))
(Database (:id db)))
......@@ -122,26 +123,38 @@
(dissoc (into {} (db/select-one [Database :name :engine :details :is_full_sync], :id db-id))
:features)))
:description nil
:entity_type nil
:caveats nil
:points_of_interest nil
:visibility_type nil
(def ^:private default-table-details
{:description nil
:entity_name nil
:entity_type nil
:caveats nil
:points_of_interest nil
:visibility_type nil
:active true
:show_in_getting_started false})
(defn- table-details [table]
(match-$ table
{:description $
:entity_type $
:caveats nil
:points_of_interest nil
:visibility_type $
:schema $
:name $
:display_name $
:rows $
:updated_at $
:entity_name $
:active $
:id $
:db_id $
:show_in_getting_started false
:raw_table_id $
:created_at $}))
(merge default-table-details
(match-$ table
{:description $
:entity_type $
:visibility_type $
:schema $
:name $
:display_name $
:rows $
:updated_at $
:entity_name $
:active $
:id $
:db_id $
:raw_table_id $
:created_at $})))
;; TODO - this is a test code smell, each test should clean up after itself and this step shouldn't be neccessary. One day we should be able to remove this!
......@@ -163,32 +176,24 @@
(expect-with-temp-db-created-via-api [{db-id :id}]
(set (filter identity (conj (for [engine datasets/all-valid-engines]
(datasets/when-testing-engine engine
(match-$ (get-or-create-test-data-db! (driver/engine->driver engine))
{:created_at $
:engine (name $engine)
:id $
:updated_at $
:name "test-data"
:native_permissions "write"
:is_sample false
:is_full_sync true
:description nil
:caveats nil
:points_of_interest nil
:features (map name (driver/features (driver/engine->driver engine)))})))
(match-$ (Database db-id)
{:created_at $
:engine "postgres"
:id $
:updated_at $
:name $
:native_permissions "write"
:is_sample false
:is_full_sync true
:description nil
:caveats nil
:points_of_interest nil
:features (map name (driver/features (driver/engine->driver :postgres)))}))))
(merge default-db-details
(match-$ (get-or-create-test-data-db! (driver/engine->driver engine))
{:created_at $
:engine (name $engine)
:id $
:updated_at $
:name "test-data"
:native_permissions "write"
:features (map name (driver/features (driver/engine->driver engine)))}))))
(merge default-db-details
(match-$ (Database db-id)
{:created_at $
:engine "postgres"
:id $
:updated_at $
:name $
:native_permissions "write"
:features (map name (driver/features (driver/engine->driver :postgres)))})))))
(do
(delete-randomly-created-databases! :skip [db-id])
(set ((user->client :rasta) :get 200 "database"))))
......@@ -197,121 +202,98 @@
;; GET /api/databases (include tables)
(expect-with-temp-db-created-via-api [{db-id :id}]
(set (cons (match-$ (Database db-id)
{:created_at $
:engine "postgres"
:id $
:updated_at $
:name $
:native_permissions "write"
:is_sample false
:is_full_sync true
:description nil
:caveats nil
:points_of_interest nil
:tables []
:features (map name (driver/features (driver/engine->driver :postgres)))})
(set (cons (merge default-db-details
(match-$ (Database db-id)
{:created_at $
:engine "postgres"
:id $
:updated_at $
:name $
:native_permissions "write"
:tables []
:features (map name (driver/features (driver/engine->driver :postgres)))}))
(filter identity (for [engine datasets/all-valid-engines]
(datasets/when-testing-engine engine
(let [database (get-or-create-test-data-db! (driver/engine->driver engine))]
(match-$ database
{:created_at $
:engine (name $engine)
:id $
:updated_at $
:name "test-data"
:native_permissions "write"
:is_sample false
:is_full_sync true
:description nil
:caveats nil
:points_of_interest nil
:tables (sort-by :name (for [table (db/select Table, :db_id (:id database))]
(table-details table)))
:features (map name (driver/features (driver/engine->driver engine)))})))))))
(merge default-db-details
(match-$ database
{:created_at $
:engine (name $engine)
:id $
:updated_at $
:name "test-data"
:native_permissions "write"
:tables (sort-by :name (for [table (db/select Table, :db_id (:id database))]
(table-details table)))
:features (map name (driver/features (driver/engine->driver engine)))}))))))))
(do
(delete-randomly-created-databases! :skip [db-id])
(set ((user->client :rasta) :get 200 "database" :include_tables true))))
(def ^:private default-field-details
{:description nil
:caveats nil
:points_of_interest nil
:active true
:position 0
:target nil
:preview_display true
:parent_id nil})
;; ## GET /api/meta/table/:id/query_metadata
;; TODO - add in example with Field :values
(expect
(match-$ (db)
{:created_at $
:engine "h2"
:id $
:updated_at $
:name "test-data"
:is_sample false
:is_full_sync true
:description nil
:caveats nil
:points_of_interest nil
:features (mapv name (driver/features (driver/engine->driver :h2)))
:tables [(match-$ (Table (id :categories))
{:description nil
:entity_type nil
:caveats nil
:points_of_interest nil
:visibility_type nil
:schema "PUBLIC"
:name "CATEGORIES"
:display_name "Categories"
:fields [(match-$ (hydrate/hydrate (Field (id :categories :id)) :values)
{:description nil
:table_id (id :categories)
:caveats nil
:points_of_interest nil
:special_type "type/PK"
:name "ID"
:display_name "ID"
:updated_at $
:active true
:id $
:raw_column_id $
:position 0
:target nil
:preview_display true
:created_at $
:last_analyzed $
:base_type "type/BigInteger"
:visibility_type "normal"
:fk_target_field_id $
:parent_id nil
:values $})
(match-$ (hydrate/hydrate (Field (id :categories :name)) :values)
{:description nil
:table_id (id :categories)
:caveats nil
:points_of_interest nil
:special_type "type/Name"
:name "NAME"
:display_name "Name"
:updated_at $
:active true
:id $
:raw_column_id $
:position 0
:target nil
:preview_display true
:created_at $
:last_analyzed $
:base_type "type/Text"
:visibility_type "normal"
:fk_target_field_id $
:parent_id nil
:values $})]
:segments []
:metrics []
:rows 75
:updated_at $
:entity_name nil
:active true
:id (id :categories)
:raw_table_id $
:db_id (id)
:show_in_getting_started false
:created_at $})]})
(merge default-db-details
(match-$ (db)
{:created_at $
:engine "h2"
:id $
:updated_at $
:name "test-data"
:features (mapv name (driver/features (driver/engine->driver :h2)))
:tables [(merge default-table-details
(match-$ (Table (id :categories))
{:schema "PUBLIC"
:name "CATEGORIES"
:display_name "Categories"
:fields [(merge default-field-details
(match-$ (hydrate/hydrate (Field (id :categories :id)) :values)
{:table_id (id :categories)
:special_type "type/PK"
:name "ID"
:display_name "ID"
:updated_at $
:id $
:raw_column_id $
:created_at $
:last_analyzed $
:base_type "type/BigInteger"
:visibility_type "normal"
:fk_target_field_id $
:values $}))
(merge default-field-details
(match-$ (hydrate/hydrate (Field (id :categories :name)) :values)
{:table_id (id :categories)
:special_type "type/Name"
:name "NAME"
:display_name "Name"
:updated_at $
:id $
:raw_column_id $
:created_at $
:last_analyzed $
:base_type "type/Text"
:visibility_type "normal"
:fk_target_field_id $
:values $}))]
:segments []
:metrics []
:rows 75
:updated_at $
:id (id :categories)
:raw_table_id $
:db_id (id)
:created_at $}))]}))
(let [resp ((user->client :rasta) :get 200 (format "database/%d/metadata" (id)))]
(assoc resp :tables (filter #(= "CATEGORIES" (:name %)) (:tables resp)))))
......
......@@ -26,83 +26,84 @@
;; `:card-create` event
(tt/expect-with-temp [Card [card {:name "My Cool Card"}]]
(expect
{:topic :card-create
:user_id (user->id :rasta)
:model "card"
:model_id (:id card)
:database_id nil
:table_id nil
:details {:name "My Cool Card", :description nil}}
(with-temp-activities
(process-activity-event! {:topic :card-create, :item card})
(db/select-one [Activity :topic :user_id :model :model_id :database_id :table_id :details]
:topic "card-create"
:model_id (:id card))))
(tt/with-temp Card [card {:name "My Cool Card"}]
(with-temp-activities
(process-activity-event! {:topic :card-create, :item card})
(db/select-one [Activity :topic :user_id :model :database_id :table_id :details]
:topic "card-create"
:model_id (:id card)))))
;; `:card-update` event
(tt/expect-with-temp [Card [card {:name "My Cool Card"}]]
(expect
{:topic :card-update
:user_id (user->id :rasta)
:model "card"
:model_id (:id card)
:database_id nil
:table_id nil
:details {:name "My Cool Card", :description nil}}
(with-temp-activities
(process-activity-event! {:topic :card-update, :item card})
(db/select-one [Activity :topic :user_id :model :model_id :database_id :table_id :details]
:topic "card-update"
:model_id (:id card))))
(tt/with-temp Card [card {:name "My Cool Card"}]
(with-temp-activities
(process-activity-event! {:topic :card-update, :item card})
(db/select-one [Activity :topic :user_id :model :database_id :table_id :details]
:topic "card-update"
:model_id (:id card)))))
;; `:card-delete` event
(tt/expect-with-temp [Card [card {:name "My Cool Card"}]]
(expect
{:topic :card-delete
:user_id (user->id :rasta)
:model "card"
:model_id (:id card)
:database_id nil
:table_id nil
:details {:name "My Cool Card", :description nil}}
(with-temp-activities
(process-activity-event! {:topic :card-delete, :item card})
(db/select-one [Activity :topic :user_id :model :model_id :database_id :table_id :details]
:topic "card-delete"
:model_id (:id card))))
(tt/with-temp Card [card {:name "My Cool Card"}]
(with-temp-activities
(process-activity-event! {:topic :card-delete, :item card})
(db/select-one [Activity :topic :user_id :model :database_id :table_id :details]
:topic "card-delete"
:model_id (:id card)))))
;; `:dashboard-create` event
(tt/expect-with-temp [Dashboard [dashboard {:name "My Cool Dashboard"}]]
(expect
{:topic :dashboard-create
:user_id (user->id :rasta)
:model "dashboard"
:model_id (:id dashboard)
:database_id nil
:table_id nil
:details {:name "My Cool Dashboard", :description nil}}
(with-temp-activities
(process-activity-event! {:topic :dashboard-create, :item dashboard})
(db/select-one [Activity :topic :user_id :model :model_id :database_id :table_id :details]
:topic "dashboard-create"
:model_id (:id dashboard))))
(tt/with-temp Dashboard [dashboard {:name "My Cool Dashboard"}]
(with-temp-activities
(process-activity-event! {:topic :dashboard-create, :item dashboard})
(db/select-one [Activity :topic :user_id :model :database_id :table_id :details]
:topic "dashboard-create"
:model_id (:id dashboard)))))
;; `:dashboard-delete` event
(tt/expect-with-temp [Dashboard [dashboard {:name "My Cool Dashboard"}]]
(expect
{:topic :dashboard-delete
:user_id (user->id :rasta)
:model "dashboard"
:model_id (:id dashboard)
:database_id nil
:table_id nil
:details {:name "My Cool Dashboard", :description nil}}
(with-temp-activities
(process-activity-event! {:topic :dashboard-delete, :item dashboard})
(db/select-one [Activity :topic :user_id :model :model_id :database_id :table_id :details]
:topic "dashboard-delete"
:model_id (:id dashboard))))
(tt/with-temp Dashboard [dashboard {:name "My Cool Dashboard"}]
(with-temp-activities
(process-activity-event! {:topic :dashboard-delete, :item dashboard})
(db/select-one [Activity :topic :user_id :model :database_id :table_id :details]
:topic "dashboard-delete"
:model_id (:id dashboard)))))
;; `:dashboard-add-cards` event
......
(ns metabase.query-processor-test.middleware.limit-test
(ns metabase.query-processor.middleware.limit-test
"Tests for the `:limit` clause and `:max-results` constraints."
(:require [expectations :refer :all]
[metabase.query-processor.interface :as i]
......
......@@ -147,7 +147,7 @@
(for [[i row] (m/indexed rows)]
(assoc (zipmap field-names (for [v row]
(u/prog1 (if (instance? java.util.Date v)
(DateTime. v) ; convert to Google version of DateTime, otherwise it doesn't work (!)
(DateTime. ^java.util.Date v) ; convert to Google version of DateTime, otherwise it doesn't work (!)
v)
(assert (not (nil? <>)))))) ; make sure v is non-nil
:id (inc i)))))
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment