Compare commits

...

75 Commits

Author SHA1 Message Date
Nathan Voxland
bf4ddeafae Merge branch 'source-of-truth' into git-branching-2
# Conflicts:
#	enterprise/backend/src/metabase_enterprise/git_source_of_truth/settings.clj
#	enterprise/backend/src/metabase_enterprise/git_source_of_truth/sources.clj
#	enterprise/frontend/src/metabase-enterprise/api/git-sync.ts
#	frontend/src/metabase-types/api/settings.ts
#	frontend/src/metabase/nav/components/AdminNavbar/AdminNavbar.tsx
#	resources/migrations/057_update_migrations.yaml
#	src/metabase/models/interface.clj
2025-08-29 16:37:06 -05:00
Nathan Voxland
c2e95e90b0 Merge remote-tracking branch 'origin/master' into source-of-truth 2025-08-29 16:28:56 -05:00
Nathan Voxland
0189385620 Merge remote-tracking branch 'origin/master' into source-of-truth 2025-08-29 16:10:20 -05:00
Nathan Voxland
b8c88a8ae3 Merged from master 2025-08-29 16:08:32 -05:00
Nathan Voxland
69250b5fc2 Fixed insert into select for pg 2025-08-29 15:00:12 -05:00
Nathan Voxland
ad1d277fea Merge remote-tracking branch 'origin/git-branching-2' into git-branching-2 2025-08-29 14:44:16 -05:00
Nathan Voxland
f996b7e55c Fixed insert into select for h2 2025-08-29 14:43:57 -05:00
Aleksandr Lesnenko
0de02d7919 empty state 2025-08-29 14:50:07 -04:00
Aleksandr Lesnenko
046625674d fix 2025-08-29 14:45:49 -04:00
Nathan Voxland
21f6b03346 Support postgresql on drop unique constraint 2025-08-29 13:35:53 -05:00
Nathan Voxland
3c2ec25dcd Fixed branching data for h2, made nil branch show as "main" with id of -1 2025-08-29 12:45:48 -05:00
Nathan Voxland
323871270c Fixed branching data for h2, made nil branch show as "main" with id of -1 2025-08-29 12:28:25 -05:00
John Swanson
51f1e0dc6e update the git-sync-configured setting 🎉 2025-08-29 10:14:27 -07:00
Aleksandr Lesnenko
39cf18ed43 fix transforms diff 2025-08-29 11:49:50 -04:00
Ryan Laurie
cb46d2e45e remove defunct setting 2025-08-29 09:43:15 -06:00
Ryan Laurie
28c612ac42 Revert "disable editing transforms when they're in the library"
This reverts commit ba8c87d6ba.
2025-08-29 09:39:54 -06:00
Aleksandr Lesnenko
f7554f9685 show transforms diff 2025-08-29 11:32:37 -04:00
John Swanson
f0aaa2f0a8 use jgit instead 2025-08-29 07:21:55 -07:00
Nathan Voxland
34f8815c2f Made branch insert/update logic generic and switched to transforms being branchable 2025-08-29 01:54:51 -05:00
Ryan Laurie
59fed921ff use github PAT 2025-08-28 17:03:53 -06:00
Ryan Laurie
70a701b719 remove dead stuff 2025-08-28 16:44:04 -06:00
John Swanson
eef3b79fc5 remove unnecessary transform bits 2025-08-28 15:12:24 -07:00
John Swanson
fa87409575 fix a couple things
load all entities, not just transforms

