Compare commits

...

91 Commits

Author SHA1 Message Date
Ryan Laurie
58f9f4550a release flags for migrations 2026-04-01 13:35:25 -06:00
Ryan Laurie
5a70a6d94e remove frontend runtime flags 2026-04-01 09:02:03 -06:00
Eric Normand
aa6a0be593 Disable put in prod 2026-04-01 06:48:42 -06:00
Eric Normand
d908f243b4 Appease the kondo 2026-04-01 06:48:42 -06:00
Eric Normand
1e3f3617f8 Protect API endpoints
API requires logged in user
Prod mode always returns {} in bootstrap and API
2026-04-01 06:48:42 -06:00
Eric Normand
188f9be1d1 Always return false on prod without hitting the db 2026-04-01 06:48:24 -06:00
Ryan Laurie
b40465eef2 remove failing prod test 2026-04-01 06:48:24 -06:00
Ryan Laurie
e1890313ab cleanup 2026-04-01 06:48:24 -06:00
Ryan Laurie
0a778c7c25 add robot instructions 2026-04-01 06:48:24 -06:00
Ryan Laurie
7db746cf04 add e2e tests 2026-04-01 06:48:23 -06:00
Eric Normand
1ffdd899a1 Bind *blocked-calls* to nil during guard tests 2026-04-01 06:48:23 -06:00
Ryan Laurie
424aa3361c dont show flags page in production 2026-04-01 06:48:23 -06:00
Eric Normand
25d44affe6 Add :model/ReleaseFlag to non-serialized entities list 2026-04-01 06:48:23 -06:00
Eric Normand
5aff089633 Fix test 2026-04-01 06:48:08 -06:00
Eric Normand
27ebc7e874 Make non-existent flags return false 2026-04-01 06:48:08 -06:00
Eric Normand
83c254ac76 Better docs 2026-04-01 06:48:08 -06:00
Ryan Laurie
c29c0dc2a9 hang hasReleaseFlag on the window for clojurescript 2026-04-01 06:48:08 -06:00
Ryan Laurie
00a91d2974 put flags on the window object 2026-04-01 06:48:08 -06:00
Ryan Laurie
770af0c170 cleanup 2026-04-01 06:48:08 -06:00
Eric Normand
ea7c79e2c3 Whitespace 2026-04-01 06:48:08 -06:00
Eric Normand
135869854a Fix tests 2026-04-01 06:48:08 -06:00
Eric Normand
8293580261 Better tests and kondo appeasement 2026-04-01 06:48:08 -06:00
Eric Normand
872d1de89b Add releaseFlagsJSON 2026-04-01 06:48:08 -06:00
Eric Normand
82bd36db7e More tests 2026-04-01 06:48:08 -06:00
Eric Normand
0b9cbc15c4 Fix tests 2026-04-01 06:48:08 -06:00
Eric Normand
9b29bcfe27 Better order for joke-of-the-day 2026-04-01 06:48:07 -06:00
Eric Normand
a64e53c93b Fixes 2026-04-01 06:48:07 -06:00
Eric Normand
4fd42c9580 Add remarks 2026-04-01 06:48:07 -06:00
Eric Normand
503b174228 Change boolean 2026-04-01 06:48:07 -06:00
Eric Normand
2bec3cf42b Better migration 2026-04-01 06:48:07 -06:00
Eric Normand
ce2a0deb44 Whitespace 2026-04-01 06:48:07 -06:00
Eric Normand
85262dc373 Remove kondo errors 2026-04-01 06:48:07 -06:00
Eric Normand
d61e102bf1 Test the jokes without failing 2026-04-01 06:48:07 -06:00
Eric Normand
953f7eb568 Add test bypass guard fixture 2026-04-01 06:48:07 -06:00
Eric Normand
cd8294f54c Move release-flags.json to resources 2026-04-01 06:48:07 -06:00
Eric Normand
fe36f9d6ca Centralize schema and allow keywords 2026-04-01 06:48:07 -06:00
Ryan Laurie
c810b7e0b2 joke frontend 2026-04-01 06:48:07 -06:00
Ryan Laurie
4f28552eae release flag ui toggle 2026-04-01 06:48:07 -06:00
Eric Normand
068bcc4796 Check 404 2026-04-01 06:48:06 -06:00
Eric Normand
7c266cd942 Make it work! 2026-04-01 06:48:06 -06:00
Eric Normand
3a431c8740 Oops, forgot to require 2026-04-01 06:48:06 -06:00
Eric Normand
690c76d931 Jokes api 2026-04-01 06:48:06 -06:00
Eric Normand
607cdfaa6e Guard test 2026-04-01 06:48:06 -06:00
Eric Normand
54dad9a3a3 Doh! Use API endpoint correctly 2026-04-01 06:48:06 -06:00
Ryan Laurie
1418b1aecd hook it up 2026-04-01 06:48:06 -06:00
Eric Normand
9508a9537e Fix Wrong schema 2026-04-01 06:48:06 -06:00
Eric Normand
963e5996cf Fix json 2026-04-01 06:48:06 -06:00
Ryan Laurie
5e87520e72 add frontend flagging
# Conflicts:
#	frontend/src/metabase-types/api/index.ts
2026-04-01 06:48:06 -06:00
Eric Normand
8e3b098d3c Linter (backend) 2026-04-01 06:47:31 -06:00
Eric Normand
6ab33034fc Main entrypoint for release-flags (guard and check) 2026-04-01 06:47:30 -06:00
Eric Normand
12e98c4a87 Initial db, model, api 2026-04-01 06:47:30 -06:00
Ryan Laurie
8b450792c7 add flag file 2026-04-01 06:47:30 -06:00
Tyler Plude
e0ba625be4 [BOT-1247] Fix empty string content blocks in anthropic API (#71765)
init
2026-03-31 12:57:49 -07:00
Nemanja Glumac
9038c23075 Run cross-version script only on the main repo (#71754) 2026-03-31 21:55:10 +02:00
Nemanja Glumac
2e28c3f930 Ask for explicit input to override fork changes (#71753) 2026-03-31 21:52:30 +02:00
Fraser
0f42846b53 Clean UI utils and component violations (#71465) 2026-04-01 08:47:17 +13:00
Fraser
afc7fdbeb6 Add subset of files to app-misc module and make schema a lib module (#71700) 2026-04-01 08:46:56 +13:00
Noah Moss
adc3c025c5 Let Metabot write a brief response after using autogen_dashboard (#71758)
remove final-response? from autogen dashboard tool to allow for a brief description
2026-03-31 15:33:51 -04:00
Ryan Laurie
5886b7b7c6 Fix metabot padding (#71680)
* avante garde monetization

* fetch market prices

* limited fun

* obey the linter
2026-03-31 12:50:00 -06:00
Sloan Sparger
aa84a83fba [Metabot] Remove metabot v3 token feature (#71600)
remove metabot v3 token feature
2026-03-31 18:21:13 +00:00
Sloan Sparger
6c14871bae [Slackbot] Make slackbot auth req messages in channel an ephemeral message (#71603)
make slackbot auth req messages in channel an ephemeral message
2026-03-31 12:55:08 -05:00
Edward Paget
9e576f007b Fix git sync to remove stale YAML files from managed export directories (#71739)
Exports are always full, so any .yaml file in a managed directory that isn't
in the current write set should be removed. This fixes stale files persisting
after entity renames, moves between collections, or deletions.

The git source now accepts a set of managed top-level directories. During
write-files!, existing files in managed dirs not in the write set are cleaned
up automatically. This replaces the previous path-prefix-based collection
scoping and the explicit build-all-removal-paths machinery, which couldn't
handle renames or cross-collection moves.
2026-03-31 12:48:56 -05:00
Sloan Sparger
400a33e4ef [Metabot] Hide embedded settings if embedding is not available (#71594)
hide embedded metabot settings if embedding is not available
2026-03-31 12:17:12 -05:00
Noah Moss
201824c6b2 Remove capability check for create-autogenerated-dashboard-tool; clarify system prompt (#71589)
* fix autogen_dashboard tool by removing capability checck, clarify system prompt around dashboards

* fix tests

* fix test
2026-03-31 13:13:30 -04:00
Edward Paget
fa31def1bc Fix support access grant password reset failing for deactivated users (#71598)
* Fix support access grant password reset failing for deactivated users

When a support user was deactivated (e.g. after a previous grant expired),
creating a new grant would leave the user inactive. The password reset
token authentication would succeed, the :after method would consume the
token and change the password, but create-session! would then reject the
login because is_active was false. with-fallback would swallow this and
fall back to emailed-secret-password-reset which also failed, resulting
in an opaque "Invalid reset token" error with the token irreversibly
consumed.

Fix: fetch-or-create-support-user! now reactivates deactivated users.

Also adds tests for the deactivated user flow, pre-existing auth identity
cascade, with-fallback error swallowing behavior, and direct login! calls
for easier debugging.

* fixup! cljfmt
2026-03-31 11:00:45 -05:00
Nicola Mometto
c134074716 Dedupe slack channels (#71653)
dedupe slack channels
2026-03-31 16:39:30 +01:00
lbrdnk
237c3fe056 Remove stale archived_message instructions (#71715) 2026-03-31 16:17:00 +02:00
Nick Fitzpatrick
01af8eb57f updating styling for Database Destination Connection Modal (#71586) 2026-03-31 19:38:54 +05:30
Edward Paget
e60356ffd7 Fix notification send test retry config key (:max-attempts → :max-retries) (#71689)
The test overrode :max-attempts but diehard uses :max-retries, so the
override was silently ignored and all 6 retries ran with exponential
backoff (~31s). With the correct key the test finishes in ~1s.

Done: task/fix-notification-retry-key
2026-03-31 14:08:49 +00:00
Kevin Kelleher
d825c4dab3 Metrics home page and viewer cleanup (#71514) 2026-03-31 13:41:56 +00:00
lbrdnk
bee5aa840a Native query context formatting (#71566)
* Extend the schema for context received in requests

* Fix native question context formatting

* Adjust context schema

* Tests

* test

* Tests

* extract dialect

* remarks
2026-03-31 15:12:05 +02:00
Kevin Kelleher
214f776ad4 Metrics explorer dimension picker and tab creation fixes (#71536) 2026-03-31 07:13:57 -04:00
Timofey Kachalov
a4c66d0626 Update permissions in embedding SDK tests to avoid permission errors in some cases (#71705) 2026-03-31 13:56:11 +04:00
Nemanja Glumac
cc7506cc6f Allow Metabase forks (including private ones) to sync automatically (#71663)
Add a new sync-fork workflow that automatically syncs metabase internal forks
with upstream metabase/metabase (master + 5 most recent release branches).
Force-syncs from upstream as source of truth, skips push when no diff to
avoid unnecessary CI runs. Only runs on forks, never on the public repo.

Guard all scheduled workflows with repository check so they only run on
schedule for metabase/metabase, not on forks (manual dispatch
still works on both).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 06:05:11 +00:00
Alexander Polyankin
8f62ee5f3a Serdes skip default values (#71544) 2026-03-31 03:42:08 +00:00
Fraser
f922a58520 Clean dead code (#71463) 2026-03-31 16:02:41 +13:00
metabase-bot[bot]
1179147e3d [chore] Update E2E test timings with recent data (#71621) 2026-03-31 14:03:04 +13:00
Fraser
b2a016926e Push shared redux actions down (#71130) 2026-03-31 13:53:24 +13:00
Maz Ameli
a9f7e0504a Change label for stacked series setting (#71541)
change label for stacked series setting
2026-03-30 23:01:28 +00:00
Edward Paget
4b032951ad Include lock name in cluster lock log and error messages (#71677) 2026-03-30 21:09:03 +00:00
Braden Shepherdson
43ab4ba9d3 [MBQL lib] Add API docs for returned-columns and other metadata (#71443)
Part of QUE2-366.
2026-03-30 21:03:49 +00:00
Nemanja Glumac
610ff28b80 Fix a collection picker flake in cross-version tests (#71599) 2026-03-30 21:26:39 +02:00
Alexander Polyankin
4bbdb37c66 Serdes file and folder human readable names (#71583) 2026-03-30 18:52:39 +00:00
Nick Fitzpatrick
b56c33b6be adjust styling on parameter placeholders (#71577) 2026-03-30 23:40:08 +05:30
Nick Fitzpatrick
a14c0b00b9 don't recompute sensible chart groupings while sidebar is mounted (#71563) 2026-03-30 23:39:46 +05:30
Eric Normand
94cdeffdab Fix field filter dropdowns for users without create-queries permission GHY-1605 (#71046)
## Summary

Field filter dropdowns on native query cards show as text inputs ("Enter some text") for users who have view-data permission but lack create-queries permission on the underlying table.

**Root cause:** Two issues combined to break this:

1. **Frontend couldn't determine dropdown type** — The frontend needs `has_field_values` from field metadata to decide whether to show a dropdown vs text input. This metadata came from `GET /api/database/:id`, which returns 403 for users without `create-queries` permission. Without it, the widget falls back to a text input.

2. **Backend param values endpoint was too restrictive** — Even if the frontend showed a dropdown, the card param values endpoint (`GET /api/card/:id/params/:key/values`) would fail because `search-values-from-field-id` does `api/read-check` on the Field (which requires `create-queries`), and the QP permission check also required `create-queries`.

**Fix:**

- **Hydrate `param_fields` on Card API response** (matching existing Dashboard pattern) — includes `has_field_values` so the frontend can show the correct widget type without needing `GET /api/database/:id`
- **Populate metadata store with `param_fields`** on card load in the query builder
- **Bind `*param-values-query*`** in card param values endpoints (like dashboards already do) so the QP only checks `view-data` permission
- **Skip `api/read-check` on Field** in `search-values-from-field-id` when `*param-values-query*` is true
2026-03-30 12:55:14 -05:00
da
480ef19d1f Document reverse tunneling for SSH connections (#71628)
Added section on reverse tunneling for SSH connections.
2026-03-30 15:54:09 +00:00
Sébastien
65d66d2887 No request to pulse if user canot manage subscriptions (#71575) 2026-03-30 15:36:05 +02:00
Mahatthana (Kelvin) Nomsawadi
a1c3983037 Fix flaky test (#71623)
Attempt to fix flake
2026-03-30 14:26:33 +01:00
Jeff Bruemmer
424541b425 docs - mcp and AI updates (#71601) 2026-03-30 09:04:43 -04:00
Fraser
cc486a476e Lift dashboard only visualizations (#71271)
* Extract Dashboard-only visualizations

  Moved 4 dashboard-only visualization types from visualizations/visualizations/ to dashboard/visualizations/:
  - Heading/ — imports dashboard context, selectors, parameter components
  - IFrameViz/ — imports dashboard selectors
  - LinkViz/ — imports dashboard selectors
  - DashCardPlaceholder.tsx — imports dashboard actions

  Created dashboard/visualizations/register.js — registers these 4 viz types separately from the main visualizations/register.js

  Updated app.js — calls both registerVisualizations() and registerDashboardVisualizations()

  Updated 3 external imports to use the new paths:
  - dashboard/actions/utils.ts — IFrameViz utils
  - parameters/utils/mapping-options.ts — IFrameViz utils
  - PublicOrEmbeddedDashboardView-card-filters.stories.tsx — Heading

  Fixed 1 relative import in DashCardPlaceholder.tsx to use absolute path for VisualizationProps type.

  Violation progression: 157 → 111 → 86 → 78

* List DashboardChartSettings up

* Fix test setup

* Register dashboared viz in embedding
2026-03-30 22:01:47 +13:00
743 changed files with 7081 additions and 6624 deletions

View File

@@ -34,6 +34,10 @@
:use {:level :warning}
:warn-on-reflection {:level :warning}
;; release flag linters
:metabase/unknown-release-flag {:level :warning}
:metabase/non-literal-release-flag {:level :warning}
;;
;; other disabled-by-default linters it would be nice to enable soon
;;
@@ -318,8 +322,7 @@
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
toucan2.core/debug {:message "Don't commit code using t2/debug"}
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
}
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
:discouraged-namespace
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
@@ -688,6 +691,11 @@
metabase.util.malli.registry/def hooks.metabase.util.malli.registry/def
metabase.util/case-enum hooks.metabase.util/case-enum
metabase.util/format-color hooks.metabase.util/format-color
metabase.release-flags.core/has-release-flag? hooks.metabase.release-flags.models/has-release-flag?
metabase.release-flags.core/guard-namespace! hooks.metabase.release-flags.models/guard-namespace!
metabase.release-flags.guard/bypass-guard-fixture hooks.metabase.release-flags.models/bypass-guard-fixture
metabase.release-flags.guard/guard-namespace! hooks.metabase.release-flags.models/guard-namespace!
metabase.release-flags.models/has-release-flag? hooks.metabase.release-flags.models/has-release-flag?
metabase.xrays.api.automagic-dashboards-test/with-indexed-model! hooks.metabase.xrays.api.automagic-dashboards-test/with-indexed-model!}
:macroexpand

View File

@@ -1009,6 +1009,13 @@
:model/QueryExecution
:model/User}}
joke-of-the-day
{:team "UX West"
:api #{metabase.joke-of-the-day.api}
:uses #{api
release-flags
util}}
legacy-mbql
{:team "Querying Platform"
:api #{metabase.legacy-mbql.normalize
@@ -1747,6 +1754,15 @@
:model/QueryExecution
:model/Table}}
release-flags
{:team "UX West"
:api #{metabase.release-flags.api
metabase.release-flags.core
metabase.release-flags.init}
:uses #{api
task
util}}
remote-sync
{:team "UX West"
:api #{metabase.remote-sync.core
@@ -1765,7 +1781,7 @@
api
config
embedding
permissions ; FIXME
permissions ; FIXME
session
settings
users
@@ -1890,6 +1906,7 @@
oauth-server
premium-features
query-processor
release-flags
request
session
settings
@@ -1905,7 +1922,7 @@
:api #{metabase.session.api
metabase.session.core
metabase.session.init
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
:uses #{api
app-db
auth-identity
@@ -3133,6 +3150,7 @@
collections
config
events
lib
logger
models
plugins

View File

@@ -0,0 +1,6 @@
;; Source of truth for release flags.
;; Auto-loaded by clj-kondo via :config-paths, and read by metabase.release-flags.init at startup.
;; See docs/developers-guide/release-flags.md for usage.
{:metabase/release-flags
{"joke-of-the-day" {:description "Whether to show the joke of the day on the homepage."
:start-date "2026-02-24"}}}

View File

@@ -0,0 +1,62 @@
(ns hooks.metabase.release-flags.models
(:require
[clj-kondo.hooks-api :as api]
[clojure.string :as str]))
(defn- keyword->flag-name
"Converts a keyword to its full string representation, including namespace if present.
:foo => \"foo\", :ns/foo => \"ns/foo\""
[kw]
(if (namespace kw)
(str (namespace kw) "/" (name kw))
(name kw)))
(defn- check-flag-name
"Checks if a flag name is in the valid set and registers a finding if not."
[valid-flags flag-name flag-node]
(when (and (seq valid-flags) (not (contains? valid-flags flag-name)))
(api/reg-finding! (assoc (meta flag-node)
:message (str "Unknown release flag: " flag-name
". Valid flags: " (str/join ", " (sort valid-flags)))
:type :metabase/unknown-release-flag))))
(defn- check-flag-arg
"Shared lint check for a flag argument node. Warns if not a literal keyword/string
or if the flag is not in release-flags.json."
[valid-flags flag-node]
(cond
(api/keyword-node? flag-node)
(check-flag-name valid-flags (keyword->flag-name (api/sexpr flag-node)) flag-node)
(api/string-node? flag-node)
(check-flag-name valid-flags (api/sexpr flag-node) flag-node)
:else
(api/reg-finding! (assoc (meta flag-node)
:message "Release flag should be a literal keyword or string, not a variable."
:type :metabase/non-literal-release-flag))))
(defn- get-valid-flags
"Reads the valid release flag names from the kondo config.
Loaded from .clj-kondo/config/release-flags/config.edn via :config-paths."
[config]
(set (keys (get config :metabase/release-flags {}))))
(defn has-release-flag?
"Lint hook for metabase.release-flags.models/has-release-flag?."
[{:keys [node config]}]
(let [[_ flag-node] (:children node)]
(check-flag-arg (get-valid-flags config) flag-node)))
(defn guard-namespace!
"Lint hook for metabase.release-flags.guard/guard-namespace!."
[{:keys [node config]}]
(let [[_ flag-node] (:children node)]
(check-flag-arg (get-valid-flags config) flag-node)))
(defn bypass-guard-fixture
"Lint hook for metabase.release-flags.guard/bypass-guard-fixture."
[{:keys [node config]}]
(let [valid-flags (get-valid-flags config)]
(doseq [flag-node (rest (:children node))]
(check-flag-arg valid-flags flag-node))))

View File

@@ -52,4 +52,8 @@ When reviewing code, use these emoji prefixes to categorize feedback:
- For non-critical issues, link to the specific line from the summary instead
- Delete all stale comments and summaries on each new push or CI run
## Release flags
All code adding or using release flags should follow the best practices outlined in `docs/developers-guide/release-flags.md`.
Language-specific rules are in `.github/instructions/*.instructions.md`.

View File

@@ -39,6 +39,7 @@ concurrency:
jobs:
get-cache-keys:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- uses: actions/checkout@v4

View File

@@ -8,6 +8,7 @@ on:
jobs:
codespell:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
permissions:

View File

@@ -7,6 +7,7 @@ on:
jobs:
fe-test-coverage:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 60
steps:

View File

@@ -25,7 +25,7 @@ on:
jobs:
# Generate matrix for scheduled/PR runs
generate-matrix:
if: github.event_name != 'workflow_dispatch'
if: github.event_name != 'workflow_dispatch' && github.repository == 'metabase/metabase'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
@@ -47,7 +47,7 @@ jobs:
# Matrix-based tests for scheduled/PR runs
test-matrix:
if: github.event_name != 'workflow_dispatch'
if: github.event_name != 'workflow_dispatch' && github.repository == 'metabase/metabase'
needs: generate-matrix
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
@@ -102,7 +102,11 @@ jobs:
# Notify Slack on nightly failures
notify-on-failure:
if: always() && github.event_name == 'schedule' && needs.test-matrix.result == 'failure'
if: >-
always()
&& github.event_name == 'schedule'
&& github.repository == 'metabase/metabase'
&& needs.test-matrix.result == 'failure'
needs: [test-matrix]
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5

View File

@@ -7,6 +7,7 @@ on:
jobs:
notify-disabled-drivers:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:

View File

@@ -7,6 +7,7 @@ on:
jobs:
update-master-docs:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
name: Call documentation update for master
uses: ./.github/workflows/docs-generate-base.yml
with:

View File

@@ -13,6 +13,7 @@ on:
jobs:
update-release-docs:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
name: Call documentation update for release branch
uses: ./.github/workflows/docs-generate-base.yml
with:

View File

@@ -106,6 +106,8 @@ jobs:
SAMPLE_APP_ENVIRONMENT: ${{ matrix.environment }}
permissions:
id-token: write
contents: read
actions: read
steps:
- uses: actions/checkout@v4
@@ -213,6 +215,8 @@ jobs:
SAMPLE_APP_ENVIRONMENT: ${{ matrix.environment }}
permissions:
id-token: write
contents: read
actions: read
steps:
- uses: actions/checkout@v4
@@ -277,6 +281,7 @@ jobs:
permissions:
id-token: write
contents: read
actions: read
steps:
- uses: actions/checkout@v4
@@ -362,6 +367,8 @@ jobs:
HOST_APP_ENVIRONMENT: ${{ matrix.environment }}
permissions:
id-token: write
contents: read
actions: read
steps:
- uses: actions/checkout@v4

View File

@@ -7,6 +7,7 @@ on:
jobs:
js-stats:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:

View File

@@ -7,6 +7,7 @@ on:
jobs:
module-boundaries-stats:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:

113
.github/workflows/sync-fork.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
name: Sync Fork with Upstream
on:
schedule:
- cron: "0 2 * * *" # Every day at 2 AM UTC
workflow_dispatch:
inputs:
force:
description: "⚠️ DESTRUCTIVE: Force-push from upstream, overwriting any fork-only commits"
required: false
default: false
type: boolean
release_branch_count:
description: "Number of most recent release branches to sync (0 to skip)"
required: false
default: "5"
type: number
jobs:
sync-master:
# ONLY run on internal metabase org forks — NEVER on the public repo or external forks
if: github.repository_owner == 'metabase' && github.repository != 'metabase/metabase'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Add upstream remote
run: git remote add metabase https://github.com/metabase/metabase.git
- name: Fetch upstream master
run: git fetch metabase master
- name: Sync master from upstream
env:
FORCE: ${{ inputs.force || 'false' }}
run: |
if git diff --quiet master metabase/master; then
echo "master is already up to date — skipping push"
exit 0
fi
git checkout master
if [ "$FORCE" = "true" ]; then
echo "⚠️ Force-syncing master (overwriting fork-only commits)"
git reset --hard metabase/master
git push origin master --force
else
if git pull metabase master; then
git push origin master
else
echo "::warning::master has diverged from upstream — skipping (use force to override)"
fi
fi
sync-release-branches:
# ONLY run on internal metabase org forks — NEVER on the public repo or external forks
if: github.repository_owner == 'metabase' && github.repository != 'metabase/metabase'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Add upstream remote
run: git remote add metabase https://github.com/metabase/metabase.git
- name: Fetch upstream release branches
run: git fetch metabase 'refs/heads/release-*:refs/remotes/metabase/release-*'
- name: Sync most recent release branches from upstream
env:
BRANCH_COUNT: ${{ inputs.release_branch_count || '5' }}
FORCE: ${{ inputs.force || 'false' }}
run: |
# Get only canonical release branches (release-x.NN.x), skip ad-hoc branches like backports
branches=$(git branch -r --list 'metabase/release-*' | sed 's|^ *||' | grep -E '^metabase/release-x\.[0-9]+\.x$' | sort -t. -k2,2rn | head -n "$BRANCH_COUNT")
for branch in $branches; do
branch_name="${branch#metabase/}"
echo "::group::$branch_name"
# Check if branch exists locally (on origin)
if git rev-parse --verify "origin/$branch_name" &>/dev/null; then
if git diff --quiet "origin/$branch_name" "$branch"; then
echo "$branch_name is already up to date — skipping push"
echo "::endgroup::"
continue
fi
fi
if [ "$FORCE" = "true" ]; then
echo "⚠️ Force-syncing $branch_name (overwriting fork-only commits)"
git checkout -B "$branch_name" "$branch"
git push origin "$branch_name" --force
else
git checkout -B "$branch_name" "origin/$branch_name" 2>/dev/null || git checkout -B "$branch_name" "$branch"
if git pull metabase "$branch_name"; then
git push origin "$branch_name"
else
echo "::warning::$branch_name has diverged from upstream — skipping (use force to override)"
fi
fi
echo "::endgroup::"
done

View File

@@ -7,6 +7,7 @@ on:
jobs:
harvest:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
permissions:
contents: read

View File

@@ -10,6 +10,7 @@ permissions:
jobs:
synchronize-translations:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
name: Synchronize Translations
env:
BRANCH_NAME: update-translations-${{ github.run_id }}

View File

@@ -18,6 +18,7 @@ on:
jobs:
collect-and-average-timings:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
env:

View File

@@ -17,6 +17,7 @@ permissions:
jobs:
bump-deps:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
name: Open PRs to Bump Backend Deps
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -93,6 +93,7 @@
"dayjs": "^1.11.18",
"detect-package-manager": "^3.0.2",
"diff": "^5.2.0",
"dotenv": "^17.3.1",
"echarts": "6.0.0",
"events": "^3.3.0",
"fast-text-encoding": "^1.0.6",
@@ -2792,7 +2793,7 @@
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -5618,6 +5619,8 @@
"@redocly/cli/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
"@redocly/cli/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"@redocly/cli/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"@redocly/cli/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
@@ -6956,6 +6959,8 @@
"typedoc/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"typescript-plugin-css-modules/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"unbox-primitive/which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
"unified/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],

View File

@@ -330,7 +330,7 @@
:test
{:extra-paths ["test_config"]
:exec-fn metabase.test-runner/find-and-run-tests-cli
:exec-args {:exclude-tags [:metabot-v3/e2e]}
:exec-args {}
:jvm-opts ["-Dmb.run.mode=test"
"-Dmacaw.run.mode=test"
"-Dmb.db.in.memory=true"

View File

@@ -150,8 +150,9 @@ Metabase's reference documentation.
- [AI overview](./ai/start.md)
- [Metabot](./ai/metabot.md)
- [Setting up Metabot](./ai/settings.md)
- [SQL generation](./ai/sql-generation.md)
- [AI settings](./ai/settings.md)
- [Agent API](./ai/agent-api.md)
- [MCP server](./ai/mcp.md)
### Exploration and organization

View File

@@ -20,17 +20,23 @@ There are a few advantages to using the agent API over the Metabase API.
- Agent endpoints are explicitly supported for building agentic BI applications.
- The agent API is versioned, so your apps can rely on consistent responses.
- Supports JWT auth so requests can be scoped to a user's permissions.
- Requests are scoped to the authenticated user's permissions.
- Doesn't require you to work with MBQL, which is Metabase's querying language.
## Supported features
The Agent API supports:
- Discovering tables and metrics
- Searching for tables and metrics
- Inspecting their fields
- Constructing and executing queries
If you want to connect an MCP-compatible AI client (like Claude Desktop) without writing custom code, see the [MCP server](./mcp.md), which builds on this API.
## Row limits and pagination
Queries return a maximum of 200 rows per request. To page through larger result sets, use `POST /api/agent/v1/query`, which returns a `continuation_token` when more rows are available. Pass the token back to get the next page.
## Agent API endpoints and reference
Check out the API endpoints: [/api/agent](../api.html#tag/apiagent).
@@ -39,9 +45,44 @@ You can also point your AI to the [Agent API reference](https://github.com/metab
## Authentication
The Agent API uses stateless JWT authentication. JWT must be configured in Metabase admin settings (**Admin** > **Settings** > **Authentication** > **JWT**).
The Agent API supports three authentication methods.
Pass a signed JWT directly in each request:
### API key
The simplest option. Create an [API key](../people-and-groups/api-keys.md) in **Admin** > **Settings** > **Authentication** > **API keys**, then pass it in each request:
```
X-API-Key: YOUR_API_KEY
```
The key's permissions are determined by the [group](../people-and-groups/managing.md#groups) assigned to that key. Unlike session or JWT authentication, API key requests aren't scoped to an individual user. Anyone using a client app that authenticates with an API key will have the same permissions as that key.
### Session token
Log in with a Metabase username and password to get a session token, then pass it in subsequent requests.
**1. Get a session token:**
```
POST /api/session
Content-Type: application/json
{"username": "analyst@example.com", "password": "your-password"}
```
The response body contains an `id` field -- that's your session token.
**2. Pass the token in each request:**
```
X-Metabase-Session: SESSION_TOKEN
```
### JWT
{% include plans-blockquote.html feature="JWT-based authentication" %}
If you've configured [JWT-based authentication](../people-and-groups/authenticating-with-jwt.md) (**Admin** > **Settings** > **Authentication** > **JWT**), you can pass a signed JWT directly:
```
Authorization: Bearer <jwt>
@@ -66,17 +107,13 @@ Example JWT payload:
}
```
### Session-based authentication
Exchange a JWT at `POST /auth/sso/to_session` to get a session token, then pass it via:
```
X-Metabase-Session: <session-token>
```
You can also exchange a JWT for a session token via `POST /auth/sso/to_session` (note: this endpoint is under `/auth`, not `/api`), then use the `X-Metabase-Session` header as described above.
## Further reading
- [MCP server](./mcp.md)
- [Agent API complete reference](https://github.com/metabase/metabase/blob/master/src/metabase/agent_api/reference.md)
- [Metabase Agent API demo](https://github.com/metabase/metabase-agent-api-demo)
- [Metabase API docs](../api.html)
- [API keys](../people-and-groups/api-keys.md)
- [JWT authentication](../people-and-groups/authenticating-with-jwt.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

58
docs/ai/mcp.md Normal file
View File

@@ -0,0 +1,58 @@
---
title: MCP server
summary: Connect MCP-compatible AI clients to Metabase to search, explore, and query your data through the semantic layer.
---
# MCP server
![A metric viewed through an MCP client connected to Metabase](./images/metric-in-mcp.png)
Metabase includes an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server (using Streamable HTTP transport) that lets AI clients connect directly to your Metabase, all scoped to the connecting person's permissions.
Before connecting, make sure AI features are enabled in your Metabase. See [AI settings](./settings.md).
## Connect an MCP client
Point your MCP client at Metabase's MCP endpoint at `/api/mcp`:
```
https://{your-metabase.example.com}/api/mcp
```
In Claude Code, for example, you can run `/mcp add metabase https://{your-metabase.example.com}/api/mcp --transport streamable-http` and Claude will handle the OAuth flow for you.
For Claude Desktop, you can create a [custom connector](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) by just giving it that URL to your Metabase's mcp endpoint.
## Authentication
MCP clients authenticate with Metabase using OAuth 2.0. Metabase runs its own embedded OAuth server, so you don't need to set up an external OAuth provider.
Your MCP client should try to connect to your Metabase. You'll see a Metabase-branded consent page asking you to approve the connection to your Metabase.
A first-time connection will go something like this:
1. The client discovers Metabase's OAuth endpoints.
2. The client registers itself with Metabase.
3. You're redirected to Metabase to log in (if you aren't already) and approve the connection.
4. The client receives an access token scoped to the permissions you have in Metabase.
Results returned by the MCP server are sent to your MCP client, which may forward them to an AI provider depending on how the client is configured. See [Privacy](./settings.md#privacy).
## Available tools
Some clients (like Claude Desktop) will ask you to approve each tool the first time it's used. The MCP server builds on Metabase's [Agent API](./agent-api.md), and exposes the following tools. If you're building a custom integration and need full control, use the [Agent API](./agent-api.md) directly instead.
- **search**: Find tables and metrics using keyword or natural language search.
- **get_table**: Get details about a table, including its fields, related tables, and metrics.
- **get_table_field_values**: Get sample values and statistics for a field in a table.
- **get_metric**: Get details about a metric, including its queryable dimensions.
- **get_metric_field_values**: Get sample values and statistics for a field in a metric.
- **construct_query**: Construct a query against a table or metric. Returns an opaque query string that can be executed with `execute_query`.
- **execute_query**: Execute a previously constructed query and return the results with column metadata, row count, and execution time.
- **query**: Query a table or metric and return results.
## Further reading
- [Agent API](./agent-api.md)
- [Metabase API docs](../api.html)
- [Model Context Protocol specification](https://modelcontextprotocol.io/)

View File

@@ -17,7 +17,8 @@ From Slack you can ask Metabot to:
- **Answer questions**: Create ad-hoc queries from natural language to answer your questions on the spot. You can click the link to save the question in Metabase.
- **Show charts and tables**: Render static visualizations or tabular results in Slack. You can copy table results or download them as TSV.
- **Work with CSVs**: [Upload a CSV](../databases/uploads.md) to Metabase, then ask follow-up questions about the data.
- **Manage notifications**: Create [subscriptions](../dashboards/subscriptions.md) and [alerts](../questions/alerts.md) from within a channel. Metabot can find an answer and subscribe you to it in one go (though Metabot won't create new questions or dashboards).
- **Create alerts**: Set up [alerts](../questions/alerts.md) on saved questions that get delivered to the current Slack channel. Metabot can find an answer and set up an alert for it in one go (though the question must already be saved).
- **Create dashboard subscriptions**: Set up recurring [subscriptions](../dashboards/subscriptions.md) that deliver a dashboard's contents to the current Slack channel.
## Set up Metabot in Slack
@@ -49,7 +50,18 @@ To chat with Metabot, people will need to link their Slack account to their Meta
### Clearing context
Metabot remembers the context of a thread. To clear context and start a fresh conversation, either begin a new direct message or @mention Metabot in a new thread in a channel.
Metabot remembers the context of a thread. To clear context and start a fresh conversation, either begin a new direct message or @mention Metabot in a new thread in a channel.
## Alerts and subscriptions
From Slack, Metabot can create [alerts](../questions/alerts.md) and [dashboard subscriptions](../dashboards/subscriptions.md) that deliver to the current Slack channel.
Some caveats:
- For goal-based alerts, the goal line must already be configured and saved on the question. Metabot can't add goal lines.
- Metabot can't modify or delete existing alerts or subscriptions.
- Alerts and subscriptions are delivered to the Slack channel where the conversation takes place. You can't redirect them to a different channel or to email.
- Alerts can only be set on questions not metrics or ad-hoc queries.
## Notes on privacy

View File

@@ -71,7 +71,7 @@ You can also ask Metabot to tell you about specific tables in your database.
When you ask Metabot to create a chart from natural language, it first looks for existing questions that might answer your request. If it finds a relevant question, it'll point you to that instead of creating something new. Otherwise, Metabot will use the [query builder](../questions/query-builder/editor.md) to create a new chart for you.
Keep in mind that Metabot is still learning the query builder. Metabot can only handle basic query builder operations, and it lacks access to the library of [custom expressions](../questions/query-builder/expressions-list.md). Metabot is also limited to single-level aggregation and grouping, so if you need more complex analysis, you can take over and refine the query yourself, or switch to the SQL editor.
Metabot is not magic, and it's [still learning Metabase and the query builder](#current-limitations).
## Metabot in the native editor
@@ -123,6 +123,18 @@ Open it from **+ New → AI exploration**, then ask a question or pick one of th
When answering questions in AI exploration, Metabot searches a limited set of content instead of your entire Metabase. Admins can set a **Collection for natural language querying** to scope AI exploration to a specific collection and its subcollections. You can still refer to content outside that scope by explicitly @-mentioning it in your prompt. See [Metabot AI settings](../ai/settings.md) for more details.
## Current limitations
Metabot is getting smarter all the time, but there are some things it can't do yet:
- **Custom expressions.** Metabot can't use [custom expressions](../questions/query-builder/expressions-list.md) in query builder questions.
- **Multi-level aggregation.** Metabot is limited to a single level of aggregation and grouping.
- **SQL variables.** Metabot can't generate SQL queries that include [SQL parameters](../questions/native-editor/sql-parameters.md) (like filters or field filters).
- **Goal lines.** Metabot can't add [goal lines](../questions/visualizations/line-bar-and-area-charts.md#goal-lines) to charts.
- **Chart formatting.** Metabot can't change visualization settings like colors, axis labels, or number formatting.
- **Managing alerts.** [Metabot in Slack](./metabot-slack.md) can create alerts and dashboard subscriptions, but can't modify or delete existing ones.
- **Search.** Metabot can only search for tables, metrics, questions, models, and dashboards. It can't discover segments, measures, documents, collections, actions, or indexed entities on its own — but it can use segments and measures in ad-hoc queries (for example, filtering by a segment).
## Metabot in Slack
Slack Metabot directly or @mention it in a channel to ask questions about your data. See [Metabot in Slack](./metabot-slack.md).

View File

@@ -1,26 +1,31 @@
---
title: Metabot AI settings
summary: Configure Metabot settings, including which collections Metabot can access, and learn tips for improving Metabot's performance through data modeling and metadata.
title: AI settings
summary: Connect an AI provider and configure AI settings, including Metabot, collections, and tips for improving performance.
redirect_from:
- /docs/latest/ai/sql-generation
---
# Metabot AI settings
# AI settings
> Metabot is only available as an add-on on Metabase Cloud.
> AI features are available with your own AI provider API key, or as an add-on on [Metabase Cloud](https://www.metabase.com/features/metabot-ai).
_Admin > AI_
This page covers admin settings for Metabase's AI assistant, [Metabot](./metabot.md).
This page covers admin settings for AI features in Metabase, including [Metabot](./metabot.md).
![Admin settings for AI Metabot](./images/ai-settings.png)
On **Metabase Cloud** you can either:
Before using Metabot in your Metabase, you'll need to add the Metabot add-on to your instance in Metabase Store.
- [Purchase the Metabot add-on](#add-metabot-from-the-metabase-store) from the Metabase Store, or
- [Bring your own API key](#bring-your-own-api-key) from a supported AI provider.
On **self-hosted Metabases**: you can [bring your own API key](#bring-your-own-api-key) from a supported AI provider.
## Add Metabot from the Metabase Store
For now, Metabot is only available for Metabase Cloud. Before you can set up Metabot in your Metabase, you'll need to add Metabot to your subscription from the Metabase Store.
If you're on [Metabase Cloud](https://www.metabase.com/cloud/), you can add Metabot from the Metabase Store:
1. Go to [store.metabase.com](https://store.metabase.com).
2. Log in with your **Metabase Store account** (distinct from the account you use to log into your Metabase).
2. Log in with your **Metabase Store account** (distinct from the account you use to log in to your Metabase).
3. In the **Instances** tab, find the instance you'd like to add Metabot to, and click "Add Metabot AI".
4. Pick the plan based on the number of requests you expect you'll need.
@@ -28,45 +33,73 @@ For now, Metabot is only available for Metabase Cloud. Before you can set up Met
5. Read through the [terms of service](https://www.metabase.com/license/hosting) and click **Add Metabot AI**.
Once you've added Metabot AI in the Metabase store, you can log into your Metabase and configure it in _Admin > AI_.
Once you've added Metabot AI in the Metabase store, you can log in to your Metabase and configure it in _Admin > AI_.
## Metabot options
## Bring your own API key
You can set options for Metabot, both for your internal Metabase, and (separately) for embedded Metabase contexts. That way you can, say, use Metabot in your Metabase, while not giving access to Metabot in your embedded Metabase.
_Admin > AI > Connection settings_
- [Enable Metabot](#enable-metabot)
- [Verified content](#verified-content)
- [Collection Metabot can use](#collection-metabot-can-use)
![Connect to an AI provider](./images/ai-connection-settings.png)
## Enable Metabot
To enable AI features with your own API key:
Toggle [Metabot](./metabot.md) on or off for your Metabase or embedded contexts. Both Metabot and Embedded Metabot are enabled by default.
1. Go to **Admin > AI > Connection settings**.
2. Select your **Provider**.
3. Enter your **API key**.
4. Click **Connect**.
5. Select a **Model** from the dropdown. Available models are fetched from the provider using your API key.
### Internal Metabot
When your connection is active, you'll see a **CONNECTED** badge. With your key connected, you get access to [Metabot](./metabot.md), [inline SQL generation](./metabot.md#inline-sql-editing), the [MCP server](./mcp.md), and the [Agent API](./agent-api.md).
When enabled, Metabot is available to help people create questions, analyze data, and answer questions about your data. When disabled, the Metabot icon and keyboard shortcuts are hidden.
### Supported providers
Currently, Metabase only supports models from Anthropic.
## Configure Metabot
_Admin > AI > Metabot_
![Metabot settings](./images/ai-settings.png)
You can configure Metabot for your internal Metabase separately from [embedded](../embedding/introduction.md) Metabase contexts. That way you can, for example, use Metabot in your Metabase while not granting access to Metabot in your embedded Metabase.
### Enable Metabot
Toggle [Metabot](./metabot.md) on or off for your Metabase. Metabot is enabled by default.
When enabled, Metabot is available to help people create questions, analyze data, and answer questions about your data. When disabled, the Metabot icon and keyboard shortcuts are hidden. Currently, Metabot is available to everyone who uses your Metabase. There's no way to scope Metabot usage per person.
Toggling off Metabot only turns off in-app Metabot features. People can still use the [MCP server](./mcp.md) and [Agent API](./agent-api.md). To turn off the MCP server and Agent API, you'll need to disconnect from the provider in **Admin > AI > Connection settings**.
### Embedded Metabot
The toggle will turn embedded Metabot on or off. The toggle affects both full-app embeds and modular embeds.
_Admin > AI > Embedded Metabot_
The toggle turns embedded Metabot on or off. The toggle affects both full-app embeds and modular embeds.
- [Full-app embedding](../embedding/full-app-embedding.md): The Metabot icon and keyboard shortcuts are only available when Metabot is enabled. Turning off Embedded Metabot will hide these icons and disable the keyboard shortcuts.
- [Modular embedding](../embedding/modular-embedding.md) The toggle doesn't add Metabot anywhere; you have to explicitly include a chat component (like the SDK's [`MetabotQuestion`](../embedding/sdk/ai-chat.md)) in your application. If, however, you've added a component, and you turn off the Embedded Metabot toggle, your chat component will stop working, so you should also remove or hide the component in your application.
- [Modular embedding](../embedding/modular-embedding.md): The toggle doesn't add Metabot anywhere; you have to explicitly include a chat component (like the SDK's [`MetabotQuestion`](../embedding/sdk/ai-chat.md)) in your application. If, however, you've added a component, and you turn off the Embedded Metabot toggle, your chat component will stop working, so you should also remove or hide the component in your application.
## Verified content
### Verified content
Admins on Pro and Enterprise plans can tell Metabot to only work with [models](../data-modeling/models.md) and [metrics](../data-modeling/metrics.md) that have been [verified](../exploration-and-organization/content-verification.md).
Restricting Metabot to verified models and metrics (and only models and metrics) helps Metabot produce more reliable answers, since you know someone has at least vetted the data Metabot can use.
## Collection Metabot can use
### Collection for natural language querying
Select a collection (including its subcollections) to limit which collections Metabot searches during [AI exploration](../ai/metabot.md#ai-exploration).
This setting only affects conversations started from **+ New → AI exploration**.
This setting only affects conversations started from **+ New > AI exploration**.
People can still @-mention items outside of this collection when prompting in AI exploration. Metabot can also see the person's current context (for example, Metabot will know about the dashboard they're currently viewing, even if the dashboard is outside the selected collection).
### Prompt suggestions
When people open a new Metabot chat, Metabase shows a few suggested prompts.
Click **Regenerate suggested prompts** to generate a fresh set of prompts. You can also run individual prompts to test Metabot's answers, or delete prompts that aren't useful.
## Tips for making the most of Metabot
The best thing you can do to improve Metabot's performance is to prep your data like you would for onboarding a new (human) hire to your data. In practice, this means you should:
@@ -74,7 +107,6 @@ The best thing you can do to improve Metabot's performance is to prep your data
- [Add descriptions for your data and content](#add-descriptions-for-your-data-and-content)
- [Make sure the semantic types for each field are correct](#make-sure-the-semantic-types-for-each-field-are-correct)
- [Define domain-specific terms in the glossary](#define-domain-specific-terms-in-the-glossary)
- [Curate prompt suggestions](#curate-prompt-suggestions)
### Add descriptions for your data and content
@@ -85,7 +117,7 @@ Admins can also curate [table metadata](../data-modeling/metadata-editing.md) by
For example, here's a decent description for an ID field that provides additional context for the data:
```txt
This is a unique ID for the product. It is also called the “Invoice number” or “Confirmation number” in customer facing emails and screens.
This is a unique ID for the product. It is also called the "Invoice number" or "Confirmation number" in customer facing emails and screens.
```
You can even ask Metabot to write descriptions for you. But Metabot will only have access to the data in the database. It can't know things like "this ID is called the 'Invoice number' in the web app", which is the kind of contextual information worth documenting.
@@ -98,43 +130,39 @@ Metabase will try to set semantic types automatically, but you should confirm th
### Define domain-specific terms in the glossary
Add your organization's terminology, acronyms, and business-specific terms to the [glossary](../exploration-and-organization/data-model-reference.md#glossary). When Metabot receives a prompt, it can look up terms in the glossary to better understand your request.
Add your organization's terminology, acronyms, and business-specific terms to the [glossary](../exploration-and-organization/data-model-reference.md#glossary). When you submit a prompt, Metabot can look up terms in the glossary to better understand your request.
For example, if you define "MRR" as "Monthly Recurring Revenue" in your glossary, Metabot will know what you mean when you ask "What's our MRR for Q4?" This is especially helpful for industry-specific jargon, internal product names, or abbreviations unique to your organization.
### Curate prompt suggestions
When you select a collection for Metabot to "learn", Metabot will suggest a series of prompts based on the content it finds in that collection. These prompts just give people a feel for the kinds of things people can ask Metabot to do.
Admins can run these generated prompts to test the answers, or trash the individual prompts if they're not useful or misleading. You can also regenerate all the prompts with a click.
## Metabot permissions are Metabase permissions
Metabot inherits the permissions of the current user, so you don't need to set permissions specifically for Metabot. Whenever someone uses Metabot, Metabot can only see what that person has permissions to see and do.
Metabot inherits the permissions of the person it's chatting with, so you don't need to set permissions specifically for Metabot. Whenever someone uses Metabot, Metabot can only see what that person has permissions to see and do.
In other words, to restrict what data Metabot can see for each person, simply apply [data](../permissions/data.md) and [collection](../permissions/collections.md) permissions to their groups as you would normally, and those permissions will apply to their use of Metabot as well.
## Viewing Metabot usage
You can see how many Metabot requests people have made this month by going to **Admin > Settings > License**.
If you're on Metabase Cloud with the Metabot add-on, you can see how many Metabot requests people have made this month by going to **Admin > Settings > License**.
If you aren't logged into the [Metabase Store](../cloud/accounts-and-billing.md).(, you'll need to log in to the store before you can view the usage. Once logged in to the store, go back to your Metabase and view the license page.
If you aren't logged into the [Metabase Store](../cloud/accounts-and-billing.md), you'll need to log in to the store before you can view the usage. Once logged in to the store, go back to your Metabase and view the license page.
The **Metabot AI requests used, this month (updated daily)** field shows how many requests your Metabase has used this month. Each message sent to Metabot counts as a request.
## Metabot can't be enabled per person
If you're using your own API key, you can track usage and costs through your AI provider's dashboard.
Currently, Metabot is available to everyone who uses your Metabase.
## Choosing the AI model
## Metabot uses a variety of generative AI models to answer your questions
If you're using your own API key, you can choose which AI model Metabase uses in [Connection settings](#bring-your-own-api-key).
Under the hood, Metabase powers Metabot with a variety of generative models. For now, you can't change which generative AI models Metabot uses, as Metabase's AI service handles their selection.
On Metabase Cloud, Metabase's AI service selects models automatically. We use internal benchmarks to determine which AI models work best for different tasks, and we're constantly iterating to improve performance.
To get the best results, we (the Metabase team) use internal benchmarks to determine which AI models Metabot should use for different tasks. And we are constantly iterating on performance, so Metabot will continue to improve over time.
## Privacy
## Unless you submit feedback, we don't collect or store the prompts you send to Metabot
On Metabase Cloud with the Metabot add-on, your questions and conversations remain private to your Metabase -- we don't send your data to external services. We do collect some metadata to gauge and improve usage.
We've intentionally limited what Metabot can do. Metabot lacks access to API keys, and it can't create assets, write data, or send your data outside of your Metabase. Your questions and conversations remain private to your Metabase (unless you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses)). We do collect some metadata to gauge and improve usage.
If you're using your own API key, your prompts and data are sent to your selected AI provider. Review your provider's data handling and privacy policies. When using the [MCP server](./mcp.md), query results are sent to the connected MCP client.
In both cases, Metabot can't create assets or write data. If you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses), the form you send may contain sensitive data from your conversation.
### What Metabot can see
@@ -144,4 +172,4 @@ Metabot has access to your Metabase metadata and some data values to help answer
- **Sample field values**: When you ask questions like "Filter everyone from Wisconsin," Metabot might check the values in the state field to understand how the data is stored (like "WI" vs "Wisconsin"). See [syncs](../databases/sync-scan.md).
- **Timeseries data**: For chart analysis, Metabot might see the timeseries data used to draw certain visualizations, depending on the chart type.
When you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses), however, the form you send may contain sensitive data from your conversation.
This data may be included when you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses).

View File

@@ -1,55 +0,0 @@
---
title: SQL generation on self-hosted Metabases
summary: Bring your own API key to generate SQL from natural language in your self-hosted Metabase.
---
# SQL generation on self-hosted Metabases
> One-shot SQL generation with your own API key is only available on self-hosted Metabases. For Metabase Cloud, check out [Metabot](./metabot.md).
Bring your own API key to generate SQL from natural language in the [native editor](../questions/native-editor/writing-sql.md).
## Setting up one-shot SQL generation
To enable SQL generation on a self-hosted Metabase:
1. Go to **Admin settings > AI**.
2. Enter your **Anthropic API key**.
3. Select the AI model you'd like to use for SQL generation. (Opus models typically give the best results.)
![SQL generation Admin](./images/sql-generation-admin.png)
You'll need an API key from [Anthropic](https://www.anthropic.com/learn/build-with-claude). Once you've entered the key, anyone in your Metabase can use SQL generation in the native editor.
Currently, only Anthropic is supported.
## Generate SQL from natural language
![Generate SQL from a natural language prompt](./images/sql-generation.png)
To generate SQL from a natural language prompt:
1. Open the [native editor](../questions/native-editor/writing-sql.md).
2. Press **Cmd+Shift+I** (Mac) or **Ctrl+Shift+I** (Windows).
3. Enter one or more tables.
4. Type a prompt describing the query you want to write.
5. Click **Generate** to create the SQL.
6. Review the generated SQL before running it.
You can also edit existing SQL. Place your cursor in the query, trigger the prompt, and describe the change you want to make (like "add a filter for orders from this year"). Metabase will suggest edits that you can accept or reject. You must select at least one table for both new queries and edits.
![Accept or reject](./images/accept-or-reject.png)
Each prompt is independent — the AI doesn't retain context from previous generations.
Like with all generative AI, always double-check the output.
## Further reading
- [The native/SQL editor](../questions/native-editor/writing-sql.md)
- [Metabot](./metabot.md)

View File

@@ -4,24 +4,24 @@ title: "AI in Metabase"
# AI in Metabase
> Metabot is only available as an add-on for Metabase Cloud.
> AI features are available as an add-on on [Metabase Cloud](https://www.metabase.com/features/metabot-ai), or on self-hosted Metabase with your own AI provider API key.
## [Metabot](./metabot.md)
Metabot is an AI assistant that helps you explore and analyze your data.
## [Setting up Metabot](./settings.md)
## [AI settings](./settings.md)
Admins can configure Metabot to focus on specific collections and manage example prompts.
## [SQL generation](./sql-generation.md)
Use your own API key to generate SQL from natural language on self-hosted Metabases.
Connect an AI provider and configure AI features like Metabot.
## [Agent API](./agent-api.md)
A REST API for building headless, agentic BI applications on top of Metabase's semantic layer.
## [MCP server](./mcp.md)
Connect MCP-compatible AI clients to your Metabase.
## [Metabot in Slack](./metabot-slack.md)
Chat with Metabot directly in Slack — ask questions, get charts, and manage subscriptions without leaving Slack.

View File

@@ -87,6 +87,10 @@ ssh -Nf -L input-port:internal-server-name:port-on-server username@bastion-host.
This allows you to use the full array of features included in SSH. If you find yourself doing this often, please let us know so we can see about making your process more convenient through Metabase.
## Reverse tunnelling
If inbound connections aren't allowed to the infrastructure that hosts the database, then reverse SSH tunneling should be used. Reverse tunnelling works by making a connection from within the database server or a server that lives next to it inside a private network, to an intermediate server that Metabase will connect to.
## Further reading
- [Adding and managing databases](./connecting.md).

View File

@@ -0,0 +1,75 @@
# Release Flags
The purpose of Metabase release flags is to allow developers to progressively merge good, tested, but incomplete code to master and avoid large, long-running feature branches. Release flags should be temporary, and should be removed at the completion of a project. Release flags are not required. You can do a traditional feature branch approach with just a PR, or even better, merge small, independently and immediately useful code to master. But release flags are available as an option to avoid long-running feature branches when necessary.
### Lifecycle
1. Create a PR which adds a new, unique flag to `release-flags/config.edn` and update `metabase-types/api/release-flg.ts` to include the new key. Have it reviewed and merged.
2. Subsequent PRs for the feature should have all code behind the release flag. The code should be disabled at the highest possible level to reduce the number of places where the flag is invoked.
3. When you are ready to launch your feature, create a PR which removes the flag from `release-flags/config.edn` and `metabase-types/api/release-flg.ts`. This will trigger linting and TypeScript errors. Remove the conditionals using the flag. Have it reviewed and merged. You’re done!
## Protecting Code
Develop your code as normal. Require it from namespaces. This will load your code as normal. However, you should protect the call sites of the functions in your code.
Throughout development, all code that should be behind the release flag should be disabled at the highest possible level in the calling code. Good encapsulation principles should be used to minimize the number of needed release flag conditionals. Nesting release flags should be avoided wherever possible. Care should be taken that release flagging code should not diminish the maintainability or readability of code. During development, you can toggle release flags from the ui at `/release-flags` or via the API at `PUT /api/release-flags`.
💡 If you find yourself needing to use the same flag in lots of places, you may need to:
1. Reconsider whether a release flag is appropriate for this feature, or
2. Consider whether your feature code could be better encapsulated
### Clojure
The main way to protect code at a call site is with `metabase.release-flags.core/has-release-flag?`.
Example:
```clojure
(when (release-flags/has-release-flag? :my-new-feature)
...)
```
A new feature often requires new namespaces to be created to support the feature. That code will be committed to master much more quickly than with a feature branch approach. However, the code is not ready to be called normally. This creates a conflict: There is code in master that is not ready to be called. In order to prevent/detect code that incorrectly calls this code, a namespace should also add a single line to the end.
```clojure
(release-flags/guard-namespace! :my-new-feature)
```
This function will instrument the functions in the namespace with code that tacks whether the release flag was enabled. If the code was called but the release flag wasn’t enabled, it will fail a test, preventing it from merging to master. This line should be removed when the feature is released.
A clj-kondo rule will check that `has-release-flag?` and `guard-namespace!` is only called with flags listed in `release-flags/config.edn`.
When you are writing new tests for your feature, you don't want them to fail. You can tell the release flag system to allow calls to your functions by adding this line to the top of the test namespace:
```clojure
(use-fixtures :once (release-flags/bypass-guard-fixture <my-release-flag>))
```
API endpoints that you want to protect behind a release flag should use `check-404` to fail requests as if the route were not even there. Here's an example:
```clojure
(api.macros/defendpoint :get "/" :- [:map
["id" :int]
["type" :string]
["setup" :string]
["punchline" :string]]
"Return the joke of the day."
[]
(api/check-404 (flags/has-release-flag? :joke-of-the-day)) ;; this line at the top of the endpoint
(rand-nth (jokes/jokes)))
```
### TypeScript
TypeScript code is toggled with `hasReleaseFlag("flag-name")`.
```ts
import { hasReleaseFlag } from "metabase/lib/release-flags";
...
if (hasReleaseFlag("cool-feature")) {
return <CoolFeature />;
}
```

View File

@@ -10,7 +10,11 @@ describe("Cross-version questions - pivot", () => {
cy.signIn("admin", { skipCache: true });
cy.log("-- Create a pivot table --");
X.startQuestionFromTable("People");
// Intentionally start from the root collection to prevent ambiguity
// in the collection picker when we attempt to save the question
cy.visit("/collection/root");
H.newButton("Question").click();
H.modal().contains("People").should("be.visible").click();
cy.log(
"-- Add filter: Narrow down the states that start with K (only two) --",

View File

@@ -1,11 +1,5 @@
const { H } = cy;
export function startQuestionFromTable(table: string) {
cy.visit("/");
H.newButton("Question").click();
H.modal().contains(table).should("be.visible").click();
}
export function saveQuestion(name: string) {
cy.log(`-- Save question: ${name}`);
cy.intercept("POST", "/api/card").as("saveQuestion");
@@ -15,6 +9,7 @@ export function saveQuestion(name: string) {
cy.button("Save").click();
cy.wait("@saveQuestion");
});
cy.findByTestId("save-question-modal").should("not.exist");
}
export function assertRowCount(count: string) {

View File

@@ -10,7 +10,12 @@ describe("Cross-version questions - pivot", () => {
cy.signIn("admin", { skipCache: true });
cy.log("-- Create a pivot table --");
X.startQuestionFromTable("People");
// Intentionally start from the root collection to prevent ambiguity
// in the collection picker when we attempt to save the question
cy.visit("/collection/root");
H.newButton("Question").click();
X.selectFromPopover("Sample Database");
X.selectFromPopover("People");
cy.log(
"-- Add filter: Narrow down the states that start with K (only two) --",

View File

@@ -1,12 +1,5 @@
const { H } = cy;
export function startQuestionFromTable(table: string) {
cy.visit("/");
H.newButton("Question").click();
selectFromPopover("Sample Database");
selectFromPopover(table);
}
export function saveQuestion(name: string) {
cy.log(`-- Save question: ${name}`);
cy.intercept("POST", "/api/card").as("saveQuestion");
@@ -16,6 +9,7 @@ export function saveQuestion(name: string) {
cy.button("Save").click();
cy.wait("@saveQuestion");
});
cy.findByTestId("save-question-modal").should("not.exist");
}
export function assertRowCount(count: string) {

View File

@@ -0,0 +1,9 @@
import type { ReleaseFlag } from "metabase-types/api";
export const setReleaseFlag = (flag: ReleaseFlag, value: boolean) =>
cy.request({
method: "PUT",
url: "/api/release-flags",
body: { [flag]: value },
failOnStatusCode: false,
});

View File

@@ -45,6 +45,7 @@ export * from "./e2e-permissions-helpers";
export * from "./e2e-python-helpers";
export * from "./e2e-qa-databases-helpers";
export * from "./e2e-relative-date-picker-helpers";
export * from "./e2e-release-flag-helpers";
export * from "./e2e-remote-sync-helpers";
export * from "./e2e-request-helpers";
export * from "./e2e-search-helpers";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
const { H } = cy;
describe("Admin - Release Flags", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
cy.intercept("GET", "/api/session/properties").as("getSessionProperties");
});
it("should allow toggling a release flag", () => {
cy.visit("/");
cy.wait("@getSessionProperties");
cy.findByTestId("home-page").should("be.visible");
cy.findByTestId("joke-of-the-day").should("not.exist");
cy.request({
method: "GET",
url: "/api/joke-of-the-day",
failOnStatusCode: false,
}).then(({ status }) => {
expect(status).to.equal(404);
});
H.setReleaseFlag("joke-of-the-day", true);
cy.visit("/");
cy.wait("@getSessionProperties");
cy.findByTestId("home-page").should("be.visible");
cy.findByTestId("joke-of-the-day").should("be.visible");
cy.request({
method: "GET",
url: "/api/joke-of-the-day",
failOnStatusCode: false,
}).then(({ status }) => {
expect(status).to.equal(200);
});
});
});

View File

@@ -150,10 +150,11 @@ describe("scenarios > embedding > sdk iframe embedding > authentication", () =>
});
frame.within(() => {
cy.findByTestId("sdk-error-container", { timeout: 10_000 }).should(
"contain",
/Failed to authenticate using an existing Metabase user session./,
);
cy.findByTestId("sdk-error-container")
.findByText(
"Failed to authenticate using an existing Metabase user session.",
)
.should("be.visible");
cy.findByRole("link", { name: "Read more." })
.should("have.attr", "href")

View File

@@ -75,17 +75,19 @@ describe("scenarios > question > view", () => {
cy.signIn("nodata");
H.visitQuestion("@questionId");
// Filter by category and vendor
// The nodata user has view-data permission (via All Users group) but no create-queries permission.
// With param_fields hydration, field filter widgets now show as dropdowns with values.
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("This question is written in SQL.");
cy.findAllByText("VENDOR").first().click();
H.dashboardParametersPopover().within(() => {
H.fieldValuesCombobox().type("Balistreri-Muller");
H.popover().within(() => {
cy.findByPlaceholderText("Search the list").type("Balistreri-Muller");
cy.findByText("Balistreri-Muller").click();
cy.findByText("Add filter").click();
});
cy.findAllByText("CATEGORY").first().click();
H.dashboardParametersPopover().within(() => {
H.fieldValuesCombobox().type("Widget");
H.popover().within(() => {
cy.findByText("Widget").click();
cy.findByText("Add filter").click();
});
@@ -104,7 +106,9 @@ describe("scenarios > question > view", () => {
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Question").click();
// Filter by category and vendor
// The nodata user has view-data permission (via All Users group) but no create-queries
// permission. The dashboard load populates field metadata into the entity cache via
// addFields, so field filter widgets show as dropdowns when navigating to the question.
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("This question is written in SQL.");
cy.findAllByText("VENDOR").first().click();
@@ -115,7 +119,6 @@ describe("scenarios > question > view", () => {
});
cy.findAllByText("CATEGORY").first().click();
H.popover().within(() => {
cy.findByPlaceholderText("Search the list").type("Widget");
cy.findByText("Widget").click();
cy.findByText("Add filter").click();
});

View File

@@ -431,6 +431,47 @@ describe("scenarios > metrics > explorer", () => {
H.MetricsViewer.assertVizType("Line");
});
it("should not show dimensions that are already in tabs in the dimension picker", () => {
addMetric("Count of products");
cy.wait("@dataset");
H.MetricsViewer.tabsShouldBe([
"Created At",
"State",
"Title",
"Category",
]);
H.MetricsViewer.getAddDimensionButton().click();
H.popover().within(() => {
cy.findByText("Rating").should("exist");
// Created At exists on the users table so would be a false positive
// testing the other tabs is sufficent
cy.findByText("State").should("not.exist");
cy.findByText("Title").should("not.exist");
cy.findByText("Category").should("not.exist");
});
});
it("should map shared dimensions to all metrics when adding a tab from the picker", () => {
addMetric("Count of products");
cy.wait("@dataset");
H.MetricsViewer.getAddDimensionButton().click();
H.popover().within(() => {
cy.findByText("Shared").should("exist");
cy.findByText("Rating").click();
});
cy.wait("@dataset");
H.MetricsViewer.tablist()
.findByRole("tab", { name: "Rating" })
.should("have.attr", "aria-selected", "true");
H.MetricsViewer.getDimensionPillContainer().within(() => {
cy.findAllByText("Select a dimension").should("not.exist");
});
});
it("should add a dimension tab and remove it", () => {
addMetric("Count of feedback");
H.MetricsViewer.tabsShouldBe([

View File

@@ -763,7 +763,7 @@ describe("scenarios > visualizations > line chart", () => {
H.openVizSettingsSidebar();
H.leftSidebar().within(() => {
cy.findByText("Display").click();
cy.findByText("Split series into panels").click();
cy.findByText("Stack series").click();
});
H.echartsContainer().within(() => {

View File

@@ -57,7 +57,6 @@
:remote-sync (deferred-tru "Remote Sync")
:etl-connections (deferred-tru "ETL Connections")
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
:metabot-v3 (deferred-tru "MetaBot")
:scim (deferred-tru "SCIM configuration")
:semantic-search (deferred-tru "Semantic Search")
:serialization (deferred-tru "Serialization")

View File

@@ -242,8 +242,7 @@
(let [snapshot-version (source.p/version snapshot)
last-imported-version (remote-sync.task/last-version)
first-import? (nil? last-imported-version)
path-filters [#"collections/.*" #"databases/.*" #"actions/.*"
#"transforms/.*" #"python-libraries/.*" #"snippets/.*"]
path-filters (mapv #(re-pattern (str % "/.*")) serialization/legal-top-level-paths)
base-ingestable (source.p/->ingestable snapshot {:path-filters path-filters})
has-transforms? (snapshot-has-transforms? base-ingestable)
{:keys [conflicts summary]} (get-conflicts base-ingestable first-import?)
@@ -314,8 +313,7 @@
(if-let [models (spec/extract-entities-for-export)]
(do
(remote-sync.task/update-progress! task-id 0.3)
(let [all-delete-prefixes (spec/build-all-removal-paths)
written-version (source/store! models all-delete-prefixes snapshot task-id message)]
(let [written-version (source/store! models snapshot task-id message)]
(remote-sync.task/set-version! task-id written-version))
(t2/update! :model/RemoteSyncObject {:status "synced" :status_changed_at sync-timestamp})
{:status :success

View File

@@ -181,7 +181,7 @@
(throw (ex-info "Invalid repository URL: only HTTPS URLs are supported (e.g., https://git-host.example.com/yourcompany/repo.git)"
{:url remote-sync-url})))
(let [source (git/git-source remote-sync-url "HEAD" remote-sync-token)]
(let [source (git/git-source remote-sync-url "HEAD" remote-sync-token nil)]
(when (and (= :read-only remote-sync-type) (not (str/blank? remote-sync-branch)) (not (some #{remote-sync-branch} (git/branches source))))
(throw (ex-info "Invalid branch name" {:url remote-sync-url :branch remote-sync-branch}))))))

View File

@@ -48,42 +48,36 @@
(defn- remote-sync-path
[opts entity]
(let [base-path (serdes/storage-path entity opts)
dirnames (drop-last base-path)
basename (str (last base-path) ".yaml")]
(str/join File/separator (map serialization/escape-segment (concat dirnames [basename])))))
(let [resolved (serialization/resolve-storage-path opts entity)
dirnames (drop-last resolved)
basename (str (last resolved) ".yaml")]
(str/join File/separator (concat dirnames [basename]))))
(defn- ->file-spec
"Converts entity from serdes stream into file spec for source write-files! "
"Converts entity from serdes stream into file spec for source write-files!"
[task-id count opts idx entity]
(when (instance? Exception entity)
;; Just short-circuit if there are errors.
(throw entity))
(u/prog1 (if (string? entity)
;; when an entity is a string that means it's a path to delete
{:path entity
:remove? true}
{:path (remote-sync-path opts entity)
:content (yaml/generate-string (serialization/serialization-deep-sort entity)
{:dumper-options {:flow-style :block :split-lines false}})})
(u/prog1 {:path (remote-sync-path opts entity)
:content (yaml/generate-string (serialization/serialization-deep-sort entity)
{:dumper-options {:flow-style :block :split-lines false}})}
(remote-sync.task/update-progress! task-id (-> (inc idx) (/ count) (* 0.65) (+ 0.3)))))
(defn store!
"Stores serialized entities from a stream to a remote source and commits the changes.
Takes a stream (a sequence of serialized entities to be stored), a list of prefixes to recursively-delete at,
a snapshot (the remote source implementing the SourceSnapshot protocol where files will be written), a task-id
(the RemoteSyncTask identifier used to track progress updates), and a message (the commit message to use when
writing files to the source).
Takes a stream (a sequence of serialized entities to be stored), a snapshot (the remote source
implementing the SourceSnapshot protocol where files will be written), a task-id (the RemoteSyncTask
identifier used to track progress updates), and a message (the commit message to use when writing
files to the source).
Returns the version written to the source.
Throws Exception if any entity in the stream is an Exception instance."
[stream delete-prefixes snapshot task-id message]
[stream snapshot task-id message]
(let [opts (serdes/storage-base-context)
;; Bound the count of the items in the stream we don't accidentally realize the entire list into memory
stream-count (bounded-count 10000 stream)]
(->> (concat stream delete-prefixes)
(->> stream
(map-indexed #(->file-spec task-id stream-count opts %1 %2))
(source.p/write-files! snapshot message))))
@@ -97,6 +91,7 @@
(git/git-source
(setting/get :remote-sync-url)
(or branch (setting/get :remote-sync-branch))
(setting/get :remote-sync-token)))
(setting/get :remote-sync-token)
serialization/legal-top-level-paths))
([]
(source-from-settings (setting/get :remote-sync-branch))))

View File

@@ -231,21 +231,12 @@
(throw (ex-info (str "Failed to push branch " branch-name " to remote") {:failures failures})))
push-response))
(defn- path-prefix
"Extracts the unique collection identifier from a serialized file path.
Takes a path string like \"collections/abc123_CollectionName/...\" and returns the collection prefix (e.g.,
\"collections/abc123\") which remains stable even when collection names change.
Returns the original path if no collection prefix is found."
(defn- top-level-dir
"Extracts the first path segment from a file path.
E.g., \"databases/my_db/my_db.yaml\" => \"databases\""
[path]
(let [matcher (re-matcher #"^(collections/[^/]{21})_[^/]+/" path)]
(if (re-find matcher)
(second (re-groups matcher))
path)))
(defn- matches-prefix [path prefixes]
(some #(or (= % path) (str/starts-with? path %)) prefixes))
(when-let [idx (str/index-of path "/")]
(subs path 0 idx)))
(defn default-branch
"Retrieves the default branch name of the git repository.
@@ -267,24 +258,17 @@
(defn write-files!
"Writes multiple files to the git repository and commits the changes.
Takes a snapshot map containing a :git Git instance and :version, a commit message string,
and a sequence of file specs (paths should be relative to the repository root).
Takes a snapshot map containing a :git Git instance, :version, and :managed-dirs,
a commit message string, and a sequence of file specs (maps with :path and :content keys,
paths should be relative to the repository root).
Each file spec is a map with either:
- :path and :content keys for writing/updating a file
- :path and :remove? true for recursively removing all files at that path
For writes within collection directories, ALL files in the same collection are replaced
(using the collection entity_id prefix to identify the collection scope). This ensures
that stale files don't remain when a collection's contents change.
For removals, all files matching the path as a prefix are deleted (allowing recursive
directory deletion). Removal entries with empty paths are no-ops. Removing non-existent
paths is also a no-op (idempotent).
All existing files within managed directories that are not in the write set are removed.
This ensures stale files (from renames, moves, or deletions) are cleaned up automatically.
Files outside managed directories are always preserved.
Returns the version written. Throws ExceptionInfo if the write or push
operation fails."
[{:keys [^Git git ^String version ^String branch] :as snapshot} ^String message files]
[{:keys [^Git git ^String version ^String branch managed-dirs] :as snapshot} ^String message files]
(let [repo (.getRepository git)
branch-ref (qualify-branch branch)
parent-id (.resolve repo version)]
@@ -292,35 +276,25 @@
(with-open [inserter (.newObjectInserter repo)]
(let [index (DirCache/newInCore)
builder (.builder index)
;; Extract collection prefixes from written paths - all files in these
;; collections will be deleted and replaced with the new files
write-prefixes (into #{}
(comp
(remove :remove?)
(map :path)
(remove str/blank?)
(map path-prefix))
files)
;; Collect removal paths/prefixes for explicit deletions
removal-prefixes (into #{}
(comp
(filter :remove?)
(map :path)
;; Set of all paths being written in this commit
write-paths (into #{}
(comp (map :path)
(remove str/blank?))
files)]
files)]
;; Add new/updated files to the index
(doseq [{:keys [path content remove?]} files
:when (and (not remove?) (not (str/blank? path)))]
(doseq [{:keys [path content]} files
:when (not (str/blank? path))]
(let [blob-id (.insert inserter Constants/OBJ_BLOB (.getBytes ^String content "UTF-8"))
entry (doto (DirCacheEntry. ^String path)
(.setFileMode FileMode/REGULAR_FILE)
(.setObjectId blob-id))]
(.add builder entry)))
;; Copy existing tree entries, excluding:
;; 1. Files in collections being written to (using write-prefixes)
;; 2. Files matching explicit removal prefixes
;; Copy existing tree entries that should be preserved:
;; - Outside managed directories AND not being overwritten by the write set
;; Files in managed dirs not in write-paths are dropped (stale file cleanup)
;; Files in write-paths are skipped here (already added above with new content)
(when parent-id
(with-open [rev-walk (RevWalk. repo)
tree-walk (TreeWalk. repo)]
@@ -328,10 +302,9 @@
(.addTree tree-walk (.getTree commit))
(.setRecursive tree-walk true)
(while (.next tree-walk)
(let [path (.getPathString tree-walk)
existing-prefix (path-prefix path)]
(when-not (or (contains? write-prefixes existing-prefix)
(matches-prefix path removal-prefixes))
(let [path (.getPathString tree-walk)]
(when (and (not (contains? write-paths path))
(not (contains? managed-dirs (top-level-dir path))))
(let [entry (doto (DirCacheEntry. path)
(.setFileMode (.getFileMode tree-walk 0))
(.setObjectId (.getObjectId tree-walk 0)))]
@@ -425,7 +398,7 @@
(push-branch! (assoc source :branch branch-name))
branch-name))
(defrecord GitSnapshot [git remote-url branch version token]
(defrecord GitSnapshot [git remote-url branch version token managed-dirs]
source.p/SourceSnapshot
(list-files [this]
@@ -470,7 +443,7 @@
(fetch! source)
(let [version (commit-sha source (:branch source))]
(if version
(->GitSnapshot (:git source) (:remote-url source) (:branch source) version (:token source))
(->GitSnapshot (:git source) (:remote-url source) (:branch source) version (:token source) (:managed-dirs source))
(throw (ex-info (str "Invalid branch: " (:branch source)) {})))))
(defn- snapshot
@@ -488,7 +461,7 @@
(snapshot* fresh-source)))
(throw e)))))
(defrecord GitSource [git remote-url branch token]
(defrecord GitSource [git remote-url branch token managed-dirs]
source.p/Source
(branches [source] (branches source))
@@ -504,10 +477,12 @@
(defn git-source
"Creates a new GitSource instance for a git repository.
Takes a URL string (the git repository URL), a branch, and an
optional token string (authentication token for private repositories).
Takes a URL string (the git repository URL), a branch, an
optional token string (authentication token for private repositories),
and a set of managed top-level directory names. Files in managed directories
are fully replaced during writes — any existing file not in the write set is removed.
Returns a GitSource record implementing the Source protocol."
[url branch token]
[url branch token managed-dirs]
(->GitSource (get-jgit (repo-path {:remote-url url :token token}) {:remote-url url :token token})
url branch token))
url branch token managed-dirs))

View File

@@ -51,11 +51,10 @@
"Writes multiple files to the source with a commit message.
Takes a SourceSnapshot instance implementing this protocol, a message (the commit message to use when writing files),
and files (a sequence of file specs). Each file spec is a map with either:
- :path and :content keys for writing/updating a file
- :path and :remove? true for recursively removing all files at that path
and files (a sequence of file specs). Each file spec is a map with :path and :content keys.
Removal entries with empty paths are no-ops. Removing non-existent paths is also a no-op (idempotent).
All existing files within managed directories that are not in the write set are removed.
Files outside managed directories are always preserved.
Returns the version of the written files.")

View File

@@ -21,7 +21,6 @@
[metabase.models.serialization :as serdes]
[metabase.settings.core :as setting]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
@@ -860,42 +859,6 @@
:by-path {}}
seen-paths))
;;; --------------------------------------------- Export Path Construction ---------------------------------------------
(defn- transform-entity-for-serdes
"Transforms entity fields to serdes format for path generation.
- Converts integer table_id to [db-name schema table-name] format
- Converts integer collection_id to entity_id string for collection path lookup"
[entity]
(cond-> entity
;; Transform table_id for Segment (and other table-based entities)
(and (:table_id entity) (integer? (:table_id entity)))
(assoc :table_id (serdes/*export-table-fk* (:table_id entity)))
;; Transform collection_id for snippet and other collection-based entities
(and (:collection_id entity) (integer? (:collection_id entity)))
(assoc :collection_id (t2/select-one-fn :entity_id :model/Collection :id (:collection_id entity)))))
(defn- entity->serdes-path
"Builds the file path for an entity using serdes/storage-path.
For Collections, returns the directory path (for recursive deletion).
For other entities, returns the full file path.
Returns the path as a string (without extension), or nil if path cannot be built."
[model-type entity ctx]
(try
(when-let [serdes-meta (serdes/generate-path model-type entity)]
(let [;; Transform entity fields to serdes format (e.g., table_id -> [db schema table])
transformed-entity (transform-entity-for-serdes entity)
entity-with-meta (assoc transformed-entity :serdes/meta serdes-meta)
storage-path (serdes/storage-path entity-with-meta ctx)]
;; For Collections, drop the last segment (filename) to get directory path
(if (= model-type "Collection")
(str/join "/" (butlast storage-path))
(str/join "/" storage-path))))
(catch Exception e
(log/warnf "Failed to build storage path for %s %s: %s"
model-type (:id entity) (ex-message e))
nil)))
;;; -------------------------------------------- Event Helper Functions ------------------------------------------------
(defn determine-status
@@ -936,86 +899,6 @@
[:id :name :collection_id])
[:id :name :collection_id]))
;;; -------------------------------------------- Removal Path Building --------------------------------------------------
(defn- query-removed-ids
"Queries RemoteSyncObject for model IDs marked for removal."
[model-type statuses]
(t2/select-fn-set :model_id :model/RemoteSyncObject
:model_type model-type
:status [:in (vec statuses)]))
(defn- query-removed-entities
"Queries entities marked for removal with all fields needed for serdes path generation.
serdes/generate-path needs: entity_id, name (for label)
serdes/storage-path needs: collection_id (for collection context), table_id (for segment paths)"
[{:keys [model-type model-key removal]}]
(let [{:keys [statuses]} removal
removed-ids (query-removed-ids model-type statuses)]
(when (seq removed-ids)
;; Select all columns since different models need different fields for serdes paths
(t2/select model-key :id [:in removed-ids]))))
(defn- setting-sentinel-delete?
"Returns true if the setting's sentinel RSO exists with 'delete' status.
Used to detect when a setting (like :remote-sync-transforms) has been disabled
and all entities of the controlled types should be removed."
[setting-key]
(when (= setting-key :remote-sync-transforms)
(t2/exists? :model/RemoteSyncObject
:model_type "Collection"
:model_id rs-settings/transforms-root-id
:status "delete")))
(defn- build-bulk-removal-paths
"Builds removal paths for ALL entities of a spec's model type.
Used when the controlling setting is disabled (sentinel RSO has 'delete' status).
Respects :removal-conditions (or :conditions) from the spec to exclude ineligible entities."
[spec ctx]
(let [{:keys [model-type model-key]} spec
conditions (removal-conditions spec)]
(for [entity (if conditions
(apply t2/select model-key (into [] cat conditions))
(t2/select model-key))
:let [path (entity->serdes-path model-type entity ctx)]
:when path]
path)))
(defn build-all-removal-paths
"Builds full file paths for all entities marked for removal in RemoteSyncObject.
Uses serdes/storage-path to generate paths that exactly match the file structure.
Does bulk queries per model type for efficiency:
1. Query RemoteSyncObject for model_ids with removal statuses
2. Query actual entities by those IDs
3. Use serdes/storage-path to build the exact file path
Also handles bulk removal for specs with :all-on-setting-disable when the
controlling setting's sentinel RSO has 'delete' status.
Returns paths without file extensions - the git source adds .yaml as needed."
[]
(let [ctx (serdes/storage-base-context)
;; Standard removal paths from enabled specs
standard-paths (into []
(for [[_model-key spec] (enabled-specs)
:when (spec-enabled? spec)
:let [{:keys [statuses]} (:removal spec)
model-type (:model-type spec)]
:when (seq statuses)
entity (query-removed-entities spec)
:let [path (entity->serdes-path model-type entity ctx)]
:when path]
path))
;; Bulk removal paths for specs with :all-on-setting-disable
bulk-paths (into []
(for [[_model-key spec] remote-sync-specs
:let [setting-key (get-in spec [:removal :all-on-setting-disable])]
:when (and setting-key (setting-sentinel-delete? setting-key))
path (build-bulk-removal-paths spec ctx)]
path))]
(into standard-paths bulk-paths)))
;;; ----------------------------------------- Sync Object Query Functions --------------------------------------------
(defmulti query-entities-for-sync

View File

@@ -21,6 +21,7 @@
make-targets-of-type
extract]
[metabase-enterprise.serialization.v2.ingest
legal-top-level-paths
strip-labels
Ingestable
ingest-yaml
@@ -33,4 +34,4 @@
load-metabase!]
[metabase-enterprise.serialization.v2.storage
store!
escape-segment])
resolve-storage-path])

View File

@@ -79,8 +79,10 @@
(yaml/from-file {:key-fn parse-key})
read-timestamps))
(def legal-top-level-paths "Known top-level paths for directory with serialization output"
#{"actions" "collections" "databases" "glossary" "python-libraries" "snippets" "transforms"})
(def legal-top-level-paths
"Known top-level paths for directory with serialization output.
We support both \"python-libraries\" and \"python_libraries\" for backwards compatibility. The modern name is \"python_libraries\"."
#{"actions" "collections" "databases" "glossary" "python_libraries" "python-libraries" "snippets" "transforms"})
(defn- ingest-all
"Returns {:entities {unlabeled-hierarchy [hierarchy File]}, :errors [Exception...]}.

View File

@@ -113,6 +113,7 @@
"QueryField"
"QueryTable"
"RecentViews"
"ReleaseFlag"
"ReplacementRun"
"RemoteSyncObject"
"RemoteSyncTask"

View File

@@ -3,29 +3,65 @@
[clojure.java.io :as io]
[clojure.string :as str]
[metabase-enterprise.serialization.dump :refer [spit-yaml!]]
[metabase.lib.core :as lib]
[metabase.models.serialization :as serdes]
[metabase.util.log :as log])
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.string :as u.str])
(:import
[java.io File]
[java.nio.file Path]))
(set! *warn-on-reflection* true)
(defn escape-segment
"Given a path segment, which is supposed to be the name of a single file or directory, escape any slashes inside it.
This occurs in practice, for example with a `Field.name` containing a slash like \"Company/organization website\"."
[segment]
(-> segment
(str/replace "/" "__SLASH__")
(str/replace "\\" "__BACKSLASH__")))
(def ^:private max-label-length 100)
(def ^:private max-label-bytes 200) ;; 255 is a limit in ext4
(defn- slugify-name
"Slugify a name for use as a file or directory name: lowercase, replace special chars with underscores,
preserve dots and unicode, escape slashes. Truncated for filesystem safety."
[^String s]
(when (seq s)
(-> s
u/lower-case-en
(str/replace "\\" "__BACKSLASH__")
(str/replace "/" "__SLASH__")
(str/replace #"[^\p{L}\p{N}_.]" "_")
(u.str/limit-bytes max-label-bytes)
(u.str/limit-chars max-label-length))))
(defn- resolve-path
"Given a storage path (vector of `{:label ... :key ...}` maps), resolves to a vector of strings
with deduplication per folder. Uses `unique-name-fns` atom `{parent-key -> unique-name-fn}` where
each fn is a `lib/non-truncating-unique-name-generator`."
[unique-name-fns path]
(loop [remaining path
parent-key nil
resolved []]
(if (empty? remaining)
resolved
(let [{:keys [label key]} (first remaining)
slug (slugify-name label)
gen (or (get @unique-name-fns parent-key)
(let [g (lib/non-truncating-unique-name-generator)]
(swap! unique-name-fns assoc parent-key g)
g))
unique-name (gen key slug)]
(recur (rest remaining)
key
(conj resolved unique-name))))))
(defn resolve-storage-path
"Given ctx and entity, returns a vector of resolved (slugified, deduplicated) path strings.
The last element is the filename (without extension)."
[ctx entity]
(resolve-path (:unique-name-fns ctx) (serdes/storage-path entity ctx)))
(defn- file ^File [ctx entity]
(let [;; Get the desired [[serdes/storage-path]].
base-path (serdes/storage-path entity ctx)
dirnames (drop-last base-path)
;; Attach the file extension to the last part.
basename (str (last base-path) ".yaml")]
(apply io/file (:root-dir ctx) (map escape-segment (concat dirnames [basename])))))
(let [resolved (resolve-storage-path ctx entity)
dirnames (drop-last resolved)
basename (str (last resolved) ".yaml")]
(apply io/file (:root-dir ctx) (concat dirnames [basename]))))
(defn- store-entity! [opts entity]
(let [f (file opts entity)]

View File

@@ -19,14 +19,19 @@
(derive :hook/timestamped?))
(defn fetch-or-create-support-user!
"Fetch or Create the support user account from settings."
"Fetch or Create the support user account from settings.
If the user exists but is deactivated, reactivate them."
[]
(or (t2/select-one :model/User :email (sag.settings/support-access-grant-email))
(t2/insert-returning-instance! :model/User
{:email (sag.settings/support-access-grant-email)
:first_name (sag.settings/support-access-grant-first-name)
:last_name (sag.settings/support-access-grant-last-name)
:password (str (random-uuid))})))
(if-let [user (t2/select-one :model/User :email (sag.settings/support-access-grant-email))]
(do
(when-not (:is_active user)
(t2/update! :model/User (:id user) {:is_active true}))
(assoc user :is_active true))
(t2/insert-returning-instance! :model/User
{:email (sag.settings/support-access-grant-email)
:first_name (sag.settings/support-access-grant-first-name)
:last_name (sag.settings/support-access-grant-last-name)
:password (str (random-uuid))})))
(methodical/defmethod t2/batched-hydrate [:model/SupportAccessGrantLog :user_info]
[_model _k grants]

View File

@@ -94,8 +94,7 @@
[:path])
(defmethod serdes/storage-path "PythonLibrary" [entity _ctx]
(let [{:keys [id label]} (-> entity serdes/path last)]
["python-libraries" (serdes/storage-leaf-file-name id label)]))
[{:label "python-libraries"} {:label (:path entity) :key (:entity_id entity)}])
;;; ------------------------------------------------ Event Hooks -----------------------------------------------------

View File

@@ -20,7 +20,7 @@
"Sets up JWT authentication and premium features for Agent API tests."
[& body]
`(sso.test-setup/with-jwt-default-setup!
(mt/with-additional-premium-features #{:agent-api :metabot-v3}
(mt/with-additional-premium-features #{:agent-api}
~@body)))
(defn- current-epoch-seconds []

View File

@@ -29,7 +29,7 @@
"Sets up JWT auth and premium features needed for agent API + workspaces."
[& body]
`(sso.test-setup/with-jwt-default-setup!
(mt/with-additional-premium-features #{:agent-api :metabot-v3 :workspaces :transforms}
(mt/with-additional-premium-features #{:agent-api :workspaces :transforms}
~@body)))
(defn- auth-headers

View File

@@ -28,7 +28,6 @@
:embedding-simple
:embedding-hub
:hosting
:metabot-v3
:no-upsell
:official-collections
:query-reference-validation
@@ -74,7 +73,6 @@
:embedding_sdk true
:embedding_simple true
:hosting true
:metabot_v3 true
:official_collections true
:query_reference_validation true
:remote_sync true

View File

@@ -9,39 +9,38 @@
(set! *warn-on-reflection* true)
(deftest feedback-endpoint-test
(mt/with-premium-features #{:metabot-v3}
(let [store-url "http://hm.example"
fake-token "test-fake-token-for-feedback"]
(testing "Submits feedback to Harbormaster with token and base URL"
(mt/with-temporary-setting-values [store-api-url store-url]
(let [captured (atom nil)
feedback {:metabot_id 1
:feedback {:positive true
:message_id "m-1"
:freeform_feedback "ok"}
:conversation_data {}
:version "v0.0.0"
:submission_time "2025-01-01T00:00:00Z"
:is_admin false}
expected-url (str store-url "/api/v2/metabot/feedback/" fake-token)]
(mt/with-dynamic-fn-redefs
[premium-features/premium-embedding-token (constantly fake-token)
http/post (fn [url opts]
(reset! captured {:url url
:body (json/decode+kw (:body opts))}))]
(let [_resp (mt/user-http-request :rasta :post 204 "metabot/feedback" feedback)]
(is (= {:url expected-url :body feedback}
@captured)))))))
(testing "Returns 500 when http post fails"
(mt/with-temporary-setting-values [store-api-url store-url]
(let [store-url "http://hm.example"
fake-token "test-fake-token-for-feedback"]
(testing "Submits feedback to Harbormaster with token and base URL"
(mt/with-temporary-setting-values [store-api-url store-url]
(let [captured (atom nil)
feedback {:metabot_id 1
:feedback {:positive true
:message_id "m-1"
:freeform_feedback "ok"}
:conversation_data {}
:version "v0.0.0"
:submission_time "2025-01-01T00:00:00Z"
:is_admin false}
expected-url (str store-url "/api/v2/metabot/feedback/" fake-token)]
(mt/with-dynamic-fn-redefs
[premium-features/premium-embedding-token (constantly fake-token)
http/post (fn [_url _opts]
(throw (ex-info "boom" {:status 404})))]
(mt/user-http-request :rasta :post 500 "metabot/feedback" {:any "payload"}))))
http/post (fn [url opts]
(reset! captured {:url url
:body (json/decode+kw (:body opts))}))]
(let [_resp (mt/user-http-request :rasta :post 204 "metabot/feedback" feedback)]
(is (= {:url expected-url :body feedback}
@captured)))))))
(testing "Throws when premium token is missing"
(testing "Returns 500 when http post fails"
(mt/with-temporary-setting-values [store-api-url store-url]
(mt/with-dynamic-fn-redefs
[premium-features/premium-embedding-token (constantly nil)]
(mt/user-http-request :rasta :post 400 "metabot/feedback" {:foo "bar"}))))))
[premium-features/premium-embedding-token (constantly fake-token)
http/post (fn [_url _opts]
(throw (ex-info "boom" {:status 404})))]
(mt/user-http-request :rasta :post 500 "metabot/feedback" {:any "payload"}))))
(testing "Throws when premium token is missing"
(mt/with-dynamic-fn-redefs
[premium-features/premium-embedding-token (constantly nil)]
(mt/user-http-request :rasta :post 400 "metabot/feedback" {:foo "bar"})))))

View File

@@ -44,7 +44,7 @@
(deftest import!-with-branch-parameter-test
(testing "import! with branch parameter uses provided branch"
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
custom-files {"custom-branch" {"collections/custom-collection.yaml"
custom-files {"custom-branch" {"collections/main/custom_collection/custom_collection.yaml"
(test-helpers/generate-collection-yaml "custom-collection-idx" "Custom Collection")}}
result (impl/import! (source.p/snapshot (test-helpers/create-mock-source :initial-files custom-files)) task-id)]
(is (= :success (:status result))))))
@@ -83,9 +83,9 @@
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
;; A valid collection + a card with unparseable YAML (malformed syntax)
bad-yaml "name: Bad Card\nentity_id: bad-card-entity0001\ndataset_query: [invalid\n"
files {"main" {"collections/coll01xxxxxxxxxxxxx_test/coll01xxxxxxxxxxxxx_test.yaml"
files {"main" {"collections/main/test/test.yaml"
(test-helpers/generate-collection-yaml "coll01xxxxxxxxxxxxx" "Test")
"collections/coll01xxxxxxxxxxxxx_test/cards/bad-card-entity0001.yaml"
"collections/main/test/bad_card.yaml"
bad-yaml}}
result (impl/import! (source.p/snapshot (test-helpers/create-mock-source :initial-files files)) task-id)]
(is (= :error (:status result))
@@ -257,9 +257,9 @@
:model/Collection {coll2-id :id} {:name "Collection 2" :is_remote_synced true :entity_id "test-collection-2xxxx" :location "/"}
:model/Card {card1-id :id} {:name "Card 1" :collection_id coll1-id :entity_id "test-card-1xxxxxxxxxx"}
:model/Card {card2-id :id} {:name "Card 2" :collection_id coll2-id :entity_id "test-card-2xxxxxxxxxx"}]
(let [test-files {"test-branch" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"test-branch" {"collections/main/test_collection_1/test_collection_1.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection 1")
"collections/test-collection-1xxxx-_/cards/test-card-1.yaml"
"collections/main/test_collection_1/test_card_1.yaml"
(test-helpers/generate-card-yaml "test-card-1xxxxxxxxxx" "Test Card 1" "test-collection-1xxxx")}}
mock-main (test-helpers/create-mock-source :initial-files test-files :branch "test-branch")
result (impl/import! (source.p/snapshot mock-main) (:id import-task))]
@@ -326,9 +326,9 @@
{:model_type "Card" :model_id card-id :model_name "Test Card" :status "updated" :status_changed_at (t/offset-date-time)}
{:model_type "Card" :model_id 999 :model_name "Test Card2" :status "deleted" :status_changed_at (t/offset-date-time)}])
(is (= 3 (t2/count :model/RemoteSyncObject)))
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"collections/test-collection-1xxxx-_/cards/test-card-1.yaml"
"collections/main/test_collection/test_card.yaml"
(test-helpers/generate-card-yaml "test-card-1xxxxxxxxxx" "Test Card" "test-collection-1xxxx")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -386,21 +386,21 @@
(t2/insert! :model/RemoteSyncObject
[{:model_type "Collection" :model_id active-coll-id :model_name "Active Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Collection" :model_id removed-coll-id :model_name "Removed Collection" :status "removed" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {"collections/active-collection-xxx_active_collection/active-collection-xxx.yaml"
(let [initial-files {"main" {"collections/main/active_collection/active_collection.yaml"
(test-helpers/generate-collection-yaml "active-collection-xxx" "Active Collection")
"collections/active-collection-xxx_active_collection/cards/active-card.yaml"
"collections/main/active_collection/active_card.yaml"
(test-helpers/generate-card-yaml "active-card-xxxxxxxxx" "Active Card" "active-collection-xxx")
"collections/removed-collection-xx_removed_collection/removed-collection-xx.yaml"
"collections/main/removed_collection/removed_collection.yaml"
(test-helpers/generate-collection-yaml "removed-collection-xx" "Removed Collection")
"collections/removed-collection-xx_removed_collection/cards/removed-card.yaml"
"collections/main/removed_collection/removed_card.yaml"
(test-helpers/generate-card-yaml "removed-card-xxxxxxxx" "Removed Card" "removed-collection-xx")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (some #(str/includes? % "active-collection-xxx") (keys files-after-export))
(is (some #(str/includes? % "active_collection") (keys files-after-export))
"Active collection files should exist after export")
(is (not (some #(str/includes? % "removed-collection-xx") (keys files-after-export)))
(is (not (some #(str/includes? % "removed_collection") (keys files-after-export)))
"Removed collection files should be deleted after export")))))))))
(deftest export!-only-deletes-top-level-removed-collections-test
@@ -421,18 +421,18 @@
(t2/insert! :model/RemoteSyncObject
[{:model_type "Collection" :model_id parent-coll-id :model_name "Parent Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Collection" :model_id nested-coll-id :model_name "Nested Collection" :status "removed" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {"collections/parent-collection-xx_parent_collection/parent-collection-xx.yaml"
(let [initial-files {"main" {"collections/main/parent_collection/parent_collection.yaml"
(test-helpers/generate-collection-yaml "parent-collection-xx" "Parent Collection")
"collections/parent-collection-xx_parent_collection/nested-removed-collxx_nested_removed_collection/nested-removed-collxx.yaml"
"collections/main/parent_collection/nested_removed_collection/nested_removed_collection.yaml"
(test-helpers/generate-collection-yaml "nested-removed-collxx" "Nested Removed Collection"
:parent-id "parent-collection-xx")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (some #(str/includes? % "parent-collection-xx") (keys files-after-export))
(is (some #(str/includes? % "parent_collection") (keys files-after-export))
"Parent collection files should exist after export")
(is (some #(str/includes? % "nested-removed-collxx") (keys files-after-export))
(is (some #(str/includes? % "nested_removed_collection") (keys files-after-export))
"Nested removed collection files should NOT be deleted (only top-level removals are processed)")))))))))
(deftest export!-handles-multiple-removed-top-level-collections-test
@@ -460,23 +460,23 @@
[{:model_type "Collection" :model_id active-coll-id :model_name "Active Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Collection" :model_id removed-coll-1-id :model_name "Removed Col 1" :status "removed" :status_changed_at (t/offset-date-time)}
{:model_type "Collection" :model_id removed-coll-2-id :model_name "Removed Col 2" :status "removed" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {"collections/removed-coll-1xxxxxxx_removed_collection_1/removed-coll-1xxxxxxx.yaml"
(let [initial-files {"main" {"collections/main/removed_collection_1/removed_collection_1.yaml"
(test-helpers/generate-collection-yaml "removed-coll-1xxxxxxx" "Removed Collection 1")
"collections/removed-coll-1xxxxxxx_removed_collection_1/cards/card-1.yaml"
"collections/main/removed_collection_1/card_1.yaml"
(test-helpers/generate-card-yaml "card-1-entity-idxxxxx" "Card 1" "removed-coll-1xxxxxxx")
"collections/removed-coll-2xxxxxxx_removed_collection_2/removed-coll-2xxxxxxx.yaml"
"collections/main/removed_collection_2/removed_collection_2.yaml"
(test-helpers/generate-collection-yaml "removed-coll-2xxxxxxx" "Removed Collection 2")
"collections/removed-coll-2xxxxxxx_removed_collection_2/dashboards/dash-1.yaml"
"collections/main/removed_collection_2/dashboard_1.yaml"
(test-helpers/generate-dashboard-yaml "dash-1-entity-idxxxxx" "Dashboard 1" "removed-coll-2xxxxxxx")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (some #(str/includes? % "active-coll-xxxxxxxxx") (keys files-after-export))
(is (some #(str/includes? % "active_collection") (keys files-after-export))
"Active collection files should exist after export")
(is (not (some #(str/includes? % "removed-coll-1xxxxxxx") (keys files-after-export)))
(is (not (some #(str/includes? % "removed_collection_1") (keys files-after-export)))
"First removed collection files should be deleted")
(is (not (some #(str/includes? % "removed-coll-2xxxxxxx") (keys files-after-export)))
(is (not (some #(str/includes? % "removed_collection_2") (keys files-after-export)))
"Second removed collection files should be deleted")))))))))
(deftest finish-remote-config!-sets-default-branch-when-blank-test
@@ -546,7 +546,7 @@
(deftest import!-v57-type-remote-synced-migration-test
(testing "importing v57 export with type=remote-synced sets is_remote_synced=true and clears type"
(let [v57-entity-id "v57-collection-xxxx"
v57-files {"main" {(str "collections/" v57-entity-id "_v57_collection/" v57-entity-id "_v57_collection.yaml")
v57-files {"main" {"collections/main/v57_collection/v57_collection.yaml"
(test-helpers/generate-v57-collection-yaml v57-entity-id "V57 Collection" :type "remote-synced")}}
mock-source (test-helpers/create-mock-source :initial-files v57-files)]
(mt/with-model-cleanup [:model/Collection :model/RemoteSyncTask]
@@ -565,10 +565,10 @@
(testing "importing v57 export with nested type=remote-synced collections migrates all correctly"
(let [parent-entity-id "v57-parent-collxxxxxx"
child-entity-id "v57-child-collxxxxxxx"
v57-files {"main" {(str "collections/" parent-entity-id "_v57_parent/" parent-entity-id "_v57_parent.yaml")
v57-files {"main" {"collections/main/v57_parent/v57_parent.yaml"
(test-helpers/generate-v57-collection-yaml parent-entity-id "V57 Parent" :type "remote-synced")
(str "collections/" parent-entity-id "_v57_parent/" child-entity-id "_v57_child/" child-entity-id "_v57_child.yaml")
"collections/main/v57_parent/v57_child/v57_child.yaml"
(test-helpers/generate-v57-collection-yaml child-entity-id "V57 Child" :parent-id parent-entity-id :type "remote-synced")}}
mock-source (test-helpers/create-mock-source :initial-files v57-files)]
(mt/with-model-cleanup [:model/Collection :model/RemoteSyncTask]
@@ -603,13 +603,13 @@
(t2/insert! :model/RemoteSyncObject
[{:model_type "Collection" :model_id coll-id :model_name "Active Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Table" :model_id table-id :model_name "test-table" :model_table_id table-id :model_table_name "test-table" :status "removed" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {"databases/test-db/tables/test-table/test-table.yaml"
(let [initial-files {"main" {"databases/test_db/tables/test_table/test_table.yaml"
(test-helpers/generate-table-yaml "test-table" "test-db")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % "test-table") (keys files-after-export)))
(is (not (some #(str/includes? % "test_table") (keys files-after-export)))
"Removed table files should be deleted after export")))))))))
(deftest export!-deletes-files-for-removed-segments-test
@@ -634,13 +634,13 @@
[{:model_type "Collection" :model_id coll-id :model_name "Active Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Segment" :model_id segment-id :model_name "Test Segment" :model_table_id table-id :model_table_name "test-table" :status "removed" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {;; File path uses slugified name to match what serdes/storage-path generates
"databases/test-db/tables/test-table/segments/test-segment-xxxxxxxx_test_segment.yaml"
"databases/test_db/tables/test_table/segments/test_segment.yaml"
(test-helpers/generate-segment-yaml "Test Segment" "test-table" "test-db")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % "test-segment-xxxxxxxx") (keys files-after-export)))
(is (not (some #(str/includes? % "test_segment") (keys files-after-export)))
"Removed segment files should be deleted after export")))))))))
(deftest export!-updates-removed-table-entries-to-synced-test
@@ -701,13 +701,13 @@
(t2/insert! :model/RemoteSyncObject
[{:model_type "Collection" :model_id coll-id :model_name "Active Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Table" :model_id table-id :model_name "test-table" :model_table_id table-id :model_table_name "test-table" :status "removed" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {"databases/test-db/schemas/PUBLIC/tables/test-table/test-table.yaml"
(let [initial-files {"main" {"databases/test_db/schemas/public/tables/test_table/test_table.yaml"
(test-helpers/generate-table-yaml "test-table" "test-db" :schema "PUBLIC")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % "test-table") (keys files-after-export)))
(is (not (some #(str/includes? % "test_table") (keys files-after-export)))
"Removed table files should be deleted after export (including schema path)")))))))))
(deftest export!-excludes-archived-segments-test
@@ -747,9 +747,9 @@
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")
file-keys (keys files-after-export)]
(is (some #(str/includes? % "active-segment-xxxxxx") file-keys)
(is (some #(str/includes? % "active_segment") file-keys)
"Active segment should be exported")
(is (not (some #(str/includes? % "archived-segment-xxxx") file-keys))
(is (not (some #(str/includes? % "archived_segment") file-keys))
"Archived segment should NOT be exported")))))))))
(deftest export!-deletes-files-for-archived-segments-test
@@ -774,13 +774,13 @@
(t2/insert! :model/RemoteSyncObject
[{:model_type "Collection" :model_id coll-id :model_name "Active Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Segment" :model_id segment-id :model_name "Archived Segment" :model_table_id table-id :model_table_name "test-table" :status "delete" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {"databases/test-db/tables/test-table/segments/archived-seg-xxxxxxxx_archived_segment.yaml"
(let [initial-files {"main" {"databases/test_db/tables/test_table/segments/archived_segment.yaml"
(test-helpers/generate-segment-yaml "Archived Segment" "test-table" "test-db")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % "archived-seg-xxxxxxxx") (keys files-after-export)))
(is (not (some #(str/includes? % "archived_segment") (keys files-after-export)))
"Archived segment files should be deleted after export"))
(testing "RemoteSyncObject entry is cleaned up after export"
(is (= "synced" (:status (t2/select-one :model/RemoteSyncObject :model_type "Segment" :model_id segment-id)))
@@ -799,9 +799,9 @@
:is_remote_synced true
:entity_id "test-collection-1xxxx"
:location "/"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"databases/test-db/tables/test-table/test-table.yaml"
"databases/test_db/tables/test_table/test_table.yaml"
(test-helpers/generate-table-yaml "test-table" "test-db")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -828,9 +828,9 @@
:is_remote_synced true
:entity_id "test-collection-1xxxx"
:location "/"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"databases/test-db/tables/test-table/fields/test-field.yaml"
"databases/test_db/tables/test_table/fields/test_field.yaml"
(test-helpers/generate-field-yaml "test-field" "test-table" "test-db")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -858,13 +858,13 @@
:definition {:source-table table-id
:filter [:> [:field field-id nil] 0]}
:entity_id "TNdMrOCMHrQc_UtvCbTC5"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"databases/test-db/tables/test-table/test-table.yaml"
"databases/test_db/tables/test_table/test_table.yaml"
(test-helpers/generate-table-yaml "test-table" "test-db")
"databases/test-db/tables/test-table/fields/test-field.yaml"
"databases/test_db/tables/test_table/fields/test_field.yaml"
(test-helpers/generate-field-yaml "test-field" "test-table" "test-db" :base-type "type/Integer" :database-type "INTEGER")
"databases/test-db/tables/test-table/segments/TNdMrOCMHrQc_UtvCbTC5_test_segment.yaml"
"databases/test_db/tables/test_table/segments/test_segment.yaml"
(test-helpers/generate-segment-yaml "Test Segment" "test-table" "test-db"
:entity-id "TNdMrOCMHrQc_UtvCbTC5"
:filter-field-name "test-field")}}
@@ -887,9 +887,9 @@
:is_remote_synced true
:entity_id "test-collection-1xxxx"
:location "/"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"databases/test-db/schemas/PUBLIC/tables/test-table/test-table.yaml"
"databases/test_db/schemas/PUBLIC/tables/test_table/test_table.yaml"
(test-helpers/generate-table-yaml "test-table" "test-db" :schema "PUBLIC")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -913,11 +913,11 @@
:collection_id coll-id
:type :model
:dataset_query (mt/mbql-query venues)}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"collections/test-collection-1xxxx-_/cards/test-model.yaml"
"collections/main/test_collection/test_model.yaml"
(test-helpers/generate-card-yaml "test-model-xxxxxxxxxx" "Test Model" "test-collection-1xxxx" "model")
"actions/test-action-xxxxxxxxx_test_action.yaml"
"actions/test_action.yaml"
(test-helpers/generate-action-yaml "test-action-xxxxxxxxx" "Test Action" "test-model-xxxxxxxxxx")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -948,9 +948,9 @@
{:name "Test Measure"
:table_id table-id
:entity_id "TNdMrOCMHrQc_UtvCbTC6"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"databases/test-db/tables/test-table/measures/TNdMrOCMHrQc_UtvCbTC6_test_measure.yaml"
"databases/test_db/tables/test_table/measures/test_measure.yaml"
(test-helpers/generate-measure-yaml "Test Measure" "test-table" "test-db"
:entity-id "TNdMrOCMHrQc_UtvCbTC6"
:agg-field-name "test-field")}}
@@ -983,13 +983,13 @@
[{:model_type "Collection" :model_id coll-id :model_name "Active Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Measure" :model_id measure-id :model_name "Test Measure" :model_table_id table-id :model_table_name "test-table" :status "removed" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {;; File path uses slugified name to match what serdes/storage-path generates
"databases/test-db/tables/test-table/measures/test-measure-xxxxxxxx_test_measure.yaml"
"databases/test_db/tables/test_table/measures/test_measure.yaml"
(test-helpers/generate-measure-yaml "Test Measure" "test-table" "test-db")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % "test-measure-xxxxxxxx") (keys files-after-export)))
(is (not (some #(str/includes? % "test_measure") (keys files-after-export)))
"Removed measure files should be deleted after export")))))))))
(deftest export!-excludes-archived-measures-test
@@ -1025,9 +1025,9 @@
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")
file-keys (keys files-after-export)]
(is (some #(str/includes? % "active-measure-xxxxxx") file-keys)
(is (some #(str/includes? % "active_measure") file-keys)
(str "Active measure should be exported. Keys: " (pr-str file-keys)))
(is (not (some #(str/includes? % "archived-measure-xxxx") file-keys))
(is (not (some #(str/includes? % "archived_measure") file-keys))
"Archived measure should NOT be exported")))))))))
(deftest export!-deletes-files-for-archived-measures-test
@@ -1050,13 +1050,13 @@
(t2/insert! :model/RemoteSyncObject
[{:model_type "Collection" :model_id coll-id :model_name "Active Collection" :status "synced" :status_changed_at (t/offset-date-time)}
{:model_type "Measure" :model_id measure-id :model_name "Archived Measure" :model_table_id table-id :model_table_name "test-table" :status "delete" :status_changed_at (t/offset-date-time)}])
(let [initial-files {"main" {"databases/test-db/tables/test-table/measures/archived-meas-xxxxxxx_archived_measure.yaml"
(let [initial-files {"main" {"databases/test_db/tables/test_table/measures/archived_measure.yaml"
(test-helpers/generate-measure-yaml "Archived Measure" "test-table" "test-db")}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test commit")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % "archived-meas-xxxxxxx") (keys files-after-export)))
(is (not (some #(str/includes? % "archived_measure") (keys files-after-export)))
"Archived measure files should be deleted after export"))
(testing "RemoteSyncObject entry is cleaned up after export"
(is (= "synced" (:status (t2/select-one :model/RemoteSyncObject :model_type "Measure" :model_id measure-id)))
@@ -1072,7 +1072,7 @@
remote-sync-enabled true]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
transform-entity-id "auto-enable-trans-xxx"
test-files {"main" {(str "transforms/" transform-entity-id "_test_transform.yaml")
test-files {"main" {"transforms/test_transform.yaml"
(test-helpers/generate-transform-yaml transform-entity-id "Test Transform")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)]
(is (false? (remote-sync.settings/remote-sync-transforms))
@@ -1091,7 +1091,7 @@
remote-sync-enabled true]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
lib-entity-id "auto-enable-lib-xxxxx"
test-files {"main" {(str "python-libraries/" lib-entity-id ".yaml")
test-files {"main" {"python-libraries/uncommon.yaml"
(format "path: uncommon.py
source: |
# shared code
@@ -1118,7 +1118,7 @@ serdes/meta:
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temporary-setting-values [remote-sync-transforms false]
(mt/with-temp [:model/Collection {_coll-id :id} {:name "No Transforms Coll" :is_remote_synced true :entity_id "no-transforms-coll-xx" :location "/"}]
(let [test-files {"main" {"collections/no-transforms-coll-xx-_/no-transforms-coll-xx.yaml"
(let [test-files {"main" {"collections/main/no_transforms_coll/no_transforms_coll.yaml"
(test-helpers/generate-collection-yaml "no-transforms-coll-xx" "No Transforms Coll")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)]
(is (false? (remote-sync.settings/remote-sync-transforms))
@@ -1134,7 +1134,7 @@ serdes/meta:
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temporary-setting-values [remote-sync-transforms true]
(mt/with-temp [:model/Collection {_coll-id :id} {:name "Already Enabled Coll" :is_remote_synced true :entity_id "already-enabled-collx" :location "/"}]
(let [test-files {"main" {"collections/already-enabled-collx-_/already-enabled-collx.yaml"
(let [test-files {"main" {"collections/main/already_enabled_coll/already_enabled_coll.yaml"
(test-helpers/generate-collection-yaml "already-enabled-collx" "Already Enabled Coll")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)]
(is (true? (remote-sync.settings/remote-sync-transforms))
@@ -1150,7 +1150,7 @@ serdes/meta:
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temporary-setting-values [remote-sync-transforms false]
(mt/with-temp [:model/Collection {_coll-id :id} {:name "All Paths Coll" :is_remote_synced true :entity_id "all-paths-coll-xxxxxx" :location "/"}]
(let [test-files {"main" {"collections/all-paths-coll-xxxxxx-_/all-paths-coll-xxxxxx.yaml"
(let [test-files {"main" {"collections/main/all_paths_coll/all_paths_coll.yaml"
(test-helpers/generate-collection-yaml "all-paths-coll-xxxxxx" "All Paths Coll")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
paths-passed (atom nil)
@@ -1164,7 +1164,7 @@ serdes/meta:
(let [filter-strs (map str @paths-passed)]
(is (some #(str/includes? % "transforms") filter-strs)
"transforms path should be included in filters")
(is (some #(str/includes? % "python-libraries") filter-strs)
(is (some #(str/includes? % "python") filter-strs)
"python-libraries path should be included in filters")
(is (some #(str/includes? % "snippets") filter-strs)
"snippets path should be included in filters"))))))))))))
@@ -1176,7 +1176,7 @@ serdes/meta:
:type "library"
:entity_id collection/library-entity-id
:location "/"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml collection/library-entity-id "Another Library")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1186,7 +1186,7 @@ serdes/meta:
(deftest import!-blocks-if-it-encounters-snippet-conflicts
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temp [:model/NativeQuerySnippet _ {:name "Test Snippet"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-snippet-yaml "blahblahblah" "A Snippet" "select 123")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1196,7 +1196,7 @@ serdes/meta:
(deftest import!-blocks-if-it-encounters-transform-conflicts
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temp [:model/Transform _ {:name "Test Transform"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-transform-yaml "blahblahblah" "A Transform")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1213,11 +1213,11 @@ serdes/meta:
:location "/"}
:model/NativeQuerySnippet _ {:name "Test Snippet"}
:model/Transform _ {:name "Test Transform"}]
(let [test-files {"main" {"collections/lib-_/lib.yaml"
(let [test-files {"main" {"collections/main/lib/lib.yaml"
(test-helpers/generate-collection-yaml collection/library-entity-id "Remote Library")
"collections/snip-_/snip.yaml"
"collections/main/snip/snip.yaml"
(test-helpers/generate-snippet-yaml "snip-entity-id" "Remote Snippet" "select 1")
"collections/trans-_/trans.yaml"
"collections/main/trans/trans.yaml"
(test-helpers/generate-transform-yaml "trans-entity-id" "Remote Transform")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1232,7 +1232,7 @@ serdes/meta:
:type "library"
:entity_id collection/library-entity-id
:location "/"}]
(let [test-files {"main" {"collections/lib-_/lib.yaml"
(let [test-files {"main" {"collections/main/lib/lib.yaml"
(test-helpers/generate-collection-yaml collection/library-entity-id "Remote Library")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id :force? true)]
@@ -1247,7 +1247,7 @@ serdes/meta:
:entity_id collection/library-entity-id
:location "/"}]
(with-redefs [remote-sync.task/last-version (constantly "previous-version")]
(let [test-files {"main" {"collections/lib-_/lib.yaml"
(let [test-files {"main" {"collections/main/lib/lib.yaml"
(test-helpers/generate-collection-yaml collection/library-entity-id "Remote Library")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1276,7 +1276,7 @@ serdes/meta:
:type nil
:entity_id collection/library-entity-id
:location "/"}]
(let [test-files {"main" {"collections/lib-_/lib.yaml"
(let [test-files {"main" {"collections/main/lib/lib.yaml"
(test-helpers/generate-collection-yaml collection/library-entity-id "Remote Library")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1292,7 +1292,7 @@ serdes/meta:
:type "library"
:entity_id collection/library-entity-id
:location "/"}]
(let [test-files {"main" {"collections/test-collection-1xxxx-_/test-collection-1xxxx.yaml"
(let [test-files {"main" {"collections/main/test_collection/test_collection.yaml"
(test-helpers/generate-collection-yaml collection/library-entity-id "Another Library")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1308,9 +1308,9 @@ serdes/meta:
(testing "import! detects transforms conflict when local has transforms and import has transforms"
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temp [:model/Transform _ {:name "Local Transform"}]
(let [test-files {"main" {"collections/test-coll-_/test-coll.yaml"
(let [test-files {"main" {"collections/main/test_coll/test_coll.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"transforms/test-transform-_/test-transform.yaml"
"transforms/remote_transform.yaml"
(test-helpers/generate-transform-yaml "test-transform-xxxxx" "Remote Transform")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1322,9 +1322,9 @@ serdes/meta:
(testing "import! detects snippets conflict when local has snippets and import has snippets"
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temp [:model/NativeQuerySnippet _ {:name "Local Snippet"}]
(let [test-files {"main" {"collections/test-coll-_/test-coll.yaml"
(let [test-files {"main" {"collections/main/test_coll/test_coll.yaml"
(test-helpers/generate-collection-yaml "test-collection-1xxxx" "Test Collection")
"snippets/test-snippet-_/test-snippet.yaml"
"snippets/remote_snippet.yaml"
(test-helpers/generate-snippet-yaml "test-snippet-xxxxxxx" "Remote Snippet" "SELECT 1")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -1337,9 +1337,9 @@ serdes/meta:
(mt/with-model-cleanup [:model/RemoteSyncTask]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temp [:model/Collection _ {:name "Local Transforms" :namespace "transforms"}]
(let [test-files {"main" {"collections/txns-coll-_/txns-coll.yaml"
(let [test-files {"main" {"collections/main/txns_coll/txns_coll.yaml"
(test-helpers/generate-collection-yaml "txns-coll-xxxxxxxxx" "Txns Coll")
"collections/txns-ns-coll-_/txns-ns-coll.yaml"
"collections/transforms/remote_transforms/remote_transforms.yaml"
(test-helpers/generate-collection-yaml "txns-ns-coll-xxxxxxx" "Remote Transforms"
:namespace "transforms")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
@@ -1353,9 +1353,9 @@ serdes/meta:
(mt/with-model-cleanup [:model/RemoteSyncTask]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]
(mt/with-temp [:model/Collection _ {:name "Local Snippets" :namespace "snippets"}]
(let [test-files {"main" {"collections/snip-coll-_/snip-coll.yaml"
(let [test-files {"main" {"collections/main/snip_coll/snip_coll.yaml"
(test-helpers/generate-collection-yaml "snip-coll-xxxxxxxxx" "Snip Coll")
"collections/snip-ns-coll-_/snip-ns-coll.yaml"
"collections/snippets/remote_snippets/remote_snippets.yaml"
(test-helpers/generate-collection-yaml "snip-ns-coll-xxxxxxx" "Remote Snippets"
:namespace "snippets")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
@@ -1383,9 +1383,9 @@ serdes/meta:
:model_name "synced"
:status "synced"
:status_changed_at (t/offset-date-time)})))
(let [test-files {"main" {"collections/syncd-coll-_/syncd-coll.yaml"
(let [test-files {"main" {"collections/main/syncd_coll/syncd_coll.yaml"
(test-helpers/generate-collection-yaml "syncd-coll-xxxxxxxxx" "Syncd Coll")
"collections/syncd-ns-coll-_/syncd-ns-coll.yaml"
"collections/transforms/remote_transforms/remote_transforms.yaml"
(test-helpers/generate-collection-yaml "syncd-ns-coll-xxxxxxx" "Remote Transforms"
:namespace "transforms")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
@@ -1405,12 +1405,12 @@ serdes/meta:
:model_name "pre-existing"
:status "synced"
:status_changed_at (t/offset-date-time)})))
(let [test-files {"main" {"collections/noloc-coll-_/noloc-coll.yaml"
(let [test-files {"main" {"collections/main/noloc_coll/noloc_coll.yaml"
(test-helpers/generate-collection-yaml "noloc-coll-xxxxxxxxx" "Noloc Coll")
"collections/noloc-tx-coll-_/noloc-tx-coll.yaml"
"collections/transforms/remote_transforms/remote_transforms.yaml"
(test-helpers/generate-collection-yaml "noloc-tx-coll-xxxxxx" "Remote Transforms"
:namespace "transforms")
"collections/noloc-sn-coll-_/noloc-sn-coll.yaml"
"collections/snippets/remote_snippets/remote_snippets.yaml"
(test-helpers/generate-collection-yaml "noloc-sn-coll-xxxxxx" "Remote Snippets"
:namespace "snippets")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
@@ -1418,3 +1418,18 @@ serdes/meta:
(is (not= :conflict (:status result))
"Should not detect a conflict when local has no unsynced namespace collections")
(is (nil? (seq (:conflict-details result)))))))))
(deftest import!-old-format-paths-test
(testing "import! can load content stored at old-format paths (entity_id in name)"
(mt/with-model-cleanup [:model/RemoteSyncTask]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
coll-eid "old-fmt-coll-xxxxxxxx"
;; Old-format paths: entity_id in filename
test-files {"main" {(str "collections/" coll-eid "_test_collection/" coll-eid "_test_collection.yaml")
(test-helpers/generate-collection-yaml coll-eid "Test Collection")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
(is (= :success (:status result))
"import should succeed with old-format paths")
(is (t2/exists? :model/Collection :entity_id coll-eid)
"collection should have been imported from old-format path")))))

View File

@@ -283,12 +283,12 @@ is_sample: false
(mt/with-temporary-setting-values [remote-sync-type :read-write
remote-sync-enabled true]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "export" :initiated_by (mt/user->id :rasta)})]
(mt/with-temp [:model/Collection {coll-id :id coll-eid :entity_id}
(mt/with-temp [:model/Collection {coll-id :id}
{:name "Snippets Collection"
:namespace :snippets
:entity_id "snippets-coll-xxxxx"
:location "/"}
:model/NativeQuerySnippet {snippet-id :id snippet-eid :entity_id}
:model/NativeQuerySnippet {snippet-id :id}
{:name "Test Snippet"
:content "SELECT 1"
:collection_id coll-id
@@ -309,9 +309,9 @@ is_sample: false
(is (= :success (:status result))
(str "Export should succeed. Result: " result))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (some #(str/includes? % coll-eid) (keys files-after-export))
(is (some #(str/includes? % "snippets_collection") (keys files-after-export))
"Export should include the snippets-namespace collection")
(is (some #(str/includes? % snippet-eid) (keys files-after-export))
(is (some #(str/includes? % "test_snippet") (keys files-after-export))
"Export should include the snippet"))))))))))
(deftest import-snippets-from-yaml-test
@@ -322,9 +322,9 @@ is_sample: false
(mt/with-temporary-setting-values [remote-sync-enabled true]
(let [coll-entity-id "snippets-coll-importx"
snippet-entity-id "test-snippet-importxx"
test-files {"main" {(str "collections/" coll-entity-id "_snippets_collection/" coll-entity-id "_snippets_collection.yaml")
test-files {"main" {"collections/snippets/snippets_collection/snippets_collection.yaml"
(generate-snippets-namespace-collection-yaml coll-entity-id "Snippets Collection")
(str "snippets/" coll-entity-id "_snippets_collection/" snippet-entity-id "_test_snippet.yaml")
"collections/snippets/snippets_collection/test_snippet.yaml"
(test-helpers/generate-snippet-yaml snippet-entity-id "Test Snippet" "SELECT 42" :collection-id coll-entity-id)}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -406,17 +406,16 @@ is_sample: false
:model_collection_id coll-id
:status "delete"
:status_changed_at (t/offset-date-time)}]
(let [removal-paths (spec/build-all-removal-paths)
snippet-removal-path (first (filter #(str/includes? % snippet-eid) removal-paths))
_ (is (some? snippet-removal-path) "Should have a removal path for the archived snippet")
initial-files {"main" {(str snippet-removal-path ".yaml")
(let [snippet-file-path (str "snippets/" snippet-eid "_archived_snippet.yaml")
initial-files {"main" {snippet-file-path
(test-helpers/generate-snippet-yaml snippet-eid "Archived Snippet" "SELECT 1" :collection-id coll-eid)}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test export")]
(is (= :success (:status result))
(str "Export should succeed. Result: " result))
;; Archived snippet is not in the export stream, so its file gets cleaned from snippets/ (a managed dir)
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % snippet-eid) (keys files-after-export)))
(is (not (some #(str/includes? % "archived_snippet") (keys files-after-export)))
"Archived snippet file should be deleted after export")))
(testing "RemoteSyncObject entry is updated to synced after export"
(is (= "synced" (:status (t2/select-one :model/RemoteSyncObject :model_type "NativeQuerySnippet" :model_id snippet-id)))

View File

@@ -80,6 +80,10 @@
remote))
(def ^:private test-managed-dirs
"Managed dirs for test sources — matches v2.ingest/legal-top-level-paths."
#{"actions" "collections" "databases" "glossary" "python_libraries" "python-libraries" "snippets" "transforms"})
(defn- ->source!
"Creates a (local) 'remote' repo and initializes a git source that uses it"
[branch {:keys [^Git git] :as _remote-repo}]
@@ -89,7 +93,7 @@
(.toURL)
(.toExternalForm))
local-repo (#'git/get-jgit (#'git/repo-path {:remote-url remote-url}) {:remote-url remote-url})]
(git/->GitSource local-repo remote-url branch nil)))
(git/->GitSource local-repo remote-url branch nil test-managed-dirs)))
(defn- init-source!
[branch dir & config]
@@ -97,27 +101,6 @@
(let [remote-repo (apply init-remote! dir config)]
[(->source! branch remote-repo) remote-repo]))
(deftest path-prefix
(let [id (u/generate-nano-id "a")]
(testing "Not in a collection"
(doseq [path ["asdf"
"asdf.txt"
"dir/asdf.txt"
"collections/asdf.txt"
"collections/asdf/a.txt"
"invalid/collections/asdf/a.txt"
(str "collections/" id)
(str "collections/" id "/but_no_name")]]
(testing path
(is (= path (#'git/path-prefix path)))
(is (true? (#'git/matches-prefix path #{(str "collections/" (u/generate-nano-id)) path}))))))
(testing "In a collection"
(doseq [path [(str "collections/" id "_my_name/asdf")
(str "collections/" id "_other_name/subdir/asdf.txt")]]
(testing path
(is (= (str "collections/" id) (#'git/path-prefix path)))
(is (true? (#'git/matches-prefix path #{(str "collections/" id) (str "collections/" (u/generate-nano-id))}))))))))
(deftest qualify-branch-test
(is (= "refs/heads/main" (#'git/qualify-branch "main")))
(is (= "refs/heads/main" (#'git/qualify-branch "refs/heads/main"))))
@@ -180,7 +163,6 @@
(deftest write-files
(let [subdir-path (str "collections/" "r" (subs (u/generate-nano-id "a") 1) "_subdir/")
otherdir-path (str "collections/" "o" (subs (u/generate-nano-id "b") 1) "_otherdir/")
thirddir-path (str "collections/" "s" (subs (u/generate-nano-id "c") 1) "_thirddir/")]
(mt/with-temp-dir [remote-dir nil]
(let [[master remote] (init-source! "master" remote-dir
@@ -188,12 +170,10 @@
"master2.txt" "File 2 in master"
(str subdir-path "path.txt") "File in subdir"
(str subdir-path "path2.txt") "File 2 in subdir"
(str otherdir-path "path.txt") "File in otherdir"
(str otherdir-path "path2.txt") "File 2 in otherdir"
(str thirddir-path "path.txt") "File in third dir"
(str thirddir-path "path2.txt") "File 2 in third dir"}
:branches ["branch-1" "branch-2"])]
(testing "Files in a subdir are replaced, other subdirs and root are unchanged"
(testing "All files in managed dirs not in write set are removed; root files outside managed dirs are preserved"
(source.p/write-files! (source.p/snapshot master) "Update 1" [{:path "master.txt" :content "Updated master content"}
{:path (str subdir-path "path.txt") :content "Updated subdir content"}
{:path (str subdir-path "path3.txt") :content "Updated subdir content 3"}
@@ -201,9 +181,8 @@
{:path (str thirddir-path "path3.txt") :content "Updated third dir content 3"}])
(is (= ["Update 1" "Initial commit"] (map :message (git/log master))))
(let [master-snap (source.p/snapshot master)]
(is (= [(str otherdir-path "path.txt")
(str otherdir-path "path2.txt")
(str subdir-path "path.txt")
;; otherdir files are removed because collections/ is a managed dir and those files weren't in the write set
(is (= [(str subdir-path "path.txt")
(str subdir-path "path3.txt")
(str thirddir-path "path.txt")
(str thirddir-path "path3.txt")
@@ -213,15 +192,12 @@
(is (= "Updated master content" (source.p/read-file master-snap "master.txt")))
(is (= "File 2 in master" (source.p/read-file master-snap "master2.txt")))
(is (= "File 2 in otherdir" (source.p/read-file master-snap (str otherdir-path "path2.txt"))))
(is (= "Updated subdir content" (source.p/read-file master-snap (str subdir-path "path.txt"))))
(is (= "Updated subdir content 3" (source.p/read-file master-snap (str subdir-path "path3.txt")))))
(testing "Check remote repo directly"
(is (= "Updated master content" (git/read-file (assoc remote :version "master") "master.txt")))
(is (= [(str otherdir-path "path.txt")
(str otherdir-path "path2.txt")
(str subdir-path "path.txt")
(is (= [(str subdir-path "path.txt")
(str subdir-path "path3.txt")
(str thirddir-path "path.txt")
(str thirddir-path "path3.txt")
@@ -230,13 +206,9 @@
(git/list-files (assoc remote :version "master"))))
(is (= ["Update 1" "Initial commit"] (map :message (git/log (assoc remote :branch "master")))))))
(testing "If no root files are touched, they all stay as-is"
(testing "Writing only to collections/ removes all other collection files"
(source.p/write-files! (source.p/snapshot master) "Update 2" [{:path (str thirddir-path "path.txt") :content "Only third dir content"}])
(is (= [(str otherdir-path "path.txt")
(str otherdir-path "path2.txt")
(str subdir-path "path.txt")
(str subdir-path "path3.txt")
(str thirddir-path "path.txt")
(is (= [(str thirddir-path "path.txt")
"master.txt"
"master2.txt"]
(git/list-files (assoc remote :version "master")))))))))
@@ -249,58 +221,57 @@
(str subdir-path "path.txt") "File in subdir"})]))))
(deftest concurrent-access
(let [subdir-path (str "collections/" (u/generate-nano-id "a") "_subdir")]
(mt/with-temp-dir [remote-dir nil]
(let [[master remote] (init-source! "master" remote-dir
:files {"master.txt" "File in master"
(str subdir-path "path.txt") "File in subdir"}
:branches ["branch-1" "branch-2"])
new-branch (->source! "new-branch" remote)]
(mt/with-temp-dir [remote-dir nil]
(let [[master remote] (init-source! "master" remote-dir
:files {"master.txt" "File in master"
"subdir/path.txt" "File in subdir"}
:branches ["branch-1" "branch-2"])
new-branch (->source! "new-branch" remote)]
(testing "Initial clone is the same"
(testing "Initial clone is the same"
(is (= ["Initial commit"] (map :message (git/log master))))
(is (= ["Initial commit"] (map :message (git/log (assoc remote :branch "master")))))
;; Add an extra commit to remote
(git-working-add! remote "additional-file.txt" "Additional file content")
(git-working-commit! remote "Added additional file")
(testing "Source is behind remote"
(is (= ["Initial commit"] (map :message (git/log master))))
(is (= ["Initial commit"] (map :message (git/log (assoc remote :branch "master")))))
(is (= ["Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "master"))))))
;; Add an extra commit to remote
(git-working-add! remote "additional-file.txt" "Additional file content")
(git-working-commit! remote "Added additional file")
(testing "After fetch, source is up to date"
(git/fetch! master)
(is (= ["Added additional file" "Initial commit"] (map :message (git/log master)))))
(testing "Source is behind remote"
(is (= ["Initial commit"] (map :message (git/log master))))
(is (= ["Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "master"))))))
(testing "Writing a file to source and pushing back to remote when there is new content on remote"
;; Make source be behind again
(git-working-add! remote "only-on-remote.txt" "Initially on remote")
(git-working-commit! remote "Only on remote")
(testing "After fetch, source is up to date"
(git/fetch! master)
(is (= ["Added additional file" "Initial commit"] (map :message (git/log master)))))
(source.p/write-files! (source.p/snapshot master) "Added to source" [{:path "initially-source.txt" :content "Initially on source"}])
(testing "Writing a file to source and pushing back to remote when there is new content on remote"
;; Make source be behind again
(git-working-add! remote "only-on-remote.txt" "Initially on remote")
(git-working-commit! remote "Only on remote")
(testing "Remote has the new commit with just the files committed, but only version is in history"
(is (= ["Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "master")))))
(is (= ["additional-file.txt" "initially-source.txt" "master.txt" "only-on-remote.txt" "subdir/path.txt"] (git/list-files (assoc remote :version "master"))))
(is (= "Initially on source" (git/read-file (assoc remote :version "master") "initially-source.txt"))))
(source.p/write-files! (source.p/snapshot master) "Added to source" [{:path "initially-source.txt" :content "Initially on source"}])
(testing "Source has the same history"
(is (= (map :message (git/log (assoc remote :branch "master"))) (map :message (git/log master))))))
(testing "Remote has the new commit with just the files committed, but only version is in history"
(is (= ["Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "master")))))
(is (= ["additional-file.txt" (str subdir-path "path.txt") "initially-source.txt" "master.txt" "only-on-remote.txt"] (git/list-files (assoc remote :version "master"))))
(is (= "Initially on source" (git/read-file (assoc remote :version "master") "initially-source.txt"))))
(testing "Writing to a branch local has not seen (but remote has) adds it to the history on remote"
(git-working-checkout! remote "new-branch" true)
(git-working-add! remote "new-branch-file.txt" "Initially on remote")
(git-working-add! remote "new-branch-remote.txt" "Initially on remote")
(git-working-commit! remote "New-branch on remote")
(testing "Source has the same history"
(is (= (map :message (git/log (assoc remote :branch "master"))) (map :message (git/log master))))))
(is (= ["New-branch on remote" "Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "new-branch")))))
(is (nil? (git/log new-branch)))
(testing "Writing to a branch local has not seen (but remote has) adds it to the history on remote"
(git-working-checkout! remote "new-branch" true)
(git-working-add! remote "new-branch-file.txt" "Initially on remote")
(git-working-add! remote "new-branch-remote.txt" "Initially on remote")
(git-working-commit! remote "New-branch on remote")
(source.p/write-files! (source.p/snapshot new-branch) "New-branch on source" [{:path "new-branch-source.txt" :content "Initially on source"}
{:path "new-branch-file.txt" :content "Updated on source"}])
(is (= ["New-branch on remote" "Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "new-branch")))))
(is (nil? (git/log new-branch)))
(source.p/write-files! (source.p/snapshot new-branch) "New-branch on source" [{:path "new-branch-source.txt" :content "Initially on source"}
{:path "new-branch-file.txt" :content "Updated on source"}])
(is (= ["New-branch on source" "New-branch on remote" "Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "new-branch")))))))))))
(is (= ["New-branch on source" "New-branch on remote" "Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "new-branch"))))))))))
(deftest git-source-using-commit-ref
(mt/with-temp-dir [remote-dir nil]
@@ -358,67 +329,64 @@
(let [[master _remote] (init-source! "master" remote-dir)]
(is (= "master" (git/default-branch master))))))
(deftest write-files-removal-test
(let [subdir-path (str "collections/" "r" (subs (u/generate-nano-id "a") 1) "_subdir/")
otherdir-path (str "collections/" "o" (subs (u/generate-nano-id "b") 1) "_otherdir/")]
(deftest write-files-top-level-exports-replaced-test
(let [old-col-path (str "collections/" "r" (subs (u/generate-nano-id "a") 1) "_mycol/")
new-col-path (str "collections/" "s" (subs (u/generate-nano-id "b") 1) "_othercol/")
kept-col-path (str "collections/" "t" (subs (u/generate-nano-id "c") 1) "_keptcol/")]
(mt/with-temp-dir [remote-dir nil]
(let [[master _remote] (init-source! "master" remote-dir
:files {"master.txt" "File in master"
(str subdir-path "file1.yaml") "File 1 in subdir"
(str subdir-path "file2.yaml") "File 2 in subdir"
(str otherdir-path "file1.yaml") "File 1 in otherdir"
(str otherdir-path "file2.yaml") "File 2 in otherdir"})]
(testing "Removal entry deletes all files under that path recursively"
(source.p/write-files! (source.p/snapshot master) "Remove subdir"
[{:path (subs subdir-path 0 (dec (count subdir-path))) :remove? true}])
:files {"databases/old_db/old_db.yaml" "Old database"
"databases/old_db/schemas/public.yaml" "Old schema"
"snippets/old_snippet.yaml" "Old snippet"
"unmanaged/keep_me.txt" "Unmanaged file"
(str old-col-path "cards/card1.yaml") "Card in old col"
(str old-col-path "cards/card2.yaml") "Card 2 in old col"
(str kept-col-path "dashboards/dash1.yaml") "Dashboard in kept col"})]
(testing "Writing to a managed dir removes all stale files in ALL managed dirs"
(source.p/write-files! (source.p/snapshot master) "Rename database"
[{:path "databases/new_db/new_db.yaml" :content "Renamed database"}
{:path "databases/new_db/schemas/public.yaml" :content "Same schema"}
{:path (str old-col-path "cards/card1.yaml") :content "Card in old col"}
{:path (str old-col-path "cards/card2.yaml") :content "Card 2 in old col"}
{:path (str kept-col-path "dashboards/dash1.yaml") :content "Dashboard in kept col"}
{:path "snippets/old_snippet.yaml" :content "Old snippet"}])
(let [files (set (source.p/list-files (source.p/snapshot master)))]
(is (contains? files "master.txt") "Root files should remain")
(is (contains? files (str otherdir-path "file1.yaml")) "Other collection files should remain")
(is (contains? files (str otherdir-path "file2.yaml")) "Other collection files should remain")
(is (not (contains? files (str subdir-path "file1.yaml"))) "Subdir files should be removed")
(is (not (contains? files (str subdir-path "file2.yaml"))) "Subdir files should be removed")))))))
(is (contains? files "databases/new_db/new_db.yaml") "New database file should exist")
(is (contains? files "databases/new_db/schemas/public.yaml") "New schema file should exist")
(is (not (contains? files "databases/old_db/old_db.yaml")) "Old database file should be removed")
(is (not (contains? files "databases/old_db/schemas/public.yaml")) "Old schema file should be removed")
(is (contains? files (str old-col-path "cards/card1.yaml")) "Written collection files should remain")
(is (contains? files "snippets/old_snippet.yaml") "Written snippet file should remain")
(is (contains? files "unmanaged/keep_me.txt") "Unmanaged files should be untouched")))
(deftest write-files-mixed-write-and-removal-test
(let [subdir-path (str "collections/" "r" (subs (u/generate-nano-id "a") 1) "_subdir/")
newdir-path (str "collections/" "n" (subs (u/generate-nano-id "b") 1) "_newdir/")]
(testing "Entity moved between collections removes files from old collection"
(source.p/write-files! (source.p/snapshot master) "Move card to new collection"
[{:path (str new-col-path "cards/card1.yaml") :content "Card moved to new col"}
{:path (str kept-col-path "dashboards/dash1.yaml") :content "Dashboard still here"}
{:path "databases/new_db/new_db.yaml" :content "Renamed database"}
{:path "databases/new_db/schemas/public.yaml" :content "Same schema"}])
(let [files (set (source.p/list-files (source.p/snapshot master)))]
(is (contains? files (str new-col-path "cards/card1.yaml")) "Moved card should exist in new collection")
(is (contains? files (str kept-col-path "dashboards/dash1.yaml")) "Kept collection files should remain")
(is (not (contains? files (str old-col-path "cards/card1.yaml"))) "Old collection card should be removed")
(is (not (contains? files (str old-col-path "cards/card2.yaml"))) "Other files in old collection should also be removed")
(is (not (contains? files "snippets/old_snippet.yaml")) "Snippets cleaned up when not in write set")
(is (contains? files "unmanaged/keep_me.txt") "Unmanaged files still untouched")))))))
(deftest write-files-entity-rename-within-collection-test
(let [col-path (str "collections/" "u" (subs (u/generate-nano-id "d") 1) "_col/")]
(mt/with-temp-dir [remote-dir nil]
(let [[master _remote] (init-source! "master" remote-dir
:files {"master.txt" "File in master"
(str subdir-path "old-file.yaml") "Old file in subdir"})]
(testing "Can combine write and removal entries in same call"
(source.p/write-files! (source.p/snapshot master) "Mixed operations"
[{:path (subs subdir-path 0 (dec (count subdir-path))) :remove? true}
{:path (str newdir-path "new-file.yaml") :content "New file content"}])
(let [snap (source.p/snapshot master)
files (set (source.p/list-files snap))]
(is (contains? files "master.txt") "Root files should remain")
(is (contains? files (str newdir-path "new-file.yaml")) "New files should be added")
(is (not (contains? files (str subdir-path "old-file.yaml"))) "Old files should be removed")
(is (= "New file content" (source.p/read-file snap (str newdir-path "new-file.yaml"))))))))))
(deftest write-files-empty-removal-path-test
(mt/with-temp-dir [remote-dir nil]
(let [[master _remote] (init-source! "master" remote-dir
:files {"master.txt" "File in master"
"subdir/file.yaml" "File in subdir"})]
(testing "Empty removal paths are ignored (no-op)"
(source.p/write-files! (source.p/snapshot master) "Empty removal"
[{:path "" :remove? true}
{:path " " :remove? true}])
(let [files (set (source.p/list-files (source.p/snapshot master)))]
(is (= #{"master.txt" "subdir/file.yaml"} files)
"All files should remain when removal path is empty"))))))
(deftest write-files-nonexistent-removal-path-test
(mt/with-temp-dir [remote-dir nil]
(let [[master _remote] (init-source! "master" remote-dir
:files {"master.txt" "File in master"})]
(testing "Removing non-existent path is a no-op"
(source.p/write-files! (source.p/snapshot master) "Remove nonexistent"
[{:path "collections/nonexistent" :remove? true}])
(let [files (set (source.p/list-files (source.p/snapshot master)))]
(is (= #{"master.txt"} files)
"Files should remain unchanged"))))))
:files {(str col-path "cards/eid123_OldCardName.yaml") "Card with old name"
(str col-path "cards/eid456_OtherCard.yaml") "Other card"})]
(testing "Entity renamed within a collection removes the old-named file"
(source.p/write-files! (source.p/snapshot master) "Rename card"
[{:path (str col-path "cards/eid123_NewCardName.yaml") :content "Card with new name"}
{:path (str col-path "cards/eid456_OtherCard.yaml") :content "Other card"}])
(let [files (set (source.p/list-files (source.p/snapshot master)))]
(is (contains? files (str col-path "cards/eid123_NewCardName.yaml")) "Renamed card should exist")
(is (contains? files (str col-path "cards/eid456_OtherCard.yaml")) "Other card should still exist")
(is (not (contains? files (str col-path "cards/eid123_OldCardName.yaml"))) "Old card name file should be removed")))))))
(deftest ensure-origin-configured-sets-origin-after-clone-test
(mt/with-temp-dir [remote-dir nil]

View File

@@ -157,26 +157,3 @@
(is (= []
@written-files)
"Should write no files when none match"))))
(deftest wrapping-source-write-files-removal-entries-test
(testing "WrappingSource filters removal entries based on path-filters"
(let [written-files (atom [])
mock-snap (reify source.p/SourceSnapshot
(list-files [_]
[])
(read-file [_ _path]
nil)
(write-files! [_ _message files]
(reset! written-files (vec files))
nil)
(version [_]
"mock-version"))
wrapped-snap (source/->WrappingSnapshot mock-snap [#"collections/.*"])
files-to-write [{:path "collections/foo.yaml" :content "foo-content"}
{:path "collections/abc123" :remove? true}
{:path "databases/db1" :remove? true}]]
(source.p/write-files! wrapped-snap "test commit" files-to-write)
(is (= [{:path "collections/foo.yaml" :content "foo-content"}
{:path "collections/abc123" :remove? true}]
@written-files)
"Should pass through removal entries that match filter and filter out those that don't"))))

View File

@@ -63,7 +63,7 @@
test-entities [(create-test-entity "test-id-1" "entity-one" "Collection")
(create-test-entity "test-id-2" "entity-two" "Card")]]
(is (= "mock-written-version" (source/store! test-entities [] (source.p/snapshot mock-source) task-id "Test commit message")))
(is (= "mock-written-version" (source/store! test-entities (source.p/snapshot mock-source) task-id "Test commit message")))
(testing "write-files! was called with correct message"
(is (= "Test commit message" (:message @written-files))))
@@ -108,7 +108,7 @@
(let [initial-task (t2/select-one :model/RemoteSyncTask :id task-id)]
(is (nil? (:progress initial-task)) "Progress should be nil initially"))
(source/store! test-entities [] (source.p/snapshot mock-source) task-id "Test commit")
(source/store! test-entities (source.p/snapshot mock-source) task-id "Test commit")
(let [final-task (t2/select-one :model/RemoteSyncTask :id task-id)]
(is (some? (:progress final-task)) "Progress should be updated after store!")
@@ -128,7 +128,7 @@
(let [written-files (atom nil)
mock-source (->MockSource written-files)]
(source/store! [] [] (source.p/snapshot mock-source) task-id "Empty commit")
(source/store! [] (source.p/snapshot mock-source) task-id "Empty commit")
(testing "write-files! was called even with empty stream"
(is (some? @written-files)))
@@ -159,8 +159,8 @@
:dataset_query {:z_field 1 :a_field 2 :database 3}
:visualization_settings {:zebra true :apple false}}]
;; Store the same entity twice
(source/store! [test-entity] [] (source.p/snapshot mock-source-1) task-id "First commit")
(source/store! [test-entity] [] (source.p/snapshot mock-source-2) task-id "Second commit")
(source/store! [test-entity] (source.p/snapshot mock-source-1) task-id "First commit")
(source/store! [test-entity] (source.p/snapshot mock-source-2) task-id "Second commit")
(testing "YAML content is identical between runs"
(let [content-1 (-> @written-files-1 :files first :content)
content-2 (-> @written-files-2 :files first :content)]

View File

@@ -245,12 +245,6 @@
(let [spec (spec/spec-for-model-key :model/Card)]
(is (nil? (spec/build-sync-object-fields spec nil))))))
;;; ------------------------------------------- Removal Path Building Tests ------------------------------------------
;; Note: build-all-removal-paths uses serdes/storage-path to generate paths.
;; The actual paths are tested in impl_test.clj integration tests which verify
;; the full export/import cycle with real database entities.
;;; --------------------------------------------- Fields for Sync Tests -----------------------------------------------
(deftest fields-for-sync-test

View File

@@ -163,7 +163,13 @@ width: fixed
"
name entity-id collection-id entity-id (str/replace (u/lower-case-en name) #"\s+" "_") dashcards-yaml)))
(defrecord MockSourceSnapshot [source-id base-url branch fail-mode files-atom]
(defn- top-level-dir
"Extracts the first path segment from a file path."
[path]
(when-let [idx (str/index-of path "/")]
(subs path 0 idx)))
(defrecord MockSourceSnapshot [source-id base-url branch fail-mode files-atom managed-dirs]
source.p/SourceSnapshot
(list-files [_this]
(case fail-mode
@@ -190,28 +196,25 @@ width: fixed
:write-files-error (throw (Exception. "Failed to write files"))
:store-error (throw (Exception. "Store failed"))
:network-error (throw (java.net.UnknownHostException. "Remote host not found"))
;; Default success case - handle both writes and removals
(let [write-entries (remove #(or (:remove? %) (str/blank? (:path %))) files)
removal-prefixes (into #{} (comp (filter :remove?)
(map :path)
(remove str/blank?))
files)
;; Default success case - remove all files in managed dirs, then add new files
(let [write-entries (remove #(str/blank? (:path %)) files)
write-paths (into #{} (map :path) write-entries)
current-files (get @files-atom branch {})
;; Remove files matching removal prefixes
after-removals (into {}
(remove (fn [[path _]]
(some #(or (= path %) (str/starts-with? path %))
removal-prefixes))
current-files))
;; Keep files outside managed dirs or in the write set
kept-files (into {}
(filter (fn [[path _]]
(or (not (contains? managed-dirs (top-level-dir path)))
(contains? write-paths path))))
current-files)
;; Add new files
final-files (into after-removals (map (juxt :path :content) write-entries))]
final-files (into kept-files (map (juxt :path :content) write-entries))]
(swap! files-atom assoc branch final-files)))
"write-files-version")
(version [_this]
"mock-version"))
(defrecord MockSource [source-id base-url branch fail-mode files-atom branches-atom]
(defrecord MockSource [source-id base-url branch fail-mode files-atom branches-atom managed-dirs]
source.p/Source
(create-branch [_this branch _base]
(swap! branches-atom conj [branch (str branch "-ref")]))
@@ -228,14 +231,19 @@ width: fixed
"main")
(snapshot [_this]
(->MockSourceSnapshot source-id base-url branch fail-mode files-atom)))
(->MockSourceSnapshot source-id base-url branch fail-mode files-atom managed-dirs)))
(def ^:private default-managed-dirs
"Default managed dirs for mock sources, matching v2.ingest/legal-top-level-paths."
#{"actions" "collections" "databases" "glossary" "python_libraries" "python-libraries" "snippets" "transforms"})
(defn create-mock-source
"Create a mock Source for testing. Optionally accepts `:branch`, `:fail-mode`, and `:initial-files`."
[& {:keys [branch fail-mode initial-files]
"Create a mock Source for testing. Optionally accepts `:branch`, `:fail-mode`, `:initial-files`, and `:managed-dirs`."
[& {:keys [branch fail-mode initial-files managed-dirs]
:or {branch "main"
fail-mode nil
initial-files nil}}]
initial-files nil
managed-dirs default-managed-dirs}}]
(let [default-files {"main" {"collections/M-Q4pcV0qkiyJ0kiSWECl_some_collection/M-Q4pcV0qkiyJ0kiSWECl_some_collection.yaml"
(generate-collection-yaml "M-Q4pcV0qkiyJ0kiSWECl" "Some Collection")
@@ -254,7 +262,7 @@ width: fixed
files-atom (atom (or initial-files default-files))
branches-atom (atom #{["main" "main-ref"] ["develop" "develop-ref"]})]
(->MockSource "test-source" "https://test.example.com" branch fail-mode files-atom branches-atom)))
(->MockSource "test-source" "https://test.example.com" branch fail-mode files-atom branches-atom managed-dirs)))
(defn clean-object
"Test fixture that resets the RemoteSyncObject table before running tests to prevent existing

View File

@@ -5,64 +5,44 @@
[metabase-enterprise.remote-sync.source.protocol :as source.p]
[metabase-enterprise.remote-sync.test-helpers :as th]))
(deftest mock-source-write-files-removal-test
(testing "MockSource handles removal entries correctly"
(deftest mock-source-write-files-managed-dir-cleanup-test
(testing "MockSource removes files in managed dirs not in write set"
(let [source (th/create-mock-source
:initial-files {"main" {"collections/abc/file1.yaml" "content1"
"collections/abc/file2.yaml" "content2"
"collections/def/file3.yaml" "content3"
"other/file4.yaml" "content4"}})
"other/file4.yaml" "content4"}}
:managed-dirs #{"collections"})
snapshot (source.p/snapshot source)]
(source.p/write-files! snapshot "Remove abc collection"
[{:path "collections/abc" :remove? true}])
(is (= #{"collections/def/file3.yaml" "other/file4.yaml"}
(source.p/write-files! snapshot "Write only abc"
[{:path "collections/abc/file1.yaml" :content "new-content1"}])
(is (= #{"collections/abc/file1.yaml" "other/file4.yaml"}
(set (source.p/list-files snapshot)))
"Should remove all files under collections/abc recursively"))))
"Only written files in managed dirs should remain; unmanaged dirs untouched"))))
(deftest mock-source-write-files-removal-exact-path-test
(testing "MockSource removal matches exact path"
(deftest mock-source-write-files-unmanaged-preserved-test
(testing "MockSource preserves files in unmanaged directories"
(let [source (th/create-mock-source
:initial-files {"main" {"collections/abc.yaml" "content1"
"collections/abcdef/file.yaml" "content2"}})
:initial-files {"main" {"collections/abc/file1.yaml" "content1"
"unmanaged/file2.yaml" "content2"}}
:managed-dirs #{"collections"})
snapshot (source.p/snapshot source)]
(source.p/write-files! snapshot "Remove abc.yaml"
[{:path "collections/abc.yaml" :remove? true}])
(is (= #{"collections/abcdef/file.yaml"}
(source.p/write-files! snapshot "Write collections"
[{:path "collections/abc/file1.yaml" :content "new-content"}])
(is (= #{"collections/abc/file1.yaml" "unmanaged/file2.yaml"}
(set (source.p/list-files snapshot)))
"Should only remove exact path match, not prefix match without slash"))))
"Unmanaged directory files should be preserved"))))
(deftest mock-source-write-files-mixed-write-and-removal-test
(testing "MockSource handles both write and removal entries"
(deftest mock-source-write-files-empty-managed-dir-cleanup-test
(testing "MockSource cleans managed dir even when no files written to it"
(let [source (th/create-mock-source
:initial-files {"main" {"collections/old/file1.yaml" "old-content"
"collections/keep/file2.yaml" "keep-content"}})
:initial-files {"main" {"collections/abc/file1.yaml" "content1"
"snippets/old.yaml" "old-snippet"}}
:managed-dirs #{"collections" "snippets"})
snapshot (source.p/snapshot source)]
(source.p/write-files! snapshot "Mixed operations"
[{:path "collections/old" :remove? true}
{:path "collections/new/file3.yaml" :content "new-content"}])
(is (= #{"collections/keep/file2.yaml" "collections/new/file3.yaml"}
(set (source.p/list-files snapshot)))
"Should remove old files and add new files"))))
(deftest mock-source-write-files-empty-removal-path-test
(testing "MockSource ignores removal entries with empty paths"
(let [source (th/create-mock-source
:initial-files {"main" {"collections/abc/file1.yaml" "content1"}})
snapshot (source.p/snapshot source)]
(source.p/write-files! snapshot "Empty removal"
[{:path "" :remove? true}
{:path " " :remove? true}])
;; Write only to collections, nothing to snippets
(source.p/write-files! snapshot "Write only collections"
[{:path "collections/abc/file1.yaml" :content "new-content"}])
(is (= #{"collections/abc/file1.yaml"}
(set (source.p/list-files snapshot)))
"Should not remove anything when removal path is empty or blank"))))
(deftest mock-source-write-files-nonexistent-removal-path-test
(testing "MockSource handles removal of non-existent paths (no-op)"
(let [source (th/create-mock-source
:initial-files {"main" {"collections/abc/file1.yaml" "content1"}})
snapshot (source.p/snapshot source)]
(source.p/write-files! snapshot "Remove nonexistent"
[{:path "collections/xyz" :remove? true}])
(is (= #{"collections/abc/file1.yaml"}
(set (source.p/list-files snapshot)))
"Should be a no-op when removing non-existent path"))))
"Snippets dir should be cleaned even though no snippet files were written"))))

View File

@@ -251,7 +251,7 @@
(is (= :success (:status result))
(str "Export should succeed. Result: " result))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (some #(str/includes? % coll-eid)
(is (some #(str/includes? % "transforms_collection")
(keys files-after-export))
"Export should include the transforms-namespace collection itself"))))))))))
@@ -278,9 +278,33 @@
(is (= :success (:status result))
(str "Export should succeed. Result: " result))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % "transforms/") (keys files-after-export)))
(is (not (some #(str/includes? % "collections/transforms/") (keys files-after-export)))
"Export should NOT include transform files when setting is disabled"))))))))))
(deftest export-removes-existing-transform-files-when-setting-disabled-test
(testing "Export removes pre-existing transform files from remote when setting is disabled"
(mt/with-premium-features #{:transforms-basic}
(mt/with-temporary-setting-values [remote-sync-type :read-write
remote-sync-transforms false
remote-sync-enabled true]
(mt/with-model-cleanup [:model/RemoteSyncTask]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "export" :initiated_by (mt/user->id :rasta)})]
(mt/with-temp [:model/Collection {rs-coll-id :id} {:name "Remote Synced" :is_remote_synced true :entity_id "remote-synced-xxxxxxx" :location "/"}
:model/RemoteSyncObject _rso {:model_type "Collection" :model_id rs-coll-id :model_name "Remote Synced" :status "create" :status_changed_at (t/offset-date-time)}]
;; Start with transform files already on the remote (from a previous export when setting was enabled)
(let [initial-files {"main" {"transforms/my_transform/my_transform.yaml" "old transform content"
"transforms/my_transform/steps/step1.yaml" "old step content"
"python-libraries/custom_lib.yaml" "old library content"}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)
result (impl/export! (source.p/snapshot mock-source) task-id "Test export")]
(is (= :success (:status result))
(str "Export should succeed. Result: " result))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/starts-with? % "transforms/") (keys files-after-export)))
"Transform files should be removed from remote when setting is disabled")
(is (not (some #(str/starts-with? % "python-libraries/") (keys files-after-export)))
"Python library files should be removed from remote when setting is disabled"))))))))))
(defn- generate-transforms-namespace-collection-yaml
"Generates YAML content for a transforms-namespace collection."
[entity-id name]
@@ -314,9 +338,9 @@ is_sample: false
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
coll-entity-id "transforms-coll-xxxxx"
transform-entity-id "test-transform-xxxxxx"
test-files {"main" {(str "collections/" coll-entity-id "_transforms/" coll-entity-id "_transforms.yaml")
test-files {"main" {"collections/transforms/transforms/transforms.yaml"
(generate-transforms-namespace-collection-yaml coll-entity-id "Transforms")
(str "collections/" coll-entity-id "_transforms/transforms/" transform-entity-id "_test_transform.yaml")
"collections/transforms/transforms/test_transform.yaml"
(test-helpers/generate-transform-yaml transform-entity-id "Test Transform" :collection-id coll-entity-id)}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -336,9 +360,9 @@ is_sample: false
transform-entity-id "python-legacy-fmt-01x"
;; Use old map format: {alias: table_id} — this is how pre-unification exports look
source-tables-yaml (format "{orders: %d}" (mt/id :orders))
test-files {"main" {(str "collections/" coll-entity-id "_transforms/" coll-entity-id "_transforms.yaml")
test-files {"main" {"collections/transforms/transforms/transforms.yaml"
(generate-transforms-namespace-collection-yaml coll-entity-id "Transforms")
(str "collections/" coll-entity-id "_transforms/transforms/" transform-entity-id "_python_transform.yaml")
"collections/transforms/transforms/python_transform.yaml"
(test-helpers/generate-python-transform-yaml transform-entity-id "Python Transform"
"test-data (h2)" source-tables-yaml
:collection-id coll-entity-id)}}
@@ -376,7 +400,7 @@ is_sample: false
{:name "Child Transform"
:collection_id coll-id
:entity_id "child-transform-xxxxx"}
:model/Transform {root-transform-id :id root-transform-eid :entity_id}
:model/Transform {root-transform-id :id}
{:name "Root Transform"
:collection_id nil
:entity_id "root-transform-xxxxxx"}
@@ -397,13 +421,13 @@ is_sample: false
:model_collection_id nil
:status "create"
:status_changed_at (t/offset-date-time)}]
(let [initial-files {"main" {(str "collections/" coll-eid "_transforms_collection/" coll-eid "_transforms_collection.yaml")
(let [initial-files {"main" {"collections/transforms/transforms_collection/transforms_collection.yaml"
(generate-transforms-namespace-collection-yaml coll-eid "Transforms Collection")
(str "collections/" coll-eid "_transforms_collection/transforms/" transform-eid "_child_transform.yaml")
"collections/transforms/transforms_collection/child_transform.yaml"
(test-helpers/generate-transform-yaml transform-eid "Child Transform" :collection-id coll-eid)}}
mock-source (test-helpers/create-mock-source :initial-files initial-files)]
(is (some #(str/includes? % coll-eid) (keys (get @(:files-atom mock-source) "main"))))
(is (some #(str/includes? % transform-eid) (keys (get @(:files-atom mock-source) "main"))))
(is (some #(str/includes? % "transforms_collection") (keys (get @(:files-atom mock-source) "main"))))
(is (some #(str/includes? % "child_transform") (keys (get @(:files-atom mock-source) "main"))))
(t2/update! :model/Collection coll-id {:archived true})
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id coll-id)
@@ -412,9 +436,9 @@ is_sample: false
(let [result (impl/export! (source.p/snapshot mock-source) task-id "Test export")]
(is (= :success (:status result)))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (not (some #(str/includes? % coll-eid) (keys files-after-export))))
(is (not (some #(str/includes? % transform-eid) (keys files-after-export))))
(is (some #(str/includes? % root-transform-eid) (keys files-after-export))
(is (not (some #(str/includes? % "transforms_collection") (keys files-after-export))))
(is (not (some #(str/includes? % "child_transform") (keys files-after-export))))
(is (some #(str/includes? % "root_transform") (keys files-after-export))
"Root transform should be exported")))))))))))
;;; ------------------------------------------- Collection Cleanup Tests -------------------------------------------
@@ -454,7 +478,7 @@ is_sample: false
:entity_id local-coll-entity-id
:location "/"}]
(is (t2/exists? :model/Collection :id local-coll-id))
(let [test-files {"main" {(str "collections/" remote-coll-entity-id "_remote_transforms/" remote-coll-entity-id "_remote_transforms.yaml")
(let [test-files {"main" {"collections/transforms/remote_transforms/remote_transforms.yaml"
(generate-transforms-namespace-collection-yaml remote-coll-entity-id "Remote Transforms")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -484,9 +508,9 @@ is_sample: false
(mt/with-model-cleanup [:model/RemoteSyncTask :model/Transform]
(is (t2/exists? :model/Collection :id coll-id))
(is (t2/exists? :model/Transform :id local-transform-id))
(let [test-files {"main" {(str "collections/" coll-entity-id "_transforms/" coll-entity-id "_transforms.yaml")
(let [test-files {"main" {"collections/transforms/transforms_collection/transforms_collection.yaml"
(generate-transforms-namespace-collection-yaml coll-entity-id "Transforms Collection")
(str "collections/" coll-entity-id "_transforms/transforms/" remote-transform-entity-id "_remote_transform.yaml")
"collections/transforms/transforms_collection/remote_transform.yaml"
(test-helpers/generate-transform-yaml remote-transform-entity-id "Remote Transform" :collection-id coll-entity-id)}}
mock-source (test-helpers/create-mock-source :initial-files test-files)]
(testing "fails with `conflict` status because local transforms will be deleted"
@@ -519,7 +543,7 @@ is_sample: false
:collection_id local-coll-id}]
(is (t2/exists? :model/Collection :id local-coll-id))
(is (t2/exists? :model/Transform :id local-transform-id))
(let [test-files {"main" {(str "collections/" remote-coll-entity-id "_remote_transforms/" remote-coll-entity-id "_remote_transforms.yaml")
(let [test-files {"main" {"collections/transforms/remote_transforms/remote_transforms.yaml"
(generate-transforms-namespace-collection-yaml remote-coll-entity-id "Remote Transforms")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id :force? true)]
@@ -604,7 +628,7 @@ serdes/meta:
(is (= :success (:status result))
(str "Export should succeed. Result: " result))
(let [files-after-export (get @(:files-atom mock-source) "main")]
(is (some #(str/includes? % "python-libraries/") (keys files-after-export))
(is (some #(str/includes? % "python_libraries/") (keys files-after-export))
"Export should include the PythonLibrary file")))))))))
(deftest import-python-library-from-yaml-test
@@ -615,7 +639,7 @@ serdes/meta:
(mt/with-model-cleanup [:model/RemoteSyncTask]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
lib-entity-id (u/generate-nano-id)
test-files {"main" {(str "python-libraries/" lib-entity-id ".yaml")
test-files {"main" {"python_libraries/common_py.yaml"
(generate-python-library-yaml lib-entity-id "common.py" "def shared_func():\n return 42")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
snapshot (source.p/snapshot mock-source)
@@ -679,7 +703,7 @@ serdes/meta:
local-entity-id (:entity_id local-library)]
(is (t2/exists? :model/PythonLibrary :path "common.py")
"Local PythonLibrary should exist before import")
(let [test-files {"main" {(str "python-libraries/" local-entity-id ".yaml")
(let [test-files {"main" {"python_libraries/common_py.yaml"
(generate-python-library-yaml local-entity-id "common.py" "# remote version\ndef new_func():\n pass")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -716,7 +740,7 @@ serdes/meta:
(is (t2/exists? :model/Collection :id coll-id)
"Local transforms collection should exist before import")
(let [remote-coll-entity-id (u/generate-nano-id)
test-files {"main" {(str "collections/" remote-coll-entity-id "_remote_collection/" remote-coll-entity-id "_remote_collection.yaml")
test-files {"main" {"collections/main/remote_collection/remote_collection.yaml"
(test-helpers/generate-collection-yaml remote-coll-entity-id "Remote Collection")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -752,9 +776,9 @@ serdes/meta:
"Local transform should exist before import")
(is (t2/exists? :model/TransformTag :id local-tag-id)
"Local tag should exist before import")
(let [test-files {"main" {(str "collections/" remote-coll-entity-id "_transforms/" remote-coll-entity-id "_transforms.yaml")
(let [test-files {"main" {"collections/transforms/remote_transforms/remote_transforms.yaml"
(generate-transforms-namespace-collection-yaml remote-coll-entity-id "Remote Transforms")
(str "collections/" remote-coll-entity-id "_transforms/transforms/" remote-transform-entity-id "_remote_transform.yaml")
"collections/transforms/remote_transforms/remote_transform.yaml"
(test-helpers/generate-transform-yaml remote-transform-entity-id "Remote Transform" :collection-id remote-coll-entity-id)}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result-without-force (impl/import! (source.p/snapshot mock-source) task-id)
@@ -800,7 +824,7 @@ serdes/meta:
(is (t2/exists? :model/Collection :id coll-id)
"Local transforms collection should exist before import")
(let [remote-coll-entity-id (u/generate-nano-id)
test-files {"main" {(str "collections/" remote-coll-entity-id "_remote_collection/" remote-coll-entity-id "_remote_collection.yaml")
test-files {"main" {"collections/main/remote_collection/remote_collection.yaml"
(test-helpers/generate-collection-yaml remote-coll-entity-id "Remote Collection")}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id)]
@@ -833,9 +857,9 @@ serdes/meta:
"remote-sync-transforms should be disabled initially")
(is (t2/exists? :model/Transform :id local-transform-id)
"Local transform should exist before import")
(let [test-files {"main" {(str "collections/" remote-coll-entity-id "_transforms/" remote-coll-entity-id "_transforms.yaml")
(let [test-files {"main" {"collections/transforms/remote_transforms/remote_transforms.yaml"
(generate-transforms-namespace-collection-yaml remote-coll-entity-id "Remote Transforms")
(str "collections/" remote-coll-entity-id "_transforms/transforms/" remote-transform-entity-id "_remote_transform.yaml")
"collections/transforms/remote_transforms/remote_transform.yaml"
(test-helpers/generate-transform-yaml remote-transform-entity-id "Remote Transform" :collection-id remote-coll-entity-id)}}
mock-source (test-helpers/create-mock-source :initial-files test-files)
result (impl/import! (source.p/snapshot mock-source) task-id :force? true)]
@@ -848,67 +872,6 @@ serdes/meta:
(is (not (t2/exists? :model/Transform :id local-transform-id))
"Local transform should be removed because it's not on the remote")))))))))
;;; ------------------------------------------- build-all-removal-paths Tests -------------------------------------------
(deftest build-all-removal-paths-includes-all-transforms-on-setting-disable-test
(testing "build-all-removal-paths returns paths for all transforms content when sentinel RSO has 'delete' status"
(mt/with-premium-features #{:transforms-basic}
(mt/with-temporary-setting-values [remote-sync-transforms true
remote-sync-enabled true]
(mt/with-temp [:model/Collection {coll-id :id} {:name "Transforms Collection"
:namespace collection/transforms-ns
:location "/"}
:model/Transform {transform-id :id transform-eid :entity_id} {:name "Test Transform"
:collection_id coll-id}
:model/TransformTag {tag-id :id tag-eid :entity_id} {:name "Test Tag"
:built_in_type nil}]
(let [library (t2/insert-returning-instance! :model/PythonLibrary {:path "common.py" :source "# test"})
lib-eid (:entity_id library)]
(is (t2/exists? :model/Transform :id transform-id))
(is (t2/exists? :model/TransformTag :id tag-id))
(is (t2/exists? :model/PythonLibrary :id (:id library)))
(let [paths-before (spec/build-all-removal-paths)]
(is (not (some #(str/includes? % transform-eid) paths-before))
"Transform should not be in removal paths before setting is disabled"))
(settings/sync-transform-tracking! true)
(settings/sync-transform-tracking! false)
(is (t2/exists? :model/RemoteSyncObject
:model_type "Collection"
:model_id settings/transforms-root-id
:status "delete")
"Sentinel RSO should exist with 'delete' status")
(let [paths-after (spec/build-all-removal-paths)]
(is (some #(str/includes? % transform-eid) paths-after)
"Transform should be in removal paths after setting is disabled")
(is (some #(str/includes? % tag-eid) paths-after)
"TransformTag should be in removal paths after setting is disabled")
(is (some #(str/includes? % lib-eid) paths-after)
"PythonLibrary should be in removal paths after setting is disabled"))))))))
(deftest build-all-removal-paths-excludes-builtin-transform-tags-test
(testing "build-all-removal-paths respects :conditions and excludes built-in tags"
(mt/with-premium-features #{:transforms-basic}
(mt/with-temporary-setting-values [remote-sync-transforms true
remote-sync-enabled true]
(mt/with-temp [:model/TransformTag {custom-tag-id :id custom-tag-eid :entity_id} {:name "Custom Tag"
:built_in_type nil}
:model/TransformTag {builtin-tag-id :id builtin-tag-eid :entity_id} {:name "Built-in Tag"
:built_in_type "target"}]
(is (t2/exists? :model/TransformTag :id custom-tag-id))
(is (t2/exists? :model/TransformTag :id builtin-tag-id))
(settings/sync-transform-tracking! true)
(settings/sync-transform-tracking! false)
(is (t2/exists? :model/RemoteSyncObject
:model_type "Collection"
:model_id settings/transforms-root-id
:status "delete")
"Sentinel RSO should exist with 'delete' status")
(let [paths (spec/build-all-removal-paths)]
(is (some #(str/includes? % custom-tag-eid) paths)
"Custom tag (built_in_type nil) should be in removal paths")
(is (not (some #(str/includes? % builtin-tag-eid) paths))
"Built-in tag should NOT be in removal paths")))))))
(deftest export-excludes-builtin-transform-tags-test
(testing "Export excludes built-in transform tags based on :conditions"
(mt/with-premium-features #{:transforms-basic}
@@ -945,21 +908,3 @@ serdes/meta:
"Built-in PythonLibrary should be in export roots")
(is (contains? exported-ids (:id custom-lib))
"Custom PythonLibrary should be in export roots")))))))
(deftest build-all-removal-paths-excludes-builtin-python-library-test
(testing "build-all-removal-paths excludes built-in PythonLibrary via :removal-conditions"
(mt/with-premium-features #{:transforms}
(mt/with-temporary-setting-values [remote-sync-transforms true
remote-sync-enabled true]
(mt/with-temp [:model/PythonLibrary _ {:path "common.py"
:source "# builtin"
:entity_id transforms-python/builtin-entity-id}
:model/PythonLibrary custom-lib {:path "custom.py"
:source "# custom"}]
(settings/sync-transform-tracking! true)
(settings/sync-transform-tracking! false)
(let [paths (spec/build-all-removal-paths)]
(is (some #(str/includes? % (:entity_id custom-lib)) paths)
"Custom PythonLibrary should be in removal paths")
(is (not (some #(str/includes? % transforms-python/builtin-entity-id) paths))
"Built-in PythonLibrary should NOT be in removal paths")))))))

View File

@@ -30,16 +30,11 @@
[#"([^/]+)?/$" :dir
#"/settings.yaml$" :settings
#"/export.log$" :log
#"/collections/metabots/(.*)\.yaml$" :metabot
#"/collections/.*/cards/(.*)\.yaml$" :card
#"/collections/.*/dashboards/(.*)\.yaml$" :dashboard
#"/collections/.*collection/([^/]*)\.yaml$" :collection
#"/collections/([^/]*)\.yaml$" :collection
#"/snippets/(.*)\.yaml" :snippet
#"/collections/(.*)\.yaml$" :collection-entity
#"/databases/.*/schemas/(.*)" :schema
#"/databases/(.*)\.yaml" :database
#"/transforms/(.*)\.yaml" :transform
#"/python-libraries/(.*)\.yaml" :python-library])
#"/python_libraries/(.*)\.yaml" :python-library])
(defn- file-type
"Find out entity type by file path"
@@ -128,34 +123,33 @@
(testing "We can export just a single collection"
(let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {}
:collection (:id coll) :data_model false :settings false)]
(is (= #{:log :dir :dashboard :card :collection :transform :python-library}
(is (= #{:log :dir :collection-entity :transform :python-library}
(tar-file-types f)))))
(testing "We can export two collections"
(let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {}
:collection (:id coll) :collection (:id coll2)
:data_model false :settings false)]
(is (= 2
(->> (tar-file-types f true)
(filter #(= :collection (first %)))
count)))))
(is (some #(= :collection-entity (first %))
(tar-file-types f true))
"Export should contain collection entities")))
(testing "We can export that collection using entity id"
(let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {}
;; eid:... syntax is kept for backward compat
:collection (str "eid:" (:entity_id coll)) :data_model false :settings false)]
(is (= #{:log :dir :dashboard :card :collection :transform :python-library}
(is (= #{:log :dir :collection-entity :transform :python-library}
(tar-file-types f)))))
(testing "We can export that collection using entity id"
(let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {}
:collection (:entity_id coll) :data_model false :settings false)]
(is (= #{:log :dir :dashboard :card :collection :transform :python-library}
(is (= #{:log :dir :collection-entity :transform :python-library}
(tar-file-types f)))))
(testing "Default export: all-collections, data-model, settings"
(let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {})]
(is (= #{:transform :log :dir :dashboard :card :collection :settings :schema :database :python-library}
(is (= #{:transform :log :dir :collection-entity :settings :schema :database :python-library}
(tar-file-types f)))))
(testing "On exception API returns log"
@@ -302,7 +296,7 @@
(deftest import-collection-reference-error-test
(testing "Import fails with 500 when collection reference is invalid"
(with-serialization-test-data! [coll _dash card]
(with-serialization-test-data! [coll _dash _card]
(let [ba (do-export (:id coll))]
;; Pop the export snowplow event
(snowplow-test/pop-event-data-and-user-id!)
@@ -310,7 +304,7 @@
(mt/with-dynamic-fn-redefs [v2.ingest/ingest-file (let [ingest-file (mt/dynamic-value #'v2.ingest/ingest-file)]
(fn [^File file]
(cond-> (ingest-file file)
(str/includes? (.getName file) (:entity_id card))
(= (.getName file) "frobinate.yaml")
(assoc :collection_id "DoesNotExist"))))]
(let [res (binding [api.serialization/*additive-logging* false]
(mt/user-http-request :crowberto :post 500 "ee/serialization/import"
@@ -333,7 +327,7 @@
(deftest import-continue-on-error-test
(testing "Import with continue_on_error=true succeeds partially despite errors"
(with-serialization-test-data! [coll _dash card]
(with-serialization-test-data! [coll _dash _card]
(let [ba (do-export (:id coll))]
;; Pop the export snowplow event
(snowplow-test/pop-event-data-and-user-id!)
@@ -341,7 +335,7 @@
(mt/with-dynamic-fn-redefs [v2.ingest/ingest-file (let [ingest-file (mt/dynamic-value #'v2.ingest/ingest-file)]
(fn [^File file]
(cond-> (ingest-file file)
(str/includes? (.getName file) (:entity_id card))
(= (.getName file) "frobinate.yaml")
(assoc :collection_id "DoesNotExist"))))]
(let [res (mt/user-http-request :crowberto :post 200 "ee/serialization/import"
{:request-options {:headers {"content-type" "multipart/form-data"}}}

View File

@@ -1,7 +1,6 @@
(ns metabase-enterprise.serialization.cmd-test
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.serialization.test-util :as ts]
[metabase-enterprise.serialization.v2.extract :as v2.extract]
@@ -34,7 +33,7 @@
(snowplow-test/with-fake-snowplow-collector
(ts/with-random-dump-dir [dump-dir "serdesv2-"]
(let [coll (ts/create! :model/Collection :name "coll")
card (ts/create! :model/Card :name "card" :collection_id (:id coll))]
_card (ts/create! :model/Card :name "card" :collection_id (:id coll))]
(cmd/export dump-dir "--collection" (str (:id coll)) "--no-data-model")
(testing "Snowplow export event was sent"
(is (=? {"event" "serialization"
@@ -93,7 +92,7 @@
;; override that reliably
(with-redefs [v2.ingest/ingest-file (fn [^java.io.File file]
(cond-> (ingest-file file)
(str/includes? (.getName file) (:entity_id card))
(= (.getName file) "card.yaml")
(assoc :collection_id "DoesNotExist")))]
(is (thrown? Exception
(cmd/import dump-dir)))

View File

@@ -116,6 +116,7 @@
:model/CloudMigration
:model/Comment
:model/CommentReaction
:model/ReleaseFlag
;; TODO (lbrdnk 2025-12-17) -- I've added rest of the workspace models here as Workspace was present. I believe
;; going forward all of that will be available for export. We should revisit this later in the project.
:model/Workspace

View File

@@ -4,6 +4,7 @@
[clojure.test :refer :all]
[medley.core :as m]
[metabase-enterprise.serialization.cmd :as cmd]
[metabase-enterprise.serialization.dump :as dump]
[metabase-enterprise.serialization.test-util :as ts]
[metabase-enterprise.serialization.v2.extract :as extract]
[metabase-enterprise.serialization.v2.ingest :as ingest]
@@ -53,13 +54,10 @@
(filter #(-> % :serdes/meta last :model (= model-name))
entities))
(defn- collections [dir]
(for [coll-dir (subdirs dir)
:when (->> ["cards" "dashboards" "timelines"]
(map #(io/file coll-dir %))
(filter #(= % coll-dir))
empty?)]
coll-dir))
(defn- yaml-model-at
"Read a YAML file and return its serdes model name."
[^File base-dir path-vec]
(-> (apply io/file base-dir path-vec) yaml/from-file :serdes/meta last :model))
(defn- file-set [^File dir]
(let [^Path base (.toPath dir)]
@@ -220,50 +218,44 @@
(testing "for Collections"
;; +1 for the Trash collection
(is (= 110 (count (for [f (file-set (io/file dump-dir))
:when (and (= (first f) "collections")
(let [[a b] (take-last 2 f)]
(= b (str a ".yaml"))))]
f)))
"which all go in collections/, even the snippets ones"))
(let [colls-dir (io/file dump-dir "collections")
coll-count (count (for [f (file-set colls-dir)
:when (= "Collection" (yaml-model-at colls-dir f))]
f))]
;; +1 for Trash collection; exact count may vary by 1 depending on naming collisions
(is (<= 109 coll-count 111)
"which all go in collections/, even the snippets ones")))
(testing "for Databases"
(is (= 10 (count (dir->dir-set (io/file dump-dir "databases"))))))
(testing "for Tables"
(is (= 100
(reduce + (for [db (get @entities "Database")
:let [tables (dir->dir-set (io/file dump-dir "databases" (:name db) "tables"))]]
(count tables))))
(reduce + (for [db (dir->dir-set (io/file dump-dir "databases"))
:let [tables-dir (io/file dump-dir "databases" db "tables")]
:when (.exists tables-dir)]
(count (dir->dir-set tables-dir)))))
"Tables are scattered, so the directories are harder to count"))
(testing "for Fields"
(is (= 1000
(reduce + (for [db (get @entities "Database")
table (subdirs (io/file dump-dir "databases" (:name db) "tables"))]
(->> (io/file table "fields")
dir->file-set
count))))
(reduce + (for [db (dir->dir-set (io/file dump-dir "databases"))
table (subdirs (io/file dump-dir "databases" db "tables"))
:let [fields-dir (io/file table "fields")]
:when (.exists fields-dir)]
(count (dir->file-set fields-dir)))))
"Fields are scattered, so the directories are harder to count"))
(testing "for cards"
;; 100 from card, and 10 from simple-model
(is (= 110 (->> (io/file dump-dir "collections")
collections
(map (comp count dir->file-set #(io/file % "cards")))
(reduce +)))))
(testing "for dashboards"
(is (= 150 (->> (io/file dump-dir "collections")
collections
(map (comp count dir->file-set #(io/file % "dashboards")))
(reduce +)))))
(testing "for timelines"
(is (= 10 (->> (io/file dump-dir "collections")
collections
(map (comp count dir->file-set #(io/file % "timelines")))
(reduce +)))))
(testing "for cards, dashboards, and timelines"
;; In the new storage format, cards/dashboards/timelines are stored directly
;; in collection directories (no per-type subfolders).
;; 100 cards + 10 simple-models + 150 dashboards + 10 timelines = 270
;; We count all yaml files under collections/main/ that are NOT collection definitions.
;; exact count may vary by 1 depending on naming collisions with collection names
(let [main-dir (io/file dump-dir "collections" "main")]
(is (<= 269 (count (for [f (file-set main-dir)
:when (not= "Collection" (yaml-model-at main-dir f))]
f)) 271))))
(testing "for segments"
(is (= 30 (reduce + (for [db (dir->dir-set (io/file dump-dir "databases"))
@@ -273,10 +265,12 @@
(count (dir->file-set segments-dir)))))))
(testing "for native query snippets"
(is (= 10 (->> (io/file dump-dir "snippets")
collections
(map (comp count dir->file-set))
(reduce +)))))
;; Snippets are now under collections/snippets/ (not a top-level snippets/ dir).
;; Count non-collection yaml files under collections/snippets/.
(let [snippets-dir (io/file dump-dir "collections" "snippets")]
(is (= 10 (count (for [f (file-set snippets-dir)
:when (not= "Collection" (yaml-model-at snippets-dir f))]
f))))))
(testing "for settings"
(is (.exists (io/file dump-dir "settings.yaml")))))
@@ -998,3 +992,25 @@
(testing "loads well too"
(is (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir)))
"ingested successfully"))))))
(deftest old-format-paths-import-test
(testing "serdes can import content stored at old-format paths (entity_id in name, per-type subfolders)"
(ts/with-random-dump-dir [dump-dir "serdesv2-"]
(mt/with-empty-h2-app-db!
;; Write YAML files using the OLD path format (entity_id_slug, cards/ subfolder)
(let [coll-eid "old-test-coll-xxxxxxx"
coll-dir (io/file dump-dir "collections" (str coll-eid "_test_collection"))
coll-yaml {:serdes/meta [{:model "Collection" :id coll-eid :label "test_collection"}]
:name "Test Collection" :entity_id coll-eid :namespace nil
:slug "test_collection" :archived false}]
;; Old format: collections/entityid_slug/entityid_slug.yaml
(dump/spit-yaml! (io/file coll-dir (str coll-eid "_test_collection.yaml")) coll-yaml)
;; Write settings.yaml (required)
(dump/spit-yaml! (io/file dump-dir "settings.yaml") {})
(testing "old-format files can be ingested and loaded"
(let [ingestable (ingest/ingest-yaml dump-dir)]
(is (serdes/with-cache (serdes.load/load-metabase! ingestable))
"ingestion should succeed")
(is (t2/exists? :model/Collection :entity_id coll-eid)
"collection should have been imported from old-format path"))))))))

View File

@@ -67,28 +67,28 @@
(testing "a top-level collection is extracted correctly"
(let [ser (serdes/extract-one "Collection" {} (t2/select-one :model/Collection :id coll-id))]
(is (=? {:serdes/meta [{:model "Collection" :id coll-eid :label coll-slug}]
:personal_owner_id nil
:parent_id nil}
(is (=? {:serdes/meta [{:model "Collection" :id coll-eid :label coll-slug}]}
ser))
(is (not (contains? ser :personal_owner_id)))
(is (not (contains? ser :parent_id)))
(is (not (contains? ser :location)))
(is (not (contains? ser :id)))))
(testing "a nested collection is extracted with the right parent_id"
(let [ser (serdes/extract-one "Collection" {} (t2/select-one :model/Collection :id child-id))]
(is (=? {:serdes/meta [{:model "Collection" :id child-eid :label child-slug}]
:personal_owner_id nil
:parent_id coll-eid}
(is (=? {:serdes/meta [{:model "Collection" :id child-eid :label child-slug}]
:parent_id coll-eid}
ser))
(is (not (contains? ser :personal_owner_id)))
(is (not (contains? ser :location)))
(is (not (contains? ser :id)))))
(testing "personal collections are extracted with email as key"
(let [ser (serdes/extract-one "Collection" {} (t2/select-one :model/Collection :id pc-id))]
(is (=? {:serdes/meta [{:model "Collection" :id pc-eid :label pc-slug}]
:parent_id nil
:personal_owner_id "mark@direstrai.ts"}
ser))
(is (not (contains? ser :parent_id)))
(is (not (contains? ser :location)))
(is (not (contains? ser :id)))))
@@ -1072,8 +1072,8 @@
[{:model "Collection" :id coll-eid}])))))
(testing "unpublished table without collection_id"
(let [ser (ts/extract-one "Table" unpub-table-id)]
(testing "is_published defaults to false"
(is (false? (:is_published ser))))
(testing "is_published is omitted (default false)"
(is (not (contains? ser :is_published))))
(testing "collection_id is nil"
(is (nil? (:collection_id ser))))
(testing "does not depend on any collection"
@@ -1081,8 +1081,8 @@
(serdes/dependencies ser)))))))
(testing "regular table without publishing fields set"
(let [ser (ts/extract-one "Table" table-id)]
(testing "is_published defaults to false"
(is (false? (:is_published ser))))
(testing "is_published is omitted (default false)"
(is (not (contains? ser :is_published))))
(testing "collection_id is nil"
(is (nil? (:collection_id ser)))))))))
@@ -1310,10 +1310,9 @@
{:model "Table" :id "Schemaless Table"}
{:model "Field" :id "A Field"}]}
(set (serdes/dependencies ser))))))
(testing "Nullable transformations stay as nulls"
(testing "Nullable transformations are omitted"
(let [ser (serdes/extract-one "Card" {} (t2/select-one :model/Card :id card-id-2))]
(is (=? {:made_public_by_id nil}
ser)))))))
(is (not (contains? ser :made_public_by_id))))))))
#_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]}
(deftest selective-serialization-basic-test
@@ -2013,7 +2012,6 @@
:description "A test metabot"
:entity_id metabot-eid
:collection_id coll-eid
:use_verified_content false
:prompts [{:prompt "A sample prompt"
:model "model"
:entity_id metabot-prompt-eid
@@ -2024,6 +2022,7 @@
:created_at string?}
ser))
(is (not (contains? ser :id)))
(is (not (contains? ser :use_verified_content)))
(testing "metabot depends on its model entities"
(is (= #{[{:model "Card" :id model-eid}]}
@@ -2060,7 +2059,6 @@
:description "A test metabot"
:entity_id metabot-eid
:collection_id coll-eid
:use_verified_content false
:prompts [{:prompt "A sample prompt"
:model "model"
:entity_id metabot-prompt-eid
@@ -2071,6 +2069,7 @@
:created_at string?}
ser))
(is (not (contains? ser :id)))
(is (not (contains? ser :use_verified_content)))
(testing "metabot depends on its prompts' cards"
(is (= #{[{:model "Card" :id card-eid}]}
@@ -2122,14 +2121,14 @@
{:type "smartLink"
:attrs {:entityId [{:model "Dashboard" :id (:entity_id dashboard)}]
:model "dashboard"}}]}
:archived false
:archived_directly false
:creator_id (:email user)
:collection_id (:entity_id collection)
:content_type "application/json+vnd.prose-mirror"
:updated_at string?
:created_at string?}
ser))
(is (not (contains? ser :archived)))
(is (not (contains? ser :archived_directly)))
(is (not (contains? ser :id)))
(testing "depends on its collection, cardEmbeds and smarkLinks "
@@ -2227,9 +2226,9 @@
(is (=? {:serdes/meta [{:model "TransformTag"
:id custom-tag-eid}]
:name "custom-etl"
:built_in_type nil
:created_at string?}
ser))
(is (not (contains? ser :built_in_type)))
(is (not (contains? ser :id)))
(is (empty? (serdes/dependencies ser)))))
@@ -2432,9 +2431,9 @@
:name "Custom ETL Job"
:description "Custom data processing job"
:schedule "0 0 2 * * ? *"
:built_in_type nil
:created_at string?}
ser))
(is (not (contains? ser :built_in_type)))
(is (not (contains? ser :id)))
(testing "job has multiple associated tags in correct order"
(is (= 2 (count (:job_tags ser))))

View File

@@ -872,29 +872,27 @@
timeline2 (first (filter #(= (:entity_id %) (:entity_id @timeline2s)) timelines))]
(testing "with inline :events"
(is (malli= [:map
[:serdes/meta [:= [{:model "Timeline"
:id (:entity_id timeline1)
:label "some_events"}]]]
[:archived [:= false]]
[:collection_id [:= (:entity_id @coll1s)]]
[:name [:= "Some events"]]
[:creator_id [:= "tom@bost.on"]]
[:created_at :string]
[:entity_id [:= (:entity_id timeline1)]]
[:description [:maybe :string]]
[:events [:sequential
[:map
[:timezone :string]
[:time_matters :boolean]
[:name :string]
[:archived :boolean]
[:description [:maybe :string]]
[:creator_id :string]
[:created_at :string]
[:timestamp :string]
[:icon {:optional true} [:maybe :string]]]]]
[:icon {:optional true} [:maybe :string]]
[:default {:optional true} :boolean]]
[:serdes/meta [:= [{:model "Timeline"
:id (:entity_id timeline1)
:label "some_events"}]]]
[:collection_id [:= (:entity_id @coll1s)]]
[:name [:= "Some events"]]
[:creator_id [:= "tom@bost.on"]]
[:created_at :string]
[:entity_id [:= (:entity_id timeline1)]]
[:description {:optional true} [:maybe :string]]
[:events [:sequential
[:map
[:timezone :string]
[:time_matters :boolean]
[:name :string]
[:archived {:optional true} :boolean]
[:description {:optional true} [:maybe :string]]
[:creator_id :string]
[:created_at :string]
[:timestamp :string]
[:icon {:optional true} [:maybe :string]]]]]
[:icon {:optional true} [:maybe :string]]]
timeline1))
(is (= 2 (-> timeline1 :events count)))
(is (= 1 (-> timeline2 :events count)))))))

View File

@@ -16,6 +16,16 @@
(comment
metabase-enterprise.impersonation.model/keep-me)
(defn- parse-default-value
"Parse a default value string into a Clojure value.
Only cares about boolean defaults (TRUE/FALSE/'true'/'false'); returns nil for everything else."
[default-value]
(when default-value
(case (u/upper-case-en default-value)
("TRUE" "'TRUE'") true
("FALSE" "'FALSE'") false
nil)))
(def ^:private datetime? #{"timestamptz"
"TIMESTAMP WITH TIME ZONE"
"timestamp"})
@@ -114,4 +124,21 @@
inner-spec (serdes/make-spec (name model) nil)]]
(testing (format "%s has %s declared as `parent-ref`" model backward-fk)
(is (= (serdes/parent-ref)
(get-in inner-spec [:transform backward-fk])))))))))))
(get-in inner-spec [:transform backward-fk]))))))
(testing ":defaults match actual DB field defaults"
(let [serialized-fields (set (concat (:copy spec) (keys (:transform spec))))
declared-defaults (or (:defaults spec) {})]
;; Every DB field with a boolean default that is serialized should be in :defaults
(doseq [[field-name field] fields
:let [field-kw (keyword (u/lower-case-en field-name))
db-default (some-> field :default parse-default-value)]
:when (and (some? db-default)
(contains? serialized-fields field-kw))]
(testing (format "`%s.%s` has DB default %s" m (name field-kw) (pr-str db-default))
(is (contains? declared-defaults field-kw))
(is (= db-default (get declared-defaults field-kw)))))
;; Every entry in :defaults should reference a serialized field
(doseq [field-kw (keys declared-defaults)]
(testing (format "`%s.%s` in :defaults is a serialized field" m (name field-kw))
(is (contains? serialized-fields field-kw)))))))))))

View File

@@ -27,36 +27,39 @@
(mt/with-empty-h2-app-db!
(ts/with-temp-dpc [:model/Collection parent {:name "Some Collection"}
:model/Collection child {:name "Child Collection" :location (format "/%d/" (:id parent))}]
(let [export (into [] (extract/extract {:no-transforms true}))
parent-filename (format "%s_some_collection" (:entity_id parent))
child-filename (format "%s_child_collection" (:entity_id child))]
(let [export (into [] (extract/extract {:no-transforms true}))]
(storage/store! export dump-dir)
(testing "the right files in the right places"
(is (= #{[parent-filename (str parent-filename ".yaml")]
[parent-filename child-filename (str child-filename ".yaml")]}
(is (= #{["main" "some_collection.yaml"]
["main" "some_collection" "child_collection.yaml"]}
(file-set (io/file dump-dir "collections")))
"collections form a tree, with same-named files")
"collections form a tree, with files as siblings of their content folders")
(is (contains? (file-set (io/file dump-dir))
["settings.yaml"])
"A few top-level files are expected"))
(testing "the Collections properly exported"
(is (= (-> (into {} (t2/select-one :model/Collection :id (:id parent)))
(dissoc :id :location)
(assoc :parent_id nil)
(update :created_at t/offset-date-time))
(-> (yaml/from-file (io/file dump-dir "collections" parent-filename (str parent-filename ".yaml")))
(dissoc :serdes/meta)
(update :created_at t/offset-date-time))))
(let [yaml-parent (-> (yaml/from-file (io/file dump-dir "collections" "main"
"some_collection.yaml"))
(dissoc :serdes/meta)
(update :created_at t/offset-date-time))
yaml-child (-> (yaml/from-file (io/file dump-dir "collections" "main"
"some_collection" "child_collection.yaml"))
(dissoc :serdes/meta)
(update :created_at t/offset-date-time))]
(is (= (-> (into {} (t2/select-one :model/Collection :id (:id parent)))
(dissoc :id :location)
(assoc :parent_id nil)
(update :created_at t/offset-date-time)
(select-keys (keys yaml-parent)))
yaml-parent))
(is (= (-> (into {} (t2/select-one :model/Collection :id (:id child)))
(dissoc :id :location)
(assoc :parent_id (:entity_id parent))
(update :created_at t/offset-date-time))
(-> (yaml/from-file (io/file dump-dir "collections" parent-filename
child-filename (str child-filename ".yaml")))
(dissoc :serdes/meta)
(update :created_at t/offset-date-time))))))))))
(is (= (-> (into {} (t2/select-one :model/Collection :id (:id child)))
(dissoc :id :location)
(assoc :parent_id (:entity_id parent))
(update :created_at t/offset-date-time)
(select-keys (keys yaml-child)))
yaml-child)))))))))
(deftest collection-nesting-test
(ts/with-random-dump-dir [dump-dir "serdesv2-"]
@@ -67,26 +70,23 @@
:location (str "/" (:id grandparent) "/")}
:model/Collection child {:name "Child Collection"
:location (str "/" (:id grandparent) "/" (:id parent) "/")}
:model/Card c1 {:name "root card" :collection_id nil}
:model/Card c2 {:name "grandparent card" :collection_id (:id grandparent)}
:model/Card c3 {:name "parent card" :collection_id (:id parent)}
:model/Card c4 {:name "child card" :collection_id (:id child)}
:model/Dashboard d1 {:name "parent dash" :collection_id (:id parent)}]
:model/Card _c1 {:name "root card" :collection_id nil}
:model/Card _c2 {:name "grandparent card" :collection_id (:id grandparent)}
:model/Card _c3 {:name "parent card" :collection_id (:id parent)}
:model/Card _c4 {:name "child card" :collection_id (:id child)}
:model/Dashboard _d1 {:name "parent dash" :collection_id (:id parent)}]
(let [export (into [] (extract/extract {:no-transforms true}))]
(storage/store! export dump-dir)
(testing "the right files in the right places"
(let [gp-dir (str (:entity_id grandparent) "_grandparent_collection")
p-dir (str (:entity_id parent) "_parent_collection")
c-dir (str (:entity_id child) "_child_collection")]
(is (= #{[gp-dir (str gp-dir ".yaml")] ; Grandparent collection
[gp-dir p-dir (str p-dir ".yaml")] ; Parent collection
[gp-dir p-dir c-dir (str c-dir ".yaml")] ; Child collection
["cards" (str (:entity_id c1) "_root_card.yaml")] ; Root card
[gp-dir "cards" (str (:entity_id c2) "_grandparent_card.yaml")] ; Grandparent card
[gp-dir p-dir "cards" (str (:entity_id c3) "_parent_card.yaml")] ; Parent card
[gp-dir p-dir c-dir "cards" (str (:entity_id c4) "_child_card.yaml")] ; Child card
[gp-dir p-dir "dashboards" (str (:entity_id d1) "_parent_dash.yaml")]} ; Parent dashboard
(file-set (io/file dump-dir "collections")))))))))))
(is (= #{["main" "grandparent_collection.yaml"]
["main" "grandparent_collection" "parent_collection.yaml"]
["main" "grandparent_collection" "parent_collection" "child_collection.yaml"]
["main" "root_card.yaml"]
["main" "grandparent_collection" "grandparent_card.yaml"]
["main" "grandparent_collection" "parent_collection" "parent_card.yaml"]
["main" "grandparent_collection" "parent_collection" "child_collection" "child_card.yaml"]
["main" "grandparent_collection" "parent_collection" "parent_dash.yaml"]}
(file-set (io/file dump-dir "collections"))))))))))
(deftest snippets-collections-nesting-test
(ts/with-random-dump-dir [dump-dir "serdesv2-"]
@@ -100,28 +100,23 @@
:model/Collection child {:name "Child Collection"
:namespace :snippets
:location (str "/" (:id grandparent) "/" (:id parent) "/")}
:model/NativeQuerySnippet c1 {:name "root snippet" :collection_id nil}
:model/NativeQuerySnippet c2 {:name "grandparent snippet" :collection_id (:id grandparent)}
:model/NativeQuerySnippet c3 {:name "parent snippet" :collection_id (:id parent)}
:model/NativeQuerySnippet c4 {:name "child snippet" :collection_id (:id child)}]
:model/NativeQuerySnippet _c1 {:name "root snippet" :collection_id nil}
:model/NativeQuerySnippet _c2 {:name "grandparent snippet" :collection_id (:id grandparent)}
:model/NativeQuerySnippet _c3 {:name "parent snippet" :collection_id (:id parent)}
:model/NativeQuerySnippet _c4 {:name "child snippet" :collection_id (:id child)}]
(let [export (into [] (extract/extract {:no-settings true
:no-data-model true
:no-transforms true}))]
(storage/store! export dump-dir)
(let [gp-dir (str (:entity_id grandparent) "_grandparent_collection")
p-dir (str (:entity_id parent) "_parent_collection")
c-dir (str (:entity_id child) "_child_collection")]
(testing "collections under collections/"
(is (= #{[gp-dir (str gp-dir ".yaml")] ; Grandparent collection
[gp-dir p-dir (str p-dir ".yaml")] ; Parent collection
[gp-dir p-dir c-dir (str c-dir ".yaml")]} ; Child collection
(file-set (io/file dump-dir "collections")))))
(testing "snippets under snippets/"
(is (= #{[(str (:entity_id c1) "_root_snippet.yaml")] ; Root snippet
[gp-dir (str (:entity_id c2) "_grandparent_snippet.yaml")] ; Grandparent snippet
[gp-dir p-dir (str (:entity_id c3) "_parent_snippet.yaml")] ; Parent snippet
[gp-dir p-dir c-dir (str (:entity_id c4) "_child_snippet.yaml")]} ; Child snippet
(file-set (io/file dump-dir "snippets")))))))))))
(testing "all snippet collections and snippets under collections/snippets/"
(is (= #{["snippets" "grandparent_collection.yaml"]
["snippets" "grandparent_collection" "parent_collection.yaml"]
["snippets" "grandparent_collection" "parent_collection" "child_collection.yaml"]
["snippets" "root_snippet.yaml"]
["snippets" "grandparent_collection" "grandparent_snippet.yaml"]
["snippets" "grandparent_collection" "parent_collection" "parent_snippet.yaml"]
["snippets" "grandparent_collection" "parent_collection" "child_collection" "child_snippet.yaml"]}
(file-set (io/file dump-dir "collections"))))))))))
(deftest embedded-slash-test
(ts/with-random-dump-dir [dump-dir "serdesv2-"]
@@ -134,20 +129,20 @@
(let [export (into [] (extract/extract {:include-field-values true}))]
(storage/store! export dump-dir)
(testing "the right files in the right places"
(is (= #{["Company__SLASH__organization website.yaml"]
["Company__SLASH__organization website___fieldvalues.yaml"]}
(file-set (io/file dump-dir "databases" "My Company Data" "tables" "Customers" "fields")))
(is (= #{["company__SLASH__organization_website.yaml"]
["company__SLASH__organization_website___fieldvalues.yaml"]}
(file-set (io/file dump-dir "databases" "my_company_data" "tables" "customers" "fields")))
"Slashes in file names get escaped")
(is (contains? (file-set (io/file dump-dir "databases" "My Company Data" "tables"))
["Orders__SLASH__Invoices" "Orders__SLASH__Invoices.yaml"])
(is (contains? (file-set (io/file dump-dir "databases" "my_company_data" "tables"))
["orders__SLASH__invoices" "orders__SLASH__invoices.yaml"])
"Slashes in directory names get escaped"))
(testing "the Field was properly exported"
(is (= (ts/extract-one "Field" (:id website))
(-> (yaml/from-file (io/file dump-dir
"databases" "My Company Data"
"tables" "Customers"
"fields" "Company__SLASH__organization website.yaml"))
"databases" "my_company_data"
"tables" "customers"
"fields" "company__SLASH__organization_website.yaml"))
(update :visibility_type keyword)
(update :base_type keyword))))))))))
@@ -242,27 +237,105 @@
(ts/with-random-dump-dir [dump-dir "serdesv2-"]
(mt/with-empty-h2-app-db!
(t2/delete! :model/PythonLibrary)
(ts/with-temp-dpc [:model/PythonLibrary lib {:path "common" :source "def test(): pass"}]
(ts/with-temp-dpc [:model/PythonLibrary _lib {:path "common" :source "def test(): pass"}]
(let [export (into [] (extract/extract {:no-settings true :no-data-model true}))]
(storage/store! export dump-dir)
(testing "python library stored at top-level python-libraries/"
(is (= #{[(str (:entity_id lib) ".yaml")]}
(file-set (io/file dump-dir "python-libraries"))))))))))
(testing "python library stored at top-level python_libraries/"
(is (= #{["common.py.yaml"]}
(file-set (io/file dump-dir "python_libraries"))))))))))
(deftest name-too-long-test
(ts/with-random-dump-dir [dump-dir "serdesv2-"]
;; that's a char that takes 3 bytes in utf-8
(ts/with-temp-dpc [:model/Card card {:name (str/join (repeat 100 "ป"))}]
(let [export (into [] (extract/extract {:no-settings true
:no-data-model true
:no-transforms true
:targets [["Card" (:id card)]]}))
;; 66 is 'char-count * max-bytes / byte-count'
card-filename (format "%s_%s" (:entity_id card) (str/join (repeat 66 "ป")))]
(storage/store! export dump-dir)
;; we could also test loading here, but file names do not play significant part in how everything's loaded,
;; `:serdes/meta` does and that one is not shortened or anything
(testing "the right files in the right places"
(is (= #{["cards" (str card-filename ".yaml")]}
(file-set (io/file dump-dir "collections")))
"collections form a tree, with same-named files"))))))
(mt/with-empty-h2-app-db!
;; that's a char that takes 3 bytes in utf-8
(ts/with-temp-dpc [:model/Card card {:name (str/join (repeat 100 "ป"))}]
(let [export (into [] (extract/extract {:no-settings true
:no-data-model true
:no-transforms true
:targets [["Card" (:id card)]]}))
;; 66 is 'char-count * max-bytes / byte-count'
card-filename (str/join (repeat 66 "ป"))]
(storage/store! export dump-dir)
;; we could also test loading here, but file names do not play significant part in how everything's loaded,
;; `:serdes/meta` does and that one is not shortened or anything
(testing "the right files in the right places"
(is (= #{["main" (str card-filename ".yaml")]}
(file-set (io/file dump-dir "collections")))
"collections form a tree, with same-named files")))))))
(deftest ^:parallel resolve-path-test
(let [resolve-path @#'storage/resolve-path]
(testing "basic slugification"
(let [fns (atom {})]
(is (= ["my_collection" "some_card"]
(resolve-path fns [{:label "My Collection" :key "coll-1"}
{:label "Some Card" :key "card-1"}])))))
(testing "special characters are replaced with underscores"
(let [fns (atom {})]
(is (= ["hello_world_"]
(resolve-path fns [{:label "Hello World!" :key "a"}])))))
(testing "slashes are escaped"
(let [fns (atom {})]
(is (= ["orders__SLASH__invoices"]
(resolve-path fns [{:label "Orders/Invoices" :key "a"}])))))
(testing "backslashes are escaped"
(let [fns (atom {})]
(is (= ["c__BACKSLASH__d"]
(resolve-path fns [{:label "C\\D" :key "a"}])))))
(testing "deduplication within the same folder"
(let [fns (atom {})]
(is (= ["my_card"]
(resolve-path fns [{:label "My Card" :key "card-1"}])))
(is (= ["my_card_2"]
(resolve-path fns [{:label "My Card" :key "card-2"}]))
"second entity with same name in same folder gets _2 suffix")))
(testing "same name in different folders does not conflict"
(let [fns (atom {})]
(is (= ["folder_a" "readme"]
(resolve-path fns [{:label "Folder A" :key "f-a"}
{:label "README" :key "doc-1"}])))
(is (= ["folder_b" "readme"]
(resolve-path fns [{:label "Folder B" :key "f-b"}
{:label "README" :key "doc-2"}]))
"same leaf name under different parents is fine")))
(testing "same key with same slug is stable"
(let [fns (atom {})]
(is (= ["my_card"]
(resolve-path fns [{:label "My Card" :key "card-1"}])))
(is (= ["my_card"]
(resolve-path fns [{:label "My Card" :key "card-1"}]))
"re-resolving the same key+label returns the same result")))
(testing "unicode is preserved"
(let [fns (atom {})]
(is (= ["données"]
(resolve-path fns [{:label "Données" :key "a"}])))))
(testing "dots are preserved"
(let [fns (atom {})]
(is (= ["parent.child"]
(resolve-path fns [{:label "parent.child" :key "a"}])))))
(testing "duplicate parent folder names with different keys"
(let [fns (atom {})]
(is (= ["my_folder" "card_a"]
(resolve-path fns [{:label "My Folder" :key "folder-1"}
{:label "Card A" :key "card-a"}])))
(is (= ["my_folder_2" "card_b"]
(resolve-path fns [{:label "My Folder" :key "folder-2"}
{:label "Card B" :key "card-b"}]))
"second folder with same name gets _2 suffix")
(is (= ["my_folder_3" "card_c"]
(resolve-path fns [{:label "My Folder" :key "folder-3"}
{:label "Card C" :key "card-c"}]))
"third folder with same name gets _3 suffix")))
(testing "empty path returns empty vector"
(let [fns (atom {})]
(is (= [] (resolve-path fns [])))))))

View File

@@ -6,8 +6,10 @@
[clojure.test :refer :all]
[java-time.api :as t]
[metabase-enterprise.support-access-grants.core :as grants]
[metabase-enterprise.support-access-grants.provider :as sag.provider]
[metabase-enterprise.support-access-grants.settings :as sag.settings]
[metabase.auth-identity.core :as auth-identity]
[metabase.auth-identity.provider :as auth.provider]
[metabase.session.api :as api.session]
[metabase.session.core :as session]
[metabase.test :as mt]
@@ -151,6 +153,160 @@
(is (= "support@example.com" (:email (t2/select-one :model/User :id (:user_id session))))
"Session should be for support user")))))))))))
(deftest reset-password-with-pre-existing-emailed-secret-auth-identity-test
(testing (str "POST /api/session/reset_password works when support user "
"has a pre-existing emailed-secret-password-reset AuthIdentity")
(testing (str "Reproduces production scenario: support user created previously, "
"emailed-secret-password-reset AuthIdentity exists, "
"new grant is created, reset_password should succeed")
(mt/with-temp [:model/User {creator-id :id} {}]
(mt/with-model-cleanup [:model/SupportAccessGrantLog :model/AuthIdentity :model/User]
(let [email "support-repro@example.com"]
(with-redefs [sag.settings/support-access-grant-email (constantly email)
sag.settings/support-access-grant-first-name (constantly "Support")
sag.settings/support-access-grant-last-name (constantly "User")]
;; Step 1: Create the support user via an initial grant (simulates first-time setup)
(let [initial-grant (grants/create-grant! creator-id 60 "TICKET-INITIAL" "Initial setup")
initial-token (:token initial-grant)]
(is (some? initial-token) "Initial token should be created")
;; Use the initial token to set the password (simulates the first login)
(let [initial-response (mt/client :post 200 "session/reset_password"
{:token initial-token
:password "InitialPassword123!"})]
(is (true? (:success initial-response)) "Initial reset should succeed"))
;; Now the support user has:
;; - A password AuthIdentity (from the password set)
;; - A consumed support-access-grant AuthIdentity
;; Simulate what happens when the user also gets an emailed-secret-password-reset token
;; (e.g., from a previous forgot-password flow or system migration)
(let [support-user (t2/select-one :model/User :email email)]
;; Create an emailed-secret-password-reset AuthIdentity directly (simulating prior usage)
(let [fake-token (auth-identity/create-password-reset! (:id support-user))]
(is (some? fake-token) "Should create emailed-secret-password-reset token")
;; Verify the pre-existing auth identity exists
(is (t2/exists? :model/AuthIdentity
:user_id (:id support-user)
:provider "emailed-secret-password-reset")
"emailed-secret-password-reset AuthIdentity should exist"))
;; Step 2: Revoke the initial grant so we can create a new one
(let [initial-grant-record (t2/select-one :model/SupportAccessGrantLog
:ticket_number "TICKET-INITIAL")]
(grants/revoke-grant! creator-id (:id initial-grant-record)))
;; Step 3: Create a NEW support access grant (simulates the production scenario)
(let [new-grant (grants/create-grant! creator-id 60 "TICKET-REPRO" "Repro grant")
new-token (:token new-grant)
new-password "NewSecurePassword456!"]
(is (some? new-token) "New token should be created")
;; Step 4: Try to reset password with the new token
;; This is where the production bug manifests:
;; The pre-existing emailed-secret-password-reset AuthIdentity triggers
;; a cascade in model hooks that may cause an exception swallowed by with-fallback
(testing "Password reset succeeds with pre-existing auth identities"
(let [response (mt/client :post 200 "session/reset_password"
{:token new-token
:password new-password})]
(is (true? (:success response)) "Reset should succeed")
(is (some? (:session_id response)) "Should return session ID")))
(testing "Support user can login with the new password"
(let [login-result (auth-identity/login! :provider/password
{:email email
:password new-password
:device-info {:device_id "test-device"
:device_description "Test"
:embedded false
:ip_address "127.0.0.1"}})]
(is (:success? login-result) "Should be able to login with new password")))))))))))))
(deftest ^:parallel with-fallback-preserves-error-info-test
(testing "with-fallback swallows exceptions from the first provider, losing error info"
(let [result (auth-identity/with-fallback
(fn [provider _request]
(if (= provider :provider/support-access-grant)
(throw (ex-info "Simulated error in login" {:test true}))
{:success? false :error :invalid-token}))
[:provider/support-access-grant
:provider/emailed-secret-password-reset]
{:token "fake" :password "TestPassword123!"})]
;; with-fallback catches the exception from the first provider and returns {:success? false}
;; then tries the second provider which also fails
(is (false? (:success? result))
"Should fail because both providers failed")
;; The returned error is from the FALLBACK provider, not from the one that actually threw.
;; The real error ("Simulated error in login") is completely lost.
;; This makes debugging production issues very difficult.
(is (= :invalid-token (:error result))
"Error is from the fallback provider, not the original - the real error is swallowed"))))
(deftest reset-password-login-directly-with-pre-existing-emailed-secret-test
(testing (str "login! for support-access-grant succeeds when user has "
"pre-existing emailed-secret-password-reset AuthIdentity")
(mt/with-temp [:model/User {grant-creator-id :id} {}
:model/User {user-id :id} {}
:model/SupportAccessGrantLog {grant-end :grant_end_timestamp :as grant}
{:user_id grant-creator-id
:ticket_number "TEST-CASCADE"
:notes "Test cascade"
:grant_start_timestamp (t/offset-date-time)
:grant_end_timestamp (t/plus (t/offset-date-time) (t/hours 1))
:revoked_at nil}]
;; Create a pre-existing emailed-secret-password-reset AuthIdentity for the user
;; (simulates a user who previously used forgot-password)
(let [_old-reset-token (auth-identity/create-password-reset! user-id)]
(is (t2/exists? :model/AuthIdentity
:user_id user-id
:provider "emailed-secret-password-reset")
"Pre-existing emailed-secret-password-reset AuthIdentity should exist"))
;; Now create the support access reset token
(let [token (sag.provider/create-support-access-reset! user-id grant)
new-password "NewPassword789!"]
;; Call login! directly (bypassing with-fallback) to see the actual error
(testing "Direct login! call should succeed"
(let [result (auth.provider/login! :provider/support-access-grant
{:token token
:password new-password})]
(is (:success? result) "login! should succeed")
(is (some? (:session result)) "Should create a session")))
(testing "Password AuthIdentity should have grant expiration"
(let [pw-auth (t2/select-one :model/AuthIdentity :user_id user-id :provider "password")]
(is (= grant-end (:expires_at pw-auth)))))
(testing "Support access grant token should be consumed"
(let [sag-auth (t2/select-one :model/AuthIdentity :user_id user-id :provider "support-access-grant")]
(is (some? (get-in sag-auth [:credentials :consumed_at])))))))))
(deftest reset-password-with-deactivated-user-test
(testing "create-grant! reactivates a deactivated support user so password reset works"
(mt/with-temp [:model/User {creator-id :id} {}]
(mt/with-model-cleanup [:model/SupportAccessGrantLog :model/AuthIdentity :model/User]
(let [email "support-deactivated@example.com"]
(with-redefs [sag.settings/support-access-grant-email (constantly email)
sag.settings/support-access-grant-first-name (constantly "Support")
sag.settings/support-access-grant-last-name (constantly "User")]
;; Create and use a first grant so the support user exists
(let [first-grant (grants/create-grant! creator-id 60 "TICKET-FIRST" "First grant")]
(mt/client :post 200 "session/reset_password"
{:token (:token first-grant) :password "FirstPassword123!"})
(grants/revoke-grant! creator-id
(:id (t2/select-one :model/SupportAccessGrantLog
:ticket_number "TICKET-FIRST"))))
;; Deactivate the support user (simulates admin action or cleanup)
(let [support-user (t2/select-one [:model/User :id :is_active] :email email)]
(t2/update! :model/User (:id support-user) {:is_active false})
(is (false? (:is_active (t2/select-one [:model/User :is_active]
:id (:id support-user))))
"User should be deactivated"))
;; Create a new grant - this should reactivate the support user
(let [new-grant (grants/create-grant! creator-id 60 "TICKET-REACTIVATE"
"Should reactivate user")
token (:token new-grant)
support-user (t2/select-one [:model/User :id :is_active] :email email)]
(is (some? token) "Token should be created")
(is (true? (:is_active support-user)) "User should be reactivated by create-grant!")
(testing "Password reset should succeed"
(let [response (mt/client :post 200 "session/reset_password"
{:token token :password "NewPassword123!"})]
(is (true? (:success response)) "Reset should succeed")
(is (some? (:session_id response)) "Should return session ID"))))))))))
(deftest forgot-password-support-user-disabled-test
(testing "POST /api/session/forgot_password - Support Users cannot reset passwords"
(with-redefs [api.session/forgot-password-impl

View File

@@ -3,13 +3,10 @@ import {
getDatabaseFocusPermissionsUrl,
getGroupFocusPermissionsUrl,
} from "metabase/admin/permissions/utils/urls";
import type { GroupId, Impersonation } from "metabase-types/api";
import type { GroupId } from "metabase-types/api";
import type { ImpersonationModalParams } from "./types";
export const getImpersonationKey = (impersonation: Impersonation) =>
`${impersonation.db_id}:${impersonation.group_id}`;
export const getImpersonatedDatabaseId = ({
databaseId,
impersonatedDatabaseId,

View File

@@ -2,12 +2,11 @@ import { useMemo } from "react";
import { getShortStrategyLabel } from "metabase/admin/performance/utils";
import { MaybeLink } from "metabase/common/components/Badge/Badge.styled";
import { Ellipsified } from "metabase/common/components/Ellipsified";
import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath";
import { Link } from "metabase/common/components/Link";
import { getIcon } from "metabase/lib/icon";
import * as Urls from "metabase/lib/urls";
import { Box, Button, FixedSizeIcon, Flex } from "metabase/ui";
import { Box, Button, Ellipsified, FixedSizeIcon, Flex } from "metabase/ui";
import type { CacheableModel } from "metabase-types/api";
import type { CacheableItem, UpdateTarget } from "../types";

View File

@@ -2,7 +2,6 @@ import { c, t } from "ttag";
import { getCollectionName } from "metabase/collections/utils";
import { DateTime } from "metabase/common/components/DateTime";
import { Ellipsified } from "metabase/common/components/Ellipsified";
import { SortableColumnHeader } from "metabase/common/components/ItemsTable/BaseItemsTable";
import {
ColumnHeader,
@@ -13,7 +12,7 @@ import {
} from "metabase/common/components/ItemsTable/BaseItemsTable.styled";
import { Columns } from "metabase/common/components/ItemsTable/Columns";
import { getIcon } from "metabase/lib/icon";
import { FixedSizeIcon, Flex, Tooltip } from "metabase/ui";
import { Ellipsified, FixedSizeIcon, Flex, Tooltip } from "metabase/ui";
import type { SortingOptions } from "metabase-types/api/sorting";
import type {

View File

@@ -1,10 +1,10 @@
import { t } from "ttag";
import type {
BaseEntityId,
CollectionAuthorityLevelConfig,
CollectionInstanceAnaltyicsConfig,
} from "metabase-types/api";
} from "metabase/collections/types";
import type { BaseEntityId } from "metabase-types/api";
export const REGULAR_COLLECTION: CollectionAuthorityLevelConfig = {
type: null,

View File

@@ -1,3 +1,7 @@
import type {
CollectionAuthorityLevelConfig,
CollectionInstanceAnaltyicsConfig,
} from "metabase/collections/types";
import { getLibraryCollectionType } from "metabase/data-studio/utils";
import type { IconData, ObjectWithModel } from "metabase/lib/icon";
import { getIconBase } from "metabase/lib/icon";
@@ -5,9 +9,7 @@ import type { ItemWithCollection } from "metabase/plugins";
import type {
Bookmark,
Collection,
CollectionAuthorityLevelConfig,
CollectionId,
CollectionInstanceAnaltyicsConfig,
CollectionType,
} from "metabase-types/api";

View File

@@ -1,4 +1,3 @@
export { DataTab } from "./DataTab";
export { DataTabSidebar } from "./DataTabSidebar";
export { useTablePreview as useTableQuestion } from "./useTablePreview";
export type { OpenTable } from "../WorkspaceProvider";

View File

@@ -779,7 +779,3 @@ export const useWorkspace = (): WorkspaceContextValue => {
return context;
};
export const useOptionalWorkspace = (): WorkspaceContextValue | undefined => {
return useContext(WorkspaceContext);
};

View File

@@ -11,12 +11,6 @@ export const isWorkspaceUninitialized = (workspace: {
return workspace.status === "uninitialized";
};
export const isWorkspaceReady = (workspace: {
status: WorkspaceSetupStatus;
}) => {
return workspace.status === "ready";
};
export function getCheckoutDisabledMessage(
reason: CheckoutDisabledReason | undefined,
): string | undefined {

View File

@@ -1,7 +1,6 @@
.modalRoot {
display: flex;
flex-direction: column;
overflow-y: hidden;
}
.modalHeader {
@@ -10,4 +9,8 @@
.modalBody {
padding-inline: 0;
flex: 1;
display: flex;
flex-direction: column;
overflow-y: hidden;
}

View File

@@ -113,42 +113,44 @@ export const DestinationDatabaseConnectionModal = ({
body: S.modalBody,
}}
>
<LoadingAndErrorWrapper loading={isLoading} error={error}>
<Flex
py="sm"
px="md"
mx="xl"
my="md"
bg="background-secondary"
align="center"
justify="space-between"
bd="1px solid border"
style={{ borderRadius: ".5rem" }}
>
<Text>{t`You can also add databases programmatically via the API.`}</Text>
<ExternalLink
key="link"
href={docsUrl}
style={{ display: "flex", alignItems: "center", gap: 4 }}
<LoadingAndErrorWrapper loading={isLoading} error={error} noWrapper>
<>
<Flex
py="sm"
px="md"
mx="xl"
my="md"
bg="background-secondary"
align="center"
justify="space-between"
bd="1px solid border"
style={{ borderRadius: ".5rem" }}
>
{t`Learn more`} <Icon name="share" aria-hidden />
</ExternalLink>
</Flex>
<Text>{t`You can also add databases programmatically via the API.`}</Text>
<ExternalLink
key="link"
href={docsUrl}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
{t`Learn more`} <Icon name="share" aria-hidden />
</ExternalLink>
</Flex>
<DatabaseEditConnectionForm
database={destinationDatabase}
isAttachedDWH={destinationDatabase?.is_attached_dwh ?? false}
handleSaveDb={handleSaveDatabase}
onSubmitted={handleOnSubmit}
onCancel={handleCloseModal}
route={route}
config={{
name: { isSlug: true },
engine: { fieldState: "hidden" },
}}
autofocusFieldName="name"
formLocation="admin"
/>
<DatabaseEditConnectionForm
database={destinationDatabase}
isAttachedDWH={destinationDatabase?.is_attached_dwh ?? false}
handleSaveDb={handleSaveDatabase}
onSubmitted={handleOnSubmit}
onCancel={handleCloseModal}
route={route}
config={{
name: { isSlug: true },
engine: { fieldState: "hidden" },
}}
autofocusFieldName="name"
formLocation="admin"
/>
</>
</LoadingAndErrorWrapper>
</Modal>
);

View File

@@ -11,7 +11,6 @@ import type {
DependencyEntry,
DependencyNode,
SearchModel,
SearchResponse,
} from "metabase-types/api";
import { getDependencyType } from "../../../../utils";
@@ -129,11 +128,3 @@ export function getEntryPickerValue(
return { id: item.id, type: getDependencyType(groupType) };
}
}
export function hasAvailableModels(
response: SearchResponse | undefined,
models: SearchModel[],
) {
const availableModels = response?.available_models ?? [];
return models.some((model) => availableModels.includes(model));
}

View File

@@ -1,4 +1,4 @@
import { Ellipsified } from "metabase/common/components/Ellipsified";
import { Ellipsified } from "metabase/ui";
import type { DependencyNode } from "metabase-types/api";
import { getDependencyErrorInfo, getDependencyErrors } from "../../../utils";

View File

@@ -1,5 +1,4 @@
import { Ellipsified } from "metabase/common/components/Ellipsified";
import { FixedSizeIcon, Group } from "metabase/ui";
import { Ellipsified, FixedSizeIcon, Group } from "metabase/ui";
import type { DependencyNode } from "metabase-types/api";
import { getNodeLocationInfo } from "../../../utils";

View File

@@ -1,5 +1,4 @@
import { Ellipsified } from "metabase/common/components/Ellipsified";
import { FixedSizeIcon, Group } from "metabase/ui";
import { Ellipsified, FixedSizeIcon, Group } from "metabase/ui";
import type { DependencyNode } from "metabase-types/api";
import { getNodeIcon, getNodeLabel } from "../../../utils";

Some files were not shown because too many files have changed in this diff Show More