From 77760e8d78571c62c7c468bdea79078f819a99df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cam=20Sa=C3=BCl?= <cammsaul@gmail.com> Date: Wed, 17 Aug 2016 13:11:13 -0700 Subject: [PATCH] Various tweaks backported from permissions :lock: --- .../src/metabase/admin/databases/database.js | 8 +-- .../src/metabase/admin/people/reducers.js | 2 +- .../src/metabase/components/UserAvatar.jsx | 12 ++-- frontend/src/metabase/lib/formatting.js | 4 +- .../src/metabase/nav/containers/Navbar.jsx | 41 ++++++-------- frontend/src/metabase/services.js | 2 +- src/metabase/api/card.clj | 17 +++--- src/metabase/api/common.clj | 37 +++++++----- src/metabase/api/dataset.clj | 26 ++++----- src/metabase/api/pulse.clj | 8 +-- src/metabase/api/session.clj | 2 +- src/metabase/api/tiles.clj | 2 +- src/metabase/cmd/load_from_h2.clj | 2 +- src/metabase/db.clj | 25 +++++---- src/metabase/middleware.clj | 30 ++++++---- src/metabase/models/database.clj | 21 ++++++- src/metabase/models/interface.clj | 17 +++++- src/metabase/models/pulse.clj | 3 +- src/metabase/models/table.clj | 2 + src/metabase/models/user.clj | 3 +- src/metabase/pulse.clj | 2 +- src/metabase/query_processor.clj | 15 +++-- src/metabase/util/password.clj | 15 +++-- test/metabase/api/dataset_test.clj | 12 ++-- test/metabase/api/pulse_test.clj | 2 +- test/metabase/middleware_test.clj | 10 +++- test/metabase/models/pulse_test.clj | 4 +- test/metabase/models/session_test.clj | 5 +- test/metabase/query_processor_test.clj | 56 +++++++++---------- 29 files changed, 216 insertions(+), 169 deletions(-) diff --git a/frontend/src/metabase/admin/databases/database.js b/frontend/src/metabase/admin/databases/database.js index 1be971f1f39..d95039f6826 100644 --- a/frontend/src/metabase/admin/databases/database.js +++ b/frontend/src/metabase/admin/databases/database.js @@ -21,7 +21,7 @@ export const fetchDatabases = createThunkAction("FETCH_DATABASES", function() { try { return await MetabaseApi.db_list(); } catch(error) { - console.log("error fetching databases", error); + console.error("error fetching databases", error); } }; }); @@ -36,7 +36,7 @@ export const initializeDatabase = createThunkAction("INITIALIZE_DATABASE", funct if (error.status == 404) { //$location.path('/admin/databases/'); } else { - console.log("error fetching database", databaseId, error); + console.error("error fetching database", databaseId, error); } } } else { @@ -59,7 +59,7 @@ export const addSampleDataset = createThunkAction("ADD_SAMPLE_DATASET", function MetabaseAnalytics.trackEvent("Databases", "Add Sample Data"); return sampleDataset; } catch(error) { - console.log("error adding sample dataset", error); + console.error("error adding sample dataset", error); return error; } }; @@ -90,7 +90,7 @@ export const saveDatabase = createThunkAction("SAVE_DATABASE", function(database } catch (error) { //$scope.$broadcast("form:api-error", error); - console.log("error saving database", error); + console.error("error saving database", error); MetabaseAnalytics.trackEvent("Databases", database.id ? "Update Failed" : "Create Failed", database.engine); formState = { formError: error }; } diff --git a/frontend/src/metabase/admin/people/reducers.js b/frontend/src/metabase/admin/people/reducers.js index 14c1e2a1459..2fc2e1a0dfa 100644 --- a/frontend/src/metabase/admin/people/reducers.js +++ b/frontend/src/metabase/admin/people/reducers.js @@ -24,5 +24,5 @@ export const users = handleActions({ [DELETE_USER]: { next: (state, { payload: user }) => _.omit(state, user.id) }, [GRANT_ADMIN]: { next: (state, { payload: user }) => ({ ...state, [user.id]: user }) }, [REVOKE_ADMIN]: { next: (state, { payload: user }) => ({ ...state, [user.id]: user }) }, - [UPDATE_USER]: { next: (state, { payload: user }) => ({ ...state, [user.id]: user }) }, + [UPDATE_USER]: { next: (state, { payload: user }) => ({ ...state, [user.id]: user }) } }, null); diff --git a/frontend/src/metabase/components/UserAvatar.jsx b/frontend/src/metabase/components/UserAvatar.jsx index 33d4e96c827..af24b4bcf6b 100644 --- a/frontend/src/metabase/components/UserAvatar.jsx +++ b/frontend/src/metabase/components/UserAvatar.jsx @@ -26,17 +26,13 @@ export default class UserAvatar extends Component { userInitials() { const { first_name, last_name } = this.props.user; - let initials = '??'; - - if (first_name !== 'undefined') { - initials = first_name.substring(0, 1).toUpperCase(); + function initial(name) { + return typeof name !== 'undefined' && name.length ? name.substring(0, 1).toUpperCase() : ''; } - if (last_name !== 'undefined') { - initials = initials + last_name.substring(0, 1).toUpperCase(); - } + const initials = initial(first_name) + initial(last_name); - return initials; + return initials.length ? initials : '?'; } render() { diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js index 8093c5fc394..3f0e5e66a23 100644 --- a/frontend/src/metabase/lib/formatting.js +++ b/frontend/src/metabase/lib/formatting.js @@ -13,7 +13,7 @@ const FIXED_NUMBER_FORMATTER_NO_COMMA = d3.format(".f"); const DECIMAL_DEGREES_FORMATTER = d3.format(".08f"); export function formatNumber(number, options = {}) { - options = { comma: true, ...options} + options = { comma: true, ...options}; if (options.compact) { return Humanize.compactInteger(number, 1); } else if (number > -1 && number < 1) { @@ -102,7 +102,7 @@ export function formatValue(value, options = {}) { return value; } else if (typeof value === "number") { if (column && (column.special_type === "latitude" || column.special_type === "longitude")) { - return DECIMAL_DEGREES_FORMATTER(value) + return DECIMAL_DEGREES_FORMATTER(value); } else { return formatNumber(value, options); } diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 45f04d3cca0..845b9b2691b 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -23,6 +23,17 @@ const mapDispatchToProps = { onChangeLocation: push }; +const AdminNavItem = ({ name, path, currentPath }) => + <li> + <Link + to={path} + data-metabase-event={"Navbar;" + name} + className={cx("NavItem py1 px2 no-decoration", {"is--selected": currentPath.startsWith(path) })} + > + {name} + </Link> + </li> + @connect(mapStateToProps, mapDispatchToProps) export default class Navbar extends Component { static propTypes = { @@ -57,10 +68,6 @@ export default class Navbar extends Component { } renderAdminNav() { - const getClasses = (path) => cx("NavItem py1 px2 no-decoration", { - "is--selected": this.isActive(path) - }); - return ( <nav className={cx("Nav AdminNav", this.props.className)}> <div className="wrapper flex align-center"> @@ -70,29 +77,13 @@ export default class Navbar extends Component { </div> <ul className="sm-ml4 flex flex-full"> - <li> - <Link to="/admin/settings" data-metabase-event={"Navbar;Settings"} className={getClasses("/admin/settings")} > - Settings - </Link> - </li> - <li> - <Link to="/admin/people" data-metabase-event={"Navbar;People"} className={getClasses("/admin/people")} > - People - </Link> - </li> - <li> - <Link to="/admin/datamodel/database" data-metabase-event={"Navbar;Data Model"} className={getClasses("/admin/datamodel")} > - Data Model - </Link> - </li> - <li> - <Link to="/admin/databases" data-metabase-event={"Navbar;Databases"} className={getClasses("/admin/databases")}> - Databases - </Link> - </li> + <AdminNavItem name="Settings" path="/admin/settings" currentPath={this.props.path} /> + <AdminNavItem name="People" path="/admin/people" currentPath={this.props.path} /> + <AdminNavItem name="Data Model" path="/admin/datamodel" currentPath={this.props.path} /> + <AdminNavItem name="Databases" path="/admin/databases" currentPath={this.props.path} /> </ul> - <ProfileLink {...this.props}></ProfileLink> + <ProfileLink {...this.props} /> </div> </nav> ); diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index 50cc9063c84..bf0a26557c0 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -544,7 +544,7 @@ CoreServices.factory('Settings', ['$resource', function($resource) { list: { url: '/api/setting', method: 'GET', - isArray: true, + isArray: true }, // POST endpoint handles create + update in this case put: { diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 6a25c5d7abc..0647e4d3ee0 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -1,7 +1,6 @@ (ns metabase.api.card (:require [clojure.data :as data] [compojure.core :refer [GET POST DELETE PUT]] - [honeysql.helpers :as h] [metabase.api.common :refer :all] (metabase [db :as db] [events :as events]) @@ -78,9 +77,9 @@ (cards-with-ids (map :model_id (db/select [ViewLog :model_id [:%max.timestamp :max]] :model "card" :user_id *current-user-id* - (-> (h/group :model_id) - (h/order-by [:max :desc]) - (h/limit 10)))))) + {:group-by [:model_id] + :order-by [[:max :desc]] + :limit 10})))) (defn- cards:popular "All `Cards`, sorted by popularity (the total number of times they are viewed in `ViewLogs`). @@ -88,8 +87,8 @@ [] (cards-with-ids (map :model_id (db/select [ViewLog :model_id [:%count.* :count]] :model "card" - (-> (h/group :model_id) - (h/order-by [:count :desc])))))) + {:group-by [:model_id] + :order-by [[:count :desc]]})))) (defn- cards:archived "`Cards` that have been archived." @@ -169,6 +168,7 @@ (->> (events/publish-event :card-read)) (dissoc :actor_id))) + (defendpoint PUT "/:id" "Update a `Card`." [id :as {{:keys [dataset_query description display name public_perms visualization_settings archived], :as body} :body}] @@ -198,6 +198,7 @@ :else :card-update)] (events/publish-event event (assoc (Card id) :actor_id *current-user-id*))))) + (defendpoint DELETE "/:id" "Delete a `Card`." [id] @@ -207,6 +208,7 @@ (u/prog1 (db/cascade-delete! Card,:id id) (events/publish-event :card-delete (assoc card :actor_id *current-user-id*))))) + (defendpoint GET "/:id/favorite" "Has current user favorited this `Card`?" [id] @@ -218,12 +220,14 @@ [card-id] (db/insert! CardFavorite :card_id card-id, :owner_id *current-user-id*)) + (defendpoint DELETE "/:card-id/favorite" "Unfavorite a Card." [card-id] (let-404 [id (db/select-one-id CardFavorite :card_id card-id, :owner_id *current-user-id*)] (db/cascade-delete! CardFavorite, :id id))) + (defendpoint POST "/:card-id/labels" "Update the set of `Labels` that apply to a `Card`." [card-id :as {{:keys [label_ids]} :body}] @@ -235,7 +239,6 @@ (db/cascade-delete! CardLabel, :label_id [:in labels-to-remove], :card_id card-id)) (doseq [label-id labels-to-add] (db/insert! CardLabel :label_id label-id, :card_id card-id))) - ;; TODO - Should this endpoint return something more useful instead ? {:status :ok}) (define-routes) diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj index f1bfdb404d3..42219830ac7 100644 --- a/src/metabase/api/common.clj +++ b/src/metabase/api/common.clj @@ -17,7 +17,7 @@ ;;; ## DYNAMIC VARIABLES ;; These get bound by middleware for each HTTP request. -(def ^:dynamic *current-user-id* +(def ^:dynamic ^Integer *current-user-id* "Int ID or `nil` of user associated with current API call." nil) @@ -26,6 +26,10 @@ ex. `@*current-user*`" (atom nil)) ; default binding is just something that will return nil when dereferenced +(def ^:dynamic ^Boolean *is-superuser?* + "Is the current user a superuser?" + false) + ;;; ## CONDITIONAL RESPONSE FUNCTIONS / MACROS @@ -64,17 +68,17 @@ (defn check-superuser "Check that `*current-user*` is a superuser or throw a 403." [] - (check-403 (db/exists? 'User, :id *current-user-id*, :is_superuser true))) + (check-403 *is-superuser?*)) ;;; #### checkp- functions: as in "check param". These functions expect that you pass a symbol so they can throw exceptions w/ relevant error messages. -(defn invalid-param-exception - "Create an `ExceptionInfo` that contains information about an invalid API params in the expected format." +(defn throw-invalid-param-exception + "Throw an `ExceptionInfo` that contains information about an invalid API params in the expected format." [field-name message] - (ex-info (format "Invalid field: %s" field-name) + (throw (ex-info (format "Invalid field: %s" field-name) {:status-code 400 - :errors {(keyword field-name) message}})) + :errors {(keyword field-name) message}}))) (defn checkp "Assertion mechanism for use inside API functions that validates individual input params. @@ -87,7 +91,7 @@ (checkp test field-name message)" ([tst field-name message] (when-not tst - (throw (invalid-param-exception (str field-name) message))))) + (throw-invalid-param-exception (str field-name) message)))) (defn checkp-with "Check (F VALUE), or throw an exception with STATUS-CODE (default is 400). @@ -287,8 +291,9 @@ (defannotation Required "Param may not be `nil`." [symb value] - (or value - (throw (invalid-param-exception (name symb) "field is a required param.")))) + (u/prog1 value + (when (nil? value) + (throw-invalid-param-exception (name symb) "field is a required param.")))) (defannotation Date "Parse param string as an [ISO 8601 date](http://en.wikipedia.org/wiki/ISO_8601), e.g. @@ -296,7 +301,7 @@ [symb value :nillable] (try (u/->Timestamp value) (catch Throwable _ - (throw (invalid-param-exception (name symb) (format "'%s' is not a valid date." value)))))) + (throw-invalid-param-exception (name symb) (format "'%s' is not a valid date." value))))) (defannotation String->Integer "Param is converted from a string to an integer." @@ -319,7 +324,7 @@ (= value "true") true (= value "false") false (nil? value) nil - :else (throw (invalid-param-exception (name symb) (format "'%s' is not a valid boolean." value))))) + :else (throw-invalid-param-exception (name symb) (format "'%s' is not a valid boolean." value)))) (defannotation Integer "Param must be an integer (this does *not* cast the param)." @@ -340,13 +345,19 @@ "Param must be an array of integers (this does *not* cast the param)." [symb value :nillable] (checkp-with vector? symb value "value must be an array.") - (mapv (fn [v] (checkp-with integer? symb v "array value must be an integer.")) value)) + (mapv #(checkp-with integer? symb % "array value must be a integer.") value)) + +(defannotation ArrayOfStrings + "Param must be an array of strings (this does *not* cast the param)." + [symb value :nillable] + (checkp-with vector? symb value "value must be an array.") + (mapv #(checkp-with string? symb % "array value must be a string.") value)) (defannotation ArrayOfMaps "Param must be an array of maps (this does *not* cast the param)." [symb value :nillable] (checkp-with vector? symb value "value must be an array.") - (mapv (fn [v] (checkp-with map? symb v "array value must be a map.")) value)) + (mapv #(checkp-with map? symb % "array value must be a map.") value)) (defannotation NonEmptyString "Param must be a non-empty string (strings that only contain whitespace are considered empty)." diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index 01c3853d9c4..9a3263516c3 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -9,36 +9,36 @@ [database :refer [Database]] [hydrate :refer [hydrate]] [query-execution :refer [QueryExecution]]) - [metabase.query-processor :as qp] - [metabase.util :as u])) + (metabase [query-processor :as qp] + [util :as u]))) -(def ^:private ^:const api-max-results-bare-rows +(def ^:private ^:const max-results-bare-rows "Maximum number of rows to return specifically on :rows type queries via the API." 2000) -(def ^:private ^:const api-max-results +(def ^:private ^:const max-results "General maximum number of rows to return from an API query." 10000) -(def ^:const dataset-query-api-constraints +(def ^:const query-constraints "Default map of constraints that we apply on dataset queries executed by the api." - {:max-results api-max-results - :max-results-bare-rows api-max-results-bare-rows}) + {:max-results max-results + :max-results-bare-rows max-results-bare-rows}) (defendpoint POST "/" "Execute an MQL query and retrieve the results as JSON." [:as {{:keys [database] :as body} :body}] (read-check Database database) ;; add sensible constraints for results limits on our query - (let [query (assoc body :constraints dataset-query-api-constraints)] - (qp/dataset-query query {:executed_by *current-user-id*}))) + (let [query (assoc body :constraints query-constraints)] + (qp/dataset-query query {:executed-by *current-user-id*}))) (defendpoint POST "/duration" "Get historical query execution duration." [:as {{:keys [database] :as body} :body}] (read-check Database database) ;; add sensible constraints for results limits on our query - (let [query (assoc body :constraints dataset-query-api-constraints) + (let [query (assoc body :constraints query-constraints) running-times (db/select-field :running_time QueryExecution :query_hash (hash query) {:order-by [[:started_at :desc]] @@ -53,7 +53,7 @@ [query] {query [Required String->Dict]} (read-check Database (:database query)) - (let [{{:keys [columns rows]} :data :keys [status] :as response} (qp/dataset-query query {:executed_by *current-user-id*}) + (let [{{:keys [columns rows]} :data :keys [status] :as response} (qp/dataset-query query {:executed-by *current-user-id*}) columns (map name columns)] ; turn keywords into strings, otherwise we get colons in our output (if (= status :completed) ;; successful query, send CSV file @@ -77,8 +77,8 @@ (read-check Database (:database dataset_query)) ;; add sensible constraints for results limits on our query ;; TODO: it would be nice to associate the card :id with the query execution tracking - (let [query (assoc dataset_query :constraints dataset-query-api-constraints) - options {:executed_by *current-user-id*}] + (let [query (assoc dataset_query :constraints query-constraints) + options {:executed-by *current-user-id*}] {:card (hydrate card :creator) :result (qp/dataset-query query options)}))) diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index bb02dec225f..ce6a11254d6 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -31,7 +31,7 @@ channels [Required ArrayOfMaps]} ;; prevent more than 5 cards ;; limit channel types to :email and :slack - (check-500 (pulse/create-pulse name *current-user-id* (filter identity (map :id cards)) channels))) + (check-500 (pulse/create-pulse! name *current-user-id* (filter identity (map :id cards)) channels))) (defendpoint GET "/:id" @@ -86,7 +86,7 @@ [id] (let [card (Card id)] (read-check Database (:database (:dataset_query card))) - (let [result (qp/dataset-query (:dataset_query card) {:executed_by *current-user-id*})] + (let [result (qp/dataset-query (:dataset_query card) {:executed-by *current-user-id*})] {:status 200, :body (html [:html [:body {:style "margin: 0;"} (binding [render/*include-title* true render/*include-buttons* true] (render/render-pulse-card card result))]])}))) @@ -96,7 +96,7 @@ [id] (let [card (Card id)] (read-check Database (:database (:dataset_query card))) - (let [result (qp/dataset-query (:dataset_query card) {:executed_by *current-user-id*}) + (let [result (qp/dataset-query (:dataset_query card) {:executed-by *current-user-id*}) data (:data result) card-type (render/detect-pulse-card-type card data) card-html (html (binding [render/*include-title* true] @@ -111,7 +111,7 @@ [id] (let [card (Card id)] (read-check Database (:database (:dataset_query card))) - (let [result (qp/dataset-query (:dataset_query card) {:executed_by *current-user-id*}) + (let [result (qp/dataset-query (:dataset_query card) {:executed-by *current-user-id*}) ba (binding [render/*include-title* true] (render/render-pulse-card-to-png card result))] {:status 200, :headers {"Content-Type" "image/png"}, :body (new java.io.ByteArrayInputStream ba) }))) diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj index 801913b20e3..a04b81025bc 100644 --- a/src/metabase/api/session.clj +++ b/src/metabase/api/session.clj @@ -112,7 +112,7 @@ ;; after a successful password update go ahead and offer the client a new session that they can use {:success true :session_id (create-session! user)}) - (throw (invalid-param-exception :password "Invalid reset token")))) + (throw-invalid-param-exception :password "Invalid reset token"))) (defendpoint GET "/password_reset_token_valid" diff --git a/src/metabase/api/tiles.clj b/src/metabase/api/tiles.clj index 62afdc0b8ce..ac78a149b49 100644 --- a/src/metabase/api/tiles.clj +++ b/src/metabase/api/tiles.clj @@ -119,7 +119,7 @@ lon-col-idx String->Integer query String->Dict} (let [updated-query (update query :query #(query-with-inside-filter % lat-field lon-field x y zoom)) - result (qp/dataset-query updated-query {:executed_by *current-user-id* + result (qp/dataset-query updated-query {:executed-by *current-user-id* :synchronously true}) points (for [row (-> result :data :rows)] [(nth row lat-col-idx) (nth row lon-col-idx)])] diff --git a/src/metabase/cmd/load_from_h2.clj b/src/metabase/cmd/load_from_h2.clj index e548ed80646..6774154deb6 100644 --- a/src/metabase/cmd/load_from_h2.clj +++ b/src/metabase/cmd/load_from_h2.clj @@ -25,8 +25,8 @@ [pulse-channel :refer [PulseChannel]] [pulse-channel-recipient :refer [PulseChannelRecipient]] [query-execution :refer [QueryExecution]] - [raw-table :refer [RawTable]] [raw-column :refer [RawColumn]] + [raw-table :refer [RawTable]] [revision :refer [Revision]] [segment :refer [Segment]] [session :refer [Session]] diff --git a/src/metabase/db.clj b/src/metabase/db.clj index 96e317bc579..ddffb5409c1 100644 --- a/src/metabase/db.clj +++ b/src/metabase/db.clj @@ -1,6 +1,7 @@ (ns metabase.db "Database definition and helper functions for interacting with the database." - (:require [clojure.java.jdbc :as jdbc] + (:require (clojure.java [io :as io] + [jdbc :as jdbc]) [clojure.tools.logging :as log] (clojure [set :as set] [string :as s] @@ -33,7 +34,7 @@ "mem:metabase;DB_CLOSE_DELAY=-1" ;; File-based DB (let [db-file-name (config/config-str :mb-db-file) - db-file (clojure.java.io/file db-file-name) + db-file (io/file db-file-name) options ";AUTO_SERVER=TRUE;MV_STORE=FALSE;DB_CLOSE_DELAY=-1"] (apply str "file:" (if (.isAbsolute db-file) ;; when an absolute path is given for the db file then don't mess with it @@ -368,6 +369,8 @@ (jdbc/query (db-connection) (honeysql->sql honeysql-form) options)) +;; TODO - wouldn't it be *pretty cool* if we just made entities implement the honeysql.format/ToSql protocol so we didn't need this function? +;; That would however mean we would have to make sure the entities are resolved first (defn entity->table-name "Get the keyword table name associated with an ENTITY, which can be anything that can be passed to `resolve-entity`. @@ -561,7 +564,7 @@ :mysql :generated_key :h2 (keyword "scope_identity()"))) -(defn simple-insert-many! +(defn- simple-insert-many! "Do a simple JDBC `insert!` of multiple objects into the database. Normally you should use `insert-many!` instead, which calls the entity's `pre-insert` method on the ROW-MAPS; `simple-insert-many!` is offered for cases where you'd like to specifically avoid this behavior. @@ -579,6 +582,7 @@ (defn insert-many! "Insert several new rows into the Database. Resolves ENTITY, and calls `pre-insert` on each of the ROW-MAPS. Returns a sequence of the IDs of the newly created objects. + Note: this *does not* call `post-insert` on newly created objects. If you need `post-insert` behavior, use `insert!` instead. (db/insert-many! 'Label [{:name \"Toucan Friendly\"} {:name \"Bird Approved\"}]) -> [38 39]" @@ -588,11 +592,9 @@ (simple-insert-many! entity (for [row-map row-maps] (models/do-pre-insert entity row-map))))) -(defn simple-insert! - "Do a simple JDBC `insert!` of a single object. - Normally you should use `insert!` instead, which calls the entity's `pre-insert` method on ROW-MAP; - `simple-insert!` is offered for cases where you'd like to specifically avoid this behavior. - Returns the ID of the inserted object. +(defn- simple-insert! + "Do a simple JDBC `insert` of a single object. + This is similar to `insert!` but returns the ID of the newly created object rather than the object itself, and does not call `post-insert`. (db/simple-insert! 'Label :name \"Toucan Friendly\") -> 1 @@ -605,8 +607,8 @@ (simple-insert! entity (apply array-map k v more)))) (defn insert! - "Insert a new object into the Database. Resolves ENTITY, and calls its `pre-insert` method on ROW-MAP to prepare it before insertion; - after insert, it fetches and returns the newly created object. + "Insert a new object into the Database. Resolves ENTITY, calls its `pre-insert` method on ROW-MAP to prepare it before insertion; + after insert, it fetches and the newly created object, passes it to `post-insert`, and returns the results. For flexibility, `insert!` can handle either a single map or individual kwargs: (db/insert! Label {:name \"Toucan Unfriendly\"}) @@ -616,7 +618,7 @@ {:pre [(map? row-map) (every? keyword? (keys row-map))]} (let [entity (resolve-entity entity)] (when-let [id (simple-insert! entity (models/do-pre-insert entity row-map))] - (entity id)))) + (models/post-insert (entity id))))) ([entity k v & more] (insert! entity (apply array-map k v more)))) @@ -723,6 +725,7 @@ "Easy way to see if something exists in the DB. (db/exists? User :id 100) NOTE: This only works for objects that have an `:id` field." + {:style/indent 1} ^Boolean [entity & kvs] (boolean (select-one entity (apply where (h/select {} :id) kvs)))) diff --git a/src/metabase/middleware.clj b/src/metabase/middleware.clj index e2498d8024f..514f3b30689 100644 --- a/src/metabase/middleware.clj +++ b/src/metabase/middleware.clj @@ -3,9 +3,9 @@ (:require [clojure.tools.logging :as log] (cheshire factory [generate :refer [add-encoder encode-str encode-nil]]) - [metabase.api.common :refer [*current-user* *current-user-id*]] - [metabase.config :as config] - [metabase.db :as db] + [metabase.api.common :refer [*current-user* *current-user-id* *is-superuser?*]] + (metabase [config :as config] + [db :as db]) (metabase.models [interface :as models] [session :refer [Session]] [setting :refer [defsetting]] @@ -60,14 +60,18 @@ (fn [{:keys [metabase-session-id] :as request}] ;; TODO - what kind of validations can we do on the sessionid to make sure it's safe to handle? str? alphanumeric? (handler (or (when (and metabase-session-id ((resolve 'metabase.core/initialized?))) - (when-let [session (db/select-one [Session :created_at :user_id] + (when-let [session (db/select-one [Session :created_at :user_id (db/qualify User :is_superuser)] (db/join [Session :user_id] [User :id]) (db/qualify User :is_active) true (db/qualify Session :id) metabase-session-id)] - (let [session-age-ms (- (System/currentTimeMillis) (.getTime ^java.util.Date (get session :created_at (u/->Date 0))))] + (let [session-age-ms (- (System/currentTimeMillis) (or (when-let [^java.util.Date created-at (:created_at session)] + (.getTime created-at)) + 0))] ;; If the session exists and is not expired (max-session-age > session-age) then validation is good (when (and session (> (config/config-int :max-session-age) (quot session-age-ms 60000))) - (assoc request :metabase-user-id (:user_id session)))))) + (assoc request + :metabase-user-id (:user_id session) + :is-superuser? (:is_superuser session)))))) request)))) @@ -79,6 +83,9 @@ (handler request) response-unauthentic))) +(def ^:private current-user-fields + (vec (concat [User :is_active :is_staff :google_auth] (models/default-fields User)))) + (defn bind-current-user "Middleware that binds `metabase.api.common/*current-user*` and `*current-user-id*` @@ -88,9 +95,8 @@ (fn [request] (if-let [current-user-id (:metabase-user-id request)] (binding [*current-user-id* current-user-id - *current-user* (delay (db/select-one (vec (concat [User :is_active :is_staff :google_auth] - (models/default-fields User))) - :id current-user-id))] + *is-superuser?* (:is-superuser? request) + *current-user* (delay (db/select-one current-user-fields, :id current-user-id))] (handler request)) (handler request)))) @@ -100,9 +106,9 @@ We check the request headers for `X-METABASE-APIKEY` and if it's not found then then no keyword is bound to the request." [handler] (fn [{:keys [headers] :as request}] - (if-let [api-key (headers metabase-api-key-header)] - (handler (assoc request :metabase-api-key api-key)) - (handler request)))) + (handler (if-let [api-key (headers metabase-api-key-header)] + (assoc request :metabase-api-key api-key) + request)))) (defn enforce-api-key diff --git a/src/metabase/models/database.clj b/src/metabase/models/database.clj index b84259fa6ad..b51e2f8f0a1 100644 --- a/src/metabase/models/database.clj +++ b/src/metabase/models/database.clj @@ -1,5 +1,6 @@ (ns metabase.models.database - (:require [cheshire.generate :refer [add-encoder encode-map]] + (:require [clojure.string :as s] + [cheshire.generate :refer [add-encoder encode-map]] [metabase.api.common :refer [*current-user*]] [metabase.db :as db] [metabase.models.interface :as i] @@ -9,6 +10,7 @@ "The string to replace passwords with when serializing Databases." "**MetabasePass**") + (i/defentity Database :metabase_database) (defn- post-select [{:keys [engine] :as database}] @@ -39,6 +41,23 @@ :pre-cascade-delete pre-cascade-delete})) +(defn schema-names + "Return a *sorted set* of schema names (as strings) associated with this `Database`." + [{:keys [id]}] + (when id + (apply sorted-set (sort-by (fn [schema-name] + (when schema-name + (s/lower-case schema-name))) + (db/select-field :schema 'Table + :db_id id + {:modifiers [:DISTINCT]}))))) + +(defn schema-exists? + "Does DATABASE have any tables with SCHEMA?" + ^Boolean [{:keys [id]}, schema] + (db/exists? 'Table :db_id id, :schema (some-> schema name))) + + (add-encoder DatabaseInstance (fn [db json-generator] (encode-map (cond (not (:is_superuser @*current-user*)) (dissoc db :details) diff --git a/src/metabase/models/interface.clj b/src/metabase/models/interface.clj index 688f9f5f6eb..daeebbf7f2d 100644 --- a/src/metabase/models/interface.clj +++ b/src/metabase/models/interface.clj @@ -44,10 +44,22 @@ "Gets called by `insert!` immediately before inserting a new object immediately before the SQL `INSERT` call. This provides an opportunity to do things like encode JSON or provide default values for certain fields. - (pre-insert [_ query] + (pre-insert [query] (let [defaults {:version 1}] (merge defaults query))) ; set some default values") + (post-insert [this] + "Gets called by `insert!` with an object that was newly inserted into the database. + This provides an opportunity to trigger specific logic that should occur when an object is inserted or modify the object that is returned. + The value returned by this method is returned to the caller of `insert!`. The default implementation is `identity`. + + (post-insert [user] + (assoc user :newly-created true)) + + (post-insert [user] + (u/prog1 user + (add-user-to-magic-perm-groups! <>)))") + (pre-update [this] "Called by `update!` before DB operations happen. A good place to set updated values for fields like `updated_at`.") @@ -62,7 +74,7 @@ The output of this function is ignored. - (pre-cascade-delete [_ {database-id :id :as database}] + (pre-cascade-delete [{database-id :id :as database}] (cascade-delete! Card :database_id database-id) ...)") @@ -178,6 +190,7 @@ :can-read? (fn [this & _] (throw (UnsupportedOperationException. (format "No implementation of can-read? for %s; please provide one." (class this))))) :can-write? (fn [this & _] (throw (UnsupportedOperationException. (format "No implementation of can-write? for %s; please provide one." (class this))))) :pre-insert identity + :post-insert identity :pre-update identity :post-select identity :pre-cascade-delete (constantly nil)}) diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj index e25d4590a4a..c202ad56263 100644 --- a/src/metabase/models/pulse.clj +++ b/src/metabase/models/pulse.clj @@ -148,8 +148,7 @@ (->> (retrieve-pulse id) (events/publish-event :pulse-update)))) -;; TODO - rename to `create-pulse!` -(defn create-pulse +(defn create-pulse! "Create a new `Pulse` by inserting it into the database along with all associated pieces of data such as: `PulseCards`, `PulseChannels`, and `PulseChannelRecipients`. diff --git a/src/metabase/models/table.clj b/src/metabase/models/table.clj index 991c816bd2e..661e5fae172 100644 --- a/src/metabase/models/table.clj +++ b/src/metabase/models/table.clj @@ -105,6 +105,7 @@ (db/update-where! Field {:table_id [:in table-ids]} :visibility_type "retired"))) +;; TODO - rename to `update-table-from-tabledef!` (defn update-table! "Update `Table` with the data from TABLE-DEF." [{:keys [id display_name], :as existing-table} {table-name :name}] @@ -118,6 +119,7 @@ ;; always return the table when we are done updated-table)) +;; TODO - rename to `create-table-from-tabledef!` (defn create-table! "Create `Table` with the data from TABLE-DEF." [database-id {schema-name :schema, table-name :name, raw-table-id :raw-table-id, visibility-type :visibility-type}] diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj index f305f30b7e7..cdb44e3e5f1 100644 --- a/src/metabase/models/user.clj +++ b/src/metabase/models/user.clj @@ -12,7 +12,8 @@ (i/defentity User :core_user) (defn- pre-insert [{:keys [email password reset_token] :as user}] - (assert (u/is-email? email)) + (assert (u/is-email? email) + (format "Not a valid email: '%s'" email)) (assert (and (string? password) (not (s/blank? password)))) (assert (not (:password_salt user)) diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj index 026d9c65cd4..a43fb49dd77 100644 --- a/src/metabase/pulse.clj +++ b/src/metabase/pulse.clj @@ -25,7 +25,7 @@ (when-let [card (Card card-id)] (let [{:keys [creator_id dataset_query]} card] (try - {:card card :result (qp/dataset-query dataset_query {:executed_by creator_id})} + {:card card :result (qp/dataset-query dataset_query {:executed-by creator_id})} (catch Throwable t (log/warn (format "Error running card query (%n)" card-id) t)))))) diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index 0f013a825eb..24f15140929 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -575,14 +575,14 @@ Possible caller-options include: - :executed_by [int] (user_id of caller)" + :executed-by [int] (user_id of caller)" {:arglists '([query options])} - [query {:keys [executed_by]}] - {:pre [(integer? executed_by)]} + [query {:keys [executed-by]}] + {:pre [(integer? executed-by)]} (let [query-uuid (str (java.util.UUID/randomUUID)) query-hash (hash query) query-execution {:uuid query-uuid - :executor_id executed_by + :executor_id executed-by :json_query query :query_hash query-hash :query_id nil @@ -598,7 +598,7 @@ :raw_query "" :additional_info "" :start_time_millis (System/currentTimeMillis)} - query (assoc query :info {:executed-by executed_by + query (assoc query :info {:executed-by executed-by :uuid query-uuid :query-hash query-hash :query-type (if (mbql-query? query) "MBQL" "native")})] @@ -651,8 +651,7 @@ [{:keys [id], :as query-execution}] (if id ;; execution has already been saved, so update it - (do - (db/update! QueryExecution id query-execution) - query-execution) + (u/prog1 query-execution + (db/update! QueryExecution id query-execution)) ;; first time saving execution, so insert it (db/insert! QueryExecution query-execution))) diff --git a/src/metabase/util/password.clj b/src/metabase/util/password.clj index d96cdbd7f62..8d4beb33efc 100644 --- a/src/metabase/util/password.clj +++ b/src/metabase/util/password.clj @@ -1,6 +1,7 @@ (ns metabase.util.password (:require [cemerick.friend.credentials :as creds] - [metabase.config :as config])) + (metabase [config :as config] + [util :as u]))) (defn- count-occurrences @@ -58,10 +59,8 @@ (defn verify-password - "Verify if a given unhashed password + salt matches the supplied hashed-password. Returns true if matched, false otherwise." - [password salt hashed-password] - (try - (creds/bcrypt-verify (str salt password) hashed-password) - (catch Throwable e - ;; we wrap the friend/bcrypt-verify with this function specifically to avoid unintended exceptions getting out - false))) + "Verify if a given unhashed password + salt matches the supplied hashed-password. Returns `true` if matched, `false` otherwise." + ^Boolean [password salt hashed-password] + ;; we wrap the friend/bcrypt-verify with this function specifically to avoid unintended exceptions getting out + (boolean (u/ignore-exceptions + (creds/bcrypt-verify (str salt password) hashed-password)))) diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index 065f5049c50..5adc5962978 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -2,7 +2,7 @@ "Unit tests for /api/dataset endpoints." (:require [clojure.string :as s] [expectations :refer :all] - [metabase.api.dataset :refer [dataset-query-api-constraints]] + [metabase.api.dataset :refer [query-constraints]] [metabase.db :as db] (metabase.models [card :refer [Card]] [query-execution :refer [QueryExecution]]) @@ -65,7 +65,7 @@ (ql/aggregation (ql/count)))) (assoc :type "query") (assoc-in [:query :aggregation] {:aggregation-type "count"}) - (assoc :constraints dataset-query-api-constraints)) + (assoc :constraints query-constraints)) :started_at true :finished_at true :running_time true} @@ -81,7 +81,7 @@ (ql/aggregation (ql/count)))) (assoc :type "query") (assoc-in [:query :aggregation] {:aggregation-type "count"}) - (assoc :constraints dataset-query-api-constraints)) + (assoc :constraints query-constraints)) :started_at true :finished_at true :running_time true @@ -108,7 +108,7 @@ :json_query {:database (id) :type "native" :native {:query "foobar"} - :constraints dataset-query-api-constraints} + :constraints query-constraints} :started_at true :finished_at true :running_time true}] @@ -163,7 +163,7 @@ (ql/aggregation (ql/count)))) (assoc :type "query") (assoc-in [:query :aggregation] {:aggregation-type "count"}) - (assoc :constraints dataset-query-api-constraints)) + (assoc :constraints query-constraints)) :started_at true :finished_at true :running_time true}} @@ -179,7 +179,7 @@ (ql/aggregation (ql/count)))) (assoc :type "query") (assoc-in [:query :aggregation] {:aggregation-type "count"}) - (assoc :constraints dataset-query-api-constraints)) + (assoc :constraints query-constraints)) :started_at true :finished_at true :running_time true diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj index 236ef6a8029..8fd015f68e9 100644 --- a/test/metabase/api/pulse_test.clj +++ b/test/metabase/api/pulse_test.clj @@ -7,7 +7,7 @@ (metabase.models [card :refer [Card]] [common :as common] [database :refer [Database]] - [pulse :refer [Pulse create-pulse], :as pulse]) + [pulse :refer [Pulse], :as pulse]) [metabase.test.data :refer :all] [metabase.test.data.users :refer :all] [metabase.test.util :as tu] diff --git a/test/metabase/middleware_test.clj b/test/metabase/middleware_test.clj index 199ce66b435..ec0e6923650 100644 --- a/test/metabase/middleware_test.clj +++ b/test/metabase/middleware_test.clj @@ -8,6 +8,7 @@ [metabase.models.session :refer [Session]] [metabase.test.data :refer :all] [metabase.test.data.users :refer :all] + [metabase.test.util :as tu] [metabase.util :as u])) ;; =========================== TEST wrap-session-id middleware =========================== @@ -62,11 +63,14 @@ (defn- random-session-id [] (str (java.util.UUID/randomUUID))) + +(tu/resolve-private-fns metabase.db simple-insert!) + ;; valid session ID (expect (user->id :rasta) (let [session-id (random-session-id)] - (db/simple-insert! Session, :id session-id, :user_id (user->id :rasta), :created_at (u/new-sql-timestamp)) + (simple-insert! Session, :id session-id, :user_id (user->id :rasta), :created_at (u/new-sql-timestamp)) (-> (auth-enforced-handler (request-with-session-id session-id)) :metabase-user-id))) @@ -77,7 +81,7 @@ (expect response-unauthentic (let [session-id (random-session-id)] - (db/simple-insert! Session, :id session-id, :user_id (user->id :rasta), :created_at (java.sql.Timestamp. 0)) + (simple-insert! Session, :id session-id, :user_id (user->id :rasta), :created_at (java.sql.Timestamp. 0)) (auth-enforced-handler (request-with-session-id session-id)))) @@ -87,7 +91,7 @@ ;; NOTE that :trashbird is our INACTIVE test user (expect response-unauthentic (let [session-id (random-session-id)] - (db/simple-insert! Session, :id session-id, :user_id (user->id :trashbird), :created_at (u/new-sql-timestamp)) + (simple-insert! Session, :id session-id, :user_id (user->id :trashbird), :created_at (u/new-sql-timestamp)) (auth-enforced-handler (request-with-session-id session-id)))) diff --git a/test/metabase/models/pulse_test.clj b/test/metabase/models/pulse_test.clj index 88cfe49bbc9..3985251a1c9 100644 --- a/test/metabase/models/pulse_test.clj +++ b/test/metabase/models/pulse_test.clj @@ -20,7 +20,7 @@ ;; create a channel then select its details (defn- create-pulse-then-select! [name creator cards channels] - (let [{:keys [cards channels] :as pulse} (create-pulse name creator cards channels)] + (let [{:keys [cards channels] :as pulse} (create-pulse! name creator cards channels)] (-> pulse (dissoc :id :creator :public_perms :created_at :updated_at) (assoc :cards (mapv #(dissoc % :id) cards)) @@ -116,7 +116,7 @@ (dissoc :id :pulse_id :created_at :updated_at) (m/dissoc-in [:details :emails])))) -;; create-pulse +;; create-pulse! ;; simple example with a single card (expect {:creator_id (user->id :rasta) diff --git a/test/metabase/models/session_test.clj b/test/metabase/models/session_test.clj index f576aa707bc..dff89ad8077 100644 --- a/test/metabase/models/session_test.clj +++ b/test/metabase/models/session_test.clj @@ -1,12 +1,13 @@ (ns metabase.models.session-test (:require [expectations :refer :all] - [metabase.db :as db] + metabase.db (metabase.models [session :refer :all] [user :refer [User]]) [metabase.test.util :refer :all] [metabase.test.data.users :refer :all] [metabase.util :as u])) +(resolve-private-fns metabase.db simple-insert-many!) ;; first-session-for-user (expect "the-greatest-day-ever" @@ -14,7 +15,7 @@ :last_name (random-name) :email (str (random-name) "@metabase.com") :password "nada"}] - (db/simple-insert-many! Session + (simple-insert-many! Session [{:id "the-greatest-day-ever" :user_id user-id :created_at (u/->Timestamp "1980-10-19T05:05:05.000Z")} diff --git a/test/metabase/query_processor_test.clj b/test/metabase/query_processor_test.clj index 5a344e08629..2c540dc209c 100644 --- a/test/metabase/query_processor_test.clj +++ b/test/metabase/query_processor_test.clj @@ -254,8 +254,8 @@ (defn- breakout-col [column] (assoc column :source :breakout)) -(defn boolean-native-form - "Convert :native_form attribute to a boolean to make test results comparisons easier" +(defn- booleanize-native-form + "Convert `:native_form` attribute to a boolean to make test results comparisons easier." [m] (update-in m [:data :native_form] boolean)) @@ -327,7 +327,7 @@ :native_form true} (->> (run-query venues (ql/aggregation (ql/count))) - boolean-native-form + booleanize-native-form (format-rows-by [int]))) @@ -339,7 +339,7 @@ :native_form true} (->> (run-query venues (ql/aggregation (ql/sum $price))) - boolean-native-form + booleanize-native-form (format-rows-by [int]))) @@ -351,7 +351,7 @@ :native_form true} (->> (run-query venues (ql/aggregation (ql/avg $latitude))) - boolean-native-form + booleanize-native-form (format-rows-by [(partial u/round-to-decimals 4)]))) @@ -363,7 +363,7 @@ :native_form true} (->> (run-query checkins (ql/aggregation (ql/distinct $user_id))) - boolean-native-form + booleanize-native-form (format-rows-by [int]))) @@ -386,7 +386,7 @@ (->> (run-query venues (ql/limit 10) (ql/order-by (ql/asc $id))) - boolean-native-form + booleanize-native-form formatted-venues-rows)) @@ -532,7 +532,7 @@ (->> (run-query checkins (ql/aggregation (ql/count)) (ql/filter (ql/between $date "2015-04-01" "2015-05-01"))) - boolean-native-form + booleanize-native-form (format-rows-by [int]))) ;;; FILTER -- "OR", "<=", "=" @@ -592,7 +592,7 @@ (ql/fields $name $id) (ql/limit 10) (ql/order-by (ql/asc $id))) - boolean-native-form + booleanize-native-form (format-rows-by [str int]))) @@ -609,7 +609,7 @@ (ql/aggregation (ql/count)) (ql/breakout $user_id) (ql/order-by (ql/asc $user_id))) - boolean-native-form + booleanize-native-form (format-rows-by [int int]))) ;;; BREAKOUT w/o AGGREGATION @@ -622,7 +622,7 @@ (->> (run-query checkins (ql/breakout $user_id) (ql/limit 10)) - boolean-native-form + booleanize-native-form (format-rows-by [int]))) @@ -641,7 +641,7 @@ (ql/aggregation (ql/count)) (ql/breakout $user_id $venue_id) (ql/limit 10)) - boolean-native-form + booleanize-native-form (format-rows-by [int int int]))) ;;; "BREAKOUT" - MULTIPLE COLUMNS W/ EXPLICIT "ORDER_BY" @@ -660,7 +660,7 @@ (ql/breakout $user_id $venue_id) (ql/order-by (ql/desc $user_id)) (ql/limit 10)) - boolean-native-form + booleanize-native-form (format-rows-by [int int int]))) @@ -699,7 +699,7 @@ :native_form true} (->> (run-query users (ql/aggregation (ql/cum-sum $id))) - boolean-native-form + booleanize-native-form (format-rows-by [int]))) @@ -712,7 +712,7 @@ (->> (run-query users (ql/aggregation (ql/cum-sum $id)) (ql/breakout $id)) - boolean-native-form + booleanize-native-form (format-rows-by [int]))) @@ -741,7 +741,7 @@ (->> (run-query users (ql/aggregation (ql/cum-sum $id)) (ql/breakout $name)) - boolean-native-form + booleanize-native-form (format-rows-by [str int]))) @@ -759,7 +759,7 @@ (->> (run-query venues (ql/aggregation (ql/cum-sum $id)) (ql/breakout $price)) - boolean-native-form + booleanize-native-form (format-rows-by [int int]))) @@ -778,7 +778,7 @@ :native_form true} (->> (run-query users (ql/aggregation (ql/cum-count))) - boolean-native-form + booleanize-native-form (format-rows-by [int]))) ;;; Cumulative count w/ a different breakout field @@ -806,7 +806,7 @@ (->> (run-query users (ql/aggregation (ql/cum-count)) (ql/breakout $name)) - boolean-native-form + booleanize-native-form (format-rows-by [str int]))) @@ -824,7 +824,7 @@ (->> (run-query venues (ql/aggregation (ql/cum-count)) (ql/breakout $price)) - boolean-native-form + booleanize-native-form (format-rows-by [int int]))) @@ -837,7 +837,7 @@ :native_form true} (-> (run-query venues (ql/aggregation (ql/stddev $latitude))) - boolean-native-form + booleanize-native-form (update-in [:data :rows] (fn [[[v]]] [[(u/round-to-decimals 1 v)]])))) @@ -867,7 +867,7 @@ (ql/aggregation (ql/count)) (ql/breakout $price) (ql/order-by (ql/asc (ql/aggregate-field 0)))) - boolean-native-form + booleanize-native-form (format-rows-by [int int]))) @@ -886,7 +886,7 @@ (ql/aggregation (ql/sum $id)) (ql/breakout $price) (ql/order-by (ql/desc (ql/aggregate-field 0)))) - boolean-native-form + booleanize-native-form (format-rows-by [int int]))) @@ -905,7 +905,7 @@ (ql/aggregation (ql/distinct $id)) (ql/breakout $price) (ql/order-by (ql/asc (ql/aggregate-field 0)))) - boolean-native-form + booleanize-native-form (format-rows-by [int int]))) @@ -924,7 +924,7 @@ (ql/aggregation (ql/avg $category_id)) (ql/breakout $price) (ql/order-by (ql/asc (ql/aggregate-field 0)))) - boolean-native-form + booleanize-native-form :data (format-rows-by [int int]))) ;;; ### order_by aggregate ["stddev" field-id] @@ -944,7 +944,7 @@ (ql/aggregation (ql/stddev $category_id)) (ql/breakout $price) (ql/order-by (ql/desc (ql/aggregate-field 0)))) - boolean-native-form + booleanize-native-form :data (format-rows-by [int (comp int math/round)]))) @@ -996,7 +996,7 @@ ;; Filter out the timestamps from the results since they're hard to test :/ (-> (run-query users (ql/order-by (ql/asc $id))) - boolean-native-form + booleanize-native-form (update-in [:data :rows] (partial mapv (fn [[id name last-login]] [(int id) name]))))) @@ -1276,7 +1276,7 @@ (run-query tips (ql/aggregation (ql/count)) (ql/breakout $tips.source.service))) - boolean-native-form + booleanize-native-form :data (#(dissoc % :cols)) (format-rows-by [str int]))) ;;; Nested Field in FIELDS -- GitLab