force push to the remote
2025-08-28 15:12:23 -07:00
John Swanson
2780d34324 work with git tokens 2025-08-28 15:12:23 -07:00
Ryan Laurie
d6dfe042fd use specific read only setting 2025-08-28 13:28:52 -06:00
John Swanson
858753bd51 use git-sync-read-only 2025-08-28 12:19:14 -07:00
John Swanson
41160173d6 fix an oopsie 2025-08-28 12:13:46 -07:00
John Swanson
c30c2a1f5c bidi sync 2025-08-28 12:13:46 -07:00
Ryan Laurie
0e107be7cd toggle-read-only-mode 2025-08-28 13:10:28 -06:00
Ryan Laurie
74d8625540 update import export setting names 2025-08-28 12:03:42 -06:00
Ryan Laurie
b3afb44973 import + export UI 2025-08-28 11:56:52 -06:00
John Swanson
5099353234 rename git-sync-branch to git-sync-import-branch 2025-08-28 10:41:17 -07:00
John Swanson
fe55e1188f import/export instead of reload 2025-08-28 10:40:03 -07:00
Aleksandr Lesnenko
f43f13b9cc transforms (#62845) 2025-08-28 12:48:27 -04:00
John Swanson
e142fb9569 fix deserializing new transforms 2025-08-28 06:29:56 -07:00
John Swanson
b6039acdf6 correct deps for transforms 2025-08-28 05:45:22 -07:00
John Swanson
f254383ed7 DOH fix dep loop 2025-08-28 04:47:57 -07:00
Ryan Laurie
b591d3e35a Add sync button 2025-08-27 16:34:38 -06:00
Ryan Laurie
8857f87a4b allow running library transforms 2025-08-27 16:34:38 -06:00
John Swanson
ca278eb44a set synced_from_source_of_truth accurately 2025-08-27 14:43:51 -07:00
John Swanson
36694ffb3b load selectively 2025-08-27 13:55:42 -07:00
John Swanson
c0773fee3a Serialize transforms 2025-08-27 13:55:41 -07:00
Aleksandr Lesnenko
4e9c823053 wip 2025-08-27 14:19:56 -04:00
Aleksandr Lesnenko
792f67160f wip 2025-08-27 14:19:41 -04:00
Aleksandr Lesnenko
53268fb3ab branching 2025-08-27 14:18:41 -04:00
Aleksandr Lesnenko
3abd0211d8 adjust width 2025-08-27 14:18:35 -04:00
Aleksandr Lesnenko
a08364900e wip 2025-08-27 14:18:19 -04:00
Aleksandr Lesnenko
c7c6a0653a git branching 2025-08-27 14:17:14 -04:00
Nathan Voxland
c809523ca8 Code cleanup 2025-08-27 13:09:18 -05:00
Nathan Voxland
6a6bb16429 Code cleanup 2025-08-27 13:06:28 -05:00
Aleksandr Lesnenko
149526487c wip
# Conflicts:
#	enterprise/frontend/src/metabase-enterprise/git_sync/SelectBranch.tsx
#	enterprise/frontend/src/metabase-enterprise/git_sync/ViewChangesButton.tsx
#	frontend/src/metabase/nav/components/AdminNavbar/AdminNavbar.tsx
#	frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
2025-08-26 22:46:42 -05:00
Aleksandr Lesnenko
63b3951f82 branching 2025-08-26 22:35:35 -05:00
Aleksandr Lesnenko
72e50a9fe3 adjust width 2025-08-26 22:35:13 -05:00
Edward Paget
e4467116b8 fix: remove some logging 2025-08-26 22:35:13 -05:00
Edward Paget
a317588160 fix: duplicates when updating on branch 2025-08-26 22:35:13 -05:00
Edward Paget
1c56fd147c support saving updated cards into branches 2025-08-26 22:35:12 -05:00
Edward Paget
2f349488c3 api for managing branches 2025-08-26 22:35:12 -05:00
Edward Paget
65af399b7c fallback on user setting 2025-08-26 22:34:35 -05:00
Aleksandr Lesnenko
fcdbfd299b wip 2025-08-26 22:34:34 -05:00
Edward Paget
335b6dca9e fix migrations and h2 2025-08-26 22:32:57 -05:00
Edward Paget
2771eda614 wip 2025-08-26 22:32:57 -05:00
Nathan Voxland
228722f230 Merge branch 'master' into source-of-truth 2025-08-26 17:49:12 -05:00
Ryan Laurie
ba8c87d6ba disable editing transforms when they're in the library 2025-08-26 14:30:10 -06:00
John Swanson
ca462fb3e7 oops, fix 2025-08-26 11:38:39 -07:00
John Swanson
b9c0cccf1a prevent writes to synced transforms
with a path forward for non-transforms
2025-08-26 11:19:17 -07:00
John Swanson
cd451dd31e delete now-unused config file 2025-08-26 07:22:26 -07:00
John Swanson
2189459744 Combine settings/config, make source more agnostic 2025-08-26 07:05:41 -07:00
Ryan Laurie
15b15b39b3 update settings 2025-08-25 16:35:08 -06:00
John Swanson
42ef031fab fix2 2025-08-25 13:20:20 -07:00
Ryan Laurie
2a7dbdce28 use git-sync-* instead 2025-08-25 13:55:46 -06:00
John Swanson
e80b91fbd4 fix 2025-08-25 12:45:05 -07:00
Ryan Laurie
1d4ac086fe add git sync settings 2025-08-25 13:40:37 -06:00
John Swanson
f9338127bb no init 2025-08-25 12:19:49 -07:00
John Swanson
f5bd4814c3 settings instead of env vars 2025-08-25 12:19:10 -07:00
John Swanson
c24c901a2f initial hacky terrible version 2025-08-25 09:35:33 -07:00
75 changed files with 3456 additions and 68 deletions

View File

@@ -170,6 +170,7 @@
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.0.25"}
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.0.24"}
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.0.25"}
org.eclipse.jgit/org.eclipse.jgit {:mvn/version "7.3.0.202506031305-r"}; Java implementation of Git version control system
org.flatland/ordered {:mvn/version "1.15.12"} ; ordered maps & sets
org.graalvm.polyglot/js-community {:mvn/version "24.2.2" :extension "pom"} ; JavaScript engine
org.graalvm.polyglot/polyglot {:mvn/version "24.2.2"} ; GraalVM platform, required by JS engine

View File

@@ -54,6 +54,7 @@
[clojure.main]
[clojure.string :as str]
[clojure.test]
[dev.branching :as dev.branching]
[dev.debug-qp :as debug-qp]
[dev.explain :as dev.explain]
[dev.h2 :as dev.h2]

198
dev/src/dev/branching.clj Normal file
View File

@@ -0,0 +1,198 @@
(ns dev.branching
(:require
[metabase.branching.core :as branching]
[metabase.test.initialize.test-users :as tu]
[toucan2.core :as t2]))
(defn create-sample-branch-setup!
"Creates a development branch setup with cards, copies, and mappings.
This is a development/testing version that simulates branching functionality
without requiring the enterprise branching models.
Returns a map with:
- :branch - simulated branch data
- :original-cards - the original card instances
- :copied-cards - the copied card instances
- :mappings - simulated branch model mappings
- :dashboard - dashboard referencing original cards"
[& {:keys [branch-name card-count copy-ratio collection-id]
:or {branch-name "dev-branch"
card-count 5
copy-ratio 0.6
collection-id nil}}]
(tu/init!)
(let [user (t2/select-one :model/User :email "rasta@metabase.com")
branch (t2/insert-returning-instance! :model/Branch {:name branch-name})
;; Determine collection
target-collection (if collection-id
(t2/select-one :model/Collection :id collection-id)
(t2/insert-returning-instance! :model/Collection
{:name (str "Branch Collection - " branch-name)}))
;; Create original cards
original-cards (vec
(for [i (range card-count)]
(t2/insert-returning-instance! :model/Card
{:name (str "Original Card " (inc i))
:collection_id (:id target-collection)
:dataset_query {:database 1
:type :native
:native {:query (str "SELECT " (inc i) " as result")}}
:display "table"
:visualization_settings {}
:creator_id (:id user)})))
;; Determine which cards to copy based on ratio
cards-to-copy-count (int (* card-count copy-ratio))
cards-to-copy (take cards-to-copy-count original-cards)
;; Create copied cards
copied-cards (vec
(for [original-card cards-to-copy]
(t2/insert-returning-instance! :model/Card
{:name (str "Branched " (:name original-card))
:collection_id (:id target-collection)
:dataset_query (update (:dataset_query original-card)
:native
assoc :query "SELECT 420 as result -- BRANCHED VERSION")
:display (:display original-card)
:visualization_settings (:visualization_settings original-card)
:creator_id (:id user)})))
;; Simulate branch model mappings (as data, not DB records)
mappings (vec
(for [[original copied] (map vector cards-to-copy copied-cards)]
(t2/insert-returning-instance! :model/BranchModelMapping
{:branch_id (:id branch)
:model_type "report_card"
:original_id (:id original)
:branched_model_id (:id copied)})))
;; Create dashboard referencing original cards
dashboard (t2/insert-returning-instance! :model/Dashboard
{:name (str "Dashboard for " branch-name)
:collection_id (:id target-collection)
:creator_id (:id user)})
;; Create dashboard cards linking to original cards
dashcards (vec
(for [[idx original-card] (map-indexed vector original-cards)]
(t2/insert-returning-instance! :model/DashboardCard
{:dashboard_id (:id dashboard)
:card_id (:id original-card)
:row (* idx 2)
:col 0
:size_x 4
:size_y 2})))]
{:branch branch
:collection target-collection
:original-cards original-cards
:copied-cards copied-cards
:mappings mappings
:dashboard dashboard
:dashcards dashcards}))
(defn resolve-branched-id
"Simulated version of resolve-branched-id that uses in-memory mappings."
[mappings model-type original-id branch-id]
(or (->> mappings
(filter #(and (= (:model_type %) (name model-type))
(= (:original_id %) original-id)
(= (:branch_id %) branch-id)))
first
:branched_model_id)
original-id))
(defn print-branch-summary
"Prints a summary of the created branch setup."
[{:keys [branch collection original-cards copied-cards mappings dashboard]}]
(println "=== Branch Setup Summary ===")
(println (format "Branch: %s (ID: %s, Slug: %s)"
(:name branch) (:id branch) (:slug branch)))
(println (format "Collection: %s (ID: %s)"
(:name collection) (:id collection)))
(println (format "Original Cards: %d" (count original-cards)))
(println (format "Copied Cards: %d" (count copied-cards)))
(println (format "Mappings: %d" (count mappings)))
(println (format "Dashboard: %s (ID: %s)"
(:name dashboard) (:id dashboard)))
(println "\n=== Card Mappings ===")
(doseq [{:keys [original_id branched_model_id]} mappings]
(let [original-name (:name (t2/select-one :model/Card :id original_id))
branched-name (:name (t2/select-one :model/Card :id branched_model_id))]
(println (format "%s -> %s" original-name branched-name))))
(println "\n=== Example Usage ===")
(println "To resolve branched IDs:")
(doseq [card original-cards]
(let [resolved-id (resolve-branched-id mappings :card (:id card) (:id branch))]
(if (not= resolved-id (:id card))
(println (format "Card %d resolves to branched card %d" (:id card) resolved-id))
(println (format "Card %d has no branch mapping, uses original" (:id card)))))))
(defn cleanup-branch-setup!
"Cleans up a branch setup by deleting all created entities."
[{:keys [branch collection original-cards copied-cards mappings dashboard dashcards]}]
(println "Cleaning up branch setup...")
;; Delete in reverse dependency order
(when dashcards
(doseq [dashcard dashcards]
(t2/delete! :model/DashboardCard :id (:id dashcard))))
(when dashboard
(t2/delete! :model/Dashboard :id (:id dashboard)))
;; Note: mappings are just data, no DB cleanup needed
(when copied-cards
(doseq [card copied-cards]
(t2/delete! :model/Card :id (:id card))))
(when original-cards
(doseq [card original-cards]
(t2/delete! :model/Card :id (:id card))))
(when collection
(t2/delete! :model/Collection :id (:id collection)))
;; Note: branch is simulated data, no DB cleanup needed
(println "Cleanup complete."))
(comment
;; Example usage:
;; Create a branch setup
(def setup (create-sample-branch-setup!))
;; Print summary
(print-branch-summary setup)
;; Test resolving branched IDs
(resolve-branched-id (:mappings setup) :card
(-> setup :original-cards first :id)
(-> setup :branch :id))
;; Create with custom options
(def custom-setup (create-sample-branch-setup!
:branch-name "feature-xyz"
:card-count 8
:copy-ratio 0.5))
;; Clean up when done
(cleanup-branch-setup! setup)
(cleanup-branch-setup! custom-setup))
(comment
(binding [branching/*enable-branch-hook* true
branching/*current-branch* (atom (t2/select-one :model/Branch :id 392))]
(t2/debug
(time (t2/select-one :model/Card :id 75)))))
(comment
(let [cards (t2/select :model/Card)]
(binding [branching/*enable-branch-hook* false]
(time (count (map #(t2/select-one :model/Card :id %) (map :id (mapcat #(repeat 5 %) cards))))))))

View File

@@ -14,6 +14,7 @@
[metabase-enterprise.api.routes.common :as ee.api.common]
[metabase-enterprise.audit-app.api.routes]
[metabase-enterprise.billing.api.routes]
[metabase-enterprise.branching.api]
[metabase-enterprise.cloud-add-ons.api]
[metabase-enterprise.content-translation.routes]
[metabase-enterprise.content-verification.api.routes]
@@ -21,6 +22,7 @@
[metabase-enterprise.database-routing.api]
[metabase-enterprise.documents.api]
[metabase-enterprise.email.api]
[metabase-enterprise.git-source-of-truth.api]
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.llm.api]
[metabase-enterprise.metabot-v3.api]
@@ -91,6 +93,7 @@
"/audit-app" (premium-handler metabase-enterprise.audit-app.api.routes/routes :audit-app)
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
"/billing" metabase-enterprise.billing.api.routes/routes
"/branch" metabase-enterprise.branching.api/routes
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
"/cloud-add-ons" metabase-enterprise.cloud-add-ons.api/routes
"/database-replication" (-> database-replication.api/routes ;; database-replication requires all these features.
@@ -100,6 +103,7 @@
"/database-routing" (premium-handler metabase-enterprise.database-routing.api/routes :database-routing)
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
"/email" (premium-handler metabase-enterprise.email.api/routes :cloud-custom-smtp)
"/git-source-of-truth" (premium-handler metabase-enterprise.git-source-of-truth.api/routes :serialization)
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
(premium-handler :attached-dwh)
(premium-handler :etl-connections))

View File

@@ -0,0 +1,166 @@
(ns metabase-enterprise.branching.api
"`/api/ee/branch/` routes"
(:require
[metabase-enterprise.branching.models.branch :as branch]
[metabase-enterprise.branching.models.branch-model-mapping :as mapping]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.branching.core :as branching]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
;;; ------------------------------------------------- Schemas -------------------------------------------------
(def BranchCreateRequest
"Schema for creating a new branch."
[:map
[:name ms/NonBlankString]
[:parent_branch_id {:optional true} [:maybe ms/Int]]])
(def BranchResponse
"Schema for branch API responses."
[:map
[:id ms/PositiveInt]
[:name :string]
[:slug :string]
[:creator_id ms/PositiveInt]
[:parent_branch_id [:maybe ms/PositiveInt]]
[:created_at ms/TemporalString]
[:updated_at ms/TemporalString]])
(def DiffResponse
"Schema for branch diff API responses."
[:map
[:id ms/PositiveInt]
[:name :string]
[:status [:enum "added" "modified" "deleted"]]
[:content_type [:enum "card" "dashboard" "collection" "transform"]]
[:current {:optional true} :map]
[:original {:optional true} :map]])
;;; ------------------------------------------------- API Endpoints -------------------------------------------------
(api.macros/defendpoint :get "/"
"Get all branches. If parent_id is specified, filter to branches with that parent. Set parent_id to -1 to get root-level branches."
[_route-params
{:keys [parent_id]} :- [:map
[:parent_id {:optional true} [:maybe ms/Int]]]]
{:data (into [{:id -1
:name "main"
:slug "main"
:creator_id 1
:parent_branch_id nil
:created_at "1970-01-01T00:00:00Z"
:updated_at "1970-01-01T00:00:00Z"}]
(map #(select-keys % [:id :name :slug :creator_id :parent_branch_id :created_at :updated_at])
(cond
(nil? parent_id) (t2/select :model/Branch)
(> 0 parent_id) (branch/get-children-by-id nil)
:else (branch/get-children-by-id parent_id))))})
(api.macros/defendpoint :get "/:id/diff"
"Get diff of changes in a branch compared to its parent branch. Open access for authenticated users."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(let [branch (t2/select-one :model/Branch :id id)]
(when-not branch
(throw (ex-info (tru "Branch not found.") {:status-code 404})))
(let [mappings (t2/select :model/BranchModelMapping :branch_id id)
card-mappings (filter #(= (:model_type %) "report_card") mappings)
transform-mappings (filter #(= (:model_type %) "transform") mappings)
card-diff-entries (for [{:keys [original_id branched_model_id]} card-mappings]
(let [is-added? (= original_id branched_model_id)
is-deleted? (and original_id (nil? branched_model_id))
status (cond
is-added? "added"
is-deleted? "deleted"
:else "modified")
original-card (when (and original_id (not is-added?))
(binding [branching/*enable-branch-hook* false]
(t2/select-one :model/Card :id original_id)))
current-card (when branched_model_id
(binding [branching/*enable-branch-hook* false]
(t2/select-one :model/Card :id branched_model_id)))
card-fields [:id :name :description :dataset_query :display :visualization_settings :database_id]]
{:id (or branched_model_id original_id)
:name (or (:name current-card) (:name original-card) "Unknown")
:status status
:content_type "card"
:current (when current-card (select-keys current-card card-fields))
:original (when original-card (select-keys original-card card-fields))}))
transform-diff-entries (for [{:keys [original_id branched_model_id]} transform-mappings]
(let [is-added? (= original_id branched_model_id)
is-deleted? (and original_id (nil? branched_model_id))
status (cond
is-added? "added"
is-deleted? "deleted"
:else "modified")
original-transform (when (and original_id (not is-added?))
(binding [branching/*enable-branch-hook* false]
(t2/select-one :model/Transform :id original_id)))
current-transform (when branched_model_id
(binding [branching/*enable-branch-hook* false]
(t2/select-one :model/Transform :id branched_model_id)))
transform-fields [:id :name :description :source :target :run_trigger]]
{:id (or branched_model_id original_id)
:name (or (:name current-transform) (:name original-transform) "Unknown")
:status status
:content_type "transform"
:current (when current-transform (select-keys current-transform transform-fields))
:original (when original-transform (select-keys original-transform transform-fields))}))
all-entries (concat card-diff-entries transform-diff-entries)]
{:data all-entries})))
(api.macros/defendpoint :get "/:id"
"Get a single branch by ID."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(or (some-> (t2/select-one :model/Branch :id id)
(select-keys [:id :name :slug :creator_id :parent_branch_id :created_at :updated_at]))
(throw (ex-info (tru "Branch not found.") {:status-code 404}))))
(api.macros/defendpoint :post "/"
"Create a new branch."
[_route-params
_query-params
{:keys [name parent_branch_id]} :- BranchCreateRequest]
(let [parent_branch_id (if (= -1 parent_branch_id) nil parent_branch_id)]
(when parent_branch_id
;; Validate parent branch exists
(when-not (t2/exists? :model/Branch :id parent_branch_id)
(throw (ex-info (tru "Parent branch does not exist.") {:status-code 400}))))
;; Create the branch
(let [branch-data (cond-> {:name name
:creator_id api/*current-user-id*}
parent_branch_id (assoc :parent_branch_id parent_branch_id))
new-id (t2/insert-returning-pk! :model/Branch branch-data)]
(-> (t2/select-one :model/Branch :id new-id)
(select-keys [:id :name :slug :creator_id :parent_branch_id :created_at :updated_at])))))
(api.macros/defendpoint :delete "/:id"
"Delete a branch by ID. Open access for authenticated users."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(let [branch (t2/select-one :model/Branch :id id)]
(when-not branch
(throw (ex-info (tru "Branch not found.") {:status-code 404})))
;; Check if there are child branches
(when (branch/get-has-children? branch)
(throw (ex-info (tru "Cannot delete branch with child branches.") {:status-code 400})))
(t2/delete! :model/Branch :id id)
api/generic-204-no-content))
;;; ------------------------------------------------- Routes -------------------------------------------------
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/branch/` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,14 @@
(ns metabase-enterprise.branching.core
"Handles the logic for setting the current branch from an HTTP request and supports
overriding t2/select to get branch-able models based on the current request."
(:require
[metabase.premium-features.core :refer [defenterprise]]
[metabase.users.settings :as u.settings]))
(defenterprise get-current-branch
"Get the current branch from the X-Metabase-Branch header."
:feature :none
[request]
(or (get-in request [:headers "X-Metabase-Branch"])
(get-in request [:headers "x-metabase-branch"])
(u.settings/git-branch)))

View File

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

View File

@@ -0,0 +1,63 @@
(ns metabase-enterprise.branching.models.branch
(:require
[metabase.models.interface :as mi]
[metabase.util :as u]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/Branch
[_model] :branch)
(doto :model/Branch
(derive :metabase/model)
(derive :hook/timestamped?))
(t2/define-before-insert :model/Branch
[branch]
(assoc branch :slug (u/slugify (:name branch))))
;;; ------------------------------------------------- Helper Functions -------------------------------------------------
(mu/defn get-parent :- [:maybe (ms/InstanceOf :model/Branch)]
"Get the parent branch of the given branch, if it exists."
[branch :- (ms/InstanceOf :model/Branch)]
(when-let [parent-id (:parent_branch_id branch)]
(t2/select-one :model/Branch :id parent-id)))
(mu/defn get-children-by-id :- [:set (ms/InstanceOf :model/Branch)]
"Get all branches with the specified parent_branch_id. If parent-id is nil,
returns root-level branches (those with no parent)."
[parent-id :- [:maybe ms/PositiveInt]]
(set (t2/select :model/Branch :parent_branch_id parent-id)))
(mu/defn get-children :- [:set (ms/InstanceOf :model/Branch)]
"Get all direct child branches of the given branch."
[branch :- (ms/InstanceOf :model/Branch)]
(get-children-by-id (:id branch)))
(mu/defn get-has-children? :- :boolean
"Check if the given branch has any child branches."
[branch :- (ms/InstanceOf :model/Branch)]
(t2/exists? :model/Branch :parent_branch_id (:id branch)))
;;; ------------------------------------------------- Hydration Methods -------------------------------------------------
(mi/define-simple-hydration-method parent
:parent
"Hydrate a branch with its parent branch."
[branch]
(get-parent branch))
(mi/define-simple-hydration-method children
:children
"Hydrate a branch with its direct child branches."
[branch]
(get-children branch))
(mi/define-simple-hydration-method has-children?
:has_children
"Hydrate a branch with a boolean indicating whether it has child branches."
[branch]
(get-has-children? branch))

View File

@@ -0,0 +1,31 @@
(ns metabase-enterprise.branching.models.branch-model-mapping
(:require
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/BranchModelMapping
[_model] :branch_model_mapping)
(doto :model/BranchModelMapping
(derive :metabase/model)
(derive :hook/timestamped?))
(defn resolve-branched-id
"Given an original model ID and branch, return the branched ID if it exists"
[model-type original-id branch-id]
(or (t2/select-one-fn :branched_model_id :model/BranchModelMapping
:original_id original-id
:model_type (name model-type)
:branch_id branch-id)
original-id))
(defn batch-resolve-branched-ids
"Efficiently resolve multiple IDs for a branch"
[model-type original-ids branch-id]
(when (seq original-ids)
(let [mappings (t2/select :model/BranchModelMapping
:model_type (name model-type)
:original_id [:in original-ids]
:branch_id branch-id)
mapping-map (into {} (map (juxt :original_id :branched_model_id)) mappings)]
(map #(get mapping-map % %) original-ids))))

View File

@@ -0,0 +1,183 @@
(ns metabase-enterprise.branching.models.hook
"Defines a toucan pipeline step hook that substitutes out tables that can be branched
with CTEs that reflect their current state when branched."
(:require
[clojure.java.jdbc :as jdbc]
[clojure.string :as str]
[clojure.walk :as walk]
[honey.sql :as sql]
[medley.core :as m]
[metabase.app-db.core :as mdb]
[metabase.branching.core :as branching]
[metabase.models.resolution :as models.resolution]
[metabase.util :as u]
[methodical.core :as methodical]
[toucan2.core :as t2]
[toucan2.pipeline :as t2.pipeline]
[toucan2.query :as t2.query]))
(def ^:private branchables
"Map of tables derving from :hook/branchable -> their columns."
(delay
(reduce-kv (fn [acc k _]
(cond-> acc
(isa? k :hook/branchable) (assoc (t2/table-name k) (map :column_name
(t2/query {:select [:column_name]
:from [[:information_schema.columns]]
:where [:and [:= :table_name (case (mdb/db-type)
:h2 (u/upper-case-en (name (t2/table-name k)))
(name (t2/table-name k)))]]})))))
{}
models.resolution/model->namespace)))
(defn- branched-cte
[table]
(let [current-branch-id (:id @branching/*current-branch*)]
{:select (into [[[:coalesce :bm.original_id :branched.id] :id]]
(for [col-name (table @branchables)
:when (not (contains? #{"id" "ID"} col-name))]
(keyword (str "branched." col-name))))
:from [[table :branched]]
:left-join [[:branch_model_mapping :bm]
[:and
[:= :bm.branched_model_id :branched.id]
[:= :bm.model_type [:inline (name table)]]]]
:where [:or
[:and [:is-not :bm.branched_model_id nil]
[:= :bm.branch_id current-branch-id]]
[:and
[:is :bm.branched_model_id nil]
[:not-exists
{:select [1]
:from [[:branch_model_mapping :bm2]]
:where [:and
[:= :bm2.branch_id current-branch-id]
[:= :bm2.original_id :branched.id]
[:= :bm2.model_type [:inline (name table)]]]}]]]}))
(defn- substitute-ctes
"Substitute out branchable tables in a query with their replacements."
[query tables]
(let [with (some->> query :with (m/index-by first))
tables* (remove #(contains? with (keyword (str "branched_" (name %)))) tables)
tables->cte-refs (zipmap tables* (map #(keyword (str "branched_" (name %))) tables*))
str-tables->cte-refs (reduce-kv #(assoc %1 (name %2) (name %3)) {} tables->cte-refs)
replaced (atom #{})]
(letfn [(walker [node]
(cond
(not (keyword? node)) node
(contains? tables->cte-refs node)
(do (swap! replaced conj node)
(node tables->cte-refs))
(contains? str-tables->cte-refs (namespace node))
(keyword (get str-tables->cte-refs (namespace node)) (name node))
(some #(str/starts-with? (name node) (str % ".")) (keys str-tables->cte-refs))
(let [[table col] (str/split (name node) #"\." 2)]
(keyword (str (get str-tables->cte-refs table) "." col)))
:else node))]
(if (empty? tables*)
query
(let [walked (walk/postwalk walker query)]
(reduce (fn [query* table]
(update query* :with conj [(table tables->cte-refs) (branched-cte table)]))
walked
@replaced))))))
(methodical/defmethod t2.pipeline/build [#_query-type :default
#_model :default
#_resolved-query :default]
[query-type model parsed-args resolved-query]
(let [built (next-method query-type model parsed-args resolved-query)]
(if (and branching/*enable-branch-hook*
(not= query-type :toucan2.tools.before-update/select-for-before-update)
(map? built)
(not= model :model/Branch)
(or (contains? built :select) (contains? built :union) (contains? built :union-all))
(not= (:from built) [[:information_schema.columns]]))
(substitute-ctes built (keys @branchables))
built)))
(defn- model->type
"Returns the model_type for the branch mapping for the given model."
[model]
(-> model
t2/table-name
name))
(defn- instance->type
"Returns the model_type for the branch mapping for the given instance."
[instance]
(-> instance
t2/model
model->type))
(t2/define-after-insert :hook/branchable
[instance]
(when @branching/*current-branch*
(t2/insert! :model/BranchModelMapping {:original_id (:id instance)
:branched_model_id (:id instance)
:model_type (instance->type instance)
:branch_id (:id @branching/*current-branch*)}))
instance)
;(defn- on-current-branch?
; [{:keys [id] :as instance}]
; (and branching/*current-branch*
; (t2/exists? :model/BranchModelMapping :original_id id :branch_id (:id @branching/*current-branch*) :model_type (instance->type instance))))
(defn- only-not-in-branch [where]
(if branching/*current-branch*
[:and where [:not-in :id {:select [:original_id]
:from [:branch_model_mapping]
:where [:= :branch_id (:id @branching/*current-branch*)]}]]
where))
(defn- translate-pk [kv-args]
(if branching/*current-branch*
(m/update-existing kv-args :toucan/pk (fn [x] (or (t2/select-one-fn :branched_model_id [:model/BranchModelMapping :branched_model_id]
:original_id x
:branch_id (:id @branching/*current-branch*)) x)))
kv-args))
;(defn- add-only-in-branch [where]
; (if branching/*current-branch*
; [:and where [:in :id {:select [:branched_model_id]
; :from [:branch_model_mapping]
; :where [:= :branch_id (:id @branching/*current-branch*)]}]]
; [:and where [:not-in :id {:select [:branched_model_id]
; :from [:branch_model_mapping]}]]))
(defn- copy-unbranched
[model parsed-args]
(when @branching/*current-branch*
(let [db-type (mdb/db-type)
kv-args (:kv-args parsed-args)
query (-> (t2.query/apply-kv-args model {} kv-args)
(update :where only-not-in-branch))
columns (map keyword (remove #(#{"id" "ID"} %) ((t2/table-name model) @branchables)))
insert-half (first (sql/format {:insert-into [(t2/table-name model) columns]} {:inline true}))]
(t2/with-connection [^java.sql.Connection conn]
(doseq [to-copy-id (t2/select-pks-vec model query)]
(let [inserted-id (-> (jdbc/execute! {:connection conn} (str insert-half " " (first (sql/format {:select columns
:from [(t2/table-name model)]
:where [:= :id to-copy-id]} {:inline true}))) {:returning-keys true})
first)]
(t2/insert! :model/BranchModelMapping {:original_id to-copy-id
:branched_model_id (:val (first (t2/query [(str "SELECT max(id) as val from " (name (t2/table-name model)))]))) ;; TODO: this is ugly for h2 vs using "RETURNING ID" and returning-keys seems to lie, is there a better way?
:model_type (model->type model)
:branch_id (:id @branching/*current-branch*)})))))))
(methodical/defmethod t2.pipeline/transduce-query [#_query-type :toucan.query-type/update.*
#_model :model/Transform ; TODO: make this :hook/branchable
#_resolved-query :default]
[rf query-type model parsed-args resolved-query]
(copy-unbranched model parsed-args)
(next-method rf
query-type
model
(update parsed-args :kv-args translate-pk)
resolved-query))

View File

@@ -0,0 +1,2 @@
(ns metabase-enterprise.branching.settings
"Settings definitions that control whether branching is on and how it is implemented.")

View File

@@ -9,9 +9,11 @@
[metabase-enterprise.action-v2.init]
[metabase-enterprise.advanced-config.init]
[metabase-enterprise.audit-app.init]
[metabase-enterprise.branching.init]
[metabase-enterprise.cache.init]
[metabase-enterprise.database-replication.init]
[metabase-enterprise.documents.init]
[metabase-enterprise.git-source-of-truth.init]
[metabase-enterprise.gsheets.init]
[metabase-enterprise.llm.init]
[metabase-enterprise.metabot-v3.init]

View File

@@ -0,0 +1,130 @@
(ns metabase-enterprise.git-source-of-truth.api
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[metabase-enterprise.git-source-of-truth.settings :as settings]
[metabase-enterprise.git-source-of-truth.sources :as sources]
[metabase-enterprise.serialization.v2.extract :as serdes.extract]
[metabase-enterprise.serialization.v2.ingest :as serdes.ingest]
[metabase-enterprise.serialization.v2.load :as serdes.load]
[metabase-enterprise.serialization.v2.storage :as serdes.storage]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[metabase.util :as u]
[metabase.util.log :as log])
(:import (java.io File)
(java.nio.file Files)
(java.nio.file.attribute FileAttribute)))
(defn cleanup-temp-directory!
"Clean up temporary directory and all its contents."
[temp-dir]
(when temp-dir
(try
(let [dir-file (io/file temp-dir)]
(when (.exists ^File dir-file)
(log/debugf "Cleaning up temporary directory: %s" temp-dir)
(letfn [(delete-file [file]
(when (.isDirectory file)
(doseq [child (.listFiles file)]
(delete-file child)))
(.delete file))]
(delete-file dir-file))
(log/debugf "Successfully cleaned up temporary directory: %s" temp-dir)))
(catch Exception e
(log/warnf e "Failed to clean up temporary directory: %s" temp-dir)))))
(defmacro with-temp-directory
"Makes a temp dir and then deletes it outside the form."
[binding & body]
`(let [~binding (.toFile (Files/createTempDirectory "git-source-of-truth-" (into-array FileAttribute [])))]
(try ~@body
(finally
(cleanup-temp-directory! ~binding)))))
(defn- reload-from-git!
"Reloads the Metabase entities from the library"
[]
(if-let [source (sources/get-source)]
(with-temp-directory dir
(log/info "Reloading Metabase configuration from source")
(try
(serdes/with-cache
(serdes.load/load-metabase!
(serdes.ingest/ingest-yaml (sources/load-source! source (.toString dir)))))
(log/info "Successfully reloaded entities from git repository")
{:status :success
:message "Successfully reloaded from git repository"}
(catch Exception e
(log/errorf e "Failed to reload from git repository: %s" (.getMessage e))
(let [error-msg (cond
(instance? java.net.UnknownHostException e)
"Network error: Unable to reach git repository host"
(str/includes? (.getMessage e) "Authentication failed")
"Authentication failed: Please check your git credentials"
(str/includes? (.getMessage e) "Repository not found")
"Repository not found: Please check the repository URL"
(str/includes? (.getMessage e) "branch")
"Branch error: Please check the specified branch exists"
:else
(format "Failed to reload from git repository: %s" (.getMessage e)))]
{:status :error
:message error-msg
:details {:error-type (type e)}}))))
{:status :error
:message "Git source of truth is not enabled. Please configure MB_GIT_SOURCE_REPO_URL environment variable."}))
(api.macros/defendpoint :post "/import"
"Reload Metabase content from Git repository source of truth.
This endpoint will:
1. Fetch the latest changes from the configured git repository
2. Load the updated content using the serialization/deserialization system
Requires superuser permissions."
[]
(api/check-superuser)
(let [result (reload-from-git!)]
(case (:status result)
:success
"Success"
:error
{:status 400
:body {:status "error"
:message (:message result)}}
;; Fallback for unexpected result format
{:status 500
:body {:status "error"
:message "Unexpected error occurred during reload"}})))
(api.macros/defendpoint :post "/export"
"Exports Metabase content to the Git repository source of truth.
Requires superuser"
[]
(api/check-superuser)
;; Placeholder
(with-temp-directory tmp
(serdes/with-cache
(-> (serdes.extract/extract {:no-settings true
:include-database-secrets false})
(serdes.storage/store! tmp)))
(sources/push-branch! (sources/get-source) tmp))
"Success")
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/git-source-of-truth` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,7 @@
(ns metabase-enterprise.git-source-of-truth.core
(:require
[metabase-enterprise.serialization.cmd :as serdes-cmd]
[metabase.cloud-migration.core :as cloud-migration]
[metabase.util.log :as log]
[potemkin :as p]))

View File

@@ -0,0 +1,120 @@
(ns metabase-enterprise.git-source-of-truth.git
(:require
[clojure.java.io :as io]
[metabase.util.log :as log])
(:import
(java.io File)
(org.eclipse.jgit.api Git)
(org.eclipse.jgit.api.errors GitAPIException InvalidRemoteException TransportException)
(org.eclipse.jgit.transport UsernamePasswordCredentialsProvider
RefSpec)))
(set! *warn-on-reflection* true)
(defn clone-repository!
"Clone git repository to a temporary directory using jgit. Returns the path to the cloned repository."
[url branch dir token]
(try
(log/info "Cloning repository" {:url url :branch branch :dir dir})
(-> (Git/cloneRepository)
(.setURI url)
(.setBranch branch)
(.setDirectory (io/file dir))
(.setCloneAllBranches false)
(.setCredentialsProvider (when token
(UsernamePasswordCredentialsProvider. "x-access-token" token)))
(.call)
(.close))
(log/info "Successfully cloned repository" {:dir dir})
(catch Exception e
(throw (ex-info (format "Failed to clone git repository: %s" (.getMessage e))
{:url url
:branch branch
:dir dir
:error (.getMessage e)})))))
(defn push-branch!
"Oh this is terrible, not just pushing a branch but ... sticking the current content into a branch based on
`source-branch`, then force pushing it to the `dest-branch`."
[{:keys [source-dir url source-branch dest-branch token]}]
(let [branch-name (str (random-uuid))
repo-dir (io/file source-dir)]
(try
;; Initialize or open existing repository
(log/info "Initializing git repository" {:dir source-dir})
(let [git-repo (if (.exists (io/file source-dir ".git"))
(Git/open repo-dir)
(-> (Git/init)
(.setDirectory repo-dir)
(.call)))
repo (.getRepository git-repo)]
(try
;; Add origin remote if it doesn't exist
(log/info "Setting up remote origin" {:url url})
(-> (.remoteAdd git-repo)
(.setName "origin")
(.setUri (org.eclipse.jgit.transport.URIish. url))
(.call))
;; Fetch from origin
(log/info "Fetching from origin")
(-> (.fetch git-repo)
(.setRemote "origin")
(.setCredentialsProvider (when token
(UsernamePasswordCredentialsProvider. "x-access-token" token)))
(.call))
;; Create and checkout new branch
(log/info "Creating new branch" {:branch branch-name})
(-> (.checkout git-repo)
(.setOrphan true)
(.setName branch-name)
(.call))
(when-let [origin-ref (.findRef repo (str "origin/" source-branch))]
(-> (.reset git-repo)
(.setMode org.eclipse.jgit.api.ResetCommand$ResetType/MIXED)
(.setRef (str "origin/" source-branch))
(.call)))
;; Add all changes
(log/info "Adding all changes")
(-> (.add git-repo)
(.addFilepattern ".")
(.call))
;; Commit changes
(log/info "Committing changes")
(-> (.commit git-repo)
(.setMessage "Update content")
(.call))
;; Push to destination branch
(log/info "Pushing to destination branch" {:dest-branch dest-branch})
(-> (.push git-repo)
(.setRemote "origin")
(.setRefSpecs [(RefSpec. (str "refs/heads/" branch-name ":refs/heads/" dest-branch))])
(.setForce true)
(.setCredentialsProvider
(when token
(UsernamePasswordCredentialsProvider. "x-access-token" token)))
(.call))
(finally
(.close git-repo))))
(catch Exception e
(log/error e "Git push failed")
(throw e)))))
(defn can-access-branch-in-repository? [git-url branch token]
(try
(let [credentials-provider (when token (UsernamePasswordCredentialsProvider. "x-access-token" token))
ls-remote-command (-> (Git/lsRemoteRepository)
(.setRemote git-url)
(.setCredentialsProvider credentials-provider))
refs (.call ls-remote-command)
branch-ref-name (str "refs/heads/" branch)]
(boolean (some #(= (.getName %) branch-ref-name) refs)))
(catch InvalidRemoteException e false)
(catch TransportException e false)
(catch GitAPIException e false)))

View File

@@ -0,0 +1,3 @@
(ns metabase-enterprise.git-source-of-truth.init
(:require
[metabase-enterprise.git-source-of-truth.settings]))

View File

@@ -0,0 +1,82 @@
(ns metabase-enterprise.git-source-of-truth.settings
(:require
[metabase-enterprise.git-source-of-truth.git :as git]
[metabase.settings.core :as setting :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru]]))
(defsetting git-sync-configured
(deferred-tru "Whether git sync is configured.")
:type :boolean
:visibility :authenticated
:export? false
:encryption :no
:default false)
(defn- set-repo-with-verification!-fn [original-key]
(fn [new-value]
(setting/set-value-of-type! :string original-key new-value)
(if #p (git/can-access-branch-in-repository? (setting/get :git-sync-url)
(setting/get :git-sync-import-branch)
(setting/get :git-sync-token))
(git-sync-configured! true)
(git-sync-configured! false))))
(defsetting git-sync-import-branch
(deferred-tru "The git branch to pull from, e.g. `main`")
:type :string
:visibility :admin
:encryption :no
:export? false
:default "main"
:setter (set-repo-with-verification!-fn :git-sync-import-branch))
(defsetting git-sync-token
(deferred-tru "A GH token")
:type :string
:visibility :admin
:doc true
:export? false
:encryption :when-encryption-key-set
:audit :getter
:setter (set-repo-with-verification!-fn :git-sync-token))
(defsetting git-sync-url
(deferred-tru "The location of your git repository, e.g. https://github.com/acme-inco/metabase.git")
:type :string
:visibility :admin
:encryption :no
:export? false
:setter (set-repo-with-verification!-fn :git-sync-url))
;; TODO actually use this
(defsetting git-sync-key
(deferred-tru "An RSA key with write permissions to the git repository.")
:type :string
:visibility :admin
:doc true
:export? false
:encryption :when-encryption-key-set
:audit :getter)
(defsetting git-sync-default-branch
(deferred-tru "The default branch for git sync.")
:type :string
:visibility :authenticated
:export? false
:encryption :no
:default nil)
(defsetting git-sync-export-branch
(deferred-tru "The git branch to push to, e.g. `new-changes`")
:type :string
:visibility :admin
:encryption :no
:export? false
:default "main")
(defsetting git-sync-read-only
(deferred-tru "Whether the instance is synced to git and read-only")
:type :boolean
:visibility :admin
:export? false
:default false)

View File

@@ -0,0 +1,62 @@
(ns metabase-enterprise.git-source-of-truth.sources
(:require
[clojure.java.io :as io]
[metabase-enterprise.git-source-of-truth.git :as git]
[metabase-enterprise.git-source-of-truth.settings :as settings]
[metabase.util.log :as log])
(:import
(java.io File)
(org.eclipse.jgit.api Git)
(org.eclipse.jgit.transport UsernamePasswordCredentialsProvider
RefSpec)))
(set! *warn-on-reflection* true)
(defprotocol ISource
(load-source! [this dir]
"Loads the source, taking a directory and returning an absolute path to the source directory that can be loaded
with serdes.
TODO: working with directories (taking a directory and modifying it and returning a path) isn't really the best
API but it'll do for now."))
(defprotocol IDestination
(push-branch! [this source-dir]
"Pushes the contentents of `source-dir` to the branch specified."))
(defn get-serdes-path
"Get the full path to the serdes files within the repository."
[repo-dir path]
(let [full-path (if (= path ".")
repo-dir
(str repo-dir File/separator path))]
(when-not (.exists (io/file full-path))
(throw (ex-info (format "Serdes path does not exist: %s" full-path)
{:repo-dir repo-dir
:config-path path
:full-path full-path})))
full-path))
(defrecord GitSource [url source-branch token dest-branch]
ISource
(load-source! [_this dir]
(git/clone-repository! url source-branch dir token)
dir)
IDestination
(push-branch! [_this source-dir]
(git/push-branch! {:source-dir source-dir
:url url
:source-branch source-branch
:dest-branch dest-branch
:token token})))
(defn get-source
"Returns the source that we should actually use. A source implements the `ISource` protocol."
[]
(when (settings/git-sync-url)
(->GitSource (settings/git-sync-url)
(settings/git-sync-import-branch)
(settings/git-sync-token)
(settings/git-sync-export-branch))))

View File

@@ -32,7 +32,10 @@
(into serdes.models/data-model)
(not (:no-settings opts))
(conj "Setting")))
(conj "Setting")
(not (:no-transforms opts))
(conj "Transform" "TransformTag" "TransformJob")))
(defn targets-of-type
"Returns target seq filtered on given model name"

View File

@@ -16,6 +16,13 @@
(declare load-one!)
(defn- filter-by-type-whitelist
"Filter a list of entity paths, keeping only those whose model type is in the whitelist."
[entity-paths type-whitelist]
(filter (fn [path]
(contains? type-whitelist (-> path peek :model)))
entity-paths))
(def ^:private model->circular-dependency-keys
"Sometimes models have circular dependencies. For example, a card for a Dashboard Question has a `dashboard_id`
pointing to the dashboard it's in. But when we try to load that dashboard, we'll create all its dashcards, and one
@@ -189,3 +196,41 @@
;; ideally, we would delay the indexing somehow, or only reindex what we've loaded.
;; while we're figuring that out, here's a crude stopgap.
(search/reindex! {:async? false})))
(defn load-selective!
"Loads entities from an ingestion source, but only those whose types are in the whitelist.
Parameters:
- ingestion: Any Ingestable instance (e.g., YamlIngestion)
- type-whitelist: Set of model names to load (e.g., #{\"Transform\"})
- opts: Options map (same as load-metabase!)"
[ingestion type-whitelist & {:keys [backfill? continue-on-error]
:or {backfill? true
continue-on-error false}}]
(u/prog1
(t2/with-transaction [_tx]
(when backfill?
(serdes.backfill/backfill-ids!))
(let [all-contents (serdes.ingest/ingest-list ingestion)
;; Filter to only load whitelisted types
filtered-contents (filter-by-type-whitelist all-contents type-whitelist)
ctx {:expanding #{}
:seen #{}
:circular #{}
:ingestion ingestion
:from-ids (m/index-by :id filtered-contents)
:errors []}]
(log/infof "Starting selective deserialization for types %s, loading %s of %s total documents"
type-whitelist (count filtered-contents) (count all-contents))
(reduce (fn [ctx item]
(try
(load-one! ctx item)
(catch Exception e
(when-not continue-on-error
(throw e))
(log/warnf (u/strip-error e "Skipping deserialization error"))
(update ctx :errors conj e))))
ctx
filtered-contents)))
(search/reindex! {:async? false})))

View File

@@ -26,7 +26,12 @@
content
["FieldValues"
"Metabot"
"Setting"]))
"Setting"
"Transform"
"TransformTag"
"TransformJob"
"TransformJobTransformTag"
"TransformTransformTag"]))
(def inlined-models
"An additional list of models which are inlined into parent entities for serialization.
@@ -92,14 +97,9 @@
"Secret"
"Session"
"TaskHistory"
"Transform"
"TransformJob"
"TransformJobRun"
"TransformJobTransformTag"
"TransformRun"
"TransformRunCancelation"
"TransformTag"
"TransformTransformTag"
"Undo"
"User"
"UserKeyValue"

View File

@@ -75,7 +75,7 @@
[_route-params
_query-params]
(api/check-superuser)
(t2/hydrate (t2/select :model/Transform) :last_run :transform_tag_ids))
(t2/hydrate (t2/select :model/Transform) :last_run :transform_tag_ids :can_write))
(api.macros/defendpoint :post "/"
"Create a new transform."
@@ -113,7 +113,7 @@
database-id (source-database-id transform)
target-table (transforms.util/target-table database-id target :active true)]
(-> transform
(t2/hydrate :last_run :transform_tag_ids)
(t2/hydrate :last_run :transform_tag_ids :can_write)
(assoc :table target-table))))
(api.macros/defendpoint :get "/run"

View File

@@ -3,7 +3,9 @@
[clojure.set :as set]
[medley.core :as m]
[metabase-enterprise.transforms.models.transform-run :as transform-run]
[metabase.api.common :as api]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[methodical.core :as methodical]
[toucan2.core :as t2]))
@@ -11,7 +13,7 @@
(methodical/defmethod t2/table-name :model/Transform [_model] :transform)
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped?]]
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped? :hook/git-sync-protected :hook/branchable]]
(derive :model/Transform trait))
(t2/deftransforms :model/Transform
@@ -109,3 +111,61 @@
{:transform_id transform-id
:tag_id tag-id
:position (get new-positions tag-id)})))))))
;;; ------------------------------------------------- Serialization --------------------------------------------------
(defmethod serdes/hash-fields :model/Transform
[_transform]
[:name :created_at])
(defn- extract-database-ids-from-target
"Extract database IDs from target configuration."
[target]
(when (map? target)
(cond
;; Direct database reference
(:database target) #{(:database target)}
;; Nested database references
(:databases target) (set (:databases target))
;; Other potential patterns - extend as needed
:else #{})))
(defn- transform-target-export
"Transform target JSON for export, replacing database IDs with portable names."
[target]
(when target
(let [export-db-fk (:export (serdes/fk :model/Database :name))]
(cond-> target
(:database target) (update :database export-db-fk)
(:databases target) (update :databases #(mapv export-db-fk %))))))
(defn- transform-target-import
"Transform target JSON for import, replacing portable names with database IDs."
[target]
(when target
(let [import-db-fk (:import (serdes/fk :model/Database :name))]
(cond-> target
(:database target) (update :database import-db-fk)
(:databases target) (update :databases #(mapv import-db-fk %))))))
(defmethod serdes/make-spec "Transform"
[_model-name opts]
{:copy [:name :description :entity_id]
:skip [:synced_to_source_of_truth]
:transform
{:created_at (serdes/date)
:updated_at (serdes/date)
:source {:export serdes/export-mbql :import serdes/import-mbql}
:target {:export transform-target-export
:import transform-target-import}
:tag_associations (serdes/nested :model/TransformTransformTag :transform_id opts)}})
(defmethod serdes/dependencies "Transform"
[{:keys [source target]}]
(set (serdes/mbql-deps source)))
(defmethod serdes/descendants "Transform"
[_model-name id]
;; Include the junction table records that link this transform to its tags
(into {} (for [junction-id (t2/select-pks-set :model/TransformTransformTag :transform_id id)]
{["TransformTransformTag" junction-id] {"Transform" id}})))

View File

@@ -4,6 +4,7 @@
[medley.core :as m]
[metabase-enterprise.transforms.models.job-run :as transforms.job-run]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[metabase.util.i18n :as i18n]
[methodical.core :as methodical]
[toucan2.core :as t2]))
@@ -122,3 +123,28 @@
(t2/changes job)) ;; user edits
(update :name str) ;; convert deferred to strings
(update :description str))))
;;; ------------------------------------------------- Serialization --------------------------------------------------
(defmethod serdes/hash-fields :model/TransformJob
[_transform-job]
[:name :created_at])
(defmethod serdes/make-spec "TransformJob"
[_model-name opts]
{:copy [:name :description :schedule :entity_id :built_in_type]
:skip []
:transform {:created_at (serdes/date)
:updated_at (serdes/date)
:tag_associations (serdes/nested :model/TransformJobTransformTag :job_id opts)}})
(defmethod serdes/dependencies "TransformJob"
[_ingested]
;; No direct dependencies - TransformTags will be loaded via the junction table dependencies
#{})
(defmethod serdes/descendants "TransformJob"
[_model-name id]
;; Include the junction table records that link this job to its tags
(into {} (for [junction-id (t2/select-pks-set :model/TransformJobTransformTag :job_id id)]
{["TransformJobTransformTag" junction-id] {"TransformJob" id}})))

View File

@@ -1,5 +1,6 @@
(ns metabase-enterprise.transforms.models.transform-job-transform-tag
(:require
[metabase.models.serialization :as serdes]
[methodical.core :as methodical]
[toucan2.core :as t2]))
@@ -10,3 +11,32 @@
(doto :model/TransformJobTransformTag
(derive :metabase/model)
(derive :hook/entity-id))
;;; ------------------------------------------------- Serialization --------------------------------------------------
(defmethod serdes/hash-fields :model/TransformJobTransformTag
[_junction]
[:job_id :tag_id :position])
(defmethod serdes/generate-path "TransformJobTransformTag"
[_ _junction]
;; Junction tables are inlined into their parent TransformJob, so no independent path
nil)
(defmethod serdes/make-spec "TransformJobTransformTag"
[_model-name _opts]
{:copy [:entity_id :position]
:skip []
:transform {:job_id (serdes/parent-ref)
:tag_id (serdes/fk :model/TransformTag)}})
(defmethod serdes/dependencies "TransformJobTransformTag"
[{:keys [tag_id]}]
;; Depends on the TransformTag being loaded first
(when tag_id
#{[{:model "TransformTag" :id tag_id}]}))
(defmethod serdes/descendants "TransformJobTransformTag"
[_model-name _id]
;; Junction tables don't contain other entities
{})

View File

@@ -1,5 +1,6 @@
(ns metabase-enterprise.transforms.models.transform-tag
(:require
[metabase.models.serialization :as serdes]
[metabase.util.i18n :as i18n]
[methodical.core :as methodical]
[toucan2.core :as t2]))
@@ -41,3 +42,23 @@
{:built_in_type nil} ;; never translate this again
(t2/changes tag)) ;; include user edits
(update :name str)))) ;; convert deferred to string
;;; ------------------------------------------------- Serialization --------------------------------------------------
(defmethod serdes/hash-fields :model/TransformTag
[_transform-tag]
[:name :created_at])
(defmethod serdes/make-spec "TransformTag"
[_model-name _opts]
{:copy [:name :entity_id :built_in_type]
:skip []
:transform {:created_at (serdes/date)
:updated_at (serdes/date)}})
(defmethod serdes/dependencies "TransformTag"
[_ingested]
#{})
(defmethod serdes/descendants "TransformTag"
[_model-name _id]
{})

View File

@@ -1,5 +1,6 @@
(ns metabase-enterprise.transforms.models.transform-transform-tag
(:require
[metabase.models.serialization :as serdes]
[methodical.core :as methodical]
[toucan2.core :as t2]))
@@ -10,3 +11,32 @@
(doto :model/TransformTransformTag
(derive :metabase/model)
(derive :hook/entity-id))
;;; ------------------------------------------------- Serialization --------------------------------------------------
(defmethod serdes/hash-fields :model/TransformTransformTag
[_junction]
[:transform_id :tag_id :position])
(defmethod serdes/generate-path "TransformTransformTag"
[_ _junction]
;; Junction tables are inlined into their parent Transform, so no independent path
nil)
(defmethod serdes/make-spec "TransformTransformTag"
[_model-name _opts]
{:copy [:entity_id :position]
:skip []
:transform {:transform_id (serdes/parent-ref)
:tag_id (serdes/fk :model/TransformTag)}})
(defmethod serdes/dependencies "TransformTransformTag"
[{:keys [tag_id]}]
;; Depends on the TransformTag being loaded first
(when tag_id
#{[{:model "TransformTag" :id tag_id}]}))
(defmethod serdes/descendants "TransformTransformTag"
[_model-name _id]
;; Junction tables don't contain other entities
{})

View File

@@ -0,0 +1,266 @@
(ns metabase-enterprise.branching.api-test
"Tests for /api/ee/branch/ endpoints"
(:require
[clojure.set :as set]
[clojure.test :refer :all]
[metabase.api.response :as api.response]
[metabase.test :as mt]
[toucan2.core :as t2]))
;;; ------------------------------------------------- Test Fixtures -------------------------------------------------
(defn- cleanup-test-branches!
"Delete all test branches created during tests."
[]
(t2/delete! :model/Branch))
;;; ------------------------------------------------- GET /api/ee/branch/ Tests -------------------------------------------------
(deftest list-branches-test
(testing "GET /api/ee/branch/"
(mt/with-premium-features #{:branching}
(mt/with-temp [:model/Branch {branch1-id :id} {:name "Main Branch"}
:model/Branch _ {:name "Feature Branch" :parent_branch_id branch1-id}
:model/Branch _ {:name "Another Root Branch"}]
(testing "Lists all branches without filter"
(let [response (->> (mt/user-http-request :rasta :get 200 "ee/branch")
:data
(map (juxt :name :parent_branch_id))
set)]
(is (set/subset? #{["Another Root Branch" nil]
["Feature Branch" branch1-id]
["Main Branch" nil]} response))))
(testing "Filters branches by parent_id"
(let [response (mt/user-http-request :rasta :get 200 (str "ee/branch?parent_id=" branch1-id))]
(is (= 1 (count (:data response))))
(is (= "Feature Branch" (-> response :data first :name)))
(is (= branch1-id (-> response :data first :parent_branch_id)))))
(testing "Returns empty list for non-existent parent_id"
(let [response (mt/user-http-request :rasta :get 200 "ee/branch?parent_id=99999")]
(is (= 0 (count (:data response))))))
(testing "Filters for root branches when parent_id is -1'"
(let [response (->> (mt/user-http-request :rasta :get 200 "ee/branch?parent_id=-1")
:data
(map :name)
set)]
(is (set/subset? #{"Main Branch" "Another Root Branch"} response))
(is (not (contains? response "Feature Branch")))))))))
(deftest list-branches-authentication-test
(testing "GET /api/ee/branch/ requires authentication"
(mt/with-premium-features #{:branching}
(is (= (get api.response/response-unauthentic :body)
(mt/client :get 401 "ee/branch"))))))
(deftest list-branches-premium-feature-test
(testing "GET /api/ee/branch/ requires branching premium feature"
(mt/with-premium-features #{}
(mt/with-temp [:model/Branch _ {:name "Test Branch"}]
;; Should return 402 Payment Required when premium feature is not enabled
(mt/user-http-request :rasta :get 402 "ee/branch")))))
;;; ------------------------------------------------- GET /api/ee/branch/:id Tests -------------------------------------------------
(deftest get-branch-test
(testing "GET /api/ee/branch/:id"
(mt/with-premium-features #{:branching}
(mt/with-temp [:model/Branch {branch-id :id} {:name "Test Branch"}]
(testing "Returns branch by ID"
(let [response (mt/user-http-request :rasta :get 200 (str "ee/branch/" branch-id))]
(is (= branch-id (:id response)))
(is (= "Test Branch" (:name response)))
(is (= "test_branch" (:slug response)))
(is (= (mt/user->id :rasta) (:creator_id response)))
(is (nil? (:parent_branch_id response)))
(is (contains? response :created_at))
(is (contains? response :updated_at))))
(testing "Returns 404 for non-existent branch ID"
(mt/user-http-request :rasta :get 404 "ee/branch/99999"))))))
(deftest get-branch-authentication-test
(testing "GET /api/ee/branch/:id requires authentication"
(mt/with-premium-features #{:branching}
(is (= (get api.response/response-unauthentic :body)
(mt/client :get 401 "ee/branch/1"))))))
(deftest get-branch-premium-feature-test
(testing "GET /api/ee/branch/:id requires branching premium feature"
(mt/with-premium-features #{}
(mt/with-temp [:model/Branch {branch-id :id} {:name "Test Branch"}]
;; Should return 402 Payment Required when premium feature is not enabled
(mt/user-http-request :rasta :get 402 (str "ee/branch/" branch-id))))))
;;; ------------------------------------------------- POST /api/ee/branch/ Tests -------------------------------------------------
(deftest create-branch-test
(testing "POST /api/ee/branch/"
(mt/with-premium-features #{:branching}
(mt/with-model-cleanup [:model/Branch]
(testing "Creates branch with valid data"
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
{:name "New Branch"
:description "A test branch"})]
(is (number? (:id response)))
(is (= "New Branch" (:name response)))
(is (= "new_branch" (:slug response)))
(is (= (mt/user->id :rasta) (:creator_id response)))
(is (nil? (:parent_branch_id response)))
(is (contains? response :created_at))
(is (contains? response :updated_at))))
(testing "Creates child branch with valid parent_branch_id"
(mt/with-temp [:model/Branch {parent-id :id} {:name "Parent Branch"}]
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
{:name "Child Branch"
:parent_branch_id parent-id})]
(is (= parent-id (:parent_branch_id response)))
(is (= "Child Branch" (:name response))))))
(testing "Creates branch without description (optional field)"
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
{:name "Minimal Branch"})]
(is (= "Minimal Branch" (:name response)))
(is (nil? (:description response)))))))))
(deftest create-branch-validation-test
(testing "POST /api/ee/branch/ input validation"
(mt/with-premium-features #{:branching}
(mt/with-model-cleanup [:model/Branch]
(testing "Rejects request with missing name"
(mt/user-http-request :rasta :post 400 "ee/branch" {}))
(testing "Rejects request with blank name"
(mt/user-http-request :rasta :post 400 "ee/branch" {:name ""}))
(testing "Rejects request with invalid parent_branch_id"
(mt/user-http-request :rasta :post 400 "ee/branch"
{:name "Invalid Parent Branch"
:parent_branch_id 99999}))
(testing "Rejects request with non-integer parent_branch_id"
(mt/user-http-request :rasta :post 400 "ee/branch"
{:name "Invalid Parent Type"
:parent_branch_id "not-a-number"}))))))
(deftest create-branch-authentication-test
(testing "POST /api/ee/branch/ requires authentication"
(mt/with-premium-features #{:branching}
(is (= (get api.response/response-unauthentic :body)
(mt/client :post 401 "ee/branch" {:name "Test Branch"}))))))
(deftest create-branch-premium-feature-test
(testing "POST /api/ee/branch/ requires branching premium feature"
(mt/with-premium-features #{}
;; Should return 402 Payment Required when premium feature is not enabled
(mt/user-http-request :rasta :post 402 "ee/branch" {:name "Test Branch"}))))
;;; ------------------------------------------------- DELETE /api/ee/branch/:id Tests -------------------------------------------------
(deftest delete-branch-test
(testing "DELETE /api/ee/branch/:id"
(mt/with-premium-features #{:branching}
(mt/with-model-cleanup [:model/Branch]
(testing "Deletes branch without children"
(mt/with-temp [:model/Branch {branch-id :id} {:name "Branch to Delete"}]
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" branch-id))
(is (nil? (t2/select-one :model/Branch :id branch-id)))))
(testing "Returns 404 when trying to delete non-existent branch"
(mt/user-http-request :rasta :delete 404 "ee/branch/99999"))))))
(deftest delete-branch-with-children-test
(testing "DELETE /api/ee/branch/:id prevents deletion of branch with children"
(mt/with-premium-features #{:branching}
(mt/with-model-cleanup [:model/Branch]
(mt/with-temp [:model/Branch {parent-id :id} {:name "Parent Branch"}
:model/Branch _child-branch {:name "Child Branch" :parent_branch_id parent-id}]
(testing "Returns 400 when trying to delete branch with children"
(mt/user-http-request :rasta :delete 400 (str "ee/branch/" parent-id)))
(testing "Parent branch still exists after failed deletion attempt"
(is (some? (t2/select-one :model/Branch :id parent-id)))))))))
(deftest delete-branch-authentication-test
(testing "DELETE /api/ee/branch/:id requires authentication"
(mt/with-premium-features #{:branching}
(is (= (get api.response/response-unauthentic :body)
(mt/client :delete 401 "ee/branch/1"))))))
(comment
(deftest delete-branch-premium-feature-test
(testing "DELETE /api/ee/branch/:id requires branching premium feature"
(mt/with-premium-features #{}
(mt/with-temp [:model/Branch {branch-id :id} {:name "Test Branch"}]
;; Should return 402 Payment Required when premium feature is not enabled
(mt/user-http-request :rasta :delete 402 (str "ee/branch/" branch-id)))))))
;;; ------------------------------------------------- Edge Cases and Error Handling -------------------------------------------------
(deftest branch-hierarchy-test
(testing "Complex branch hierarchy operations"
(mt/with-premium-features #{:branching}
(mt/with-temp [:model/Branch {root-id :id} {:name "Root"}
:model/Branch {level1-id :id} {:name "Level 1" :parent_branch_id root-id}
:model/Branch {level2-id :id} {:name "Level 2" :parent_branch_id level1-id}]
(testing "Can delete leaf node (deepest branch)"
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" level2-id))
(is (nil? (t2/select-one :model/Branch :id level2-id))))
(testing "Can delete middle node after removing children"
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" level1-id))
(is (nil? (t2/select-one :model/Branch :id level1-id))))
(testing "Can delete root node after removing all children"
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" root-id))
(is (nil? (t2/select-one :model/Branch :id root-id))))))))
(deftest parameter-validation-edge-cases-test
(testing "Parameter validation edge cases"
(mt/with-premium-features #{:branching}
(mt/with-model-cleanup [:model/Branch]
(comment
(testing "GET /api/ee/branch/ with invalid parent_id type"
;; Should handle gracefully - invalid integer should return 400
(mt/user-http-request :rasta :get 400 "ee/branch" {:parent_id "invalid"})))
(testing "GET /api/ee/branch/:id with invalid ID format"
;; Should handle gracefully - invalid integer should return 400
(mt/user-http-request :rasta :get 404 "ee/branch/invalid-id"))
(testing "POST /api/ee/branch/ with extra unexpected fields"
;; Should ignore extra fields and create branch successfully
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
{:name "Test Branch"
:unexpected_field "should be ignored"})]
(is (= "Test Branch" (:name response)))
(is (not (contains? response :unexpected_field)))))))))
(deftest concurrent-operations-test
(testing "Concurrent branch operations"
(mt/with-premium-features #{:branching}
(mt/with-temp [:model/Branch {parent-id :id} {:name "Parent"}
:model/Branch {child-id :id} {:name "Child" :parent_branch_id parent-id}]
(testing "Multiple child branch creation for same parent"
(let [responses (doall
(pmap (fn [i]
(mt/user-http-request :rasta :post 200 "ee/branch"
{:name (str "Concurrent Child " i)
:parent_branch_id parent-id}))
(range 5)))]
(is (= 5 (count responses)))
(is (every? #(= parent-id (:parent_branch_id %)) responses))
(is (= 5 (count (distinct (map :id responses)))))))
(testing "Cannot delete parent while children exist (race condition protection)"
(mt/user-http-request :rasta :delete 400 (str "ee/branch/" parent-id)))))))

View File

@@ -0,0 +1,85 @@
(ns metabase-enterprise.branching.hook-test
(:require
[clojure.test :refer :all]
[metabase.branching.core :as branching]
[metabase.test :as mt]
[metabase.util.string :as string]
[toucan2.core :as t2]))
(deftest insert-cards
(let [branch1-name (str "branch1-" (string/random-string 5))
branch2-name (str "branch2-" (string/random-string 5))
branch1-id (t2/insert-returning-pk! :model/Branch {:name branch1-name})
_branch2-id (t2/insert-returning-pk! :model/Branch {:name branch2-name})]
(testing "No branch set"
(let [no-branch-name (str "no-branch-" (string/random-string 5))
no-branch-id (t2/insert-returning-pk! :model/Card {:name no-branch-name
:display "table"
:dataset_query "{}"
:visualization_settings "{}"
:creator_id (mt/user->id :rasta)
:database_id 1})]
(is (= no-branch-name (:name (t2/select-one (t2/table-name :model/Card) no-branch-id))))
(is (false? (t2/exists? :model/BranchModelMapping :original_id no-branch-id)))
(testing "Can see the no-branch from main"
(is (= no-branch-id (:id (t2/select-one :model/Card :name no-branch-name)))))
(testing "Can see the no-branch from another branch"
(branching/with-current-branch-var branch1-name
(is (= no-branch-id (:id (t2/select-one :model/Card :name no-branch-name))))))
(testing "Inserting into a branch"
(branching/with-current-branch-var branch1-name
(let [card1-id (t2/insert-returning-pk! :model/Card {:name "Branch1 Card"
:display "table"
:dataset_query "{}"
:visualization_settings "{}"
:creator_id (mt/user->id :rasta)
:database_id 1})]
(is (= "Branch1 Card" (:name (t2/select-one (t2/table-name :model/Card) card1-id))))
(is (= [[branch1-id card1-id]] (t2/select-fn-vec (juxt :branch_id :original_id) :model/BranchModelMapping :original_id card1-id)))
(testing "Can select it out while in the branch"
(is (= no-branch-id (:id (t2/select-one :model/Card :name no-branch-name)))))
(testing "Can't select it from a different branch"
(branching/with-current-branch-var branch2-name
(is (= 0 (count (t2/select :model/Card :id card1-id)))))))))))))
(deftest update-cares
(let [branch1-name (str "branch1-" (string/random-string 5))
branch2-name (str "branch2-" (string/random-string 5))
branch1-id (t2/insert-returning-pk! :model/Branch {:name branch1-name})
_branch2-id (t2/insert-returning-pk! :model/Branch {:name branch2-name})
base-card-id (t2/insert-returning-pk! :model/Card {:name "Base Card Version"
:display "table"
:dataset_query "{}"
:visualization_settings "{}"
:creator_id (mt/user->id :rasta)
:database_id 1})]
(testing "No branch set updating"
(t2/update! (t2/table-name :model/Card) base-card-id {:name "Updated Base Card Version"})
(is (= "Updated Base Card Version" (:name (t2/select-one (t2/table-name :model/Card) base-card-id))))
(is (false? (t2/exists? :model/BranchModelMapping :original_id base-card-id)))
(testing "Can see the change on another branch which has no changes for it"
(branching/with-current-branch-var branch1-name
(is (= "Updated Base Card Version" (:name (t2/select-one :model/Card :id base-card-id))))))
(testing "Updating in a branch by pk"
(branching/with-current-branch-var branch1-name
(t2/update! :model/Card base-card-id {:name "Renamed in Branch 1"})
(is (= "Renamed in Branch 1" (:name (t2/select-one :model/Card :id base-card-id))))
(is (= [[branch1-id base-card-id]] (t2/select-fn-vec (juxt :branch_id :original_id) :model/BranchModelMapping :original_id base-card-id)))
(testing "main branch doesn't see the change"
(branching/with-current-branch-var nil
(is (= "Updated Base Card Version" (:name (t2/select-one :model/Card :id base-card-id))))))
(testing "other branch doesn't see the change"
(branching/with-current-branch-var branch2-name
(is (= "Updated Base Card Version" (:name (t2/select-one :model/Card :id base-card-id))))))
(testing "Can update by non-PK"
(t2/update! :model/Card :name "Renamed in Branch 1" {:name "Renamed by name in Branch 1"})
(is (= "Renamed by name in Branch 1" (:name (t2/select-one :model/Card :id base-card-id))))
(branching/with-current-branch-var nil
(is (= "Updated Base Card Version" (:name (t2/select-one :model/Card :id base-card-id)))))))))))

View File

@@ -0,0 +1,79 @@
import type {
CreateBranchRequest,
GitBranch,
GitDiff,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
import { idTag, invalidateTags } from "./tags";
export const gitSyncApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
importGit: builder.mutation({
query: () => ({
method: "POST",
url: "/api/ee/git-source-of-truth/import",
}),
}),
exportGit: builder.mutation({
query: () => ({
method: "POST",
url: "/api/ee/git-source-of-truth/export",
}),
}),
listGitBranches: builder.query<GitBranch[], void>({
query: () => ({
url: `/api/ee/branch/`,
method: "GET",
}),
transformResponse: (response: { data: GitBranch[] }) => response.data,
providesTags: ["git-branch"],
}),
getGitBranch: builder.query<GitBranch, number>({
query: (id) => ({
url: `/api/ee/branch/${id}`,
method: "GET",
}),
providesTags: (result, error, id) =>
invalidateTags(error, [idTag("git-branch", id)]),
}),
createGitBranch: builder.mutation<GitBranch, CreateBranchRequest>({
query: (body) => ({
url: `/api/ee/branch/`,
method: "POST",
body,
}),
invalidatesTags: ["git-branch"],
}),
deleteGitBranch: builder.mutation<void, number>({
query: (id) => ({
url: `/api/ee/branch/${id}`,
method: "DELETE",
}),
invalidatesTags: ["git-branch"],
}),
getGitDiff: builder.query<GitDiff[], { branchId: number }>({
query: ({ branchId }) => ({
url: `/api/ee/branch/${branchId}/diff`,
method: "GET",
}),
transformResponse: (response: { data: GitDiff[] }) => response.data,
providesTags: (result, error, { branchId }) =>
invalidateTags(error, [idTag("git-diff", branchId)]),
}),
}),
});
export const {
useImportGitMutation,
useExportGitMutation,
useListGitBranchesQuery,
useGetGitBranchQuery,
useCreateGitBranchMutation,
useDeleteGitBranchMutation,
useGetGitDiffQuery,
} = gitSyncApi;

View File

@@ -6,6 +6,7 @@ export * from "./billing-info";
export * from "./content-translation";
export * from "./db-routing";
export * from "./gdrive";
export * from "./git-sync";
export * from "./metabot";
export * from "./document";
export * from "./saml";

View File

@@ -20,6 +20,8 @@ export const ENTERPRISE_TAG_TYPES = [
"transform-tag",
"transform-job",
"transform-run",
"git-branch",
"git-diff",
] as const;
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];

View File

@@ -0,0 +1,40 @@
import { t } from "ttag";
import {
SettingsPageWrapper,
SettingsSection,
} from "metabase/admin/components/SettingsSection";
import { Table } from "metabase/common/components/Table";
import { Button } from "metabase/ui";
export const GitChangeList = () => {
return (
<SettingsPageWrapper title="Git changes">
<SettingsSection title="Pending changes">
<Table
rows={[
{ id: 1, change: "Added new dashboard", author: "Alice" },
{ id: 2, change: "Updated metric definition", author: "Bob" },
{ id: 3, change: "Removed segment", author: "Charlie" },
]}
columns={[
{ key: "author", name: "Author" },
{ key: "change", name: "Change" },
{ key: "actions", name: "" },
]}
rowRenderer={(row) => (
<tr>
<td style={{ padding: "8px 16px" }}>{row.author}</td>
<td style={{ padding: "8px 16px" }}>{row.change}</td>
<td style={{ padding: "8px 16px" }}>
<Button variant="filled" color="danger" size="compact-sm">
{t`Reject`}
</Button>
</td>
</tr>
)}
/>
</SettingsSection>
</SettingsPageWrapper>
);
};

View File

@@ -0,0 +1,132 @@
import { t } from "ttag";
import {
SettingsPageWrapper,
SettingsSection,
} from "metabase/admin/components/SettingsSection";
import { AdminSettingInput } from "metabase/admin/settings/components/widgets/AdminSettingInput";
import ActionButton from "metabase/common/components/ActionButton";
import { useSetting } from "metabase/common/hooks";
import { Box, Flex, Group, Icon, Stack, Text } from "metabase/ui";
import {
useExportGitMutation,
useImportGitMutation,
} from "metabase-enterprise/api";
export const GitSyncSettings = () => {
const [importGit, { isLoading: isImporting }] = useImportGitMutation();
const [exportGit, { isLoading: isExporting }] = useExportGitMutation();
const isLoading = isImporting || isExporting;
return (
<SettingsPageWrapper title={t`Git sync settings`}>
<SettingsSection
title={t`Configure git`}
description={
<Stack gap="xs">
<Text>
{/* eslint-disable-next-line */}
{t`Enable Git sync to back up and version control your Metabase library.`}
</Text>
<GitSyncStatus />
</Stack>
}
>
<AdminSettingInput
name="git-sync-url"
title={t`Git Url`}
inputType="text"
/>
<AdminSettingInput
name="git-sync-token"
title={t`Github PAT`}
inputType="password"
/>
<AdminSettingInput
name="git-sync-default-branch"
title={t`Default branch`}
inputType="text"
/>
</SettingsSection>
<SettingsSection
title={t`Sync control`}
description={t`Manually trigger a git sync to push any local changes to the remote repository and pull down any changes from the remote repository.`}
>
<AdminSettingInput
name="git-sync-read-only"
// eslint-disable-next-line
description={t`Make this instance read only, preventing any changes except when pulled from git.`}
title={t`Read only mode`}
inputType="boolean"
/>
<Flex gap="md" align="end">
<AdminSettingInput
name="git-sync-import-branch"
// eslint-disable-next-line
description={t`Metabase will pull in all content from this branch`}
title={t`Import branch`}
inputType="text"
w="20rem"
/>
<ActionButton
primary
actionFn={() => importGit({}).unwrap()}
variant="filled"
failedText={t`Sync failed`}
activeText={t`Syncing...`}
successText={t`Synced`}
useLoadingSpinner
disabled={isLoading}
>
<Group align="center" gap="sm">
<Icon name="download" />
{t`Import`}
</Group>
</ActionButton>
</Flex>
<Flex gap="md" align="end">
<AdminSettingInput
name="git-sync-export-branch"
// eslint-disable-next-line
description={t`Metabase will save all content to this branch`}
title={t`Export branch`}
inputType="text"
w="20rem"
/>
<ActionButton
primary
actionFn={() => exportGit({}).unwrap()}
variant="filled"
failedText={t`Sync failed`}
activeText={t`Syncing...`}
successText={t`Synced`}
useLoadingSpinner
disabled={isLoading}
>
<Group align="center" gap="sm">
<Icon name="upload" />
{t`Export`}
</Group>
</ActionButton>
</Flex>
</SettingsSection>
</SettingsPageWrapper>
);
};
const GitSyncStatus = () => {
const syncStatus = useSetting("git-sync-configured");
const color = syncStatus ? "success" : "error";
const message = syncStatus
? t`Git sync is configured.`
: t`Git sync is not configured.`;
return (
<Flex align={"center"}>
<Box bdrs="50%" h="14" w="14" mr="sm" bg={color} />
<Text c="text-medium">{message}</Text>
</Flex>
);
};

