Compare commits

...

7 Commits

Author SHA1 Message Date
Chris Truter
0dd7d78c07 Crude toposorted dag calculation 2025-11-20 10:25:51 +02:00
lbrdnk
33daf1d822 Mirror single transform (#65982)
* Mirror single transform

Creates dwh namespace
Creates mirror target table
Creates mirror transform artifact
with adjusted
target
name

Abusing toucan models
sending those in entity-info

Most of the code incrementally
updates entity-info
with checkout progress
and we should continue with that
with more reasonable way
(i.e. not toucan models but some
subset of relevant keys)

Testing code at the end of
copying ns

Namespaces will change
into different subsystems probably
for now this is poc

* Assert toplevel, docstring

* Adjust namespaces and cleanup

* drop old file

* Tweak for rich comment, incomplete

* Vibe UX

* Tighten up workspace demo-0 code (#66044)

* Update FE to track API changes. Fix some small, subtle FE and BE issues.

* Critical error

---------

Co-authored-by: Chris Truter <chris@metabase.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2025-11-20 08:57:21 +01:00
Oleksandr Solovyov
9faad636cb add namespace to workspace collections 2025-11-19 18:47:16 +02:00
dan sutton
b201dca6cf empty for CI 2025-11-19 09:20:17 -06:00
Chris Truter
f1982e56e3 Fix contents API shape 2025-11-19 12:13:13 +02:00
Chris Truter
439c3f3be5 Add API endpoint to return the contents of a workspace 2025-11-19 12:11:01 +02:00
Oleksandr Solovyov
37449925fa migration and basic model file (#65956)
* migration and basic model file

* basic api
2025-11-19 12:04:35 +02:00
34 changed files with 1282 additions and 22 deletions

View File

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

View File

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

View File

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

View File

@@ -114,4 +114,5 @@
"User"
"UserKeyValue"
"UserParameterValue"
"ViewLog"])
"ViewLog"
"Workspace"])

View File

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

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

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

View File

@@ -0,0 +1,3 @@
(ns metabase-enterprise.workspaces.init
(:require
[metabase-enterprise.workspaces.models.workspace]))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -18,3 +18,4 @@ export * from "./transform";
export * from "./transform-job";
export * from "./transform-tag";
export * from "./upload-management";
export * from "./workspace";

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { WorkspaceSection } from "./WorkspaceSection";

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ export type Transform = {
target: TransformTarget;
created_at: string;
updated_at: string;
workspace_id: number | null;
// hydrated fields
tag_ids?: TransformTagId[];

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

View File

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

View File

@@ -9,4 +9,5 @@
[metabase.api-keys.models.api-key
generate-key
is-api-key-user?
prefix])
prefix
create-api-key-with-new-user!])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,8 @@
:model/TransformTag
:model/TransformTransformTag
:model/Undo
:model/UserKeyValue})
:model/UserKeyValue
:model/Workspace})
(defn- all-model-names []
(into (sorted-set)