Compare commits
17 Commits
python-run
...
data-apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b51178eb13 | ||
|
|
024b40066b | ||
|
|
ec4463e206 | ||
|
|
2fc88c360c | ||
|
|
201fa7a7b8 | ||
|
|
e8491a0a2a | ||
|
|
b4c0e8bb27 | ||
|
|
6c29dbd547 | ||
|
|
b64c8c1c83 | ||
|
|
ef182d6d4b | ||
|
|
86361d832c | ||
|
|
87f57d1075 | ||
|
|
08ff5bbaad | ||
|
|
f2c7142876 | ||
|
|
22427fc794 | ||
|
|
107d0ae201 | ||
|
|
4a2fae7c45 |
@@ -410,7 +410,7 @@
|
||||
instaparse.core/defparser clojure.core/def
|
||||
metabase-enterprise.serialization.test-util/with-dbs clojure.core/fn
|
||||
metabase-enterprise.serialization.test-util/with-random-dump-dir clojure.core/let
|
||||
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
|
||||
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
|
||||
metabase.actions.test-util/with-actions clojure.core/let
|
||||
metabase.api.common/let-404 clojure.core/let
|
||||
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn
|
||||
@@ -421,6 +421,8 @@ metabase-enterprise.content-translation.routes-test/with-new-secret-key!
|
||||
metabase.channel.impl.http-test/with-captured-http-requests clojure.core/fn
|
||||
metabase.channel.impl.http-test/with-server clojure.core/let
|
||||
metabase.collections.models.collection-test/with-collection-in-location clojure.core/let
|
||||
metabase.data-apps.test-util/with-data-app! clojure.core/let
|
||||
metabase.data-apps.test-util/with-released-app! clojure.core/let
|
||||
metabase.driver.mongo.connection/with-mongo-client clojure.core/let
|
||||
metabase.driver.mongo.connection/with-mongo-database clojure.core/let
|
||||
metabase.driver.mongo.query-processor/mongo-let clojure.core/let
|
||||
|
||||
@@ -411,6 +411,15 @@
|
||||
warehouse-schema
|
||||
xrays}}
|
||||
|
||||
data-apps
|
||||
{:team "Workflows"
|
||||
:api #{metabase.data-apps.api
|
||||
metabase.data-apps.core}
|
||||
:uses #{api
|
||||
models
|
||||
request
|
||||
util}}
|
||||
|
||||
database-routing
|
||||
{:team "Admin Webapp"
|
||||
:api #{metabase.database-routing.core}
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
metabase.collections.models.collection-test/with-personal-and-impersonal-collections [[:block 1]]
|
||||
metabase.config/build-type-case [[:block 0]]
|
||||
metabase.dashboards.models.dashboard-test/with-dash-in-collection [[:inner 0]]
|
||||
metabase.data-apps.test-util/with-data-app-cleanup! [[:block 0]]
|
||||
metabase.driver-api.core/match [[:inner 0]]
|
||||
metabase.driver-api.core/match-one [[:inner 0]]
|
||||
metabase.driver-api.core/replace [[:inner 0]]
|
||||
|
||||
474
dev/src/dev/db_schema.clj
Normal file
474
dev/src/dev/db_schema.clj
Normal file
@@ -0,0 +1,474 @@
|
||||
(ns dev.db-schema
|
||||
(:require
|
||||
[clojure.data.json :as json]
|
||||
[clojure.string :as str]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(org.postgresql.jdbc PgArray)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn strip-nils
|
||||
"Remove any keys corresponding to nil values from the given map."
|
||||
[m]
|
||||
(if (some (comp nil? val) m)
|
||||
(with-meta (into {} (remove nil-val?) m) (meta m))
|
||||
m))
|
||||
|
||||
;; Default values that can be omitted from compact representation
|
||||
(def column-defaults
|
||||
{:is_nullable false
|
||||
:default nil
|
||||
:character_maximum_length nil
|
||||
:numeric_precision nil
|
||||
:numeric_scale nil
|
||||
:datetime_precision 6})
|
||||
|
||||
(def index-defaults
|
||||
{:is_unique false
|
||||
:is_primary false})
|
||||
|
||||
;; Type mappings from PostgreSQL to generic types
|
||||
(def type-mappings
|
||||
{"integer" "int"
|
||||
"character varying" "varchar"
|
||||
"boolean" "bool"
|
||||
"timestamp without time zone" "timestamp"
|
||||
"timestamp with time zone" "timestamptz"
|
||||
"double precision" "double"})
|
||||
|
||||
;; Common defaults for specific types
|
||||
(def type-defaults
|
||||
{"varchar" {:character_maximum_length 254}
|
||||
"int" {:numeric_precision 32 :numeric_scale 0}})
|
||||
|
||||
;; Standard ID column definition
|
||||
(def id-column-defaults
|
||||
{:column_name "id"
|
||||
:type "int"
|
||||
:is_nullable false
|
||||
:default nil
|
||||
:character_maximum_length nil
|
||||
:numeric_precision 32
|
||||
:numeric_scale 0
|
||||
:datetime_precision nil})
|
||||
|
||||
;; Pseudo type definitions
|
||||
(def entity-id-defaults
|
||||
{:type "varchar"
|
||||
:character_maximum_length 21
|
||||
:is_nullable true
|
||||
:datetime_precision 6})
|
||||
|
||||
(def auto-timestamp-defaults
|
||||
{:type "timestamptz"
|
||||
:default "now()"
|
||||
:character_maximum_length nil
|
||||
:numeric_precision nil
|
||||
:numeric_scale nil
|
||||
:is_nullable false
|
||||
:datetime_precision 6})
|
||||
|
||||
(def pk-defaults
|
||||
{:type "int"
|
||||
:is_nullable false
|
||||
:default nil
|
||||
:character_maximum_length nil
|
||||
:numeric_precision 32
|
||||
:numeric_scale 0
|
||||
:datetime_precision 6})
|
||||
|
||||
(def fk-defaults
|
||||
{:type "int"
|
||||
:is_nullable false
|
||||
:default nil
|
||||
:character_maximum_length nil
|
||||
:numeric_precision 32
|
||||
:numeric_scale 0
|
||||
:datetime_precision nil})
|
||||
|
||||
(defn get-table-columns [table-name]
|
||||
(t2/query
|
||||
["SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
character_maximum_length,
|
||||
numeric_precision,
|
||||
numeric_scale,
|
||||
datetime_precision,
|
||||
udt_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = ?
|
||||
ORDER BY ordinal_position"
|
||||
table-name]))
|
||||
|
||||
(defn get-table-indexes [table-name]
|
||||
(t2/query
|
||||
["SELECT
|
||||
i.relname as index_name,
|
||||
array_agg(
|
||||
CASE WHEN ix.indoption[c.ordinality-1] & 1 = 1
|
||||
THEN json_build_array(a.attname, 'desc')::text
|
||||
ELSE a.attname
|
||||
END
|
||||
ORDER BY c.ordinality
|
||||
) as columns,
|
||||
ix.indisunique as is_unique,
|
||||
ix.indisprimary as is_primary
|
||||
FROM pg_class t
|
||||
JOIN pg_index ix ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN unnest(ix.indkey) WITH ORDINALITY AS c(attnum, ordinality) ON true
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = c.attnum
|
||||
WHERE t.relname = ?
|
||||
GROUP BY i.relname, ix.indisunique, ix.indisprimary, ix.indoption
|
||||
ORDER BY i.relname"
|
||||
table-name]))
|
||||
|
||||
(defn get-tables [table-filter]
|
||||
(let [base-query "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
query (cond
|
||||
(str/includes? table-filter ",")
|
||||
(let [table-list (map str/trim (str/split table-filter #","))
|
||||
placeholders (str/join "," (repeat (count table-list) "?"))]
|
||||
(into [(str base-query " AND table_name IN (" placeholders ")")]
|
||||
table-list))
|
||||
|
||||
(str/ends-with? table-filter "%")
|
||||
[(str base-query " AND table_name LIKE ?")
|
||||
table-filter]
|
||||
|
||||
:else
|
||||
[(str base-query " AND table_name = ?")
|
||||
table-filter])]
|
||||
(->> (t2/query query)
|
||||
(map :table_name))))
|
||||
|
||||
(defn remove-defaults [m defaults]
|
||||
(reduce-kv (fn [acc k v]
|
||||
(if (or (= v (get defaults k)) (nil? v))
|
||||
acc
|
||||
(assoc acc k v)))
|
||||
{}
|
||||
m))
|
||||
|
||||
(defn expand-defaults [m defaults]
|
||||
(merge defaults m))
|
||||
|
||||
(defn get-foreign-keys [table-name]
|
||||
(t2/query
|
||||
["SELECT
|
||||
kcu.column_name,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name,
|
||||
rc.delete_rule
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage AS ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
AND ccu.table_schema = tc.table_schema
|
||||
JOIN information_schema.referential_constraints AS rc
|
||||
ON tc.constraint_name = rc.constraint_name
|
||||
AND tc.table_schema = rc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_name = ?" table-name]))
|
||||
|
||||
(defn- sort-column-keys [column]
|
||||
(merge (select-keys column [:column_name :type])
|
||||
(into (sorted-map) (dissoc column :column_name :type))))
|
||||
|
||||
(defn compact-type [col]
|
||||
(let [type (:type col)]
|
||||
(cond
|
||||
(and (= type "varchar") (:character_maximum_length col))
|
||||
(str type "(" (:character_maximum_length col) ")")
|
||||
|
||||
(and (= type "int") (:numeric_precision col) (not= (:numeric_precision col) 32))
|
||||
(str type "(" (:numeric_precision col) ")")
|
||||
|
||||
:else type)))
|
||||
|
||||
(defn normalize-column [col]
|
||||
(let [generic-type (get type-mappings (:data_type col) (:data_type col))
|
||||
base-normalized (-> col
|
||||
(select-keys [:column_name :data_type :is_nullable
|
||||
:character_maximum_length :numeric_precision :numeric_scale
|
||||
:datetime_precision :udt_name])
|
||||
(update :is_nullable #(= "YES" %))
|
||||
(assoc :default (some-> (:column_default col) str))
|
||||
(assoc :type generic-type)
|
||||
(dissoc :data_type :udt_name))]
|
||||
(cond
|
||||
(and (= "id" (:column_name base-normalized))
|
||||
(= base-normalized (merge id-column-defaults {:column_name "id"})))
|
||||
:id
|
||||
|
||||
(and (= "entity_id" (:column_name base-normalized))
|
||||
(= base-normalized (merge entity-id-defaults {:column_name (:column_name base-normalized)})))
|
||||
{:column_name (:column_name base-normalized), :type "entity_id"}
|
||||
|
||||
;; Foreign key
|
||||
(and (str/ends-with? (:column_name base-normalized) "_id")
|
||||
(= base-normalized (merge fk-defaults {:column_name (:column_name base-normalized)})))
|
||||
{:column_name (:column_name base-normalized), :type "fk"}
|
||||
|
||||
(and (= "created_at" (:column_name base-normalized))
|
||||
(= base-normalized (merge auto-timestamp-defaults {:column_name "created_at"})))
|
||||
:created_at
|
||||
|
||||
(and (= "updated_at" (:column_name base-normalized))
|
||||
(= base-normalized (merge auto-timestamp-defaults {:column_name "updated_at"})))
|
||||
:updated_at
|
||||
|
||||
;; Remove type-specific defaults if they match exactly
|
||||
:else
|
||||
(let [type-defaults-for-type (get type-defaults generic-type {})
|
||||
without-common-defaults (remove-defaults base-normalized column-defaults)
|
||||
without-type-defaults (if (and (seq type-defaults-for-type)
|
||||
(every? (fn [[k v]] (= (get without-common-defaults k) v)) type-defaults-for-type))
|
||||
(remove-defaults without-common-defaults type-defaults-for-type)
|
||||
without-common-defaults)
|
||||
;; Compact the type with its parameters
|
||||
compacted (-> without-type-defaults
|
||||
(assoc :type (compact-type without-type-defaults))
|
||||
(dissoc :character_maximum_length :numeric_precision :numeric_scale))]
|
||||
(sort-column-keys (into (sorted-map) compacted))))))
|
||||
|
||||
(defn expand-column [col]
|
||||
(cond
|
||||
(= col :id)
|
||||
id-column-defaults
|
||||
|
||||
(= col :created_at)
|
||||
(merge auto-timestamp-defaults {:column_name "created_at"})
|
||||
|
||||
(= col :updated_at)
|
||||
(merge auto-timestamp-defaults {:column_name "updated_at"})
|
||||
|
||||
(and (map? col) (= "pk" (:type col)))
|
||||
(merge pk-defaults col)
|
||||
|
||||
(and (map? col) (= "fk" (:type col)))
|
||||
(merge fk-defaults (dissoc col :target_table :target_column))
|
||||
|
||||
(and (map? col) (= "entity_id" (:type col)))
|
||||
(merge entity-id-defaults col)
|
||||
|
||||
(and (map? col) (= "created_at" (:type col)))
|
||||
(merge auto-timestamp-defaults col)
|
||||
|
||||
(and (map? col) (= "updated_at" (:type col)))
|
||||
(merge auto-timestamp-defaults col)
|
||||
|
||||
:else
|
||||
(let [with-common-defaults (expand-defaults col column-defaults)
|
||||
generic-type (:type with-common-defaults)
|
||||
type-defaults-for-type (get type-defaults generic-type {})]
|
||||
(merge type-defaults-for-type with-common-defaults))))
|
||||
|
||||
(defn pk-shorthand? [idx]
|
||||
(and (:is_primary idx)
|
||||
(= 1 (count (:columns idx)))
|
||||
(let [first-col (first (:columns idx))]
|
||||
(= (if (vector? first-col) (first first-col) first-col) "id"))))
|
||||
|
||||
(defn fk-shorthand? [idx table-name]
|
||||
(and (not (:is_primary idx))
|
||||
(not (:is_unique idx))
|
||||
(= 1 (count (:columns idx)))
|
||||
(let [col-name (first (:columns idx))
|
||||
col-name (if (vector? col-name) (first col-name) col-name)]
|
||||
(and (str/ends-with? col-name "_id")
|
||||
(= (:index_name idx)
|
||||
(str "idx_" table-name "_" col-name))))))
|
||||
|
||||
(defn normalize-index [idx table-name]
|
||||
(let [base (-> idx
|
||||
(select-keys [:index_name :columns :is_unique :is_primary])
|
||||
(update :columns (fn [pg-array]
|
||||
(let [raw-cols (.getArray ^PgArray pg-array)]
|
||||
(mapv (fn [col]
|
||||
(if (and (string? col) (str/starts-with? col "["))
|
||||
;; JSON string, parse it
|
||||
(let [parsed (json/read-str col)]
|
||||
[(first parsed) (keyword (second parsed))])
|
||||
;; Regular column name
|
||||
col))
|
||||
raw-cols)))))]
|
||||
(cond
|
||||
(pk-shorthand? base) :pk
|
||||
(fk-shorthand? base table-name) {:fk (let [first-col (first (:columns base))]
|
||||
(if (vector? first-col)
|
||||
(first first-col)
|
||||
first-col))}
|
||||
:else (remove-defaults base index-defaults))))
|
||||
|
||||
(defn extract-schema [table-filter]
|
||||
(let [tables (get-tables table-filter)]
|
||||
(reduce (fn [acc table]
|
||||
(let [columns (map normalize-column (get-table-columns table))
|
||||
foreign-keys (get-foreign-keys table)
|
||||
fk-map (into {} (map (fn [fk]
|
||||
[(:column_name fk)
|
||||
{:target_table (:foreign_table_name fk)
|
||||
:target_column (:foreign_column_name fk)
|
||||
:on_delete (let [on-delete (str/lower-case (:delete_rule fk))]
|
||||
(keyword ({"no action" :restrict} on-delete on-delete)))}])
|
||||
foreign-keys))
|
||||
;; Update fk columns with actual targets
|
||||
updated-columns (map (fn [col]
|
||||
(if (and (map? col) (= "fk" (:type col)))
|
||||
(let [fk-info (get fk-map (:column_name col))
|
||||
target-table (:target_table fk-info)
|
||||
target-column (:target_column fk-info)]
|
||||
(cond-> (assoc col :target_table target-table)
|
||||
(and target-column
|
||||
(not (= (:column_name col) (str target-table "_" target-column))))
|
||||
(assoc :target_column target-column)
|
||||
|
||||
(not (#{nil :cascade} (:on_delete fk-info)))
|
||||
(assoc :on_delete (:on_delete fk-info))))
|
||||
col))
|
||||
columns)
|
||||
indexes (map #(normalize-index % table) (get-table-indexes table))
|
||||
;; Filter out PK indexes since they're implicit in pk columns
|
||||
filtered-indexes (remove (fn [idx]
|
||||
(or (= idx :pk)
|
||||
(and (map? idx)
|
||||
(:is_primary idx)
|
||||
(= ["id"] (:columns idx)))))
|
||||
indexes)
|
||||
;; Filter out FK indexes for columns with fk type
|
||||
fk-columns (set (map :column_name (filter #(and (map? %) (= "fk" (:type %))) updated-columns)))
|
||||
final-indexes (remove (fn [idx]
|
||||
(and (map? idx)
|
||||
(:fk idx)
|
||||
(contains? fk-columns (:fk idx))))
|
||||
filtered-indexes)]
|
||||
(assoc acc (keyword table)
|
||||
{:columns updated-columns
|
||||
:indexes final-indexes})))
|
||||
(sorted-map)
|
||||
tables)))
|
||||
|
||||
(defn expand-index [idx table-name]
|
||||
(cond
|
||||
(= idx :pk)
|
||||
{:index_name (str table-name "_pkey")
|
||||
:columns ["id"]
|
||||
:is_unique true
|
||||
:is_primary true}
|
||||
|
||||
(and (map? idx) (:fk idx))
|
||||
(let [col-name (:fk idx)]
|
||||
{:index_name (str "idx_" table-name "_" col-name)
|
||||
:columns [col-name]
|
||||
:is_unique false
|
||||
:is_primary false})
|
||||
|
||||
:else
|
||||
(expand-defaults idx index-defaults)))
|
||||
|
||||
(defn expand-schema [compact-schema]
|
||||
(reduce-kv (fn [acc table-key table-def]
|
||||
(let [table-name (name table-key)
|
||||
expanded-columns (map (comp strip-nils expand-column) (:columns table-def))
|
||||
;; Add implicit indexes for pk and fk columns
|
||||
pk-indexes (map (fn [col]
|
||||
(when (= col :id)
|
||||
{:index_name (str table-name "_pkey")
|
||||
:columns ["id"]
|
||||
:is_unique true
|
||||
:is_primary true}))
|
||||
(:columns table-def))
|
||||
fk-indexes (map (fn [col]
|
||||
(when (and (map? col) (= "fk" (:type col)))
|
||||
{:index_name (str "idx_" table-name "_" (:column_name col))
|
||||
:columns [(:column_name col)]
|
||||
:is_unique false
|
||||
:is_primary false}))
|
||||
(:columns table-def))
|
||||
explicit-indexes (map #(expand-index % table-name) (:indexes table-def))
|
||||
all-indexes (map strip-nils (concat (remove nil? pk-indexes)
|
||||
(remove nil? fk-indexes)
|
||||
explicit-indexes))]
|
||||
(assoc acc table-key
|
||||
{:columns expanded-columns
|
||||
:indexes all-indexes})))
|
||||
(sorted-map)
|
||||
compact-schema))
|
||||
|
||||
(defn output [& args]
|
||||
(let [table-filter (or (first args) "%")]
|
||||
(extract-schema table-filter)))
|
||||
|
||||
(comment
|
||||
(output)
|
||||
(= (output "data_app%")
|
||||
{:data_app {:columns [:id
|
||||
{:column_name "name", :type "text"}
|
||||
{:column_name "description", :type "text", :is_nullable true}
|
||||
{:column_name "slug", :type "varchar"}
|
||||
{:column_name "creator_id", :type "fk", :target_table "core_user", :target_column "id"}
|
||||
{:column_name "status", :type "varchar(32)"}
|
||||
{:column_name "entity_id", :type "character", :is_nullable true}
|
||||
:created_at
|
||||
:updated_at],
|
||||
:indexes [{:index_name "data_app_slug_key", :columns ["slug"], :is_unique true}]},
|
||||
:data_app_definition {:columns [:id
|
||||
{:column_name "app_id", :type "fk", :target_table "data_app", :target_column "id"}
|
||||
{:column_name "creator_id", :type "fk", :target_table "core_user", :target_column "id"}
|
||||
{:column_name "revision_number", :type "int"}
|
||||
{:column_name "config", :type "text"}
|
||||
:created_at],
|
||||
:indexes [{:index_name "idx_data_app_definition_app_id_revision_numer",
|
||||
:columns ["app_id" ["revision_number" :desc]]}
|
||||
{:index_name "uniq_idx_data_app_definition_app_id_revision_number",
|
||||
:columns ["app_id" "revision_number"],
|
||||
:is_unique true}]},
|
||||
:data_app_release {:columns [:id
|
||||
{:column_name "app_id", :type "fk", :target_table "data_app", :target_column "id"}
|
||||
{:column_name "app_definition_id",
|
||||
:type "fk",
|
||||
:target_table "data_app_definition",
|
||||
:target_column "id",
|
||||
:on_delete :restrict}
|
||||
{:column_name "creator_id", :type "fk", :target_table "core_user", :target_column "id"}
|
||||
{:column_name "retracted", :type "bool", :default "false"}
|
||||
:created_at
|
||||
:updated_at],
|
||||
:indexes [{:index_name "idx_data_app_release_app_id_retracted_id",
|
||||
:columns ["app_id" "retracted" ["id" :desc]]}]}})
|
||||
|
||||
(= (expand-schema (output "data_app"))
|
||||
'{:data_app {:columns ({:column_name "id", :type "int", :is_nullable false, :numeric_precision 32, :numeric_scale 0}
|
||||
{:is_nullable false, :datetime_precision 6, :column_name "name", :type "text"}
|
||||
{:is_nullable true, :datetime_precision 6, :column_name "description", :type "text"}
|
||||
{:is_nullable false, :datetime_precision 6, :column_name "slug", :type "varchar"}
|
||||
{:type "fk",
|
||||
:is_nullable false,
|
||||
:numeric_precision 32,
|
||||
:numeric_scale 0,
|
||||
:column_name "creator_id"}
|
||||
{:is_nullable false, :datetime_precision 6, :column_name "status", :type "varchar(32)"}
|
||||
{:is_nullable true, :datetime_precision 6, :column_name "entity_id", :type "character"}
|
||||
{:type "timestamptz",
|
||||
:default "now()",
|
||||
:is_nullable false,
|
||||
:datetime_precision 6,
|
||||
:column_name "created_at"}
|
||||
{:type "timestamptz",
|
||||
:default "now()",
|
||||
:is_nullable false,
|
||||
:datetime_precision 6,
|
||||
:column_name "updated_at"}),
|
||||
:indexes ({:index_name "data_app_pkey", :columns ["id"], :is_unique true, :is_primary true}
|
||||
{:index_name "idx_data_app_creator_id",
|
||||
:columns ["creator_id"],
|
||||
:is_unique false,
|
||||
:is_primary false}
|
||||
{:is_unique true, :is_primary false, :index_name "data_app_slug_key", :columns ["slug"]})}}))
|
||||
@@ -0,0 +1,9 @@
|
||||
# Data Apps Enterprise Module
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains the enterprise-specific features and extensions for Metabase Data Apps.
|
||||
|
||||
## Enterprise Features
|
||||
This module extends the core data apps functionality with enterprise-specific capabilities.
|
||||
Please refer to the main documentation at: `src/metabase/data_app/CLAUDE.md`
|
||||
@@ -0,0 +1 @@
|
||||
(ns metabase-enterprise.data-apps.api)
|
||||
@@ -16,6 +16,7 @@
|
||||
"Card"
|
||||
"Collection"
|
||||
"Dashboard"
|
||||
"DataApp"
|
||||
"NativeQuerySnippet"
|
||||
"Timeline"])
|
||||
|
||||
@@ -31,7 +32,9 @@
|
||||
"An additional list of models which are inlined into parent entities for serialization.
|
||||
These are not extracted and serialized separately, but they may need some processing done.
|
||||
For example, the models should also have their entity_id fields populated (if they have one)."
|
||||
["DashboardCard"
|
||||
["DataAppDefinition"
|
||||
"DataAppRelease"
|
||||
"DashboardCard"
|
||||
"DashboardTab"
|
||||
"Dimension"
|
||||
"ParameterCard"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
(ns metabase-enterprise.serialization.v2.data-apps-test
|
||||
"Serialization tests for data apps models"
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.serialization.test-util :as ts]
|
||||
[metabase-enterprise.serialization.v2.extract :as extract]
|
||||
[metabase-enterprise.serialization.v2.ingest :as ingest]
|
||||
[metabase-enterprise.serialization.v2.load :as serdes.load]
|
||||
[metabase-enterprise.serialization.v2.storage :as storage]
|
||||
[metabase.data-apps.models :as data-apps.models]
|
||||
[metabase.data-apps.test-util :as data-apps.tu]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest data-app-e2e-test
|
||||
(testing "DataApp with released definition must be serialized and deserialized correctly"
|
||||
(ts/with-random-dump-dir [dump-dir "serdes-data-apps-"]
|
||||
(ts/with-dbs [source-db dest-db]
|
||||
(ts/with-db source-db
|
||||
;; Create data app with released definition using the data apps test utility
|
||||
(data-apps.tu/with-released-app!
|
||||
[_app {:name "Test Data App"
|
||||
:slug "test-data-app"
|
||||
:description "A test data app for serialization"}]
|
||||
(let [extraction (serdes/with-cache (into [] (extract/extract {})))
|
||||
data-apps (filter #(= "DataApp" (-> % :serdes/meta last :model)) extraction)
|
||||
data-app (first data-apps)]
|
||||
(is (=? {:creator_id "crowberto@metabase.com"
|
||||
:name "Test Data App"
|
||||
:released_definition [{:config (mt/malli=? :map)
|
||||
:creator_id "crowberto@metabase.com",
|
||||
:release [{:app_id (:entity_id data-app)
|
||||
:creator_id "crowberto@metabase.com"}],
|
||||
:revision_number 1}]}
|
||||
data-app))
|
||||
(storage/store! (seq extraction) dump-dir)))
|
||||
|
||||
(ts/with-db dest-db
|
||||
(serdes/with-cache
|
||||
(serdes.load/load-metabase! (ingest/ingest-yaml dump-dir)))
|
||||
|
||||
(let [imported-app (data-apps.models/get-published-data-app "test-data-app")]
|
||||
(is (=? {:creator_id (mt/user->id :crowberto),
|
||||
:definition {:app_id (:id imported-app),
|
||||
:config {:actions [],
|
||||
:pages [{:name "Default Page"}],
|
||||
:parameters []},
|
||||
:creator_id (mt/user->id :crowberto),
|
||||
:id (mt/user->id :crowberto),
|
||||
:revision_number 1},
|
||||
:description "A test data app for serialization",
|
||||
:name "Test Data App",
|
||||
:slug "test-data-app",
|
||||
:status :published}
|
||||
imported-app)))))))))
|
||||
|
||||
(deftest data-app-no-released-definition-test
|
||||
(testing "DataApp with no released definition must be serialized and deserialized correctly"
|
||||
(ts/with-random-dump-dir [dump-dir "serdes-data-apps-no-release-"]
|
||||
(ts/with-dbs [source-db dest-db]
|
||||
(ts/with-db source-db
|
||||
(data-apps.tu/with-data-app!
|
||||
[_app {:name "Test Data App No Release"
|
||||
:slug "test-data-app-no-release"
|
||||
:description "A test data app without released definition"
|
||||
:definition {:config data-apps.tu/default-app-definition-config}}]
|
||||
(let [extraction (serdes/with-cache (into [] (extract/extract {})))
|
||||
data-apps (filter #(= "DataApp" (-> % :serdes/meta last :model)) extraction)
|
||||
data-app (first data-apps)]
|
||||
(is (=? {:creator_id "crowberto@metabase.com"
|
||||
:name "Test Data App No Release"
|
||||
:released_definition nil}
|
||||
data-app))
|
||||
(storage/store! (seq extraction) dump-dir)))
|
||||
|
||||
(ts/with-db dest-db
|
||||
(serdes/with-cache
|
||||
(serdes.load/load-metabase! (ingest/ingest-yaml dump-dir)))
|
||||
|
||||
(let [imported-app (t2/select-one :model/DataApp :slug "test-data-app-no-release")]
|
||||
(is (zero? (t2/count :model/DataAppDefinition :app_id (:id imported-app))))
|
||||
(is (=? {:creator_id (mt/user->id :crowberto)
|
||||
:description "A test data app without released definition"
|
||||
:name "Test Data App No Release"
|
||||
:slug "test-data-app-no-release"
|
||||
:status :private}
|
||||
imported-app)))))))))
|
||||
|
||||
(deftest data-app-multiple-definitions-one-release-test
|
||||
(testing "DataApp with multiple definitions and one release will only serialize the relased definition"
|
||||
(ts/with-random-dump-dir [dump-dir "serdes-data-apps-multiple-"]
|
||||
(ts/with-dbs [source-db dest-db]
|
||||
(ts/with-db source-db
|
||||
;; Create data app with initial definition
|
||||
(data-apps.tu/with-data-app!
|
||||
[app {:name "Test Data App Multiple"
|
||||
:slug "test-data-app-multiple"
|
||||
:description "A test data app with multiple definitions"
|
||||
:definition {:config data-apps.tu/default-app-definition-config}}]
|
||||
;; Create a second definition and release it
|
||||
(let [new-definition {:config {:actions []
|
||||
:pages [{:name "Released Page"}]
|
||||
:parameters []}
|
||||
:creator_id (mt/user->id :crowberto)}
|
||||
_ (data-apps.models/set-latest-definition! (:id app) new-definition)
|
||||
_ (data-apps.models/release! (:id app) (mt/user->id :crowberto))
|
||||
;; Create another definition after release (not released)
|
||||
_ (data-apps.models/set-latest-definition! (:id app)
|
||||
{:config {:actions []
|
||||
:pages [{:name "Post-Release Draft"}]
|
||||
:parameters []}
|
||||
:creator_id (mt/user->id :crowberto)})
|
||||
extraction (serdes/with-cache (into [] (extract/extract {})))
|
||||
data-apps (filter #(= "DataApp" (-> % :serdes/meta last :model)) extraction)
|
||||
data-app (first data-apps)]
|
||||
(is (=? {:creator_id "crowberto@metabase.com"
|
||||
:name "Test Data App Multiple"
|
||||
:released_definition [{:config (mt/malli=? :map)
|
||||
:creator_id "crowberto@metabase.com"
|
||||
:release [{:app_id (:entity_id data-app)
|
||||
:creator_id "crowberto@metabase.com"}]
|
||||
:revision_number 2}]}
|
||||
data-app))
|
||||
(storage/store! (seq extraction) dump-dir))))
|
||||
|
||||
(ts/with-db dest-db
|
||||
(serdes/with-cache
|
||||
(serdes.load/load-metabase! (ingest/ingest-yaml dump-dir)))
|
||||
|
||||
(let [imported-app (data-apps.models/get-published-data-app "test-data-app-multiple")]
|
||||
(is (=? {:creator_id (mt/user->id :crowberto)
|
||||
:definition {:app_id (:id imported-app)
|
||||
:config {:actions []
|
||||
:pages [{:name "Released Page"}]
|
||||
:parameters []}
|
||||
:creator_id (mt/user->id :crowberto)
|
||||
:id (mt/user->id :crowberto)
|
||||
:revision_number 2}
|
||||
:description "A test data app with multiple definitions"
|
||||
:name "Test Data App Multiple"
|
||||
:slug "test-data-app-multiple"
|
||||
:status :published}
|
||||
imported-app))))))))
|
||||
111
enterprise/frontend/src/metabase-enterprise/api/data-apps.ts
Normal file
111
enterprise/frontend/src/metabase-enterprise/api/data-apps.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { SkipToken } from "@reduxjs/toolkit/query";
|
||||
|
||||
import type {
|
||||
DataApp,
|
||||
DataAppDefinition,
|
||||
DataAppDefinitionRelease,
|
||||
} from "metabase/data-apps/types";
|
||||
import { idTag, listTag } from "metabase-enterprise/api/tags";
|
||||
import type {
|
||||
CreateDataAppRequest,
|
||||
DataAppsListResponse,
|
||||
UpdateDataAppDefinitionRequest,
|
||||
UpdateDataAppRequest,
|
||||
UpdateDataAppStatusRequest,
|
||||
} from "metabase-enterprise/data-apps/types";
|
||||
import type { PaginationRequest } from "metabase-types/api";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
|
||||
export const dataAppsApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getDataApps: builder.query<
|
||||
DataAppsListResponse,
|
||||
PaginationRequest | SkipToken | void
|
||||
>({
|
||||
query: () => ({
|
||||
method: "GET",
|
||||
url: `/api/data-app/`,
|
||||
}),
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? [
|
||||
...result.data?.map(({ id }) => idTag("data-app", id)),
|
||||
listTag("data-app"),
|
||||
]
|
||||
: [listTag("data-app")],
|
||||
}),
|
||||
|
||||
getDataApp: builder.query<DataApp, { id: string }>({
|
||||
query: ({ id }) => ({
|
||||
method: "GET",
|
||||
url: `/api/data-app/${id}`,
|
||||
}),
|
||||
providesTags: (_result, _error, arg) => [idTag("data-app", arg.id)],
|
||||
}),
|
||||
|
||||
createDataApp: builder.mutation<DataApp, CreateDataAppRequest>({
|
||||
query: (params) => ({
|
||||
method: "POST",
|
||||
url: `/api/data-app/`,
|
||||
params,
|
||||
}),
|
||||
invalidatesTags: [listTag("data-app")],
|
||||
}),
|
||||
|
||||
updateDataApp: builder.mutation<DataApp, UpdateDataAppRequest>({
|
||||
query: ({ id, ...bodyParams }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/data-app/${id}`,
|
||||
body: bodyParams,
|
||||
}),
|
||||
invalidatesTags: (_result, _error, arg) => [idTag("data-app", arg.id)],
|
||||
}),
|
||||
|
||||
updateDataAppStatus: builder.mutation<DataApp, UpdateDataAppStatusRequest>({
|
||||
query: ({ id, status }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/data-app/${id}/status`,
|
||||
body: {
|
||||
status,
|
||||
},
|
||||
}),
|
||||
invalidatesTags: (_result, _error, arg) => [idTag("data-app", arg.id)],
|
||||
}),
|
||||
|
||||
updateDataAppDefinition: builder.mutation<
|
||||
DataAppDefinition,
|
||||
UpdateDataAppDefinitionRequest
|
||||
>({
|
||||
query: ({ id, config }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/data-app/${id}/definition`,
|
||||
body: {
|
||||
config,
|
||||
},
|
||||
}),
|
||||
invalidatesTags: (_result, _error, arg) => [idTag("data-app", arg.id)],
|
||||
}),
|
||||
|
||||
releaseDataAppDefinition: builder.mutation<
|
||||
DataAppDefinitionRelease,
|
||||
{ id: string }
|
||||
>({
|
||||
query: ({ id }) => ({
|
||||
method: "POST",
|
||||
url: `/api/data-app/${id}/release`,
|
||||
}),
|
||||
invalidatesTags: (_result, _error, arg) => [idTag("data-app", arg.id)],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetDataAppsQuery,
|
||||
useGetDataAppQuery,
|
||||
useCreateDataAppMutation,
|
||||
useUpdateDataAppMutation,
|
||||
useUpdateDataAppStatusMutation,
|
||||
useUpdateDataAppDefinitionMutation,
|
||||
useReleaseDataAppDefinitionMutation,
|
||||
} = dataAppsApi;
|
||||
@@ -3,6 +3,7 @@ export * from "./ai-sql-generation";
|
||||
export * from "./audit-info";
|
||||
export * from "./api";
|
||||
export * from "./billing-info";
|
||||
export * from "./data-apps";
|
||||
export * from "./metabot";
|
||||
export * from "./content-translation";
|
||||
export * from "./gdrive";
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ENTERPRISE_TAG_TYPES = [
|
||||
"metabot-entities-list",
|
||||
"metabot-prompt-suggestions",
|
||||
"gsheets-status",
|
||||
"data-app",
|
||||
] as const;
|
||||
|
||||
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { Location } from "history";
|
||||
import { useCallback, useState } from "react";
|
||||
import { replace } from "react-router-redux";
|
||||
import { useMount } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { ToolbarButton } from "metabase/common/components/ToolbarButton";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { closeNavbar } from "metabase/redux/app";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Icon,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useGetDataAppQuery,
|
||||
useUpdateDataAppMutation,
|
||||
} from "metabase-enterprise/api";
|
||||
|
||||
import { DataAppsComponentsList } from "./DataAppsComponentsList";
|
||||
import { DataAppEditSettingsModal } from "./modals/DataAppEditSettingsModal";
|
||||
import { DataAppPublishModal } from "./modals/DataAppPublishModal";
|
||||
import type { DataAppEditSettings } from "./types";
|
||||
|
||||
type SettingsSectionKey = "components";
|
||||
|
||||
type DataAppContainerProps = {
|
||||
params: {
|
||||
appId: string;
|
||||
};
|
||||
location: Location<{ justCreated?: string }>;
|
||||
};
|
||||
|
||||
export const DataAppContainer = ({
|
||||
params: { appId },
|
||||
location,
|
||||
}: DataAppContainerProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isNewApp = !!location.query.justCreated;
|
||||
|
||||
const [activeSettingsSection, setActiveSettingsSection] = useState<
|
||||
SettingsSectionKey | undefined
|
||||
>(isNewApp ? "components" : undefined); // show components list by default for new empty data app
|
||||
|
||||
const { data: dataApp } = useGetDataAppQuery({ id: appId });
|
||||
const [updateDataApp] = useUpdateDataAppMutation();
|
||||
|
||||
const [
|
||||
isOpenEditTitleModal,
|
||||
{ open: openEditTitleModal, close: closeEditTitleModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const [
|
||||
isOpenPublishModal,
|
||||
{ open: openPublishModal, close: closePublishModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
|
||||
useMount(() => {
|
||||
if (location.query.justCreated) {
|
||||
// remove "justCreated" query param
|
||||
dispatch(replace(window.location.pathname));
|
||||
}
|
||||
});
|
||||
|
||||
const handleEditSettingsSubmit = useCallback(
|
||||
async (newSettings: DataAppEditSettings) => {
|
||||
if (!dataApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateDataApp({
|
||||
id: dataApp.id,
|
||||
...newSettings,
|
||||
});
|
||||
|
||||
closeEditTitleModal();
|
||||
},
|
||||
[closeEditTitleModal, dataApp, updateDataApp],
|
||||
);
|
||||
|
||||
if (!dataApp) {
|
||||
return <div>{t`Not found`}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack mih="100%" h="0" gap={0}>
|
||||
<Group
|
||||
bg="white"
|
||||
gap={0}
|
||||
py="0.5rem"
|
||||
px="2rem 1rem"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderBottom: "2px solid var(--mb-color-border)",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center">
|
||||
<Title order={4}>
|
||||
{dataApp.name}{" "}
|
||||
{dataApp.status === "archived" ? t`(Archived)` : ""}
|
||||
</Title>
|
||||
<Tooltip label={t`Edit Data App Settings`}>
|
||||
<ActionIcon onClick={openEditTitleModal}>
|
||||
<Icon name="pencil" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<ToolbarButton
|
||||
icon="add"
|
||||
aria-label={t`Add components`}
|
||||
tooltipLabel={t`Add components`}
|
||||
isActive={activeSettingsSection === "components"}
|
||||
onClick={() => setActiveSettingsSection("components")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid="data-app-publish-button"
|
||||
px="md"
|
||||
py="sm"
|
||||
variant="subtle"
|
||||
onClick={openPublishModal}
|
||||
>
|
||||
{t`Share`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group
|
||||
bg="var(--mb-color-bg-light)"
|
||||
align="stretch"
|
||||
gap={0}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, var(--mb-color-border) 1px, transparent 0)",
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
>
|
||||
{`Canvas here`}
|
||||
</Box>
|
||||
|
||||
{activeSettingsSection === "components" && (
|
||||
<ComponentsSidebar
|
||||
onClose={() => setActiveSettingsSection(undefined)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<DataAppEditSettingsModal
|
||||
opened={isOpenEditTitleModal}
|
||||
dataApp={dataApp}
|
||||
onSubmit={handleEditSettingsSubmit}
|
||||
onClose={closeEditTitleModal}
|
||||
/>
|
||||
|
||||
<DataAppPublishModal
|
||||
opened={isOpenPublishModal}
|
||||
dataApp={dataApp}
|
||||
onClose={closePublishModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ComponentsSidebarProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
export const ComponentsSidebar = ({ onClose }: ComponentsSidebarProps) => {
|
||||
// TODO: use Sidebar component ?
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
borderLeft: "1px solid var(--mb-color-border)",
|
||||
width: "20rem",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
bg="white"
|
||||
p="0.5rem 1rem"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
mb="1rem"
|
||||
>
|
||||
<Title order={4}>{t`Components`}</Title>
|
||||
<ActionIcon onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<DataAppsComponentsList />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Card, Stack, Text } from "metabase/ui";
|
||||
|
||||
const COMPONENTS = [
|
||||
{
|
||||
title: "Main Heading",
|
||||
slug: "h1",
|
||||
},
|
||||
{
|
||||
title: "Paragraph",
|
||||
slug: "p",
|
||||
},
|
||||
{
|
||||
title: "List",
|
||||
slug: "list",
|
||||
},
|
||||
{
|
||||
title: "Card",
|
||||
slug: "card",
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
slug: "table",
|
||||
},
|
||||
{
|
||||
title: "Form",
|
||||
slug: "form",
|
||||
},
|
||||
];
|
||||
|
||||
export const DataAppsComponentsList = () => {
|
||||
return (
|
||||
<Stack px="1rem">
|
||||
{COMPONENTS.map(({ title, slug }) => (
|
||||
<Card key={slug}>
|
||||
<Text fw={500}>{title}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { t } from "ttag";
|
||||
|
||||
import NoResults from "assets/img/no_results.svg";
|
||||
import { BrowseCard } from "metabase/browse/components/BrowseCard";
|
||||
import {
|
||||
BrowseSection,
|
||||
CenteredEmptyState,
|
||||
} from "metabase/browse/components/BrowseContainer.styled";
|
||||
import { BrowseGrid } from "metabase/browse/components/BrowseGrid";
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import type { DataApp } from "metabase/data-apps/types";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { uuid } from "metabase/lib/uuid";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Box, Button, Flex, Group, Icon, Stack, Title } from "metabase/ui";
|
||||
import {
|
||||
useCreateDataAppMutation,
|
||||
useGetDataAppsQuery,
|
||||
} from "metabase-enterprise/api";
|
||||
import { getUpdateApiErrorMessage } from "metabase-enterprise/data-apps/utils";
|
||||
|
||||
export const DataAppsList = () => {
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
|
||||
const { data, isLoading } = useGetDataAppsQuery();
|
||||
const dataApps: DataApp[] | undefined = data?.data;
|
||||
|
||||
if (!dataApps && isLoading) {
|
||||
return <LoadingAndErrorWrapper loading />;
|
||||
}
|
||||
|
||||
if (!dataApps?.length && !isAdmin) {
|
||||
return (
|
||||
<CenteredEmptyState
|
||||
title={<Box mb=".5rem">{t`No data apps here yet`}</Box>}
|
||||
illustrationElement={
|
||||
<Box mb=".5rem">
|
||||
<img src={NoResults} />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mt="1rem" p="0 2.5rem">
|
||||
<Stack>
|
||||
<BrowseSection>
|
||||
<Flex w="100%" direction="row" justify="space-between" align="center">
|
||||
<Title order={2} c="text-dark">
|
||||
<Group gap="sm" align="center">
|
||||
<Icon
|
||||
size={24}
|
||||
color="var(--mb-color-brand)"
|
||||
name="format_code"
|
||||
/>
|
||||
{t`Data Apps`}
|
||||
</Group>
|
||||
</Title>
|
||||
|
||||
<AddDataAppButton />
|
||||
</Flex>
|
||||
</BrowseSection>
|
||||
|
||||
<BrowseSection direction="column">
|
||||
<BrowseGrid data-testid="database-browser">
|
||||
{dataApps &&
|
||||
dataApps.length > 0 &&
|
||||
dataApps.map((app) => (
|
||||
<BrowseCard
|
||||
to={Urls.dataAppEdit(app.id)}
|
||||
key={app.id}
|
||||
title={app.name}
|
||||
icon="format_code"
|
||||
size="lg"
|
||||
iconColor="var(--mb-color-brand)"
|
||||
/>
|
||||
))}
|
||||
</BrowseGrid>
|
||||
</BrowseSection>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const AddDataAppButton = () => {
|
||||
const [createDataAppMutation] = useCreateDataAppMutation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
try {
|
||||
const { data: app, error } = await createDataAppMutation({
|
||||
name: "Untitled",
|
||||
slug: uuid(),
|
||||
});
|
||||
|
||||
if (app) {
|
||||
dispatch(push(Urls.dataAppEdit(app.id, { isNew: true })));
|
||||
} else {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message: getUpdateApiErrorMessage(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message: getUpdateApiErrorMessage(e),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [createDataAppMutation, dispatch]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCreate}
|
||||
>{t`Create Data App`}</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
|
||||
import { useGetPublicDataAppQuery } from "metabase/api";
|
||||
|
||||
export const PublicDataApp = ({
|
||||
params: { appUrl },
|
||||
}: {
|
||||
params: { appUrl: string };
|
||||
}) => {
|
||||
const { data: dataApp } = useGetPublicDataAppQuery({ slug: appUrl });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>DATA APP {appUrl}</div>
|
||||
|
||||
<code>{JSON.stringify(dataApp, null, 4)}</code>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PLUGIN_DATA_APPS } from "metabase/plugins";
|
||||
import { DataAppContainer } from "metabase-enterprise/data-apps/DataAppContainer";
|
||||
import { DataAppsList } from "metabase-enterprise/data-apps/DataAppsList";
|
||||
import { PublicDataApp } from "metabase-enterprise/data-apps/PublicDataApp";
|
||||
import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
import type { TokenFeature } from "metabase-types/api";
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (true || hasPremiumFeature("data-apps" as TokenFeature)) {
|
||||
PLUGIN_DATA_APPS.isEnabled = () => true;
|
||||
PLUGIN_DATA_APPS.LIST_APPS_PAGE_COMPONENT = DataAppsList;
|
||||
PLUGIN_DATA_APPS.APP_PAGE_COMPONENT = DataAppContainer;
|
||||
PLUGIN_DATA_APPS.PUBLIC_APP_PAGE_COMPONENT = PublicDataApp;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { DataApp } from "metabase/data-apps/types";
|
||||
import { Form, FormProvider, FormTextInput } from "metabase/forms";
|
||||
import { Box, Button, Modal, Stack } from "metabase/ui";
|
||||
|
||||
import type { DataAppEditSettings } from "../types";
|
||||
|
||||
interface DataAppEditSettingsModalProps {
|
||||
dataApp: DataApp;
|
||||
opened: boolean;
|
||||
onSubmit: (newSettings: DataAppEditSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DataAppEditSettingsModal = ({
|
||||
dataApp,
|
||||
opened,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: DataAppEditSettingsModalProps) => {
|
||||
const initialValues = useMemo(() => {
|
||||
const settings: DataAppEditSettings = {
|
||||
name: dataApp.name,
|
||||
slug: dataApp.slug,
|
||||
};
|
||||
|
||||
return settings;
|
||||
}, [dataApp]);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} title={t`Data App Settings`} onClose={onClose}>
|
||||
<FormProvider initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form as={Stack}>
|
||||
<FormTextInput label={t`Name`} name="name" required />
|
||||
|
||||
<FormTextInput label={t`Url slug`} name="slug" required />
|
||||
|
||||
<Box>
|
||||
<Button variant="filled" type="submit">{t`Save`}</Button>
|
||||
</Box>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { DataApp } from "metabase/data-apps/types";
|
||||
import { PublicLinkCopyPanel } from "metabase/embedding/components/PublicLinkPopover/PublicLinkCopyPanel";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Group,
|
||||
Icon,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useReleaseDataAppDefinitionMutation,
|
||||
useUpdateDataAppDefinitionMutation,
|
||||
useUpdateDataAppMutation,
|
||||
useUpdateDataAppStatusMutation,
|
||||
} from "metabase-enterprise/api";
|
||||
|
||||
interface DataAppPublishModalProps {
|
||||
dataApp: DataApp;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DataAppPublishModal = ({
|
||||
dataApp,
|
||||
opened,
|
||||
onClose,
|
||||
}: DataAppPublishModalProps) => {
|
||||
const fullAppUrl = Urls.publishedDataApp(dataApp.slug);
|
||||
|
||||
const [updateDataAppStatus] = useUpdateDataAppStatusMutation();
|
||||
|
||||
const needsToPublish =
|
||||
dataApp.status === "private" || dataApp.status === "archived";
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
await updateDataAppStatus({ id: dataApp.id, status: "archived" });
|
||||
}, [updateDataAppStatus, dataApp.id]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
title={t`Your app is ${dataApp.status}`}
|
||||
onClose={onClose}
|
||||
>
|
||||
{needsToPublish ? (
|
||||
<DataAppFirstPublishContent dataApp={dataApp} />
|
||||
) : (
|
||||
<Stack>
|
||||
<Text>{t`Share a link to use your app.`}</Text>
|
||||
|
||||
<PublicLinkCopyPanel url={fullAppUrl} />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Anchor fw="bold" target="_blank" href={fullAppUrl}>
|
||||
<Group display="inline-flex" align="center" gap="xs">
|
||||
{t`View app`}
|
||||
<Icon name="share" />
|
||||
</Group>
|
||||
</Anchor>
|
||||
|
||||
{dataApp.status !== "archived" && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="subtle"
|
||||
onClick={handleArchive}
|
||||
>{t`Archive`}</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const DataAppFirstPublishContent = ({ dataApp }: { dataApp: DataApp }) => {
|
||||
const [urlSlug, setUrlSlug] = useState(dataApp.slug);
|
||||
|
||||
const [updateDataApp] = useUpdateDataAppMutation();
|
||||
const [updateDataAppDefinition] = useUpdateDataAppDefinitionMutation();
|
||||
const [releaseDataAppDefinition] = useReleaseDataAppDefinitionMutation();
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
await Promise.all([
|
||||
updateDataApp({
|
||||
id: dataApp.id,
|
||||
slug: urlSlug,
|
||||
}),
|
||||
|
||||
updateDataAppDefinition({
|
||||
id: dataApp.id,
|
||||
config: {
|
||||
actions: [],
|
||||
pages: [],
|
||||
parameters: [],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await releaseDataAppDefinition({
|
||||
id: dataApp.id,
|
||||
});
|
||||
}, [
|
||||
dataApp.id,
|
||||
releaseDataAppDefinition,
|
||||
updateDataApp,
|
||||
updateDataAppDefinition,
|
||||
urlSlug,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text>{t`Are you ready to publish this app?`}</Text>
|
||||
|
||||
<TextInput
|
||||
label={t`Url slug`}
|
||||
value={urlSlug}
|
||||
required
|
||||
onChange={(e) => setUrlSlug(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button disabled={!urlSlug} onClick={handlePublish}>{t`Publish`}</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { DataApp, DataAppDefinition } from "metabase/data-apps/types";
|
||||
import type { PaginationResponse } from "metabase-types/api";
|
||||
|
||||
export type DataAppEditSettings = {
|
||||
name: DataApp["name"];
|
||||
slug: DataApp["slug"];
|
||||
};
|
||||
|
||||
export type DataAppsListResponse = {
|
||||
data: DataApp[];
|
||||
} & PaginationResponse;
|
||||
|
||||
export type UpdatableDataAppSettings = Pick<
|
||||
DataApp,
|
||||
"name" | "slug" | "description" | "definition"
|
||||
>;
|
||||
|
||||
export type UpdateDataAppRequest = {
|
||||
id: DataApp["id"];
|
||||
} & Partial<UpdatableDataAppSettings>;
|
||||
|
||||
export type UpdateDataAppStatusRequest = {
|
||||
id: DataApp["id"];
|
||||
status: DataApp["status"];
|
||||
};
|
||||
|
||||
export type CreateDataAppRequest = Pick<DataApp, "name" | "slug">;
|
||||
|
||||
export type UpdateDataAppDefinitionRequest = {
|
||||
id: DataApp["id"];
|
||||
config: DataAppDefinition["config"];
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { DataApp } from "metabase/data-apps/types";
|
||||
import type { GenericErrorResponse } from "metabase/lib/errors";
|
||||
import { slugify } from "metabase/lib/formatting/url";
|
||||
|
||||
export function createMockDataApp(opts: Partial<DataApp> = {}): DataApp {
|
||||
return {
|
||||
id: "",
|
||||
name: "Untitled",
|
||||
description: null,
|
||||
definition: null,
|
||||
slug: slugify(opts.name || "Untitled"),
|
||||
status: "private", // private, published, archived
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
export const getUpdateApiErrorMessage = (
|
||||
error: GenericErrorResponse | unknown,
|
||||
): string => {
|
||||
const maybeError = error as GenericErrorResponse;
|
||||
|
||||
if (typeof maybeError.data === "string") {
|
||||
return maybeError.data;
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(maybeError.data?.errors) &&
|
||||
"error" in maybeError.data?.errors[0]
|
||||
) {
|
||||
return maybeError.data.errors[0].error;
|
||||
}
|
||||
|
||||
if (Array.isArray(maybeError.errors) && "error" in maybeError.errors[0]) {
|
||||
return maybeError.errors[0].error;
|
||||
}
|
||||
|
||||
return t`Unknown error`;
|
||||
};
|
||||
@@ -38,4 +38,5 @@ import "./resource_downloads";
|
||||
import "./user_provisioning";
|
||||
import "./clean_up";
|
||||
import "./metabot";
|
||||
import "./data-apps";
|
||||
import "./database_replication";
|
||||
|
||||
15
frontend/src/metabase/api/data-apps.ts
Normal file
15
frontend/src/metabase/api/data-apps.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Api } from "metabase/api/api";
|
||||
import type { DataApp } from "metabase/data-apps/types";
|
||||
|
||||
export const dataAppsApi = Api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getPublicDataApp: builder.query<DataApp, { slug: string }>({
|
||||
query: ({ slug }) => ({
|
||||
method: "GET",
|
||||
url: `/api/app/${slug}`,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetPublicDataAppQuery } = dataAppsApi;
|
||||
@@ -12,6 +12,7 @@ export * from "./collection";
|
||||
export * from "./dashboard";
|
||||
export * from "./database";
|
||||
export * from "./dataset";
|
||||
export * from "./data-apps";
|
||||
export * from "./entity-id";
|
||||
export * from "./field";
|
||||
export * from "./google";
|
||||
|
||||
28
frontend/src/metabase/data-apps/types.ts
Normal file
28
frontend/src/metabase/data-apps/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type DataApp = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string; // private, published, archived
|
||||
description: string | null;
|
||||
definition: DataAppDefinition | null;
|
||||
entity_id?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type DataAppDefinition = {
|
||||
id: string;
|
||||
app_id: string;
|
||||
revision_number: number;
|
||||
config: object;
|
||||
entity_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type DataAppDefinitionRelease = {
|
||||
id: string;
|
||||
app_id: string;
|
||||
app_definition_id: string;
|
||||
published_at: string;
|
||||
retracted: boolean;
|
||||
};
|
||||
16
frontend/src/metabase/lib/urls/data-apps.ts
Normal file
16
frontend/src/metabase/lib/urls/data-apps.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import MetabaseSettings from "metabase/lib/settings";
|
||||
|
||||
export function dataAppEdit(appId: string, options: { isNew?: boolean } = {}) {
|
||||
const siteUrl = MetabaseSettings.get("site-url");
|
||||
|
||||
const query = options?.isNew
|
||||
? new URLSearchParams({ justCreated: "true" }).toString()
|
||||
: "";
|
||||
|
||||
return `${siteUrl}/data-apps/edit/${appId}${query ? "?" + query : ""}`;
|
||||
}
|
||||
|
||||
export function publishedDataApp(appUrl: string) {
|
||||
const siteUrl = MetabaseSettings.get("site-url");
|
||||
return `${siteUrl}/public/app/${appUrl}`;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from "./bookmarks";
|
||||
export * from "./browse";
|
||||
export * from "./collections";
|
||||
export * from "./dashboards";
|
||||
export * from "./data-apps";
|
||||
export * from "./indexed-entities";
|
||||
export * from "./modelToUrl";
|
||||
export * from "./misc";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { c, t } from "ttag";
|
||||
import { useUserSetting } from "metabase/common/hooks";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { PLUGIN_DATA_APPS } from "metabase/plugins";
|
||||
import { getIsEmbeddingIframe } from "metabase/selectors/embed";
|
||||
import { getEntityTypes } from "metabase/selectors/embedding-data-picker";
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ export const BrowseNavSection = ({
|
||||
const BROWSE_MODELS_URL = "/browse/models";
|
||||
const BROWSE_DATA_URL = "/browse/databases";
|
||||
const BROWSE_METRICS_URL = "/browse/metrics";
|
||||
const BROWSE_DATA_APPS_URL = "/data-apps";
|
||||
|
||||
const [expandBrowse = true, setExpandBrowse] = useUserSetting(
|
||||
"expand-browse-in-nav",
|
||||
@@ -123,6 +125,18 @@ export const BrowseNavSection = ({
|
||||
{t`Metrics`}
|
||||
</PaddedSidebarLink>
|
||||
)}
|
||||
|
||||
{PLUGIN_DATA_APPS.isEnabled() && (
|
||||
<PaddedSidebarLink
|
||||
icon="format_code"
|
||||
url={BROWSE_DATA_APPS_URL}
|
||||
isSelected={nonEntityItem?.url?.startsWith(BROWSE_DATA_APPS_URL)}
|
||||
onClick={onItemSelect}
|
||||
aria-label={t`Browse data apps`}
|
||||
>
|
||||
{t`Data Apps`}
|
||||
</PaddedSidebarLink>
|
||||
)}
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Location } from "history";
|
||||
import React, {
|
||||
type ComponentType,
|
||||
type Dispatch,
|
||||
@@ -728,6 +729,22 @@ export const PLUGIN_DASHCARD_MENU: PluginDashcardMenu = {
|
||||
dashcardMenuItemGetters: [],
|
||||
};
|
||||
|
||||
export const PLUGIN_DATA_APPS = {
|
||||
isEnabled: () => false,
|
||||
LIST_APPS_PAGE_COMPONENT: PluginPlaceholder,
|
||||
APP_PAGE_COMPONENT: PluginPlaceholder as ComponentType<{
|
||||
params: {
|
||||
appId: string;
|
||||
};
|
||||
location: Location<{ justCreated?: string }>;
|
||||
}>,
|
||||
PUBLIC_APP_PAGE_COMPONENT: PluginPlaceholder as ComponentType<{
|
||||
params: {
|
||||
appUrl: string;
|
||||
};
|
||||
}>,
|
||||
};
|
||||
|
||||
export const PLUGIN_CONTENT_TRANSLATION = {
|
||||
isEnabled: false,
|
||||
setEndpointsForStaticEmbedding: (_encodedToken: string) => {},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Route } from "metabase/hoc/Title";
|
||||
import { PLUGIN_DATA_APPS } from "metabase/plugins";
|
||||
import { PublicNotFound } from "metabase/public/components/PublicNotFound";
|
||||
import PublicAction from "metabase/public/containers/PublicAction";
|
||||
import PublicApp from "metabase/public/containers/PublicApp";
|
||||
@@ -18,6 +19,12 @@ export const getRoutes = (store) => {
|
||||
path="dashboard/:uuid(/:tabSlug)"
|
||||
component={PublicOrEmbeddedDashboardPage}
|
||||
/>
|
||||
{PLUGIN_DATA_APPS.isEnabled() && (
|
||||
<Route
|
||||
path="app/:appUrl"
|
||||
component={PLUGIN_DATA_APPS.PUBLIC_APP_PAGE_COMPONENT}
|
||||
/>
|
||||
)}
|
||||
<Route path="*" component={PublicNotFound} />
|
||||
</Route>
|
||||
<Route path="*" component={PublicNotFound} />
|
||||
|
||||
@@ -39,6 +39,7 @@ import NewModelOptions from "metabase/models/containers/NewModelOptions";
|
||||
import { getRoutes as getModelRoutes } from "metabase/models/routes";
|
||||
import {
|
||||
PLUGIN_COLLECTIONS,
|
||||
PLUGIN_DATA_APPS,
|
||||
PLUGIN_EMBEDDING_IFRAME_SDK_SETUP,
|
||||
PLUGIN_LANDING_PAGE,
|
||||
PLUGIN_METABOT,
|
||||
@@ -244,6 +245,18 @@ export const getRoutes = (store) => {
|
||||
<ModalRoute path="archive" modal={ArchiveDashboardModalConnected} />
|
||||
</Route>
|
||||
|
||||
{PLUGIN_DATA_APPS.isEnabled() && (
|
||||
<Route path="data-apps">
|
||||
<IndexRoute
|
||||
component={PLUGIN_DATA_APPS.LIST_APPS_PAGE_COMPONENT}
|
||||
/>
|
||||
<Route
|
||||
path="edit/:appId"
|
||||
component={PLUGIN_DATA_APPS.APP_PAGE_COMPONENT}
|
||||
/>
|
||||
</Route>
|
||||
)}
|
||||
|
||||
<Route path="/question">
|
||||
<Route
|
||||
path="/question/entity/:entity_id(**)"
|
||||
|
||||
19
mage/mage/db_schema.clj
Normal file
19
mage/mage/db_schema.clj
Normal file
@@ -0,0 +1,19 @@
|
||||
(ns mage.db-schema
|
||||
(:require
|
||||
[clojure.pprint :as pprint]
|
||||
[mage.be-dev :as be-dev]
|
||||
[mage.color :as c])
|
||||
(:import (java.io StringWriter)))
|
||||
|
||||
(defn output [{[table-name] :arguments, {:keys [port]} :options}]
|
||||
(let [port (or port
|
||||
(slurp ".nrepl-port")
|
||||
(do (println (c/red "No port specified, and no .nrepl-port file found."))
|
||||
(System/exit 1)))
|
||||
;; send internal print statements to nowhere
|
||||
output (binding [*out* (StringWriter.)]
|
||||
(-> (be-dev/nrepl-eval "dev.db-schema" (format "(output %s)" (pr-str table-name)) port)
|
||||
(get "value")
|
||||
read-string
|
||||
:value))]
|
||||
(pprint/pprint output)))
|
||||
@@ -232,6 +232,377 @@ databaseChangeLog:
|
||||
path: instance_analytics_views/users/v2/h2-users.sql
|
||||
relativeToChangelogFile: true
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:00:00
|
||||
author: qnkhuat
|
||||
comment: Create data_app table for data apps functionality
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- not:
|
||||
- tableExists:
|
||||
tableName: data_app
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: data_app
|
||||
remarks: Applications
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
remarks: Unique ID
|
||||
type: int
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: name
|
||||
remarks: App name
|
||||
type: ${text.type}
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: description
|
||||
remarks: App description
|
||||
type: ${text.type}
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: slug
|
||||
remarks: unique slug of an app
|
||||
type: varchar(254)
|
||||
constraints:
|
||||
nullable: false
|
||||
unique: true
|
||||
- column:
|
||||
name: creator_id
|
||||
remarks: the creator
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
references: core_user(id)
|
||||
foreignKeyName: fk_data_app_creator_id
|
||||
deleteCascade: true
|
||||
- column:
|
||||
name: status
|
||||
remarks: Status of the app (private, published, archived)
|
||||
type: varchar(32)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: entity_id
|
||||
remarks: Entity id
|
||||
type: char(21)
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
remarks: The timestamp of when the app was created
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
remarks: The timestamp of when the app was updated
|
||||
name: updated_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
rollback:
|
||||
- dropTable:
|
||||
tableName: data_app
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:00:10
|
||||
author: qnkhuat
|
||||
comment: Add index on data_app.creator_id
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- tableExists:
|
||||
tableName: data_app
|
||||
- not:
|
||||
- indexExists:
|
||||
tableName: data_app
|
||||
indexName: idx_data_app_creator_id
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: data_app
|
||||
indexName: idx_data_app_creator_id
|
||||
columns:
|
||||
- column:
|
||||
name: creator_id
|
||||
rollback: #no op, will be deleted with the table
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:01:00
|
||||
author: qnkhuat
|
||||
comment: Create data_app_definition table for data apps functionality
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- not:
|
||||
- tableExists:
|
||||
tableName: data_app_definition
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: data_app_definition
|
||||
remarks: App definitions (append only)
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
remarks: Unique ID
|
||||
type: int
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: app_id
|
||||
remarks: Referenced app
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
referencedTableName: data_app
|
||||
referencedColumnNames: id
|
||||
foreignKeyName: fk_data_app_definition_app_id
|
||||
deleteCascade: true
|
||||
- column:
|
||||
name: creator_id
|
||||
remarks: the creator
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
references: core_user(id)
|
||||
foreignKeyName: fk_data_app_definition_creator_id
|
||||
deleteCascade: true
|
||||
- column:
|
||||
name: revision_number
|
||||
remarks: Revision number for ordering
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: config
|
||||
remarks: App configuration JSON
|
||||
type: ${text.type}
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
remarks: The timestamp of when the definition was created
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
rollback:
|
||||
- dropTable:
|
||||
tableName: data_app_definition
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:02:00
|
||||
author: qnkhuat
|
||||
comment: Index for getting the latest definition
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- tableExists:
|
||||
tableName: data_app_definition
|
||||
- not:
|
||||
- indexExists:
|
||||
tableName: data_app_definition
|
||||
indexName: idx_data_app_definition_app_id_revision_numer
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: data_app_definition
|
||||
indexName: idx_data_app_definition_app_id_revision_numer
|
||||
columns:
|
||||
- column:
|
||||
name: app_id
|
||||
- column:
|
||||
name: revision_number
|
||||
descending: true
|
||||
rollback: #no op, will be deleted with the table
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:02:10
|
||||
author: qnkhuat
|
||||
comment: Add index on data_app_definition.creator_id
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- tableExists:
|
||||
tableName: data_app_definition
|
||||
- not:
|
||||
- indexExists:
|
||||
tableName: data_app_definition
|
||||
indexName: idx_data_app_definition_creator_id
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: data_app_definition
|
||||
indexName: idx_data_app_definition_creator_id
|
||||
columns:
|
||||
- column:
|
||||
name: creator_id
|
||||
rollback: #no op, will be deleted with the table
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:02:20
|
||||
author: qnkhuat
|
||||
comment: Add unique constraint on data_app_definition (app_id, revision_number)
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- tableExists:
|
||||
tableName: data_app_definition
|
||||
- not:
|
||||
- uniqueConstraintExists:
|
||||
tableName: data_app_definition
|
||||
constraintName: uniq_idx_data_app_definition_app_id_revision_number
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
tableName: data_app_definition
|
||||
constraintName: uniq_idx_data_app_definition_app_id_revision_number
|
||||
columnNames: app_id, revision_number
|
||||
rollback:
|
||||
- dropUniqueConstraint:
|
||||
tableName: data_app_definition
|
||||
constraintName: uniq_idx_data_app_definition_app_id_revision_number
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:03:00
|
||||
author: qnkhuat
|
||||
comment: Create data_app_release table for data apps functionality
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- not:
|
||||
- tableExists:
|
||||
tableName: data_app_release
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: data_app_release
|
||||
remarks: App releasing records
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
remarks: Unique ID
|
||||
type: int
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: app_id
|
||||
remarks: Referenced app
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
referencedTableName: data_app
|
||||
referencedColumnNames: id
|
||||
foreignKeyName: fk_data_app_release_app_id
|
||||
deleteCascade: true
|
||||
- column:
|
||||
name: app_definition_id
|
||||
remarks: Referenced app definition
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
referencedTableName: data_app_definition
|
||||
referencedColumnNames: id
|
||||
foreignKeyName: fk_data_app_release_app_definition_id
|
||||
- column:
|
||||
name: creator_id
|
||||
remarks: the creator
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
references: core_user(id)
|
||||
foreignKeyName: fk_data_app_release_creator_id
|
||||
deleteCascade: true
|
||||
- column:
|
||||
name: retracted
|
||||
remarks: Whether this version is retracted
|
||||
type: ${boolean.type}
|
||||
defaultValueBoolean: false
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
remarks: The timestamp of when the app was first released
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
remarks: The timestamp of when the release was last updated
|
||||
name: updated_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
rollback:
|
||||
- dropTable:
|
||||
tableName: data_app_release
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:04:00
|
||||
author: qnkhuat
|
||||
comment: index data_app_release.app_definition_id
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- not:
|
||||
- indexExists:
|
||||
tableName: data_app_release
|
||||
indexName: idx_data_app_release_app_definition_id
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: data_app_release
|
||||
indexName: idx_data_app_release_app_definition_id
|
||||
columns:
|
||||
- column:
|
||||
name: app_definition_id
|
||||
rollback: #no op, will be deleted with the table
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:07:00
|
||||
author: qnkhuat
|
||||
comment: Index to get the latest active release
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- not:
|
||||
- indexExists:
|
||||
tableName: data_app_release
|
||||
indexName: idx_data_app_release_app_id_retracted_id
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: data_app_release
|
||||
indexName: idx_data_app_release_app_id_retracted_id
|
||||
columns:
|
||||
- column:
|
||||
name: app_id
|
||||
- column:
|
||||
name: retracted
|
||||
- column:
|
||||
name: id
|
||||
descending: true
|
||||
rollback: #no op, will be deleted with the table
|
||||
|
||||
- changeSet:
|
||||
id: v56.2025-09-01T16:08:00
|
||||
author: qnkhuat
|
||||
comment: Add index on data_app_release.creator_id
|
||||
preConditions:
|
||||
- onFail: MARK_RAN
|
||||
- tableExists:
|
||||
tableName: data_app_release
|
||||
- not:
|
||||
- indexExists:
|
||||
tableName: data_app_release
|
||||
indexName: idx_data_app_release_creator_id
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: data_app_release
|
||||
indexName: idx_data_app_release_creator_id
|
||||
columns:
|
||||
- column:
|
||||
name: creator_id
|
||||
rollback: #no op, will be deleted with the table
|
||||
|
||||
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
[metabase.collections.api]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.dashboards.api]
|
||||
[metabase.data-apps.api]
|
||||
[metabase.eid-translation.api]
|
||||
[metabase.embedding.api]
|
||||
[metabase.geojson.api]
|
||||
@@ -131,6 +132,7 @@
|
||||
"/alert" (+auth metabase.pulse.api/alert-routes)
|
||||
"/analytics" (+auth 'metabase.analytics.api)
|
||||
"/api-key" (+auth 'metabase.api-keys.api)
|
||||
"/app" (+auth metabase.data-apps.api/consumer-routes)
|
||||
"/automagic-dashboards" (+auth metabase.xrays.api/automagic-dashboards-routes)
|
||||
"/bookmark" (+auth 'metabase.bookmarks.api)
|
||||
"/bug-reporting" (+auth 'metabase.bug-reporting.api)
|
||||
@@ -141,6 +143,7 @@
|
||||
"/cloud-migration" (+auth 'metabase.cloud-migration.api)
|
||||
"/collection" (+auth 'metabase.collections.api)
|
||||
"/dashboard" (+auth 'metabase.dashboards.api)
|
||||
"/data-app" (+auth metabase.data-apps.api/creator-routes)
|
||||
"/database" (+auth 'metabase.warehouses.api)
|
||||
"/dataset" (+auth 'metabase.query-processor.api)
|
||||
"/docs" (metabase.api.docs/make-routes #'routes)
|
||||
|
||||
@@ -53,7 +53,10 @@
|
||||
"Entities in the order they should be serialized/deserialized. This is done so we make sure that we load
|
||||
instances of entities before others that might depend on them, e.g. `Databases` before `Tables` before `Fields`."
|
||||
(concat
|
||||
[:model/Channel
|
||||
[:model/DataApp
|
||||
:model/DataAppDefinition
|
||||
:model/DataAppRelease
|
||||
:model/Channel
|
||||
:model/ChannelTemplate
|
||||
:model/Database
|
||||
:model/User
|
||||
|
||||
158
src/metabase/data_apps/CLAUDE.md
Normal file
158
src/metabase/data_apps/CLAUDE.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Data Apps Development Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains the core functionality for Metabase Data Apps - a feature that allows users to create and publish custom applications within Metabase.
|
||||
|
||||
## Key Components
|
||||
|
||||
- **core.clj** - Public API for the data apps module (all external interactions should go through this namespace)
|
||||
- **models.clj** - Internal database models and schema definitions for Apps, AppDefinitions, and AppRelease
|
||||
- **api/data-app.clj** - Admin/CRUD API endpoints under `/api/data-apps/` for managing data apps
|
||||
- **api/app.clj** - Consumer API endpoints under `/api/app/` for using published data apps
|
||||
- **enterprise/backend/src/metabase_enterprise/data_apps/** - Enterprise-specific features and extensions for data apps
|
||||
|
||||
## Data Model
|
||||
|
||||
### Tables and Relationships
|
||||
|
||||
```
|
||||
data_app
|
||||
├── id (PK)
|
||||
├── name
|
||||
├── slug (unique)
|
||||
├── status (private, published, archived)
|
||||
├── entity_id
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
data_app_definition
|
||||
├── id (PK)
|
||||
├── app_id (FK -> data_app.id, cascade delete)
|
||||
├── revision_number (incrementing number, calculated atomically)
|
||||
├── config (JSON)
|
||||
├── entity_id (char(21))
|
||||
└── created_at
|
||||
|
||||
data_app_release
|
||||
├── id (PK)
|
||||
├── app_id (FK -> data_app.id, cascade delete)
|
||||
├── app_definition_id (FK -> data_app_definition.id, cascade delete)
|
||||
├── created_at
|
||||
├── updated_at
|
||||
└── retracted (boolean, default false)
|
||||
```
|
||||
|
||||
### Key Relationships
|
||||
|
||||
1. **DataApp → DataAppDefinition** (1:N)
|
||||
- Each app can have multiple sequential snapshots (revisions) of its definition
|
||||
- Definitions are append-only (immutable once created)
|
||||
- Indexed on `app_id` for efficient queries
|
||||
- `revision_number` is calculated atomically to prevent race conditions
|
||||
|
||||
2. **DataApp → DataAppRelease** (1:N)
|
||||
- Each app can have multiple publishing records
|
||||
- Only ONE release can be active at a time (retracted = false)
|
||||
- When publishing a new release, all previous releases are automatically retracted
|
||||
- Multiple releases can exist without being retracted
|
||||
- Retracted status allows for simpler publishing and rollback
|
||||
|
||||
3. **DataAppDefinition → DataAppRelease** (1:N)
|
||||
- Each definition can be published multiple times
|
||||
- Release records track when a definition was published
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Model Structure
|
||||
|
||||
The data apps feature uses three main models:
|
||||
- `:model/DataApp` - The main app entity with unique slug (table: `data_app`)
|
||||
- `:model/DataAppDefinition` - Immutable versioned app configurations (table: `data_app_definition`)
|
||||
- `:model/DataAppRelease` - Release records tracking active versions (table: `data_app_release`)
|
||||
|
||||
### Public API (core.clj)
|
||||
|
||||
These functions form the public interface for the data apps module. All external code should use these functions rather than directly accessing the models:
|
||||
|
||||
- `create-app!` - Creates a new app with its initial definition
|
||||
- `set-latest-definition!` - Creates a new version of an app definition (revision_number calculated atomically)
|
||||
- `publish!` - Publishes a specific app definition version
|
||||
|
||||
**Important:** Do not import or use functions from `models.clj` directly outside of this module. Always use the public API in `core.clj`.
|
||||
|
||||
### Database Considerations
|
||||
|
||||
- App definitions use `revision_number` (not version) that increments atomically
|
||||
- Only one release can be active per app (enforced by auto-retracting previous releases)
|
||||
- Validation occurs before insert/update operations
|
||||
- The `retracted` field is the only updatable field on releases (append-only design)
|
||||
- Publishing automatically retracts all previous releases for that app
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The data apps feature exposes two distinct sets of API endpoints:
|
||||
|
||||
### Admin/Management APIs (`/api/data-app/`)
|
||||
|
||||
Located in `api/data-apps.clj`, these endpoints support the manipulation of Data Apps by Creators.
|
||||
|
||||
### Consumer APIs (`/api/app/`)
|
||||
|
||||
Located in `api/app.clj`, these endpoints are used to run Data Apps for Consumers.
|
||||
|
||||
### API Design Principles
|
||||
|
||||
1. **Separation of Concerns**: Creator operations are clearly separated from Consumer operations
|
||||
2. **Published-Only Access**: Consumer endpoints only return published app versions
|
||||
3. **Permission Enforcement**: All endpoints check appropriate permissions (read/create/update/delete)
|
||||
4. **Version Control**: Admin APIs handle versioning, while consumer APIs only see published versions
|
||||
5. **Resource Safety**: Consumer endpoints cannot modify app definitions, only interact with them
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Test Utilities
|
||||
|
||||
Use the testing utilities in `test/metabase/data_apps/test_util.clj`:
|
||||
|
||||
- **`with-data-app-cleanup`** - Wraps tests that create data apps to prevent resource leakage
|
||||
- **`with-data-app`** - Creates temporary data app models using `with-temp` pattern
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
1. **Flow tests** for CRUD operations (create → read → update → delete in one test)
|
||||
2. **Focused tests** for edge cases (validation errors, permissions, 404s)
|
||||
3. **Always use cleanup macros** to prevent test data leakage between runs
|
||||
4. **Use `=?` for response validation** instead of checking individual fields - more idiomatic and concise
|
||||
|
||||
### Common Patterns
|
||||
|
||||
1. Always validate app definition configs before saving
|
||||
2. Use transactions when updating multiple related records
|
||||
3. Revision numbers are calculated atomically using SQL (no race conditions)
|
||||
4. Handle publication state transitions explicitly in transactions
|
||||
5. Use `data_app` prefix for all table names for clarity
|
||||
|
||||
## Maintenance
|
||||
|
||||
### When to Update This File
|
||||
|
||||
Please update this CLAUDE.md file when:
|
||||
|
||||
1. **Adding new models or tables** - Update the data model section
|
||||
2. **Modifying database schema** - Update table structures and relationships
|
||||
3. **Adding new core functions** - Add to the Key Functions section
|
||||
4. **Changing validation rules** - Document in the appropriate section
|
||||
5. **Introducing new patterns** - Add to Common Patterns
|
||||
6. **Adding new files** - Update the Key Components section
|
||||
7. **Adding or modifying API endpoints** - Update the API Endpoints section
|
||||
|
||||
### Files to Check for Updates
|
||||
|
||||
When updating this documentation, review these files for changes:
|
||||
- `models.clj` - Model definitions and business logic
|
||||
- `core.clj` - Core functionality
|
||||
- `/resources/migrations/*_update_migrations.yaml` - Database schema changes
|
||||
- Any new `.clj` files added to this directory
|
||||
|
||||
This ensures the documentation stays in sync with the actual implementation.
|
||||
17
src/metabase/data_apps/api.clj
Normal file
17
src/metabase/data_apps/api.clj
Normal file
@@ -0,0 +1,17 @@
|
||||
(ns metabase.data-apps.api
|
||||
"Main API namespace for data apps that exports routes"
|
||||
(:require
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.data-apps.api.app]
|
||||
[metabase.data-apps.api.data-app]))
|
||||
|
||||
(comment metabase.data-apps.api.app/keep-me
|
||||
metabase.data-apps.api.data-app/keep-me)
|
||||
|
||||
(def ^{:arglists '([request respond raise])} consumer-routes
|
||||
"/api/app routes for consumer-facing data app APIs"
|
||||
(api.macros/ns-handler 'metabase.data-apps.api.app))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} creator-routes
|
||||
"/api/data-app routes for data app CRUD operations"
|
||||
(api.macros/ns-handler 'metabase.data-apps.api.data-app))
|
||||
37
src/metabase/data_apps/api/app.clj
Normal file
37
src/metabase/data_apps/api/app.clj
Normal file
@@ -0,0 +1,37 @@
|
||||
(ns metabase.data-apps.api.app
|
||||
"/api/app endpoints for data app consumers"
|
||||
(:require
|
||||
[honey.sql.helpers :as sql.helpers]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.data-apps.models :as data-apps.models]
|
||||
[metabase.request.core :as request]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
"Fetch a list of published data apps. Takes `limit`, `offset` for pagination."
|
||||
[_route-params
|
||||
_query-params]
|
||||
(let [limit (request/limit)
|
||||
offset (request/offset)
|
||||
clauses (cond-> {:where ;; TODO: might be faster to use left join here instead of exists, benchmark with EXPLAIN
|
||||
[:exists {:select [1]
|
||||
:from [:data_app_release]
|
||||
:where [:and
|
||||
[:= :data_app_release.app_id :data_app.id]
|
||||
[:= :data_app_release.retracted false]]}]}
|
||||
limit (sql.helpers/limit limit)
|
||||
offset (sql.helpers/offset offset))
|
||||
filter-clauses-without-paging (dissoc clauses :limit :offset)]
|
||||
{:data (t2/select :model/DataApp (sql.helpers/order-by clauses [:created_at :desc]))
|
||||
:total (t2/count :model/DataApp filter-clauses-without-paging)
|
||||
:limit limit
|
||||
:offset offset}))
|
||||
|
||||
(api.macros/defendpoint :get "/:slug"
|
||||
"Fetch the published definition of a data app."
|
||||
[{:keys [slug]} :- [:map [:slug :string]]]
|
||||
(-> (data-apps.models/get-published-data-app slug)
|
||||
api/read-check))
|
||||
103
src/metabase/data_apps/api/data_app.clj
Normal file
103
src/metabase/data_apps/api/data_app.clj
Normal file
@@ -0,0 +1,103 @@
|
||||
(ns metabase.data-apps.api.data-app
|
||||
"/api/data-app endpoints for data apps CRUD operations"
|
||||
(:require
|
||||
[honey.sql.helpers :as sql.helpers]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.data-apps.models :as data-apps.models]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- get-data-app
|
||||
"Get a data app by id with latest definitions and release info."
|
||||
[id]
|
||||
(let [data-app (api/read-check (t2/select-one :model/DataApp id))]
|
||||
(assoc data-app
|
||||
:definition (data-apps.models/latest-definition id)
|
||||
;; app_definition_id is only needed for testing
|
||||
:release (some-> (data-apps.models/latest-release id)
|
||||
(select-keys [:id :app_definition_id :created_at])))))
|
||||
|
||||
(defn- data-app-clauses
|
||||
"Honeysql clauses for filtering data apps with status and pagination"
|
||||
[limit offset]
|
||||
(cond-> {}
|
||||
true (sql.helpers/where [:!= :status "archived"])
|
||||
(some? limit) (sql.helpers/limit limit)
|
||||
(some? offset) (sql.helpers/offset offset)))
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
"List all data apps. Optionally filter by status.
|
||||
|
||||
Takes `limit`, `offset` for pagination."
|
||||
[_route-params]
|
||||
(let [limit (request/limit)
|
||||
offset (request/offset)
|
||||
clauses (data-app-clauses limit offset)]
|
||||
{:data (t2/select :model/DataApp
|
||||
(sql.helpers/order-by clauses
|
||||
[:created_at :desc]
|
||||
[:id :desc]))
|
||||
:total (t2/count :model/DataApp (dissoc clauses :order-by :limit :offset))
|
||||
:limit limit
|
||||
:offset offset}))
|
||||
|
||||
(api.macros/defendpoint :get "/:id"
|
||||
"Get a specific data app by ID with latest definitions and release info."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(get-data-app id))
|
||||
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Create a new data app with optional initial definition."
|
||||
[_route _query body :- [:map
|
||||
[:name ms/NonBlankString]
|
||||
[:slug ms/NonBlankString]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:definition {:optional true} [:maybe :map]]]]
|
||||
(api/create-check :model/DataApp body)
|
||||
(data-apps.models/create-app! (assoc body :creator_id api/*current-user-id*)))
|
||||
|
||||
(api.macros/defendpoint :put "/:id"
|
||||
"Update an existing data app."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query
|
||||
body :- [:map {:closed true}
|
||||
[:name {:optional true} ms/NonBlankString]
|
||||
[:slug {:optional true} ms/NonBlankString]
|
||||
[:description {:optional true} [:maybe :string]]]]
|
||||
(let [existing-data-app (get-data-app id)]
|
||||
(api/update-check existing-data-app body)
|
||||
(t2/update! :model/DataApp id body)
|
||||
(get-data-app id)))
|
||||
|
||||
(api.macros/defendpoint :put "/:id/status"
|
||||
"Change the status of an app."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query
|
||||
body :- [:map {:closed true}
|
||||
[:status :keyword]]]
|
||||
(let [existing-data-app (api/check-404 (t2/select-one :model/DataApp id))]
|
||||
(api/update-check existing-data-app body)
|
||||
(t2/update! :model/DataApp id body)
|
||||
(get-data-app id)))
|
||||
|
||||
(api.macros/defendpoint :put "/:id/definition"
|
||||
"Create a new definition version for a data app."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query
|
||||
body]
|
||||
(let [existing-data-app (api/read-check (t2/select-one :model/DataApp id))]
|
||||
(api/update-check existing-data-app {})
|
||||
(data-apps.models/set-latest-definition! id (assoc body :creator_id api/*current-user-id*))))
|
||||
|
||||
(api.macros/defendpoint :post "/:id/release"
|
||||
"Release the latest definition version of a data app."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query
|
||||
_body]
|
||||
(let [existing-data-app (api/read-check (t2/select-one :model/DataApp id))]
|
||||
(api/update-check existing-data-app {})
|
||||
(data-apps.models/release! id api/*current-user-id*)))
|
||||
1
src/metabase/data_apps/core.clj
Normal file
1
src/metabase/data_apps/core.clj
Normal file
@@ -0,0 +1 @@
|
||||
(ns metabase.data-apps.core)
|
||||
291
src/metabase/data_apps/models.clj
Normal file
291
src/metabase/data_apps/models.clj
Normal file
@@ -0,0 +1,291 @@
|
||||
(ns metabase.data-apps.models
|
||||
(:require
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private retention-max-per-app 20)
|
||||
(def ^:private retention-max-total 1000)
|
||||
|
||||
(methodical/defmethod t2/table-name :model/DataApp [_model] :data_app)
|
||||
(methodical/defmethod t2/table-name :model/DataAppDefinition [_model] :data_app_definition)
|
||||
(methodical/defmethod t2/table-name :model/DataAppRelease [_model] :data_app_release)
|
||||
|
||||
(doseq [model [:model/DataApp
|
||||
:model/DataAppDefinition
|
||||
:model/DataAppRelease]]
|
||||
(derive model :metabase/model))
|
||||
|
||||
(derive :model/DataApp :hook/timestamped?)
|
||||
(derive :model/DataApp :hook/entity-id)
|
||||
(derive :model/DataAppRelease :hook/timestamped?)
|
||||
(derive :model/DataAppDefinition :hook/created-at-timestamped?)
|
||||
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
;; :model/DataApp ;;
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
|
||||
(def ^:private data-app-statuses #{:private :published :archived})
|
||||
|
||||
(def ^:private simple-slug-regex #"^[0-9a-zA-Z_-]+$")
|
||||
|
||||
(t2/deftransforms :model/DataApp
|
||||
{:status (mi/transform-validator mi/transform-keyword (fn [v] (mi/assert-enum data-app-statuses (keyword v))))
|
||||
:slug (mi/transform-validator mi/transform-identity (partial mi/assert-regex simple-slug-regex))})
|
||||
|
||||
;; TODO: revisit permissions model
|
||||
(defmethod mi/can-read? :model/DataApp
|
||||
([_data-app]
|
||||
true)
|
||||
([_ _pk]
|
||||
true))
|
||||
|
||||
(defmethod mi/can-create? :model/DataApp
|
||||
([_data-app]
|
||||
(mi/superuser?))
|
||||
([_ _pk]
|
||||
(mi/superuser?)))
|
||||
|
||||
(defmethod mi/can-update? :model/DataApp
|
||||
([_data-app]
|
||||
(mi/superuser?))
|
||||
([_ _pk]
|
||||
(mi/superuser?)))
|
||||
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
;; :model/DataAppDefinition ;;
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
|
||||
(t2/deftransforms :model/DataAppDefinition
|
||||
{:config mi/transform-json})
|
||||
|
||||
(mr/def ::AppDefinitionConfig
|
||||
[:maybe [:map {:closed true}
|
||||
[:actions [:sequential :map]]
|
||||
[:parameters [:sequential :map]]
|
||||
[:pages [:sequential :map]]]])
|
||||
|
||||
(defn- validate-app-definition
|
||||
"Validate an AppDefinition."
|
||||
[app-definition]
|
||||
(mu/validate-throw ::AppDefinitionConfig (:config app-definition)))
|
||||
|
||||
(t2/define-before-insert :model/DataAppDefinition
|
||||
[instance]
|
||||
(validate-app-definition instance)
|
||||
instance)
|
||||
|
||||
(t2/define-before-update :model/DataAppDefinition
|
||||
[instance]
|
||||
(throw (ex-info "AppDefinition is append-only and cannot be updated"
|
||||
{:status-code 400
|
||||
:changes (t2/changes instance)})))
|
||||
|
||||
(defn- next-revision-number-hsql
|
||||
[app-id]
|
||||
[:coalesce
|
||||
[:+
|
||||
;; MySQL requires an extra subselect wrapper to circumvent its rule against having a source table as the target.
|
||||
{:select [:%max.revision_number]
|
||||
:from [[{:select [:*] :from [:data_app_definition]} :dumb_alias]]
|
||||
:where [:= :app_id app-id]}
|
||||
[:inline 1]]
|
||||
[:inline 1]])
|
||||
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
;; :model/DataAppRelease ;;
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
|
||||
(t2/define-before-update :model/DataAppRelease
|
||||
[instance]
|
||||
(let [changed-keys (-> instance t2/changes keys set)]
|
||||
(when-not (= #{:retracted} changed-keys)
|
||||
(throw (ex-info (format "AppRelease is append-only. Only 'retracted' field can be updated, but got: %s"
|
||||
changed-keys)
|
||||
{:status-code 400}))))
|
||||
instance)
|
||||
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
;; Serialization ;;
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
|
||||
(declare latest-release)
|
||||
|
||||
(defmethod serdes/make-spec "DataAppRelease" [_model-name _opts]
|
||||
{:copy []
|
||||
:skip []
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:creator_id (serdes/fk :model/User)
|
||||
:app_id (serdes/fk :model/DataApp)
|
||||
:app_definition_id (serdes/parent-ref)}})
|
||||
|
||||
(defmethod serdes/make-spec "DataAppDefinition" [_model-name opts]
|
||||
{:copy [:revision_number :config]
|
||||
:skip []
|
||||
:transform {:created_at (serdes/date)
|
||||
:creator_id (serdes/fk :model/User)
|
||||
:app_id (serdes/parent-ref)
|
||||
:release (serdes/nested :model/DataAppRelease :app_definition_id opts)}})
|
||||
|
||||
(defn- export-released-definition
|
||||
"Export the released definition for a data app, if it exists."
|
||||
[data-app]
|
||||
(when-let [release (latest-release (:id data-app))]
|
||||
[(serdes/extract-one "DataAppDefinition" {} (assoc (t2/select-one :model/DataAppDefinition (:app_definition_id release))
|
||||
:release [release]))]))
|
||||
|
||||
;; data-apps => definition => release
|
||||
|
||||
(defmethod serdes/make-spec "DataApp" [_model-name opts]
|
||||
{:copy [:name :description :slug :status :entity_id]
|
||||
:skip []
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:creator_id (serdes/fk :model/User)
|
||||
:released_definition (assoc (serdes/nested :model/DataAppDefinition :app_id opts)
|
||||
:export-with-context (fn [data-app _k _input] (export-released-definition data-app)))}})
|
||||
|
||||
(defmethod serdes/dependencies "DataApp" [_]
|
||||
#{})
|
||||
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
;; Public APIs ;;
|
||||
;;------------------------------------------------------------------------------------------------;;
|
||||
|
||||
;; A simple way to avoid concurrent or redundant pruning, and for pruning to happen off the main thread.
|
||||
(def ^:private pruner-dirty (atom false))
|
||||
(def ^:private pruner (agent :never-started))
|
||||
|
||||
(defn- prune-definitions!
|
||||
"Remove older definitions that don't correspond to releases or latest working drafts."
|
||||
([]
|
||||
(prune-definitions! retention-max-per-app retention-max-total))
|
||||
([retention-max-per-app retention-max-total]
|
||||
(t2/delete! :model/DataAppDefinition
|
||||
{:where [:in :id
|
||||
{:with [[:protected_definitions
|
||||
{:select [:dad.id]
|
||||
:from [[:data_app_definition :dad]]
|
||||
:where [:or
|
||||
;; Released definitions
|
||||
[:exists {:select [1]
|
||||
:from [[:data_app_release :dar]]
|
||||
:where [:and
|
||||
[:= :dar.app_definition_id :dad.id]
|
||||
[:= :dar.retracted false]]}]
|
||||
;; The highest revision per app
|
||||
[:exists {:select [1]
|
||||
:from [[:data_app_definition :dad2]]
|
||||
:where [:and
|
||||
[:= :dad2.app_id :dad.app_id]
|
||||
[:= :dad2.revision_number
|
||||
{:select [:%max.revision_number]
|
||||
:from [[:data_app_definition :dad3]]
|
||||
:where [:= :dad3.app_id :dad.app_id]}]
|
||||
[:= :dad2.id :dad.id]]}]]}]
|
||||
|
||||
[:ranked
|
||||
{:select [:dad.id :dad.app_id :dad.revision_number :dad.created_at
|
||||
[[:raw "ROW_NUMBER() OVER (PARTITION BY dad.app_id ORDER BY dad.revision_number DESC)"] :app_rank]
|
||||
[[:raw "ROW_NUMBER() OVER (ORDER BY dad.id DESC)"] :global_rank]]
|
||||
:from [[:data_app_definition :dad]]
|
||||
:where [:not [:exists {:select [1]
|
||||
:from [[:protected_definitions :pd]]
|
||||
:where [:= :pd.id :dad.id]}]]}]]
|
||||
|
||||
:select [:id]
|
||||
:from [:ranked]
|
||||
:where [:or
|
||||
[:> :app_rank retention-max-per-app]
|
||||
[:> :global_rank retention-max-total]]}]})))
|
||||
|
||||
(defn- prune-definitions-async!
|
||||
([]
|
||||
(prune-definitions-async! retention-max-per-app retention-max-total))
|
||||
([& opts]
|
||||
(reset! pruner-dirty true)
|
||||
(send-off pruner (fn [existing] (if @pruner-dirty :started existing)))
|
||||
(send-off pruner (fn [last-status]
|
||||
(if @pruner-dirty
|
||||
(try
|
||||
(apply prune-definitions! opts)
|
||||
:finished
|
||||
(catch Exception e
|
||||
(log/warn e "Failure pruning Data App definitions")
|
||||
:failed)
|
||||
(finally
|
||||
;; Reset the dirty flag even if it failed, to avoid spinning on expensive failures.
|
||||
(reset! pruner-dirty false)))
|
||||
(get #{:skipped :finished} last-status :skipped))))))
|
||||
|
||||
(defn set-latest-definition!
|
||||
"Create a new definition for an existing app."
|
||||
[app-id definition]
|
||||
(u/prog1 (t2/insert-returning-instance! :model/DataAppDefinition
|
||||
(merge definition
|
||||
{:app_id app-id
|
||||
:revision_number (next-revision-number-hsql app-id)}))
|
||||
(prune-definitions-async!)))
|
||||
|
||||
(defn create-app!
|
||||
"Create a new App with an initial definition."
|
||||
[app-data]
|
||||
(t2/with-transaction [_conn]
|
||||
(let [app (t2/insert-returning-instance! :model/DataApp (merge {:status :private} (dissoc app-data :definition)))]
|
||||
(when-let [definition (:definition app-data)]
|
||||
(set-latest-definition! (:id app) definition))
|
||||
(assoc app :definition (:definition app-data)))))
|
||||
|
||||
(defn latest-definition
|
||||
"Get the latest definition (released or not) for a data app."
|
||||
[app-id]
|
||||
(t2/select-one :model/DataAppDefinition
|
||||
:app_id app-id
|
||||
{:order-by [[:revision_number :desc]]}))
|
||||
|
||||
(defn release!
|
||||
"Release the latest definition of an app. Also take the app out of private or archived if necessary."
|
||||
[app-id creator-id]
|
||||
(t2/with-transaction [_conn]
|
||||
(let [latest-def (latest-definition app-id)]
|
||||
(when-not latest-def
|
||||
(throw (ex-info "No definition found for app" {:app-id app-id})))
|
||||
(t2/update! :model/DataApp app-id {:status :published})
|
||||
(t2/insert-returning-instance! :model/DataAppRelease
|
||||
{:app_id app-id
|
||||
:app_definition_id (:id latest-def)
|
||||
:creator_id creator-id}))))
|
||||
|
||||
(defn latest-release
|
||||
"Get the latest release info for a data app."
|
||||
[app-id]
|
||||
(t2/select-one :model/DataAppRelease
|
||||
:app_id app-id
|
||||
:retracted false
|
||||
{:order-by [[:id :desc]]}))
|
||||
|
||||
(defn released-definition
|
||||
"Get the latest released definition for a data app."
|
||||
[app-id]
|
||||
(when-let [release-definition-id (:app_definition_id (latest-release app-id))]
|
||||
(t2/select-one :model/DataAppDefinition release-definition-id)))
|
||||
|
||||
(defn get-published-data-app
|
||||
"Get the published version of a data app by id."
|
||||
[slug]
|
||||
(let [app (t2/select-one :model/DataApp :slug slug :status :published)
|
||||
_ (when-not app
|
||||
(throw (ex-info "Not found." {:status-code 404})))
|
||||
released-definition (released-definition (:id app))]
|
||||
(when-not released-definition
|
||||
(throw (ex-info "Data app is not released"
|
||||
{:status-code 404})))
|
||||
(assoc app :definition released-definition)))
|
||||
@@ -299,6 +299,11 @@
|
||||
{:in u/qualified-name
|
||||
:out keyword})
|
||||
|
||||
(def transform-identity
|
||||
"Identity transform, useful with validations."
|
||||
{:in identity
|
||||
:out identity})
|
||||
|
||||
(def transform-json-no-keywordization
|
||||
"Transform for json-no-keywordization"
|
||||
{:in json-in
|
||||
@@ -312,6 +317,15 @@
|
||||
(throw (ex-info (format "Invalid value %s. Must be one of %s" value (str/join ", " enum)) {:status-code 400
|
||||
:value value}))))
|
||||
|
||||
(mu/defn assert-regex
|
||||
"Assert that a value matches a regex."
|
||||
[regex :- [:fn u/regexp?]
|
||||
value]
|
||||
(when-not (and value (re-find regex value))
|
||||
(throw (ex-info (format "Value doesn't match the regex") {:status-code 400
|
||||
:value value
|
||||
:regex regex}))))
|
||||
|
||||
(mu/defn assert-namespaced
|
||||
"Assert that a value is a namespaced keyword under `qualified-ns`."
|
||||
[qualified-ns :- string?
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
Tests will check to make sure new models get included in this map."
|
||||
'{:model/Action metabase.actions.models
|
||||
:model/ApiKey metabase.api-keys.models.api-key
|
||||
:model/DataApp metabase.data-apps.models
|
||||
:model/DataAppDefinition metabase.data-apps.models
|
||||
:model/DataAppRelease metabase.data-apps.models
|
||||
:model/ApplicationPermissionsRevision metabase.permissions.models.application-permissions-revision
|
||||
:model/AuditLog metabase.audit-app.models.audit-log
|
||||
:model/BookmarkOrdering metabase.bookmarks.models.bookmark
|
||||
|
||||
@@ -795,7 +795,9 @@
|
||||
(recur xs (conj prefix x))))))
|
||||
|
||||
;;; Clj doesn't have `regexp?`, but Cljs does
|
||||
#?(:clj (defn- regexp? [x]
|
||||
#?(:clj (defn regexp?
|
||||
"Check if something is a regex."
|
||||
[x]
|
||||
(instance? java.util.regex.Pattern x)))
|
||||
|
||||
(derive :dispatch-type/nil :dispatch-type/*)
|
||||
|
||||
@@ -297,14 +297,14 @@
|
||||
:interval-ms 100})"
|
||||
[{:keys [thunk done? timeout-ms interval-ms]
|
||||
:or {timeout-ms 1000 interval-ms 100}}]
|
||||
(let [start-time (System/currentTimeMillis)]
|
||||
(let [start-time (System/nanoTime)]
|
||||
(loop []
|
||||
(let [response (thunk)]
|
||||
(if (done? response)
|
||||
response
|
||||
(let [current-time (System/currentTimeMillis)
|
||||
(let [current-time (System/nanoTime)
|
||||
elapsed-time (- current-time start-time)]
|
||||
(if (>= elapsed-time timeout-ms)
|
||||
(if (>= elapsed-time (* 1e6 timeout-ms))
|
||||
nil ; timeout reached
|
||||
(do
|
||||
(Thread/sleep (long interval-ms))
|
||||
|
||||
87
test/metabase/data_apps/api/app_test.clj
Normal file
87
test/metabase/data_apps/api/app_test.clj
Normal file
@@ -0,0 +1,87 @@
|
||||
(ns metabase.data-apps.api.app-test
|
||||
"Tests for /api/app endpoints for data app consumers"
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.data-apps.test-util :as data-apps.tu]
|
||||
[metabase.test :as mt]
|
||||
[ring.util.codec :as codec]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- get-app-slug
|
||||
"Helper function to construct a properly slug-encoded app endpoint."
|
||||
[app-slug]
|
||||
(str "/app/" (codec/url-encode app-slug)))
|
||||
|
||||
(defn- get-app-list
|
||||
"Helper function to fetch the list of apps."
|
||||
([user status query-params]
|
||||
(let [endpoint (if query-params
|
||||
(str "/app/?" query-params)
|
||||
"/app/")]
|
||||
(mt/user-http-request user :get status endpoint)))
|
||||
([]
|
||||
(get-app-list :crowberto 200 nil)))
|
||||
|
||||
(defn- filter-apps-by-ids
|
||||
"To avoid failure during devs since we might have data apps in the db."
|
||||
[response app-ids]
|
||||
(assert (set? app-ids))
|
||||
;; TODO should refactor this pattern to filter out the pre-existing app ids
|
||||
(assoc response :data (filter #(app-ids (:id %)) (:data response))))
|
||||
|
||||
(deftest get-published-apps-test
|
||||
(testing "GET /api/app/ - fetch published data apps"
|
||||
(data-apps.tu/with-released-app!
|
||||
[app {}]
|
||||
(is (=? {:data [{:id (:id app)}]
|
||||
:limit nil
|
||||
:offset nil
|
||||
:total (mt/malli=? pos-int?)}
|
||||
(filter-apps-by-ids (get-app-list) #{(:id app)})))))
|
||||
|
||||
(testing "GET /api/app/ - pagination works"
|
||||
(data-apps.tu/with-released-app!
|
||||
[app1 {:name "App 1" :slug "app1"}
|
||||
app2 {:name "App 2" :slug "app2"}]
|
||||
(let [response (filter-apps-by-ids (get-app-list :crowberto 200 "limit=1&offset=0")
|
||||
#{(:id app1) (:id app2)})]
|
||||
(is (=? {:data [{:id (:id app2)}]
|
||||
:limit 1
|
||||
:offset 0
|
||||
:total (mt/malli=? pos-int?)}
|
||||
response))))))
|
||||
|
||||
(deftest get-published-app-by-slug-test
|
||||
(testing "GET /api/app/:slug - fetch specific published app"
|
||||
(data-apps.tu/with-released-app!
|
||||
[app {:slug "my-test-app"}]
|
||||
(is (=? {:id (:id app)
|
||||
:name (:name app)
|
||||
:slug (:slug app)
|
||||
:definition (mt/malli=? :map)}
|
||||
(mt/user-http-request :crowberto :get 200 (get-app-slug "my-test-app"))))))
|
||||
|
||||
(testing "GET /api/app/:slug - returns 404 for non-existent app"
|
||||
(mt/user-http-request :crowberto :get 404 (get-app-slug "non-existent-app")))
|
||||
|
||||
(testing "GET /api/app/:slug - returns 404 for app private app"
|
||||
(data-apps.tu/with-data-app! [_app {:status :private
|
||||
:slug "unreleased-app"}]
|
||||
(mt/user-http-request :crowberto :get 404 (get-app-slug "unreleased-app"))))
|
||||
|
||||
(testing "GET /api/app/:slug - returns 404 for app archived app"
|
||||
(data-apps.tu/with-data-app! [_app {:status :archived
|
||||
:slug "archived-app"}]
|
||||
(mt/user-http-request :crowberto :get 404 (get-app-slug "archived-app"))))
|
||||
|
||||
(testing "GET /api/app/:slug - returns 404 for app released but archived apps"
|
||||
(data-apps.tu/with-released-app! [app {:status :published
|
||||
:slug "archived-app"}]
|
||||
(t2/update! :model/DataApp (:id app) {:status :archived})
|
||||
(mt/user-http-request :crowberto :get 404 (get-app-slug "archived-app"))))
|
||||
|
||||
(testing "GET /api/appp/:slug - returns 404 for published app without any active releases"
|
||||
(data-apps.tu/with-released-app! [app {:status :published
|
||||
:slug "published-no-release-app"}]
|
||||
(t2/update! :model/DataAppRelease :app_id (:id app) {:retracted true})
|
||||
(mt/user-http-request :crowberto :get 404 (get-app-slug "published-no-release-app")))))
|
||||
213
test/metabase/data_apps/api/data_app_test.clj
Normal file
213
test/metabase/data_apps/api/data_app_test.clj
Normal file
@@ -0,0 +1,213 @@
|
||||
(ns metabase.data-apps.api.data-app-test
|
||||
"Tests for /api/data-app endpoints"
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.data-apps.test-util :as data-apps.tu]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- filter-apps-by-ids
|
||||
[response app-ids]
|
||||
(assert (set? app-ids))
|
||||
(assoc response :data (sort-by :id (filter #(app-ids (:id %)) (:data response)))))
|
||||
|
||||
(deftest data-app-listing-test
|
||||
(testing "Listing data apps returns all non-archived apps"
|
||||
(data-apps.tu/with-data-app! [app1 {}]
|
||||
(data-apps.tu/with-data-app! [app2 {}]
|
||||
(testing "List endpoint returns both apps"
|
||||
(let [list-response (mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url))
|
||||
filtered-response (filter-apps-by-ids list-response #{(:id app1) (:id app2)})]
|
||||
(is (=? {:data [{:id (:id app1)} {:id (:id app2)}]
|
||||
:limit nil
|
||||
:offset nil
|
||||
:total (mt/malli=? pos-int?)}
|
||||
filtered-response))))
|
||||
|
||||
(testing "After archiving one app, listing returns only non-archived"
|
||||
(mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url (:id app1) "/status") {:status :archived})
|
||||
(let [list-response (mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url))
|
||||
filtered-response (filter-apps-by-ids list-response #{(:id app1) (:id app2)})]
|
||||
(is (=? {:data [{:id (:id app2)}]
|
||||
:limit nil
|
||||
:offset nil
|
||||
:total (mt/malli=? pos-int?)}
|
||||
filtered-response))))))))
|
||||
|
||||
(deftest data-app-get-test
|
||||
(testing "GET /api/data-app/:id - get specific data app"
|
||||
(testing "Get private app returns app without definition or release"
|
||||
(data-apps.tu/with-data-app!
|
||||
[app {:name "Private App" :slug "private-app" :description "A private app"}]
|
||||
(let [response (mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url (:id app)))]
|
||||
(is (=? {:id (:id app)
|
||||
:name "Private App"
|
||||
:slug "private-app"
|
||||
:description "A private app"
|
||||
:creator_id (mt/user->id :crowberto)
|
||||
:definition nil
|
||||
:release nil}
|
||||
response)))))
|
||||
|
||||
(testing "Get app with definition returns latest definition"
|
||||
(data-apps.tu/with-data-app!
|
||||
[app {:name "App With Definition"
|
||||
:slug "app-with-def"
|
||||
:definition {:config data-apps.tu/default-app-definition-config}}]
|
||||
(is (=? {:id (:id app)
|
||||
:name "App With Definition"
|
||||
:slug "app-with-def"
|
||||
:creator_id (mt/user->id :crowberto)
|
||||
:definition {:config data-apps.tu/default-app-definition-config
|
||||
:revision_number 1}
|
||||
:release nil}
|
||||
(mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url (:id app)))))))
|
||||
|
||||
(testing "Get released app returns definition and release info"
|
||||
(data-apps.tu/with-released-app!
|
||||
[app {:name "Released App" :slug "released-app"}]
|
||||
(is (=? {:id (:id app)
|
||||
:name "Released App"
|
||||
:slug "released-app"
|
||||
:creator_id (mt/user->id :crowberto)
|
||||
:definition {:config (mt/malli=? :map)
|
||||
:revision_number (mt/malli=? pos-int?)}
|
||||
:release {:app_definition_id (mt/malli=? pos-int?)
|
||||
:released_at (mt/malli=? some?)}}
|
||||
(mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url (:id app)))))))
|
||||
|
||||
(testing "Get non-existent app returns 404"
|
||||
(mt/user-http-request :crowberto :get 404 (data-apps.tu/data-app-url Integer/MAX_VALUE)))))
|
||||
|
||||
(deftest comprehensive-crud-flow-test
|
||||
(testing "Complete CRUD flow for data apps including definition and release"
|
||||
(data-apps.tu/with-data-app-cleanup!
|
||||
(mt/with-test-user :crowberto
|
||||
(let [create-response (mt/user-http-request :crowberto :post 200 (data-apps.tu/data-app-url)
|
||||
{:name "My Data App"
|
||||
:slug "my-data-app"
|
||||
:description "A comprehensive test app"})
|
||||
app-id (:id create-response)]
|
||||
(testing "Create returns expected fields"
|
||||
(is (=? {:id (mt/malli=? pos-int?)
|
||||
:name "My Data App"
|
||||
:slug "my-data-app"
|
||||
:description "A comprehensive test app"
|
||||
:creator_id (mt/user->id :crowberto)}
|
||||
create-response)))
|
||||
|
||||
(testing "Update app fields"
|
||||
(let [update-response (mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url app-id)
|
||||
{:name "Updated Data App"
|
||||
:slug "updated-data-app"
|
||||
:description "Updated description"})]
|
||||
(is (=? {:name "Updated Data App"
|
||||
:slug "updated-data-app"
|
||||
:description "Updated description"}
|
||||
update-response))))
|
||||
|
||||
(testing "Add definition to app"
|
||||
(let [config data-apps.tu/default-app-definition-config
|
||||
definition-response (mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url app-id "/definition")
|
||||
{:config config})]
|
||||
(is (=? {:app_id app-id
|
||||
:config data-apps.tu/default-app-definition-config
|
||||
:revision_number 1}
|
||||
definition-response))
|
||||
|
||||
(testing "fetching the app will return the latest definition"
|
||||
(is (=? {:definition {:config config
|
||||
:revision_number 1}}
|
||||
(mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url app-id))))))
|
||||
|
||||
(testing "Add second definition version"
|
||||
(let [new-config {:actions []
|
||||
:parameters []
|
||||
:pages [{:name "New page"}]}
|
||||
definition-response (mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url app-id "/definition")
|
||||
{:config new-config})]
|
||||
(is (=? {:app_id app-id
|
||||
:config new-config
|
||||
:revision_number 2}
|
||||
definition-response))
|
||||
|
||||
(testing "fetching the app will return the latest definition"
|
||||
(is (=? {:definition {:config new-config
|
||||
:revision_number 2}}
|
||||
(mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url app-id)))))
|
||||
|
||||
(testing "Release latest definition"
|
||||
(let [release-response (mt/user-http-request :crowberto :post 200 (data-apps.tu/data-app-url app-id "/release"))]
|
||||
(is (=? {:app_definition_id (:id definition-response)
|
||||
:released_at (mt/malli=? some?)
|
||||
:retracted false}
|
||||
release-response))
|
||||
|
||||
(let [app-with-release (mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url app-id))]
|
||||
(is (=? {:release {:app_definition_id (:id definition-response)}}
|
||||
app-with-release))))))))
|
||||
|
||||
(testing "Archive the status"
|
||||
(mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url app-id "/status") {:status :archived})
|
||||
(let [archived-app (t2/select-one :model/DataApp app-id)]
|
||||
(is (=? {:status :archived}
|
||||
archived-app))))
|
||||
|
||||
(testing "Mark the app as private"
|
||||
(mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url app-id "/status") {:status :private})
|
||||
(let [private-app (t2/select-one :model/DataApp app-id)]
|
||||
(is (=? {:status :private}
|
||||
private-app)))))))))
|
||||
|
||||
(deftest definition-validation-test
|
||||
(testing "Validation of definition fields"
|
||||
(data-apps.tu/with-data-app!
|
||||
[app {}]
|
||||
(testing "Backend ignores user-provided revision_number and sets it automatically"
|
||||
(let [definition-response (mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url (:id app) "/definition")
|
||||
{:config data-apps.tu/default-app-definition-config
|
||||
:revision_number 5})]
|
||||
(is (= 1 (:revision_number definition-response))))))))
|
||||
|
||||
(deftest multiple-releases-retraction-test
|
||||
(testing "Multiple releases with old releases being retracted"
|
||||
(data-apps.tu/with-data-app!
|
||||
[{app-id :id} {:name "Multi Release App"
|
||||
:slug "multi-release-app"
|
||||
:description "App for testing multiple releases"}]
|
||||
(testing "Create first definition and release"
|
||||
(mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url app-id "/definition")
|
||||
{:config data-apps.tu/default-app-definition-config})
|
||||
(let [release1-response (mt/user-http-request :crowberto :post 200 (data-apps.tu/data-app-url app-id "/release"))]
|
||||
(is (=? {:retracted false} release1-response))
|
||||
|
||||
(testing "First release is active in database"
|
||||
(let [releases (t2/select :model/DataAppRelease :app_id app-id)]
|
||||
(is (= 1 (count releases)))
|
||||
(is (false? (:retracted (first releases))))))))
|
||||
|
||||
(testing "Create second definition and release"
|
||||
(let [config2 {:actions []
|
||||
:parameters []
|
||||
:pages [{:name "Updated page"}]}
|
||||
second-definition (mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url app-id "/definition")
|
||||
{:config config2})]
|
||||
(mt/user-http-request :crowberto :post 200 (data-apps.tu/data-app-url app-id "/release"))
|
||||
(testing "Only the latest release is returned when fetching the app"
|
||||
(let [app-with-release (mt/user-http-request :crowberto :get 200 (data-apps.tu/data-app-url app-id))]
|
||||
(is (=? {:release {:app_definition_id (:id second-definition)}
|
||||
:definition {:revision_number 2}} app-with-release)))))))))
|
||||
|
||||
(deftest status-update-api-test
|
||||
(data-apps.tu/with-data-app!
|
||||
[app {:name "App With Definition"
|
||||
:slug "app-with-def"
|
||||
:definition {:config data-apps.tu/default-app-definition-config}}]
|
||||
(testing "status is disallowed when updating from PUT /api/data-app/:id"
|
||||
(mt/user-http-request :crowberto :put 400 (data-apps.tu/data-app-url (:id app)) {:status :archived}))
|
||||
|
||||
(testing "can update using PUT /api/data-app/:id/status"
|
||||
(mt/user-http-request :crowberto :put 200 (data-apps.tu/data-app-url (:id app) "/status") {:status :archived})
|
||||
|
||||
(testing "bad statues are ignored"
|
||||
(mt/user-http-request :crowberto :put 400 (data-apps.tu/data-app-url (:id app) "/status") {:status :bad-status})))))
|
||||
196
test/metabase/data_apps/models_test.clj
Normal file
196
test/metabase/data_apps/models_test.clj
Normal file
@@ -0,0 +1,196 @@
|
||||
(ns metabase.data-apps.models-test
|
||||
"Tests for data apps models"
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.test :refer :all]
|
||||
[metabase.data-apps.models :as data-apps.models]
|
||||
[metabase.data-apps.test-util :as data-apps.tu]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.time Instant)
|
||||
(java.util.concurrent CountDownLatch)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- prune-definitions! [& opts]
|
||||
(apply #'data-apps.models/prune-definitions! opts))
|
||||
|
||||
(defn- prune-async! [& opts]
|
||||
(apply #'data-apps.models/prune-definitions-async! opts))
|
||||
|
||||
(defn- mariadb-appdb? []
|
||||
(= "MariaDB"
|
||||
(t2/with-connection [conn]
|
||||
(.getDatabaseProductName
|
||||
(.getMetaData conn)))))
|
||||
|
||||
(deftest async-pruning-test
|
||||
(testing "Test async pruning isn't totally broken"
|
||||
(data-apps.tu/with-data-app-cleanup!
|
||||
(data-apps.tu/with-data-app! [app {:name "MyApp"}]
|
||||
(dotimes [_ 10]
|
||||
(data-apps.models/set-latest-definition! (:id app) {:creator_id (mt/user->id :crowberto)
|
||||
:config data-apps.tu/default-app-definition-config}))
|
||||
(is (= 10 (t2/count :model/DataAppDefinition :app_id (:id app))))
|
||||
(let [call-count 5
|
||||
keep-count 2
|
||||
invocations (atom 0)
|
||||
block-fn #(u/poll {:thunk (fn []
|
||||
[@@#'data-apps.models/pruner-dirty
|
||||
@@#'data-apps.models/pruner])
|
||||
:done? (fn [[dirty status]]
|
||||
(and (false? dirty)
|
||||
(get #{:finished :skipped :failed} status)))
|
||||
:interval-ms 50
|
||||
:timeout-ms 1000})
|
||||
latch (CountDownLatch. (inc call-count))]
|
||||
;; Wait for any prior pruning to finish, so we don't get de-duplicated with it.
|
||||
(block-fn)
|
||||
(mt/with-dynamic-fn-redefs [data-apps.models/prune-definitions!
|
||||
(let [original-fn (mt/dynamic-value @#'data-apps.models/prune-definitions!)]
|
||||
(fn [& opts]
|
||||
(swap! invocations inc)
|
||||
(.countDown latch)
|
||||
(apply original-fn opts)))]
|
||||
(dotimes [_ call-count]
|
||||
(prune-async! keep-count keep-count)))
|
||||
;; Wait for our own invocations to complete.
|
||||
(.countDown latch)
|
||||
(block-fn)
|
||||
(testing "We skip consecutive pruning, if nothing has changed."
|
||||
(is (= 1 @invocations)))
|
||||
(testing "We keep 2 unreferenced definition + HEAD"
|
||||
;; Getting CI issues with the latest MariaDB, it claims there is "No database selected" for the Delete.
|
||||
(when-not (mariadb-appdb?)
|
||||
(t2/select-fn-vec (juxt :app_id :revision_number) :model/DataAppDefinition)
|
||||
(is (= #{8 9 10} (t2/select-fn-set :revision_number :model/DataAppDefinition :app_id (:id app)))))))))))
|
||||
|
||||
(deftest prune-definitions-test
|
||||
;; Getting CI issues with the latest MariaDB, it claims there is "No database selected" for the Delete.
|
||||
;; Version 10.2 appears not to have trouble with this test, but it does fail for the async one.
|
||||
(when-not (mariadb-appdb?)
|
||||
(testing "Pruning a single app with multiple releases"
|
||||
(data-apps.tu/with-data-app-cleanup!
|
||||
;; Prune *everything* unprotected to avoid flakes from other tests, or state in your dev database.
|
||||
(prune-definitions! 0 0)
|
||||
(let [retention-per-app 5
|
||||
retention-total 5
|
||||
|
||||
;; Create an app with a bunch of releases with a bunch of interspersed definitions.
|
||||
num-versions 5
|
||||
defs-per-version 5
|
||||
num-definitions (inc (* num-versions defs-per-version))
|
||||
app-id (let [creator-id (mt/user->id :crowberto)
|
||||
{app-id :id} (data-apps.models/create-app!
|
||||
{:name "My app for singles"
|
||||
:slug "single-tingle"
|
||||
:creator_id creator-id})]
|
||||
(doseq [i (range num-definitions)]
|
||||
(let [_ (data-apps.models/set-latest-definition!
|
||||
app-id
|
||||
{:creator_id creator-id
|
||||
:config data-apps.tu/default-app-definition-config})]
|
||||
;; Create release for positions 1 and 4 (0-indexed) = revisions 2 and 5
|
||||
(when (= 1 (mod i defs-per-version))
|
||||
(data-apps.models/release! app-id creator-id))))
|
||||
app-id)
|
||||
|
||||
deleted-count (prune-definitions! retention-per-app retention-total)
|
||||
|
||||
revisions-fn #(t2/select-fn-set :revision_number
|
||||
[:model/DataAppDefinition :app_id :revision_number]
|
||||
:app_id app-id)
|
||||
revisions (revisions-fn)]
|
||||
|
||||
(testing "Definitions were deleted"
|
||||
;; We expect some deletions based on retention policies
|
||||
(is (= 15 deleted-count)))
|
||||
|
||||
(testing "Final count respects global retention limit"
|
||||
;; This is a lax inequality as we're also keeping additional protected definitions.
|
||||
(is (<= (max (* 1 retention-per-app) retention-total)
|
||||
(count revisions))))
|
||||
|
||||
(testing "All protected definitions remain"
|
||||
(is (set/superset? revisions (->> (range num-definitions)
|
||||
(filter #(= 1 (mod % defs-per-version)))
|
||||
(map inc)))))
|
||||
|
||||
(testing "We additionally retain the 5 most recent versions"
|
||||
;; Given that we are retaining v22 (because it's released) and v26 (because it's the latest), it would
|
||||
;; probably be more intuitive to NOT keep versions 20 and 21 as we already have the 5 most recent.
|
||||
(is (= #{2 7 12 17 20 21 22 23 24 25 26} revisions)))
|
||||
|
||||
(testing "Idempotency: running pruning again should delete nothing"
|
||||
(is (= 0 (prune-definitions! retention-per-app retention-total)))
|
||||
(is (= revisions (revisions-fn)))))))
|
||||
|
||||
(testing "Pruning multiple apps with multiple releases"
|
||||
(data-apps.tu/with-data-app-cleanup!
|
||||
(let [num-apps 5
|
||||
retention-per-app 5
|
||||
retention-total 18
|
||||
|
||||
;; Create N apps with definitions created in time order
|
||||
;; App 1 oldest, App N newest.
|
||||
;; Each app has the following sequence of definitions: u r u u r u u u u u, where
|
||||
;; u - unreleased
|
||||
;; r - was released
|
||||
protected-revisions [2 5 10]
|
||||
app-ids (doall
|
||||
(for [i (range num-apps)]
|
||||
(let [app-idx (inc i)
|
||||
creator-id (mt/user->id :crowberto)
|
||||
app-id (:id (data-apps.models/create-app!
|
||||
{:name (str "App " (inc app-idx))
|
||||
:slug (str "app-" (inc app-idx))
|
||||
:creator_id creator-id}))]
|
||||
(doseq [i (range 10)]
|
||||
(let [created-at (Instant/ofEpochSecond (+ 1000000 (* app-idx 10000) (* i 1000)))
|
||||
_ (data-apps.models/set-latest-definition!
|
||||
app-id
|
||||
{:creator_id creator-id
|
||||
:config data-apps.tu/default-app-definition-config
|
||||
:created_at created-at})]
|
||||
;; Create release for positions 1 and 4 (0-indexed) = revisions 2 and 5
|
||||
(when (#{1 4} i)
|
||||
(data-apps.models/release! app-id creator-id))))
|
||||
app-id)))
|
||||
|
||||
deleted-count (#'data-apps.models/prune-definitions! retention-per-app retention-total)
|
||||
|
||||
remaining-pairs-fn #(t2/select-fn-set (juxt :app_id :revision_number)
|
||||
[:model/DataAppDefinition :app_id :revision_number]
|
||||
:app_id [:in app-ids])
|
||||
remaining-pairs (remaining-pairs-fn)]
|
||||
|
||||
(testing "Definitions were deleted"
|
||||
;; We expect some deletions based on retention policies
|
||||
(is (= 21 deleted-count)))
|
||||
|
||||
(testing "Final count respects global retention limit"
|
||||
;; This is a lax inequality as we're also keeping additional protected definitions.
|
||||
(is (<= (max (* num-apps retention-per-app) retention-total)
|
||||
(count remaining-pairs))))
|
||||
|
||||
(testing "All protected definitions remain"
|
||||
(doseq [app-id app-ids, rn protected-revisions]
|
||||
(is (contains? remaining-pairs [app-id rn])
|
||||
(str "App " app-id " missing protected definitions"))))
|
||||
|
||||
(is (= (into (sorted-set)
|
||||
(mapcat (fn [app-id revisions]
|
||||
(map (partial vector app-id) revisions))
|
||||
app-ids
|
||||
[[2 5 10]
|
||||
[2 5 10]
|
||||
[2 5 6 7 8 9 10]
|
||||
[2 4 5 6 7 8 9 10]
|
||||
[2 4 5 6 7 8 9 10]]))
|
||||
remaining-pairs))
|
||||
|
||||
(testing "Idempotency: running pruning again should delete nothing"
|
||||
(is (= 0 (#'data-apps.models/prune-definitions! retention-per-app retention-total)))
|
||||
(is (= remaining-pairs (remaining-pairs-fn)))))))))
|
||||
107
test/metabase/data_apps/test_util.clj
Normal file
107
test/metabase/data_apps/test_util.clj
Normal file
@@ -0,0 +1,107 @@
|
||||
(ns metabase.data-apps.test-util
|
||||
"Test utilities for data apps"
|
||||
(:require
|
||||
[metabase.data-apps.models :as data-apps.models]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.util :as tu]
|
||||
[metabase.util.malli :as mu]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defmacro with-data-app-cleanup!
|
||||
"Execute `body`, then delete any *new* data app-related rows created during execution.
|
||||
This includes DataApp, DataAppDefinition, and DataAppRelease models.
|
||||
|
||||
Example:
|
||||
(with-data-app-cleanup!
|
||||
(create-data-app-via-api!)
|
||||
(is (= ...)))"
|
||||
{:style/indent 0}
|
||||
[& body]
|
||||
`(tu/with-model-cleanup [:model/DataAppRelease :model/DataAppDefinition :model/DataApp]
|
||||
~@body))
|
||||
|
||||
(defn do-with-data-app!
|
||||
"Create a temporary data app for testing. Optionally creates a definition if :definition is provided."
|
||||
[{:keys [definition] :as data-app} thunk]
|
||||
(mt/with-temp
|
||||
[:model/DataApp {app-id :id :as app} (merge
|
||||
{:name "Test Data App"}
|
||||
(dissoc data-app :definition))]
|
||||
(try
|
||||
(let [app-with-definition (if definition
|
||||
(->> (merge definition {:creator_id (mt/user->id :crowberto)})
|
||||
(data-apps.models/set-latest-definition! app-id)
|
||||
(assoc app :definition))
|
||||
app)]
|
||||
(thunk app-with-definition))
|
||||
(finally
|
||||
;; Work around the fact that MySQL tries to delete child tables in the wrong order (definition, then release),
|
||||
;; and trips around the fact that ONLY the "definition -> release" FK does NOT cascade on delete.
|
||||
(t2/delete! :model/DataAppRelease :app_id app-id)))))
|
||||
|
||||
(defn data-app-url
|
||||
"URL helper for data app endpoints"
|
||||
([] "/data-app")
|
||||
([id] (str "/data-app/" id))
|
||||
([id suffix] (str "/data-app/" id suffix)))
|
||||
|
||||
(defmacro with-data-app!
|
||||
"Macro that sets up temporary data apps for testing. Supports both single and multiple app creation.
|
||||
|
||||
Single app example:
|
||||
(with-data-app!
|
||||
[app {:name \"My Test App\"
|
||||
:definition default-app-definition-config}]
|
||||
(is (= 1 (get-in app [:definition :revision_number]))))
|
||||
|
||||
Multiple apps example:
|
||||
(with-data-app!
|
||||
[app1 {:name \"App 1\"} app2 {:name \"App 2\"}]
|
||||
(is (= \"App 1\" (:name app1)))
|
||||
(is (= \"App 2\" (:name app2))))"
|
||||
[bindings & body]
|
||||
(let [binding-pairs (partition 2 bindings)]
|
||||
`(with-data-app-cleanup!
|
||||
~(reduce (fn [acc-body [binding props]]
|
||||
`(do-with-data-app! ~props (fn [~binding] ~acc-body)))
|
||||
`(do ~@body)
|
||||
(reverse binding-pairs)))))
|
||||
|
||||
(def default-app-definition-config
|
||||
"Default app definition config that matches the malli spec"
|
||||
(mu/validate-throw ::data-apps.models/AppDefinitionConfig
|
||||
{:actions []
|
||||
:parameters []
|
||||
:pages [{:name "Default Page"}]}))
|
||||
|
||||
(defn do-with-released-app!
|
||||
"Create a temporary data app with a released definition for testing."
|
||||
[data-app thunk]
|
||||
(let [creator-id (or (:creator_id data-app) (mt/user->id :crowberto))]
|
||||
(with-data-app! [app (cond-> data-app
|
||||
(not (contains? data-app :definition))
|
||||
(assoc :definition {:config default-app-definition-config
|
||||
:creator_id creator-id}))]
|
||||
(data-apps.models/release! (:id app) creator-id)
|
||||
(thunk (data-apps.models/get-published-data-app (:slug app))))))
|
||||
|
||||
(defmacro with-released-app!
|
||||
"Macro that sets up temporary data apps with released definitions for testing. Supports both single and multiple app creation.
|
||||
|
||||
Single app example:
|
||||
(with-released-app!
|
||||
[app {:name \"My Test App\"}]
|
||||
(is (= \"My Test App\" (:name app))))
|
||||
|
||||
Multiple apps example:
|
||||
(with-released-app!
|
||||
[app1 {:name \"App 1\"} app2 {:name \"App 2\"}]
|
||||
(is (= \"App 1\" (:name app1)))
|
||||
(is (= \"App 2\" (:name app2))))"
|
||||
[bindings & body]
|
||||
(let [binding-pairs (partition 2 bindings)]
|
||||
`(with-data-app-cleanup!
|
||||
~(reduce (fn [acc-body [binding props]]
|
||||
`(do-with-released-app! ~props (fn [~binding] ~acc-body)))
|
||||
`(do ~@body)
|
||||
(reverse binding-pairs)))))
|
||||
@@ -163,6 +163,22 @@
|
||||
:cache_field_values_schedule "0 50 0 * * ? *"
|
||||
:settings {:database-source-dataset-name "test-data"}}))
|
||||
|
||||
:model/DataApp
|
||||
(fn [_] (default-timestamped
|
||||
{:name (u.random/random-name)
|
||||
:slug (u.random/random-name)
|
||||
:description (str "Test description for " (u.random/random-name))
|
||||
:status :private
|
||||
:creator_id (user-id :crowberto)}))
|
||||
|
||||
:model/DataAppDefinition
|
||||
(fn [_] {:created_at :%now
|
||||
:creator_id (user-id :crowberto)})
|
||||
|
||||
:model/DataAppRelease
|
||||
(fn [_] {:released_at :%now
|
||||
:creator_id (user-id :crowberto)})
|
||||
|
||||
:model/Dimension
|
||||
(fn [_] (default-timestamped
|
||||
{:name (u.random/random-name)
|
||||
@@ -790,7 +806,9 @@
|
||||
additional-conditions (with-model-cleanup-additional-conditions model)]]
|
||||
(t2/query-one
|
||||
{:delete-from (t2/table-name model)
|
||||
:where [:and max-id-condition additional-conditions]}))
|
||||
:where (if (some? additional-conditions)
|
||||
[:and max-id-condition additional-conditions]
|
||||
max-id-condition)}))
|
||||
;; TODO we don't (currently) have index update hooks on deletes, so we need this to ensure rollback happens.
|
||||
(search/reindex! {:in-place? true})))))
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.namespace.find :as ns.find]))
|
||||
|
||||
;; This is made `^:const` so it will get calculated when the uberjar is compiled. `find-namespaces` won't work if
|
||||
;; source is excluded; either way this takes a few seconds, so doing it at compile time speeds up launch as well.
|
||||
(defonce ^:const ^{:doc "Vector of symbols of all Metabase namespaces, excluding test namespaces. This is intended
|
||||
(defonce ^{:doc "Vector of symbols of all Metabase namespaces, excluding test namespaces. This is intended
|
||||
for use by various test routines that need to load related namespaces.
|
||||
|
||||
DEPRECATED: Using this is an anti-pattern, it messes up our ability to analyze the code and find dependencies between
|
||||
|
||||
Reference in New Issue
Block a user