View File

@@ -0,0 +1,12 @@
import { t } from "ttag";
import { SettingsNavItem } from "metabase/admin/settings/components/SettingsNav";
export const LibraryNav = () => {
return (
<SettingsNavItem path="library" label={t`Library`} icon="folder">
<SettingsNavItem path="library/sync" label={t`Sync`} />
<SettingsNavItem path="library/changes" label={t`Changes`} />
</SettingsNavItem>
);
};

View File

@@ -0,0 +1,253 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { t } from "ttag";
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
import { Badge } from "metabase/common/components/Badge";
import { useUserSetting } from "metabase/common/hooks";
import { useSelector } from "metabase/lib/redux";
import {
ActionIcon,
Box,
Combobox,
Divider,
Group,
Icon,
Loader,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
UnstyledButton,
useCombobox,
} from "metabase/ui";
import {
useCreateGitBranchMutation,
useDeleteGitBranchMutation,
useListGitBranchesQuery,
} from "metabase-enterprise/api/git-sync";
import type { GitBranch } from "metabase-types/api";
interface SelectBranchProps {
disabled?: boolean;
}
export const SelectBranch = ({ disabled = false }: SelectBranchProps) => {
const [currentBranch, setCurrentBranch] = useUserSetting("git-branch", {
shouldDebounce: false,
});
const [searchValue, setSearchValue] = useState("");
const currentUser = useSelector(getCurrentUser);
const inputRef = useRef<HTMLInputElement>(null);
const { data: branches = [], isLoading } = useListGitBranchesQuery();
const defaultBranch = branches.find((b) => b.name === "main");
const displayBranch = currentBranch || defaultBranch?.name;
const [createBranch, { isLoading: isCreatingBranch }] =
useCreateGitBranchMutation();
const [deleteBranch, { isLoading: isDeletingBranch }] =
useDeleteGitBranchMutation();
const filteredBranches = useMemo(() => {
const availableBranches = branches.filter(
(branch) => branch.name !== displayBranch,
);
if (!searchValue) {
return availableBranches;
}
const search = searchValue.toLowerCase();
return availableBranches.filter((branch) =>
branch.name.toLowerCase().includes(search),
);
}, [branches, searchValue, displayBranch]);
const isExactMatch = useMemo(
() =>
filteredBranches.some(
(b) => b.name.toLowerCase() === searchValue.toLowerCase(),
),
[filteredBranches, searchValue],
);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearchValue("");
},
onDropdownOpen: () => {
setTimeout(() => {
inputRef.current?.focus();
}, 0);
},
});
const handleBranchSelect = useCallback(
(branch: GitBranch) => {
if (branch.name === displayBranch) {
combobox.closeDropdown();
return;
}
setCurrentBranch(branch.name);
combobox.closeDropdown();
setSearchValue("");
// Force page reload to clear all cached data
window.location.reload();
},
[displayBranch, setCurrentBranch, combobox],
);
const handleCreateAndSwitch = useCallback(
async (branchName: string) => {
try {
const result = await createBranch({
name: branchName.trim(),
}).unwrap();
handleBranchSelect(result);
} catch (error) {
console.error("Failed to create branch:", error);
}
},
[createBranch, handleBranchSelect],
);
const handleDeleteBranch = useCallback(
async (event: React.MouseEvent, branch: GitBranch) => {
event.stopPropagation();
if (branch.name === displayBranch) {
const defaultBranch = branches.find((b) => b.name === "main");
if (defaultBranch) {
setCurrentBranch(defaultBranch.name);
}
}
try {
await deleteBranch(branch.id).unwrap();
} catch (error) {
console.error("Failed to delete branch:", error);
}
},
[branches, deleteBranch, displayBranch, setCurrentBranch],
);
return (
<Combobox store={combobox} withinPortal position="bottom-end">
<Combobox.Target>
<UnstyledButton
onClick={() => combobox.toggleDropdown()}
disabled={disabled || isCreatingBranch}
style={{ minWidth: 0 }}
>
<Badge
icon={{ name: "git_branch", size: 14 }}
activeColor="text-white"
inactiveColor="text-white"
isSingleLine
>
{displayBranch}
{(isLoading || isCreatingBranch || isDeletingBranch) && (
<Loader size="xs" ml="xs" />
)}
</Badge>
</UnstyledButton>
</Combobox.Target>
<Combobox.Dropdown style={{ minWidth: 320 }}>
<Combobox.EventsTarget>
<Stack gap={0}>
<Box p="sm">
<TextInput
ref={inputRef}
placeholder={t`Find or create branch`}
value={searchValue}
onChange={(e) => setSearchValue(e.currentTarget.value)}
autoFocus
/>
</Box>
{(filteredBranches.length > 0 || searchValue) && <Divider />}
<ScrollArea.Autosize mah={300}>
<Stack gap={0} p="sm">
{defaultBranch && filteredBranches.includes(defaultBranch) && (
<>
<Combobox.Option
value={defaultBranch.name}
onClick={() => handleBranchSelect(defaultBranch)}
py="xs"
>
<Group justify="space-between" w="100%">
<Text>{defaultBranch.name}</Text>
</Group>
</Combobox.Option>
{filteredBranches.filter((b) => b !== defaultBranch)
.length > 0 && <Divider my="xs" />}
</>
)}
{filteredBranches
.filter((b) => b !== defaultBranch)
.map((branch) => {
const isAuthor = currentUser?.id === branch.creator_id;
const canDelete = isAuthor && branch.name !== displayBranch;
return (
<Combobox.Option
key={branch.id}
value={branch.name}
onClick={() => handleBranchSelect(branch)}
py="xs"
>
<Group justify="space-between" w="100%">
<Text>{branch.name}</Text>
{canDelete && (
<Tooltip label={t`Delete branch`}>
<ActionIcon
size="xs"
variant="subtle"
color="red"
onClick={(e) => handleDeleteBranch(e, branch)}
disabled={isDeletingBranch}
>
<Icon name="close" size={12} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Combobox.Option>
);
})}
{searchValue && !isExactMatch && (
<Combobox.Option
value="create-new"
onClick={() => handleCreateAndSwitch(searchValue)}
disabled={isCreatingBranch}
py="xs"
>
<Text c="brand">{t`Create "${searchValue}" off "main"`}</Text>
</Combobox.Option>
)}
{!searchValue && filteredBranches.length === 0 && (
<Box p="sm">
<Text size="md" c="text-medium" ta="center">
{branches.length === 0
? t`Type a name to create your first branch`
: t`No branches found`}
</Text>
</Box>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Combobox.EventsTarget>
</Combobox.Dropdown>
</Combobox>
);
};

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
import { t } from "ttag";
import { useUserSetting } from "metabase/common/hooks";
import { Icon, Tooltip, UnstyledButton } from "metabase/ui";
import { useListGitBranchesQuery } from "metabase-enterprise/api/git-sync";
import { ViewChangesModal } from "./ViewChangesModal";
export const ViewChangesButton = () => {
const [currentBranch] = useUserSetting("git-branch", {
shouldDebounce: false,
});
const [modalOpened, setModalOpened] = useState(false);
const { data: branches = [] } = useListGitBranchesQuery();
const defaultBranch = branches.find((b) => !b.parent_branch_id);
const displayBranch = currentBranch || defaultBranch?.name;
if (displayBranch === defaultBranch?.name) {
return null;
}
return (
<>
<Tooltip label={t`Compare changes`}>
<UnstyledButton
onClick={() => setModalOpened(true)}
style={{ minWidth: 0 }}
>
<Icon
name="git_compare"
size={16}
color="text-dark"
style={{ cursor: "pointer" }}
/>
</UnstyledButton>
</Tooltip>
<ViewChangesModal
opened={modalOpened}
onClose={() => setModalOpened(false)}
currentBranch={displayBranch}
/>
</>
);
};

View File

@@ -0,0 +1,254 @@
import { useState } from "react";
import { t } from "ttag";
import {
Badge,
Box,
Center,
Flex,
Group,
Icon,
Loader,
Modal,
ScrollArea,
Stack,
Text,
UnstyledButton,
} from "metabase/ui";
import {
useGetGitDiffQuery,
useListGitBranchesQuery,
} from "metabase-enterprise/api/git-sync";
import type { GitDiffContentType, GitDiffStatus } from "metabase-types/api";
import { DiffRenderer } from "./components/DiffRenderer";
import {
CardRenderer,
DefaultRenderer,
TransformRenderer,
} from "./components/EntityDiffRenderers";
interface ViewChangesModalProps {
opened: boolean;
onClose: () => void;
currentBranch: string;
}
export const ViewChangesModal = ({
opened,
onClose,
currentBranch,
}: ViewChangesModalProps) => {
const [selectedItem, setSelectedItem] = useState<number | null>(null);
const { data: branches = [] } = useListGitBranchesQuery();
const currentBranchObj = branches.find((b) => b.name === currentBranch);
const { data: diffs = [], isLoading } = useGetGitDiffQuery(
{ branchId: currentBranchObj?.id || 0 },
{ skip: !opened || !currentBranchObj },
);
const selectedDiff = diffs.find((d) => d.id === selectedItem) || diffs[0];
const getStatusText = (status: GitDiffStatus) => {
switch (status) {
case "added":
return "A";
case "modified":
return "M";
case "deleted":
return "D";
default:
return "?";
}
};
const getContentTypeLabel = (contentType: GitDiffContentType) => {
switch (contentType) {
case "card":
return t`Question`;
case "transform":
return t`Transform`;
default:
return contentType;
}
};
const getEntityRenderer = (contentType: GitDiffContentType) => {
switch (contentType) {
case "card":
return CardRenderer;
case "transform":
return TransformRenderer;
default:
return DefaultRenderer;
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t`Changes in ${currentBranch}`}
size="calc(100vw - 3rem)"
styles={{
content: {
height: "calc(100vh - 3rem)",
maxHeight: "calc(100vh - 3rem)",
display: "flex",
flexDirection: "column",
},
body: {
flex: 1,
padding: 0,
overflow: "hidden",
},
}}
>
{isLoading ? (
<Center h="100%">
<Stack align="center">
<Loader size="lg" />
<Text c="dimmed">{t`Loading changes...`}</Text>
</Stack>
</Center>
) : diffs.length === 0 ? (
<Center h="100%">
<Stack align="center" gap="xs">
<Icon name="git_compare" size={40} color="text-light" />
<Text fw={600} c="text-medium">
{t`No changes found`}
</Text>
<Text size="sm" c="text-medium" ta="center" maw="25rem">
{t`This branch has no changes compared to the main branch`}
</Text>
</Stack>
</Center>
) : (
<Flex h="100%" style={{ overflow: "hidden" }}>
<Box
w={300}
p="md"
bg="var(--mb-color-bg-light)"
style={{
borderRight: "1px solid var(--mb-color-border)",
}}
>
<Group mb="md" justify="space-between">
<Text fw={600}>{t`Content changed`}</Text>
<Text size="xs" c="text-medium">
{diffs.length} {diffs.length === 1 ? "item" : "items"}
</Text>
</Group>
<Stack gap={0}>
{diffs.map((diff) => (
<UnstyledButton
key={diff.id}
onClick={() => setSelectedItem(diff.id)}
p="sm"
bg={
selectedItem === diff.id ||
(!selectedItem && diff === selectedDiff)
? "var(--mb-color-bg-medium)"
: "transparent"
}
style={{
borderRadius: 0,
}}
styles={{
root: {
"&:hover": {
backgroundColor: "var(--mb-color-bg-medium)",
},
},
}}
>
<Group gap="xs" justify="space-between" w="100%">
<Box flex={1} miw={0}>
<Text size="md" truncate>
{diff.name}
</Text>
<Text size="xs" c="text-medium" truncate>
{getContentTypeLabel(diff.content_type)}
</Text>
</Box>
<Badge
size="xs"
bg={
diff.status === "added"
? "success"
: diff.status === "deleted"
? "danger"
: "brand"
}
c="white"
>
{getStatusText(diff.status)}
</Badge>
</Group>
</UnstyledButton>
))}
</Stack>
</Box>
<Box flex={1} style={{ overflow: "hidden" }}>
{selectedDiff ? (
<Stack h="100%" gap={0}>
<Box
p="md"
bg="var(--mb-color-bg-light)"
style={{
borderBottom: "1px solid var(--mb-color-border)",
}}
>
<Group justify="space-between">
<Group gap="xs">
<Text fw={600} size="sm">
{selectedDiff.name}
</Text>
<Badge
size="xs"
bg={
selectedDiff.status === "added"
? "success"
: selectedDiff.status === "deleted"
? "danger"
: "brand"
}
c="white"
>
{selectedDiff.status}
</Badge>
</Group>
<Text size="xs" c="text-medium">
{getContentTypeLabel(selectedDiff.content_type)}
</Text>
</Group>
</Box>
<ScrollArea flex={1}>
<DiffRenderer
diff={selectedDiff}
renderContent={(entity) => {
const Renderer = getEntityRenderer(
selectedDiff.content_type,
);
return <Renderer entity={entity} />;
}}
/>
</ScrollArea>
</Stack>
) : (
<Center h="100%">
<Text c="dimmed">{t`Select an item to view changes`}</Text>
</Center>
)}
</Box>
</Flex>
)}
</Modal>
);
};

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { t } from "ttag";
import { Box, Center, Flex, Stack, Text } from "metabase/ui";
import type { GitDiff } from "metabase-types/api";
interface DiffRendererProps {
diff: GitDiff;
renderContent: (entity: any, type: "original" | "current") => ReactNode;
height?: string | number;
}
export function DiffRenderer({
diff,
renderContent,
height = "400px",
}: DiffRendererProps) {
if (diff.status === "added" && diff.current) {
return (
<Stack gap="md" p="md">
{renderContent(diff.current, "current")}
</Stack>
);
}
if (diff.status === "deleted" && diff.original) {
return (
<Stack gap="md" p="md">
<Box opacity={0.7}>{renderContent(diff.original, "original")}</Box>
</Stack>
);
}
if (diff.status === "modified" && diff.original && diff.current) {
return (
<Box p="md" style={{ minHeight: height }}>
<Flex gap="md" h={height}>
<Box flex={1}>
<Text
fw={600}
c="text-medium"
size="sm"
mb="md"
>{t`Original`}</Text>
{renderContent(diff.original, "original")}
</Box>
<Box flex={1}>
<Text fw={600} c="brand" size="sm" mb="md">{t`Current`}</Text>
{renderContent(diff.current, "current")}
</Box>
</Flex>
</Box>
);
}
return (
<Center h={height}>
<Text c="text-medium" size="sm">{t`No content available`}</Text>
</Center>
);
}

View File

@@ -0,0 +1,176 @@
import { useEffect, useMemo, useState } from "react";
import { t } from "ttag";
import _ from "underscore";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { Notebook } from "metabase/querying/notebook/components/Notebook";
import { loadMetadataForCard } from "metabase/questions/actions";
import { getMetadata } from "metabase/selectors/metadata";
import { getSetting } from "metabase/selectors/settings";
import { Box, Center, Code, Loader, Text } from "metabase/ui";
import Question from "metabase-lib/v1/Question";
import { GitDiffVisualization } from "./GitDiffVisualization";
export const CardRenderer = ({ entity }: { entity: any }) => (
<Box
bg="var(--mb-color-bg-white)"
bd="1px solid var(--mb-color-border)"
bdrs="8px"
p="16px"
h="400px"
style={{ overflow: "auto" }}
>
<GitDiffVisualization card={entity} />
</Box>
);
export const TransformRenderer = ({ entity }: { entity: any }) => {
const dispatch = useDispatch();
const metadata = useSelector(getMetadata);
const reportTimezone = useSelector((state) =>
getSetting(state, "report-timezone-long"),
);
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
null,
);
const [isLoading, setIsLoading] = useState(true);
const transformCard = useMemo(() => {
if (!entity?.source?.query) {
return null;
}
return {
name: entity.name || "Transform",
dataset_query: entity.source.query,
display: "table" as const,
visualization_settings: {},
};
}, [entity]);
useEffect(() => {
if (!transformCard) {
setIsLoading(false);
return;
}
setIsLoading(true);
const cardForMetadata = _.omit(transformCard, "id");
dispatch(loadMetadataForCard(cardForMetadata)).then(() => {
setIsLoading(false);
});
}, [transformCard, dispatch]);
// Create question after metadata is loaded
useEffect(() => {
if (!isLoading && transformCard && metadata) {
try {
const baseQuestion = new Question(transformCard, metadata);
setModifiedQuestion(baseQuestion);
} catch (error) {
console.warn("Failed to create transform question:", error);
}
}
}, [isLoading, transformCard, metadata]);
// No-op function for read-only display
const handleUpdateQuestion = async (newQuestion: Question) => {
setModifiedQuestion(newQuestion);
};
const isNativeQuery = entity?.source?.query?.type === "native";
if (isNativeQuery) {
const sql = entity?.source?.query?.native?.query;
if (!sql) {
return null;
}
return (
<Code
block
bg="var(--mb-color-bg-white)"
bd="1px solid var(--mb-color-border)"
bdrs="8px"
p="16px"
h="100%"
style={{
overflow: "auto",
fontSize: "12px",
lineHeight: "1.5",
}}
>
{sql}
</Code>
);
}
if (isLoading) {
return (
<Center
bg="var(--mb-color-bg-white)"
bd="1px solid var(--mb-color-border)"
bdrs="8px"
p="16px"
h="100%"
style={{ overflow: "auto" }}
>
<Loader size="sm" />
</Center>
);
}
if (!modifiedQuestion) {
return (
<Center
bg="var(--mb-color-bg-white)"
bd="1px solid var(--mb-color-border)"
bdrs="8px"
p="16px"
h="100%"
style={{ overflow: "auto" }}
>
<Text c="text-medium" size="sm">
{t`Unable to load transform query`}
</Text>
</Center>
);
}
return (
<Box
bg="var(--mb-color-bg-white)"
bd="1px solid var(--mb-color-border)"
bdrs="8px"
p="16px"
h="100%"
style={{ overflow: "auto" }}
>
<Notebook
question={modifiedQuestion}
isDirty={false}
isRunnable={false}
isResultDirty={false}
reportTimezone={reportTimezone}
hasVisualizeButton={false}
updateQuestion={handleUpdateQuestion}
readOnly={true}
/>
</Box>
);
};
export const DefaultRenderer = ({ entity }: { entity: any }) => (
<Box
bg="var(--mb-color-bg-white)"
bd="1px solid var(--mb-color-border)"
bdrs="8px"
p="16px"
h="100%"
style={{ overflow: "auto" }}
>
<Text size="sm" c="text-medium">
{JSON.stringify(entity, null, 2)}
</Text>
</Box>
);

View File

@@ -0,0 +1,68 @@
import { t } from "ttag";
import { Center, Loader, Text } from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
import {
type GitDiffCard,
useGitDiffCardData,
} from "../hooks/useGitDiffCardData";
interface GitDiffVisualizationProps {
card: GitDiffCard;
height?: string | number;
}
export function GitDiffVisualization({
card,
height = "400px",
}: GitDiffVisualizationProps) {
const { series, isLoading, error, metadata, hasData } =
useGitDiffCardData(card);
if (isLoading) {
return (
<Center h={height}>
<Loader size="md" />
</Center>
);
}
if (error) {
return (
<Center h={height}>
<Text c="text-medium" size="sm">
{t`Failed to load visualization`}
</Text>
</Center>
);
}
// If we have data, render the full visualization
if (series && hasData) {
return (
<Visualization rawSeries={series} metadata={metadata} height={height} />
);
}
// Render a placeholder if no data is available
const placeholderCard = {
...card,
display: card.display || "table",
visualization_settings: card.visualization_settings || {},
};
return (
<Visualization
rawSeries={[
{
card: placeholderCard,
data: null,
},
]}
metadata={metadata}
isPlaceholder
height={height}
/>
);
}

View File

@@ -0,0 +1,81 @@
import { useMemo } from "react";
import { skipToken } from "metabase/api";
import { useGetAdhocQueryQuery } from "metabase/api/dataset";
import { useSelector } from "metabase/lib/redux";
import { getMetadata } from "metabase/selectors/metadata";
import type { RawSeries } from "metabase-types/api";
export interface GitDiffCard {
id?: number;
name?: string;
display?: string;
database_id?: number;
dataset_query?: any;
visualization_settings?: any;
result_metadata?: any;
}
export function useGitDiffCardData(gitCard: GitDiffCard | null | undefined) {
const metadata = useSelector(getMetadata);
const queryParams = useMemo(() => {
if (!gitCard?.dataset_query) {
return null;
}
const database =
gitCard.database_id || gitCard.dataset_query?.database || null;
if (!database) {
console.warn("No database ID found for git diff card:", gitCard);
return null;
}
return {
...gitCard.dataset_query,
database,
parameters: [],
};
}, [gitCard]);
const {
data: dataset,
isLoading,
error,
isFetching,
} = useGetAdhocQueryQuery(queryParams || skipToken);
const series: RawSeries | null = useMemo(() => {
if (!gitCard || !dataset?.data) {
return null;
}
const cardForVisualization = {
id: gitCard.id,
name: gitCard.name || "Untitled Question",
display: gitCard.display || "table",
visualization_settings: gitCard.visualization_settings || {},
dataset_query: gitCard.dataset_query,
database_id: gitCard.database_id,
result_metadata:
dataset.data?.results_metadata || gitCard.result_metadata,
};
return [
{
card: cardForVisualization,
started_at: dataset.started_at,
data: dataset.data,
},
];
}, [gitCard, dataset]);
return {
series,
isLoading: isLoading || isFetching,
error,
metadata,
hasData: !!dataset?.data,
};
}

View File

@@ -0,0 +1,18 @@
import { PLUGIN_GIT_SYNC } from "metabase/plugins";
import { GitChangeList } from "./GitChangeList";
import { GitSyncSettings } from "./GitSyncSettings";
import { LibraryNav } from "./LibraryNav";
import { SelectBranch } from "./SelectBranch";
import { ViewChangesButton } from "./ViewChangesButton";
// eslint-disable-next-line
if ("git-sync-enabled" > "FIXME add a real token feature") {
PLUGIN_GIT_SYNC.GitSyncSettings = GitSyncSettings;
PLUGIN_GIT_SYNC.LibraryNav = LibraryNav;
PLUGIN_GIT_SYNC.GitChangeList = GitChangeList;
PLUGIN_GIT_SYNC.SelectBranch = SelectBranch;
PLUGIN_GIT_SYNC.ViewChangesButton = ViewChangesButton;
}
export { SelectBranch, ViewChangesButton };

View File

@@ -15,6 +15,7 @@ import "./collections";
import "./content_translation";
import "./content_verification";
import "./database_routing";
import "./git_sync";
import "./whitelabel";
import "./embedding";
import "./embedding-sdk";

View File

@@ -0,0 +1,51 @@
export interface GitCommit {
sha: string;
message: string;
author: string;
date: string;
}
export interface GitBranch {
id: number;
name: string;
slug: string;
description?: string;
creator_id: number;
parent_branch_id?: number;
created_at: string;
updated_at: string;
}
export interface CreateBranchRequest {
name: string;
description?: string;
parent_branch_id?: number;
}
export interface SwitchBranchRequest {
branchName: string;
}
export interface GitSyncStatus {
currentBranch: string;
isDirty: boolean;
ahead: number;
behind: number;
}
export type GitDiffStatus = "added" | "modified" | "deleted";
export type GitDiffContentType =
| "card"
| "dashboard"
| "collection"
| "transform";
export interface GitDiff {
id: number;
name: string;
status: GitDiffStatus;
content_type: GitDiffContentType;
current?: any;
original?: any;
}

View File

@@ -20,6 +20,7 @@ export * from "./email";
export * from "./entity-id";
export * from "./field";
export * from "./geojson";
export * from "./git-sync";
export * from "./group";
export * from "./logger";
export * from "./metabot";

View File

@@ -559,6 +559,7 @@ export type UserSettings = {
"expand-browse-in-nav"?: boolean;
"expand-bookmarks-in-nav"?: boolean;
"browse-filter-only-verified-models"?: boolean;
"git-branch"?: string | null;
"browse-filter-only-verified-metrics"?: boolean;
"show-updated-permission-modal": boolean;
"show-updated-permission-banner": boolean;
@@ -619,9 +620,26 @@ export type DatabaseReplicationConnections = Record<
{ connection_id: string }
>;
export type SyncableEntity =
| "transform"
| "snippet"
| "dataset"
| "metric"
| "segment"
| "dashboard"
| "question";
export interface EnterpriseSettings extends Settings {
"application-colors"?: ColorSettings | null;
"application-logo-url"?: string;
"git-sync-token"?: string | null;
"git-sync-url"?: string | null;
"git-sync-default-branch"?: string | null;
"git-sync-import-branch"?: string | null; // import branch
"git-sync-export-branch"?: string | null;
"git-sync-entities"?: Partial<Record<SyncableEntity, boolean>> | null;
"git-sync-configured"?: boolean | null;
"git-sync-read-only"?: boolean;
"login-page-illustration"?: IllustrationSettingValue;
"login-page-illustration-custom"?: string;
"landing-page-illustration"?: IllustrationSettingValue;

View File

@@ -10,6 +10,7 @@ import {
import { UpsellGem } from "metabase/admin/upsells/components/UpsellGem";
import { useHasTokenFeature } from "metabase/common/hooks";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_GIT_SYNC } from "metabase/plugins";
import { getLocation } from "metabase/selectors/routing";
import { Divider, Flex } from "metabase/ui";
@@ -46,6 +47,7 @@ export function SettingsNav() {
{hasSaml && <SettingsNavItem path="authentication/saml" label="SAML" />}
{hasJwt && <SettingsNavItem path="authentication/jwt" label="JWT" />}
</SettingsNavItem>
<PLUGIN_GIT_SYNC.LibraryNav />
<NavDivider />
<SettingsNavItem path="email" label={t`Email`} icon="mail" />
<SettingsNavItem

View File

@@ -2,7 +2,11 @@ import { IndexRedirect, Route } from "react-router";
import { AdminSettingsLayout } from "metabase/common/components/AdminLayout/AdminSettingsLayout";
import { NotFound } from "metabase/common/components/ErrorPages";
import { PLUGIN_ADMIN_SETTINGS, PLUGIN_AUTH_PROVIDERS } from "metabase/plugins";
import {
PLUGIN_ADMIN_SETTINGS,
PLUGIN_AUTH_PROVIDERS,
PLUGIN_GIT_SYNC,
} from "metabase/plugins";
import { GoogleAuthForm } from "./settings/auth/components/GoogleAuthForm";
import {
@@ -60,6 +64,18 @@ export const getSettingsRoutes = () => (
path="authentication/jwt"
component={() => <PLUGIN_AUTH_PROVIDERS.SettingsJWTForm />}
/>
<Route
path="library"
component={() => <PLUGIN_GIT_SYNC.GitSyncSettings />}
/>
<Route
path="library/sync"
component={() => <PLUGIN_GIT_SYNC.GitSyncSettings />}
/>
<Route
path="library/changes"
component={() => <PLUGIN_GIT_SYNC.GitChangeList />}
/>
<Route path="maps" component={MapsSettingsPage} />
<Route path="localization" component={LocalizationSettingsPage} />
<Route path="uploads" component={UploadSettingsPage} />

View File

@@ -125,6 +125,7 @@ export default class ActionButton extends Component {
failedClassName,
forceActiveStyle,
children,
disabled,
...props
} = this.props;
const { active, result } = this.state;
@@ -146,6 +147,7 @@ export default class ActionButton extends Component {
})
}
onClick={this.onClick}
disabled={disabled}
>
{active ? (
useLoadingSpinner ? (

View File

@@ -24,7 +24,8 @@ export const AppBanner = () => {
const isAdmin = useSelector(getUserIsAdmin);
const isHosted = useSelector(getIsHosted);
const tokenStatus = useSetting("token-status");
const readOnly = useSetting("read-only-mode");
const migrateReadOnly = useSetting("read-only-mode");
const gitReadOnly = useSetting("git-sync-read-only");
const isDevMode = useSetting("development-mode?");
const { shouldShowLicenseTokenMissingBanner, dismissBanner } =
@@ -44,7 +45,7 @@ export const AppBanner = () => {
return isDevMode ? <DevModeBanner /> : null;
}
if (readOnly) {
if (migrateReadOnly || gitReadOnly) {
return <ReadOnlyBanner />;
}

View File

@@ -25,7 +25,6 @@
background: var(--mb-color-bg-white);
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-xl);
flex-shrink: 0;
cursor: pointer;
color: var(--mb-color-text-primary);
flex: 0 0 auto;

View File

@@ -88,13 +88,13 @@
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-md);
max-width: var(--max-width);
background: var(--mb-color-bg-white);
padding: var(--mantine-spacing-md);
}
.listEntityIcon {
color: var(--mb-color-text-light);
&:hover {
color: var(--mb-color-brand);
}

View File

@@ -8,9 +8,10 @@ import LogoIcon from "metabase/common/components/LogoIcon";
import CS from "metabase/css/core/index.css";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { useRegisterShortcut } from "metabase/palette/hooks/useRegisterShortcut";
import { PLUGIN_GIT_SYNC } from "metabase/plugins";
import { getIsPaidPlan } from "metabase/selectors/settings";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Button, Icon } from "metabase/ui";
import { Button, Flex, Group, Icon } from "metabase/ui";
import type { AdminPath } from "metabase-types/store";
import StoreLink from "../StoreLink";
@@ -42,6 +43,7 @@ export const AdminNavbar = ({
const isPaidPlan = useSelector(getIsPaidPlan);
const isAdmin = useSelector(getUserIsAdmin);
const dispatch = useDispatch();
const gitSyncConfigured = useSelector((_state) => true);
useRegisterShortcut(
[
@@ -91,11 +93,17 @@ export const AdminNavbar = ({
))}
</AdminNavbarItems>
{!isPaidPlan && isAdmin && <StoreLink />}
<AdminExitLink
to="/"
data-testid="exit-admin"
>{t`Exit admin`}</AdminExitLink>
<Flex ml="auto" align="center" gap="md">
<Group ml="auto" gap="sm" display="flex" align="center" wrap="nowrap">
{gitSyncConfigured && <PLUGIN_GIT_SYNC.SelectBranch />}
{gitSyncConfigured && <PLUGIN_GIT_SYNC.ViewChangesButton />}
</Group>
{!isPaidPlan && isAdmin && <StoreLink />}
<AdminExitLink
to="/"
data-testid="exit-admin"
>{t`Exit admin`}</AdminExitLink>
</Flex>
</MobileHide>
</AdminNavbarRoot>
);
@@ -108,7 +116,6 @@ interface AdminMobileNavbarProps {
const MobileNavbar = ({ adminPaths, currentPath }: AdminMobileNavbarProps) => {
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const ref = useClickOutside(() => setMobileNavOpen(false));
return (

View File

@@ -58,7 +58,6 @@ const AppBarLarge = ({
onLogout,
}: AppBarLargeProps): JSX.Element => {
const isNavBarVisible = isNavBarOpen && isNavBarEnabled;
return (
<AppBarRoot
hasSidebarOpen={

View File

@@ -1,14 +1,33 @@
import { t } from "ttag";
import { Banner } from "metabase/common/components/Banner";
import { useSetting } from "metabase/common/hooks";
import { Text } from "metabase/ui";
export const ReadOnlyBanner = () => {
const gitSyncConfigured = useSetting("git-sync-read-only");
if (gitSyncConfigured) {
return (
<Banner
bg="brand"
c="white"
body={
<Text fw="bold" c="white">
{/* eslint-disable-next-line no-literal-metabase-strings -- correct usage */}
{t`Content in this Metabase instance is managed by serious people and cannot be changed here.`}
</Text>
}
icon="info_filled"
/>
);
}
return (
<Banner
bg="warning"
body={
<Text fw="bold" color="text-dark">
<Text fw="bold" c="text-dark">
{/* eslint-disable-next-line no-literal-metabase-strings -- correct usage */}
{t`Metabase is under maintenance and is operating in read-only mode. It should only take up to 30 minutes.`}
</Text>

View File

@@ -152,7 +152,6 @@ export function MainNavbarView({
{t`Home`}
</PaddedSidebarLink>
</SidebarSection>
{shouldDisplayGettingStarted && (
<SidebarSection>
<ErrorBoundary>
@@ -177,7 +176,6 @@ export function MainNavbarView({
</ErrorBoundary>
</SidebarSection>
)}
{bookmarks.length > 0 && (
<SidebarSection>
<ErrorBoundary>
@@ -192,7 +190,6 @@ export function MainNavbarView({
</ErrorBoundary>
</SidebarSection>
)}
<SidebarSection>
<ErrorBoundary>
<CollectionSectionHeading
@@ -217,7 +214,6 @@ export function MainNavbarView({
)}
</ErrorBoundary>
</SidebarSection>
<SidebarSection>
<ErrorBoundary>
<BrowseNavSection
@@ -228,7 +224,6 @@ export function MainNavbarView({
/>
</ErrorBoundary>
</SidebarSection>
{trashCollection && (
<TrashSidebarSection>
<ErrorBoundary>

View File

@@ -849,3 +849,11 @@ export const PLUGIN_TRANSFORMS: TransformsPlugin = {
getAdminPaths: () => [],
getAdminRoutes: () => null,
};
export const PLUGIN_GIT_SYNC = {
LibraryNav: PluginPlaceholder,
GitSyncSettings: NotFoundPlaceholder,
GitChangeList: NotFoundPlaceholder,
SelectBranch: PluginPlaceholder as ComponentType<{ disabled?: boolean }>,
ViewChangesButton: PluginPlaceholder as ComponentType,
};

View File

@@ -0,0 +1,6 @@
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<line x1="4" x2="4" y1="2" y2="10" stroke-width="1.5"/>
<circle cx="12" cy="4" r="2" stroke-width="1.5"/>
<circle cx="4" cy="12" r="2" stroke-width="1.5"/>
<path d="M12 6a6 6 0 0 1-6 6" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="5" cy="6" r="3"/>
<path d="M12 6h5a2 2 0 0 1 2 2v7"/>
<path d="m15 9-3-3 3-3"/>
<circle cx="19" cy="18" r="3"/>
<path d="M12 18H7a2 2 0 0 1-2-2V9"/>
<path d="m9 15 3 3-3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

View File

@@ -178,6 +178,10 @@ import gear_settings_filled_component from "./gear_settings_filled.svg?component
import gear_settings_filled_source from "./gear_settings_filled.svg?source";
import gem_component from "./gem.svg?component";
import gem_source from "./gem.svg?source";
import git_branch_component from "./git_branch.svg?component";
import git_branch_source from "./git_branch.svg?source";
import git_compare_component from "./git_compare.svg?component";
import git_compare_source from "./git_compare.svg?source";
import globe_component from "./globe.svg?component";
import globe_source from "./globe.svg?source";
import google_component from "./google.svg?component";
@@ -817,6 +821,14 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
component: gem_component,
source: gem_source,
},
git_branch: {
component: git_branch_component,
source: git_branch_source,
},
git_compare: {
component: git_compare_component,
source: git_compare_source,
},
globe: {
component: globe_component,
source: globe_source,
@@ -1502,6 +1514,8 @@ export type IconName =
| "gear"
| "gear_settings_filled"
| "gem"
| "git_branch"
| "git_compare"
| "globe"
| "grabber"
| "grid"

View File

@@ -1083,6 +1083,233 @@ databaseChangeLog:
- customChange:
class: "metabase.app_db.custom_migrations.MigrateClickHouseDetailsToMultiDB"
- changeSet:
id: v57.2025-08-25T10:00:00
author: edpaget
comment: Create branch table
changes:
- createTable:
tableName: branch
remarks: Store branches for branched modeling
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(254)
constraints:
nullable: false
- column:
name: slug
type: varchar(254)
constraints:
nullable: false
- column:
name: created_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
name: updated_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
name: creator_id
type: int
constraints:
nullable: true
- column:
name: parent_branch_id
type: bigint
constraints:
nullable: true
- column:
name: is_main
type: ${boolean.type}
defaultValueBoolean: false
constraints:
nullable: true
- changeSet:
id: v57.2025-08-25T10:00:10
author: edpaget
comment: Add foreign key constraint for branch.creator_id
changes:
- addForeignKeyConstraint:
baseTableName: branch
baseColumnNames: creator_id
referencedTableName: core_user
referencedColumnNames: id
constraintName: fk_branch_creator_id
onDelete: SET NULL
- changeSet:
id: v57.2025-08-25T10:00:20
author: edpaget
comment: Add foreign key constraint for branch.parent_branch_id
changes:
- addForeignKeyConstraint:
baseTableName: branch
baseColumnNames: parent_branch_id
referencedTableName: branch
referencedColumnNames: id
constraintName: fk_branch_parent_branch_id
onDelete: SET NULL
- changeSet:
id: v57.2025-08-25T10:00:30
author: edpaget
comment: Add index on branch.creator_id
changes:
- createIndex:
tableName: branch
indexName: idx_branch_creator_id
columns:
- column:
name: creator_id
- changeSet:
id: v57.2025-08-25T10:00:40
author: edpaget
comment: Add index on branch.parent_branch_id
changes:
- createIndex:
tableName: branch
indexName: idx_branch_parent_branch_id
columns:
- column:
name: parent_branch_id
- changeSet:
id: v57.2025-08-25T10:01:00
author: edpaget
comment: Create branch_model_mapping table
changes:
- createTable:
tableName: branch_model_mapping
remarks: Maps original models to branched models
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: original_id
type: int
constraints:
nullable: false
- column:
name: branched_model_id
type: int
constraints:
nullable: false
- column:
name: branch_id
type: bigint
constraints:
nullable: false
- column:
name: model_type
type: varchar(50)
constraints:
nullable: false
- column:
name: file_path
type: ${text.type}
constraints:
nullable: true
- column:
name: is_dirty
type: ${boolean.type}
defaultValueBoolean: false
constraints:
nullable: true
- column:
name: last_synced_at
type: ${timestamp_type}
constraints:
nullable: true
- column:
name: created_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
name: updated_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- changeSet:
id: v57.2025-08-25T10:01:10
author: edpaget
comment: Add foreign key constraint for branch_model_mapping.branch_id
changes:
- addForeignKeyConstraint:
baseTableName: branch_model_mapping
baseColumnNames: branch_id
referencedTableName: branch
referencedColumnNames: id
constraintName: fk_branch_model_mapping_branch_id
onDelete: CASCADE
- changeSet:
id: v57.2025-08-25T10:01:20
author: edpaget
comment: Add unique index on branch_model_mapping
changes:
- addUniqueConstraint:
tableName: branch_model_mapping
columnNames: original_id, model_type, branch_id
constraintName: idx_branch_model_mapping_unique
- changeSet:
id: v57.2025-08-25T10:01:30
author: edpaget
comment: Add index on branch_model_mapping for branch_id and model_type
changes:
- createIndex:
tableName: branch_model_mapping
indexName: idx_branch_model_mapping_branch_model
columns:
- column:
name: branch_id
- column:
name: model_type
- changeSet:
id: v57.2025-08-25T10:01:40
author: edpaget
comment: Add index on branch_model_mapping.branched_model_id
changes:
- createIndex:
tableName: branch_model_mapping
indexName: idx_branch_model_mapping_branched_model
columns:
- column:
name: branched_model_id
- changeSet:
id: v57.2025-08-29T04:40:42
author: nvoxland
comment: Drop unique constraint for entity id that can now be duplicated
changes:
- customChange:
class: "metabase.app_db.custom_migrations.DropUniqueConstraint" ## TODO: This should be rewritten to take tablename as an arg
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
########################################################################################################################

View File

@@ -188,6 +188,31 @@
:where [:or [:like :object (h2x/literal "/data/db/%")]
[:like :object (h2x/literal "/query/db/%")]]}))
(define-migration DropUniqueConstraint
(case (mdb.connection/db-type)
:h2
(let [constraint-name (-> (t2/query {:select [:CONSTRAINT_NAME]
:from [:INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE]
:where [:and
[:= :TABLE_NAME "TRANSFORM"]
[:= :COLUMN_NAME "ENTITY_ID"]]})
first
:constraint_name)]
(t2/query [(str "ALTER TABLE TRANSFORM DROP CONSTRAINT " constraint-name)]))
:postgres
(let [constraint-name (-> (t2/query {:select [:constraint_name]
:from [:information_schema.constraint_column_usage]
:where [:and
[:= :table_name "transform"]
[:= :column_name "entity_id"]]})
first
:constraint_name)]
(t2/query [(str "ALTER TABLE TRANSFORM DROP CONSTRAINT " constraint-name)]))
:else
(throw (ex-info "Unsupported appdb type" {})))
(log/info "Dropped unique constraint on transform.entity_id"))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Quartz Scheduler Helpers |
;;; +----------------------------------------------------------------------------------------------------------------+

View File

@@ -0,0 +1,44 @@
(ns metabase.branching.core
"Middleware that handles setting the current branch."
(:require
[metabase.premium-features.core :refer [defenterprise]]
[toucan2.core :as t2]))
(def ^:dynamic *current-branch*
"The current 'branch' for a request in Metabase. Some models support forking changes
into related groups of changes -- called a 'branch' -- that transparently replace the
original model when the branch is activated."
(delay nil))
(def ^:dynamic *enable-branch-hook*
"Controls whether the toucan pipeline hook is enabled, this exists so we can disable it
while running code like custom migrations that we do not want it enabled for."
true)
(defn do-with-current-branch-var
"Impl for with-current-branch-var"
[branch-name thunk]
(binding [*current-branch* (if branch-name (delay (t2/select-one :model/Branch :name branch-name)) (delay nil))]
(thunk)))
(defmacro with-current-branch-var
[branch & body]
`(do-with-current-branch-var ~branch (^:once fn* [] ~@body)))
(defenterprise get-current-branch
"Extract the current branch from the X-Metabase-Branch header used to extract
branched models from the common pk depending on the branch set.
In OSS this does not set the branch."
metabase-enterprise.branching.core
[_request]
nil)
(defn handle-current-branch
"Ring middleware to set the current branch using the `get-current-branch` function."
[handler]
(fn [request respond raise]
(if-let [current-branch (get-current-branch request)]
(with-current-branch-var current-branch
(handler (assoc request :current-branch current-branch) respond raise))
(handler request respond raise))))

View File

@@ -974,7 +974,7 @@
;; :total_count
:limit (if (zero? limit) 1 limit)
:offset offset))
rows (mdb/query limit-query)
rows (t2/query limit-query)
res {:total (->> rows first :total_count)
:data (if (= limit 0)
[]

View File

@@ -125,6 +125,7 @@
(doto :model/Collection
(derive :metabase/model)
(derive :hook/entity-id)
(derive :hook/git-sync-protected)
(derive ::mi/read-policy.full-perms-for-perms-set)
(derive ::mi/write-policy.full-perms-for-perms-set))

View File

@@ -7,6 +7,7 @@
[metabase.analytics.core :as analytics]
[metabase.api-routes.core :as api-routes]
[metabase.app-db.core :as mdb]
[metabase.branching.core :as branching]
[metabase.classloader.core :as classloader]
[metabase.cloud-migration.core :as cloud-migration]
[metabase.config.core :as config]
@@ -135,42 +136,43 @@
;; we have to skip creating sample content if we're running tests, because it causes some tests to timeout
;; and the test suite can take 2x longer. this is really unfortunate because it could lead to some false
;; negatives, but for now there's not much we can do
(mdb/setup-db! :create-sample-content? (not config/is-test?))
;; Disable read-only mode if its on during startup.
;; This can happen if a cloud migration process dies during h2 dump.
(when (cloud-migration/read-only-mode)
(cloud-migration/read-only-mode! false))
(init-status/set-progress! 0.4)
;; Set up Prometheus
(log/info "Setting up prometheus metrics")
(analytics/setup!)
(init-status/set-progress! 0.5)
(premium-features/airgap-check-user-count)
(init-status/set-progress! 0.55)
(task/init-scheduler!)
(analytics/add-listeners-to-scheduler!)
;; run a very quick check to see if we are doing a first time installation
;; the test we are using is if there is at least 1 User in the database
(let [new-install? (not (setup/has-user-setup))]
;; initialize Metabase from an `config.yml` file if present (Enterprise Edition™ only)
(config-from-file/init-from-file-if-code-available!)
(init-status/set-progress! 0.6)
(when new-install?
(log/info "Looks like this is a new installation ... preparing setup wizard")
;; create setup token
(create-setup-token-and-log-setup-url!)
;; publish install event
(events/publish-event! :event/install {}))
(init-status/set-progress! 0.7)
;; deal with our sample database as needed
(when (config/load-sample-content?)
(if new-install?
;; add the sample database DB for fresh installs
(sample-data/extract-and-sync-sample-database!)
;; otherwise update if appropriate
(sample-data/update-sample-database-if-needed!)))
(init-status/set-progress! 0.8))
(ensure-audit-db-installed!)
(binding [branching/*enable-branch-hook* false]
(mdb/setup-db! :create-sample-content? (not config/is-test?))
;; Disable read-only mode if its on during startup.
;; This can happen if a cloud migration process dies during h2 dump.
(when (cloud-migration/read-only-mode)
(cloud-migration/read-only-mode! false))
(init-status/set-progress! 0.4)
;; Set up Prometheus
(log/info "Setting up prometheus metrics")
(analytics/setup!)
(init-status/set-progress! 0.5)
(premium-features/airgap-check-user-count)
(init-status/set-progress! 0.55)
(task/init-scheduler!)
(analytics/add-listeners-to-scheduler!)
;; run a very quick check to see if we are doing a first time installation
;; the test we are using is if there is at least 1 User in the database
(let [new-install? (not (setup/has-user-setup))]
;; initialize Metabase from an `config.yml` file if present (Enterprise Edition™ only)
(config-from-file/init-from-file-if-code-available!)
(init-status/set-progress! 0.6)
(when new-install?
(log/info "Looks like this is a new installation ... preparing setup wizard")
;; create setup token
(create-setup-token-and-log-setup-url!)
;; publish install event
(events/publish-event! :event/install {}))
(init-status/set-progress! 0.7)
;; deal with our sample database as needed
(when (config/load-sample-content?)
(if new-install?
;; add the sample database DB for fresh installs
(sample-data/extract-and-sync-sample-database!)
;; otherwise update if appropriate
(sample-data/update-sample-database-if-needed!)))
(init-status/set-progress! 0.8))
(ensure-audit-db-installed!))
(notification/seed-notification!)
(init-status/set-progress! 0.9)

View File

@@ -0,0 +1,17 @@
(ns metabase.core.git-source-of-truth
(:require
[metabase.classloader.core :as classloader]
[metabase.util.log :as log]))
(defn initialize-from-git-if-available!
"Shim for running the git source of truth code, used by [[metabase.core.core]]. The git source of truth code only ships in
the Enterprise Edition™ JAR, so this checks whether the namespace exists, and if it does,
invokes [[metabase-enterprise.git-source-of-truth.core/initialize-from-git!]]; otherwise, this no-ops."
[]
(when (try
(classloader/require 'metabase-enterprise.git-source-of-truth.core)
:ok
(catch Throwable _
(log/debug "metabase-enterprise.git-source-of-truth.core not available; cannot initialize from git.")
nil))
((resolve 'metabase-enterprise.git-source-of-truth.core/initialize-from-git!))))

View File

@@ -40,7 +40,8 @@
(derive :metabase/model)
(derive :perms/use-parent-collection-perms)
(derive :hook/timestamped?)
(derive :hook/entity-id))
(derive :hook/entity-id)
(derive :hook/git-sync-protected))
(defmethod mi/can-write? :model/Dashboard
([instance]

View File

@@ -31,7 +31,9 @@
[toucan2.core :as t2]
[toucan2.model :as t2.model]
[toucan2.protocols :as t2.protocols]
[toucan2.tools.after :as t2.after]
[toucan2.tools.before-insert :as t2.before-insert]
[toucan2.tools.before-update :as t2.before-update]
[toucan2.tools.hydrate :as t2.hydrate]
[toucan2.tools.identity-query :as t2.identity-query]
[toucan2.util :as t2.u])
@@ -58,6 +60,10 @@
on a future deserialization."
false)
(def ^:dynamic *syncing-source-of-truth-entities*
"This is dynamically bound to the set of entities that we are syncing when syncing the source of truth."
#{})
(def ^{:arglists '([x & _args])} dispatch-on-model
"Helper dispatch function for multimethods. Dispatches on the first arg, using [[models.dispatch/model]]."
;; make sure model namespace gets loaded e.g. `:model/Database` should load `metabase.model.database` if needed.
@@ -579,9 +585,34 @@
(-> instance
add-entity-id))
(defn- prevent-sync-protected-writes
"Prevents writes to entities that are synced to source of truth, unless explicitly allowed."
[instance]
(when (and (try ((requiring-resolve 'metabase-enterprise.git-source-of-truth.settings/git-sync-read-only))
(catch Exception _ false))
(not *deserializing?*))
(throw (ex-info "Cannot modify entities synced to source of truth"
{:entity-id (:entity_id instance)
:model (model instance)})))
instance)
(t2/define-before-insert :hook/git-sync-protected
[instance]
(prevent-sync-protected-writes instance))
(t2/define-before-update :hook/git-sync-protected
[instance]
(prevent-sync-protected-writes instance))
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/timestamped? :hook/entity-id)
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/updated-at-timestamped? :hook/entity-id)
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/created-at-timestamped? :hook/entity-id)
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/entity-id :hook/git-sync-protected)
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/timestamped? :hook/git-sync-protected)
(methodical/prefer-method! #'t2.before-update/before-update :hook/entity-id :hook/git-sync-protected)
(methodical/prefer-method! #'t2.before-update/before-update :hook/timestamped? :hook/git-sync-protected)
(methodical/prefer-method! #'t2.after/each-row-fn [:toucan.query-type/insert.* :hook/branchable]
[:toucan.query-type/insert.* :hook/search-index])
;; --- helper fns
(defn changes-with-pk
@@ -648,6 +679,14 @@
{:arglists '([instance] [model pk])}
dispatch-on-model)
(defmethod can-write? :hook/git-sync-protected
([instance]
(try (false? ((requiring-resolve 'metabase-enterprise.git-source-of-truth.settings/git-sync-read-only)))
(catch Exception _ true)))
([_model _pk]
(try (false? ((requiring-resolve 'metabase-enterprise.git-source-of-truth.settings/git-sync-read-only)))
(catch Exception _ true))))
#_{:clj-kondo/ignore [:unused-private-var]}
(define-simple-hydration-method ^:private hydrate-can-write
:can_write

View File

@@ -17,6 +17,8 @@
:model/ApplicationPermissionsRevision metabase.permissions.models.application-permissions-revision
:model/AuditLog metabase.audit-app.models.audit-log
:model/BookmarkOrdering metabase.bookmarks.models.bookmark
:model/Branch metabase-enterprise.branching.models.branch
:model/BranchModelMapping metabase-enterprise.branching.models.branch-model-mapping
:model/CacheConfig metabase.cache.models.cache-config
:model/Card metabase.queries.models.card
:model/CardBookmark metabase.bookmarks.models.bookmark

View File

@@ -115,8 +115,10 @@
(derive :metabase/model)
;; You can read/write a Card if you can read/write its parent Collection
(derive :perms/use-parent-collection-perms)
;(derive :hook/branchable)
(derive :hook/timestamped?)
(derive :hook/entity-id))
(derive :hook/entity-id)
(derive :hook/git-sync-protected))
(defmethod mi/can-write? :model/Card
([instance]

View File

@@ -3,6 +3,7 @@
(:require
[metabase.analytics.core :as analytics]
[metabase.api.macros :as api.macros]
[metabase.branching.core :as branching]
[metabase.config.core :as config]
[metabase.server.middleware.auth :as mw.auth]
[metabase.server.middleware.browser-cookie :as mw.browser-cookie]
@@ -66,6 +67,7 @@
#'mw.security/add-security-headers ; Add HTTP headers to API responses to prevent them from being cached
#'mw.json/wrap-json-body ; extracts json POST/PUT body and makes it available on request
#'mw.offset-paging/handle-paging ; binds per-request parameters to handle paging
#'branching/handle-current-branch ; binds per-request branch parameters to handle getting branchable models
#'mw.json/wrap-streamed-json-response ; middleware to automatically serialize suitable objects as JSON in responses
#'wrap-keyword-params ; converts string keys in :params to keyword keys
#'wrap-params ; parses GET and POST params as :query-params/:form-params and both as :params

View File

@@ -124,3 +124,12 @@
:type :keyword
:default :all
:audit :raw-value)
(defsetting git-branch
(deferred-tru "The git branch of the Metabase instance.")
:encryption :no
:user-local :only
:export? false
:visibility :authenticated
:type :string
:default nil)

View File

@@ -121,6 +121,13 @@
:model/Action
(fn [_] {:creator_id (rasta-id)})
:model/Branch
(fn [_] (default-timestamped
(let [name (u.random/random-name)]
{:name name
:slug (u/slugify name)
:creator_id (rasta-id)})))
:model/Channel
(fn [_] (default-timestamped
{:name (u.random/random-name)