Compare commits
7 Commits
representa
...
workspaces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dd7d78c07 | ||
|
|
33daf1d822 | ||
|
|
9faad636cb | ||
|
|
b201dca6cf | ||
|
|
f1982e56e3 | ||
|
|
439c3f3be5 | ||
|
|
37449925fa |
@@ -2249,6 +2249,18 @@
|
||||
models
|
||||
premium-features
|
||||
upload
|
||||
util}}
|
||||
|
||||
enterprise/workspaces
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.workspaces.api
|
||||
metabase-enterprise.workspaces.init}
|
||||
:uses #{api
|
||||
api-keys
|
||||
driver
|
||||
models
|
||||
request
|
||||
system
|
||||
util}}}
|
||||
|
||||
;; namespaces matching these patterns (with `re-find`) are excluded from the module linter. Since regex literals
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
[metabase-enterprise.transforms-python.api]
|
||||
[metabase-enterprise.transforms.api]
|
||||
[metabase-enterprise.upload-management.api]
|
||||
[metabase-enterprise.workspaces.api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :as api.routes.common]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
@@ -131,7 +132,8 @@
|
||||
"/transform" (premium-handler metabase-enterprise.transforms.api/routes :transforms)
|
||||
"/transform-job" (premium-handler metabase-enterprise.transforms.api/transform-job-routes :transforms)
|
||||
"/transform-tag" (premium-handler metabase-enterprise.transforms.api/transform-tag-routes :transforms)
|
||||
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)})
|
||||
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)
|
||||
"/workspace" metabase-enterprise.workspaces.api/routes})
|
||||
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑
|
||||
|
||||
(def ^:private routes-map
|
||||
|
||||
@@ -23,4 +23,5 @@
|
||||
[metabase-enterprise.sso.init]
|
||||
[metabase-enterprise.stale.init]
|
||||
[metabase-enterprise.transforms-python.init]
|
||||
[metabase-enterprise.transforms.init]))
|
||||
[metabase-enterprise.transforms.init]
|
||||
[metabase-enterprise.workspaces.init]))
|
||||
|
||||
@@ -114,4 +114,5 @@
|
||||
"User"
|
||||
"UserKeyValue"
|
||||
"UserParameterValue"
|
||||
"ViewLog"])
|
||||
"ViewLog"
|
||||
"Workspace"])
|
||||
|
||||
@@ -222,12 +222,13 @@
|
||||
[_model-name opts]
|
||||
{:copy [:name :description :entity_id]
|
||||
:skip [:dependency_analysis_version :source_type]
|
||||
:transform {:created_at (serdes/date)
|
||||
:creator_id (serdes/fk :model/User)
|
||||
:source {:export #(update % :query serdes/export-mbql)
|
||||
:import #(update % :query serdes/import-mbql)}
|
||||
:target {:export serdes/export-mbql :import serdes/import-mbql}
|
||||
:tags (serdes/nested :model/TransformTransformTag :transform_id opts)}})
|
||||
:transform {:created_at (serdes/date)
|
||||
:creator_id (serdes/fk :model/User)
|
||||
:workspace_id (serdes/fk :model/Workspace)
|
||||
:source {:export #(update % :query serdes/export-mbql)
|
||||
:import #(update % :query serdes/import-mbql)}
|
||||
:target {:export serdes/export-mbql :import serdes/import-mbql}
|
||||
:tags (serdes/nested :model/TransformTransformTag :transform_id opts)}})
|
||||
|
||||
(defmethod serdes/dependencies "Transform"
|
||||
[{:keys [source tags]}]
|
||||
|
||||
106
enterprise/backend/src/metabase_enterprise/workspaces/api.clj
Normal file
106
enterprise/backend/src/metabase_enterprise/workspaces/api.clj
Normal file
@@ -0,0 +1,106 @@
|
||||
(ns metabase-enterprise.workspaces.api
|
||||
"`/api/ee/workspace/` routes"
|
||||
(:require
|
||||
[honey.sql.helpers :as sql.helpers]
|
||||
[metabase-enterprise.workspaces.common :as ws.common]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; schemas
|
||||
|
||||
(def ^:private type->t2-model {:transform :model/Transform})
|
||||
|
||||
;; The key we use to group entities of the same type in requests and responses.
|
||||
(def ^:private type->grouping {:transform :transforms})
|
||||
|
||||
(mr/def ::entity-type [:enum :transform])
|
||||
|
||||
(mr/def ::entity-grouping [:enum :transforms])
|
||||
|
||||
;; Entities that live within the Workspace
|
||||
(mr/def ::downstream-entity
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:type ::entity-type]
|
||||
[:name :string]])
|
||||
|
||||
(def CreateWorkspace
|
||||
"Schema for creating a new workspace"
|
||||
[:map
|
||||
[:name [:string {:min 1}]]
|
||||
[:database_id {:optional true} :int]
|
||||
[:upstream [:map-of ::entity-grouping [:sequential {:min 1} ms/PositiveInt]]]])
|
||||
|
||||
(def Workspace
|
||||
"Schema for workspace response"
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:name :string]
|
||||
[:collection_id :int]
|
||||
[:database_id :int]
|
||||
[:created_at :any]
|
||||
[:updated_at :any]])
|
||||
|
||||
(defn- ws->response [ws]
|
||||
(select-keys ws [:id :name :collection_id :database_id :created_at :updated_at :archived_at]))
|
||||
|
||||
;;; routes
|
||||
|
||||
(api.macros/defendpoint :get "/" :- [:map [:items [:sequential Workspace]]]
|
||||
"Get a list of all workspaces"
|
||||
[_route-params
|
||||
_query-params]
|
||||
{:items (->> (t2/select :model/Workspace
|
||||
(cond-> {:order-by [[:created_at :desc]]}
|
||||
(request/limit) (sql.helpers/limit (request/limit))
|
||||
(request/offset) (sql.helpers/offset (request/offset))))
|
||||
(mapv ws->response))
|
||||
:limit (request/limit)
|
||||
:offset (request/offset)})
|
||||
|
||||
(api.macros/defendpoint :get "/:id" :- Workspace
|
||||
"Get a single workspace by ID"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params]
|
||||
(-> (t2/select-one :model/Workspace :id id)
|
||||
api/check-404
|
||||
ws->response))
|
||||
|
||||
(api.macros/defendpoint :get "/:id/contents"
|
||||
:- [:map [:contents [:map-of ::entity-grouping [:sequential ::downstream-entity]]]]
|
||||
"Get the contents being edited within a Workspace."
|
||||
[{workspace-id :id} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params]
|
||||
(let [fetch-entities (fn [[entity-type entity-grouping]]
|
||||
(let [t2-model (type->t2-model entity-type)]
|
||||
(when-let [entities (t2/select [t2-model :id :name] :workspace_id workspace-id)]
|
||||
[entity-grouping (for [e entities] (assoc e :type entity-type))])))]
|
||||
{:contents (into {} (keep fetch-entities) type->grouping)}))
|
||||
|
||||
(api.macros/defendpoint :post "/" :- Workspace
|
||||
"Create a new workspace"
|
||||
[_route-params
|
||||
_query-params
|
||||
body :- CreateWorkspace]
|
||||
(-> (ws.common/create-workspace! api/*current-user-id* body)
|
||||
ws->response))
|
||||
|
||||
(api.macros/defendpoint :post "/:id/archive" :- Workspace
|
||||
"Update a workspace"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
_body-params]
|
||||
(let [ws (api/check-404 (t2/select-one :model/Workspace :id id))]
|
||||
(api/check-400 (nil? (:archived_at ws)) "You cannot archive an archived workspace")
|
||||
(t2/update! :model/Workspace id {:archived_at [:now]})
|
||||
(-> (t2/select-one :model/Workspace :id id)
|
||||
ws->response)))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/workspace/` routes."
|
||||
(api.macros/ns-handler *ns* api/+check-superuser +auth))
|
||||
127
enterprise/backend/src/metabase_enterprise/workspaces/common.clj
Normal file
127
enterprise/backend/src/metabase_enterprise/workspaces/common.clj
Normal file
@@ -0,0 +1,127 @@
|
||||
(ns metabase-enterprise.workspaces.common
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.isolation :as ws.isolation]
|
||||
[metabase-enterprise.workspaces.mirroring :as ws.mirroring]
|
||||
[metabase.api-keys.core :as api-key]
|
||||
[metabase.api.common :as api]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- temp-assert-single-transform!
|
||||
"A temporary restriction, until we have graph analysis merged."
|
||||
[upstream-entities]
|
||||
(assert (and (= 1 (count (keys upstream-entities)))
|
||||
(= 1 (count (:transforms upstream-entities))))
|
||||
"The only contents of the workspace must be a single transform."))
|
||||
|
||||
(defn- build-graph
|
||||
"Placeholder for our actual dag module, handling at most 1 transform, and not returning all the analysis."
|
||||
[upstream]
|
||||
(if (not-any? seq (vals upstream))
|
||||
{:db_id nil
|
||||
:transforms []
|
||||
:inputs []}
|
||||
(do
|
||||
(temp-assert-single-transform! upstream)
|
||||
(let [transform-id (first (get upstream :transforms))
|
||||
;; NOte: we only support MBQL transforms for now
|
||||
transform (t2/select-one [:model/Transform :id :name :source :target] :id transform-id)
|
||||
_ (assert (not-empty transform))
|
||||
;; Note: if there are additional tables being joined, we are not picking them up @_@
|
||||
source-id (get-in transform [:source :query :stages 0 :source-table])
|
||||
_ (assert (pos-int? source-id))
|
||||
source-table (t2/select-one [:model/Table :id :schema :name] :id source-id)
|
||||
_ (assert (not-empty source-table))
|
||||
output-table (let [{:keys [schema name]} (:target transform)]
|
||||
;; TODO (Chris 2025-11-19) Relax downstream assumption that upstream-tx output table exists
|
||||
{:id (t2/select-one-pk :model/Table :schema schema :name name)
|
||||
:schema schema
|
||||
:name name})
|
||||
db-ids (vec (sort (distinct (filter pos-int? [(-> transform :source :query :database) (:db_id source-table)]))))
|
||||
_ (assert (= 1 (count db-ids)) "All inputs and outputs must belong to the same database.")]
|
||||
;; Graph is based on this.
|
||||
;; https://metaboat.slack.com/archives/C099RKNLP6U/p1763470366995789?thread_ts=1763462819.139739&cid=C099RKNLP6U
|
||||
;; "tables" was renamed to "outputs", to perhaps make it clearer that they are not the input tables.
|
||||
{:db_id (first db-ids)
|
||||
:transforms [(select-keys transform [:id :name])]
|
||||
:inputs [source-table]
|
||||
:outputs [output-table]}))))
|
||||
|
||||
;; TODO: Generate new metabase user for the workspace
|
||||
;; TODO: Should we move this to model as per the diagram?
|
||||
(defn- create-workspace-container!
|
||||
"Create the workspace and its related collection, user, and api key."
|
||||
[creator-id database-id workspace-name]
|
||||
;; TODO (Chris 2025-11-19) Unsure API key name is unique, and remove this (insecure) workaround.
|
||||
(let [api-key (let [key-name (format "API key for Workspace %s" workspace-name)]
|
||||
(or (t2/select-one :model/ApiKey :name key-name)
|
||||
(api-key/create-api-key-with-new-user! {:key-name key-name})))
|
||||
;; TODO (Chris 2025-11-19) Associate the api-key user with the workspace as well.
|
||||
ws (t2/insert-returning-instance! :model/Workspace
|
||||
{:name workspace-name
|
||||
:creator_id creator-id
|
||||
:database_id database-id
|
||||
:api_key_id (:id api-key)
|
||||
:execution_user (:user_id api-key)})
|
||||
coll (t2/insert-returning-instance! :model/Collection
|
||||
{:name (format "Collection for Workspace %s" workspace-name)
|
||||
:namespace "workspace"
|
||||
:workspace_id (:id ws)})
|
||||
ws (assoc ws :collection_id (:id coll))]
|
||||
;; Set the backlink from the workspace to the collection inside it.
|
||||
(t2/update! :model/Workspace (:id ws) {:collection_id (:id coll)})
|
||||
;; TODO (Sanya 2025-11-18) - not sure how to transfer this api key to agent
|
||||
#_(log/infof "Generated API key for workspace: %s" (u.secret/expose (:unmasked_key api-key)))
|
||||
ws))
|
||||
|
||||
;; TODO internal: test!
|
||||
(defn create-workspace!
|
||||
"Create workspace"
|
||||
[creator-id {ws-name :name
|
||||
maybe-db-id :database_id
|
||||
upstream :upstream}]
|
||||
;; TODO put this in the malli schema for a request
|
||||
(assert (or maybe-db-id (some seq (vals upstream))) "Must provide a database_id unless initial entities are given.")
|
||||
(temp-assert-single-transform! upstream)
|
||||
(let [graph (build-graph upstream)
|
||||
inferred-db-id (:db_id graph)
|
||||
_ (when (and maybe-db-id inferred-db-id)
|
||||
(assert (= maybe-db-id inferred-db-id)
|
||||
"The database_id provided must match that of the upstream entities."))
|
||||
db-id (or inferred-db-id maybe-db-id)
|
||||
_ (assert db-id "Was not given and could not infer a database_id for the workspace.")
|
||||
database (api/check-500 (t2/select-one :model/Database :id db-id))
|
||||
workspace (create-workspace-container! creator-id db-id ws-name)
|
||||
;; Creates the new schema database schema
|
||||
_ (ws.isolation/ensure-database-isolation! workspace database)
|
||||
graph (ws.mirroring/mirror-entities! workspace database graph)
|
||||
_ (t2/update! :model/Workspace {:id (:id workspace)} {:graph graph})
|
||||
workspace (assoc workspace :graph graph)]
|
||||
workspace))
|
||||
|
||||
#_:clj-kondo/ignore
|
||||
(comment
|
||||
(defn- clean-up-ws!* [& [ws-id]]
|
||||
;; Pass nil to clean up all workspaces
|
||||
(let [ws-clause (or ws-id [:not= nil])]
|
||||
(t2/delete! :model/Collection :workspace_id ws-clause)
|
||||
(t2/delete! :model/Transform :workspace_id ws-clause)
|
||||
(doseq [[db_id schema] (t2/select-fn-set (juxt :db_id :schema) :model/Table :workspace_id [:not= nil])]
|
||||
(t2/delete! :model/Table :schema schema)
|
||||
(let [db (t2/select-one :model/Database db_id)
|
||||
driver (metabase.driver.util/database->driver db)
|
||||
jdbc-spec ((requiring-resolve 'metabase.driver.sql-jdbc.connection/connection-details->spec)
|
||||
driver
|
||||
(:details db))]
|
||||
(clojure.java.jdbc/execute! jdbc-spec [(str "DROP SCHEMA \"" schema "\" CASCADE")])))
|
||||
(t2/delete! :model/Workspace :id ws-clause)))
|
||||
|
||||
(def upstream-tx-id (t2/select-one-pk :model/Transform :workspace_id nil {:order-by [:id]}))
|
||||
(def admin-id (t2/select-one-pk :model/User :is_superuser true {:order-by [:id]}))
|
||||
|
||||
;; Ensure output table exists
|
||||
(#'metabase-enterprise.transforms.execute/run-mbql-transform! (t2/select-one :model/Transform upstream-tx-id))
|
||||
|
||||
(binding [api/*current-user-id* admin-id]
|
||||
(create-workspace! admin-id {:name "Workplace Workspace", :upstream {:transforms [upstream-tx-id]}}))
|
||||
|
||||
(clean-up-ws!*))
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.workspaces.init
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.models.workspace]))
|
||||
@@ -0,0 +1,101 @@
|
||||
(ns metabase-enterprise.workspaces.isolation
|
||||
(:require
|
||||
;; TODO: should we go with next.jdbc instead?
|
||||
[clojure.java.jdbc :as jdbc]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.workspaces.sync :as ws.sync]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util :as u]))
|
||||
|
||||
;;;; Naming
|
||||
|
||||
;; re-using https://github.com/metabase/metabase/pull/61887/commits/c92e4a9cc451c61a13fef19ed9d6107873b17f07
|
||||
;; (original ws isolation code)
|
||||
(defn- instance-uuid-slug
|
||||
"Create a slug from the site UUID, taking the first character of each section."
|
||||
[site-uuid-string]
|
||||
(apply str (map first (str/split site-uuid-string #"-"))))
|
||||
|
||||
(defn- isolation-schema-name
|
||||
"Generate schema/database name for workspace isolation following mb__isolation_<slug>_<workspace-id> pattern."
|
||||
[workspace-id]
|
||||
(let [instance-slug (instance-uuid-slug (str (system/site-uuid)))
|
||||
clean-workspace-id (str/replace (str workspace-id) #"[^a-zA-Z0-9]" "_")]
|
||||
(format "mb__isolation_%s_%s" instance-slug clean-workspace-id)))
|
||||
|
||||
(defn- isolated-table-name
|
||||
"Generate name for a table mirroring transform target table in the isolated database namespace."
|
||||
[{:keys [schema name] :as _source-table}]
|
||||
;; the schema that original transform target lives in
|
||||
(format "%s__%s" schema name))
|
||||
|
||||
;;;; Dispatch for database/driver/... multimethods
|
||||
|
||||
;; I'm making this to have a var so var can be passed as dispatch to `duplicate-transform-table!` during
|
||||
;; development as this is subject to change.
|
||||
(defn dispatch-on-engine
|
||||
"Take engine from database `db` and dispatch on that."
|
||||
[database & _args]
|
||||
(driver.u/database->driver database))
|
||||
|
||||
;;;; Isolation init
|
||||
|
||||
(defmulti init-workspace-database-isolation!
|
||||
"For now create only the new schema"
|
||||
{:added "0.59.0" :arglists '([database workspace])}
|
||||
#'dispatch-on-engine
|
||||
:hierarchy #'driver/hierarchy)
|
||||
|
||||
(defmethod init-workspace-database-isolation! :postgres [database workspace]
|
||||
(let [driver (driver.u/database->driver database)
|
||||
schema-name (isolation-schema-name (:id workspace))
|
||||
jdbc-spec (sql-jdbc.conn/connection-details->spec driver (:details database))]
|
||||
(jdbc/execute! jdbc-spec [(format "CREATE SCHEMA %s" schema-name)])))
|
||||
|
||||
;;;; Transform table duplication
|
||||
|
||||
(defmulti duplicate-output-table!
|
||||
"WIP"
|
||||
{:added "0.59.0" :arglists '([database transform])}
|
||||
#'dispatch-on-engine
|
||||
:hierarchy #'driver/hierarchy)
|
||||
|
||||
(defmethod duplicate-output-table! :postgres [database workspace output]
|
||||
(let [details (:details database)
|
||||
driver* (driver.u/database->driver database)
|
||||
jdbc-spec (sql-jdbc.conn/connection-details->spec driver* details)
|
||||
source-schema (:schema output)
|
||||
source-table (:name output)
|
||||
isolated-schema (isolation-schema-name (:id workspace))
|
||||
isolated-table (isolated-table-name output)]
|
||||
(assert (every? some? [source-schema source-table isolated-schema isolated-table]) "Figured out table")
|
||||
;; TODO: execute the following only if the transform was previously executed and its table exists.
|
||||
(jdbc/execute! jdbc-spec [(format (str "CREATE TABLE \"%s\".\"%s\""
|
||||
" AS SELECT * FROM \"%s\".\"%s\""
|
||||
"WITH NO DATA")
|
||||
isolated-schema
|
||||
isolated-table
|
||||
source-schema
|
||||
source-table)])
|
||||
(let [table-metadata (ws.sync/sync-transform-mirror! database isolated-schema isolated-table)]
|
||||
(select-keys table-metadata [:id :schema :name]))))
|
||||
|
||||
;;;; To be public when things are settled
|
||||
|
||||
(defn create-isolated-output-tables!
|
||||
"Create new _isolated tables_ to correspond to the outputs of the upstream graph.
|
||||
Decorate the graph outputs with the mapping to the new tables."
|
||||
[workspace database graph]
|
||||
(assoc graph :outputs (vec
|
||||
(for [upstream-output (:outputs graph)]
|
||||
(let [isolated-table (duplicate-output-table! database workspace upstream-output)]
|
||||
(assoc upstream-output :mapping isolated-table))))))
|
||||
|
||||
(defn ensure-database-isolation!
|
||||
"WIP"
|
||||
[workspace database]
|
||||
;; TODO: Make this check the ws existence aka fail closed ~atm
|
||||
(init-workspace-database-isolation! database workspace))
|
||||
@@ -0,0 +1,127 @@
|
||||
(ns metabase-enterprise.workspaces.mirroring
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.isolation :as ws.isolation]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;; Following for reference ~atm
|
||||
(def t2-transform-not-mirrored-keys
|
||||
"WIP"
|
||||
#{:id :creator_id :created_at :updated_at :entity_id #_!!! :target})
|
||||
|
||||
;; t2 for snake
|
||||
(def t2-transform-mirrored-keys
|
||||
"WIP"
|
||||
#{:description :name :source :source_type})
|
||||
|
||||
(defn- mirror-transform!
|
||||
[workspace database-id graph]
|
||||
;; With addition of e.g. Clickhouse I expect some juggling with how we provide targets,
|
||||
;; but for now doing plain copy-paste schema name here.
|
||||
;; TODO (lbrdnk 2025-11-18) revisit schema, name vs db specific access pattern.
|
||||
(let [#_#_table-mapping (u/for-map [{:keys [name schema mapping]} (:outputs graph)]
|
||||
[[name schema] ((juxt :name :schema) mapping)])
|
||||
_ (= 1 (count (:outputs graph)))
|
||||
output-table (-> graph :outputs first :mapping)
|
||||
_ (= 1 (count (:transforms graph)))
|
||||
transform (-> graph :transforms first)
|
||||
|
||||
mirror (t2/insert-returning-instance!
|
||||
:model/Transform
|
||||
;; TODO (Chris 2025-11-19) avoid reselecting transform here, by passing along all the keys from the top
|
||||
(merge (-> (t2/select-one (into [:model/Transform] t2-transform-mirrored-keys) (:id transform))
|
||||
;; TODO (Chris 2025-11-19) shouldn't rename like this - will mess up promotion!
|
||||
;; this should be happening elsewhere, not on write, for now ok
|
||||
(update :name str "_DUP")
|
||||
(assoc :workspace_id (:id workspace)))
|
||||
;; TODO (Chris 2025-11-19) remap source table (when necessary)
|
||||
{:target {:type "table"
|
||||
:database database-id
|
||||
:schema (:schema output-table)
|
||||
:name (:name output-table)}}))]
|
||||
(assoc graph :transforms [(assoc transform :mapping (select-keys mirror [:id :name]))])))
|
||||
|
||||
(comment
|
||||
;;
|
||||
(t2/select-one :model/Transform)
|
||||
;;=> (toucan2.instance/instance
|
||||
;; :model/Transform
|
||||
;; {:description "x",
|
||||
;; :dependency_analysis_version 2,
|
||||
;; :name "overwrite_existing_test",
|
||||
;; :source
|
||||
;; {:type :query,
|
||||
;; :query
|
||||
;; {:lib/type :mbql/query,
|
||||
;; :lib/metadata
|
||||
;; (metabase.lib.metadata.invocation-tracker/invocation-tracker-provider
|
||||
;; (metabase.lib.metadata.cached-provider/cached-metadata-provider
|
||||
;; (metabase.lib-be.metadata.jvm/->UncachedApplicationDatabaseMetadataProvider 2))),
|
||||
;; :database 2,
|
||||
;; :stages
|
||||
;; [{:lib/type :mbql.stage/mbql,
|
||||
;; :aggregation
|
||||
;; [[:sum
|
||||
;; #:lib{:uuid "ed6c85c7-9d2f-48e5-899a-ca77a56791f0"}
|
||||
;; [:field
|
||||
;; {:effective-type :type/Float, :lib/uuid "3e975bcc-4444-41d8-94ad-f107ec83419f", :base-type :type/Float}
|
||||
;; 259]]],
|
||||
;; :source-table 32,
|
||||
;; :breakout
|
||||
;; [[:field
|
||||
;; {:effective-type :type/DateTimeWithLocalTZ,
|
||||
;; :base-type :type/DateTimeWithLocalTZ,
|
||||
;; :lib/uuid "32cf8766-5a19-430d-a006-4482cd8b9493",
|
||||
;; :temporal-unit :month}
|
||||
;; 261]]}]}},
|
||||
;; :source_type :mbql,
|
||||
;; :creator_id 1,
|
||||
;; :updated_at #t "2025-11-18T10:07:10.024448Z",
|
||||
;; :id 1,
|
||||
;; :entity_id "otpI4D8HlmpmnIaesJzAL",
|
||||
;; :target {:type "table", :database 2, :name "tabi_tabi", :schema "public"},
|
||||
;; :created_at #t "2025-11-18T10:03:57.366345Z"})
|
||||
)
|
||||
|
||||
(defn mirror-entities!
|
||||
"WIP"
|
||||
[workspace
|
||||
database
|
||||
graph]
|
||||
;; Add a check that isolation exists instead as this is supposed to be called from common or core.
|
||||
#_(ws.isolation/ensure-database-isolation! workspace database)
|
||||
(->> graph
|
||||
(ws.isolation/create-isolated-output-tables! workspace database)
|
||||
(mirror-transform! workspace (:id database))))
|
||||
|
||||
#_:clj-kondo/ignore
|
||||
(comment
|
||||
;;;; manually mirror some transform without dependencies -- adjust to your needs
|
||||
;;
|
||||
;; Set the following vars, then exec try block
|
||||
;; for cleanup use the do block
|
||||
;;
|
||||
(def ws* {:id "ahoj2"})
|
||||
|
||||
(def graph* (#'metabase-enterprise.workspaces.common/build-graph
|
||||
{:transforms (t2/select-fn-vec :id [:model/Transform :id] :workspace_id nil {:order-by [:id], :limit 1})}))
|
||||
|
||||
(def db-id* (t2/select-one-fn :db_id [:model/Table :db_id] (:id (first (:inputs graph*)))))
|
||||
|
||||
(try (mirror-entities! ws* db-id* graph*)
|
||||
(catch Throwable t
|
||||
(def eee t)
|
||||
(throw t)))
|
||||
|
||||
;; synced transform tables
|
||||
#_(t2/select :model/Table :schema [:like "mb__isolation_%_ahoj2"])
|
||||
|
||||
;; drop tested
|
||||
(do
|
||||
(t2/delete! :model/Transform :id [:> 1])
|
||||
(t2/delete! :model/Table :schema "mb__isolation_91499_ahoj2")
|
||||
(let [db (t2/select-one :model/Database :id 2)
|
||||
driver (metabase.driver.util/database->driver db)
|
||||
jdbc-spec ((requiring-resolve 'metabase.driver.sql-jdbc.connection/connection-details->spec)
|
||||
driver
|
||||
(:details db))]
|
||||
(clojure.java.jdbc/execute! jdbc-spec ["DROP SCHEMA \"mb__isolation_91499_ahoj2\" CASCADE"]))))
|
||||
@@ -0,0 +1,14 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace
|
||||
(:require
|
||||
[metabase.models.interface :as mi]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Workspace [_model] :workspace)
|
||||
|
||||
(t2/deftransforms :model/Workspace
|
||||
{:graph mi/transform-json})
|
||||
|
||||
(doto :model/Workspace
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
@@ -0,0 +1,23 @@
|
||||
(ns metabase-enterprise.workspaces.sync
|
||||
(:require
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.sync.core :as sync]
|
||||
[metabase.sync.sync-metadata.tables :as sync-tables]))
|
||||
|
||||
;; this ns will be probably moved
|
||||
|
||||
;; will figure out whether that is a good idea soonish
|
||||
;; this may be tricky!!!
|
||||
(defn sync-transform-mirror!
|
||||
"TODO"
|
||||
[database schema-name table-name]
|
||||
(let [driver (driver.u/database->driver database)
|
||||
table {:name table-name
|
||||
:schema schema-name}]
|
||||
(if (driver/table-exists? driver database table)
|
||||
(let [created (sync-tables/create-or-reactivate-table! database table)]
|
||||
(doto created sync/sync-table!))
|
||||
(throw (ex-info "Can not find transform mirror table"
|
||||
{:schema schema-name
|
||||
:table table-name})))))
|
||||
@@ -105,7 +105,8 @@
|
||||
:model/ConnectionImpersonation
|
||||
:model/CloudMigration
|
||||
:model/Comment
|
||||
:model/CommentReaction})
|
||||
:model/CommentReaction
|
||||
:model/Workspace})
|
||||
|
||||
(deftest ^:parallel comprehensive-entity-id-test
|
||||
(let [entity-id-models (->> (v2.entity-ids/toucan-models)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
(ns metabase-enterprise.workspaces.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest workspace-endpoints-require-superuser-test
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Private Workspace"}]
|
||||
(testing "GET /api/ee/workspace requires superuser"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :get 403 "ee/workspace"))))
|
||||
|
||||
(testing "GET /api/ee/workspace/:id requires superuser"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :get 403 (str "ee/workspace/" (:id workspace))))))
|
||||
|
||||
(testing "POST /api/ee/workspace requires superuser"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :post 403 "ee/workspace"
|
||||
{:name "Unauthorized Workspace"})))))
|
||||
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Put Workspace"}]
|
||||
(testing "PUT /api/ee/workspace/:id requires superuser"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :put 403 (str "ee/workspace/" (:id workspace))
|
||||
{:name "Updated"})))))
|
||||
|
||||
(mt/with-temp [:model/Workspace workspace {:name "Delete Workspace"}]
|
||||
(testing "DELETE /api/ee/workspace/:id requires superuser"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :delete 403 (str "ee/workspace/" (:id workspace))))))))
|
||||
|
||||
(deftest workspace-crud-flow-test
|
||||
(mt/with-premium-features [:workspaces :dependencies]
|
||||
(mt/with-model-cleanup [:model/Collection :model/Workspace :model/Transform]
|
||||
(let [tx-id (t2/select-one-pk :model/Transform {:order-by [[:id :desc]]})
|
||||
upstream {:transforms [tx-id]}
|
||||
workspace-name (str "Workspace " (random-uuid))
|
||||
created (mt/user-http-request :crowberto :post 200 "ee/workspace"
|
||||
{:name workspace-name
|
||||
:upstream upstream})
|
||||
workspace-id (:id created)
|
||||
collection-id (:collection_id created)]
|
||||
(is (pos-int? workspace-id))
|
||||
(is (pos-int? collection-id))
|
||||
(is (= workspace-name (:name created)))
|
||||
(is (t2/exists? :model/Workspace :id workspace-id :collection_id collection-id))
|
||||
(is (t2/exists? :model/Collection :id collection-id :workspace_id workspace-id))
|
||||
|
||||
(testing "workspace appears in list response"
|
||||
(let [{:keys [items]} (mt/user-http-request :crowberto :get 200 "ee/workspace")]
|
||||
(is (some #(= workspace-id (:id %)) items))))
|
||||
|
||||
(testing "workspace can be fetched individually"
|
||||
(let [response (mt/user-http-request :crowberto :get 200 (str "ee/workspace/" workspace-id))]
|
||||
(is (= workspace-id (:id response)))))
|
||||
|
||||
#_(testing "workspace can be archived"
|
||||
(let [updated (mt/user-http-request :crowberto :post 200 (str "ee/workspace/" workspace-id "/archive"))]
|
||||
(is (some? (:archived_at updated)))))))))
|
||||
185
enterprise/backend/test/metabase_enterprise/workspaces/dag.clj
Normal file
185
enterprise/backend/test/metabase_enterprise/workspaces/dag.clj
Normal file
@@ -0,0 +1,185 @@
|
||||
(ns metabase-enterprise.workspaces.dag
|
||||
(:require [clojure.set :as set]
|
||||
[flatland.ordered.map :as ordered-map]
|
||||
[metabase.util :as u]))
|
||||
|
||||
;; TODO handle shadowing / execution of transforms that depend on checkout out models
|
||||
|
||||
(defn- example-type [id]
|
||||
(-> id name first {\t :table
|
||||
\x :transform
|
||||
\m :model}))
|
||||
|
||||
(defn- related [t id]
|
||||
(keyword (str (name t) (subs (name id) 1))))
|
||||
|
||||
(def ^:private t->x (partial related :x))
|
||||
(def ^:private x->t (partial related :t))
|
||||
|
||||
(defn- is? [t id] (= (name t) (str (first (name id)))))
|
||||
|
||||
(defn- lexico [id]
|
||||
(let [n (name id)]
|
||||
[(parse-long (subs n 1))
|
||||
({\x 1 \t 2 \m 3} (first n))]))
|
||||
|
||||
(defn- expand-shorthand
|
||||
"As shorthand we can use transforms in the place of their induced tables.
|
||||
This function expands the graph to insert the induced tables, and replace the corresponding references."
|
||||
[{:keys [dependencies] :as graph}]
|
||||
(assoc graph :dependencies
|
||||
(let [xs (set (filter #(is? :x %) (concat (keys dependencies) (mapcat val dependencies))))]
|
||||
(into (sorted-map-by #(compare (lexico %1) (lexico %2)))
|
||||
(merge (zipmap (map x->t xs) (map vector xs))
|
||||
(into {} (filter (comp #(is? :t %) key)) dependencies)
|
||||
(update-vals
|
||||
(into {} (remove (comp #(is? :t %) key)) dependencies)
|
||||
(fn [deps] (->> deps
|
||||
(map (fn [id]
|
||||
(if (is? :x id)
|
||||
(x->t id)
|
||||
id)))
|
||||
(sort-by lexico)
|
||||
(vec)))))))))
|
||||
|
||||
(def ^:private example-shorthand
|
||||
{:check-outs #{:x3 :m6 :m10 :m13}
|
||||
:dependencies {:x3 [:x1 :t2]
|
||||
:x4 [:x3]
|
||||
:m6 [:x4 :t5]
|
||||
:m10 [:t9]
|
||||
:x11 [:m10]
|
||||
:m12 [:x11]
|
||||
:m13 [:x11 :m12]}})
|
||||
|
||||
(def ^:private example
|
||||
{:check-outs #{:x3 :m6 :m10 :m13}
|
||||
:dependencies {:t1 [:x1]
|
||||
:x3 [:t1 :t2]
|
||||
:t3 [:x3]
|
||||
:x4 [:t3]
|
||||
:t4 [:x4]
|
||||
:m6 [:t4 :t5]
|
||||
:m10 [:t9]
|
||||
:x11 [:m10]
|
||||
:t11 [:x11]
|
||||
:m12 [:t11]
|
||||
:m13 [:t11 :m12]}})
|
||||
|
||||
(comment (= example (expand-shorthand example-shorthand)))
|
||||
|
||||
(defn- children [dependencies]
|
||||
(reduce
|
||||
(fn [acc [child parents]]
|
||||
(reduce (fn [acc p] (update acc p u/conjv child)) acc parents))
|
||||
(sorted-map-by #(compare (lexico %1) (lexico %2)))
|
||||
dependencies))
|
||||
|
||||
(comment
|
||||
(children (:dependencies example)))
|
||||
|
||||
(defn- ->ts [ids]
|
||||
(into #{}
|
||||
(keep (fn [id]
|
||||
(cond (is? :t id) id
|
||||
(is? :x id) (x->t id))))
|
||||
ids))
|
||||
|
||||
;; naive dfs toposort
|
||||
|
||||
(defn- toposort-visit [node child->parents visited result]
|
||||
(cond
|
||||
(visited node) [visited result]
|
||||
:else (let [parents (child->parents node [])
|
||||
[visited' result'] (reduce (fn [[v r] p]
|
||||
(toposort-visit p child->parents v r))
|
||||
[(conj visited node) result]
|
||||
parents)]
|
||||
[visited' (conj result' node)])))
|
||||
|
||||
(defn- toposort-dfs [child->parents]
|
||||
;; we assume the graph contains the degenerate nodes as keys with empty parent lists
|
||||
(let [all-nodes (set (keys child->parents))]
|
||||
(loop [visited #{}
|
||||
result []
|
||||
remaining all-nodes]
|
||||
(if (empty? remaining)
|
||||
result
|
||||
(let [node (first remaining)
|
||||
[visited' result'] (toposort-visit node child->parents visited result)]
|
||||
(recur visited' result' (disj remaining node)))))))
|
||||
|
||||
(defn- toposort-map [child->parents]
|
||||
(let [child->parents (if (map? child->parents) child->parents (into {} child->parents))]
|
||||
(into (ordered-map/ordered-map)
|
||||
(keep (fn [n] (when-let [deps (get child->parents n)] [n deps])))
|
||||
(toposort-dfs child->parents))))
|
||||
|
||||
;; naive version - calculate above and below, and take intersection
|
||||
|
||||
;; safety
|
||||
(def ^:private max-iterations 100)
|
||||
|
||||
(defn- path-induced-subgraph [{:keys [check-outs dependencies] :as _graph}]
|
||||
(let [->parents dependencies
|
||||
->children (children dependencies)]
|
||||
(loop [iteration 1
|
||||
below check-outs
|
||||
above check-outs
|
||||
seen-down? check-outs
|
||||
seen-up? check-outs
|
||||
to-descend check-outs
|
||||
to-ascend check-outs]
|
||||
(if (and (empty? to-descend) (empty? to-ascend))
|
||||
;; Inputs are all the external tables that we depend on.
|
||||
;; NOTE: if there is a chain of models, we assume that ->parent here maps all the way to the source table
|
||||
(let [nodes (set/intersection above below)
|
||||
entities (remove #(is? :t %) nodes)
|
||||
tables (sort-by lexico (filter #(is? :t %) nodes))]
|
||||
{:check-outs (sort-by lexico check-outs)
|
||||
:inputs (sort-by lexico (remove nodes (->ts (mapcat ->parents check-outs))))
|
||||
:tables tables
|
||||
:transforms (sort-by lexico (filter #(is? :x %) nodes))
|
||||
:entities (sort-by lexico entities)
|
||||
:dependencies (apply dissoc
|
||||
(toposort-map
|
||||
(for [n nodes]
|
||||
[n (vec (filter nodes (get dependencies n)))]))
|
||||
tables)})
|
||||
(if (> iteration max-iterations)
|
||||
{:error "max iterations exceeded"}
|
||||
(let [next-down (into #{} (comp (mapcat ->children) (remove seen-down?)) to-descend)
|
||||
next-up (into #{} (comp (mapcat ->parents) (remove seen-up?)) to-ascend)]
|
||||
(recur (inc iteration)
|
||||
(into below next-down)
|
||||
(into above next-up)
|
||||
(into seen-down? next-down)
|
||||
(into seen-up? next-up)
|
||||
next-down
|
||||
next-up)))))))
|
||||
|
||||
(comment
|
||||
#p (path-induced-subgraph example))
|
||||
|
||||
;; (path-induced-subgraph example) =>
|
||||
{:check-outs '(:x3 :m6 :m10 :m12),
|
||||
;; these are all the external tables that we will need ro access to
|
||||
:inputs '(:t1 :t2 :t5 :t9),
|
||||
;; these are all the tables we need to "shadow" in the workspace schema (previously called outputs)
|
||||
:tables '(:t3 :t4 :t11),
|
||||
;; these are all the transforms we need to execute when running the workspace (replaces "downstream")
|
||||
:transforms '(:x3 :x4 :x11)
|
||||
;; these are the appdb entities that need to be shadowed with "_workspace" versions.
|
||||
;; this excludes the tables, which I'm assuming will be handled separately via sync.
|
||||
;; we might change our mind here and just manually create the metadata along with
|
||||
;; the other entities, if that's easier.
|
||||
:entities '(:x3 :x4 :m6 :m10 :x11 :t11 :m12)
|
||||
;; a mapping from entities that we copy to their dependencies within the subgraph
|
||||
;; they may have other dependencies outside the subgraph, but we ignore these
|
||||
;; as they don't need to be remapped
|
||||
:dependencies {:x3 []
|
||||
:x4 [:t3]
|
||||
:m6 [:t4]
|
||||
:m10 []
|
||||
:x11 [:m10]
|
||||
:m12 [:t11]}}
|
||||
@@ -18,3 +18,4 @@ export * from "./transform";
|
||||
export * from "./transform-job";
|
||||
export * from "./transform-tag";
|
||||
export * from "./upload-management";
|
||||
export * from "./workspace";
|
||||
|
||||
@@ -48,6 +48,7 @@ export const ENTERPRISE_TAG_TYPES = [
|
||||
"remote-sync-branches",
|
||||
"remote-sync-current-task",
|
||||
"python-transform-library",
|
||||
"workspace",
|
||||
] as const;
|
||||
|
||||
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];
|
||||
|
||||
49
enterprise/frontend/src/metabase-enterprise/api/workspace.ts
Normal file
49
enterprise/frontend/src/metabase-enterprise/api/workspace.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
CreateWorkspaceRequest,
|
||||
Workspace,
|
||||
WorkspaceContents,
|
||||
WorkspaceId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
import { idTag, invalidateTags, listTag, tag } from "./tags";
|
||||
|
||||
export const workspaceApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getWorkspace: builder.query<Workspace, WorkspaceId>({
|
||||
query: (id) => ({
|
||||
method: "GET",
|
||||
url: `/api/ee/workspace/${id}`,
|
||||
}),
|
||||
providesTags: (_, error, id) =>
|
||||
invalidateTags(error, [idTag("workspace", id)]),
|
||||
}),
|
||||
createWorkspace: builder.mutation<Workspace, CreateWorkspaceRequest>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/workspace",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, error) =>
|
||||
invalidateTags(error, [
|
||||
listTag("workspace"),
|
||||
listTag("transform"),
|
||||
tag("transform"),
|
||||
]),
|
||||
}),
|
||||
getWorkspaceContents: builder.query<WorkspaceContents, WorkspaceId>({
|
||||
query: (id) => ({
|
||||
method: "GET",
|
||||
url: `/api/ee/workspace/${id}/contents`,
|
||||
}),
|
||||
providesTags: (_, error, id) =>
|
||||
invalidateTags(error, [idTag("workspace", id)]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetWorkspaceQuery,
|
||||
useCreateWorkspaceMutation,
|
||||
useGetWorkspaceContentsQuery,
|
||||
} = workspaceApi;
|
||||
@@ -18,6 +18,7 @@ import { ManageSection } from "./ManageSection";
|
||||
import { NameSection } from "./NameSection";
|
||||
import { RunSection } from "./RunSection";
|
||||
import { TargetSection } from "./TargetSection";
|
||||
import { WorkspaceSection } from "./WorkspaceSection";
|
||||
import {
|
||||
isTransformCanceling,
|
||||
isTransformRunning,
|
||||
@@ -69,6 +70,7 @@ export function TransformPage({ params }: TransformPageProps) {
|
||||
<Stack gap="3.5rem" data-testid="transform-page">
|
||||
<Stack gap="lg">
|
||||
<HeaderSection transform={transform} />
|
||||
<WorkspaceSection transform={transform} />
|
||||
<NameSection transform={transform} />
|
||||
</Stack>
|
||||
<RunSection transform={transform} />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { useMetadataToasts } from "metabase/metadata/hooks";
|
||||
import { Button, Card, Group, Icon, Text } from "metabase/ui";
|
||||
import {
|
||||
useCreateWorkspaceMutation,
|
||||
useGetWorkspaceContentsQuery,
|
||||
useGetWorkspaceQuery,
|
||||
} from "metabase-enterprise/api";
|
||||
import type { Transform } from "metabase-types/api";
|
||||
|
||||
import { TitleSection } from "../../../components/TitleSection";
|
||||
|
||||
type WorkspaceSectionProps = {
|
||||
transform: Transform;
|
||||
};
|
||||
|
||||
export function WorkspaceSection({ transform }: WorkspaceSectionProps) {
|
||||
const dispatch = useDispatch();
|
||||
const hasWorkspace = transform.workspace_id != null;
|
||||
const [createWorkspace, { isLoading }] = useCreateWorkspaceMutation();
|
||||
const { sendErrorToast } = useMetadataToasts();
|
||||
const [createdWorkspaceId, setCreatedWorkspaceId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: workspace } = useGetWorkspaceQuery(
|
||||
transform.workspace_id ?? 0,
|
||||
{
|
||||
skip: transform.workspace_id == null,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: workspaceContents } = useGetWorkspaceContentsQuery(
|
||||
createdWorkspaceId ?? 0,
|
||||
{
|
||||
skip: createdWorkspaceId == null,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
workspaceContents &&
|
||||
workspaceContents.contents.transforms.length === 1
|
||||
) {
|
||||
const newTransformId = workspaceContents.contents.transforms[0].id;
|
||||
dispatch(push(Urls.transform(newTransformId)));
|
||||
}
|
||||
}, [workspaceContents, dispatch]);
|
||||
|
||||
const handleCheckoutClick = async () => {
|
||||
try {
|
||||
const result = await createWorkspace({
|
||||
name: `${transform.name} workspace`,
|
||||
upstream: {
|
||||
transforms: [transform.id],
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
setCreatedWorkspaceId(result.id);
|
||||
} catch (error) {
|
||||
sendErrorToast(t`Failed to create workspace`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TitleSection label={t`Workspace`}>
|
||||
<Card p="md" shadow="none" withBorder>
|
||||
{hasWorkspace ? (
|
||||
<Group>
|
||||
<Icon name="folder" aria-hidden />
|
||||
<Text>
|
||||
{workspace
|
||||
? t`This is part of ${workspace.name} (${transform.workspace_id})`
|
||||
: t`This is part of Workspace ${transform.workspace_id}`}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<Icon name="folder" aria-hidden />
|
||||
<Text c="text-secondary">
|
||||
{t`This transform is not part of any workspace`}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<Icon name="add" aria-hidden />}
|
||||
onClick={handleCheckoutClick}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t`Check this out in a new workspace`}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
</TitleSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkspaceSection } from "./WorkspaceSection";
|
||||
@@ -59,6 +59,7 @@ export * from "./util";
|
||||
export * from "./visualization";
|
||||
export * from "./visualization-settings";
|
||||
export * from "./visualizer";
|
||||
export * from "./workspace";
|
||||
|
||||
// ISO8601 timestamp
|
||||
export type ISO8601Time = string;
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createMockTransform(opts?: Partial<Transform>): Transform {
|
||||
target: createMockTransformTarget(),
|
||||
created_at: "2000-01-01T00:00:00Z",
|
||||
updated_at: "2000-01-01T00:00:00Z",
|
||||
workspace_id: null,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type Transform = {
|
||||
target: TransformTarget;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
workspace_id: number | null;
|
||||
|
||||
// hydrated fields
|
||||
tag_ids?: TransformTagId[];
|
||||
|
||||
31
frontend/src/metabase-types/api/workspace.ts
Normal file
31
frontend/src/metabase-types/api/workspace.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { TransformId } from "./transform";
|
||||
|
||||
export type WorkspaceId = number;
|
||||
|
||||
export type Workspace = {
|
||||
id: WorkspaceId;
|
||||
name: string;
|
||||
collection_id: number;
|
||||
database_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CreateWorkspaceRequest = {
|
||||
name: string;
|
||||
database_id?: number;
|
||||
upstream: {
|
||||
transforms?: TransformId[];
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkspaceContentItem = {
|
||||
id: TransformId;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type WorkspaceContents = {
|
||||
contents: {
|
||||
transforms: WorkspaceContentItem[];
|
||||
};
|
||||
};
|
||||
@@ -501,6 +501,306 @@ databaseChangeLog:
|
||||
baseTableName: core_session
|
||||
constraintName: fk_session_auth_identity
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:00
|
||||
author: piranha
|
||||
comment: Create workspace table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: workspace
|
||||
remarks: Internal Metabase container
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: int
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
remarks: Integer PK
|
||||
- column:
|
||||
name: name
|
||||
type: varchar(254)
|
||||
remarks: Workspace name
|
||||
- column:
|
||||
name: collection_id
|
||||
type: int
|
||||
constraints:
|
||||
nullable: true
|
||||
remarks: FK to workspace's collection
|
||||
- column:
|
||||
name: creator_id
|
||||
type: int
|
||||
remarks: FK to owner
|
||||
- column:
|
||||
name: api_key_id
|
||||
type: int
|
||||
remarks: FK to api key used by agent
|
||||
- column:
|
||||
name: execution_user
|
||||
type: int
|
||||
remarks: FK to api key's user
|
||||
- column:
|
||||
name: database_id
|
||||
type: int
|
||||
remarks: FK to db used for this workspace
|
||||
- column:
|
||||
name: schema
|
||||
type: varchar(254)
|
||||
remarks: Isolated schema where work happens
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: graph
|
||||
type: ${text.type}
|
||||
remarks: Graph configuration serialized as JSON
|
||||
- column:
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Timestamp when the workspace was created
|
||||
defaultValueComputed: current_timestamp
|
||||
- column:
|
||||
name: updated_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Timestamp when the workspace was last updated
|
||||
defaultValueComputed: current_timestamp
|
||||
- column:
|
||||
name: archived_at
|
||||
type: ${timestamp_type}
|
||||
remarks: Timestamp when (and if) the workspace was archived
|
||||
constraints:
|
||||
nullable: true
|
||||
rollback:
|
||||
- dropTable:
|
||||
tableName: workspace
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:01
|
||||
author: piranha
|
||||
comment: Add index on workspace.collection_id for quick lookups by collection
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: workspace
|
||||
indexName: idx_workspace_collection_id
|
||||
columns:
|
||||
- column:
|
||||
name: collection_id
|
||||
rollback: # will be removed along the table
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:02
|
||||
author: piranha
|
||||
comment: Add index on workspace.creator_id for quick lookups
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: workspace
|
||||
indexName: idx_workspace_creator_id
|
||||
columns:
|
||||
- column:
|
||||
name: creator_id
|
||||
rollback: # will be removed along the table
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:03
|
||||
author: piranha
|
||||
comment: Add index on workspace.api_key_id for quick lookups
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: workspace
|
||||
indexName: idx_workspace_api_key_id
|
||||
columns:
|
||||
- column:
|
||||
name: api_key_id
|
||||
rollback: # will be removed along the table
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:04
|
||||
author: piranha
|
||||
comment: Add index on workspace.execution_user for quick lookups
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: workspace
|
||||
indexName: idx_workspace_execution_user_id
|
||||
columns:
|
||||
- column:
|
||||
name: execution_user
|
||||
rollback: # will be removed along the table
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:07
|
||||
author: piranha
|
||||
comment: Add workspace_id column to metabase_table table
|
||||
changes:
|
||||
- addColumn:
|
||||
tableName: metabase_table
|
||||
columns:
|
||||
- column:
|
||||
name: workspace_id
|
||||
type: int
|
||||
remarks: Workspace that owns schema with this table
|
||||
constraints:
|
||||
nullable: true
|
||||
rollback:
|
||||
- dropColumn:
|
||||
tableName: metabase_table
|
||||
columnName: workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:08
|
||||
author: piranha
|
||||
comment: Add foreign key constraint from metabase_table.workspace_id to workspace
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: metabase_table
|
||||
baseColumnNames: workspace_id
|
||||
constraintName: fk_metabase_table_workspace_id
|
||||
referencedTableName: workspace
|
||||
referencedColumnNames: id
|
||||
rollback:
|
||||
- dropForeignKeyConstraint:
|
||||
baseTableName: metabase_table
|
||||
constraintName: fk_metabase_table_workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:09
|
||||
author: piranha
|
||||
comment: Add workspace_id column to metabase_field table
|
||||
changes:
|
||||
- addColumn:
|
||||
tableName: metabase_field
|
||||
columns:
|
||||
- column:
|
||||
name: workspace_id
|
||||
type: int
|
||||
remarks: Workspace that owns schema with this field
|
||||
constraints:
|
||||
nullable: true
|
||||
rollback:
|
||||
- dropColumn:
|
||||
tableName: metabase_field
|
||||
columnName: workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:10
|
||||
author: piranha
|
||||
comment: Add foreign key constraint from metabase_field.workspace_id to workspace
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: metabase_field
|
||||
baseColumnNames: workspace_id
|
||||
constraintName: fk_metabase_field_workspace_id
|
||||
referencedTableName: workspace
|
||||
referencedColumnNames: id
|
||||
rollback:
|
||||
- dropForeignKeyConstraint:
|
||||
baseTableName: metabase_field
|
||||
constraintName: fk_metabase_field_workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:11
|
||||
author: piranha
|
||||
comment: Add workspace_id column to collection table
|
||||
changes:
|
||||
- addColumn:
|
||||
tableName: collection
|
||||
columns:
|
||||
- column:
|
||||
name: workspace_id
|
||||
type: int
|
||||
remarks: Workspace containing this collection
|
||||
constraints:
|
||||
nullable: true
|
||||
rollback:
|
||||
- dropColumn:
|
||||
tableName: collection
|
||||
columnName: workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:12
|
||||
author: piranha
|
||||
comment: Add foreign key constraint from collection.workspace_id to workspace
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: collection
|
||||
baseColumnNames: workspace_id
|
||||
constraintName: fk_collection_workspace_id
|
||||
referencedTableName: workspace
|
||||
referencedColumnNames: id
|
||||
rollback:
|
||||
- dropForeignKeyConstraint:
|
||||
baseTableName: collection
|
||||
constraintName: fk_collection_workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:13
|
||||
author: piranha
|
||||
comment: Add workspace_id column to report_card table
|
||||
changes:
|
||||
- addColumn:
|
||||
tableName: report_card
|
||||
columns:
|
||||
- column:
|
||||
name: workspace_id
|
||||
type: int
|
||||
remarks: Workspace containing this card
|
||||
constraints:
|
||||
nullable: true
|
||||
rollback:
|
||||
- dropColumn:
|
||||
tableName: report_card
|
||||
columnName: workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:14
|
||||
author: piranha
|
||||
comment: Add foreign key constraint from report_card.workspace_id to workspace
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: report_card
|
||||
baseColumnNames: workspace_id
|
||||
constraintName: fk_report_card_workspace_id
|
||||
referencedTableName: workspace
|
||||
referencedColumnNames: id
|
||||
rollback:
|
||||
- dropForeignKeyConstraint:
|
||||
baseTableName: report_card
|
||||
constraintName: fk_report_card_workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:17
|
||||
author: piranha
|
||||
comment: Add workspace_id column to transform table
|
||||
changes:
|
||||
- addColumn:
|
||||
tableName: transform
|
||||
columns:
|
||||
- column:
|
||||
name: workspace_id
|
||||
type: int
|
||||
remarks: Workspace containing this transform
|
||||
constraints:
|
||||
nullable: true
|
||||
rollback:
|
||||
- dropColumn:
|
||||
tableName: transform
|
||||
columnName: workspace_id
|
||||
|
||||
- changeSet:
|
||||
id: v58.2025-11-18T12:00:18
|
||||
author: piranha
|
||||
comment: Add foreign key constraint from transform.workspace_id to workspace
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: transform
|
||||
baseColumnNames: workspace_id
|
||||
constraintName: fk_transform_workspace_id
|
||||
referencedTableName: workspace
|
||||
referencedColumnNames: id
|
||||
rollback:
|
||||
- dropForeignKeyConstraint:
|
||||
baseTableName: transform
|
||||
constraintName: fk_transform_workspace_id
|
||||
|
||||
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
@@ -9,4 +9,5 @@
|
||||
[metabase.api-keys.models.api-key
|
||||
generate-key
|
||||
is-api-key-user?
|
||||
prefix])
|
||||
prefix
|
||||
create-api-key-with-new-user!])
|
||||
|
||||
@@ -203,10 +203,10 @@
|
||||
|
||||
(mu/defn create-api-key-with-new-user!
|
||||
"Create a new API key and a new user for that key at the same time."
|
||||
[{:keys [key-name group-id]} :- [:map
|
||||
{:closed true}
|
||||
[:key-name ::api-keys.schema/name]
|
||||
[:group-id {:optional true} pos-int?]]]
|
||||
[{:keys [key-name group-id]}
|
||||
:- [:map {:closed true}
|
||||
[:key-name ::api-keys.schema/name]
|
||||
[:group-id {:optional true} pos-int?]]]
|
||||
(api/checkp (not (t2/exists? :model/ApiKey :name key-name))
|
||||
"name" "An API key with this name already exists.")
|
||||
(let [unhashed-key (key-with-unique-prefix)
|
||||
@@ -218,7 +218,8 @@
|
||||
:last_name ""
|
||||
:type :api-key
|
||||
:password (str (random-uuid))})]
|
||||
(user/set-permissions-groups! user-id [(perms/all-users-group) group-id])
|
||||
(when group-id
|
||||
(user/set-permissions-groups! user-id [(perms/all-users-group) group-id]))
|
||||
(-> (t2/insert-returning-instance! :model/ApiKey
|
||||
{:user_id user-id
|
||||
:name key-name
|
||||
|
||||
@@ -1834,7 +1834,8 @@
|
||||
(serdes/fk :model/Collection)
|
||||
{:export location-path->parent-id
|
||||
:import parent-id->location-path}))
|
||||
:personal_owner_id (serdes/fk :model/User)}})
|
||||
:personal_owner_id (serdes/fk :model/User)
|
||||
:workspace_id (serdes/fk :model/Workspace)}})
|
||||
|
||||
;;; +----------------------------------------------------------------------------------------------------------------+
|
||||
;;; | Perms Checking Helper Fns |
|
||||
|
||||
@@ -107,7 +107,8 @@
|
||||
:model/User metabase.users.models.user
|
||||
:model/UserKeyValue metabase.user-key-value.models.user-key-value
|
||||
:model/UserParameterValue metabase.users.models.user-parameter-value
|
||||
:model/ViewLog metabase.view-log.models.view-log})
|
||||
:model/ViewLog metabase.view-log.models.view-log
|
||||
:model/Workspace metabase-enterprise.workspaces.models.workspace})
|
||||
|
||||
;;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
;;; !! !!
|
||||
|
||||
@@ -1212,6 +1212,7 @@
|
||||
:document_id (serdes/fk :model/Document)
|
||||
:creator_id (serdes/fk :model/User)
|
||||
:made_public_by_id (serdes/fk :model/User)
|
||||
:workspace_id (serdes/fk :model/Workspace)
|
||||
:dataset_query {:export serdes/export-mbql :import serdes/import-mbql}
|
||||
:parameters {:export serdes/export-parameters :import serdes/import-parameters}
|
||||
:parameter_mappings {:export serdes/export-parameter-mappings :import serdes/import-parameter-mappings}
|
||||
|
||||
@@ -431,6 +431,7 @@
|
||||
:table_id (serdes/fk :model/Table)
|
||||
:fk_target_field_id (serdes/fk :model/Field)
|
||||
:parent_id (serdes/fk :model/Field)
|
||||
:workspace_id (serdes/fk :model/Workspace)
|
||||
:dimensions (serdes/nested :model/Dimension :field_id opts)}})
|
||||
|
||||
(defmethod serdes/storage-path "Field" [field _]
|
||||
|
||||
@@ -362,10 +362,11 @@
|
||||
:points_of_interest :caveats :show_in_getting_started :field_order :initial_sync_status :is_upload
|
||||
:database_require_filter :is_defective_duplicate :unique_table_helper :is_writable :data_authority]
|
||||
:skip [:estimated_row_count :view_count]
|
||||
:transform {:created_at (serdes/date)
|
||||
:archived_at (serdes/date)
|
||||
:transform {:created_at (serdes/date)
|
||||
:archived_at (serdes/date)
|
||||
:deactivated_at (serdes/date)
|
||||
:db_id (serdes/fk :model/Database :name)}})
|
||||
:db_id (serdes/fk :model/Database :name)
|
||||
:workspace_id (serdes/fk :model/Workspace)}})
|
||||
|
||||
(defmethod serdes/storage-path "Table" [table _ctx]
|
||||
(concat (serdes/storage-path-prefixes (serdes/path table))
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
:model/TransformTag
|
||||
:model/TransformTransformTag
|
||||
:model/Undo
|
||||
:model/UserKeyValue})
|
||||
:model/UserKeyValue
|
||||
:model/Workspace})
|
||||
|
||||
(defn- all-model-names []
|
||||
(into (sorted-set)
|
||||
|
||||
Reference in New Issue
Block a user