Compare commits

...

17 Commits

Author SHA1 Message Date
Chris Truter
b51178eb13 Save a query when serializing 2025-07-21 12:56:31 +02:00
Ngoc Khuat
024b40066b Merge branch 'data-apps' into data-apps-serdes 2025-07-21 17:44:44 +07:00
Ngoc Khuat
ec4463e206 remove retracted from copying and fix tests 2025-07-21 15:31:08 +07:00
Chris Truter
2fc88c360c Rough version of app definition pruning (#60971) 2025-07-17 22:30:18 +00:00
Chris Truter
201fa7a7b8 Merge conflicting files with master... doubt this will work (#61124) 2025-07-17 15:01:24 +03:00
Chris Truter
e8491a0a2a Merge branch 'master' into data-apps 2025-07-17 11:46:39 +02:00
Chris Truter
b4c0e8bb27 Comment about weird detail 2025-07-17 11:45:11 +02:00
Ngoc Khuat
6c29dbd547 add more tests 2025-07-16 15:44:04 +07:00
Ngoc Khuat
b64c8c1c83 First pass data apps serdes 2025-07-16 15:19:34 +07:00
Denis Berezin
ef182d6d4b Review fixes for Data Apps CRUD (#60856)
* Product review fixes

* Add archive flow
2025-07-11 23:05:46 +03:00
Chris Truter
86361d832c Tweaks to db schema script (#60837) 2025-07-11 10:00:06 +00:00
Chris Truter
87f57d1075 Add dev script to summarize db schema (#60826)
Like `\d` but better.
2025-07-11 09:45:06 +00:00
Denis Berezin
08ff5bbaad Data Apps CRUD API integration (#60744)
* Add api intergration

* Add autoreload

* Fix types
2025-07-10 19:41:58 +03:00
Ngoc Khuat
f2c7142876 Data Apps CRUD (#60569) 2025-07-10 20:52:15 +07:00
Chris Truter
22427fc794 Merge branch 'master' into data-apps 2025-07-10 12:07:48 +02:00
Ngoc Khuat
107d0ae201 Table schema and models for Data Apps (#60452) 2025-07-09 14:07:27 +02:00
Denis Berezin
4a2fae7c45 Add data apps basic pages and layout (#60514)
* Add data apps basic pages and layout

* Add page layout for edit mode

* Add published app foundation

* Fix CI
2025-07-09 13:15:44 +03:00
50 changed files with 3187 additions and 11 deletions

View File

@@ -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

View File

@@ -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}

View File

@@ -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
View 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"]})}}))

View File

@@ -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`

View File

@@ -0,0 +1 @@
(ns metabase-enterprise.data-apps.api)

View File

@@ -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"

View File

@@ -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))))))))

View 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;

View File

@@ -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";

View File

@@ -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];

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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"];
};

View File

@@ -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`;
};

View File

@@ -38,4 +38,5 @@ import "./resource_downloads";
import "./user_provisioning";
import "./clean_up";
import "./metabot";
import "./data-apps";
import "./database_replication";

View 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;

View File

@@ -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";

View 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;
};

View 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}`;
}

View File

@@ -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";

View File

@@ -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>
);

View File

@@ -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) => {},

View File

@@ -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} />

View File

@@ -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
View 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)))

View File

@@ -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 <<<<<<<<<<
########################################################################################################################

View File

@@ -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)

View File

@@ -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

View 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.

View 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))

View 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))

View 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*)))

View File

@@ -0,0 +1 @@
(ns metabase.data-apps.core)

View 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)))

View File

@@ -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?

View File

@@ -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

View File

@@ -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/*)

View File

@@ -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))

View 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")))))

View 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})))))

View 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)))))))))

View 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)))))

View File

@@ -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})))))

View File

@@ -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