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

Merge pull request #549 from metabase/strip_db_details

BIG NICE REFACTOR ! :heart_eyes_cat:
parents 9e447e85 18c38930
No related branches found
No related tags found
No related merge requests found
Showing
with 387 additions and 536 deletions
......@@ -57,7 +57,7 @@
(defendpoint GET "/:id"
"Get `Card` with ID."
[id]
(->404 (sel :one Card :id id)
(->404 (Card id)
read-check
(hydrate :creator :can_read :can_write)))
......@@ -75,7 +75,7 @@
:name name
:public_perms public_perms
:visualization_settings visualization_settings))
(sel :one Card :id id))
(Card id))
(defendpoint DELETE "/:id"
"Delete a `Card`."
......
......@@ -8,6 +8,7 @@
[metabase.api.common.internal :refer :all]
[metabase.db :refer :all]
[metabase.db.internal :refer [entity->korma]]
[metabase.models.interface :as models]
[metabase.util :as u]
[metabase.util.password :as password])
(:import com.metabase.corvus.api.ApiException
......@@ -29,17 +30,6 @@
(atom nil)) ; default binding is just something that will return nil when dereferenced
;;; ## GENERAL HELPER FNS / MACROS
;; TODO - move this to something like `metabase.util.debug`
(defmacro with-current-user
"Primarily for debugging purposes. Evaulates BODY as if `*current-user*` was the User with USER-ID."
[user-id & body]
`(binding [*current-user-id* ~user-id
*current-user* (delay (sel :one 'metabase.models.user/User :id ~user-id))]
~@body))
;;; ## CONDITIONAL RESPONSE FUNCTIONS / MACROS
(defn check
......@@ -421,44 +411,26 @@
`(defroutes ~'routes ~@api-routes ~@additional-routes)))
;; ## NEW PERMISSIONS CHECKING MACROS
;; Since checking `@can_read`/`@can_write` is such a common pattern, these
;; macros eliminate a bit of the redundancy around doing so.
;; They support two forms:
;;
;; (read-check my-table) ; checks @(:can_read my-table)
;; (read-check Table 1) ; checks @(:can_read (sel :one Table :id 1))
;;
;; * The first form is useful when you've already fetched an object (especially in threading forms such as `->404`).
;; * The second form takes care of fetching the object for you and is useful in cases where you won't need the object afterward
;; or want to combine the `sel` and permissions check statements into a single form.
;;
;; Both forms will throw a 404 if the object doesn't exist (saving you one more check!) and return the selected object.
(defmacro read-check
"Checks that `@can_read` is true for this object."
(defn read-check
"Check whether we can read an existing OBJ, or ENTITY with ID."
([obj]
`(let-404 [{:keys [~'can_read] :as obj#} ~obj]
(check-403 @~'can_read)
obj#))
(check-404 obj)
(check-403 (models/can-read? obj))
obj)
([entity id]
(cond
;; simple optimization : since @can-read is always true for a Database
;; the read-check macro will just resolve to true in this simple case
;; use `name` so we can match 'Database or 'metabase.models.database/Database
;;
;; TODO - it would be nice to generalize the read-checking pattern, and make it
;; a separate multimethod or protocol so other models besides DB can write optimized
;; implementations. Currently, we always fetch an *entire* object to do read checking,
;; which is wasteful.
(= (name entity) "Database") `(comment "@(:can-read database) is always true.") ; put some non-constant value here so Eastwood doesn't complain about unused return values
:else `(read-check (sel :one ~entity :id ~id)))))
(defmacro write-check
"Checks that `@can_write` is true for this object."
{:pre [(models/metabase-entity? entity)
(integer? id)]}
(if (satisfies? models/ICanReadWrite entity)
(read-check (entity id)))))
(defn write-check
"Check whether we can write an existing OBJ, or ENTITY with ID."
([obj]
`(let-404 [{:keys [~'can_write] :as obj#} ~obj]
(check-403 @~'can_write)
obj#))
(check-404 obj)
(check-403 (models/can-write? obj))
obj)
([entity id]
`(write-check (sel :one ~entity :id ~id))))
{:pre [(models/metabase-entity? entity)
(integer? id)]}
(if (satisfies? models/ICanReadWrite entity) (models/can-write? entity id)
(write-check (entity id)))))
......@@ -36,7 +36,7 @@
(defendpoint GET "/:id"
"Get `Dashboard` with ID."
[id]
(let-404 [db (-> (sel :one Dashboard :id id)
(let-404 [db (-> (Dashboard id)
read-check
(hydrate :creator [:ordered_cards [:card :creator]] :can_read :can_write))]
{:dashboard db})) ; why is this returned with this {:dashboard} wrapper?
......@@ -50,7 +50,7 @@
:description description
:name name
:public_perms public_perms))
(sel :one Dashboard :id id))
(Dashboard id))
(defendpoint DELETE "/:id"
"Delete a `Dashboard`."
......
......@@ -64,7 +64,7 @@
(defendpoint GET "/:id"
"Get `Database` with ID."
[id]
(check-404 (sel :one Database :id id)))
(check-404 (Database id)))
(defendpoint PUT "/:id"
"Update a `Database`."
......@@ -75,7 +75,7 @@
:name name
:engine engine
:details details))
(sel :one Database :id id))
(Database id))
(defendpoint DELETE "/:id"
"Delete a `Database`."
......@@ -126,7 +126,7 @@
(defendpoint POST "/:id/sync"
"Update the metadata for this `Database`."
[id]
(let-404 [db (sel :one Database :id id)]
(let-404 [db (Database id)]
(write-check db)
(future (driver/sync-database! db))) ; run sync-tables asynchronously
{:status :ok})
......
......@@ -29,7 +29,7 @@
(defendpoint GET "/:id"
"Get `Field` with ID."
[id]
(->404 (sel :one Field :id id)
(->404 (Field id)
read-check
(hydrate [:table :db])))
......@@ -43,12 +43,12 @@
:special_type special_type} ; but field_type and preview_display must be replaced
(when field_type {:field_type field_type}) ; with new non-nil values
(when (not (nil? preview_display)) {:preview_display preview_display}))))
(sel :one Field :id id))
(Field id))
(defendpoint GET "/:id/summary"
"Get the count and distinct count of `Field` with ID."
[id]
(let-404 [field (sel :one Field :id id)]
(let-404 [field (Field id)]
(read-check field)
[[:count (metadata/field-count field)]
[:distincts (metadata/field-distinct-count field)]]))
......@@ -79,7 +79,7 @@
"If `Field`'s special type is `category`/`city`/`state`/`country`, or its base type is `BooleanField`, return
all distinct values of the field, and a map of human-readable values defined by the user."
[id]
(let-404 [field (sel :one Field :id id)]
(let-404 [field (Field id)]
(read-check field)
(if-not (field-should-have-field-values? field)
{:values {} :human_readable_values {}}
......@@ -91,7 +91,7 @@
or whose base type is `BooleanField`."
[id :as {{:keys [fieldId values_map]} :body}] ; WTF is the reasoning behind client passing fieldId in POST params?
{values_map [Required Dict]}
(let-404 [field (sel :one Field :id id)]
(let-404 [field (Field id)]
(write-check field)
(check (field-should-have-field-values? field)
[400 "You can only update the mapped values of a Field whose 'special_type' is 'category'/'city'/'state'/'country' or whose 'base_type' is 'BooleanField'."])
......
......@@ -30,7 +30,7 @@
(defendpoint GET "/:id"
"Get `Table` with ID."
[id]
(->404 (sel :one Table :id id)
(->404 (Table id)
read-check
(hydrate :db :pk_field)))
......@@ -43,7 +43,7 @@
:entity_name entity_name
:entity_type entity_type
:description description))
(sel :one Table :id id))
(Table id))
(defendpoint GET "/:id/fields"
"Get all `Fields` for `Table` with ID."
......@@ -60,7 +60,7 @@
will any of its corresponding values be returned. (This option is provided for use in the Admin Edit Metadata page)."
[id include_sensitive_fields]
{include_sensitive_fields String->Boolean}
(->404 (sel :one Table :id id)
(->404 (Table id)
read-check
(hydrate :db [:fields :target] :field_values)
(update-in [:fields] (if include_sensitive_fields
......@@ -82,7 +82,7 @@
(defendpoint POST "/:id/sync"
"Re-sync the metadata for this `Table`."
[id]
(let-404 [table (sel :one Table :id id)]
(let-404 [table (Table id)]
(write-check table)
;; run the task asynchronously
(future (driver/sync-table! table)))
......@@ -107,7 +107,4 @@
new_order))
{:result "success"}))
;; TODO - GET /:id/segments
;; TODO - POST /:id/segments
(define-routes)
......@@ -12,7 +12,7 @@
"Notification about a potential schema change to one of our `Databases`.
Caller can optionally specify a `:table_id` or `:table_name` in the body to limit updates to a single `Table`."
[id :as {{:keys [table_id table_name] :as body} :body}]
(let-404 [database (sel :one Database :id id)]
(let-404 [database (Database id)]
(cond
table_id (when-let [table (sel :one Table :db_id id :id (int table_id))]
(future (driver/sync-table! table)))
......
......@@ -15,18 +15,13 @@
[table :as table])
[metabase.middleware.auth :as auth]))
(defn- +apikey
(def ^:private +apikey
"Wrap API-ROUTES so they may only be accessed with proper apikey credentials."
[api-routes]
(-> api-routes
auth/enforce-apikey))
auth/enforce-api-key)
(defn- +auth
(def ^:private +auth
"Wrap API-ROUTES so they may only be accessed with proper authentiaction credentials."
[api-routes]
(-> api-routes
auth/bind-current-user
auth/enforce-authentication))
auth/enforce-authentication)
(defroutes routes
(context "/card" [] (+auth card/routes))
......
......@@ -41,9 +41,9 @@
:last_name last_name
:is_active true
:is_superuser false)
(sel :one User :id (:id existing-user)))
(User (:id existing-user)))
;; account already exists and is active, so do nothing and just return the account
:else (sel :one User :id (:id existing-user)))
:else (User (:id existing-user)))
(hydrate :user :organization))))
......@@ -76,7 +76,7 @@
:is_superuser (if (:is_superuser @*current-user*)
is_superuser
nil)))
(sel :one User :id id))
(User id))
(defendpoint PUT "/:id/password"
......@@ -88,7 +88,7 @@
(let-404 [user (sel :one [User :password_salt :password] :id id :is_active true)]
(checkp (creds/bcrypt-verify (str (:password_salt user) old_password) (:password user)) "old_password" "Invalid password"))
(set-user-password id password)
(sel :one User :id id))
(User id))
(defendpoint DELETE "/:id"
......
;; -*- comment-column: 35; -*-
(ns metabase.core
(:gen-class)
(:require [clojure.tools.logging :as log]
[clojure.java.browse :refer [browse-url]]
[colorize.core :as color]
[medley.core :as medley]
[metabase.config :as config]
[metabase.db :as db]
(metabase.middleware [auth :as auth]
[log-api-call :refer :all]
[format :refer :all])
[metabase.models.setting :refer [defsetting]]
[metabase.models.user :refer [User]]
[metabase.routes :as routes]
[metabase.setup :as setup]
[metabase.task :as task]
[ring.adapter.jetty :as ring-jetty]
(ring.middleware [cookies :refer [wrap-cookies]]
[gzip :refer [wrap-gzip]]
......@@ -21,7 +11,18 @@
wrap-json-body]]
[keyword-params :refer [wrap-keyword-params]]
[params :refer [wrap-params]]
[session :refer [wrap-session]])))
[session :refer [wrap-session]])
[medley.core :as medley]
(metabase [config :as config]
[db :as db]
[routes :as routes]
[setup :as setup]
[task :as task])
(metabase.middleware [auth :as auth]
[log-api-call :refer :all]
[format :refer :all])
(metabase.models [setting :refer [defsetting]]
[user :refer [User]])))
;; ## CONFIG
......@@ -32,17 +33,19 @@
"The primary entry point to the HTTP server"
(-> routes/routes
(log-api-call :request :response)
format-response ; [METABASE] Do formatting before converting to JSON so serializer doesn't barf
(wrap-json-body ; extracts json POST body and makes it avaliable on request
format-response ; [METABASE] Do formatting before converting to JSON so serializer doesn't barf
(wrap-json-body ; extracts json POST body and makes it avaliable on request
{:keywords? true})
wrap-json-response ; middleware to automatically serialize suitable objects as JSON in responses
wrap-keyword-params ; converts string keys in :params to keyword keys
wrap-params ; parses GET and POST params as :query-params/:form-params and both as :params
auth/wrap-apikey ; looks for a Metabase API Key on the request and assocs as :metabase-apikey
auth/wrap-sessionid ; looks for a Metabase sessionid and assocs as :metabase-sessionid
wrap-cookies ; Parses cookies in the request map and assocs as :cookies
wrap-session ; reads in current HTTP session and sets :session/key
wrap-gzip)) ; GZIP response if client can handle it
wrap-json-response ; middleware to automatically serialize suitable objects as JSON in responses
wrap-keyword-params ; converts string keys in :params to keyword keys
wrap-params ; parses GET and POST params as :query-params/:form-params and both as :params
auth/bind-current-user ; Binds *current-user* and *current-user-id* if :metabase-user-id is non-nil
auth/wrap-current-user-id ; looks for :metabase-session-id and sets :metabase-user-id if Session ID is valid
auth/wrap-api-key ; looks for a Metabase API Key on the request and assocs as :metabase-api-key
auth/wrap-session-id ; looks for a Metabase Session ID and assoc as :metabase-session-id
wrap-cookies ; Parses cookies in the request map and assocs as :cookies
wrap-session ; reads in current HTTP session and sets :session/key
wrap-gzip)) ; GZIP response if client can handle it
(defn- -init-create-setup-token
"Create and set a new setup token, and open the setup URL on the user's system."
......@@ -57,10 +60,7 @@
setup-token)]
(log/info (color/green "Please use the following url to setup your Metabase installation:\n\n"
setup-url
"\n\n"))
;; Attempt to browse URL on user's system; this will just fail silently if we can't do it
;(browse-url setup-url)
))
"\n\n"))))
(defn init
......
......@@ -2,17 +2,17 @@
"Korma database definition and helper functions for interacting with the database."
(:require [clojure.java.jdbc :as jdbc]
[clojure.tools.logging :as log]
[clojure.string :as str]
(clojure [set :as set]
[string :as str])
[environ.core :refer [env]]
(korma [core :refer :all]
[db :refer :all])
(korma [core :as k]
[db :as kdb])
[medley.core :as m]
[metabase.config :as config]
[metabase.db.internal :refer :all :as i]
[metabase.util :as u]))
(declare post-select)
[metabase.db.internal :as i]
[metabase.models.interface :as models]
[metabase.util :as u])
(:import com.metabase.corvus.migrations.LiquibaseMigrations))
;; ## DB FILE, JDBC/KORMA DEFINITONS
......@@ -33,30 +33,30 @@
"Configure connection details for JDBC."
[]
(case (config/config-kw :mb-db-type)
:h2 {:subprotocol "h2"
:classname "org.h2.Driver"
:subname (db-file)}
:h2 {:subprotocol "h2"
:classname "org.h2.Driver"
:subname (db-file)}
:postgres {:subprotocol "postgresql"
:classname "org.postgresql.Driver"
:subname (str "//" (config/config-str :mb-db-host)
":" (config/config-str :mb-db-port)
"/" (config/config-str :mb-db-dbname))
:user (config/config-str :mb-db-user)
:password (config/config-str :mb-db-pass)}))
:classname "org.postgresql.Driver"
:subname (str "//" (config/config-str :mb-db-host)
":" (config/config-str :mb-db-port)
"/" (config/config-str :mb-db-dbname))
:user (config/config-str :mb-db-user)
:password (config/config-str :mb-db-pass)}))
(defn setup-korma-db
"Configure connection details for Korma."
[]
(case (config/config-kw :mb-db-type)
:h2 (h2 {:db (db-file)
:naming {:keys str/lower-case
:fields str/upper-case}})
:postgres (postgres {:db (config/config-str :mb-db-dbname)
:port (config/config-int :mb-db-port)
:user (config/config-str :mb-db-user)
:password (config/config-str :mb-db-pass)
:host (config/config-str :mb-db-host)})))
:h2 (kdb/h2 {:db (db-file)
:naming {:keys str/lower-case
:fields str/upper-case}})
:postgres (kdb/postgres {:db (config/config-str :mb-db-dbname)
:port (config/config-int :mb-db-port)
:user (config/config-str :mb-db-user)
:password (config/config-str :mb-db-pass)
:host (config/config-str :mb-db-host)})))
;; ## CONNECTION
......@@ -81,9 +81,9 @@
[jdbc-db direction]
(let [conn (jdbc/get-connection jdbc-db)]
(case direction
:up (com.metabase.corvus.migrations.LiquibaseMigrations/setupDatabase conn)
:down (com.metabase.corvus.migrations.LiquibaseMigrations/teardownDatabase conn)
:print (com.metabase.corvus.migrations.LiquibaseMigrations/genSqlDatabase conn))))
:up (LiquibaseMigrations/setupDatabase conn)
:down (LiquibaseMigrations/teardownDatabase conn)
:print (LiquibaseMigrations/genSqlDatabase conn))))
;; ## SETUP-DB
......@@ -126,94 +126,17 @@
(log/info "Database Migrations Current ... CHECK")
;; Establish our 'default' Korma DB Connection
(default-connection (create-db korma-db))))
(kdb/default-connection (kdb/create-db korma-db))))
(defn setup-db-if-needed [& args]
(when-not @setup-db-has-been-called?
(apply setup-db args)))
;; # UTILITY FUNCTIONS
;; ## CAST-COLUMNS
;; TODO - Doesn't Korma have similar `transformations` functionality? Investigate.
(def ^:const ^:private type-fns
"A map of column type keywords to the functions that should be used to \"cast\"
them when going `:in` or `:out` of the database."
{:json {:in i/write-json
:out i/read-json}
:keyword {:in name
:out keyword}})
(defn types
"Tag columns in an entity definition with a type keyword.
This keyword will be used to automatically \"cast\" columns when they are present.
;; apply ((type-fns :json) :in) -- cheshire/generate-string -- to value of :details before inserting into DB
;; apply ((type-fns :json) :out) -- read-json -- to value of :details when reading from DB
(defentity Database
(types {:details :json}))"
[entity types-map]
{:pre [(every? keyword? (keys types-map))
(every? (partial contains? type-fns) (vals types-map))]}
(assoc entity ::types types-map))
(defn apply-type-fns
"Recursively apply a sequence of functions associated with COLUMN-TYPE-PAIRS to OBJ.
COLUMN-TYPE-PAIRS should be the value of `(seq (::types korma-entity))`.
DIRECTION should be either `:in` or `:out`."
{:arglists '([direction column-type-pairs obj])}
[direction [[column column-type] & rest-pairs] obj]
(if-not column obj
(recur direction rest-pairs (if-not (column obj) obj
(update-in obj [column] (-> type-fns column-type direction))))))
;; TODO - It would be good to allow custom types by just inserting the `{:in fn :out fn}` inline with the
;; entity definition
;; TODO - hydration-keys should be an entity function for the sake of prettiness
;; ## TIMESTAMPED
(defn timestamped
"Mark ENTITY as having `:created_at` *and* `:updated_at` fields.
(defentity Card
timestamped)
* When a new object is created via `ins`, values for both fields will be generated.
* When an object is updated via `upd`, `:updated_at` will be updated."
[entity]
(assoc entity ::timestamped true))
;; # ---------------------------------------- UTILITY FUNCTIONS ----------------------------------------
;; ## UPD
(defmulti pre-update
"Multimethod that is called by `upd` before DB operations happen.
A good place to set updated values for fields like `updated_at`, or serialize maps into JSON."
(fn [entity _] entity))
(defmethod pre-update :default [_ obj]
obj) ; default impl does no modifications to OBJ
(defmulti post-update
"Multimethod that is called by `upd` after a SQL `UPDATE` *succeeds*.
(This gets called with whatever the output of `pre-update` was).
A good place to schedule asynchronous tasks, such as creating a `FieldValues` object for a `Field`
when it is marked with `special_type` `:category`.
The output of this function is ignored."
(fn [entity _] entity))
(defmethod post-update :default [_ _] ; default impl does nothing and returns nil
nil)
(defn upd
"Wrapper around `korma.core/update` that updates a single row by its id value and
automatically passes &rest KWARGS to `korma.core/set-fields`.
......@@ -224,15 +147,13 @@
[entity entity-id & {:as kwargs}]
{:pre [(integer? entity-id)]}
(let [obj (->> (assoc kwargs :id entity-id)
(pre-update entity)
(#(dissoc % :id))
(apply-type-fns :in (seq (::types entity))))
obj (cond-> obj
(::timestamped entity) (assoc :updated_at (u/new-sql-timestamp)))
result (-> (update entity (set-fields obj) (where {:id entity-id}))
(models/pre-update entity)
(models/internal-pre-update entity)
(#(dissoc % :id)))
result (-> (k/update entity (k/set-fields obj) (k/where {:id entity-id}))
(> 0))]
(when result
(post-update entity (assoc obj :id entity-id)))
(models/post-update entity (assoc obj :id entity-id)))
result))
(defn upd-non-nil-keys
......@@ -248,28 +169,35 @@
"Wrapper around `korma.core/delete` that makes it easier to delete a row given a single PK value.
Returns a `204 (No Content)` response dictionary."
[entity & {:as kwargs}]
(delete entity (where kwargs))
(k/delete entity (k/where kwargs))
{:status 204
:body nil})
;; ## SEL
(defmulti post-select
"Called on the results from a call to `sel`. Default implementation doesn't do anything, but
you can provide custom implementations to do things like add hydrateable keys or remove sensitive fields."
(fn [entity _] entity))
;; Default implementation of post-select
(defmethod post-select :default [_ result]
result)
(defmulti default-fields
"The default fields that should be used for ENTITY by calls to `sel` if none are specified."
identity)
(defmethod default-fields :default [_]
nil) ; by default return nil, which we'll take to mean "everything"
(comment
:id->field `(let [[entity# field#] ~entity]
(->> (sel :many :fields [entity# field# :id] ~@forms)
(map (fn [{id# :id field-val# field#}]
{id# field-val#}))
(into {})))
:field->id `(let [[entity# field#] ~entity]
(->> (sel :many :fields [entity# field# :id] ~@forms)
(map (fn [{id# :id field-val# field#}]
{field-val# id#}))
(into {})))
:field->field `(let [[entity# field1# field2#] ~entity]
(->> (sel :many entity# ~@forms)
(map (fn [obj#]
{(field1# obj#) (field2# obj#)}))
(into {})))
:field->obj `(let [[entity# field#] ~entity]
(->> (sel :many entity# ~@forms)
(map (fn [obj#]
{(field# obj#) obj#}))
(into {})))
)
(defmacro sel
"Wrapper for korma `select` that calls `post-select` on results and provides a few other conveniences.
......@@ -331,91 +259,19 @@
(sel :many Table :db_id 1) -> (select User (where {:id 1}))
(sel :many Table :db_id 1 (order :name :ASC)) -> (select User (where {:id 1}) (order :name ASC))"
{:arglists '([one-or-many option? entity & forms])}
[one-or-many & args]
{:pre [(contains? #{:one :many} one-or-many)]}
(if (= one-or-many :one)
`(first (sel :many ~@args (limit 1)))
(let [[option [entity & forms]] (u/optional keyword? args)]
(case option
:field `(let [[entity# field#] ~entity]
(map field#
(sel :many [entity# field#] ~@forms)))
:id `(sel :many :field [~entity :id] ~@forms)
:id->fields `(->> (sel :many :fields [~@entity :id] ~@forms)
(map (fn [{id# :id :as obj#}]
{id# obj#}))
(into {}))
:id->field `(let [[entity# field#] ~entity]
(->> (sel :many :fields [entity# field# :id] ~@forms)
(map (fn [{id# :id field-val# field#}]
{id# field-val#}))
(into {})))
:field->id `(let [[entity# field#] ~entity]
(->> (sel :many :fields [entity# field# :id] ~@forms)
(map (fn [{id# :id field-val# field#}]
{field-val# id#}))
(into {})))
:field->field `(let [[entity# field1# field2#] ~entity]
(->> (sel :many entity# ~@forms)
(map (fn [obj#]
{(field1# obj#) (field2# obj#)}))
(into {})))
:field->obj `(let [[entity# field#] ~entity]
(->> (sel :many entity# ~@forms)
(map (fn [obj#]
{(field# obj#) obj#}))
(into {})))
:fields `(let [[~'_ & fields# :as entity#] ~entity]
(map #(select-keys % fields#)
(sel :many entity# ~@forms)))
nil `(-sel-select ~entity ~@forms)))))
(defmacro -sel-select
"Internal macro used by `sel` (don't call this directly).
Generates the korma `select` form."
[entity & forms]
(let [forms (sel-apply-kwargs forms)] ; convert kwargs like `:id 1` to korma `where` clause
`(let [[entity# field-keys#] (destructure-entity ~entity) ; pull out field-keys if passed entity vector like `[entity & field-keys]`
entity# (entity->korma entity#) ; entity## is the actual entity like `metabase.models.user/User` that we can dispatch on
entity-select-form# (-> entity# ; entity-select-form# is the tweaked version we'll pass to korma `select`
(assoc :fields (or field-keys#
(default-fields entity#))))] ; tell korma which fields to grab. If `field-keys` weren't passed in vector do lookup at runtime
(when (config/config-bool :mb-db-logging)
(log/debug "DB CALL: " (:name entity#)
(or (:fields entity-select-form#) "*")
~@(mapv (fn [[form & args]]
`[~(name form) ~(apply str (interpose " " args))])
forms)))
(->> (select entity-select-form# ~@forms)
(map (partial apply-type-fns :out (seq (::types entity#))))
(map (partial post-select entity#)))))) ; map `post-select` over the results
{:arglists '([options? entity & forms])}
[& args]
(let [[option args] (u/optional keyword? args)]
`(~(if option
;; if an option was specified, hand off to macro named metabase.db.internal/sel:OPTION
(symbol (format "metabase.db.internal/sel:%s" (name option)))
;; otherwise just hand off to low-level sel* macro
'metabase.db.internal/sel*)
~@args)))
;; ## INS
(defmulti pre-insert
"Gets called by `ins` immediately before inserting a new object immediately before the korma `insert` call.
This provides an opportunity to do things like encode JSON or provide default values for certain fields.
(pre-insert Query [_ query]
(let [defaults {:version 1}]
(merge defaults query))) ; set some default values"
(fn [entity _] entity))
(defmethod pre-insert :default [_ obj]
obj) ; default impl returns object as is
(defmulti post-insert
"Gets called by `ins` after an object is inserted into the DB. (This object is fetched via `sel`).
A good place to do asynchronous tasks such as creating related objects.
Implementations should return the newly created object."
(fn [entity _] entity))
;; Default implementation returns object as-is
(defmethod post-insert :default [_ obj]
obj)
(defn ins
"Wrapper around `korma.core/insert` that renames the `:scope_identity()` keyword in output to `:id`
and automatically passes &rest KWARGS to `korma.core/values`.
......@@ -423,15 +279,11 @@
Returns newly created object by calling `sel`."
[entity & {:as kwargs}]
(let [vals (->> kwargs
(pre-insert entity)
(apply-type-fns :in (seq (::types entity))))
vals (cond-> vals
(::timestamped entity) (assoc :created_at (u/new-sql-timestamp)
:updated_at (u/new-sql-timestamp)))
{:keys [id]} (-> (insert entity (values vals))
(clojure.set/rename-keys {(keyword "scope_identity()") :id}))]
(->> (sel :one entity :id id)
(post-insert entity))))
(models/pre-insert entity)
(models/internal-pre-insert entity))
{:keys [id]} (-> (k/insert entity (k/values vals))
(set/rename-keys {(keyword "scope_identity()") :id}))]
(models/post-insert entity (entity id))))
;; ## EXISTS?
......@@ -441,39 +293,25 @@
(exists? User :id 100)"
[entity & {:as kwargs}]
`(not (empty? (select (entity->korma ~entity)
(fields [:id])
~@(when (seq kwargs)
`[(where ~kwargs)])
(limit 1)))))
`(boolean (seq (k/select (i/entity->korma ~entity)
(k/fields [:id])
(k/where ~(if (seq kwargs) kwargs {}))
(k/limit 1)))))
;; ## CASADE-DELETE
(defmulti pre-cascade-delete
"Called by `cascade-delete` for each matching object that is about to be deleted.
Implementations should delete any objects related to this object by recursively
calling `cascade-delete`.
(defmethod pre-cascade-delete Database [_ {database-id :id :as database}]
(cascade-delete Card :database_id database-id)
...)"
(fn [entity _]
entity))
(defmethod pre-cascade-delete :default [_ instance]
instance)
(defn -cascade-delete [entity f]
(let [entity (i/entity->korma entity)
results (i/sel-exec entity f)]
(dorun (for [obj results]
(do (models/pre-cascade-delete entity obj)
(del entity :id (:id obj))))))
{:status 204, :body nil})
;; TODO - does this *really* need to be a macro?
(defmacro cascade-delete
"Do a cascading delete of object(s). For each matching object, the `pre-cascade-delete` multimethod is called,
which should delete any objects related the object about to be deleted.
Like `del`, this returns a 204/nil reponse so it can be used directly in an API endpoint."
[entity & kwargs]
`(let [entity# (entity->korma ~entity)
instances# (sel :many entity# ~@kwargs)]
(dorun (map (fn [instance#]
(pre-cascade-delete entity# instance#)
(del entity# :id (:id instance#)))
instances#))
{:status 204, :body nil}))
`(-cascade-delete ~entity (i/sel-fn ~@kwargs)))
(ns metabase.db.internal
"Internal functions and macros used by the public-facing functions in `metabase.db`."
(:require [clojure.walk :as walk]
[cheshire.core :as cheshire]
[korma.core :refer [where]]
(:require [clojure.string :as s]
[clojure.tools.logging :as log]
[clojure.walk :as walk]
[korma.core :refer [where], :as k]
[metabase.config :as config]
[metabase.models.interface :as models]
[metabase.util :as u]))
(declare entity->korma)
......@@ -42,7 +45,7 @@
* Symbols like `'metabase.models.user/User` are handled the same way as strings."
(memoize
(fn -entity->korma [entity]
{:post [(= (type %) :korma.core/Entity)]}
{:post [(:metabase.models.interface/entity %)]}
(cond (vector? entity) (-entity->korma (first entity))
(string? entity) (-entity->korma (symbol entity))
(symbol? entity) (try (eval entity)
......@@ -57,22 +60,105 @@
:else entity))))
;; ## READ-JSON
;;; ## ---------------------------------------- SEL 2.0 FUNCTIONS ----------------------------------------
(defn- read-json-str-or-clob
"If JSON-STRING is a JDBC Clob, convert to a String. Then call `json/read-str`."
[json-str]
(some-> (u/jdbc-clob->str json-str)
cheshire/parse-string))
;;; Low-level sel implementation
(defn read-json
"Read JSON-STRING (or JDBC Clob) as JSON and keywordize keys."
[json-string]
(->> (read-json-str-or-clob json-string)
walk/keywordize-keys))
(defmacro sel-fn [& forms]
(let [forms (sel-apply-kwargs forms)
entity-placeholder (gensym "ENTITY--")]
(loop [query `(k/select* ~entity-placeholder), [[f & args] & more] forms]
(cond
f (recur `(~f ~query ~@args) more)
(seq more) (recur query more)
:else `[(fn [~entity-placeholder]
~query) ~(str query)]))))
(defn write-json
"If OBJ is not already a string, encode it as JSON."
[obj]
(if (string? obj) obj
(cheshire/generate-string obj)))
(defn sel-exec [entity [select-fn log-str]]
(let [[entity field-keys] (destructure-entity entity)
entity (entity->korma entity)
entity+fields (assoc entity :fields (or field-keys
(:metabase.models.interface/default-fields entity)))]
;; Log if applicable
(future
(when (config/config-bool :mb-db-logging)
(log/debug "DB CALL: " (:name entity)
(or (:fields entity+fields) "*")
(s/replace log-str #"korma.core/" ""))))
(->> (k/exec (select-fn entity+fields))
(map (partial models/internal-post-select entity))
(map (partial models/post-select entity)))))
(defmacro sel* [entity & forms]
`(sel-exec ~entity (sel-fn ~@forms)))
;;; :field
(defmacro sel:field [[entity field] & forms]
`(let [field# ~field]
(map field# (sel* [~entity field#] ~@forms))))
;;; :id
(defmacro sel:id [entity & forms]
`(sel:field [~entity :id] ~@forms))
;;; :fields
(defn sel:fields* [fields results]
(for [result results]
(select-keys result fields)))
(defmacro sel:fields [[entity & fields] & forms]
`(let [fields# ~(vec fields)]
(sel:fields* (set fields#) (sel* `[~~entity ~@fields#] ~@forms))))
;;; :id->fields
(defn sel:id->fields* [fields results]
(->> results
(map (u/rpartial select-keys fields))
(zipmap (map :id results))))
(defmacro sel:id->fields [[entity & fields] & forms]
`(let [fields# ~(conj (set fields) :id)]
(sel:id->fields* fields# (sel* `[~~entity ~@fields#] ~@forms))))
;;; :field->field
(defn sel:field->field* [f1 f2 results]
(into {} (for [result results]
{(f1 result) (f2 result)})))
(defmacro sel:field->field [[entity f1 f2] & forms]
`(let [f1# ~f1
f2# ~f2]
(sel:field->field* f1# f2# (sel* [~entity f1# f2#] ~@forms))))
;;; : id->field
(defmacro sel:id->field [[entity field] & forms]
`(sel:field->field [~entity :id ~field] ~@forms))
;;; :field->id
(defmacro sel:field->id [[entity field] & forms]
`(sel:field->field [~entity ~field :id] ~@forms))
;;; :field->obj
(defn sel:field->obj* [field results]
(into {} (for [result results]
{(field result) result})))
(defmacro sel:field->obj [[entity field] & forms]
`(sel:field->obj* ~field (sel* ~entity ~@forms)))
;;; :one & :many
(defmacro sel:one [& args]
`(first (metabase.db/sel ~@args (k/limit 1))))
(defmacro sel:many [& args]
`(metabase.db/sel ~@args))
(ns metabase.middleware.auth
"Middleware for dealing with authentication and session management."
(:require [korma.core :as korma]
(:require [korma.core :as k]
[metabase.config :as config]
[metabase.db :refer [sel]]
[metabase.api.common :refer [*current-user* *current-user-id*]]
......@@ -8,16 +8,16 @@
[user :refer [User current-user-fields]])))
(def metabase-session-cookie "metabase.SESSION_ID")
(def metabase-session-header "x-metabase-session")
(def metabase-apikey-header "x-metabase-apikey")
(def ^:const metabase-session-cookie "metabase.SESSION_ID")
(def ^:const metabase-session-header "x-metabase-session")
(def ^:const metabase-api-key-header "x-metabase-apikey")
(def response-unauthentic {:status 401 :body "Unauthenticated"})
(def response-forbidden {:status 403 :body "Forbidden"})
(def ^:const response-unauthentic {:status 401 :body "Unauthenticated"})
(def ^:const response-forbidden {:status 403 :body "Forbidden"})
(defn wrap-sessionid
"Middleware that sets the :metabase-sessionid keyword on the request if a session id can be found.
(defn wrap-session-id
"Middleware that sets the `:metabase-session-id` keyword on the request if a session id can be found.
We first check the request :cookies for `metabase.SESSION_ID`, then if no cookie is found we look in the
http headers for `X-METABASE-SESSION`. If neither is found then then no keyword is bound to the request."
......@@ -25,32 +25,34 @@
(fn [{:keys [cookies headers] :as request}]
(if-let [session-id (or (get-in cookies [metabase-session-cookie :value]) (headers metabase-session-header))]
;; alternatively we could always associate the keyword and just let it be nil if there is no value
(handler (assoc request :metabase-sessionid session-id))
(handler (assoc request :metabase-session-id session-id))
(handler request))))
(defn wrap-current-user-id
"Add `:metabase-user-id` to the request if a valid session token was passed."
[handler]
(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 metabase-session-id
(when-let [session (first (k/select Session
;; NOTE: we join with the User table and ensure user.is_active = true
(k/with User (k/where {:is_active true}))
(k/fields :created_at :user_id)
(k/where {:id metabase-session-id})))]
(let [session-age-ms (- (System/currentTimeMillis) (.getTime ^java.util.Date (get session :created_at (java.util.Date. 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))))))
request))))
(defn enforce-authentication
"Middleware that enforces authentication of the client, cancelling the request processing if auth fails.
Authentication is determined by validating the :metabase-sessionid on the request against the db session list.
If the session is valid then we associate a :metabase-userid on the request and carry on, but if the validation
fails then we return an HTTP 401 response indicating that the client is not authentic.
NOTE: we are purposely not associating the full current user object here so that we can be modular."
(defn enforce-authentication
"Middleware that returns a 401 response if REQUEST has no associated `:metabase-user-id`."
[handler]
(fn [{:keys [metabase-sessionid] :as request}]
;; TODO - what kind of validations can we do on the sessionid to make sure it's safe to handle? str? alphanumeric?
(let [session (first (korma/select Session
;; NOTE: we join with the User table and ensure user.is_active = true
(korma/with User (korma/where {:is_active true}))
(korma/fields :created_at :user_id)
(korma/where {:id metabase-sessionid})))
session-age-ms (- (System/currentTimeMillis) (.getTime ^java.util.Date (get session :created_at (java.util.Date. 0))))]
;; If the session exists and is not expired (max-session-age > session-age) then validation is good
(if (and session (> (config/config-int :max-session-age) (quot session-age-ms 60000)))
(handler (assoc request :metabase-userid (:user_id session)))
;; default response is 401
response-unauthentic))))
(fn [{:keys [metabase-user-id] :as request}]
(if metabase-user-id
(handler request)
response-unauthentic)))
(defmacro sel-current-user [current-user-id]
......@@ -65,35 +67,35 @@
*current-user* delay that returns current user (or nil) from DB"
[handler]
(fn [request]
(let [current-user-id (:metabase-userid request)]
(if-let [current-user-id (:metabase-user-id request)]
(binding [*current-user-id* current-user-id
*current-user* (if-not current-user-id (atom nil)
(delay (sel-current-user current-user-id)))]
(handler request)))))
*current-user* (delay (sel-current-user current-user-id))]
(handler request))
(handler request))))
(defn wrap-apikey
"Middleware that sets the :metabase-apikey keyword on the request if a valid API Key can be found.
(defn wrap-api-key
"Middleware that sets the :metabase-api-key keyword on the request if a valid API Key can be found.
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-apikey-header)]
(handler (assoc request :metabase-apikey api-key))
(if-let [api-key (headers metabase-api-key-header)]
(handler (assoc request :metabase-api-key api-key))
(handler request))))
(defn enforce-apikey
(defn enforce-api-key
"Middleware that enforces validation of the client via API Key, cancelling the request processing if the check fails.
Validation is handled by first checking for the presence of the :metabase-apikey on the request. If the api key
Validation is handled by first checking for the presence of the :metabase-api-key on the request. If the api key
is available then we validate it by checking it against the configured :mb-api-key value set in our global config.
If the request :metabase-apikey matches the configured :mb-api-key value then the request continues, otherwise we
If the request :metabase-api-key matches the configured :mb-api-key value then the request continues, otherwise we
reject the request and return a 403 Forbidden response."
[handler]
(fn [{:keys [metabase-apikey] :as request}]
(if (= (config/config-str :mb-api-key) metabase-apikey)
(fn [{:keys [metabase-api-key] :as request}]
(if (= (config/config-str :mb-api-key) metabase-api-key)
(handler request)
;; default response is 403
response-forbidden)))
......@@ -3,6 +3,7 @@
(cheshire factory
[generate :refer [add-encoder encode-str]])
[medley.core :refer [filter-vals map-vals]]
[metabase.models.interface :refer [api-serialize]]
[metabase.util :as util]))
(declare -format-response)
......@@ -45,11 +46,14 @@
[m]
(filter-vals #(not (or (delay? %)
(fn? %)))
m))
;; Convert typed maps such as metabase.models.database/DatabaseInstance to plain maps because empty, which is used internally by filter-vals,
;; will fail otherwise
(into {} m)))
(defn- -format-response [obj]
(cond
(map? obj) (->> (remove-fns-and-delays obj) ; recurse over all vals in the map
(map-vals -format-response))
(coll? obj) (map -format-response obj) ; recurse over all items in the collection
(map? obj) (->> (api-serialize obj)
remove-fns-and-delays
(map-vals -format-response)) ; recurse over all vals in the map
(coll? obj) (map -format-response obj) ; recurse over all items in the collection
:else obj))
(ns metabase.models.card
(:require [korma.core :refer :all]
(:require [korma.core :refer :all, :exclude [defentity]]
[metabase.api.common :refer [*current-user-id*]]
[metabase.db :refer :all]
(metabase.models [common :refer :all]
(metabase.models [interface :refer :all]
[user :refer [User]])))
(def ^:const display-types
......@@ -18,19 +18,25 @@
:table
:timeseries})
(defrecord CardInstance []
clojure.lang.IFn
(invoke [this k]
(get this k)))
(extend-ICanReadWrite CardInstance :read :public-perms, :write :public-perms)
(defentity Card
(table :report_card)
(types {:dataset_query :json
:display :keyword
:visualization_settings :json})
timestamped
(assoc :hydration-keys #{:card}))
(defmethod post-select Card [_ {:keys [creator_id] :as card}]
(-> (assoc card
:creator (delay (sel :one User :id creator_id)))
assoc-permissions-sets))
(defmethod pre-cascade-delete Card [_ {:keys [id]}]
(cascade-delete 'metabase.models.dashboard-card/DashboardCard :card_id id)
(cascade-delete 'metabase.models.card-favorite/CardFavorite :card_id id))
[(table :report_card)
(hydration-keys card)
(types :dataset_query :json, :display :keyword, :visualization_settings :json)
timestamped]
(post-select [_ {:keys [creator_id] :as card}]
(map->CardInstance (assoc card :creator (delay (User creator_id)))))
(pre-cascade-delete [_ {:keys [id]}]
(cascade-delete 'metabase.models.dashboard-card/DashboardCard :card_id id)
(cascade-delete 'metabase.models.card-favorite/CardFavorite :card_id id)))
(extend-ICanReadWrite CardEntity :read :public-perms, :write :public-perms)
(ns metabase.models.card-favorite
(:require [korma.core :refer :all]
(:require [korma.core :refer :all, :exclude [defentity]]
[metabase.db :refer :all]
(metabase.models [card :refer [Card]]
[interface :refer :all]
[user :refer [User]])))
(defentity CardFavorite
(table :report_cardfavorite)
timestamped)
[(table :report_cardfavorite)
timestamped]
(defmethod post-select CardFavorite [_ {:keys [card_id owner_id] :as card-favorite}]
(assoc card-favorite
:owner (delay (sel :one User :id owner_id))
:card (delay (sel :one Card :id card_id))))
(post-select [_ {:keys [card_id owner_id] :as card-favorite}]
(assoc card-favorite
:owner (delay (User owner_id))
:card (delay (Card card_id)))))
......@@ -3,7 +3,7 @@
[metabase.api.common :refer [*current-user* *current-user-id* check]]
[metabase.util :as u]))
(def timezones
(def ^:const timezones
["GMT"
"UTC"
"US/Alaska"
......@@ -15,62 +15,16 @@
"US/Pacific"
"America/Costa_Rica"])
;;; ALLEN'S PERMISSIONS IMPLEMENTATION
(def ^:const perms-none 0)
(def ^:const perms-read 1)
(def ^:const perms-readwrite 2)
(def perms-none 0)
(def perms-read 1)
(def perms-readwrite 2)
(def permissions
(def ^:const permissions
[{:id perms-none :name "None"},
{:id perms-read :name "Read Only"},
{:id perms-readwrite :name "Read & Write"}])
;;; CAM'S PERMISSIONS IMPL
;; (TODO - need to use one or the other)
(defn public-permissions
"Return the set of public permissions for some object with key `:public_perms`. Possible permissions are `:read` and `:write`."
[{:keys [public_perms]}]
(check public_perms 500 "Can't check public permissions: object doesn't have :public_perms.")
({0 #{}
1 #{:read}
2 #{:read :write}} public_perms))
(defn user-permissions
"Return the set of current user's permissions for some object with keys `:creator_id` and `:public_perms`."
[{:keys [creator_id public_perms] :as obj}]
(check creator_id 500 "Can't check user permissions: object doesn't have :creator_id."
public_perms 500 "Can't check user permissions: object doesn't have :public_perms.")
(cond (:is_superuser *current-user*) #{:read :write} ; superusers have full access to everything
(= creator_id *current-user-id*) #{:read :write} ; if user created OBJ they have all permissions
(<= perms-read public_perms) #{:read} ; if the object is public then everyone gets :read
:else #{})) ; default is user has no permissions a.k.a private
(defn user-can?
"Check if *current-user* has a given PERMISSION for OBJ.
PERMISSION should be either `:read` or `:write`."
[permission obj]
(contains? @(:user-permissions-set obj) permission))
(defn assoc-permissions-sets
"Associates the following delays with OBJ:
* `:public-permissions-set`
* `:user-permissions-set`
* `:can_read`
* `:can_write`
Note that these delays depend upon the presence of `creator_id`, and `public_perms` fields in OBJ."
[obj]
(u/assoc* obj
:public-permissions-set (delay (public-permissions <>))
:user-permissions-set (delay (user-permissions <>))
:can_read (delay (user-can? :read <>))
:can_write (delay (user-can? :write <>))))
(defn name->human-readable-name
"Convert a string NAME of some object like a `Table` or `Field` to one more friendly to humans.
......
(ns metabase.models.dashboard
(:require [korma.core :refer :all]
(:require [korma.core :refer :all, :exclude [defentity]]
[metabase.db :refer :all]
(metabase.models [common :refer :all]
[dashboard-card :refer [DashboardCard]]
[interface :refer :all]
[user :refer [User]])
[metabase.util :as u]))
(defrecord DashboardInstance []
clojure.lang.IFn
(invoke [this k]
(get this k)))
(extend-ICanReadWrite DashboardInstance :read :public-perms, :write :public-perms)
(defentity Dashboard
(table :report_dashboard)
timestamped)
[(table :report_dashboard)
timestamped]
(post-select [_ {:keys [id creator_id description] :as dash}]
(-> dash
(assoc :creator (delay (User creator_id))
:description (u/jdbc-clob->str description)
:ordered_cards (delay (sel :many DashboardCard :dashboard_id id (order :created_at :asc))))
map->DashboardInstance))
(defmethod post-select Dashboard [_ {:keys [id creator_id description] :as dash}]
(-> dash
(assoc :creator (delay (sel :one User :id creator_id))
:description (u/jdbc-clob->str description)
:ordered_cards (delay (sel :many DashboardCard :dashboard_id id (order :created_at :asc))))
assoc-permissions-sets))
(pre-cascade-delete [_ {:keys [id]}]
(cascade-delete DashboardCard :dashboard_id id)))
(defmethod pre-cascade-delete Dashboard [_ {:keys [id]}]
(cascade-delete DashboardCard :dashboard_id id))
(extend-ICanReadWrite DashboardEntity :read :public-perms, :write :public-perms)
(ns metabase.models.dashboard-card
(:require [korma.core :refer :all]
(:require [clojure.set :as set]
[korma.core :refer :all, :exclude [defentity]]
[metabase.db :refer :all]
(metabase.models [card :refer [Card]])))
(metabase.models [card :refer [Card]]
[interface :refer :all])))
(defentity DashboardCard
(table :report_dashboardcard)
timestamped)
[(table :report_dashboardcard)
timestamped]
;; #### fields:
;; * `id`
;; * `created_at`
;; * `updated_at`
;; * `sizeX`
;; * `sizeY`
;; * `row`
;; * `col`
;; * `card_id`
;; * `dashboard_id`
(pre-insert [_ dashcard]
(let [defaults {:sizeX 2
:sizeY 2}]
(merge defaults dashcard)))
(defmethod post-select DashboardCard [_ {:keys [card_id dashboard_id] :as dashcard}]
(-> dashcard
(clojure.set/rename-keys {:sizex :sizeX ; mildly retarded: H2 columns are all uppercase, we're converting them
:sizey :sizeY}) ; to all downcase, and the Angular app expected mixed-case names here
(assoc :card (delay (sel :one Card :id card_id))
:dashboard (delay (sel :one 'metabase.models.dashboard/Dashboard :id dashboard_id)))))
(defmethod pre-insert DashboardCard [_ dashcard]
(let [defaults {:sizeX 2
:sizeY 2}]
(merge defaults dashcard)))
(post-select [_ {:keys [card_id dashboard_id] :as dashcard}]
(-> dashcard
(set/rename-keys {:sizex :sizeX ; mildly retarded: H2 columns are all uppercase, we're converting them
:sizey :sizeY}) ; to all downcase, and the Angular app expected mixed-case names here
(assoc :card (delay (Card card_id))
:dashboard (delay (sel :one 'metabase.models.dashboard/Dashboard :id dashboard_id))))))
(ns metabase.models.dashboard-subscription
(:require [korma.core :refer :all]))
(defentity DashboardSubscription
(table :report_dashboardsubscription))
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